[
  {
    "path": ".agents/skills/orpc-contract-first/SKILL.md",
    "content": "---\nname: orpc-contract-first\ndescription: 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.\n---\n\n# oRPC Contract-First Development\n\n## Project Structure\n\n```\nweb/contract/\n├── base.ts           # Base contract (inputStructure: 'detailed')\n├── router.ts         # Router composition & type exports\n├── marketplace.ts    # Marketplace contracts\n└── console/          # Console contracts by domain\n    ├── system.ts\n    └── billing.ts\n```\n\n## Workflow\n\n1. **Create contract** in `web/contract/console/{domain}.ts`\n   - Import `base` from `../base` and `type` from `@orpc/contract`\n   - Define route with `path`, `method`, `input`, `output`\n\n2. **Register in router** at `web/contract/router.ts`\n   - Import directly from domain file (no barrel files)\n   - Nest by API prefix: `billing: { invoices, bindPartnerStack }`\n\n3. **Create hooks** in `web/service/use-{domain}.ts`\n   - Use `consoleQuery.{group}.{contract}.queryKey()` for query keys\n   - Use `consoleClient.{group}.{contract}()` for API calls\n\n## Key Rules\n\n- **Input structure**: Always use `{ params, query?, body? }` format\n- **Path params**: Use `{paramName}` in path, match in `params` object\n- **Router nesting**: Group by API prefix (e.g., `/billing/*` → `billing: {}`)\n- **No barrel files**: Import directly from specific files\n- **Types**: Import from `@/types/`, use `type<T>()` helper\n\n## Type Export\n\n```typescript\nexport type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>\n```\n"
  },
  {
    "path": ".agents/skills/release-skills/SKILL.md",
    "content": "---\nname: release-skills\ndescription: 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\", \"推送\".\n---\n\n# Release Skills\n\nUniversal release workflow supporting any project type with multi-language changelog.\n\n## Quick Start\n\nJust run `/release-skills` - auto-detects your project configuration.\n\n## Supported Projects\n\n| Project Type | Version File | Auto-Detected |\n|--------------|--------------|---------------|\n| Node.js | package.json | ✓ |\n| Python | pyproject.toml | ✓ |\n| Rust | Cargo.toml | ✓ |\n| Claude Plugin | marketplace.json | ✓ |\n| Generic | VERSION / version.txt | ✓ |\n\n## Options\n\n| Flag | Description |\n|------|-------------|\n| `--dry-run` | Preview changes without executing |\n| `--major` | Force major version bump |\n| `--minor` | Force minor version bump |\n| `--patch` | Force patch version bump |\n\n## Workflow\n\n### Step 1: Detect Project Configuration\n\n1. Check for `.releaserc.yml` (optional config override)\n2. Auto-detect version file by scanning (priority order):\n   - `package.json` (Node.js)\n   - `pyproject.toml` (Python)\n   - `Cargo.toml` (Rust)\n   - `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin)\n   - `VERSION` or `version.txt` (Generic)\n3. Scan for changelog files using glob patterns:\n   - `CHANGELOG*.md`\n   - `HISTORY*.md`\n   - `CHANGES*.md`\n4. Identify language of each changelog by filename suffix\n5. Display detected configuration\n\n**Language Detection Rules**:\n\n| Filename Pattern | Language |\n|------------------|----------|\n| `CHANGELOG.md` (no suffix) | en (default) |\n| `CHANGELOG.zh.md` / `CHANGELOG_CN.md` / `CHANGELOG.zh-CN.md` | zh |\n| `CHANGELOG.ja.md` / `CHANGELOG_JP.md` | ja |\n| `CHANGELOG.ko.md` / `CHANGELOG_KR.md` | ko |\n| `CHANGELOG.de.md` / `CHANGELOG_DE.md` | de |\n| `CHANGELOG.fr.md` / `CHANGELOG_FR.md` | fr |\n| `CHANGELOG.es.md` / `CHANGELOG_ES.md` | es |\n| `CHANGELOG.{lang}.md` | Corresponding language code |\n\n**Output Example**:\n```\nProject detected:\n  Version file: package.json (1.2.3)\n  Changelogs:\n    - CHANGELOG.md (en)\n    - CHANGELOG.zh.md (zh)\n    - CHANGELOG.ja.md (ja)\n```\n\n### Step 2: Analyze Changes Since Last Tag\n\n```bash\nLAST_TAG=$(git tag --sort=-v:refname | head -1)\ngit log ${LAST_TAG}..HEAD --oneline\ngit diff ${LAST_TAG}..HEAD --stat\n```\n\nCategorize by conventional commit types:\n\n| Type | Description |\n|------|-------------|\n| feat | New features |\n| fix | Bug fixes |\n| docs | Documentation |\n| refactor | Code refactoring |\n| perf | Performance improvements |\n| test | Test changes |\n| style | Formatting, styling |\n| chore | Maintenance (skip in changelog) |\n\n**Breaking Change Detection**:\n- Commit message starts with `BREAKING CHANGE`\n- Commit body/footer contains `BREAKING CHANGE:`\n- Removed public APIs, renamed exports, changed interfaces\n\nIf breaking changes detected, warn user: \"Breaking changes detected. Consider major version bump (--major flag).\"\n\n### Step 3: Determine Version Bump\n\nRules (in priority order):\n1. User flag `--major/--minor/--patch` → Use specified\n2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0)\n3. `feat:` commits present → Minor bump (1.2.x → 1.3.0)\n4. Otherwise → Patch bump (1.2.3 → 1.2.4)\n\nDisplay version change: `1.2.3 → 1.3.0`\n\n### Step 4: Generate Multi-language Changelogs\n\nFor each detected changelog file:\n\n1. **Identify language** from filename suffix\n2. **Detect third-party contributors**:\n   - Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:\"%H %s\"`\n   - For each merged PR, identify the PR author via `gh pr view <number> --json author --jq '.author.login'`\n   - Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`)\n   - If PR author ≠ repo owner → third-party contributor\n3. **Generate content in that language**:\n   - Section titles in target language\n   - Change descriptions written naturally in target language (not translated)\n   - Date format: YYYY-MM-DD (universal)\n   - **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry\n4. **Insert at file head** (preserve existing content)\n\n**Section Title Translations** (built-in):\n\n| Type | en | zh | ja | ko | de | fr | es |\n|------|----|----|----|----|----|----|-----|\n| feat | Features | 新功能 | 新機能 | 새로운 기능 | Funktionen | Fonctionnalités | Características |\n| fix | Fixes | 修复 | 修正 | 수정 | Fehlerbehebungen | Corrections | Correcciones |\n| docs | Documentation | 文档 | ドキュメント | 문서 | Dokumentation | Documentation | Documentación |\n| refactor | Refactor | 重构 | リファクタリング | 리팩토링 | Refactoring | Refactorisation | Refactorización |\n| perf | Performance | 性能优化 | パフォーマンス | 성능 | Leistung | Performance | Rendimiento |\n| breaking | Breaking Changes | 破坏性变更 | 破壊的変更 | 주요 변경사항 | Breaking Changes | Changements majeurs | Cambios importantes |\n\n**Changelog Format**:\n\n```markdown\n## {VERSION} - {YYYY-MM-DD}\n\n### Features\n- Description of new feature\n- Description of third-party contribution (by @username)\n\n### Fixes\n- Description of fix\n\n### Documentation\n- Description of docs changes\n```\n\nOnly include sections that have changes. Omit empty sections.\n\n**Third-Party Attribution Rules**:\n- Only add `(by @username)` for contributors who are NOT the repo owner\n- Use GitHub username with `@` prefix\n- Place at the end of the changelog entry line\n- Apply to all languages consistently (always use `(by @username)` format, not translated)\n\n**Multi-language Example**:\n\nEnglish (CHANGELOG.md):\n```markdown\n## 1.3.0 - 2026-01-22\n\n### Features\n- Add user authentication module (by @contributor1)\n- Support OAuth2 login\n\n### Fixes\n- Fix memory leak in connection pool\n```\n\nChinese (CHANGELOG.zh.md):\n```markdown\n## 1.3.0 - 2026-01-22\n\n### 新功能\n- 新增用户认证模块 (by @contributor1)\n- 支持 OAuth2 登录\n\n### 修复\n- 修复连接池内存泄漏问题\n```\n\nJapanese (CHANGELOG.ja.md):\n```markdown\n## 1.3.0 - 2026-01-22\n\n### 新機能\n- ユーザー認証モジュールを追加 (by @contributor1)\n- OAuth2 ログインをサポート\n\n### 修正\n- コネクションプールのメモリリークを修正\n```\n\n### Step 5: Group Changes by Skill/Module\n\nAnalyze commits since last tag and group by affected skill/module:\n\n1. **Identify changed files** per commit\n2. **Group by skill/module**:\n   - `skills/<skill-name>/*` → Group under that skill\n   - Root files (CLAUDE.md, etc.) → Group as \"project\"\n   - Multiple skills in one commit → Split into multiple groups\n3. **For each group**, identify related README updates needed\n\n**Example Grouping**:\n```\nbaoyu-cover-image:\n  - feat: add new style options\n  - fix: handle transparent backgrounds\n  → README updates: options table\n\nbaoyu-comic:\n  - refactor: improve panel layout algorithm\n  → No README updates needed\n\nproject:\n  - docs: update CLAUDE.md architecture section\n```\n\n### Step 6: Commit Each Skill/Module Separately\n\nFor each skill/module group (in order of changes):\n\n1. **Check README updates needed**:\n   - Scan `README*.md` for mentions of this skill/module\n   - Verify options/flags documented correctly\n   - Update usage examples if syntax changed\n   - Update feature descriptions if behavior changed\n\n2. **Stage and commit**:\n   ```bash\n   git add skills/<skill-name>/*\n   git add README.md README.zh.md  # If updated for this skill\n   git commit -m \"<type>(<skill-name>): <meaningful description>\"\n   ```\n\n3. **Commit message format**:\n   - Use conventional commit format: `<type>(<scope>): <description>`\n   - `<type>`: feat, fix, refactor, docs, perf, etc.\n   - `<scope>`: skill name or \"project\"\n   - `<description>`: Clear, meaningful description of changes\n\n**Example Commits**:\n```bash\ngit commit -m \"feat(baoyu-cover-image): add watercolor and minimalist styles\"\ngit commit -m \"fix(baoyu-comic): improve panel layout for long dialogues\"\ngit commit -m \"docs(project): update architecture documentation\"\n```\n\n**Common README Updates Needed**:\n| Change Type | README Section to Check |\n|-------------|------------------------|\n| New options/flags | Options table, usage examples |\n| Renamed options | Options table, usage examples |\n| New features | Feature description, examples |\n| Breaking changes | Migration notes, deprecation warnings |\n| Restructured internals | Architecture section (if exposed to users) |\n\n### Step 7: Generate Changelog and Update Version\n\n1. **Generate multi-language changelogs** (as described in Step 4)\n2. **Update version file**:\n   - Read version file (JSON/TOML/text)\n   - Update version number\n   - Write back (preserve formatting)\n\n**Version Paths by File Type**:\n\n| File | Path |\n|------|------|\n| package.json | `$.version` |\n| pyproject.toml | `project.version` |\n| Cargo.toml | `package.version` |\n| marketplace.json | `$.metadata.version` |\n| VERSION / version.txt | Direct content |\n\n### Step 8: User Confirmation\n\nBefore creating the release commit, ask user to confirm:\n\n**Use AskUserQuestion with two questions**:\n\n1. **Version bump** (single select):\n   - Show recommended version based on Step 3 analysis\n   - Options: recommended (with label), other semver options\n   - Example: `1.2.3 → 1.3.0 (Recommended)`, `1.2.3 → 1.2.4`, `1.2.3 → 2.0.0`\n\n2. **Push to remote** (single select):\n   - Options: \"Yes, push after commit\", \"No, keep local only\"\n\n**Example Output Before Confirmation**:\n```\nCommits created:\n  1. feat(baoyu-cover-image): add watercolor and minimalist styles\n  2. fix(baoyu-comic): improve panel layout for long dialogues\n  3. docs(project): update architecture documentation\n\nChangelog preview (en):\n  ## 1.3.0 - 2026-01-22\n  ### Features\n  - Add watercolor and minimalist styles to cover-image\n  ### Fixes\n  - Improve panel layout for long dialogues in comic\n\nReady to create release commit and tag.\n```\n\n### Step 9: Create Release Commit and Tag\n\nAfter user confirmation:\n\n1. **Stage version and changelog files**:\n   ```bash\n   git add <version-file>\n   git add CHANGELOG*.md\n   ```\n\n2. **Create release commit**:\n   ```bash\n   git commit -m \"chore: release v{VERSION}\"\n   ```\n\n3. **Create tag**:\n   ```bash\n   git tag v{VERSION}\n   ```\n\n4. **Push if user confirmed** (Step 8):\n   ```bash\n   git push origin main\n   git push origin v{VERSION}\n   ```\n\n**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.\n\n**Post-Release Output**:\n```\nRelease v1.3.0 created.\n\nCommits:\n  1. feat(baoyu-cover-image): add watercolor and minimalist styles\n  2. fix(baoyu-comic): improve panel layout for long dialogues\n  3. docs(project): update architecture documentation\n  4. chore: release v1.3.0\n\nTag: v1.3.0\nStatus: Pushed to origin  # or \"Local only - run git push when ready\"\n```\n\n## Configuration (.releaserc.yml)\n\nOptional config file in project root to override defaults:\n\n```yaml\n# .releaserc.yml - Optional configuration\n\n# Version file (auto-detected if not specified)\nversion:\n  file: package.json\n  path: $.version  # JSONPath for JSON, dotted path for TOML\n\n# Changelog files (auto-detected if not specified)\nchangelog:\n  files:\n    - path: CHANGELOG.md\n      lang: en\n    - path: CHANGELOG.zh.md\n      lang: zh\n    - path: CHANGELOG.ja.md\n      lang: ja\n\n  # Section mapping (conventional commit type → changelog section)\n  # Use null to skip a type in changelog\n  sections:\n    feat: Features\n    fix: Fixes\n    docs: Documentation\n    refactor: Refactor\n    perf: Performance\n    test: Tests\n    chore: null\n\n# Commit message format\ncommit:\n  message: \"chore: release v{version}\"\n\n# Tag format\ntag:\n  prefix: v  # Results in v1.0.0\n  sign: false\n\n# Additional files to include in release commit\ninclude:\n  - README.md\n  - package.json\n```\n\n## Dry-Run Mode\n\nWhen `--dry-run` is specified:\n\n```\n=== DRY RUN MODE ===\n\nProject detected:\n  Version file: package.json (1.2.3)\n  Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh)\n\nLast tag: v1.2.3\nProposed version: v1.3.0\n\nChanges grouped by skill/module:\n  baoyu-cover-image:\n    - feat: add watercolor style\n    - feat: add minimalist style\n    → Commit: feat(baoyu-cover-image): add watercolor and minimalist styles\n    → README updates: options table\n\n  baoyu-comic:\n    - fix: panel layout for long dialogues\n    → Commit: fix(baoyu-comic): improve panel layout for long dialogues\n    → No README updates\n\nChangelog preview (en):\n  ## 1.3.0 - 2026-01-22\n  ### Features\n  - Add watercolor and minimalist styles to cover-image\n  ### Fixes\n  - Improve panel layout for long dialogues in comic\n\nChangelog preview (zh):\n  ## 1.3.0 - 2026-01-22\n  ### 新功能\n  - 为 cover-image 添加水彩和极简风格\n  ### 修复\n  - 改进 comic 长对话的面板布局\n\nCommits to create:\n  1. feat(baoyu-cover-image): add watercolor and minimalist styles\n  2. fix(baoyu-comic): improve panel layout for long dialogues\n  3. chore: release v1.3.0\n\nNo changes made. Run without --dry-run to execute.\n```\n\n## Example Usage\n\n```\n/release-skills              # Auto-detect version bump\n/release-skills --dry-run    # Preview only\n/release-skills --minor      # Force minor bump\n/release-skills --patch      # Force patch bump\n/release-skills --major      # Force major bump (with confirmation)\n```\n\n## When to Use\n\nTrigger this skill when user requests:\n- \"release\", \"发布\", \"create release\", \"new version\", \"新版本\"\n- \"bump version\", \"update version\", \"更新版本\"\n- \"prepare release\"\n- \"push to remote\" (with uncommitted changes)\n\n**Important**: If user says \"just push\" or \"直接 push\" with uncommitted changes, STILL follow all steps above first.\n"
  },
  {
    "path": ".claude/CLAUDE.md",
    "content": "# Ultracite Code Standards\n\nThis project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.\n\n## Quick Reference\n\n- **Format code**: `pnpm dlx ultracite fix`\n- **Check for issues**: `pnpm dlx ultracite check`\n- **Diagnose setup**: `pnpm dlx ultracite doctor`\n\nBiome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.\n\n---\n\n## Core Principles\n\nWrite code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.\n\n### Type Safety & Explicitness\n\n- Use explicit types for function parameters and return values when they enhance clarity\n- Prefer `unknown` over `any` when the type is genuinely unknown\n- Use const assertions (`as const`) for immutable values and literal types\n- Leverage TypeScript's type narrowing instead of type assertions\n- Use meaningful variable names instead of magic numbers - extract constants with descriptive names\n\n### Modern JavaScript/TypeScript\n\n- Use arrow functions for callbacks and short functions\n- Prefer `for...of` loops over `.forEach()` and indexed `for` loops\n- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access\n- Prefer template literals over string concatenation\n- Use destructuring for object and array assignments\n- Use `const` by default, `let` only when reassignment is needed, never `var`\n\n### Async & Promises\n\n- Always `await` promises in async functions - don't forget to use the return value\n- Use `async/await` syntax instead of promise chains for better readability\n- Handle errors appropriately in async code with try-catch blocks\n- Don't use async functions as Promise executors\n\n### React & JSX\n\n- Use function components over class components\n- Call hooks at the top level only, never conditionally\n- Specify all dependencies in hook dependency arrays correctly\n- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)\n- Nest children between opening and closing tags instead of passing as props\n- Don't define components inside other components\n- Use semantic HTML and ARIA attributes for accessibility:\n  - Provide meaningful alt text for images\n  - Use proper heading hierarchy\n  - Add labels for form inputs\n  - Include keyboard event handlers alongside mouse events\n  - Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles\n\n### Error Handling & Debugging\n\n- Remove `console.log`, `debugger`, and `alert` statements from production code\n- Throw `Error` objects with descriptive messages, not strings or other values\n- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them\n- Prefer early returns over nested conditionals for error cases\n\n### Code Organization\n\n- Keep functions focused and under reasonable cognitive complexity limits\n- Extract complex conditions into well-named boolean variables\n- Use early returns to reduce nesting\n- Prefer simple conditionals over nested ternary operators\n- Group related code together and separate concerns\n\n### Security\n\n- Add `rel=\"noopener\"` when using `target=\"_blank\"` on links\n- Avoid `dangerouslySetInnerHTML` unless absolutely necessary\n- Don't use `eval()` or assign directly to `document.cookie`\n- Validate and sanitize user input\n\n### Performance\n\n- Avoid spread syntax in accumulators within loops\n- Use top-level regex literals instead of creating them in loops\n- Prefer specific imports over namespace imports\n- Avoid barrel files (index files that re-export everything)\n- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags\n\n### Framework-Specific Guidance\n\n**Next.js:**\n- Use Next.js `<Image>` component for images\n- Use `next/head` or App Router metadata API for head elements\n- Use Server Components for async data fetching instead of async Client Components\n\n**React 19+:**\n- Use ref as a prop instead of `React.forwardRef`\n\n**Solid/Svelte/Vue/Qwik:**\n- Use `class` and `for` attributes (not `className` or `htmlFor`)\n\n---\n\n## Testing\n\n- Write assertions inside `it()` or `test()` blocks\n- Avoid done callbacks in async tests - use async/await instead\n- Don't use `.only` or `.skip` in committed code\n- Keep test suites reasonably flat - avoid excessive `describe` nesting\n\n## When Biome Can't Help\n\nBiome's linter will catch most issues automatically. Focus your attention on:\n\n1. **Business logic correctness** - Biome can't validate your algorithms\n2. **Meaningful naming** - Use descriptive names for functions, variables, and types\n3. **Architecture decisions** - Component structure, data flow, and API design\n4. **Edge cases** - Handle boundary conditions and error states\n5. **User experience** - Accessibility, performance, and usability considerations\n6. **Documentation** - Add comments for complex logic, but prefer self-documenting code\n\n---\n\nMost formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.\n"
  },
  {
    "path": ".cursor/hooks.json",
    "content": "{\n  \"version\": 1,\n  \"hooks\": {\n    \"afterFileEdit\": [\n      {\n        \"command\": \"pnpm dlx ultracite fix\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "node_modules\n.git\n.context\ndist\nout\nbuild\n**/node_modules\n**/dist\n**/out\n**/.turbo\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.{cmd,[cC][mM][dD]} text eol=crlf\n*.{bat,[bB][aA][tT]} text eol=crlf\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a problem or regression\ntitle: \"[Bug]: \"\nlabels:\n  - bug\nbody:\n  - type: textarea\n    id: actual\n    attributes:\n      label: Observed behavior and screenshots\n      placeholder: What actually happened\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs or screenshots\n      description: Paste relevant logs or add screenshots\n      placeholder: Attach files or paste logs here\n    validations:\n      required: false\n  - type: input\n    id: app_version\n    attributes:\n      label: App version\n      description: The VidBee version (e.g., 1.2.3)\n      placeholder: 1.2.3\n    validations:\n      required: true\n  - type: input\n    id: os_version\n    attributes:\n      label: OS version\n      description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)\n      placeholder: macOS 14.2\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest an idea or improvement\ntitle: \"[Feature]: \"\nlabels:\n  - enhancement\nbody:\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem to solve\n      description: What problem are you trying to solve?\n      placeholder: I want to...\n    validations:\n      required: true\n  - type: textarea\n    id: proposal\n    attributes:\n      label: Proposed solution\n      description: Describe the feature or change you want\n      placeholder: It would be great if...\n    validations:\n      required: true\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives considered\n      description: Other solutions or workarounds you considered\n      placeholder: I tried...\n    validations:\n      required: false\n  - type: textarea\n    id: extra\n    attributes:\n      label: Additional context\n      description: Add any other context or screenshots\n      placeholder: Links, screenshots, or related issues\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\n\non:\n  workflow_call:\n    inputs:\n      upload_artifacts:\n        required: false\n        type: boolean\n        default: false\n        description: 'Whether to upload build artifacts'\n    secrets:\n      MAC_CERT_P12_BASE64:\n        required: false\n      MAC_CERT_P12_PASSWORD:\n        required: false\n      APPLE_API_KEY_ID:\n        required: false\n      APPLE_API_ISSUER:\n        required: false\n      APPLE_API_KEY_P8_BASE64:\n        required: false\n\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        include:\n          - platform: windows\n            os: windows-latest\n            build_script: pnpm run build:win\n            mac_ffmpeg_mode: native\n          - platform: macos\n            os: macos-latest\n            build_script: pnpm run build:mac\n            mac_ffmpeg_mode: universal\n          - platform: linux\n            os: ubuntu-latest\n            build_script: pnpm run build:linux\n            mac_ffmpeg_mode: native\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 8\n\n      - name: Install Dependencies\n        run: pnpm install --filter ./apps/desktop...\n\n      - name: Lint and format check\n        run: pnpm run check && pnpm run typecheck\n\n      - name: Setup macOS signing\n        if: matrix.platform == 'macos'\n        shell: bash\n        env:\n          MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}\n          MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}\n          APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}\n          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}\n          APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}\n        run: |\n          set -euo pipefail\n          echo \"SIGNING_AVAILABLE=false\" >> \"$GITHUB_ENV\"\n\n          # Check if all required secrets are present\n          if [[ -z \"$MAC_CERT_P12_BASE64\" ]] || [[ -z \"$MAC_CERT_P12_PASSWORD\" ]] || \\\n             [[ -z \"$APPLE_API_KEY_ID\" ]] || [[ -z \"$APPLE_API_ISSUER\" ]] || \\\n             [[ -z \"$APPLE_API_KEY_P8_BASE64\" ]]; then\n            echo \"::notice::macOS signing secrets not available, skipping code signing setup\"\n            exit 0\n          fi\n\n          CERT_PATH=\"$RUNNER_TEMP/mac_cert.p12\"\n          KEYCHAIN_PATH=\"$RUNNER_TEMP/build.keychain\"\n          API_KEY_PATH=\"$RUNNER_TEMP/AuthKey.p8\"\n\n          echo \"$MAC_CERT_P12_BASE64\" | base64 --decode > \"$CERT_PATH\"\n          echo \"$APPLE_API_KEY_P8_BASE64\" | base64 --decode > \"$API_KEY_PATH\"\n\n          security create-keychain -p \"$MAC_CERT_P12_PASSWORD\" \"$KEYCHAIN_PATH\"\n          security set-keychain-settings -lut 21600 \"$KEYCHAIN_PATH\"\n          security unlock-keychain -p \"$MAC_CERT_P12_PASSWORD\" \"$KEYCHAIN_PATH\"\n          security import \"$CERT_PATH\" -k \"$KEYCHAIN_PATH\" -P \"$MAC_CERT_P12_PASSWORD\" -T /usr/bin/codesign -T /usr/bin/productbuild\n          security list-keychain -d user -s \"$KEYCHAIN_PATH\"\n          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \"$MAC_CERT_P12_PASSWORD\" \"$KEYCHAIN_PATH\"\n\n          echo \"CSC_KEYCHAIN=$KEYCHAIN_PATH\" >> \"$GITHUB_ENV\"\n          echo \"CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD\" >> \"$GITHUB_ENV\"\n          echo \"APPLE_API_KEY=$API_KEY_PATH\" >> \"$GITHUB_ENV\"\n          echo \"APPLE_API_KEY_ID=$APPLE_API_KEY_ID\" >> \"$GITHUB_ENV\"\n          echo \"APPLE_API_ISSUER=$APPLE_API_ISSUER\" >> \"$GITHUB_ENV\"\n          echo \"SIGNING_AVAILABLE=true\" >> \"$GITHUB_ENV\"\n\n      - name: Build application\n        env:\n          VIDBEE_MAC_FFMPEG_MODE: ${{ matrix.mac_ffmpeg_mode }}\n        run: ${{ matrix.build_script }}\n\n      - name: Verify macOS codesign and notarization\n        if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'\n        shell: bash\n        run: |\n          set -euo pipefail\n          apps_found=0\n          while IFS= read -r app; do\n            apps_found=1\n            echo \"Verifying codesign for $app\"\n            codesign --verify --deep --strict --verbose=2 \"$app\"\n            spctl -a -t exec -vv \"$app\"\n            echo \"Validating notarization ticket for $app\"\n            xcrun stapler validate \"$app\"\n          done < <(find apps/desktop/dist -type d -name \"*.app\" -prune -print)\n\n          if [[ \"$apps_found\" -eq 0 ]]; then\n            echo \"::error::No .app bundles found in dist\"\n            exit 1\n          fi\n\n          dmgs_found=0\n          while IFS= read -r dmg; do\n            dmgs_found=1\n            echo \"Submitting DMG for notarization: $dmg\"\n            xcrun notarytool submit \"$dmg\" --key \"$APPLE_API_KEY\" --key-id \"$APPLE_API_KEY_ID\" --issuer \"$APPLE_API_ISSUER\" --wait\n            echo \"Stapling notarization ticket for $dmg\"\n            xcrun stapler staple \"$dmg\"\n            echo \"Validating notarization ticket for $dmg\"\n            xcrun stapler validate \"$dmg\"\n          done < <(find apps/desktop/dist -type f -name \"*.dmg\" -print)\n\n          if [[ \"$dmgs_found\" -eq 0 ]]; then\n            echo \"::notice::No DMG artifacts found to validate\"\n          fi\n\n      - name: Upload build artifacts\n        if: inputs.upload_artifacts == true\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist-${{ matrix.os }}\n          path: |\n            apps/desktop/dist/*.exe\n            apps/desktop/dist/*.zip\n            apps/desktop/dist/*.dmg\n            apps/desktop/dist/*.AppImage\n            apps/desktop/dist/*.snap\n            apps/desktop/dist/*.deb\n            apps/desktop/dist/*.rpm\n            apps/desktop/dist/*.tar.gz\n            apps/desktop/dist/*.yml\n            apps/desktop/dist/*.blockmap\n          retention-days: 1\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n    branches: [ main ]\n\njobs:\n  build:\n    uses: ./.github/workflows/build.yml\n    with:\n      upload_artifacts: true\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker Publish\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - 'apps/api/**'\n      - 'apps/desktop/resources/drizzle/**'\n      - 'apps/web/**'\n      - 'packages/**'\n      - 'pnpm-lock.yaml'\n      - 'pnpm-workspace.yaml'\n      - 'package.json'\n      - '.github/workflows/docker-publish.yml'\n  workflow_dispatch:\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - app: api\n            dockerfile: apps/api/Dockerfile\n            image_suffix: api\n          - app: web\n            dockerfile: apps/web/Dockerfile\n            image_suffix: web\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract image metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/vidbee-${{ matrix.image_suffix }}\n          tags: |\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=sha,format=short\n\n      - name: Build and push image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ${{ matrix.dockerfile }}\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            VITE_API_URL=${{ vars.VITE_API_URL || 'http://localhost:3100' }}\n"
  },
  {
    "path": ".github/workflows/extension-build.yml",
    "content": "name: Build Extension\n\non:\n  workflow_call:\n    inputs:\n      upload_artifacts:\n        required: false\n        type: boolean\n        default: false\n        description: 'Whether to upload build artifacts'\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 8\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'pnpm'\n          cache-dependency-path: pnpm-lock.yaml\n\n      - name: Install dependencies\n        run: pnpm install --filter ./apps/extension...\n\n      - name: Build extension\n        run: pnpm --filter ./apps/extension build\n\n      - name: Build extension zip\n        run: pnpm --filter ./apps/extension zip\n\n      - name: Upload extension artifacts\n        if: inputs.upload_artifacts == true\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist-extension\n          path: apps/extension/.output/*.zip\n          retention-days: 1\n          if-no-files-found: error\n"
  },
  {
    "path": ".github/workflows/extension-publish.yml",
    "content": "name: Publish Extension\n\non:\n  push:\n    branches: [ main ]\n    paths:\n      - apps/extension/package.json\n\njobs:\n  detect-version:\n    if: vars.ENABLE_EXTENSION_CI == 'true'\n    runs-on: ubuntu-latest\n    outputs:\n      changed: ${{ steps.version_check.outputs.changed }}\n      current_version: ${{ steps.version_check.outputs.current_version }}\n      previous_version: ${{ steps.version_check.outputs.previous_version }}\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Check if extension version changed\n        id: version_check\n        shell: bash\n        run: |\n          set -euo pipefail\n          before_sha=\"${{ github.event.before }}\"\n          current_version=$(node -e \"const fs = require('fs'); const v = JSON.parse(fs.readFileSync('apps/extension/package.json', 'utf8')).version; process.stdout.write(v);\")\n          previous_version=\"\"\n          if git cat-file -e \"${before_sha}:apps/extension/package.json\" 2>/dev/null; then\n            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); });\")\n          fi\n\n          echo \"current_version=${current_version}\" >> \"$GITHUB_OUTPUT\"\n          echo \"previous_version=${previous_version}\" >> \"$GITHUB_OUTPUT\"\n\n          if [[ -n \"${previous_version}\" && \"${current_version}\" == \"${previous_version}\" ]]; then\n            echo \"changed=false\" >> \"$GITHUB_OUTPUT\"\n            exit 0\n          fi\n\n          echo \"changed=true\" >> \"$GITHUB_OUTPUT\"\n\n  submit:\n    if: vars.ENABLE_EXTENSION_CI == 'true' && needs.detect-version.outputs.changed == 'true'\n    needs: detect-version\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 8\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'pnpm'\n          cache-dependency-path: pnpm-lock.yaml\n\n      - name: Install dependencies\n        run: pnpm install --filter ./apps/extension...\n\n      - name: Build extension\n        run: pnpm --filter ./apps/extension build\n\n      - name: Zip extensions\n        run: pnpm --filter ./apps/extension zip\n\n      - name: Submit to stores\n        run: |\n          pnpm --dir apps/extension wxt submit \\\n            --chrome-zip .output/*-chrome.zip\n        env:\n          CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}\n          CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}\n          CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}\n          CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}\n          CHROME_PUBLISH_TARGET: \"default\"\n          CHROME_SKIP_SUBMIT_REVIEW: false\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Build and Release Electron App\n\non:\n  push:\n    tags:\n      - v*.*.*\n  workflow_dispatch:\n\njobs:\n  build:\n    uses: ./.github/workflows/build.yml\n    with:\n      upload_artifacts: true\n    secrets: inherit\n\n  release:\n    needs: [build]\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n\n      - name: Detect release type\n        id: release_meta\n        shell: bash\n        run: |\n          if [[ \"${GITHUB_REF_NAME}\" == *-preview.* ]]; then\n            echo \"is_preview=true\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"is_preview=false\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Download artifacts\n        uses: actions/download-artifact@v4\n        with:\n          pattern: dist-*\n          merge-multiple: true\n          path: dist/\n\n      - name: Release\n        uses: softprops/action-gh-release@v1\n        with:\n          generate_release_notes: true\n          prerelease: ${{ steps.release_meta.outputs.is_preview == 'true' }}\n          files: |\n            dist/*.exe\n            dist/*.zip\n            dist/*.dmg\n            dist/*.AppImage\n            dist/*.snap\n            dist/*.deb\n            dist/*.rpm\n            dist/*.tar.gz\n            dist/*.yml\n            dist/*.blockmap\n        env:\n          GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}\n\n      - name: Notify Cloudflare Pages\n        if: steps.release_meta.outputs.is_preview != 'true'\n        env:\n          CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}\n        run: |\n          curl -X POST \"$CLOUDFLARE_WEBHOOK_URL\"\n"
  },
  {
    "path": ".github/workflows/translator.yaml",
    "content": "name: 'translator'\non:\n  issues:\n    types: [opened, edited]\n  issue_comment:\n    types: [created, edited]\n  discussion:\n    types: [created, edited]\n  discussion_comment:\n    types: [created, edited]\n\njobs:\n  translate:\n    if: ${{ !github.event.issue.pull_request }}\n    permissions:\n      issues: write\n      discussions: write\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          IS_MODIFY_TITLE: true\n"
  },
  {
    "path": ".github/workflows/ytdlp-auto-release.yml",
    "content": "name: Auto Release yt-dlp Patch\n\non:\n  schedule:\n    - cron: '17 */6 * * *'\n  workflow_dispatch:\n\npermissions:\n  contents: write\n\nconcurrency:\n  group: ytdlp-auto-release\n  cancel-in-progress: false\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    env:\n      DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}\n      GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}\n      GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}\n      YTDLP_RELEASE_TOKEN: ${{ secrets.ACCESS_TOKEN }}\n    steps:\n      - name: Check release token\n        run: |\n          if [ -z \"${GH_TOKEN}\" ]; then\n            echo \"ACCESS_TOKEN secret is required to push the release commit and tag.\"\n            exit 1\n          fi\n\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n        with:\n          token: ${{ secrets.ACCESS_TOKEN }}\n          ref: ${{ github.event.repository.default_branch }}\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 8\n\n      - name: Install Dependencies\n        run: pnpm install --filter ./apps/desktop...\n\n      - name: Check for yt-dlp updates\n        id: check\n        run: node apps/desktop/scripts/ytdlp-auto-release.mjs check\n\n      - name: Prepare patch release\n        if: steps.check.outputs.update_available == 'true'\n        id: prepare\n        run: node apps/desktop/scripts/ytdlp-auto-release.mjs prepare\n\n      - name: Validate release changes\n        if: steps.check.outputs.update_available == 'true'\n        run: pnpm run check\n\n      - name: Commit release changes\n        if: steps.check.outputs.update_available == 'true'\n        env:\n          RELEASE_VERSION: ${{ steps.prepare.outputs.release_version }}\n          LATEST_YTDLP_VERSION: ${{ steps.prepare.outputs.latest_ytdlp_version }}\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n          git add apps/desktop/package.json apps/desktop/changelogs/CHANGELOG.md apps/desktop/release-metadata.json\n          git commit -m \"chore(release): publish v${RELEASE_VERSION} with yt-dlp ${LATEST_YTDLP_VERSION}\"\n          git tag \"v${RELEASE_VERSION}\"\n\n      - name: Push release commit and tag\n        if: steps.check.outputs.update_available == 'true'\n        env:\n          RELEASE_VERSION: ${{ steps.prepare.outputs.release_version }}\n        run: |\n          git push origin HEAD:${DEFAULT_BRANCH}\n          git push origin \"v${RELEASE_VERSION}\"\n\n      - name: Report up-to-date status\n        if: steps.check.outputs.update_available != 'true'\n        run: echo \"yt-dlp is already up to date. No patch release is needed.\"\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\n/dist\napps/desktop/dist\napps/web/dist\napps/api/.data/\nout\n.conductor/\n.wxt\n.output\n.DS_Store\n.eslintcache\n*.log*\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n# Exit on any error\nset -e\n\n# Check if there are any staged files\nif [ -z \"$(git diff --cached --name-only)\" ]; then\n  echo \"No staged files to format\"\n  exit 0\nfi\n\n# Store the hash of staged changes to detect modifications\nSTAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)\n\n# Save list of staged files (handling all file states)\nSTAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)\nPARTIALLY_STAGED=$(git diff --name-only)\n\n# Stash unstaged changes to preserve working directory\n# --keep-index keeps staged changes in working tree\ngit stash push --quiet --keep-index --message \"pre-commit-stash\" || true\nSTASHED=$?\n\n# Run formatter on the staged files\npnpm --filter ./apps/desktop run fix\nFORMAT_EXIT_CODE=$?\n\n# Restore working directory state\nif [ $STASHED -eq 0 ]; then\n  # Re-stage the formatted files\n  if [ -n \"$STAGED_FILES\" ]; then\n    echo \"$STAGED_FILES\" | while IFS= read -r file; do\n      if [ -f \"$file\" ]; then\n        git add \"$file\"\n      fi\n    done\n  fi\n  \n  # Restore unstaged changes\n  git stash pop --quiet || true\n  \n  # Restore partial staging if files were partially staged\n  if [ -n \"$PARTIALLY_STAGED\" ]; then\n    for file in $PARTIALLY_STAGED; do\n      if [ -f \"$file\" ] && echo \"$STAGED_FILES\" | grep -q \"^$file$\"; then\n        # File was partially staged - need to unstage the unstaged parts\n        git restore --staged \"$file\" 2>/dev/null || true\n        git add -p \"$file\" < /dev/null 2>/dev/null || git add \"$file\"\n      fi\n    done\n  fi\nelse\n  # No stash was created, just re-add the formatted files\n  if [ -n \"$STAGED_FILES\" ]; then\n    echo \"$STAGED_FILES\" | while IFS= read -r file; do\n      if [ -f \"$file\" ]; then\n        git add \"$file\"\n      fi\n    done\n  fi\nfi\n\n# Check if staged files actually changed\nNEW_STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)\nif [ \"$STAGED_HASH\" != \"$NEW_STAGED_HASH\" ]; then\n  echo \"✨ Files formatted by Ultracite\"\nfi\n\nexit $FORMAT_EXIT_CODE\n"
  },
  {
    "path": ".npmrc",
    "content": "electron_mirror=https://npmmirror.com/mirrors/electron/\nelectron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/\nshamefully-hoist=true\n\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"biomejs.biome\", \"bradlc.vscode-tailwindcss\"]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.biome\": \"explicit\",\n    \"source.organizeImports.biome\": \"explicit\"\n  },\n  \"[javascript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[javascriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"tailwindCSS.experimental.classRegex\": [\n    [\"cva\\\\(([^)]*)\\\\)\", \"[\\\"'`]([^\\\"'`]*).*?[\\\"'`]\"],\n    [\"cn\\\\(([^)]*)\\\\)\", \"(?:'|\\\"|`)([^']*)(?:'|\\\"|`)\"]\n  ],\n  \"i18n-ally.localesPaths\": [\"apps/desktop/src/renderer/src/locales\"],\n  \"i18n-ally.keystyle\": \"nested\",\n  \"[css]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"editor.formatOnPaste\": true,\n  \"emmet.showExpandedAbbreviation\": \"never\",\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[html]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[vue]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[svelte]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[yaml]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[graphql]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[markdown]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[mdx]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "1. use pnpm instead of npm\n2. use pnpm run check after tasks to check code\n3. Support i18n. When writing business logic, initially only translate the English version of en.json\n4. use English for comments&console\n5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles\n6. 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.\n7. 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.\n8. After pushing each commit for a pull request, use the gh command to add a comment to the corresponding pull request, with the content being: \"@codex review\"\n\n# Ultracite Code Standards\n\nThis project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.\n\n## Quick Reference\n\n- **Format code**: `pnpm dlx ultracite fix`\n- **Check for issues**: `pnpm dlx ultracite check`\n- **Diagnose setup**: `pnpm dlx ultracite doctor`\n\nBiome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.\n\n---\n\n## Core Principles\n\nWrite code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.\n\n### Type Safety & Explicitness\n\n- Use explicit types for function parameters and return values when they enhance clarity\n- Prefer `unknown` over `any` when the type is genuinely unknown\n- Use const assertions (`as const`) for immutable values and literal types\n- Leverage TypeScript's type narrowing instead of type assertions\n- Use meaningful variable names instead of magic numbers - extract constants with descriptive names\n\n### Modern JavaScript/TypeScript\n\n- Use arrow functions for callbacks and short functions\n- Prefer `for...of` loops over `.forEach()` and indexed `for` loops\n- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access\n- Prefer template literals over string concatenation\n- Use destructuring for object and array assignments\n- Use `const` by default, `let` only when reassignment is needed, never `var`\n\n### Async & Promises\n\n- Always `await` promises in async functions - don't forget to use the return value\n- Use `async/await` syntax instead of promise chains for better readability\n- Handle errors appropriately in async code with try-catch blocks\n- Don't use async functions as Promise executors\n\n### React & JSX\n\n- Use function components over class components\n- Call hooks at the top level only, never conditionally\n- Specify all dependencies in hook dependency arrays correctly\n- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)\n- Nest children between opening and closing tags instead of passing as props\n- Don't define components inside other components\n- Use semantic HTML and ARIA attributes for accessibility:\n  - Provide meaningful alt text for images\n  - Use proper heading hierarchy\n  - Add labels for form inputs\n  - Include keyboard event handlers alongside mouse events\n  - Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles\n\n### Error Handling & Debugging\n\n- Remove `console.log`, `debugger`, and `alert` statements from production code\n- Throw `Error` objects with descriptive messages, not strings or other values\n- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them\n- Prefer early returns over nested conditionals for error cases\n\n### Code Organization\n\n- Keep functions focused and under reasonable cognitive complexity limits\n- Extract complex conditions into well-named boolean variables\n- Use early returns to reduce nesting\n- Prefer simple conditionals over nested ternary operators\n- Group related code together and separate concerns\n\n### Security\n\n- Add `rel=\"noopener\"` when using `target=\"_blank\"` on links\n- Avoid `dangerouslySetInnerHTML` unless absolutely necessary\n- Don't use `eval()` or assign directly to `document.cookie`\n- Validate and sanitize user input\n\n### Performance\n\n- Avoid spread syntax in accumulators within loops\n- Use top-level regex literals instead of creating them in loops\n- Prefer specific imports over namespace imports\n- Avoid barrel files (index files that re-export everything)\n- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags\n\n### Framework-Specific Guidance\n\n**Next.js:**\n- Use Next.js `<Image>` component for images\n- Use `next/head` or App Router metadata API for head elements\n- Use Server Components for async data fetching instead of async Client Components\n\n**React 19+:**\n- Use ref as a prop instead of `React.forwardRef`\n\n**Solid/Svelte/Vue/Qwik:**\n- Use `class` and `for` attributes (not `className` or `htmlFor`)\n\n---\n\n## Testing\n\n- Write assertions inside `it()` or `test()` blocks\n- Avoid done callbacks in async tests - use async/await instead\n- Don't use `.only` or `.skip` in committed code\n- Keep test suites reasonably flat - avoid excessive `describe` nesting\n\n## When Biome Can't Help\n\nBiome's linter will catch most issues automatically. Focus your attention on:\n\n1. **Business logic correctness** - Biome can't validate your algorithms\n2. **Meaningful naming** - Use descriptive names for functions, variables, and types\n3. **Architecture decisions** - Component structure, data flow, and API design\n4. **Edge cases** - Handle boundary conditions and error states\n5. **User experience** - Accessibility, performance, and usability considerations\n6. **Documentation** - Add comments for complex logic, but prefer self-documenting code\n\n---\n\nMost formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to VidBee\n\nThank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.\n\n## Getting Ready\n- Use Node.js 18+ and pnpm 8+.\n- Install dependencies with `pnpm install`.\n- Run `pnpm dev` to test changes locally.\n\n## Tech Stack\n- Runtime: Electron 38, electron-vite, electron-builder.\n- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.\n- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.\n\n## Local Development\n- Use `pnpm install` to pull dependencies after cloning.\n- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.\n- Preview the production build locally with `pnpm start`.\n\n## Useful Scripts\n\n| Command | Purpose |\n| --- | --- |\n| `pnpm run typecheck` | Type-check the main and renderer projects. |\n| `pnpm build` | Run type checks and produce production bundles. |\n| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |\n| `pnpm build:unpack` | Produce unpacked output directories for inspection. |\n| `pnpm run check` | Format and lint the codebase with Biome. |\n\n## Project Structure\n\n```text\napps/desktop/src/\n|-- main/            # Electron main process, IPC services, configuration\n|-- preload/         # Context bridge and preload helpers\n`-- renderer/\n    |-- src/\n    |   |-- pages/      # Application routes (Home, Settings, Playlist, etc.)\n    |   |-- components/ # UI components, download views, shared controls\n    |   |-- data/       # Static datasets such as popularSites.ts\n    |   |-- hooks/      # Custom hooks and global atoms\n    |   |-- lib/        # Utilities shared across the renderer\n    |   `-- assets/     # Global styles and icons\n    `-- index.html\n```\n\n## Internationalization\n- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.\n- Only update strings in `apps/desktop/src/renderer/src/locales/en.json`; maintainers handle the other locales.\n- Keep copy edits focused and avoid removing translation keys without discussion.\n\n## Configuration and Storage\n- Persistent settings are stored with `electron-store` and exposed through IPC helpers.\n- User-facing preferences such as download paths and themes live in `apps/desktop/src/main/settings.ts` and related services.\n- Logs are recorded with `electron-log` to simplify troubleshooting.\n\n## Packaging\n- Build production bundles with `pnpm build`.\n- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.\n- Use `pnpm build:unpack` to generate unpacked directories under `apps/desktop/dist/` for manual inspection.\n- 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.\n\n## Working on Changes\n- Keep each pull request focused on a single problem or feature.\n- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.\n- Write comments and console messages in English only.\n- When updating copy in the app, adjust strings in `apps/desktop/src/renderer/src/locales/en.json`; other locale files are handled by maintainers.\n\n## Opening Issues\n- Search existing issues to avoid duplicates.\n- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.\n\n## Submitting Pull Requests\n- Explain the motivation and impact of the change in the description.\n- Mention any user facing updates or migrations.\n- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.\n\nWe appreciate every contribution that keeps VidBee simple and reliable.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 VidBee\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"left\">\n  <a href=\"https://github.com/nexmoe/VidBee\">\n    <img src=\"apps/desktop/build/icon.png\" alt=\"Logo\" width=\"80\" height=\"80\">\n  </a>\n\n  <h3>VidBee</h3>\n  <p>\n    <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>\n    <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>\n    <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>\n    <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>\n    <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>\n    <a href=\"https://deepwiki.com/nexmoe/VidBee\"><img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\"></a>\n    <br />\n    <br />\n    <a href=\"https://github.com/nexmoe/VidBee/releases/latest\" target=\"_blank\"><img src=\"screenshots/main-interface.png\" alt=\"VidBee Desktop\" width=\"46%\"/></a>\n    <a href=\"https://github.com/nexmoe/VidBee/releases/latest\" target=\"_blank\"><img src=\"screenshots/download-queue.png\" alt=\"VidBee Download Queue\" width=\"46%\"/></a>\n    <br />\n    <br />\n  </p>\n</div>\n\nVidBee 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.\n\n## 👋🏻 Getting Started\n\nVidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.\n\n[📥 Download VidBee](https://vidbee.org/download/) | [📚 Documentation](https://docs.vidbee.org)\n\n> [!IMPORTANT]\n>\n> **Star Us**, You will receive all release notifications from GitHub without any delay ~\n\n<a href=\"https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042\" target=\"_blank\" style=\"display: block\" align=\"left\">\n  <picture>\n    <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\">\n    <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\">\n  </picture>\n</a>\n\n<!-- Made with [OSS Insight](https://ossinsight.io/) -->\n\n## ✨ Features\n\n### 🌍 Global Video Download Support\n\nDownload videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.\n\n![VidBee Main Interface](screenshots/main-interface.png)\n\n### 🎨 Best-in-class UI Experience\n\nModern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.\n\n![VidBee Download Queue](screenshots/download-queue.png)\n\n### 📡 RSS Auto Download\n\nAutomatically 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.\n\n## 🌐 Supported Sites\n\nVidBee 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/)\n\n## 🧱 Web + API (Docker-ready)\n\nThis monorepo now includes:\n\n- `packages/downloader-core`: Shared yt-dlp/ffmpeg download core\n- `apps/api`: Fastify API server with oRPC and SSE events\n- `apps/web`: TanStack Start web client using oRPC\n\nRun locally:\n\n```bash\npnpm run start:web\n```\n\nThis command starts `apps/api` and `apps/web` together.\n\nRun with Docker:\n\n```bash\ndocker compose up -d --build\n```\n\nRun with GitHub Container Registry images:\n\n```yaml\nservices:\n  api:\n    image: ghcr.io/nexmoe/vidbee-api:latest\n    environment:\n      VIDBEE_API_HOST: 0.0.0.0\n      VIDBEE_API_PORT: 3100\n      VIDBEE_DOWNLOAD_DIR: /data/downloads\n      VIDBEE_HISTORY_STORE_PATH: /data/vidbee/vidbee.db\n    ports:\n      - \"3100:3100\"\n    volumes:\n      - vidbee-downloads:/data/downloads\n      - vidbee-data:/data/vidbee\n    restart: unless-stopped\n\n  web:\n    image: ghcr.io/nexmoe/vidbee-web:latest\n    depends_on:\n      - api\n    ports:\n      - \"3000:3000\"\n    restart: unless-stopped\n\nvolumes:\n  vidbee-downloads:\n  vidbee-data:\n```\n\nStop services:\n\n```bash\ndocker compose down\n```\n\nOptional env vars (via `.env`):\n\n```bash\nVIDBEE_API_PORT=3100\nVIDBEE_WEB_PORT=3000\nVITE_API_URL=http://localhost:3100\n```\n\n## 🤝 Contributing\n\nYou are welcome to join the open source community to build together. For more details, check out:\n\n- Monorepo apps:\n  - `apps/desktop`: VidBee desktop app (Electron)\n  - `apps/docs`: Documentation site (Next.js)\n  - `apps/extension`: Browser extension (WXT)\n- [Contributing Guide](./CONTRIBUTING.md)\n- [DeepWiki Documentation](https://deepwiki.com/nexmoe/VidBee)\n\n## 📄 License\n\nThis project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.\n\n## 🙏 Thanks\n\n- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine\n- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing\n- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps\n- [React](https://react.dev/) - The UI library\n- [Vite](https://vitejs.dev/) - Next generation frontend tooling\n- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework\n- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components\n"
  },
  {
    "path": "apps/api/Dockerfile",
    "content": "FROM node:22-alpine\n\nENV PNPM_HOME=/pnpm\nENV PATH=${PNPM_HOME}:${PATH}\nENV YTDLP_PATH=/usr/bin/yt-dlp\nENV FFMPEG_PATH=/usr/bin/ffmpeg\n\nRUN apk add --no-cache yt-dlp ffmpeg python3 make g++\n\nRUN corepack enable\n\nWORKDIR /app\n\nCOPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./\nCOPY apps/api/package.json apps/api/package.json\nCOPY packages/db/package.json packages/db/package.json\nCOPY packages/downloader-core/package.json packages/downloader-core/package.json\n\nRUN pnpm install --filter \"{./apps/api}...\" --frozen-lockfile\n\nCOPY apps/api apps/api\nCOPY apps/desktop/resources/drizzle apps/desktop/resources/drizzle\nCOPY packages/db packages/db\nCOPY packages/downloader-core packages/downloader-core\n\nEXPOSE 3100\n\nENV VIDBEE_API_HOST=0.0.0.0\nENV VIDBEE_API_PORT=3100\nENV VIDBEE_DOWNLOAD_DIR=/data/downloads\n\nVOLUME [\"/data/downloads\"]\n\nCMD [\"pnpm\", \"--filter\", \"./apps/api\", \"run\", \"start\"]\n"
  },
  {
    "path": "apps/api/package.json",
    "content": "{\n  \"name\": \"api\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"tsx watch src/index.ts\",\n    \"start\": \"tsx src/index.ts\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"check\": \"ultracite check && pnpm run typecheck\"\n  },\n  \"dependencies\": {\n    \"@fastify/cors\": \"^11.2.0\",\n    \"@orpc/openapi\": \"^1.13.5\",\n    \"@orpc/server\": \"^1.13.5\",\n    \"@orpc/zod\": \"^1.13.5\",\n    \"@vidbee/db\": \"workspace:*\",\n    \"@vidbee/downloader-core\": \"workspace:*\",\n    \"better-sqlite3\": \"^12.4.1\",\n    \"drizzle-orm\": \"^0.44.7\",\n    \"fastify\": \"^5.7.4\"\n  },\n  \"devDependencies\": {\n    \"@types/better-sqlite3\": \"^7.6.13\",\n    \"@types/node\": \"^22.18.6\",\n    \"drizzle-kit\": \"0.31.7\",\n    \"tsx\": \"^4.21.0\",\n    \"typescript\": \"^5.9.2\",\n    \"ultracite\": \"7.1.5\"\n  }\n}\n"
  },
  {
    "path": "apps/api/src/index.ts",
    "content": "import { createApiServer } from './server'\n\nconst host = process.env.VIDBEE_API_HOST?.trim() || '0.0.0.0'\nconst portValue = Number(process.env.VIDBEE_API_PORT ?? '')\nconst port = Number.isInteger(portValue) && portValue > 0 ? portValue : 3100\n\nconst server = await createApiServer()\n\ntry {\n  await server.listen({ host, port })\n  server.log.info(`VidBee API server listening on http://${host}:${port}`)\n} catch (error) {\n  server.log.error(error)\n  process.exit(1)\n}\n\nconst shutdown = async (signal: string) => {\n  server.log.info(`Received ${signal}, shutting down API server`)\n  await server.close()\n  process.exit(0)\n}\n\nprocess.on('SIGINT', () => {\n  void shutdown('SIGINT')\n})\nprocess.on('SIGTERM', () => {\n  void shutdown('SIGTERM')\n})\n"
  },
  {
    "path": "apps/api/src/lib/database-migrate.ts",
    "content": "import { existsSync } from 'node:fs'\nimport path from 'node:path'\nimport type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'\nimport { migrate } from 'drizzle-orm/better-sqlite3/migrator'\n\nconst MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../../../desktop/resources/drizzle')\n\nexport const runDatabaseMigrations = (database: BetterSQLite3Database): void => {\n  if (!existsSync(MIGRATIONS_FOLDER)) {\n    throw new Error(`API migrations folder not found: ${MIGRATIONS_FOLDER}`)\n  }\n\n  migrate(database, { migrationsFolder: MIGRATIONS_FOLDER })\n}\n"
  },
  {
    "path": "apps/api/src/lib/downloader.ts",
    "content": "import path from 'node:path'\nimport type { DownloadTask } from '@vidbee/downloader-core'\nimport { DownloaderCore } from '@vidbee/downloader-core'\nimport { HistoryStore } from './history-store'\n\nconst defaultDownloadDir =\n  process.env.VIDBEE_DOWNLOAD_DIR?.trim() || process.env.DOWNLOAD_DIR?.trim() || undefined\n\nconst maxConcurrentValue = process.env.VIDBEE_MAX_CONCURRENT?.trim()\nconst parsedMaxConcurrent = maxConcurrentValue ? Number(maxConcurrentValue) : Number.NaN\nconst maxConcurrent =\n  Number.isFinite(parsedMaxConcurrent) && parsedMaxConcurrent > 0 ? parsedMaxConcurrent : undefined\n\nconst configuredHistoryStorePath = process.env.VIDBEE_HISTORY_STORE_PATH?.trim()\nconst historyStorePath = configuredHistoryStorePath\n  ? configuredHistoryStorePath\n  : defaultDownloadDir\n    ? path.join(defaultDownloadDir, '.vidbee', 'vidbee.db')\n    : path.join(process.cwd(), '.vidbee', 'vidbee.db')\n\nexport const historyStore = new HistoryStore(historyStorePath)\n\nexport const downloaderCore = new DownloaderCore({\n  downloadDir: defaultDownloadDir,\n  maxConcurrent\n})\n\nconst terminalStatuses = new Set<DownloadTask['status']>(['completed', 'error', 'cancelled'])\n\ndownloaderCore.on('task-updated', (task: DownloadTask) => {\n  if (!terminalStatuses.has(task.status)) {\n    return\n  }\n  historyStore.save(task)\n})\n"
  },
  {
    "path": "apps/api/src/lib/history-record-mapper.ts",
    "content": "import type { DownloadHistoryInsert, DownloadHistoryRow } from '@vidbee/db/history'\nimport type { DownloadTask } from '@vidbee/downloader-core'\n\nconst TERMINAL_STATUSES = new Set<DownloadTask['status']>(['completed', 'error', 'cancelled'])\nconst TAG_SEPARATOR = '\\n'\n\nconst parseJson = <T>(value: string | null | undefined): T | undefined => {\n  if (!value) {\n    return undefined\n  }\n  try {\n    return JSON.parse(value) as T\n  } catch {\n    return undefined\n  }\n}\n\nconst sanitizeList = (values?: string[]): string[] => {\n  if (!values || values.length === 0) {\n    return []\n  }\n  return values\n    .map((value) => value.trim())\n    .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)\n}\n\nconst serializeTags = (values?: string[]): string | null => {\n  const sanitized = sanitizeList(values)\n  return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null\n}\n\nconst parseTags = (value: string | null): string[] | undefined => {\n  if (!value) {\n    return undefined\n  }\n  const parsed = value\n    .split(TAG_SEPARATOR)\n    .map((tag) => tag.trim())\n    .filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)\n  return parsed.length > 0 ? parsed : undefined\n}\n\nexport const isTerminalTask = (task: DownloadTask): boolean => TERMINAL_STATUSES.has(task.status)\n\nexport const serializeHistoryTask = (task: DownloadTask): DownloadHistoryInsert => {\n  const downloadedAt = task.createdAt\n  const completedAt = task.completedAt ?? null\n  const sortKey = completedAt ?? downloadedAt\n\n  return {\n    id: task.id,\n    url: task.url,\n    title: task.title ?? task.url,\n    thumbnail: task.thumbnail ?? null,\n    type: task.type,\n    status: task.status,\n    downloadPath: task.downloadPath ?? null,\n    savedFileName: task.savedFileName ?? null,\n    fileSize: task.fileSize ?? null,\n    duration: task.duration ?? null,\n    downloadedAt,\n    completedAt,\n    sortKey,\n    error: task.error ?? null,\n    ytDlpCommand: task.ytDlpCommand ?? null,\n    ytDlpLog: task.ytDlpLog ?? null,\n    description: task.description ?? null,\n    channel: task.channel ?? null,\n    uploader: task.uploader ?? null,\n    viewCount: task.viewCount ?? null,\n    tags: serializeTags(task.tags),\n    origin: null,\n    subscriptionId: null,\n    selectedFormat: task.selectedFormat ? JSON.stringify(task.selectedFormat) : null,\n    playlistId: task.playlistId ?? null,\n    playlistTitle: task.playlistTitle ?? null,\n    playlistIndex: task.playlistIndex ?? null,\n    playlistSize: task.playlistSize ?? null\n  }\n}\n\nexport const mapHistoryRowToTask = (row: DownloadHistoryRow): DownloadTask => {\n  const parsedTags = parseTags(row.tags ?? null)\n  const parsedSelectedFormat = parseJson<DownloadTask['selectedFormat']>(row.selectedFormat)\n\n  return {\n    id: row.id,\n    url: row.url,\n    title: row.title,\n    thumbnail: row.thumbnail ?? undefined,\n    type: row.type as DownloadTask['type'],\n    status: row.status as DownloadTask['status'],\n    createdAt: row.downloadedAt,\n    completedAt: row.completedAt ?? undefined,\n    downloadPath: row.downloadPath ?? undefined,\n    savedFileName: row.savedFileName ?? undefined,\n    fileSize: row.fileSize ?? undefined,\n    duration: row.duration ?? undefined,\n    error: row.error ?? undefined,\n    ytDlpCommand: row.ytDlpCommand ?? undefined,\n    ytDlpLog: row.ytDlpLog ?? undefined,\n    description: row.description ?? undefined,\n    channel: row.channel ?? undefined,\n    uploader: row.uploader ?? undefined,\n    viewCount: row.viewCount ?? undefined,\n    tags: parsedTags,\n    selectedFormat: parsedSelectedFormat,\n    playlistId: row.playlistId ?? undefined,\n    playlistTitle: row.playlistTitle ?? undefined,\n    playlistIndex: row.playlistIndex ?? undefined,\n    playlistSize: row.playlistSize ?? undefined\n  }\n}\n"
  },
  {
    "path": "apps/api/src/lib/history-store.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { downloadHistoryTable } from '@vidbee/db/history'\nimport type { DownloadTask } from '@vidbee/downloader-core'\nimport DatabaseConstructor from 'better-sqlite3'\nimport { desc, eq, inArray } from 'drizzle-orm'\nimport { drizzle } from 'drizzle-orm/better-sqlite3'\nimport { runDatabaseMigrations } from './database-migrate'\nimport { isTerminalTask, mapHistoryRowToTask, serializeHistoryTask } from './history-record-mapper'\n\nexport class HistoryStore {\n  private readonly db\n  private readonly sqlite\n\n  constructor(databasePath: string) {\n    fs.mkdirSync(path.dirname(databasePath), { recursive: true })\n    this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })\n    this.sqlite.pragma('journal_mode = WAL')\n    this.db = drizzle(this.sqlite)\n    runDatabaseMigrations(this.db)\n  }\n\n  save(task: DownloadTask): void {\n    if (!isTerminalTask(task)) {\n      return\n    }\n\n    const row = serializeHistoryTask(task)\n\n    this.db\n      .insert(downloadHistoryTable)\n      .values(row)\n      .onConflictDoUpdate({\n        target: downloadHistoryTable.id,\n        set: { ...row }\n      })\n      .run()\n  }\n\n  list(): DownloadTask[] {\n    const rows = this.db\n      .select()\n      .from(downloadHistoryTable)\n      .orderBy(desc(downloadHistoryTable.sortKey))\n      .all()\n\n    const tasks: DownloadTask[] = []\n    for (const row of rows) {\n      const task = mapHistoryRowToTask(row)\n      if (isTerminalTask(task)) {\n        tasks.push(task)\n      }\n    }\n    return tasks\n  }\n\n  removeItems(ids: string[]): number {\n    const normalizedIds = ids.map((id) => id.trim()).filter((id) => id.length > 0)\n    if (normalizedIds.length === 0) {\n      return 0\n    }\n    const result = this.db\n      .delete(downloadHistoryTable)\n      .where(inArray(downloadHistoryTable.id, normalizedIds))\n      .run()\n    return result.changes\n  }\n\n  removeByPlaylist(playlistId: string): number {\n    const normalizedPlaylistId = playlistId.trim()\n    if (!normalizedPlaylistId) {\n      return 0\n    }\n    const result = this.db\n      .delete(downloadHistoryTable)\n      .where(eq(downloadHistoryTable.playlistId, normalizedPlaylistId))\n      .run()\n    return result.changes\n  }\n}\n"
  },
  {
    "path": "apps/api/src/lib/rpc-router.ts",
    "content": "import { spawn } from 'node:child_process'\nimport { randomUUID } from 'node:crypto'\nimport { constants as fsConstants } from 'node:fs'\nimport { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport { implement, ORPCError } from '@orpc/server'\nimport { downloaderContract } from '@vidbee/downloader-core'\nimport { downloaderCore, historyStore } from './downloader'\nimport { webSettingsStore } from './web-settings-store'\n\nconst os = implement(downloaderContract)\nconst WEB_SETTINGS_FILES_DIR = path.resolve(process.cwd(), '.data', 'web-settings-files')\nconst MAX_WEB_SETTINGS_FILE_BYTES = 1_000_000\nconst MANAGED_SETTINGS_FILE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000\nconst SAFE_FILE_NAME_REGEX = /[^A-Za-z0-9._-]+/g\ntype ManagedSettingsFileKind = 'cookies' | 'config'\n\nconst toErrorMessage = (error: unknown, fallbackMessage: string): string => {\n  if (error instanceof Error && error.message.trim().length > 0) {\n    return error.message\n  }\n\n  return fallbackMessage\n}\n\nconst runProcess = (command: string, args: string[]): Promise<boolean> =>\n  new Promise((resolve) => {\n    const child = spawn(command, args, {\n      stdio: 'ignore',\n      windowsHide: true\n    })\n\n    child.on('error', () => {\n      resolve(false)\n    })\n\n    child.on('close', (code) => {\n      resolve(code === 0)\n    })\n  })\n\nconst pathExists = async (targetPath: string): Promise<boolean> => {\n  try {\n    await access(targetPath, fsConstants.F_OK)\n    return true\n  } catch {\n    return false\n  }\n}\n\nconst isPathWithinBase = (basePath: string, targetPath: string): boolean => {\n  const normalizedBase = path.resolve(basePath)\n  const normalizedTarget = path.resolve(targetPath)\n  const relativePath = path.relative(normalizedBase, normalizedTarget)\n\n  return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)\n}\n\nconst openFileWithSystem = async (targetPath: string): Promise<boolean> => {\n  if (process.platform === 'darwin') {\n    return runProcess('open', [targetPath])\n  }\n\n  if (process.platform === 'win32') {\n    return runProcess('cmd', ['/c', 'start', '', targetPath])\n  }\n\n  return runProcess('xdg-open', [targetPath])\n}\n\nconst openFileLocationWithSystem = async (targetPath: string): Promise<boolean> => {\n  if (process.platform === 'darwin') {\n    return runProcess('open', ['-R', targetPath])\n  }\n\n  if (process.platform === 'win32') {\n    return runProcess('explorer', [`/select,${targetPath}`])\n  }\n\n  return runProcess('xdg-open', [path.dirname(targetPath)])\n}\n\nconst copyFileToClipboardWithSystem = async (targetPath: string): Promise<boolean> => {\n  if (process.platform === 'darwin') {\n    const escapedPath = targetPath.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n    return runProcess('osascript', ['-e', `set the clipboard to (POSIX file \"${escapedPath}\")`])\n  }\n\n  if (process.platform === 'win32') {\n    const escapedPath = targetPath.replace(/'/g, \"''\")\n    return runProcess('powershell', [\n      '-NoProfile',\n      '-Command',\n      `Set-Clipboard -Path '${escapedPath}'`\n    ])\n  }\n\n  return false\n}\n\nconst listServerDirectories = async (\n  rawPath: string | undefined\n): Promise<{\n  currentPath: string\n  parentPath: string | null\n  directories: { name: string; path: string }[]\n}> => {\n  const requestedPath = rawPath?.trim()\n  const candidatePath = requestedPath && requestedPath.length > 0 ? requestedPath : process.cwd()\n  const currentPath = path.resolve(candidatePath)\n\n  const pathInfo = await stat(currentPath)\n  if (!pathInfo.isDirectory()) {\n    throw new Error('Path is not a directory.')\n  }\n\n  const entries = await readdir(currentPath, { withFileTypes: true })\n  const directories = entries\n    .filter((entry) => entry.isDirectory())\n    .map((entry) => ({\n      name: entry.name,\n      path: path.join(currentPath, entry.name)\n    }))\n    .sort((a, b) => a.name.localeCompare(b.name))\n\n  const parsed = path.parse(currentPath)\n  const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath)\n\n  return { currentPath, parentPath, directories }\n}\n\nconst sanitizeUploadedFileName = (fileName: string, fallbackFileName: string): string => {\n  const normalized = path\n    .basename(fileName.trim())\n    .replace(SAFE_FILE_NAME_REGEX, '-')\n    .replace(/-+/g, '-')\n    .replace(/^-|-$/g, '')\n\n  if (!normalized) {\n    return fallbackFileName\n  }\n\n  return normalized.slice(0, 120)\n}\n\nconst storeWebSettingsFile = async (\n  kind: 'cookies' | 'config',\n  fileName: string,\n  content: string\n): Promise<string> => {\n  const contentBuffer = Buffer.from(content, 'utf-8')\n  if (contentBuffer.byteLength > MAX_WEB_SETTINGS_FILE_BYTES) {\n    throw new Error('Uploaded file is too large.')\n  }\n\n  const destinationDir = path.join(WEB_SETTINGS_FILES_DIR, kind)\n  await mkdir(destinationDir, { recursive: true })\n\n  const fallbackFileName = kind === 'cookies' ? 'cookies.txt' : 'config.txt'\n  const safeFileName = sanitizeUploadedFileName(fileName, fallbackFileName)\n  const storedFileName = `${Date.now()}-${randomUUID()}-${safeFileName}`\n  const destinationPath = path.join(destinationDir, storedFileName)\n\n  await writeFile(destinationPath, contentBuffer)\n  return destinationPath\n}\n\nconst resolveManagedSettingsFilePath = (\n  rawPath: string,\n  kind: ManagedSettingsFileKind\n): string | null => {\n  const trimmedPath = rawPath.trim()\n  if (!trimmedPath) {\n    return null\n  }\n\n  const resolvedPath = path.resolve(trimmedPath)\n  const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)\n  if (!isPathWithinBase(managedDirectory, resolvedPath)) {\n    return null\n  }\n\n  return resolvedPath\n}\n\nconst pruneManagedSettingsFiles = async (\n  kind: ManagedSettingsFileKind,\n  referencedPaths: string[]\n): Promise<void> => {\n  const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)\n  const keepPaths = new Set<string>()\n\n  for (const rawPath of referencedPaths) {\n    const managedPath = resolveManagedSettingsFilePath(rawPath, kind)\n    if (managedPath) {\n      keepPaths.add(managedPath)\n    }\n  }\n\n  let entries: { isFile: () => boolean; name: string }[] = []\n  try {\n    entries = await readdir(managedDirectory, { withFileTypes: true })\n  } catch {\n    return\n  }\n\n  const now = Date.now()\n  for (const entry of entries) {\n    if (!entry.isFile()) {\n      continue\n    }\n\n    const candidatePath = path.resolve(path.join(managedDirectory, entry.name))\n    if (keepPaths.has(candidatePath)) {\n      continue\n    }\n\n    try {\n      const candidateInfo = await stat(candidatePath)\n      if (now - candidateInfo.mtimeMs < MANAGED_SETTINGS_FILE_RETENTION_MS) {\n        continue\n      }\n\n      await rm(candidatePath, { force: true })\n    } catch {\n      // Ignore cleanup errors to keep upload and settings updates resilient.\n    }\n  }\n}\n\nconst triggerManagedSettingsFilePrune = (\n  kind: ManagedSettingsFileKind,\n  newlyUploadedPath: string\n): void => {\n  void (async () => {\n    try {\n      const settings = await webSettingsStore.get()\n      const currentSettingsPath = kind === 'cookies' ? settings.cookiesPath : settings.configPath\n      await pruneManagedSettingsFiles(kind, [newlyUploadedPath, currentSettingsPath])\n    } catch {\n      // Ignore cleanup errors to keep upload and settings updates resilient.\n    }\n  })()\n}\n\nexport const rpcRouter = os.router({\n  status: os.status.handler(() => {\n    const status = downloaderCore.getStatus()\n    return {\n      ok: true,\n      version: '1.0.0',\n      active: status.active,\n      pending: status.pending\n    }\n  }),\n  videoInfo: os.videoInfo.handler(async ({ input }) => {\n    try {\n      const video = await downloaderCore.getVideoInfo(input.url, input.settings)\n      return { video }\n    } catch (error) {\n      throw new ORPCError('INTERNAL_SERVER_ERROR', {\n        message: toErrorMessage(error, 'Failed to fetch video info.')\n      })\n    }\n  }),\n  playlist: {\n    info: os.playlist.info.handler(async ({ input }) => {\n      try {\n        const playlist = await downloaderCore.getPlaylistInfo(input.url, input.settings)\n        return { playlist }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to fetch playlist info.')\n        })\n      }\n    }),\n    download: os.playlist.download.handler(async ({ input }) => {\n      try {\n        const result = await downloaderCore.startPlaylistDownload(input)\n        return { result }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to start playlist download.')\n        })\n      }\n    })\n  },\n  downloads: {\n    create: os.downloads.create.handler(async ({ input }) => {\n      try {\n        const download = await downloaderCore.createDownload(input)\n        return { download }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to create download.')\n        })\n      }\n    }),\n    list: os.downloads.list.handler(() => {\n      return {\n        downloads: downloaderCore.listDownloads()\n      }\n    }),\n    cancel: os.downloads.cancel.handler(async ({ input }) => {\n      try {\n        const cancelled = await downloaderCore.cancelDownload(input.id)\n        return { cancelled }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to cancel download.')\n        })\n      }\n    })\n  },\n  history: {\n    list: os.history.list.handler(() => {\n      return {\n        history: historyStore.list()\n      }\n    }),\n    removeItems: os.history.removeItems.handler(({ input }) => {\n      try {\n        const removed = historyStore.removeItems(input.ids)\n        downloaderCore.removeHistoryItems(input.ids)\n        return { removed }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to remove history items.')\n        })\n      }\n    }),\n    removeByPlaylist: os.history.removeByPlaylist.handler(({ input }) => {\n      try {\n        const removed = historyStore.removeByPlaylist(input.playlistId)\n        downloaderCore.removeHistoryByPlaylist(input.playlistId)\n        return { removed }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to remove playlist history.')\n        })\n      }\n    })\n  },\n  files: {\n    exists: os.files.exists.handler(async ({ input }) => {\n      try {\n        const resolvedPath = path.resolve(input.path)\n        return { exists: await pathExists(resolvedPath) }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to check file existence.')\n        })\n      }\n    }),\n    listDirectories: os.files.listDirectories.handler(async ({ input }) => {\n      try {\n        return await listServerDirectories(input.path)\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to list server directories.')\n        })\n      }\n    }),\n    openFile: os.files.openFile.handler(async ({ input }) => {\n      try {\n        const resolvedPath = path.resolve(input.path)\n        const exists = await pathExists(resolvedPath)\n        if (!exists) {\n          return { success: false }\n        }\n\n        return { success: await openFileWithSystem(resolvedPath) }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to open file.')\n        })\n      }\n    }),\n    openFileLocation: os.files.openFileLocation.handler(async ({ input }) => {\n      try {\n        const resolvedPath = path.resolve(input.path)\n        const exists = await pathExists(resolvedPath)\n        if (!exists) {\n          return { success: false }\n        }\n\n        return { success: await openFileLocationWithSystem(resolvedPath) }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to open file location.')\n        })\n      }\n    }),\n    copyFileToClipboard: os.files.copyFileToClipboard.handler(async ({ input }) => {\n      try {\n        const resolvedPath = path.resolve(input.path)\n        const exists = await pathExists(resolvedPath)\n        if (!exists) {\n          return { success: false }\n        }\n\n        return { success: await copyFileToClipboardWithSystem(resolvedPath) }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to copy file to clipboard.')\n        })\n      }\n    }),\n    deleteFile: os.files.deleteFile.handler(async ({ input }) => {\n      try {\n        const settings = await webSettingsStore.get()\n        const managedDownloadPath = settings.downloadPath.trim()\n        if (!managedDownloadPath) {\n          throw new ORPCError('FORBIDDEN', {\n            message: 'Deleting files is disabled until a download path is configured.'\n          })\n        }\n\n        const resolvedPath = path.resolve(input.path)\n        if (!isPathWithinBase(managedDownloadPath, resolvedPath)) {\n          throw new ORPCError('FORBIDDEN', {\n            message: 'Refusing to delete files outside the managed download directory.'\n          })\n        }\n\n        const exists = await pathExists(resolvedPath)\n        if (!exists) {\n          return { success: false }\n        }\n\n        await rm(resolvedPath)\n        return { success: true }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to delete file.')\n        })\n      }\n    }),\n    uploadSettingsFile: os.files.uploadSettingsFile.handler(async ({ input }) => {\n      try {\n        const storedPath = await storeWebSettingsFile(input.kind, input.fileName, input.content)\n        triggerManagedSettingsFilePrune(input.kind, storedPath)\n        return { path: storedPath }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to upload settings file.')\n        })\n      }\n    })\n  },\n  settings: {\n    get: os.settings.get.handler(async () => {\n      try {\n        const settings = await webSettingsStore.get()\n        return { settings }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to read settings.')\n        })\n      }\n    }),\n    set: os.settings.set.handler(async ({ input }) => {\n      try {\n        const settings = await webSettingsStore.set(input.settings)\n        return { settings }\n      } catch (error) {\n        throw new ORPCError('INTERNAL_SERVER_ERROR', {\n          message: toErrorMessage(error, 'Failed to save settings.')\n        })\n      }\n    })\n  }\n})\n"
  },
  {
    "path": "apps/api/src/lib/sse.ts",
    "content": "import type { ServerResponse } from 'node:http'\n\nconst HEARTBEAT_INTERVAL_MS = 15_000\n\nexport class SseHub {\n  private readonly clients = new Set<ServerResponse>()\n  private heartbeatTimer: NodeJS.Timeout | null = null\n\n  addClient(client: ServerResponse): void {\n    this.clients.add(client)\n    client.write('event: connected\\ndata: {\"ok\":true}\\n\\n')\n    this.ensureHeartbeatTimer()\n  }\n\n  removeClient(client: ServerResponse): void {\n    this.clients.delete(client)\n    if (this.clients.size === 0) {\n      this.clearHeartbeatTimer()\n    }\n  }\n\n  publish(event: string, payload: unknown): void {\n    if (this.clients.size === 0) {\n      return\n    }\n\n    const data = JSON.stringify(payload)\n    const message = `event: ${event}\\ndata: ${data}\\n\\n`\n\n    for (const client of this.clients) {\n      client.write(message)\n    }\n  }\n\n  closeAll(): void {\n    for (const client of this.clients) {\n      client.end()\n    }\n    this.clients.clear()\n    this.clearHeartbeatTimer()\n  }\n\n  private ensureHeartbeatTimer(): void {\n    if (this.heartbeatTimer) {\n      return\n    }\n\n    this.heartbeatTimer = setInterval(() => {\n      for (const client of this.clients) {\n        client.write(': heartbeat\\n\\n')\n      }\n    }, HEARTBEAT_INTERVAL_MS)\n  }\n\n  private clearHeartbeatTimer(): void {\n    if (!this.heartbeatTimer) {\n      return\n    }\n    clearInterval(this.heartbeatTimer)\n    this.heartbeatTimer = null\n  }\n}\n"
  },
  {
    "path": "apps/api/src/lib/web-settings-store.ts",
    "content": "import { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport { WebAppSettingsSchema } from '@vidbee/downloader-core'\n\nconst STORAGE_DIR = path.resolve(process.cwd(), '.data')\nconst STORAGE_FILE = path.join(STORAGE_DIR, 'web-settings.json')\n\nconst defaultWebSettings = WebAppSettingsSchema.parse({\n  downloadPath: '',\n  maxConcurrentDownloads: 5,\n  browserForCookies: 'none',\n  cookiesPath: '',\n  proxy: '',\n  configPath: '',\n  betaProgram: false,\n  language: 'en',\n  theme: 'system',\n  oneClickDownload: false,\n  oneClickDownloadType: 'video',\n  oneClickQuality: 'best',\n  closeToTray: true,\n  autoUpdate: true,\n  subscriptionOnlyLatestDefault: true,\n  enableAnalytics: true,\n  embedSubs: true,\n  embedThumbnail: false,\n  embedMetadata: true,\n  embedChapters: true,\n  shareWatermark: false\n})\n\ntype WebAppSettings = typeof defaultWebSettings\n\nclass WebSettingsStore {\n  private settings = defaultWebSettings\n  private initialized = false\n\n  private async ensureInitialized(): Promise<void> {\n    if (this.initialized) {\n      return\n    }\n\n    this.initialized = true\n\n    try {\n      const raw = await readFile(STORAGE_FILE, 'utf-8')\n      const parsed = JSON.parse(raw)\n      const result = WebAppSettingsSchema.safeParse(parsed)\n      if (result.success) {\n        this.settings = result.data\n      }\n    } catch {\n      this.settings = defaultWebSettings\n    }\n  }\n\n  async get(): Promise<WebAppSettings> {\n    await this.ensureInitialized()\n    return this.settings\n  }\n\n  async set(nextSettings: WebAppSettings): Promise<WebAppSettings> {\n    await this.ensureInitialized()\n    const validated = WebAppSettingsSchema.parse(nextSettings)\n    await mkdir(STORAGE_DIR, { recursive: true })\n    await writeFile(STORAGE_FILE, JSON.stringify(validated), 'utf-8')\n    this.settings = validated\n    return this.settings\n  }\n}\n\nexport const webSettingsStore = new WebSettingsStore()\n"
  },
  {
    "path": "apps/api/src/server.ts",
    "content": "import { lookup } from 'node:dns/promises'\nimport type { ServerResponse } from 'node:http'\nimport net from 'node:net'\nimport cors from '@fastify/cors'\nimport { OpenAPIHandler } from '@orpc/openapi/fastify'\nimport { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'\nimport { RPCHandler } from '@orpc/server/fastify'\nimport { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'\nimport type { DownloadTask } from '@vidbee/downloader-core'\nimport Fastify from 'fastify'\nimport { downloaderCore } from './lib/downloader'\nimport { rpcRouter } from './lib/rpc-router'\nimport { SseHub } from './lib/sse'\n\nconst MAX_PROXY_IMAGE_BYTES = 10 * 1024 * 1024\nconst MAX_PROXY_REDIRECTS = 5\n\nconst isPrivateIpv4 = (ip: string): boolean => {\n  const octets = ip.split('.').map((value) => Number.parseInt(value, 10))\n  if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) {\n    return false\n  }\n\n  const [a, b] = octets\n  if (a === 10) {\n    return true\n  }\n  if (a === 127) {\n    return true\n  }\n  if (a === 169 && b === 254) {\n    return true\n  }\n  if (a === 172 && b >= 16 && b <= 31) {\n    return true\n  }\n  if (a === 192 && b === 168) {\n    return true\n  }\n  return false\n}\n\nconst isPrivateIpv6 = (ip: string): boolean => {\n  const normalized = ip.toLowerCase()\n  if (normalized === '::1') {\n    return true\n  }\n  if (normalized.startsWith('fc') || normalized.startsWith('fd')) {\n    return true\n  }\n  if (normalized.startsWith('fe80:')) {\n    return true\n  }\n  return false\n}\n\nconst isBlockedHost = async (url: URL): Promise<boolean> => {\n  const hostname = url.hostname.trim().toLowerCase()\n  if (!hostname) {\n    return true\n  }\n\n  if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '0.0.0.0') {\n    return true\n  }\n\n  if (net.isIP(hostname) === 4) {\n    return isPrivateIpv4(hostname)\n  }\n  if (net.isIP(hostname) === 6) {\n    return isPrivateIpv6(hostname)\n  }\n\n  try {\n    const records = await lookup(hostname, { all: true, verbatim: true })\n    if (records.length === 0) {\n      return true\n    }\n    for (const record of records) {\n      if (record.family === 4 && isPrivateIpv4(record.address)) {\n        return true\n      }\n      if (record.family === 6 && isPrivateIpv6(record.address)) {\n        return true\n      }\n    }\n    return false\n  } catch {\n    return true\n  }\n}\n\nconst parseRemoteImageUrl = (value: string): URL | null => {\n  try {\n    const parsed = new URL(value)\n    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n      return null\n    }\n\n    return parsed\n  } catch {\n    return null\n  }\n}\n\nexport const createApiServer = async () => {\n  await downloaderCore.initialize()\n  const isDev = process.env.NODE_ENV !== 'production'\n\n  const fastify = Fastify({\n    logger: true,\n    disableRequestLogging: isDev\n  })\n\n  await fastify.register(cors, {\n    origin: true,\n    methods: ['GET', 'POST', 'OPTIONS']\n  })\n\n  const rpcHandler = new RPCHandler(rpcRouter)\n  const openApiHandler = new OpenAPIHandler(rpcRouter, {\n    plugins: [\n      new OpenAPIReferencePlugin({\n        schemaConverters: [new ZodToJsonSchemaConverter()],\n        docsProvider: 'swagger',\n        docsPath: '/docs',\n        specPath: '/openapi.json',\n        docsTitle: 'VidBee API Reference',\n        specGenerateOptions: {\n          info: {\n            title: 'VidBee API',\n            version: '1.0.0'\n          },\n          servers: [{ url: '/openapi' }]\n        }\n      })\n    ]\n  })\n\n  const sseHub = new SseHub()\n\n  downloaderCore.on('task-updated', (task: DownloadTask) => {\n    sseHub.publish('task-updated', { task })\n  })\n  downloaderCore.on('queue-updated', (downloads: DownloadTask[]) => {\n    sseHub.publish('queue-updated', { downloads })\n  })\n\n  fastify.get('/health', async () => {\n    return { ok: true }\n  })\n\n  fastify.get<{ Querystring: { url?: string } }>('/images/proxy', async (request, reply) => {\n    const sourceUrl = request.query.url?.trim()\n    if (!sourceUrl) {\n      return reply.code(400).send({ message: 'Missing url query parameter.' })\n    }\n\n    const parsedUrl = parseRemoteImageUrl(sourceUrl)\n    if (!parsedUrl) {\n      return reply.code(400).send({ message: 'Invalid remote image URL.' })\n    }\n\n    let response: Response | null = null\n    let currentUrl = parsedUrl\n\n    for (let redirectCount = 0; redirectCount <= MAX_PROXY_REDIRECTS; redirectCount++) {\n      if (await isBlockedHost(currentUrl)) {\n        return reply.code(400).send({ message: 'Remote host is not allowed.' })\n      }\n\n      try {\n        response = await fetch(currentUrl.toString(), {\n          signal: AbortSignal.timeout(15_000),\n          redirect: 'manual'\n        })\n      } catch {\n        return reply.code(502).send({ message: 'Failed to fetch remote image.' })\n      }\n\n      const locationHeader = response.headers.get('location')\n      const isRedirect =\n        response.status >= 300 &&\n        response.status < 400 &&\n        typeof locationHeader === 'string' &&\n        locationHeader.length > 0\n      if (!isRedirect) {\n        break\n      }\n\n      currentUrl = new URL(locationHeader, currentUrl)\n      response.body?.cancel()\n    }\n\n    if (!response) {\n      return reply.code(502).send({ message: 'Failed to fetch remote image.' })\n    }\n\n    if (!response.ok) {\n      return reply.code(502).send({\n        message: `Remote image request failed with status ${response.status}.`\n      })\n    }\n\n    const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''\n    if (!contentType.startsWith('image/')) {\n      return reply.code(415).send({ message: 'Remote resource is not an image.' })\n    }\n\n    const contentLengthHeader = response.headers.get('content-length')\n    if (contentLengthHeader) {\n      const declaredSize = Number.parseInt(contentLengthHeader, 10)\n      if (Number.isFinite(declaredSize) && declaredSize > MAX_PROXY_IMAGE_BYTES) {\n        return reply.code(413).send({ message: 'Remote image is too large.' })\n      }\n    }\n\n    if (!response.body) {\n      return reply.code(502).send({ message: 'Remote image response body is empty.' })\n    }\n\n    const reader = response.body.getReader()\n    const chunks: Buffer[] = []\n    let totalBytes = 0\n\n    while (true) {\n      const { done, value } = await reader.read()\n      if (done) {\n        break\n      }\n\n      if (!value) {\n        continue\n      }\n\n      totalBytes += value.byteLength\n      if (totalBytes > MAX_PROXY_IMAGE_BYTES) {\n        await reader.cancel()\n        return reply.code(413).send({ message: 'Remote image is too large.' })\n      }\n\n      chunks.push(Buffer.from(value))\n    }\n\n    const imageBuffer = Buffer.concat(chunks, totalBytes)\n    const cacheControl = response.headers.get('cache-control')\n    const etag = response.headers.get('etag')\n    const lastModified = response.headers.get('last-modified')\n\n    reply.header('Content-Type', contentType)\n    reply.header('Content-Length', imageBuffer.length.toString())\n    reply.header('Cache-Control', cacheControl ?? 'public, max-age=3600')\n    if (etag) {\n      reply.header('ETag', etag)\n    }\n    if (lastModified) {\n      reply.header('Last-Modified', lastModified)\n    }\n\n    return reply.send(imageBuffer)\n  })\n\n  fastify.get('/events', async (request, reply) => {\n    const requestOrigin = request.headers.origin?.trim()\n    const responseHeaders: Record<string, string> = {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      Connection: 'keep-alive',\n      'X-Accel-Buffering': 'no',\n      'Access-Control-Allow-Origin': requestOrigin || '*'\n    }\n\n    if (requestOrigin) {\n      responseHeaders.Vary = 'Origin'\n    }\n\n    reply.hijack()\n    reply.raw.writeHead(200, responseHeaders)\n\n    const response = reply.raw as ServerResponse\n    sseHub.addClient(response)\n\n    request.raw.on('close', () => {\n      sseHub.removeClient(response)\n    })\n  })\n\n  fastify.all('/rpc/*', async (request, reply) => {\n    await rpcHandler.handle(request, reply, {\n      prefix: '/rpc'\n    })\n  })\n\n  fastify.all('/docs', async (request, reply) => {\n    await openApiHandler.handle(request, reply, {\n      prefix: '/'\n    })\n  })\n\n  fastify.all('/openapi.json', async (request, reply) => {\n    await openApiHandler.handle(request, reply, {\n      prefix: '/'\n    })\n  })\n\n  fastify.addHook('onClose', async () => {\n    sseHub.closeAll()\n  })\n\n  return fastify\n}\n"
  },
  {
    "path": "apps/api/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "apps/desktop/build/after-pack.cjs",
    "content": "const { execFileSync } = require('node:child_process')\nconst fs = require('node:fs')\nconst path = require('node:path')\n\nconst BINARIES = [\n  'yt-dlp_macos',\n  path.join('ffmpeg', 'ffmpeg'),\n  path.join('ffmpeg', 'ffprobe'),\n  'deno'\n]\n\nconst findAppBundle = (appOutDir) => {\n  const entries = fs.readdirSync(appOutDir)\n  const app = entries.find((entry) => entry.endsWith('.app'))\n  return app ? path.join(appOutDir, app) : null\n}\n\nconst resolveSigningIdentity = () =>\n  process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'\n\nconst signBinary = (targetPath, entitlementsPath) => {\n  const identity = resolveSigningIdentity()\n  const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]\n\n  if (identity !== '-') {\n    args.push('--options', 'runtime', '--timestamp')\n  }\n\n  args.push(targetPath)\n  execFileSync('codesign', args, { stdio: 'inherit' })\n}\n\nexports.default = async function afterPack(context) {\n  if (context.electronPlatformName !== 'darwin') {\n    return\n  }\n\n  const appBundle = findAppBundle(context.appOutDir)\n  if (!appBundle) {\n    console.warn('afterPack: No .app bundle found, skipping tool signing.')\n    return\n  }\n\n  const resourcesPath = path.join(\n    appBundle,\n    'Contents',\n    'Resources',\n    'app.asar.unpacked',\n    'resources'\n  )\n\n  const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')\n\n  for (const binary of BINARIES) {\n    const targetPath = path.join(resourcesPath, binary)\n    if (!fs.existsSync(targetPath)) {\n      console.warn(`afterPack: Missing ${binary}, skipping.`)\n      continue\n    }\n    console.log(`afterPack: Signing ${binary} with entitlements.`)\n    signBinary(targetPath, entitlementsPath)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/build/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n    <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n    <true/>\n    <key>com.apple.security.cs.disable-library-validation</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "apps/desktop/changelogs/CHANGELOG.fr.md",
    "content": "# Journal des modifications de VidBee\n\nCette page ne présente que les évolutions visibles par les utilisateurs, sans détails techniques.\nPour les notes de version complètes, consultez [GitHub Releases](https://github.com/nexmoe/VidBee/releases).\n\n## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14\n### Corrections de bugs\n- Utilisation de la source Electron par defaut pendant le packaging afin d'ameliorer la fiabilite des builds macOS de release.\n\n## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14\n### Mises a jour de fonctionnalites\n- Amelioration du flux de publication afin de diffuser les builds preview separement des notifications de mise a jour en production.\n\n### Corrections de bugs\n- Reactivation de npm rebuild pendant le packaging Electron afin de preparer plus fiablement les dependances natives dans les builds de release.\n- Amelioration du bundling desktop pour inclure plus regulierement les dependances partagees du workspace dans les versions publiees.\n\n## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14\n### Corrections de bugs\n- Reactivation de npm rebuild pendant le packaging Electron pour preparer plus fiablement les dependances natives dans les builds de release.\n\n## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14\n### Mises a jour de fonctionnalites\n- Ajout d'un canal de publication preview pour diffuser les builds de test sans declencher les mises a jour du site de production.\n\n### Corrections de bugs\n- Amelioration du bundling desktop afin d'inclure plus regulierement les dependances partagees du workspace dans les builds publies.\n\n## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14\n### Corrections de bugs\n- Amelioration de la fiabilite du packaging desktop afin d'inclure plus regulierement les composants de telechargement partages.\n\n## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14\n### Mises a jour de fonctionnalites\n- Ajout des editions Web et API, avec des capacites de telechargement partagees et un comportement des reglages harmonise.\n- Ajout de la prise en charge de l'envoi de fichiers Cookie et de configuration depuis les reglages.\n- Migration de l'historique des telechargements vers SQLite pour une meilleure fiabilite et une meilleure coherence multi-plateforme.\n- 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.\n\n### Corrections de bugs\n- Amelioration de la robustesse et des diagnostics du processus d'initialisation des binaires embarques sur desktop.\n- Correction du saut de curseur dans le champ de profil des reglages.\n- Correction de la validation du dossier de telechargement sous Linux lors de la selection d'un dossier existant non vide.\n- Amelioration de la coherence des localisations, y compris des corrections de traduction chinoise.\n\n## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15\n### Mises a jour de fonctionnalites\n- Ajout de nouvelles actions en un clic pour coller un lien et lancer le téléchargement plus vite.\n- Ajout de la prise en charge des langues française, russe et turque dans l'application.\n- Le format de conteneur sélectionné est désormais respecté de manière plus cohérente.\n\n### Corrections de bugs\n- Amélioration de la compatibilité des téléchargements pour YouTube et les scénarios de repli de format.\n- Les réglages et la documentation ont été améliorés, avec des indications plus claires pour les rapports de bug et le RSS.\n\n## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24\n### Mises a jour de fonctionnalites\n- Le flux de téléchargement en un clic est plus direct et demande moins d'étapes.\n- Un onglet Cookie dédié a été ajouté dans les réglages pour simplifier les actions liées au compte.\n- Les points d'entrée FAQ sont plus clairs et les messages d'erreur sont plus faciles à comprendre.\n\n### Corrections de bugs\n- Les indications RSS sont plus claires, surtout pour les nouveaux utilisateurs.\n\n## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23\n### Mises a jour de fonctionnalites\n- Le chargement des playlists est plus stable et ne compresse plus l'interface.\n- Le guide d'utilisation des cookies inclut désormais des exemples plus clairs.\n\n## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21\n### Mises a jour de fonctionnalites\n- Les actions liées au téléchargement sont plus faciles à trouver.\n- Ajout d'une option pour inclure ou retirer le filigrane lors du partage.\n- Les interactions de téléchargement sont plus cohérentes dans l'ensemble.\n\n## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20\n### Mises a jour de fonctionnalites\n- Les éléments avec le même titre dans les playlists sont plus faciles à distinguer.\n- Il est plus simple de trouver les journaux et les fichiers liés lors du dépannage.\n\n### Corrections de bugs\n- Les notifications de téléchargement sont moins intrusives.\n- Les liens et indications des abonnements sont plus fiables.\n\n## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17\n### Mises a jour de fonctionnalites\n- Ajout d'actions rapides pour tout sélectionner et vider l'historique des téléchargements.\n- Le comportement lors de la réduction et de la réouverture est plus fluide.\n- Les doublons dans les abonnements sont réduits.\n- Les pages Playlist et Réglages sont plus simples à utiliser.\n\n### Corrections de bugs\n- La reprise après une interruption de téléchargement est plus fiable.\n\n## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15\n### Mises a jour de fonctionnalites\n- Le comportement du dossier de téléchargement dans les réglages est plus prévisible.\n\n### Corrections de bugs\n- Les rapports de retour contiennent désormais des informations d'appui plus claires.\n\n## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14\n### Mises a jour de fonctionnalites\n- Les flux de téléchargement et la mise en page sont plus clairs.\n- La navigation des abonnements est plus fluide.\n- Les réglages par défaut sont plus adaptés à un usage quotidien.\n\n### Corrections de bugs\n- Les messages d'erreur proposent des étapes suivantes plus claires.\n\n## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12\n### Mises a jour de fonctionnalites\n- L'installation et la mise à jour sur macOS sont plus stables.\n\n## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12\n### Mises a jour de fonctionnalites\n- Les détails de progression des téléchargements sont plus lisibles.\n\n### Corrections de bugs\n- Les notifications de mise à jour localisées sont plus claires.\n\n## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11\n### Mises a jour de fonctionnalites\n- Davantage d'options de préférences de sortie média ont été ajoutées.\n- La configuration initiale et l'utilisation quotidienne sont plus fluides.\n\n## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11\n### Mises a jour de fonctionnalites\n- Les flux liés aux informations vidéo locales sont plus faciles à utiliser.\n- La gestion des profils de cookies est plus stable et plus prévisible.\n\n## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10\n### Mises a jour de fonctionnalites\n- Correction de problèmes connus dans les réglages avancés.\n\n### Corrections de bugs\n- Amélioration de la stabilité du chargement des couvertures distantes.\n- La sélection des couvertures d'abonnement est plus fiable.\n\n## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09\n### Mises a jour de fonctionnalites\n- Le comportement de la fenêtre au démarrage est plus naturel.\n- Le comportement global des réglages est plus cohérent.\n\n## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02\n### Mises a jour de fonctionnalites\n- Le statut de mise à jour est plus visible sur la page About.\n\n### Corrections de bugs\n- La sélection de format est plus fiable selon les scénarios.\n\n## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26\n### Mises a jour de fonctionnalites\n- La disponibilité du téléchargement a été rétablie pour davantage de sites.\n- Le flux de signalement des problèmes est plus simple.\n\n## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26\n### Mises a jour de fonctionnalites\n- Les notifications de mise à jour sont moins perturbantes.\n- Les textes et liens de la page About sont plus clairs.\n- Les interactions du panneau de téléchargement sont plus fluides.\n\n## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20\n### Mises a jour de fonctionnalites\n- Ajout d'actions groupées pour nettoyer l'historique des téléchargements.\n- L'ouverture des liens de tâche de téléchargement est plus prévisible.\n- Ajout de la prise en charge des dossiers de téléchargement personnalisés.\n- La boîte de dialogue de configuration RSS est plus simple à comprendre et à remplir.\n\n## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06\n### Mises a jour de fonctionnalites\n- La saisie des chemins est plus tolérante au quotidien.\n\n### Corrections de bugs\n- Ajout de plus d'options de compatibilité pour davantage de scénarios d'usage.\n\n## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16\n### Mises a jour de fonctionnalites\n- Ajout de la prise en charge du lancement automatique.\n- La prise en charge des langues a été encore élargie.\n\n## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15\n### Mises a jour de fonctionnalites\n- Première version majeure stable de VidBee.\n- Ajout des téléchargements via abonnements RSS.\n- La navigation et le flux général de l'interface sont plus clairs.\n- L'historique et l'aperçu des médias ont été améliorés.\n\n## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08\n### Mises a jour de fonctionnalites\n- Les textes et messages du téléchargement en un clic sont plus faciles à comprendre.\n- Le style visuel est plus cohérent.\n\n## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03\n### Mises a jour de fonctionnalites\n- Les messages de mise à jour et l'affichage des options de téléchargement sont plus clairs.\n\n## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02\n### Corrections de bugs\n- La stabilité du traitement des téléchargements a été améliorée dans davantage de scénarios.\n\n## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31\n### Mises a jour de fonctionnalites\n- L'expérience de distribution multi-appareils a été améliorée.\n\n## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30\n### Mises a jour de fonctionnalites\n- L'expérience Linux est plus conviviale.\n- Ajout de notifications de nouvelles versions pour des mises à niveau plus rapides.\n\n## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29\n### Mises a jour de fonctionnalites\n- Ajout de la prise en charge du téléchargement de playlists.\n- Ajout de contrôles pour réduire les perturbations sur le bureau.\n\n## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27\n### Mises a jour de fonctionnalites\n- Poursuite du peaufinage UX pendant la phase de préversion.\n\n## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26\n### Mises a jour de fonctionnalites\n- Poursuite du peaufinage UX pendant la phase de préversion.\n\n## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25\n### Mises a jour de fonctionnalites\n- Poursuite du peaufinage UX pendant la phase de préversion.\n\n## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24\n### Mises a jour de fonctionnalites\n- Le programme de préversion publique a démarré.\n\n## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24\n### Mises a jour de fonctionnalites\n- Ajout de la prise en charge de la mise a jour automatique et amélioration des consignes de publication dans la documentation.\n- Amélioration de la documentation du projet, y compris les captures d'écran et le guide de contribution.\n\n### Corrections de bugs\n- Simplification de la gestion des chemins de téléchargement et suppression de la logique de chemin de sortie inutilisée.\n\n## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23\n### Corrections de bugs\n- Suppression d'une étape inutile de création de dossier dans le workflow de publication.\n\n## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23\n### Corrections de bugs\n- Amélioration du workflow de publication pour télécharger les binaires yt-dlp lors du packaging multiplateforme.\n\n## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23\n### Corrections de bugs\n- Mise à jour du workflow de publication pour cibler uniquement les builds Windows.\n\n## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23\n### Corrections de bugs\n- Simplification des étapes de build de publication et de la gestion des artefacts dans CI.\n- Ajustement des déclencheurs CI: l'automatisation des pull requests ne s'exécute que pour `main`.\n\n## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23\n### Corrections de bugs\n- Définition explicite du shell pour l'étape de build dans le workflow de publication.\n\n## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23\n### Mises a jour de fonctionnalites\n- Itération de release précoce sans changement utilisateur supplémentaire documenté.\n\n## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23\n### Mises a jour de fonctionnalites\n- Point de départ de la première release publique.\n"
  },
  {
    "path": "apps/desktop/changelogs/CHANGELOG.md",
    "content": "# VidBee Changelog\n\nThis page only includes user-visible updates and avoids implementation details.\nFor full release notes, see [GitHub Releases](https://github.com/nexmoe/VidBee/releases).\n\n## [v1.3.5](https://github.com/nexmoe/VidBee/releases/tag/v1.3.5) - 2026-03-18\n### Requirement Updates\n- Updated the bundled yt-dlp runtime from v2026.03.13 to v2026.03.17 so site compatibility stays current.\n## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14\n### Bug Fixes\n- Improved macOS release build reliability by using the default Electron download source during packaging.\n\n## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14\n### Requirement Updates\n- Improved the release pipeline so preview builds can be published separately from production update notifications.\n\n### Bug Fixes\n- Restored npm rebuilds during Electron packaging so native dependencies are prepared more reliably in release builds.\n- Bundled shared workspace packages more consistently in desktop builds.\n\n## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14\n### Bug Fixes\n- Restored npm rebuilds during Electron packaging so native dependencies are prepared more reliably in release builds.\n\n## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14\n### Requirement Updates\n- Added a preview release channel so test builds can be published without triggering production site updates.\n\n### Bug Fixes\n- Bundled shared workspace packages more consistently in desktop builds.\n\n## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14\n### Bug Fixes\n- Improved desktop packaging reliability so shared downloader components are bundled more consistently.\n\n## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14\n### Requirement Updates\n- Added web and API editions with shared downloader capabilities and aligned settings behavior.\n- Added support for uploading Cookie and config files from Settings.\n- Migrated download history storage to SQLite for better reliability and cross-platform consistency.\n- Added a shared add-url popover flow in download dialogs and refined dark-theme thumb visibility.\n\n### Bug Fixes\n- Improved bundled binary setup resilience and diagnostics in desktop startup scripts.\n- Fixed profile input cursor jumping in Settings.\n- Fixed Linux download-directory validation when selecting existing non-empty folders.\n- Polished localization consistency, including Chinese translation corrections.\n\n## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15\n### Requirement Updates\n- Added new one-click actions so you can paste and start downloading faster.\n- Added French, Russian, and Turkish app localization support.\n- Completed tray localization for Turkish.\n- Reorganized Cookie settings into three clearer groups.\n- Added RSSHub portal links to the RSS documentation.\n\n### Bug Fixes\n- Improved download compatibility for YouTube and format fallback scenarios.\n- Download output now follows the selected container format more consistently.\n- Settings and docs were improved, including clearer bug report and RSS guidance.\n- Added fallback handling when requested download formats are unavailable.\n- Synced missing Turkish locale keys.\n- Reduced release pipeline friction with check and workflow fixes.\n\n## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24\n### Requirement Updates\n- The one-click download flow is now more direct with fewer steps.\n- A dedicated Cookie tab was added in Settings for easier account-related actions.\n\n### Bug Fixes\n- FAQ entry points are clearer and error messages are easier to understand.\n- RSS guidance is clearer, especially for new users.\n\n## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23\n### Bug Fixes\n- Playlist loading is more stable and no longer causes layout compression issues.\n- Cookie usage guidance now includes clearer examples.\n\n## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21\n### Requirement Updates\n- Download-related actions are easier to access.\n- Added an option to include or remove watermarks when sharing.\n\n### Bug Fixes\n- Download interactions are more consistent overall.\n\n## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20\n### Requirement Updates\n- Items with the same title in playlists are easier to distinguish.\n- It is easier to find logs and related files when troubleshooting.\n- Added docs site improvements, including i18n support, sitemap generation, and protocol documentation.\n\n### Bug Fixes\n- Download notifications are less intrusive.\n- Subscription links and guidance are more reliable.\n- Resolved TypeScript build issues in the documentation project.\n\n## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17\n### Requirement Updates\n- Added faster ways to select all and clear download history.\n- Playlist and Settings pages are easier to use.\n- Minimize-to-tray behavior is now the default.\n- Feedback reporting now warns when GitHub issue links are too long.\n\n### Bug Fixes\n- Minimize and reopen behavior feels smoother.\n- Duplicate items in subscriptions are reduced.\n- Resume behavior after interrupted downloads is more reliable.\n- Playlist list height is constrained more reliably.\n- Feedback issue links are cleaner and more stable.\n- Added clearer Windows-only Cookie guidance.\n- Strengthened ffmpeg/ffprobe bundle checks.\n\n## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15\n### Bug Fixes\n- Download folder behavior in Settings is more predictable.\n- Feedback reports now have clearer supporting information.\n\n## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14\n### Requirement Updates\n- Default settings are better for everyday use.\n- Extension error pages and branding were refined.\n- Download error panels now include richer troubleshooting links.\n- Subscription tabs support horizontal scrolling.\n\n### Bug Fixes\n- Download flows and page layouts are clearer.\n- Subscription browsing feels smoother.\n- Error messages now provide clearer next-step suggestions.\n- Added safeguards for Bilibili subtitle embedding failures.\n- Missing locale translation entries were filled.\n- Embedded thumbnail behavior is safer by default.\n\n## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12\n### Bug Fixes\n- Installation and update experience on macOS is more stable.\n- Bundled tools are now signed during macOS builds.\n- DMG notarization was tightened in CI for better release reliability.\n\n## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12\n### Requirement Updates\n- Download progress details are easier to read.\n\n### Bug Fixes\n- Localized update notifications are clearer.\n\n## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11\n### Requirement Updates\n- Added more media output preference options.\n\n### Bug Fixes\n- First-time setup and daily usage flow are smoother.\n\n## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11\n### Requirement Updates\n- Local video information workflows are easier to use.\n\n### Bug Fixes\n- Cookie profile management is more stable and predictable.\n\n## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10\n### Bug Fixes\n- Fixed known issues in Advanced Settings.\n- Improved remote cover loading stability.\n- Subscription cover selection is more reliable.\n\n## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09\n### Requirement Updates\n- Startup window behavior feels more natural.\n\n### Bug Fixes\n- Settings behavior is more consistent overall.\n\n## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02\n### Requirement Updates\n- Update status is easier to spot on the About page.\n\n### Bug Fixes\n- Format selection is more reliable across scenarios.\n\n## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26\n### Requirement Updates\n- Issue reporting flow is simpler.\n- Added issue templates and streamlined bug report forms.\n\n### Bug Fixes\n- Restored download availability for more sites.\n- CI download steps were hardened for better reliability.\n\n## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26\n### Requirement Updates\n- Update notifications are less disruptive.\n- Added JavaScript runtime support for yt-dlp integration.\n- Advanced options panels now use smoother animations.\n\n### Bug Fixes\n- About page text and links are clearer.\n- Download panel interactions feel smoother.\n- Electron language resources were limited to English for consistency.\n\n## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20\n### Requirement Updates\n- Added bulk actions for download history cleanup.\n- Opening download task links now behaves more predictably.\n- Added support for custom download folders.\n\n### Bug Fixes\n- RSS setup dialog is easier to understand and fill.\n\n## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06\n### Bug Fixes\n- Added more compatibility options for wider usage scenarios.\n- Path input is more forgiving in daily usage.\n\n## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16\n### Requirement Updates\n- Added auto-launch support.\n- Language support was further expanded.\n\n## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15\n### Requirement Updates\n- Added RSS subscription downloads.\n- Added site icons in supported source lists.\n- Introduced remote image loading with caching for media previews.\n- Sidebar interactions were refined with draggable title-bar behavior.\n\n### Bug Fixes\n- First major stable release of VidBee.\n- Navigation and overall interface flow became clearer.\n- History and media preview experience were improved.\n- Migrated history storage to SQLite (Drizzle) for more reliable data handling.\n\n## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08\n### Bug Fixes\n- One-click download copy and feedback prompts are easier to understand.\n- Visual style is more consistent.\n\n## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03\n### Bug Fixes\n- Update prompts and download option display are clearer.\n\n## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02\n### Bug Fixes\n- Download processing stability improved in more scenarios.\n\n## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31\n### Bug Fixes\n- Multi-device distribution experience was improved.\n\n## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30\n### Requirement Updates\n- Linux experience is more user-friendly.\n- Added version update notifications for faster upgrades.\n\n## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29\n### Requirement Updates\n- Added playlist download support.\n- Added controls to reduce desktop disruption.\n\n## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27\n### Bug Fixes\n- Continued UX polishing during the preview stage.\n\n## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26\n### Bug Fixes\n- Continued UX polishing during the preview stage.\n\n## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25\n### Bug Fixes\n- Continued UX polishing during the preview stage.\n\n## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24\n### Requirement Updates\n- Public preview period started.\n\n## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24\n### Requirement Updates\n- Added auto-updater support and improved release guidance in documentation.\n- Improved project documentation, including screenshots and contribution guidelines.\n\n### Bug Fixes\n- Simplified download path handling and removed unused output path logic.\n\n## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23\n### Bug Fixes\n- Removed an unnecessary directory creation step in the release workflow.\n\n## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23\n### Bug Fixes\n- Improved the release workflow to download yt-dlp binaries for cross-platform packaging.\n\n## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23\n### Bug Fixes\n- Updated the release workflow to target Windows builds only.\n\n## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23\n### Bug Fixes\n- Simplified release build steps and artifact handling in CI.\n- Adjusted CI triggers so pull-request automation runs only for `main`.\n\n## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23\n### Bug Fixes\n- Set an explicit shell for the build step in the release workflow.\n\n## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23\n### Requirement Updates\n- Early release iteration with no additional user-visible changes recorded.\n\n## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23\n### Requirement Updates\n- Initial public release baseline.\n"
  },
  {
    "path": "apps/desktop/changelogs/CHANGELOG.ru.md",
    "content": "# Журнал изменений VidBee\n\nНа этой странице указаны только заметные для пользователей изменения, без технических деталей.\nПолные заметки к релизам доступны в [GitHub Releases](https://github.com/nexmoe/VidBee/releases).\n\n## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14\n### Исправления ошибок\n- Для упаковки снова используется стандартный источник Electron, что повышает надежность macOS release-сборок.\n\n## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14\n### Обновления функций\n- Улучшен процесс публикации релизов: preview-сборки теперь публикуются отдельно от уведомлений об обновлениях для production.\n\n### Исправления ошибок\n- Возвращена сборка npm rebuild при упаковке Electron, чтобы нативные зависимости надежнее подготавливались в релизных сборках.\n- Улучшено включение общих workspace-пакетов в desktop-сборки для более стабильных релизных артефактов.\n\n## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14\n### Исправления ошибок\n- Возвращена сборка npm rebuild при упаковке Electron, чтобы нативные зависимости надежнее подготавливались в релизных сборках.\n\n## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14\n### Обновления функций\n- Добавлен preview-канал релизов, чтобы тестовые сборки публиковались отдельно и не запускали обновление production-сайта.\n\n### Исправления ошибок\n- Улучшено включение общих workspace-пакетов в desktop-сборки для более стабильных релизных артефактов.\n\n## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14\n### Исправления ошибок\n- Повышена надежность desktop-пакетирования, чтобы общие компоненты загрузки стабильнее попадали в релизные сборки.\n\n## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14\n### Обновления функций\n- Добавлены веб- и API-редакции с общими возможностями загрузки и согласованным поведением настроек.\n- Добавлена поддержка загрузки файлов Cookie и конфигурации из страницы настроек.\n- История загрузок перенесена в SQLite для более высокой надежности и лучшей кроссплатформенной согласованности.\n- Добавлен общий сценарий добавления URL в диалогах загрузки и улучшена видимость ползунка в темной теме.\n\n### Исправления ошибок\n- Повышена устойчивость и информативность диагностики при инициализации встроенных бинарных файлов в desktop.\n- Исправлено прыгание курсора в поле профиля на странице настроек.\n- Исправлена проверка каталога загрузки в Linux при выборе существующей непустой папки.\n- Улучшена согласованность локализации, включая исправления китайских переводов.\n\n## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15\n### Обновления функций\n- Добавлены новые действия в один клик: вставить ссылку и быстрее начать загрузку.\n- Добавлена локализация приложения на французский, русский и турецкий языки.\n- Выбранный формат контейнера теперь соблюдается более последовательно.\n\n### Исправления ошибок\n- Улучшена совместимость загрузок для YouTube и сценариев с резервным выбором формата.\n- Улучшены настройки и документация, включая более понятные подсказки для отчётов об ошибках и RSS.\n\n## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24\n### Обновления функций\n- Сценарий загрузки в один клик стал прямее и требует меньше шагов.\n- В настройках появилась отдельная вкладка Cookie для более удобных действий с аккаунтами.\n- Подсказки по RSS стали яснее, особенно для новых пользователей.\n\n### Исправления ошибок\n- Раздел FAQ стал заметнее, а сообщения об ошибках понятнее.\n\n## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23\n### Исправления ошибок\n- Загрузка плейлистов стала стабильнее и больше не сжимает интерфейс.\n- Инструкции по Cookie теперь содержат более понятные примеры.\n\n## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21\n### Обновления функций\n- Действия, связанные со скачиванием, стало проще найти.\n- Добавлена возможность включать или отключать водяной знак при публикации.\n- Взаимодействия при скачивании стали более единообразными.\n\n## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20\n### Обновления функций\n- Уведомления во время загрузок стали менее навязчивыми.\n- Элементы с одинаковыми названиями в плейлистах теперь проще различать.\n\n### Исправления ошибок\n- При диагностике проблем стало легче находить логи и связанные файлы.\n- Ссылки и подсказки для подписок стали надежнее.\n\n## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17\n### Обновления функций\n- Добавлены быстрые действия для выбора всего и очистки истории загрузок.\n- Поведение при сворачивании и повторном открытии стало плавнее.\n- Количество дубликатов в подписках уменьшено.\n- Страницы плейлистов и настроек стали удобнее.\n\n### Исправления ошибок\n- Возобновление после прерванной загрузки работает стабильнее.\n\n## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15\n### Исправления ошибок\n- Поведение папки загрузок в настройках стало более предсказуемым.\n- В отчетах об ошибках появилась более полезная сопроводительная информация.\n\n## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14\n### Обновления функций\n- Просмотр подписок ощущается более плавным.\n- Настройки по умолчанию лучше подходят для повседневного использования.\n\n### Исправления ошибок\n- Сценарии загрузки и структура страниц стали понятнее.\n- Ошибки теперь сопровождаются более понятными подсказками о следующих шагах.\n\n## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12\n### Исправления ошибок\n- Установка и обновление на macOS стали стабильнее.\n\n## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12\n### Обновления функций\n- Детали прогресса загрузки стало проще читать.\n\n### Исправления ошибок\n- Локализованные уведомления об обновлениях стали понятнее.\n\n## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11\n### Обновления функций\n- Добавлено больше настроек предпочтений для вывода медиа.\n- Первичная настройка и ежедневные сценарии использования стали плавнее.\n\n## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11\n### Обновления функций\n- Сценарии с локальной информацией о видео стали удобнее.\n\n### Исправления ошибок\n- Управление профилями Cookie стало стабильнее и предсказуемее.\n\n## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10\n### Исправления ошибок\n- Исправлены известные проблемы в расширенных настройках.\n- Улучшена стабильность загрузки удаленных обложек.\n- Выбор обложек для подписок стал надежнее.\n\n## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09\n### Обновления функций\n- Поведение окна при запуске стало более естественным.\n- Поведение настроек в целом стало более последовательным.\n\n## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02\n### Обновления функций\n- Статус обновлений на странице About теперь заметнее.\n\n### Исправления ошибок\n- Выбор форматов работает надежнее в разных сценариях.\n\n## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26\n### Исправления ошибок\n- Восстановлена возможность загрузки для большего числа сайтов.\n- Процесс отправки отчета об ошибке стал проще.\n\n## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26\n### Обновления функций\n- Уведомления об обновлениях стали менее отвлекающими.\n- Взаимодействия в панели загрузок стали более плавными.\n\n### Исправления ошибок\n- Тексты и ссылки на странице About стали понятнее.\n\n## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20\n### Обновления функций\n- Добавлены массовые действия для очистки истории загрузок.\n- Добавлена поддержка пользовательских папок загрузки.\n\n### Исправления ошибок\n- Открытие ссылок задач загрузки теперь ведет себя предсказуемее.\n- Диалог настройки RSS стал понятнее и проще для заполнения.\n\n## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06\n### Обновления функций\n- Ввод путей стал более гибким в ежедневном использовании.\n\n### Исправления ошибок\n- Добавлены дополнительные параметры совместимости для более широких сценариев использования.\n\n## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16\n### Обновления функций\n- Добавлена поддержка автозапуска.\n- Языковая поддержка была дополнительно расширена.\n\n## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15\n### Обновления функций\n- Добавлены загрузки по RSS-подпискам.\n\n### Исправления ошибок\n- Первый крупный стабильный релиз VidBee.\n- Навигация и общий поток интерфейса стали понятнее.\n- Улучшен опыт работы с историей и предпросмотром медиа.\n\n## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08\n### Обновления функций\n- Визуальный стиль стал более цельным.\n\n### Исправления ошибок\n- Тексты и подсказки в сценарии загрузки в один клик стали понятнее.\n\n## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03\n### Обновления функций\n- Подсказки об обновлениях и отображение вариантов загрузки стали яснее.\n\n## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02\n### Исправления ошибок\n- Стабильность обработки загрузок улучшена в большем числе сценариев.\n\n## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31\n### Исправления ошибок\n- Улучшен опыт установки и использования на разных устройствах.\n\n## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30\n### Обновления функций\n- Использование на Linux стало удобнее.\n- Добавлены уведомления о новых версиях для более быстрых обновлений.\n\n## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29\n### Обновления функций\n- Добавлена поддержка загрузки плейлистов.\n- Добавлены настройки для снижения отвлекающих факторов на рабочем столе.\n\n## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27\n### Обновления функций\n- Продолжена полировка пользовательского опыта на этапе предварительной версии.\n\n## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26\n### Обновления функций\n- Продолжена полировка пользовательского опыта на этапе предварительной версии.\n\n## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25\n### Обновления функций\n- Продолжена полировка пользовательского опыта на этапе предварительной версии.\n\n## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24\n### Обновления функций\n- Начался период публичного предварительного просмотра.\n\n## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24\n### Обновления функций\n- Добавлена поддержка автообновления и улучшены инструкции по релизам в документации.\n- Улучшена проектная документация, включая скриншоты и руководство для контрибьюторов.\n\n### Исправления ошибок\n- Упрощена обработка путей загрузки и удалена неиспользуемая логика выходного пути.\n\n## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23\n### Исправления ошибок\n- Удален лишний шаг создания директории в workflow публикации релиза.\n\n## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23\n### Исправления ошибок\n- Улучшен workflow релиза: добавена загрузка бинарников yt-dlp для кроссплатформенной сборки.\n\n## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23\n### Исправления ошибок\n- Обновлен workflow релиза: теперь собираются только Windows-версии.\n\n## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23\n### Исправления ошибок\n- Упрощены шаги сборки релиза и обработка артефактов в CI.\n- Скорректированы триггеры CI: автоматизация pull request запускается только для `main`.\n\n## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23\n### Исправления ошибок\n- Для шага сборки в workflow релиза явно указан shell для более стабильного выполнения.\n\n## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23\n### Обновления функций\n- Ранний итерационный релиз без дополнительных пользовательских изменений в заметках.\n\n## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23\n### Обновления функций\n- Начальная базовая версия публичного релиза.\n"
  },
  {
    "path": "apps/desktop/changelogs/CHANGELOG.zh.md",
    "content": "# VidBee 更新日志\n\n本页只记录你能直接感知到的更新，不展开技术实现细节。\n完整发布记录请查看 [GitHub Releases](https://github.com/nexmoe/VidBee/releases)。\n\n## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14\n### Bug 修复\n- 改用 Electron 默认下载源进行打包，提升 macOS 发布构建的稳定性。\n\n## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14\n### 需求更新\n- 优化了发布流程，preview 测试版现在可以独立于正式更新通知发布。\n\n### Bug 修复\n- 恢复 Electron 打包时的 npm rebuild，原生依赖在发布构建中的准备过程会更可靠。\n- 进一步改善桌面端构建打包，让共享工作区依赖在发布版本中更稳定地被正确包含。\n\n## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14\n### Bug 修复\n- 恢复 Electron 打包时的 npm rebuild，原生依赖在发布构建中的准备过程会更可靠。\n\n## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14\n### 需求更新\n- 新增 preview 发布通道，测试版可独立发布且不会触发正式站点更新通知。\n\n### Bug 修复\n- 进一步改善桌面端构建打包，让共享工作区依赖在发布版本中更稳定地被正确包含。\n\n## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14\n### Bug 修复\n- 提升了桌面端打包稳定性，让共享下载组件在发布版本中更稳定地被正确包含。\n\n## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14\n### 需求更新\n- 新增 Web 与 API 版本，并与桌面端共享下载核心能力和主要设置行为。\n- 设置页新增上传 Cookie 与配置文件的能力。\n- 下载历史迁移到 SQLite 存储，可靠性和跨端一致性更好。\n- 下载弹窗新增统一的添加链接交互，并优化深色主题下滑块可见性。\n\n### Bug 修复\n- 提升了桌面端内置二进制初始化流程的稳定性与诊断信息质量。\n- 修复了设置页资料输入框光标跳动问题。\n- 修复了 Linux 下选择非空目录作为下载目录时的校验问题。\n- 优化了本地化一致性，修正了部分中文翻译问题。\n\n## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15\n### 需求更新\n- 新增更快捷的一键操作，粘贴链接后可更快开始下载。\n- 新增法语、俄语与土耳其语界面本地化支持。\n- 完善了土耳其语托盘菜单的本地化。\n- 将 Cookie 设置重构为三个更清晰的分组。\n- 在 RSS 文档中新增 RSSHub 门户链接。\n\n### Bug 修复\n- 提升了 YouTube 与格式回退场景下的下载兼容性。\n- 下载输出会更稳定地遵循你选择的容器格式。\n- 设置页与文档体验持续优化，问题反馈与 RSS 指引更清晰。\n- 新增当请求格式不可用时的下载回退处理。\n- 补齐了土耳其语缺失的本地化键。\n- 通过检查与工作流修复提升了发布流程稳定性。\n\n## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24\n### 需求更新\n- 一键下载流程更直接，操作步骤更短。\n- 设置页新增 Cookie 管理标签，账号相关操作更集中。\n- 常见问题入口更明显，报错提示更容易理解。\n\n### Bug 修复\n- RSS 使用说明更清晰，新用户上手更快。\n\n## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23\n### 需求更新\n- Cookie 使用指引补充了更直观的示例。\n\n### Bug 修复\n- 播放列表加载更稳定，不再出现界面挤压问题。\n\n## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21\n### 需求更新\n- 下载相关操作入口更顺手。\n- 新增分享时的水印开关选择。\n- 整体下载操作的一致性更好。\n\n## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20\n### 需求更新\n- 播放列表内重名内容更容易区分。\n- 排查问题时更容易找到日志和相关文件。\n- 订阅相关链接和指引更加可靠。\n- 文档站点能力进一步完善，新增 i18n 支持、站点地图与协议文档。\n\n### Bug 修复\n- 下载过程中的提示更克制，减少打扰。\n- 修复了文档项目中的 TypeScript 构建问题。\n\n## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17\n### 需求更新\n- 支持更快捷地全选和清空下载历史。\n- 订阅使用中重复内容更少。\n- 播放列表与设置页面更易用。\n- 默认改为最小化到系统托盘。\n- 反馈流程会在 GitHub Issue 链接过长时给出提醒。\n\n### Bug 修复\n- 最小化和重新打开应用时体验更顺滑。\n- 下载中断后的继续体验更稳定。\n- 更可靠地限制播放列表区域高度。\n- 问题反馈链接结构更简洁稳定。\n- 增加了更清晰的 Windows 专用 Cookie 说明。\n- 强化了 ffmpeg/ffprobe 资源目录检查。\n\n## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15\n### 需求更新\n- 提交反馈时可提供的信息更清楚。\n\n### Bug 修复\n- 设置项对下载目录选择的行为更符合预期。\n\n## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14\n### 需求更新\n- 订阅页面浏览体验更流畅。\n- 错误提示提供了更明确的下一步建议。\n- 默认设置更适合日常使用。\n- 扩展错误页与品牌展示进一步优化。\n- 下载错误面板提供了更丰富的排查链接。\n- 订阅标签页支持横向滚动。\n\n### Bug 修复\n- 下载流程与页面布局更清晰。\n- 增加了 Bilibili 字幕嵌入失败保护。\n- 补齐了缺失的本地化翻译条目。\n- 默认关闭了更容易引发问题的嵌入缩略图行为。\n\n## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12\n### Bug 修复\n- macOS 的安装和更新体验更稳定。\n- 在 macOS 构建中增加了捆绑工具签名流程。\n- 在 CI 中完善了 DMG 公证流程，发布可靠性更高。\n\n## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12\n### 需求更新\n- 下载进度信息更直观。\n\n### Bug 修复\n- 更新提示的本地化显示更清晰。\n\n## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11\n### 需求更新\n- 新增更多媒体输出偏好设置。\n- 首次配置和日常使用流程更顺畅。\n\n## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11\n### 需求更新\n- 本地视频信息相关流程更顺手。\n\n### Bug 修复\n- Cookie 配置管理更稳定、可预期。\n\n## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10\n### Bug 修复\n- 高级设置页的已知问题得到修复。\n- 远程封面加载稳定性更好。\n- 订阅封面选择更可靠。\n\n## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09\n### 需求更新\n- 开机自启后的窗口行为更自然。\n\n### Bug 修复\n- 设置页整体行为更一致。\n\n## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02\n### 需求更新\n- 关于页面更容易看到更新状态。\n\n### Bug 修复\n- 下载格式选择在更多场景下更稳定。\n\n## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26\n### 需求更新\n- 恢复了更多站点的下载可用性。\n- 问题反馈流程更简洁。\n- 新增 Issue 模板并简化了 Bug 报告表单。\n\n### Bug 修复\n- 强化了 CI 下载步骤的稳定性。\n\n## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26\n### 需求更新\n- 更新提示更克制，不打断当前操作。\n- 关于页面文案与链接更清楚。\n- 增加了 yt-dlp 集成所需的 JavaScript 运行时支持。\n- 高级选项面板的动效过渡更顺滑。\n\n### Bug 修复\n- 下载相关面板交互更顺滑。\n- 为了统一体验与体积控制，Electron 语言资源默认限制为英文。\n\n## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20\n### 需求更新\n- 支持批量管理下载历史，清理更高效。\n- 支持自定义下载目录。\n- RSS 设置弹窗更容易理解和填写。\n\n### Bug 修复\n- 打开下载任务链接时行为更符合预期。\n\n## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06\n### Bug 修复\n- 增加更多兼容选项，适配场景更广。\n- 路径填写容错更好，日常使用更省心。\n\n## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16\n### 需求更新\n- 新增开机自启动支持。\n- 语言支持进一步完善。\n\n## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15\n### 需求更新\n- VidBee 首个主版本发布。\n- 新增 RSS 订阅下载能力。\n- 历史记录与媒体预览体验得到加强。\n- 支持站点列表新增站点图标展示。\n- 增强了媒体预览远程图片加载与缓存能力。\n- 侧边栏拖拽与标题栏交互行为更合理。\n\n### Bug 修复\n- 导航结构和整体界面体验更清晰。\n- 历史记录存储迁移到 SQLite（Drizzle），数据处理更可靠。\n\n## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08\n### 需求更新\n- 一键下载文案和反馈提示更易懂。\n- 视觉风格更统一。\n\n## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03\n### Bug 修复\n- 更新提示与下载选项展示更清晰。\n\n## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02\n### Bug 修复\n- 更多场景下的下载处理稳定性得到提升。\n\n## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31\n### Bug 修复\n- 多设备分发体验进一步优化。\n\n## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30\n### 需求更新\n- Linux 使用体验更友好。\n- 新增版本更新提醒，方便及时升级。\n\n## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29\n### 需求更新\n- 新增播放列表下载支持。\n\n### Bug 修复\n- 增加减少桌面打扰的相关控制项。\n\n## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27\n### 需求更新\n- 预览阶段的持续体验打磨。\n\n## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26\n### 需求更新\n- 预览阶段的持续体验打磨。\n\n## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25\n### 需求更新\n- 预览阶段的持续体验打磨。\n\n## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24\n### 需求更新\n- 公开预览期开始。\n\n## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24\n### 需求更新\n- 新增自动更新支持，并完善了文档中的发布说明。\n- 完善项目文档，包括截图与贡献指南。\n\n### Bug 修复\n- 简化下载路径处理逻辑，移除了未使用的输出路径逻辑。\n\n## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23\n### Bug 修复\n- 移除了发布流程中不必要的目录创建步骤。\n\n## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23\n### Bug 修复\n- 优化发布流程：支持在跨平台打包时下载 yt-dlp 二进制文件。\n\n## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23\n### Bug 修复\n- 调整发布流程为仅构建 Windows 目标。\n\n## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23\n### Bug 修复\n- 简化 CI 中的发布构建步骤与产物处理流程。\n- 调整 CI 触发条件：仅在 `main` 分支运行 Pull Request 自动化流程。\n\n## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23\n### Bug 修复\n- 为发布流程中的构建步骤显式指定 shell，提升执行稳定性。\n\n## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23\n### 需求更新\n- 早期迭代版本，未记录新增用户可见改动。\n\n## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23\n### 需求更新\n- 初始公开发布基线版本。\n"
  },
  {
    "path": "apps/desktop/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/renderer/src/assets/global.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@renderer/components\",\n    \"utils\": \"@renderer/lib/utils\",\n    \"ui\": \"@renderer/components/ui\",\n    \"lib\": \"@renderer/lib\",\n    \"hooks\": \"@renderer/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "apps/desktop/dev-app-update.yml",
    "content": "provider: generic\nurl: https://github.com/nexmoe/vidbee/releases/latest/download\nupdaterCacheDirName: vidbee-updater\n"
  },
  {
    "path": "apps/desktop/drizzle.config.ts",
    "content": "import { defineConfig } from 'drizzle-kit'\n\nexport default defineConfig({\n  schema: './src/main/lib/database/schema.ts',\n  out: './resources/drizzle',\n  dialect: 'sqlite'\n})\n"
  },
  {
    "path": "apps/desktop/electron-builder.yml",
    "content": "appId: com.vidbee\nproductName: VidBee\ndirectories:\n  buildResources: build\nafterPack: build/after-pack.cjs\nprotocols:\n  - name: VidBee\n    schemes:\n      - vidbee\nfiles:\n  - '!**/.vscode/*'\n  - '!**/.context/**'\n  - '!**/.github/**'\n  - '!node_modules/@vidbee/downloader-core{,/**}'\n  - '!node_modules/.pnpm/@vidbee+downloader-core@*/**'\n  - '!src/*'\n  - '!electron.vite.config.{js,ts,mjs,cjs}'\n  - '!{.eslintcache,eslint.config.mjs,dev-app-update.yml}'\n  - '!changelogs/**'\n  - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'\n  - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'\nextraResources:\n  - from: resources\n    to: resources\n    filter:\n      - '**/*'\nwin:\n  executableName: vidbee\nnsis:\n  artifactName: ${name}-${version}-setup.${ext}\n  shortcutName: ${productName}\n  uninstallDisplayName: ${productName}\n  createDesktopShortcut: always\nmac:\n  hardenedRuntime: true\n  entitlements: build/entitlements.mac.plist\n  entitlementsInherit: build/entitlements.mac.plist\n  notarize: true\n  artifactName: ${name}-${version}-${arch}.${ext}\n  target:\n    - target: zip\n      arch:\n        - arm64\n        - x64\n    - target: dmg\n      arch:\n        - arm64\n        - x64\nlinux:\n  target:\n    - AppImage\n    - deb\n  maintainer: nexmoex@gmail.com\n  category: Utility\nappImage:\n  artifactName: ${name}-${version}.${ext}\nnpmRebuild: true\npublish:\n  provider: generic\n  url: https://github.com/nexmoe/vidbee/releases/latest/download\nelectronLanguages:\n  - en\n"
  },
  {
    "path": "apps/desktop/electron.vite.config.ts",
    "content": "import { resolve } from 'node:path'\nimport tailwindcss from '@tailwindcss/vite'\nimport react from '@vitejs/plugin-react'\nimport { defineConfig, externalizeDepsPlugin } from 'electron-vite'\nimport Icons from 'unplugin-icons/vite'\n\nconst bundledWorkspacePackages = ['@vidbee/db', '@vidbee/downloader-core', '@vidbee/i18n']\n\nexport default defineConfig({\n  main: {\n    plugins: [\n      externalizeDepsPlugin({\n        exclude: bundledWorkspacePackages\n      })\n    ],\n    resolve: {\n      alias: {\n        '@main': resolve('src/main'),\n        '@shared': resolve('src/shared')\n      }\n    },\n    assetsInclude: ['**/*.png', '**/*.ico', '**/*.icns'],\n    publicDir: 'build'\n  },\n  preload: {\n    plugins: [\n      externalizeDepsPlugin({\n        exclude: bundledWorkspacePackages\n      })\n    ]\n  },\n  renderer: {\n    base: './',\n    resolve: {\n      alias: {\n        '@main': resolve('src/main'),\n        '@renderer': resolve('src/renderer/src'),\n        '@shared': resolve('src/shared')\n      }\n    },\n    plugins: [\n      react(),\n      Icons({\n        compiler: 'jsx',\n        jsx: 'react'\n      }),\n      tailwindcss()\n    ]\n  }\n})\n"
  },
  {
    "path": "apps/desktop/package.json",
    "content": "{\n  \"name\": \"vidbee\",\n  \"version\": \"1.3.5\",\n  \"description\": \"A modern Electron application for downloading videos and audios\",\n  \"main\": \"./out/main/index.js\",\n  \"author\": \"VidBee\",\n  \"homepage\": \"https://github.com/nexmoe/vidbee\",\n  \"scripts\": {\n    \"check\": \"ultracite check && pnpm run check:i18n && pnpm run typecheck\",\n    \"check:i18n\": \"node scripts/check-locales.js\",\n    \"typecheck:node\": \"tsc --noEmit -p tsconfig.node.json --composite false\",\n    \"typecheck:web\": \"tsc --noEmit -p tsconfig.web.json --composite false\",\n    \"typecheck\": \"pnpm run typecheck:node && pnpm run typecheck:web\",\n    \"prepare:native-deps\": \"node scripts/ensure-native-deps.mjs\",\n    \"start\": \"pnpm run prepare:native-deps && electron-vite preview\",\n    \"dev\": \"pnpm run setup && pnpm run prepare:native-deps && node scripts/set-console-encoding.js && electron-vite dev\",\n    \"build\": \"electron-vite build\",\n    \"setup\": \"node scripts/setup-dev-binaries.js\",\n    \"postinstall\": \"node scripts/postinstall.mjs\",\n    \"build:unpack\": \"pnpm run setup && pnpm run build && electron-builder --dir\",\n    \"build:win\": \"pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win\",\n    \"build:mac\": \"pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64\",\n    \"build:linux\": \"pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux\",\n    \"release\": \"git checkout main && git pull && pnpm run check && bumpp\",\n    \"db:generate\": \"drizzle-kit generate\",\n    \"db:migrate\": \"drizzle-kit migrate\",\n    \"fix\": \"ultracite fix\"\n  },\n  \"dependencies\": {\n    \"@electron-toolkit/preload\": \"^3.0.2\",\n    \"@electron-toolkit/utils\": \"^4.0.0\",\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@vidbee/db\": \"workspace:*\",\n    \"@vidbee/downloader-core\": \"workspace:*\",\n    \"@vidbee/i18n\": \"workspace:*\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@svgr/core\": \"^8.1.0\",\n    \"@svgr/plugin-jsx\": \"^8.1.0\",\n    \"@tailwindcss/vite\": \"^4.1.13\",\n    \"better-sqlite3\": \"^12.4.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"dayjs\": \"^1.11.18\",\n    \"drizzle-orm\": \"^0.44.7\",\n    \"electron-ipc-decorator\": \"^0.2.0\",\n    \"electron-log\": \"^5.4.3\",\n    \"electron-store\": \"^11.0.2\",\n    \"electron-updater\": \"^6.3.9\",\n    \"flag-icons\": \"^7.5.0\",\n    \"i18next\": \"^25.5.3\",\n    \"jotai\": \"^2.15.0\",\n    \"lucide-react\": \"^0.544.0\",\n    \"next-themes\": \"^0.4.6\",\n    \"react-hook-form\": \"^7.63.0\",\n    \"react-i18next\": \"^16.0.0\",\n    \"react-router\": \"^7.9.4\",\n    \"rss-parser\": \"^3.13.0\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwindcss\": \"^4.1.13\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"yt-dlp-wrap-plus\": \"^2.3.20\",\n    \"zod\": \"^4.1.11\"\n  },\n  \"dependenciesMeta\": {\n    \"@vidbee/downloader-core\": {\n      \"injected\": true\n    }\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.3.13\",\n    \"@electron-toolkit/tsconfig\": \"^2.0.0\",\n    \"@iconify/json\": \"^2.2.394\",\n    \"@types/node\": \"^22.18.6\",\n    \"@types/react\": \"^19.1.13\",\n    \"@types/react-dom\": \"^19.1.9\",\n    \"@vidbee/ui\": \"workspace:*\",\n    \"@vitejs/plugin-react\": \"^5.0.3\",\n    \"bumpp\": \"^10.3.1\",\n    \"drizzle-kit\": \"^0.31.7\",\n    \"electron\": \"^38.1.2\",\n    \"electron-builder\": \"^25.1.8\",\n    \"electron-vite\": \"^4.0.1\",\n    \"react\": \"^19.1.1\",\n    \"react-dom\": \"^19.1.1\",\n    \"typescript\": \"^5.9.2\",\n    \"ultracite\": \"7.1.5\",\n    \"unplugin-icons\": \"^22.4.2\",\n    \"vite\": \"^7.1.6\"\n  }\n}\n"
  },
  {
    "path": "apps/desktop/release-metadata.json",
    "content": "{\n  \"ytDlpVersion\": \"2026.03.17\"\n}\n"
  },
  {
    "path": "apps/desktop/resources/.gitignore",
    "content": "# Ignore yt-dlp binaries (too large for git)\nyt-dlp.exe\nyt-dlp_macos\nyt-dlp_linux\nffmpeg.exe\nffmpeg_macos\nffmpeg_linux\nffmpeg\nffmpeg/\nffprobe\nffprobe.exe\ndeno.exe\ndeno\n\n# But keep the README\n!README.md\n!.gitignore\n"
  },
  {
    "path": "apps/desktop/resources/README.md",
    "content": "# Resources Directory\n\nThis directory contains bundled resources for the application.\n\n## yt-dlp Binaries\n\nTo bundle yt-dlp with the application, place the appropriate binaries in this directory:\n\n### Required Files\n\n1. **Windows**: `yt-dlp.exe`\n2. **macOS**: `yt-dlp_macos`\n3. **Linux**: `yt-dlp_linux`\n\n### How to Download\n\nYou can download the latest yt-dlp binaries from the official GitHub releases:\n\n**Option 1: Manual Download**\n\n- Visit: <https://github.com/yt-dlp/yt-dlp/releases/latest>\n- Download the appropriate version for each platform:\n  - Windows: `yt-dlp.exe`\n  - macOS: `yt-dlp_macos`\n  - Linux: `yt-dlp` (rename to `yt-dlp_linux`)\n\n**Option 2: Using curl/wget (Linux/macOS)**\n\n```bash\n# For Windows binary\ncurl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe\n\n# For macOS binary\ncurl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos\nchmod +x resources/yt-dlp_macos\n\n# For Linux binary\ncurl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux\nchmod +x resources/yt-dlp_linux\n```\n\n**Option 3: Using PowerShell (Windows)**\n\n```powershell\n# Download all three binaries\nInvoke-WebRequest -Uri \"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe\" -OutFile \"resources/yt-dlp.exe\"\nInvoke-WebRequest -Uri \"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos\" -OutFile \"resources/yt-dlp_macos\"\nInvoke-WebRequest -Uri \"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp\" -OutFile \"resources/yt-dlp_linux\"\n```\n\n## ffmpeg/ffprobe Binaries\n\nffmpeg is required for merging audio/video streams and audio extraction. ffprobe is required for post-processing metadata. Bundle both binaries under `resources/ffmpeg/`.\n\n### Required Files\n\n1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe`\n2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`\n3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`\n\n### How to Download\n\n- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and copy `ffmpeg` and `ffprobe` into `resources/ffmpeg/`.\n- **macOS**: Download the `ffmpeg-*.zip` asset from <https://github.com/eko5624/mpv-mac/releases/latest>, then copy `ffmpeg` and `ffprobe` from the archive into `resources/ffmpeg/`.\n- On macOS/Linux ensure both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`.\n\n### Note\n\n- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/ffprobe from the system PATH.\n- You can override the lookup path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`.\n- File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe)\n\n## JS Runtime (Deno)\n\nyt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.\n\n### Required Files\n\n1. **Windows**: `deno.exe`\n2. **macOS**: `deno`\n3. **Linux**: `deno`\n\n### How to Download\n\n- Visit: <https://github.com/denoland/deno/releases/latest>\n- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.\n- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`\n\n### Note\n\n- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.\n"
  },
  {
    "path": "apps/desktop/resources/drizzle/0000_swift_aaron_stack.sql",
    "content": "CREATE TABLE `download_history` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`url` text NOT NULL,\n\t`title` text NOT NULL,\n\t`thumbnail` text,\n\t`type` text NOT NULL,\n\t`status` text NOT NULL,\n\t`download_path` text,\n\t`saved_file_name` text,\n\t`file_size` integer,\n\t`duration` integer,\n\t`downloaded_at` integer NOT NULL,\n\t`completed_at` integer,\n\t`sort_key` integer NOT NULL,\n\t`error` text,\n\t`description` text,\n\t`channel` text,\n\t`uploader` text,\n\t`view_count` integer,\n\t`tags` text,\n\t`origin` text,\n\t`subscription_id` text,\n\t`selected_format` text,\n\t`playlist_id` text,\n\t`playlist_title` text,\n\t`playlist_index` integer,\n\t`playlist_size` integer\n);\n--> statement-breakpoint\nCREATE TABLE `subscription_items` (\n\t`subscription_id` text NOT NULL,\n\t`item_id` text NOT NULL,\n\t`title` text NOT NULL,\n\t`url` text NOT NULL,\n\t`published_at` integer NOT NULL,\n\t`thumbnail` text,\n\t`added` integer NOT NULL,\n\t`download_id` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tPRIMARY KEY(`subscription_id`, `item_id`)\n);\n--> statement-breakpoint\nCREATE INDEX `subscription_items_subscription_idx` ON `subscription_items` (`subscription_id`);--> statement-breakpoint\nCREATE TABLE `subscriptions` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`title` text NOT NULL,\n\t`source_url` text NOT NULL,\n\t`feed_url` text NOT NULL,\n\t`platform` text NOT NULL,\n\t`keywords` text NOT NULL,\n\t`tags` text NOT NULL,\n\t`only_latest` integer NOT NULL,\n\t`enabled` integer NOT NULL,\n\t`cover_url` text,\n\t`latest_video_title` text,\n\t`latest_video_published_at` integer,\n\t`last_checked_at` integer,\n\t`last_success_at` integer,\n\t`status` text NOT NULL,\n\t`last_error` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`download_directory` text,\n\t`naming_template` text\n);\n"
  },
  {
    "path": "apps/desktop/resources/drizzle/0001_smiling_agent_zero.sql",
    "content": "ALTER TABLE `download_history` ADD `yt_dlp_command` text;"
  },
  {
    "path": "apps/desktop/resources/drizzle/0002_smooth_impossible_man.sql",
    "content": "ALTER TABLE `download_history` ADD `yt_dlp_log` text;"
  },
  {
    "path": "apps/desktop/resources/drizzle/meta/0000_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"91501763-7554-435d-91d8-382c52ee65e4\",\n  \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n  \"tables\": {\n    \"download_history\": {\n      \"name\": \"download_history\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_path\": {\n          \"name\": \"download_path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"saved_file_name\": {\n          \"name\": \"saved_file_name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"file_size\": {\n          \"name\": \"file_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"duration\": {\n          \"name\": \"duration\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"downloaded_at\": {\n          \"name\": \"downloaded_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completed_at\": {\n          \"name\": \"completed_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sort_key\": {\n          \"name\": \"sort_key\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"error\": {\n          \"name\": \"error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"channel\": {\n          \"name\": \"channel\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"uploader\": {\n          \"name\": \"uploader\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view_count\": {\n          \"name\": \"view_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"origin\": {\n          \"name\": \"origin\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"selected_format\": {\n          \"name\": \"selected_format\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_id\": {\n          \"name\": \"playlist_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_title\": {\n          \"name\": \"playlist_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_index\": {\n          \"name\": \"playlist_index\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_size\": {\n          \"name\": \"playlist_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscription_items\": {\n      \"name\": \"subscription_items\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"item_id\": {\n          \"name\": \"item_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"added\": {\n          \"name\": \"added\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_id\": {\n          \"name\": \"download_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"subscription_items_subscription_idx\": {\n          \"name\": \"subscription_items_subscription_idx\",\n          \"columns\": [\"subscription_id\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {\n        \"subscription_items_pk\": {\n          \"columns\": [\"subscription_id\", \"item_id\"],\n          \"name\": \"subscription_items_pk\"\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"source_url\": {\n          \"name\": \"source_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_url\": {\n          \"name\": \"feed_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"platform\": {\n          \"name\": \"platform\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"keywords\": {\n          \"name\": \"keywords\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"only_latest\": {\n          \"name\": \"only_latest\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"enabled\": {\n          \"name\": \"enabled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"cover_url\": {\n          \"name\": \"cover_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_title\": {\n          \"name\": \"latest_video_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_published_at\": {\n          \"name\": \"latest_video_published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_checked_at\": {\n          \"name\": \"last_checked_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_success_at\": {\n          \"name\": \"last_success_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"last_error\": {\n          \"name\": \"last_error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_directory\": {\n          \"name\": \"download_directory\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"naming_template\": {\n          \"name\": \"naming_template\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "apps/desktop/resources/drizzle/meta/0001_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"a2ffc60d-d3a6-4bbb-96d3-fbc79bca6d8b\",\n  \"prevId\": \"91501763-7554-435d-91d8-382c52ee65e4\",\n  \"tables\": {\n    \"download_history\": {\n      \"name\": \"download_history\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_path\": {\n          \"name\": \"download_path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"saved_file_name\": {\n          \"name\": \"saved_file_name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"file_size\": {\n          \"name\": \"file_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"duration\": {\n          \"name\": \"duration\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"downloaded_at\": {\n          \"name\": \"downloaded_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completed_at\": {\n          \"name\": \"completed_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sort_key\": {\n          \"name\": \"sort_key\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"error\": {\n          \"name\": \"error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"yt_dlp_command\": {\n          \"name\": \"yt_dlp_command\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"channel\": {\n          \"name\": \"channel\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"uploader\": {\n          \"name\": \"uploader\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view_count\": {\n          \"name\": \"view_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"origin\": {\n          \"name\": \"origin\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"selected_format\": {\n          \"name\": \"selected_format\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_id\": {\n          \"name\": \"playlist_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_title\": {\n          \"name\": \"playlist_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_index\": {\n          \"name\": \"playlist_index\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_size\": {\n          \"name\": \"playlist_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscription_items\": {\n      \"name\": \"subscription_items\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"item_id\": {\n          \"name\": \"item_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"added\": {\n          \"name\": \"added\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_id\": {\n          \"name\": \"download_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"subscription_items_subscription_idx\": {\n          \"name\": \"subscription_items_subscription_idx\",\n          \"columns\": [\"subscription_id\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {\n        \"subscription_items_pk\": {\n          \"columns\": [\"subscription_id\", \"item_id\"],\n          \"name\": \"subscription_items_pk\"\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"source_url\": {\n          \"name\": \"source_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_url\": {\n          \"name\": \"feed_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"platform\": {\n          \"name\": \"platform\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"keywords\": {\n          \"name\": \"keywords\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"only_latest\": {\n          \"name\": \"only_latest\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"enabled\": {\n          \"name\": \"enabled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"cover_url\": {\n          \"name\": \"cover_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_title\": {\n          \"name\": \"latest_video_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_published_at\": {\n          \"name\": \"latest_video_published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_checked_at\": {\n          \"name\": \"last_checked_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_success_at\": {\n          \"name\": \"last_success_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"last_error\": {\n          \"name\": \"last_error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_directory\": {\n          \"name\": \"download_directory\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"naming_template\": {\n          \"name\": \"naming_template\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "apps/desktop/resources/drizzle/meta/0002_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"3a269324-eb4d-44c4-860c-80d90f4f3df1\",\n  \"prevId\": \"a2ffc60d-d3a6-4bbb-96d3-fbc79bca6d8b\",\n  \"tables\": {\n    \"download_history\": {\n      \"name\": \"download_history\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_path\": {\n          \"name\": \"download_path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"saved_file_name\": {\n          \"name\": \"saved_file_name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"file_size\": {\n          \"name\": \"file_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"duration\": {\n          \"name\": \"duration\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"downloaded_at\": {\n          \"name\": \"downloaded_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completed_at\": {\n          \"name\": \"completed_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sort_key\": {\n          \"name\": \"sort_key\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"error\": {\n          \"name\": \"error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"yt_dlp_command\": {\n          \"name\": \"yt_dlp_command\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"yt_dlp_log\": {\n          \"name\": \"yt_dlp_log\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"channel\": {\n          \"name\": \"channel\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"uploader\": {\n          \"name\": \"uploader\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view_count\": {\n          \"name\": \"view_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"origin\": {\n          \"name\": \"origin\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"selected_format\": {\n          \"name\": \"selected_format\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_id\": {\n          \"name\": \"playlist_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_title\": {\n          \"name\": \"playlist_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_index\": {\n          \"name\": \"playlist_index\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"playlist_size\": {\n          \"name\": \"playlist_size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscription_items\": {\n      \"name\": \"subscription_items\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"item_id\": {\n          \"name\": \"item_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"thumbnail\": {\n          \"name\": \"thumbnail\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"added\": {\n          \"name\": \"added\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_id\": {\n          \"name\": \"download_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"subscription_items_subscription_idx\": {\n          \"name\": \"subscription_items_subscription_idx\",\n          \"columns\": [\"subscription_id\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {\n        \"subscription_items_pk\": {\n          \"columns\": [\"subscription_id\", \"item_id\"],\n          \"name\": \"subscription_items_pk\"\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"source_url\": {\n          \"name\": \"source_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_url\": {\n          \"name\": \"feed_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"platform\": {\n          \"name\": \"platform\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"keywords\": {\n          \"name\": \"keywords\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"only_latest\": {\n          \"name\": \"only_latest\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"enabled\": {\n          \"name\": \"enabled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"cover_url\": {\n          \"name\": \"cover_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_title\": {\n          \"name\": \"latest_video_title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_video_published_at\": {\n          \"name\": \"latest_video_published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_checked_at\": {\n          \"name\": \"last_checked_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"last_success_at\": {\n          \"name\": \"last_success_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"last_error\": {\n          \"name\": \"last_error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"download_directory\": {\n          \"name\": \"download_directory\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"naming_template\": {\n          \"name\": \"naming_template\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "apps/desktop/resources/drizzle/meta/_journal.json",
    "content": "{\n  \"version\": \"7\",\n  \"dialect\": \"sqlite\",\n  \"entries\": [\n    {\n      \"idx\": 0,\n      \"version\": \"6\",\n      \"when\": 1763176841336,\n      \"tag\": \"0000_swift_aaron_stack\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 1,\n      \"version\": \"6\",\n      \"when\": 1768961568903,\n      \"tag\": \"0001_smiling_agent_zero\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 2,\n      \"version\": \"6\",\n      \"when\": 1768961585359,\n      \"tag\": \"0002_smooth_impossible_man\",\n      \"breakpoints\": true\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/desktop/scripts/check-locales.js",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst currentFilePath = fileURLToPath(import.meta.url)\nconst currentDirPath = path.dirname(currentFilePath)\nconst localesDir = path.join(currentDirPath, '..', '..', '..', 'packages', 'i18n', 'src', 'locales')\nconst baseLocaleFile = 'en.json'\n\nconst readJson = (filePath) => {\n  try {\n    const raw = fs.readFileSync(filePath, 'utf8')\n    return JSON.parse(raw)\n  } catch (error) {\n    console.error(`ERROR: Failed to read ${filePath}`)\n    console.error(String(error))\n    process.exit(1)\n  }\n}\n\nconst collectLeafKeys = (value, prefix = '', keys = new Set()) => {\n  if (value && typeof value === 'object' && !Array.isArray(value)) {\n    for (const [key, child] of Object.entries(value)) {\n      const next = prefix ? `${prefix}.${key}` : key\n      if (child && typeof child === 'object' && !Array.isArray(child)) {\n        collectLeafKeys(child, next, keys)\n      } else {\n        keys.add(next)\n      }\n    }\n    return keys\n  }\n\n  if (prefix) {\n    keys.add(prefix)\n  }\n\n  return keys\n}\n\nif (!fs.existsSync(localesDir)) {\n  console.error(`ERROR: Locales directory not found: ${localesDir}`)\n  process.exit(1)\n}\n\nconst localeFiles = fs\n  .readdirSync(localesDir)\n  .filter((file) => file.endsWith('.json'))\n  .sort()\n\nif (!localeFiles.includes(baseLocaleFile)) {\n  console.error(`ERROR: Base locale file not found: ${baseLocaleFile}`)\n  process.exit(1)\n}\n\nconst baseLocalePath = path.join(localesDir, baseLocaleFile)\nconst baseLocaleData = readJson(baseLocalePath)\nconst baseKeys = collectLeafKeys(baseLocaleData)\n\nlet hasMissing = false\nlet hasExtra = false\n\nfor (const file of localeFiles) {\n  if (file === baseLocaleFile) {\n    continue\n  }\n\n  const localePath = path.join(localesDir, file)\n  const localeData = readJson(localePath)\n  const localeKeys = collectLeafKeys(localeData)\n\n  const missing = [...baseKeys].filter((key) => !localeKeys.has(key))\n  const extra = [...localeKeys].filter((key) => !baseKeys.has(key))\n\n  if (missing.length > 0) {\n    hasMissing = true\n    console.error(`ERROR: Missing keys in ${file}`)\n    for (const key of missing) {\n      console.error(`  - ${key}`)\n    }\n  }\n\n  if (extra.length > 0) {\n    hasExtra = true\n    console.warn(`WARN: Extra keys in ${file}`)\n    for (const key of extra) {\n      console.warn(`  - ${key}`)\n    }\n  }\n}\n\nif (hasMissing) {\n  process.exit(1)\n}\n\nif (hasExtra) {\n  console.log('INFO: No missing keys, but extra keys were found.')\n  process.exit(0)\n}\n\nconsole.log('OK: All locale files include every key from en.json.')\n"
  },
  {
    "path": "apps/desktop/scripts/check-ytdlp.js",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst scriptDir = path.dirname(fileURLToPath(import.meta.url))\n\n// Get platform from command line arguments\nconst platform = process.argv[2]\n\nif (!platform) {\n  console.error('❌ Error: Platform argument is required!')\n  console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')\n  process.exit(1)\n}\n\nconst supportedPlatforms = ['win', 'mac', 'linux']\n\nif (!supportedPlatforms.includes(platform)) {\n  console.error('❌ Error: Invalid platform specified!')\n  console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')\n  process.exit(1)\n}\n\nconst binaries = [\n  {\n    label: 'yt-dlp',\n    paths: {\n      win: ['yt-dlp.exe'],\n      mac: ['yt-dlp_macos'],\n      linux: ['yt-dlp_linux']\n    },\n    help: {\n      default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'\n    }\n  },\n  {\n    label: 'ffmpeg',\n    paths: {\n      win: ['ffmpeg/ffmpeg.exe'],\n      mac: ['ffmpeg/ffmpeg'],\n      linux: ['ffmpeg/ffmpeg']\n    },\n    help: {\n      win: 'https://ffmpeg.org/download.html',\n      linux: 'https://ffmpeg.org/download.html',\n      mac: 'https://github.com/eko5624/mpv-mac/releases/latest'\n    }\n  },\n  {\n    label: 'ffprobe',\n    paths: {\n      win: ['ffmpeg/ffprobe.exe'],\n      mac: ['ffmpeg/ffprobe'],\n      linux: ['ffmpeg/ffprobe']\n    },\n    help: {\n      win: 'https://ffmpeg.org/download.html',\n      linux: 'https://ffmpeg.org/download.html',\n      mac: 'https://github.com/eko5624/mpv-mac/releases/latest'\n    }\n  },\n  {\n    label: 'deno',\n    paths: {\n      win: ['deno.exe'],\n      mac: ['deno'],\n      linux: ['deno']\n    },\n    help: {\n      default: 'https://github.com/denoland/deno/releases/latest'\n    }\n  }\n]\n\nlet hasMissingBinary = false\n\nfor (const binary of binaries) {\n  const candidates = binary.paths[platform] || []\n  const found = candidates.find((filename) =>\n    fs.existsSync(path.join(scriptDir, '..', 'resources', filename))\n  )\n\n  if (found) {\n    console.log(`✅ ${binary.label} found: resources/${found}`)\n  } else {\n    const expected = candidates.length ? candidates.join(' or ') : binary.label\n    console.error(`❌ Error: resources/${expected} not found!`)\n    console.error(`Please download ${binary.label} to the resources/ directory first.`)\n    const help =\n      typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default\n    if (help) {\n      console.error(`See ${help}`)\n    }\n    hasMissingBinary = true\n  }\n}\n\nif (hasMissingBinary) {\n  process.exit(1)\n}\n"
  },
  {
    "path": "apps/desktop/scripts/ensure-native-deps.mjs",
    "content": "#!/usr/bin/env node\n\nimport { execSync, spawnSync } from 'node:child_process'\nimport path from 'node:path'\n\nconst desktopRoot = path.resolve(import.meta.dirname, '..')\nconst checkScript =\n  \"const Database=require('better-sqlite3');const db=new Database(':memory:');db.close()\"\n\nfunction canLoadBetterSqlite3WithElectron() {\n  const result = spawnSync('pnpm', ['exec', 'electron', '-e', checkScript], {\n    cwd: desktopRoot,\n    env: {\n      ...process.env,\n      ELECTRON_RUN_AS_NODE: '1'\n    },\n    encoding: 'utf8'\n  })\n\n  if (result.status === 0) {\n    return true\n  }\n\n  const stderr = result.stderr?.trim()\n  const stdout = result.stdout?.trim()\n  const details = stderr || stdout || 'No output'\n  console.warn(`[native-deps] better-sqlite3 check failed: ${details}`)\n  return false\n}\n\nif (canLoadBetterSqlite3WithElectron()) {\n  console.log('[native-deps] better-sqlite3 is ready for Electron')\n  process.exit(0)\n}\n\nconsole.log('[native-deps] Rebuilding Electron native dependencies...')\nexecSync('pnpm exec electron-builder install-app-deps', {\n  cwd: desktopRoot,\n  stdio: 'inherit'\n})\n\nif (!canLoadBetterSqlite3WithElectron()) {\n  throw new Error('[native-deps] better-sqlite3 is still unavailable after install-app-deps')\n}\n\nconsole.log('[native-deps] Electron native dependencies are ready')\n"
  },
  {
    "path": "apps/desktop/scripts/postinstall.mjs",
    "content": "#!/usr/bin/env node\n\nimport { execSync } from 'node:child_process'\nimport path from 'node:path'\n\nconst desktopRoot = path.resolve(import.meta.dirname, '..')\nconst initCwd = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : ''\nconst forceDesktopPostinstall = process.env.VIDBEE_DESKTOP_POSTINSTALL === '1'\nconst isDesktopInstall = initCwd.startsWith(desktopRoot)\n\nif (!(forceDesktopPostinstall || isDesktopInstall)) {\n  console.log('Skipping desktop postinstall in workspace-level install.')\n  process.exit(0)\n}\n\nexecSync('node scripts/setup-dev-binaries.js', {\n  cwd: desktopRoot,\n  stdio: 'inherit'\n})\n\nexecSync('pnpm exec electron-builder install-app-deps', {\n  cwd: desktopRoot,\n  stdio: 'inherit'\n})\n"
  },
  {
    "path": "apps/desktop/scripts/set-console-encoding.js",
    "content": "/**\n * 设置控制台编码为 UTF-8，解决中文乱码问题\n * 这个脚本在 Windows 上设置控制台代码页为 UTF-8\n */\n\nconst { exec } = require('node:child_process')\nconst os = require('node:os')\n\nif (os.platform() === 'win32') {\n  console.log('Setting console encoding to UTF-8...')\n\n  // 设置控制台代码页为 UTF-8 (65001)\n  exec('chcp 65001', (error, stdout, stderr) => {\n    if (error) {\n      console.warn('Failed to set console code page:', error)\n      return\n    }\n\n    if (stderr) {\n      console.warn('Console code page setting warning:', stderr)\n    }\n\n    console.log('Console encoding set to UTF-8')\n    console.log('Output:', stdout)\n  })\n} else {\n  console.log('Not on Windows, no console encoding change needed')\n}\n"
  },
  {
    "path": "apps/desktop/scripts/setup-dev-binaries.js",
    "content": "#!/usr/bin/env node\n\n/**\n * Development environment setup script\n * Automatically downloads yt-dlp and ffmpeg binaries based on the current system\n */\n\nimport { execSync, spawnSync } from 'node:child_process'\nimport fs from 'node:fs'\nimport http from 'node:http'\nimport https from 'node:https'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n// Configuration\nconst currentFilePath = fileURLToPath(import.meta.url)\nconst currentDirPath = path.dirname(currentFilePath)\nconst RESOURCES_DIR = path.join(currentDirPath, '..', 'resources')\nconst FFMPEG_DIR = path.join(RESOURCES_DIR, 'ffmpeg')\nconst YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'\nconst DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'\nconst MAC_FFMPEG_MODE = (process.env.VIDBEE_MAC_FFMPEG_MODE || 'native').trim().toLowerCase()\nconst GITHUB_TOKEN =\n  process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN\n\n// Platform configuration\nconst PLATFORM_CONFIG = {\n  win32: {\n    ytdlp: {\n      asset: 'yt-dlp.exe',\n      output: 'yt-dlp.exe'\n    },\n    ffmpeg: {\n      url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',\n      innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',\n      ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',\n      output: 'ffmpeg.exe',\n      ffprobeOutput: 'ffprobe.exe',\n      extract: 'unzip',\n      release: {\n        repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],\n        assetPattern: /ffmpeg-master-latest-win64-gpl\\.zip$/i,\n        binaryName: 'ffmpeg.exe'\n      }\n    }\n  },\n  darwin: {\n    ytdlp: {\n      asset: 'yt-dlp_macos',\n      output: 'yt-dlp_macos'\n    },\n    ffmpeg: {\n      // For development, download only the architecture matching current system\n      arm64: {\n        url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip',\n        innerPath: 'ffmpeg/ffmpeg',\n        ffprobeInnerPath: 'ffmpeg/ffprobe',\n        output: 'ffmpeg',\n        ffprobeOutput: 'ffprobe',\n        extract: 'unzip',\n        release: {\n          repo: 'eko5624/mpv-mac',\n          assetPattern: /ffmpeg-arm64.*\\.zip$/i\n        }\n      },\n      x64: {\n        url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip',\n        innerPath: 'ffmpeg/ffmpeg',\n        ffprobeInnerPath: 'ffmpeg/ffprobe',\n        output: 'ffmpeg',\n        ffprobeOutput: 'ffprobe',\n        extract: 'unzip',\n        release: {\n          repo: 'eko5624/mpv-mac',\n          assetPattern: /ffmpeg-x86_64.*\\.zip$/i\n        }\n      }\n    }\n  },\n  linux: {\n    ytdlp: {\n      asset: 'yt-dlp',\n      output: 'yt-dlp_linux'\n    },\n    ffmpeg: {\n      url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',\n      innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',\n      ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe',\n      output: 'ffmpeg',\n      ffprobeOutput: 'ffprobe',\n      extract: 'tar',\n      release: {\n        repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],\n        assetPattern: /ffmpeg-master-latest-linux64-gpl\\.tar\\.xz$/i,\n        binaryName: 'ffmpeg'\n      }\n    }\n  }\n}\n\n// Utility functions\nfunction log(message, type = 'info') {\n  const icons = {\n    info: '📦',\n    success: '✅',\n    error: '❌',\n    warn: '⚠️',\n    download: '⬇️'\n  }\n  console.log(`${icons[type] || 'ℹ️'} ${message}`)\n}\n\nfunction ensureDir(dir) {\n  if (!fs.existsSync(dir)) {\n    fs.mkdirSync(dir, { recursive: true })\n  }\n}\n\nfunction safeUnlink(filePath) {\n  if (fs.existsSync(filePath)) {\n    fs.unlinkSync(filePath)\n  }\n}\n\nfunction getDownloadHeaders(url) {\n  const headers = {\n    'User-Agent': 'vidbee-setup',\n    Accept: '*/*'\n  }\n  if (GITHUB_TOKEN && /github\\.com|githubusercontent\\.com/.test(url)) {\n    headers.Authorization = `Bearer ${GITHUB_TOKEN}`\n  }\n  return headers\n}\n\nfunction downloadFile(url, dest) {\n  return new Promise((resolve, reject) => {\n    const protocol = url.startsWith('https') ? https : http\n    const file = fs.createWriteStream(dest)\n    let downloadedBytes = 0\n    let isSettled = false\n\n    const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {\n      const settleRequest = () => {\n        if (isSettled) {\n          return false\n        }\n        isSettled = true\n        request.setTimeout(0)\n        return true\n      }\n\n      if (response.statusCode === 302 || response.statusCode === 301) {\n        // Handle redirect\n        if (!settleRequest()) {\n          return\n        }\n        response.resume()\n        file.close()\n        safeUnlink(dest)\n        const redirectUrl = response.headers.location\n        if (!redirectUrl) {\n          return reject(new Error(`Redirect without location for ${url}`))\n        }\n        log(`Redirected to ${redirectUrl}`, 'info')\n        return downloadFile(redirectUrl, dest).then(resolve).catch(reject)\n      }\n\n      const contentLength = response.headers['content-length']\n      if (response.statusCode !== 200) {\n        if (!settleRequest()) {\n          return\n        }\n        response.resume()\n        file.close()\n        safeUnlink(dest)\n        return reject(\n          new Error(\n            `Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`\n          )\n        )\n      }\n\n      response.on('data', (chunk) => {\n        downloadedBytes += chunk.length\n      })\n\n      response.pipe(file)\n      file.on('finish', () => {\n        if (!settleRequest()) {\n          return\n        }\n        file.close()\n        log(\n          `Downloaded ${formatBytes(downloadedBytes)} from ${url}`,\n          downloadedBytes ? 'success' : 'warn'\n        )\n        resolve()\n      })\n    })\n\n    request.setTimeout(30_000, () => {\n      if (isSettled) {\n        return\n      }\n      request.destroy(new Error('Download timeout'))\n    })\n\n    request.on('error', (err) => {\n      if (isSettled) {\n        return\n      }\n      isSettled = true\n      request.setTimeout(0)\n      file.close()\n      safeUnlink(dest)\n      log(`Download error for ${url}: ${err.message}`, 'error')\n      reject(err)\n    })\n  })\n}\n\nasync function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {\n  let lastError\n  for (let attempt = 1; attempt <= retries; attempt += 1) {\n    try {\n      log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')\n      await downloadFile(url, dest)\n      return\n    } catch (error) {\n      lastError = error\n      safeUnlink(dest)\n      if (attempt < retries) {\n        const backoff = delayMs * attempt\n        log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')\n        await new Promise((resolve) => setTimeout(resolve, backoff))\n      }\n    }\n  }\n  throw lastError\n}\n\nfunction fetchJson(url) {\n  return new Promise((resolve, reject) => {\n    const protocol = url.startsWith('https') ? https : http\n    const headers = {\n      'User-Agent': 'vidbee-setup',\n      Accept: 'application/vnd.github+json'\n    }\n    if (GITHUB_TOKEN) {\n      headers.Authorization = `Bearer ${GITHUB_TOKEN}`\n    }\n\n    protocol\n      .get(url, { headers }, (response) => {\n        if (response.statusCode === 302 || response.statusCode === 301) {\n          return fetchJson(response.headers.location).then(resolve).catch(reject)\n        }\n        if (response.statusCode !== 200) {\n          return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))\n        }\n\n        let body = ''\n        response.on('data', (chunk) => {\n          body += chunk\n        })\n        response.on('end', () => {\n          try {\n            resolve(JSON.parse(body))\n          } catch (error) {\n            reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))\n          }\n        })\n      })\n      .on('error', (err) => {\n        reject(err)\n      })\n  })\n}\n\nfunction inferFfmpegInnerPath(assetName, binaryName) {\n  if (!assetName) {\n    return null\n  }\n  const match = assetName.match(/^(.*)\\.(tar\\.xz|zip)$/i)\n  if (!match) {\n    return null\n  }\n  return `${match[1]}/bin/${binaryName}`\n}\n\nasync function resolveReleaseAsset(release) {\n  if (!release) {\n    return null\n  }\n  const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])\n  if (repoCandidates.length === 0) {\n    return null\n  }\n\n  let lastError\n  for (const repo of repoCandidates) {\n    try {\n      const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)\n      const assets = Array.isArray(data.assets) ? data.assets : []\n      const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))\n      if (match?.browser_download_url) {\n        return { name: match.name, url: match.browser_download_url }\n      }\n      lastError = new Error(`No matching assets found in ${repo}`)\n    } catch (error) {\n      lastError = error\n    }\n  }\n\n  if (lastError) {\n    throw lastError\n  }\n  return null\n}\n\nfunction extractZip(zipPath, extractDir) {\n  const platform = os.platform()\n  ensureDir(extractDir)\n\n  if (platform === 'win32') {\n    // Use PowerShell Expand-Archive on Windows\n    try {\n      const zipAbsPath = path.resolve(zipPath)\n      const extractAbsDir = path.resolve(extractDir)\n      execSync(\n        `powershell -NoProfile -Command \"Expand-Archive -Path '${zipAbsPath.replace(/'/g, \"''\")}' -DestinationPath '${extractAbsDir.replace(/'/g, \"''\")}' -Force\"`,\n        { stdio: 'inherit' }\n      )\n    } catch (error) {\n      throw new Error(`Failed to extract zip: ${error.message}`)\n    }\n  } else {\n    // Use unzip command on macOS/Linux\n    try {\n      execSync(`unzip -q \"${zipPath}\" -d \"${extractDir}\"`, { stdio: 'inherit' })\n    } catch (error) {\n      throw new Error(`Failed to extract zip: ${error.message}`)\n    }\n  }\n}\n\nfunction extractTarXz(tarPath, extractDir) {\n  ensureDir(extractDir)\n  execSync(`tar -xf \"${tarPath}\" -C \"${extractDir}\"`, { stdio: 'inherit' })\n}\n\nfunction setExecutable(filePath) {\n  if (os.platform() !== 'win32') {\n    fs.chmodSync(filePath, 0o755)\n  }\n}\n\nfunction fileExists(filePath) {\n  return fs.existsSync(filePath)\n}\n\nfunction findFirstFileByName(dirPath, fileName) {\n  if (!fileExists(dirPath)) {\n    return null\n  }\n\n  for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {\n    const fullPath = path.join(dirPath, entry.name)\n    if (entry.isFile() && entry.name === fileName) {\n      return fullPath\n    }\n    if (entry.isDirectory()) {\n      const found = findFirstFileByName(fullPath, fileName)\n      if (found) {\n        return found\n      }\n    }\n  }\n\n  return null\n}\n\nfunction formatBytes(bytes) {\n  if (!bytes || bytes <= 0) {\n    return 'unknown size'\n  }\n  if (bytes >= 1024 * 1024) {\n    return `${Math.round(bytes / (1024 * 1024))} MB`\n  }\n  return `${Math.round(bytes / 1024)} KB`\n}\n\nfunction checkBinary(filePath, args, label, options = {}) {\n  const timeoutMs =\n    typeof options.timeoutMs === 'number'\n      ? options.timeoutMs\n      : os.platform() === 'win32'\n        ? 20_000\n        : 8000\n  const result = spawnSync(filePath, args, {\n    encoding: 'utf8',\n    timeout: timeoutMs,\n    windowsHide: true\n  })\n\n  if (result.error) {\n    return { ok: false, message: result.error.message, code: result.error.code }\n  }\n\n  if (result.status !== 0) {\n    const output = `${result.stdout || ''}\\n${result.stderr || ''}`.trim()\n    return { ok: false, message: output || `exit code ${result.status}` }\n  }\n\n  const output = `${result.stdout || ''}\\n${result.stderr || ''}`.trim()\n  const firstLine = output.split(/\\r?\\n/).find((line) => line.trim())\n  return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }\n}\n\nfunction logBinaryVersion(label, validation) {\n  if (!validation.ok) {\n    return\n  }\n  log(`${label} version: ${validation.message}`, 'info')\n}\n\nfunction getDenoAssetName(platform, arch) {\n  if (platform === 'win32') {\n    if (arch === 'arm64') {\n      return 'deno-aarch64-pc-windows-msvc.zip'\n    }\n    return 'deno-x86_64-pc-windows-msvc.zip'\n  }\n  if (platform === 'darwin') {\n    if (arch === 'arm64') {\n      return 'deno-aarch64-apple-darwin.zip'\n    }\n    return 'deno-x86_64-apple-darwin.zip'\n  }\n  if (platform === 'linux') {\n    if (arch === 'arm64') {\n      return 'deno-aarch64-unknown-linux-gnu.zip'\n    }\n    return 'deno-x86_64-unknown-linux-gnu.zip'\n  }\n  return null\n}\n\nfunction getDenoOutputName(platform) {\n  return platform === 'win32' ? 'deno.exe' : 'deno'\n}\n\nfunction getMacFfmpegMode() {\n  if (MAC_FFMPEG_MODE === 'native' || MAC_FFMPEG_MODE === 'universal') {\n    return MAC_FFMPEG_MODE\n  }\n\n  throw new Error(\n    `Unsupported VIDBEE_MAC_FFMPEG_MODE value \"${MAC_FFMPEG_MODE}\". Expected \"native\" or \"universal\".`\n  )\n}\n\nfunction hasRequiredMacArchitectures(filePath, expectedArchitectures) {\n  const result = spawnSync('lipo', ['-archs', filePath], {\n    encoding: 'utf8'\n  })\n\n  if (result.error) {\n    throw new Error(`Failed to inspect Mach-O architectures: ${result.error.message}`)\n  }\n\n  if (result.status !== 0) {\n    const output = `${result.stdout || ''}\\n${result.stderr || ''}`.trim()\n    throw new Error(\n      `Failed to inspect Mach-O architectures: ${output || `exit code ${result.status}`}`\n    )\n  }\n\n  const availableArchitectures = result.stdout\n    .trim()\n    .split(/\\s+/)\n    .filter((value) => value.length > 0)\n\n  return expectedArchitectures.every((architecture) =>\n    availableArchitectures.includes(architecture)\n  )\n}\n\nfunction runCommandOrThrow(command, args, label) {\n  const result = spawnSync(command, args, {\n    encoding: 'utf8'\n  })\n\n  if (result.error) {\n    throw new Error(`${label} failed: ${result.error.message}`)\n  }\n\n  if (result.status !== 0) {\n    const output = `${result.stdout || ''}\\n${result.stderr || ''}`.trim()\n    throw new Error(`${label} failed: ${output || `exit code ${result.status}`}`)\n  }\n}\n\nfunction resolveMacExtractedBinary(extractDir, expectedInnerPath, binaryName) {\n  const expectedPath = path.join(extractDir, expectedInnerPath)\n  if (fileExists(expectedPath)) {\n    return expectedPath\n  }\n\n  const discoveredPath = findFirstFileByName(extractDir, binaryName)\n  if (discoveredPath) {\n    return discoveredPath\n  }\n\n  throw new Error(`${binaryName} binary not found under ${extractDir}`)\n}\n\nasync function resolveMacFfmpegDownloadUrl(ffmpegConfig) {\n  let downloadUrl = ffmpegConfig.url\n\n  if (ffmpegConfig.release) {\n    try {\n      const resolved = await resolveReleaseAsset(ffmpegConfig.release)\n      if (resolved) {\n        downloadUrl = resolved.url\n      }\n    } catch (error) {\n      log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')\n    }\n  }\n\n  return downloadUrl\n}\n\n// Main download functions\nasync function downloadYtDlp(config) {\n  const { asset, output } = config.ytdlp\n  const outputPath = path.join(RESOURCES_DIR, output)\n\n  if (fileExists(outputPath)) {\n    const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')\n    if (validation.ok) {\n      logBinaryVersion('yt-dlp', validation)\n    } else {\n      log(`Existing ${output} failed version check: ${validation.message}`, 'warn')\n    }\n    log(`${output} already exists, skipping download`, 'info')\n    return\n  }\n\n  log(`Downloading ${asset}...`, 'download')\n  const url = `${YTDLP_BASE_URL}/${asset}`\n  const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)\n\n  try {\n    await downloadFileWithRetry(url, tempPath)\n    fs.renameSync(tempPath, outputPath)\n    setExecutable(outputPath)\n    const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')\n    if (!validation.ok) {\n      safeUnlink(outputPath)\n      throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)\n    }\n    logBinaryVersion('yt-dlp', validation)\n    log(`Downloaded ${output} successfully`, 'success')\n  } catch (error) {\n    if (fs.existsSync(tempPath)) {\n      fs.unlinkSync(tempPath)\n    }\n    throw error\n  }\n}\n\nasync function downloadFfmpegWindows(config) {\n  const {\n    url: fallbackUrl,\n    innerPath: fallbackInnerPath,\n    ffprobeInnerPath: fallbackFfprobeInnerPath,\n    output,\n    ffprobeOutput,\n    release\n  } = config.ffmpeg\n  const outputPath = path.join(FFMPEG_DIR, output)\n  const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null\n\n  const ffmpegExists = fileExists(outputPath)\n  const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true\n\n  if (ffmpegExists && ffprobeExists) {\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    const ffprobeValidation = ffprobeOutputPath\n      ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n      : { ok: true }\n    if (validation.ok && ffprobeValidation.ok) {\n      logBinaryVersion('ffmpeg', validation)\n      if (ffprobeOutputPath) {\n        logBinaryVersion('ffprobe', ffprobeValidation)\n      }\n      log('ffmpeg and ffprobe already exist, skipping download', 'info')\n      return\n    }\n    log(\n      `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,\n      'warn'\n    )\n  }\n\n  log('Downloading ffmpeg for Windows...', 'download')\n  ensureDir(FFMPEG_DIR)\n  const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')\n  const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')\n  let downloadUrl = fallbackUrl\n  let innerPath = fallbackInnerPath\n  let ffprobeInnerPath = fallbackFfprobeInnerPath\n\n  if (release) {\n    try {\n      const resolved = await resolveReleaseAsset(release)\n      if (resolved) {\n        downloadUrl = resolved.url\n        const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')\n        if (inferred) {\n          innerPath = inferred\n        }\n        const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')\n        if (inferredFfprobe) {\n          ffprobeInnerPath = inferredFfprobe\n        }\n      }\n    } catch (error) {\n      log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')\n    }\n  }\n\n  try {\n    await downloadFileWithRetry(downloadUrl, tempZip)\n    log('Extracting ffmpeg...', 'info')\n    extractZip(tempZip, extractDir)\n\n    const sourcePath = path.join(extractDir, innerPath.replace(/\\\\/g, path.sep))\n    if (!fileExists(sourcePath)) {\n      throw new Error(`ffmpeg binary not found at ${sourcePath}`)\n    }\n\n    fs.copyFileSync(sourcePath, outputPath)\n    if (ffprobeInnerPath && ffprobeOutputPath) {\n      const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath.replace(/\\\\/g, path.sep))\n      if (!fileExists(ffprobeSourcePath)) {\n        throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)\n      }\n      fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)\n    }\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    if (validation.ok) {\n      logBinaryVersion('ffmpeg', validation)\n      if (ffprobeOutputPath) {\n        const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n        if (ffprobeValidation.ok) {\n          logBinaryVersion('ffprobe', ffprobeValidation)\n        }\n      }\n      log(`Downloaded ${output} successfully`, 'success')\n    } else if (validation.code === 'ETIMEDOUT') {\n      log(`Downloaded ${output} version check timed out; keeping binary`, 'warn')\n    } else {\n      safeUnlink(outputPath)\n      throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)\n    }\n\n    // Cleanup\n    fs.unlinkSync(tempZip)\n    fs.rmSync(extractDir, { recursive: true, force: true })\n  } catch (error) {\n    if (fs.existsSync(tempZip)) {\n      fs.unlinkSync(tempZip)\n    }\n    if (fs.existsSync(extractDir)) {\n      fs.rmSync(extractDir, { recursive: true, force: true })\n    }\n    throw error\n  }\n}\n\nasync function downloadFfmpegMac(config) {\n  const mode = getMacFfmpegMode()\n  const currentArchitecture = os.arch() === 'arm64' ? 'arm64' : 'x64'\n  const targetArchitectures = mode === 'universal' ? ['arm64', 'x64'] : [currentArchitecture]\n\n  const ffmpegConfig = config.ffmpeg[targetArchitectures[0]]\n  if (!ffmpegConfig) {\n    throw new Error(`Unsupported architecture: ${currentArchitecture}`)\n  }\n\n  const { output, ffprobeOutput } = ffmpegConfig\n  const outputPath = path.join(FFMPEG_DIR, output)\n  const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null\n\n  const ffmpegExists = fileExists(outputPath)\n  const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true\n\n  if (ffmpegExists && ffprobeExists) {\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    const ffprobeValidation = ffprobeOutputPath\n      ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n      : { ok: true }\n\n    let hasExpectedArchitectures = true\n    if (mode === 'universal' && ffprobeOutputPath) {\n      try {\n        hasExpectedArchitectures =\n          hasRequiredMacArchitectures(outputPath, ['arm64', 'x86_64']) &&\n          hasRequiredMacArchitectures(ffprobeOutputPath, ['arm64', 'x86_64'])\n      } catch (error) {\n        hasExpectedArchitectures = false\n        log(`Failed to validate existing universal ffmpeg binaries: ${error.message}`, 'warn')\n      }\n    }\n\n    if (validation.ok && ffprobeValidation.ok && hasExpectedArchitectures) {\n      logBinaryVersion('ffmpeg', validation)\n      if (ffprobeOutputPath) {\n        logBinaryVersion('ffprobe', ffprobeValidation)\n      }\n      log(\n        `ffmpeg and ffprobe already exist for macOS (${mode === 'universal' ? 'universal' : currentArchitecture}), skipping download`,\n        'info'\n      )\n      return\n    }\n\n    log(\n      `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,\n      'warn'\n    )\n  }\n\n  log(\n    `Downloading ffmpeg for macOS (${mode === 'universal' ? 'universal' : currentArchitecture})...`,\n    'download'\n  )\n  ensureDir(FFMPEG_DIR)\n  const tempArtifacts = targetArchitectures.map((targetArchitecture) => ({\n    key: targetArchitecture,\n    tempZip: path.join(RESOURCES_DIR, `ffmpeg-${targetArchitecture}.zip`),\n    extractDir: path.join(RESOURCES_DIR, `ffmpeg-${targetArchitecture}`)\n  }))\n\n  try {\n    const resolvedBinaries = []\n\n    for (const targetArchitecture of targetArchitectures) {\n      const targetConfig = config.ffmpeg[targetArchitecture]\n      if (!targetConfig) {\n        throw new Error(`Unsupported macOS ffmpeg architecture: ${targetArchitecture}`)\n      }\n\n      const tempArtifact = tempArtifacts.find((artifact) => artifact.key === targetArchitecture)\n      if (!tempArtifact) {\n        throw new Error(`Temporary artifact not configured for ${targetArchitecture}`)\n      }\n\n      const downloadUrl = await resolveMacFfmpegDownloadUrl(targetConfig)\n      await downloadFileWithRetry(downloadUrl, tempArtifact.tempZip)\n      log(`Extracting ffmpeg for macOS (${targetArchitecture})...`, 'info')\n      extractZip(tempArtifact.tempZip, tempArtifact.extractDir)\n\n      resolvedBinaries.push({\n        ffmpegPath: resolveMacExtractedBinary(\n          tempArtifact.extractDir,\n          targetConfig.innerPath,\n          'ffmpeg'\n        ),\n        ffprobePath: resolveMacExtractedBinary(\n          tempArtifact.extractDir,\n          targetConfig.ffprobeInnerPath,\n          'ffprobe'\n        )\n      })\n    }\n\n    if (mode === 'universal') {\n      if (!ffprobeOutputPath) {\n        throw new Error('Universal macOS ffprobe output path is required.')\n      }\n\n      runCommandOrThrow(\n        'lipo',\n        ['-create', ...resolvedBinaries.map((binary) => binary.ffmpegPath), '-output', outputPath],\n        'Creating universal ffmpeg binary'\n      )\n      runCommandOrThrow(\n        'lipo',\n        [\n          '-create',\n          ...resolvedBinaries.map((binary) => binary.ffprobePath),\n          '-output',\n          ffprobeOutputPath\n        ],\n        'Creating universal ffprobe binary'\n      )\n    } else {\n      fs.copyFileSync(resolvedBinaries[0].ffmpegPath, outputPath)\n      if (ffprobeOutputPath) {\n        fs.copyFileSync(resolvedBinaries[0].ffprobePath, ffprobeOutputPath)\n      }\n    }\n\n    setExecutable(outputPath)\n    if (ffprobeOutputPath) {\n      setExecutable(ffprobeOutputPath)\n    }\n\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    if (!validation.ok) {\n      safeUnlink(outputPath)\n      safeUnlink(ffprobeOutputPath)\n      throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)\n    }\n\n    if (mode === 'universal' && ffprobeOutputPath) {\n      const isUniversal =\n        hasRequiredMacArchitectures(outputPath, ['arm64', 'x86_64']) &&\n        hasRequiredMacArchitectures(ffprobeOutputPath, ['arm64', 'x86_64'])\n      if (!isUniversal) {\n        safeUnlink(outputPath)\n        safeUnlink(ffprobeOutputPath)\n        throw new Error(\n          'Created macOS ffmpeg binaries are missing required universal architectures.'\n        )\n      }\n    }\n\n    logBinaryVersion('ffmpeg', validation)\n    if (ffprobeOutputPath) {\n      const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n      logBinaryVersion('ffprobe', ffprobeValidation)\n    }\n    log(`Downloaded ${output} successfully`, 'success')\n  } catch (error) {\n    safeUnlink(outputPath)\n    safeUnlink(ffprobeOutputPath)\n    throw error\n  } finally {\n    for (const tempArtifact of tempArtifacts) {\n      safeUnlink(tempArtifact.tempZip)\n      if (fs.existsSync(tempArtifact.extractDir)) {\n        fs.rmSync(tempArtifact.extractDir, { recursive: true, force: true })\n      }\n    }\n  }\n}\n\nasync function downloadFfmpegLinux(config) {\n  const {\n    url: fallbackUrl,\n    innerPath: fallbackInnerPath,\n    ffprobeInnerPath: fallbackFfprobeInnerPath,\n    output,\n    ffprobeOutput,\n    release\n  } = config.ffmpeg\n  const outputPath = path.join(FFMPEG_DIR, output)\n  const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null\n\n  const ffmpegExists = fileExists(outputPath)\n  const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true\n\n  if (ffmpegExists && ffprobeExists) {\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    const ffprobeValidation = ffprobeOutputPath\n      ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n      : { ok: true }\n    if (validation.ok && ffprobeValidation.ok) {\n      logBinaryVersion('ffmpeg', validation)\n      if (ffprobeOutputPath) {\n        logBinaryVersion('ffprobe', ffprobeValidation)\n      }\n      log('ffmpeg and ffprobe already exist, skipping download', 'info')\n      return\n    }\n    log(\n      `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,\n      'warn'\n    )\n  }\n\n  log('Downloading ffmpeg for Linux...', 'download')\n  ensureDir(FFMPEG_DIR)\n  const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')\n  const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')\n  let downloadUrl = fallbackUrl\n  let innerPath = fallbackInnerPath\n  let ffprobeInnerPath = fallbackFfprobeInnerPath\n\n  if (release) {\n    try {\n      const resolved = await resolveReleaseAsset(release)\n      if (resolved) {\n        downloadUrl = resolved.url\n        const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')\n        if (inferred) {\n          innerPath = inferred\n        }\n        const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')\n        if (inferredFfprobe) {\n          ffprobeInnerPath = inferredFfprobe\n        }\n      }\n    } catch (error) {\n      log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')\n    }\n  }\n\n  try {\n    await downloadFileWithRetry(downloadUrl, tempTar)\n    log('Extracting ffmpeg...', 'info')\n    extractTarXz(tempTar, extractDir)\n\n    const sourcePath = path.join(extractDir, innerPath)\n    if (!fileExists(sourcePath)) {\n      throw new Error(`ffmpeg binary not found at ${sourcePath}`)\n    }\n\n    fs.copyFileSync(sourcePath, outputPath)\n    setExecutable(outputPath)\n    if (ffprobeInnerPath && ffprobeOutputPath) {\n      const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)\n      if (!fileExists(ffprobeSourcePath)) {\n        throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)\n      }\n      fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)\n      setExecutable(ffprobeOutputPath)\n    }\n    const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')\n    if (!validation.ok) {\n      safeUnlink(outputPath)\n      throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)\n    }\n    logBinaryVersion('ffmpeg', validation)\n    if (ffprobeOutputPath) {\n      const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')\n      logBinaryVersion('ffprobe', ffprobeValidation)\n    }\n    log(`Downloaded ${output} successfully`, 'success')\n\n    // Cleanup\n    fs.unlinkSync(tempTar)\n    fs.rmSync(extractDir, { recursive: true, force: true })\n  } catch (error) {\n    if (fs.existsSync(tempTar)) {\n      fs.unlinkSync(tempTar)\n    }\n    if (fs.existsSync(extractDir)) {\n      fs.rmSync(extractDir, { recursive: true, force: true })\n    }\n    throw error\n  }\n}\n\nasync function downloadDenoRuntime() {\n  const platform = os.platform()\n  const arch = os.arch()\n  const assetName = getDenoAssetName(platform, arch)\n\n  if (!assetName) {\n    log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')\n    return\n  }\n\n  const outputName = getDenoOutputName(platform)\n  const outputPath = path.join(RESOURCES_DIR, outputName)\n\n  if (fileExists(outputPath)) {\n    const validation = checkBinary(outputPath, ['--version'], 'deno')\n    if (validation.ok) {\n      logBinaryVersion('deno', validation)\n    } else {\n      log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')\n    }\n    log(`${outputName} already exists, skipping download`, 'info')\n    return\n  }\n\n  log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')\n  const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')\n  const extractDir = path.join(RESOURCES_DIR, 'deno-temp')\n  const downloadUrl = `${DENO_BASE_URL}/${assetName}`\n\n  try {\n    await downloadFileWithRetry(downloadUrl, tempZip)\n    log('Extracting Deno runtime...', 'info')\n    extractZip(tempZip, extractDir)\n\n    const sourcePath = path.join(extractDir, outputName)\n    if (!fileExists(sourcePath)) {\n      throw new Error(`Deno binary not found at ${sourcePath}`)\n    }\n\n    fs.copyFileSync(sourcePath, outputPath)\n    setExecutable(outputPath)\n    const validation = checkBinary(outputPath, ['--version'], 'deno')\n    if (!validation.ok) {\n      safeUnlink(outputPath)\n      throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)\n    }\n    logBinaryVersion('deno', validation)\n    log(`Downloaded ${outputName} successfully`, 'success')\n\n    fs.unlinkSync(tempZip)\n    fs.rmSync(extractDir, { recursive: true, force: true })\n  } catch (error) {\n    if (fs.existsSync(tempZip)) {\n      fs.unlinkSync(tempZip)\n    }\n    if (fs.existsSync(extractDir)) {\n      fs.rmSync(extractDir, { recursive: true, force: true })\n    }\n    throw error\n  }\n}\n\n// Main setup function\nasync function setup() {\n  const platform = os.platform()\n  const config = PLATFORM_CONFIG[platform]\n\n  if (!config) {\n    log(`Unsupported platform: ${platform}`, 'error')\n    process.exit(1)\n  }\n\n  log(`Setting up development binaries for ${platform}...`, 'info')\n  ensureDir(RESOURCES_DIR)\n\n  try {\n    // Download yt-dlp\n    await downloadYtDlp(config)\n\n    // Download JS runtime (Deno)\n    await downloadDenoRuntime()\n\n    // Download ffmpeg\n    if (platform === 'win32') {\n      await downloadFfmpegWindows(config)\n    } else if (platform === 'darwin') {\n      await downloadFfmpegMac(config)\n    } else if (platform === 'linux') {\n      await downloadFfmpegLinux(config)\n    }\n\n    log('Development environment setup completed!', 'success')\n  } catch (error) {\n    log(`Setup failed: ${error.message}`, 'error')\n    process.exit(1)\n  }\n}\n\n// Run setup when executed directly\nconst isDirectExecution =\n  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFilePath)\n\nif (isDirectExecution) {\n  setup()\n}\n\nexport { setup }\n"
  },
  {
    "path": "apps/desktop/scripts/ytdlp-auto-release.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst scriptDir = path.dirname(fileURLToPath(import.meta.url))\nconst desktopDir = path.join(scriptDir, '..')\nconst packageJsonPath = path.join(desktopDir, 'package.json')\nconst changelogPath = path.join(desktopDir, 'changelogs', 'CHANGELOG.md')\nconst releaseMetadataPath = path.join(desktopDir, 'release-metadata.json')\nconst shanghaiDateFormatter = new Intl.DateTimeFormat('en-US', {\n  timeZone: 'Asia/Shanghai',\n  year: 'numeric',\n  month: '2-digit',\n  day: '2-digit'\n})\n\nconst command = process.argv[2]\nconst args = process.argv.slice(3)\n\nconst readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))\n\nconst writeJson = (filePath, data) => {\n  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\\n`)\n}\n\nconst setOutput = (name, value) => {\n  const outputPath = process.env.GITHUB_OUTPUT\n  if (!outputPath) {\n    return\n  }\n\n  fs.appendFileSync(outputPath, `${name}=${value}\\n`)\n}\n\nconst getArgValue = (name) => {\n  const index = args.indexOf(name)\n  if (index === -1) {\n    return undefined\n  }\n\n  return args[index + 1]\n}\n\nconst hasFlag = (name) => args.includes(name)\n\nconst formatShanghaiDate = (date = new Date()) => {\n  const parts = Object.fromEntries(\n    shanghaiDateFormatter.formatToParts(date).map((part) => [part.type, part.value])\n  )\n\n  return `${parts.year}-${parts.month}-${parts.day}`\n}\n\nconst normalizeVersion = (version) => version.replace(/^v/i, '').trim()\n\nconst getNextPatchVersion = (currentVersion) => {\n  const match = currentVersion.trim().match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+].*)?$/)\n\n  if (!match) {\n    throw new Error(`Unsupported app version format: ${currentVersion}`)\n  }\n\n  const [, major, minor, patch] = match\n  return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`\n}\n\nconst getAuthHeaders = () => {\n  const token =\n    process.env.YTDLP_RELEASE_TOKEN ??\n    process.env.GITHUB_TOKEN ??\n    process.env.GH_TOKEN ??\n    process.env.ACCESS_TOKEN\n\n  if (!token) {\n    return {\n      Accept: 'application/vnd.github+json',\n      'User-Agent': 'VidBee ytdlp auto release'\n    }\n  }\n\n  return {\n    Accept: 'application/vnd.github+json',\n    Authorization: `Bearer ${token}`,\n    'User-Agent': 'VidBee ytdlp auto release'\n  }\n}\n\nconst fetchLatestYtDlpVersion = async (overrideVersion) => {\n  if (overrideVersion) {\n    return normalizeVersion(overrideVersion)\n  }\n\n  const response = await fetch('https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest', {\n    headers: getAuthHeaders()\n  })\n\n  if (!response.ok) {\n    throw new Error(\n      `Failed to fetch yt-dlp latest release: ${response.status} ${response.statusText}`\n    )\n  }\n\n  const data = await response.json()\n  const tagName = typeof data.tag_name === 'string' ? data.tag_name : ''\n\n  if (!tagName) {\n    throw new Error('yt-dlp latest release response did not include tag_name')\n  }\n\n  return normalizeVersion(tagName)\n}\n\nconst prependChangelogEntry = ({\n  changelogContent,\n  releaseVersion,\n  releaseDate,\n  previousYtDlpVersion,\n  latestYtDlpVersion\n}) => {\n  const versionSectionIndex = changelogContent.indexOf('\\n## [')\n\n  if (versionSectionIndex === -1) {\n    throw new Error('Unable to find the first version heading in CHANGELOG.md')\n  }\n\n  const changelogEntry = [\n    `## [v${releaseVersion}](https://github.com/nexmoe/VidBee/releases/tag/v${releaseVersion}) - ${releaseDate}`,\n    '### Requirement Updates',\n    `- Updated the bundled yt-dlp runtime from v${previousYtDlpVersion} to v${latestYtDlpVersion} so site compatibility stays current.`,\n    ''\n  ].join('\\n')\n\n  return `${changelogContent.slice(0, versionSectionIndex + 1)}${changelogEntry}${changelogContent.slice(versionSectionIndex + 1)}`\n}\n\nconst readReleaseState = () => {\n  const packageJson = readJson(packageJsonPath)\n  const releaseMetadata = readJson(releaseMetadataPath)\n\n  return {\n    packageJson,\n    currentAppVersion: packageJson.version,\n    currentYtDlpVersion: normalizeVersion(releaseMetadata.ytDlpVersion ?? '')\n  }\n}\n\nconst runCheck = async () => {\n  const latestYtDlpVersion = await fetchLatestYtDlpVersion(getArgValue('--latest-version'))\n  const { currentYtDlpVersion } = readReleaseState()\n  const updateAvailable = latestYtDlpVersion !== currentYtDlpVersion\n\n  console.log(`Tracked yt-dlp version: ${currentYtDlpVersion || 'unknown'}`)\n  console.log(`Latest yt-dlp version: ${latestYtDlpVersion}`)\n  console.log(updateAvailable ? 'yt-dlp update detected.' : 'yt-dlp is already up to date.')\n\n  setOutput('current_ytdlp_version', currentYtDlpVersion)\n  setOutput('latest_ytdlp_version', latestYtDlpVersion)\n  setOutput('update_available', String(updateAvailable))\n}\n\nconst runPrepare = async () => {\n  const latestYtDlpVersion = await fetchLatestYtDlpVersion(getArgValue('--latest-version'))\n  const releaseDate = getArgValue('--release-date') ?? formatShanghaiDate()\n  const dryRun = hasFlag('--dry-run')\n  const { packageJson, currentAppVersion, currentYtDlpVersion } = readReleaseState()\n\n  if (latestYtDlpVersion === currentYtDlpVersion) {\n    console.log('yt-dlp is already up to date. No release changes prepared.')\n    setOutput('update_available', 'false')\n    setOutput('current_ytdlp_version', currentYtDlpVersion)\n    setOutput('latest_ytdlp_version', latestYtDlpVersion)\n    setOutput('release_version', currentAppVersion)\n    return\n  }\n\n  const releaseVersion = getNextPatchVersion(currentAppVersion)\n  const updatedPackageJson = { ...packageJson, version: releaseVersion }\n  const currentChangelog = fs.readFileSync(changelogPath, 'utf8')\n  const updatedChangelog = prependChangelogEntry({\n    changelogContent: currentChangelog,\n    releaseVersion,\n    releaseDate,\n    previousYtDlpVersion: currentYtDlpVersion,\n    latestYtDlpVersion\n  })\n  const updatedReleaseMetadata = {\n    ytDlpVersion: latestYtDlpVersion\n  }\n\n  if (!dryRun) {\n    writeJson(packageJsonPath, updatedPackageJson)\n    writeJson(releaseMetadataPath, updatedReleaseMetadata)\n    fs.writeFileSync(changelogPath, updatedChangelog)\n  }\n\n  console.log(`Prepared patch release ${currentAppVersion} -> ${releaseVersion}`)\n  console.log(`Bundled yt-dlp ${currentYtDlpVersion} -> ${latestYtDlpVersion}`)\n  if (dryRun) {\n    console.log('Dry run enabled. No files were written.')\n  }\n\n  setOutput('update_available', 'true')\n  setOutput('current_ytdlp_version', currentYtDlpVersion)\n  setOutput('latest_ytdlp_version', latestYtDlpVersion)\n  setOutput('release_date', releaseDate)\n  setOutput('release_version', releaseVersion)\n}\n\nconst main = async () => {\n  if (command === 'check') {\n    await runCheck()\n    return\n  }\n\n  if (command === 'prepare') {\n    await runPrepare()\n    return\n  }\n\n  console.error(\n    'Usage: node scripts/ytdlp-auto-release.mjs <check|prepare> [--latest-version <version>] [--release-date <YYYY-MM-DD>] [--dry-run]'\n  )\n  process.exit(1)\n}\n\nmain().catch((error) => {\n  const message = error instanceof Error ? error.message : String(error)\n  console.error(message)\n  process.exit(1)\n})\n"
  },
  {
    "path": "apps/desktop/src/main/assets.d.ts",
    "content": "/// <reference types=\"electron-vite/node\" />\n\ndeclare module '*.png?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.ico?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.icns?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.jpg?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.jpeg?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.gif?asset' {\n  const value: string\n  export default value\n}\n\ndeclare module '*.svg?asset' {\n  const value: string\n  export default value\n}\n"
  },
  {
    "path": "apps/desktop/src/main/config/logger-config.ts",
    "content": "import log from 'electron-log/main'\n\n/**\n * Configure electron-log\n * Set log format, file path, transport methods, etc.\n */\nexport function configureLogger() {\n  // Set log levels\n  // Development: show all logs\n  // Production: show info level and above only\n  const isDev = process.env.NODE_ENV === 'development'\n  log.transports.console.level = isDev ? 'silly' : 'info'\n  log.transports.file.level = isDev ? 'silly' : 'info'\n\n  // Enable IPC transport in development environment to show renderer process logs in main process console\n  if (isDev) {\n    log.transports.ipc.level = 'silly'\n  } else {\n    log.transports.ipc.level = false\n  }\n\n  // Set maximum log file size (10MB)\n  log.transports.file.maxSize = 10 * 1024 * 1024\n\n  // Enable error catching - catch unhandled errors and rejected promises\n  log.errorHandler.startCatching({\n    showDialog: false, // Don't show error dialog, only log to file\n    onError: (options) => {\n      log.error('Unhandled error caught by electron-log:', options.error)\n      log.error('App versions:', options.versions)\n    }\n  })\n\n  log.info('Log file location:', log.transports.file.getFile().path)\n}\n"
  },
  {
    "path": "apps/desktop/src/main/download-engine/args-builder.ts",
    "content": "import {\n  buildDownloadArgs as buildSharedDownloadArgs,\n  resolveAudioFormatSelector as resolveSharedAudioFormatSelector,\n  resolveVideoFormatSelector as resolveSharedVideoFormatSelector,\n  sanitizeFilenameTemplate as sanitizeSharedFilenameTemplate,\n  type YtDlpDownloadOptions,\n  type YtDlpDownloadSettings\n} from '@vidbee/downloader-core/yt-dlp-args'\nimport type { AppSettings, DownloadOptions } from '../../shared/types'\n\nconst toSharedOptions = (options: DownloadOptions): YtDlpDownloadOptions => ({\n  url: options.url,\n  type: options.type,\n  format: options.format,\n  audioFormat: options.audioFormat,\n  audioFormatIds: options.audioFormatIds,\n  startTime: options.startTime,\n  endTime: options.endTime,\n  customDownloadPath: options.customDownloadPath,\n  customFilenameTemplate: options.customFilenameTemplate\n})\n\nconst toSharedSettings = (settings: AppSettings): YtDlpDownloadSettings => ({\n  downloadPath: settings.downloadPath,\n  browserForCookies: settings.browserForCookies,\n  cookiesPath: settings.cookiesPath,\n  proxy: settings.proxy,\n  configPath: settings.configPath,\n  embedSubs: settings.embedSubs,\n  embedThumbnail: settings.embedThumbnail,\n  embedMetadata: settings.embedMetadata,\n  embedChapters: settings.embedChapters\n})\n\nexport const sanitizeFilenameTemplate = (template: string): string =>\n  sanitizeSharedFilenameTemplate(template)\n\nexport const resolveVideoFormatSelector = (options: DownloadOptions): string =>\n  resolveSharedVideoFormatSelector(toSharedOptions(options))\n\nexport const resolveAudioFormatSelector = (options: DownloadOptions): string =>\n  resolveSharedAudioFormatSelector(toSharedOptions(options))\n\nexport const buildDownloadArgs = (\n  options: DownloadOptions,\n  downloadPath: string,\n  settings: AppSettings,\n  jsRuntimeArgs: string[] = []\n): string[] =>\n  buildSharedDownloadArgs(\n    toSharedOptions(options),\n    downloadPath,\n    toSharedSettings(settings),\n    jsRuntimeArgs\n  )\n"
  },
  {
    "path": "apps/desktop/src/main/download-engine/format-utils.ts",
    "content": "import type {\n  AppSettings,\n  DownloadOptions,\n  OneClickQualityPreset,\n  VideoFormat\n} from '../../shared/types'\n\nconst qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {\n  best: null,\n  good: 1080,\n  normal: 720,\n  bad: 480,\n  worst: 360\n}\n\nconst qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {\n  best: 320,\n  good: 256,\n  normal: 192,\n  bad: 128,\n  worst: 96\n}\n\nconst selectVideoFormatForPreset = (\n  formats: VideoFormat[],\n  preset: OneClickQualityPreset\n): VideoFormat | undefined => {\n  if (formats.length === 0) {\n    return undefined\n  }\n\n  const sorted = [...formats].sort((a, b) => {\n    const heightDiff = (b.height ?? 0) - (a.height ?? 0)\n    if (heightDiff !== 0) {\n      return heightDiff\n    }\n    const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)\n    if (fpsDiff !== 0) {\n      return fpsDiff\n    }\n    return (b.tbr ?? 0) - (a.tbr ?? 0)\n  })\n\n  if (preset === 'worst') {\n    return sorted.at(-1) ?? sorted[0]\n  }\n\n  const heightLimit = qualityPresetToVideoHeight[preset]\n  if (!heightLimit) {\n    return sorted[0]\n  }\n\n  const withinLimit = sorted.find((format) => {\n    const height = format.height ?? 0\n    return height > 0 && height <= heightLimit\n  })\n\n  return withinLimit ?? sorted[0]\n}\n\nconst selectAudioFormatForPreset = (\n  formats: VideoFormat[],\n  preset: OneClickQualityPreset\n): VideoFormat | undefined => {\n  if (formats.length === 0) {\n    return undefined\n  }\n\n  const sorted = [...formats].sort((a, b) => {\n    const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)\n    if (bitrateDiff !== 0) {\n      return bitrateDiff\n    }\n    const sizeA = a.filesize ?? a.filesize_approx ?? 0\n    const sizeB = b.filesize ?? b.filesize_approx ?? 0\n    if (sizeB !== sizeA) {\n      return sizeB - sizeA\n    }\n    return 0\n  })\n\n  if (preset === 'worst') {\n    return sorted.at(-1) ?? sorted[0]\n  }\n\n  const abrLimit = qualityPresetToAudioAbr[preset]\n  if (!abrLimit) {\n    return sorted[0]\n  }\n\n  const withinLimit = sorted.find((format) => {\n    const bitrate = format.tbr ?? 0\n    return bitrate > 0 && bitrate <= abrLimit\n  })\n\n  return withinLimit ?? sorted[0]\n}\n\nconst findFormatBySelector = (\n  formats: VideoFormat[],\n  selector?: string\n): VideoFormat | undefined => {\n  if (!selector) {\n    return undefined\n  }\n\n  const candidateIds = selector\n    .split('/')\n    .map((option) => option.split('+')[0].trim())\n    .filter((option) => option.length > 0)\n\n  for (const candidateId of candidateIds) {\n    const match = formats.find((format) => format.format_id === candidateId)\n    if (match) {\n      return match\n    }\n  }\n\n  return undefined\n}\n\nexport const findFormatByIdCandidates = (\n  formats: VideoFormat[],\n  rawFormatId: string | undefined\n): VideoFormat | undefined => {\n  if (!rawFormatId) {\n    return undefined\n  }\n\n  const parts = rawFormatId\n    .split('+')\n    .map((part) => part.trim())\n    .filter((part) => part.length > 0)\n\n  for (const part of parts) {\n    const match = formats.find((format) => format.format_id === part)\n    if (match) {\n      return match\n    }\n  }\n\n  return undefined\n}\n\nexport const parseSizeToBytes = (value?: string): number | undefined => {\n  if (!value) {\n    return undefined\n  }\n\n  const cleaned = value.trim().replace(/^~\\s*/, '')\n  if (!cleaned) {\n    return undefined\n  }\n\n  const match = cleaned.match(/^([\\d.,]+)\\s*([KMGTP]?i?B)$/i)\n  if (!match) {\n    return undefined\n  }\n\n  const amount = Number(match[1].replace(/,/g, ''))\n  if (Number.isNaN(amount)) {\n    return undefined\n  }\n\n  const unit = match[2].toUpperCase()\n  const multipliers: Record<string, number> = {\n    B: 1,\n    KB: 1000,\n    KIB: 1024,\n    MB: 1_000_000,\n    MIB: 1_048_576,\n    GB: 1_000_000_000,\n    GIB: 1_073_741_824,\n    TB: 1_000_000_000_000,\n    TIB: 1_099_511_627_776\n  }\n\n  const multiplier = multipliers[unit]\n  if (!multiplier) {\n    return undefined\n  }\n\n  return Math.round(amount * multiplier)\n}\n\nexport const resolveSelectedFormat = (\n  formats: VideoFormat[],\n  options: DownloadOptions,\n  settings: AppSettings\n): VideoFormat | undefined => {\n  const directMatch = findFormatBySelector(formats, options.format)\n  if (directMatch) {\n    return directMatch\n  }\n\n  const preset = settings.oneClickQuality ?? 'best'\n\n  if (options.type === 'video') {\n    const videoFormats = formats.filter(\n      (format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'\n    )\n    return selectVideoFormatForPreset(videoFormats, preset)\n  }\n\n  if (options.type === 'audio') {\n    const audioFormats = formats.filter(\n      (format) =>\n        !!format.acodec &&\n        format.acodec !== 'none' &&\n        (!format.video_ext || format.video_ext === 'none')\n    )\n    return selectAudioFormatForPreset(audioFormats, preset)\n  }\n\n  return undefined\n}\n"
  },
  {
    "path": "apps/desktop/src/main/index.ts",
    "content": "import { existsSync } from 'node:fs'\nimport { isAbsolute, join, relative, resolve } from 'node:path'\nimport { electronApp, optimizer } from '@electron-toolkit/utils'\nimport { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'\nimport {\n  app,\n  BrowserWindow,\n  type BrowserWindowConstructorOptions,\n  ipcMain,\n  protocol,\n  shell\n} from 'electron'\nimport log from 'electron-log/main'\nimport { autoUpdater } from 'electron-updater'\nimport appIcon from '../../build/icon.png?asset'\nimport {\n  buildAudioFormatPreference,\n  buildVideoFormatPreference\n} from '../shared/utils/format-preferences'\nimport { configureLogger } from './config/logger-config'\nimport { services } from './ipc'\nimport { downloadEngine } from './lib/download-engine'\nimport { ffmpegManager } from './lib/ffmpeg-manager'\nimport { subscriptionManager } from './lib/subscription-manager'\nimport { subscriptionScheduler } from './lib/subscription-scheduler'\nimport { ytdlpManager } from './lib/ytdlp-manager'\nimport { startExtensionApiServer, stopExtensionApiServer } from './local-api'\nimport { settingsManager } from './settings'\nimport { createTray, destroyTray } from './tray'\nimport { applyAutoLaunchSetting } from './utils/auto-launch'\nimport { applyDockVisibility } from './utils/dock'\n\n// Initialize electron-log for main process\nlog.initialize()\n\n// Configure logger settings\nconfigureLogger()\n\nif (process.platform === 'linux') {\n  // Force fallback to native GTK/KDE file dialogs when desktop portal is too old.\n  // This avoids folder selection issues on older Linux distributions.\n  app.commandLine.appendSwitch('xdg-portal-required-version', '4')\n}\n\nconst RENDERER_DIST_PATH = join(import.meta.dirname, '../renderer')\n\nprotocol.registerSchemesAsPrivileged([\n  {\n    scheme: APP_PROTOCOL,\n    privileges: {\n      secure: true,\n      standard: true,\n      supportFetchAPI: true\n    }\n  }\n])\n\nlet mainWindow: BrowserWindow | null = null\nlet isQuitting = false\nlet isYtdlpReady = false\ninterface DeepLinkData {\n  url: string\n  type: 'single' | 'playlist'\n}\nconst pendingDeepLinkUrls: DeepLinkData[] = []\nconst pendingOneClickDownloads: DeepLinkData[] = []\nlet isRendererReady = false\n\nconst getActiveMainWindow = (): BrowserWindow | null => {\n  if (!mainWindow || mainWindow.isDestroyed()) {\n    return null\n  }\n  if (mainWindow.webContents.isDestroyed()) {\n    return null\n  }\n  return mainWindow\n}\n\nconst sendToRenderer = (channel: string, ...args: unknown[]): void => {\n  const window = getActiveMainWindow()\n  if (!window) {\n    return\n  }\n  try {\n    window.webContents.send(channel, ...args)\n  } catch (error) {\n    log.warn('Failed to send message to renderer:', channel, error)\n  }\n}\n\nconst parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {\n  try {\n    const parsed = new URL(rawUrl)\n    if (parsed.protocol !== `${APP_PROTOCOL}:`) {\n      return null\n    }\n\n    const host = parsed.hostname\n    const path = parsed.pathname.replace(/^\\/+/, '')\n    const isDownloadLink = host === 'download' || path.startsWith('download')\n    if (!isDownloadLink) {\n      return null\n    }\n\n    const targetUrl = parsed.searchParams.get('url')\n    if (!targetUrl?.trim()) {\n      return null\n    }\n\n    const typeParam = parsed.searchParams.get('type')\n    const type = typeParam === 'playlist' ? 'playlist' : 'single'\n\n    return {\n      url: targetUrl.trim(),\n      type\n    }\n  } catch (error) {\n    log.warn('Failed to parse deep link:', error)\n    return null\n  }\n}\n\nconst deliverDeepLink = (data: DeepLinkData): void => {\n  const window = getActiveMainWindow()\n  if (!(window && isRendererReady)) {\n    pendingDeepLinkUrls.push(data)\n    return\n  }\n\n  if (window.isMinimized()) {\n    window.restore()\n  }\n  if (!window.isVisible()) {\n    window.show()\n  }\n  window.focus()\n  sendToRenderer('download:deeplink', data)\n}\n\nconst flushPendingDeepLinks = (): void => {\n  if (!(getActiveMainWindow() && isRendererReady) || pendingDeepLinkUrls.length === 0) {\n    return\n  }\n\n  const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length)\n  for (const data of pending) {\n    sendToRenderer('download:deeplink', data)\n  }\n}\n\nconst handleDeepLinkUrl = (rawUrl: string): void => {\n  const data = parseDownloadDeepLink(rawUrl)\n  if (!data) {\n    log.warn('Ignored unsupported deep link:', rawUrl)\n    return\n  }\n  if (settingsManager.get('oneClickDownload')) {\n    queueOneClickDownload(data)\n    return\n  }\n  deliverDeepLink(data)\n}\n\nconst handleDeepLinkArgv = (argv: string[]): void => {\n  for (const arg of argv) {\n    if (arg.startsWith(`${APP_PROTOCOL}://`)) {\n      handleDeepLinkUrl(arg)\n    }\n  }\n}\n\nsubscriptionManager.on('subscriptions:updated', (subscriptions) => {\n  sendToRenderer('subscriptions:updated', subscriptions)\n})\n\nexport function createWindow(): void {\n  const isMac = process.platform === 'darwin'\n  const isWindows = process.platform === 'win32'\n  const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin\n\n  const windowOptions: BrowserWindowConstructorOptions = {\n    width: 1200,\n    height: 800,\n    show: false,\n    autoHideMenuBar: true,\n    icon: appIcon, // Set application icon\n    frame: false,\n    webPreferences: {\n      preload: join(import.meta.dirname, '../preload/index.js'),\n      sandbox: false,\n      contextIsolation: true,\n      nodeIntegration: false,\n      webSecurity: false // Allow drag regions to work\n    }\n  }\n\n  if (isMac) {\n    windowOptions.titleBarStyle = 'hidden'\n    windowOptions.trafficLightPosition = { x: 12.5, y: 10 }\n    windowOptions.vibrancy = 'fullscreen-ui'\n  }\n\n  if (isWindows) {\n    windowOptions.backgroundMaterial = 'acrylic'\n  }\n\n  // Create the browser window\n  mainWindow = new BrowserWindow(windowOptions)\n\n  mainWindow.on('close', (event) => {\n    const closeToTray = settingsManager.get('closeToTray')\n    if (closeToTray && !isQuitting) {\n      event.preventDefault()\n      mainWindow?.hide()\n    }\n  })\n\n  mainWindow.on('closed', () => {\n    mainWindow = null\n    isRendererReady = false\n  })\n\n  mainWindow.on('ready-to-show', () => {\n    if (shouldStartHidden) {\n      return\n    }\n    mainWindow?.show()\n  })\n\n  mainWindow.webContents.setWindowOpenHandler((details) => {\n    shell.openExternal(details.url)\n    return { action: 'deny' }\n  })\n\n  // HMR for renderer base on electron-vite cli.\n  // Load the remote URL for development or the local html file for production.\n  if (process.env.ELECTRON_RENDERER_URL) {\n    mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)\n  } else {\n    mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)\n  }\n\n  mainWindow.webContents.on('did-finish-load', () => {\n    sendToRenderer('subscriptions:updated', subscriptionManager.getAll())\n    isRendererReady = true\n    flushPendingDeepLinks()\n  })\n\n  // Setup error handling for renderer process\n  setupRendererErrorHandling()\n\n  // Setup download engine event forwarding to renderer\n  setupDownloadEvents()\n}\n\nfunction setupRendererErrorHandling(): void {\n  if (!mainWindow) {\n    return\n  }\n\n  // Handle uncaught exceptions in renderer process\n  mainWindow.webContents.on('unresponsive', () => {\n    log.error('Renderer process became unresponsive')\n  })\n\n  mainWindow.webContents.on('responsive', () => {\n    log.info('Renderer process became responsive again')\n  })\n\n  // Listen for renderer errors via IPC\n  ipcMain.on('error:renderer', (_event, errorData) => {\n    log.error('Renderer error received:', errorData)\n\n    // Log detailed error information\n    if (errorData.error) {\n      log.error('Error name:', errorData.error.name)\n      log.error('Error message:', errorData.error.message)\n      if (errorData.error.stack) {\n        log.error('Error stack:', errorData.error.stack)\n      }\n    }\n\n    if (errorData.errorInfo?.componentStack) {\n      log.error('Component stack:', errorData.errorInfo.componentStack)\n    }\n\n    if (errorData.context) {\n      log.error('Error context:', errorData.context)\n    }\n  })\n}\n\nfunction setupDownloadEvents(): void {\n  downloadEngine.on('download-queued', (item: unknown) => {\n    sendToRenderer('download:queued', item)\n  })\n\n  downloadEngine.on('download-updated', (id: string, updates: unknown) => {\n    sendToRenderer('download:updated', { id, updates })\n  })\n\n  downloadEngine.on('download-started', (id: string) => {\n    sendToRenderer('download:started', id)\n  })\n\n  downloadEngine.on('download-progress', (id: string, progress: unknown) => {\n    sendToRenderer('download:progress', { id, progress })\n  })\n\n  downloadEngine.on('download-log', (id: string, logText: string) => {\n    sendToRenderer('download:log', { id, log: logText })\n  })\n\n  downloadEngine.on('download-completed', (id: string) => {\n    sendToRenderer('download:completed', id)\n  })\n\n  downloadEngine.on('download-error', (id: string, error: Error) => {\n    sendToRenderer('download:error', { id, error: error.message })\n  })\n\n  downloadEngine.on('download-cancelled', (id: string) => {\n    sendToRenderer('download:cancelled', id)\n  })\n}\n\nconst createDownloadId = (): string =>\n  `download_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`\n\nconst queueOneClickDownload = (data: DeepLinkData): void => {\n  if (!isYtdlpReady) {\n    pendingOneClickDownloads.push(data)\n    return\n  }\n  void startOneClickDownload(data)\n}\n\nconst flushPendingOneClickDownloads = (): void => {\n  if (!isYtdlpReady || pendingOneClickDownloads.length === 0) {\n    return\n  }\n  const pending = pendingOneClickDownloads.splice(0, pendingOneClickDownloads.length)\n  for (const data of pending) {\n    void startOneClickDownload(data)\n  }\n}\n\nconst startOneClickDownload = async (data: DeepLinkData): Promise<void> => {\n  try {\n    const settings = settingsManager.getAll()\n    const downloadType = settings.oneClickDownloadType ?? 'video'\n    const format =\n      downloadType === 'video'\n        ? buildVideoFormatPreference(settings)\n        : buildAudioFormatPreference(settings)\n\n    if (data.type === 'playlist') {\n      const result = await downloadEngine.startPlaylistDownload({\n        url: data.url,\n        type: downloadType,\n        format\n      })\n      log.info('One-click playlist download queued:', {\n        url: data.url,\n        count: result.totalCount\n      })\n      return\n    }\n\n    const downloadId = createDownloadId()\n    const started = downloadEngine.startDownload(downloadId, {\n      url: data.url,\n      type: downloadType,\n      format\n    })\n    if (started) {\n      log.info('One-click download queued:', { id: downloadId, url: data.url })\n    } else {\n      log.info('One-click download already queued:', { id: downloadId, url: data.url })\n    }\n  } catch (error) {\n    log.error('Failed to start one-click download:', error)\n  }\n}\n\nfunction sanitizeRequestPath(requestUrl: URL): string {\n  const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`\n  const trimmedLeading = rawPath.replace(/^\\/+/, '')\n  const cleaned = trimmedLeading.replace(/\\/+$/, '')\n  return cleaned || 'index.html'\n}\n\nfunction isWithinBase(targetPath: string, basePath: string): boolean {\n  const relativePath = relative(basePath, targetPath)\n  return !(relativePath.startsWith('..') || isAbsolute(relativePath))\n}\n\nfunction resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {\n  const sanitizedPath = sanitizeRequestPath(requestUrl)\n  const [rootSegment, ...restSegments] = sanitizedPath.split('/')\n  const rendererPath = restSegments.join('/') || 'index.html'\n\n  if (rootSegment === 'renderer') {\n    const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)\n\n    if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {\n      return rendererTarget\n    }\n  }\n\n  const userDataTarget = resolve(userDataPath, sanitizedPath)\n\n  if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {\n    return userDataTarget\n  }\n\n  const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)\n\n  if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {\n    return rendererFallback\n  }\n\n  return null\n}\n\nfunction registerVidbeeProtocol(): void {\n  try {\n    const userDataPath = app.getPath('userData')\n    protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {\n      const requestUrl = new URL(request.url)\n      const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)\n\n      if (!filePath) {\n        log.error(`File not found for ${request.url}`)\n        callback({ error: -6 })\n        return\n      }\n\n      callback(filePath)\n    })\n  } catch (error) {\n    log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)\n  }\n}\n\nfunction initAutoUpdater(): void {\n  try {\n    log.info('Initializing auto-updater...')\n\n    log.transports.file.level = 'info'\n    autoUpdater.logger = log\n    autoUpdater.autoDownload = true\n    autoUpdater.autoInstallOnAppQuit = true\n\n    autoUpdater.on('update-available', (info) => {\n      log.info('Update available:', info.version)\n      sendToRenderer('update:available', info)\n\n      // If auto-update is enabled, the update will be downloaded automatically\n      // because autoDownload is set to true\n      if (settingsManager.get('autoUpdate')) {\n        log.info('Auto-update is enabled, update will be downloaded automatically')\n      }\n    })\n\n    autoUpdater.on('update-not-available', (info) => {\n      log.info('Update not available:', info.version)\n      sendToRenderer('update:not-available', info)\n    })\n\n    autoUpdater.on('error', (err) => {\n      log.error('Update error:', err)\n      sendToRenderer('update:error', err.message)\n    })\n\n    autoUpdater.on('download-progress', (progressObj) => {\n      log.info('Download progress:', progressObj.percent)\n      sendToRenderer('update:download-progress', progressObj)\n    })\n\n    autoUpdater.on('update-downloaded', (info) => {\n      log.info('Update downloaded:', info.version)\n      sendToRenderer('update:downloaded', info)\n    })\n\n    log.info('Auto-updater initialized successfully')\n\n    // Check for updates immediately if auto-update is enabled\n    const autoUpdateEnabled = settingsManager.get('autoUpdate')\n    if (autoUpdateEnabled) {\n      log.info('Auto-update is enabled, checking for updates immediately...')\n      // Use checkForUpdates instead of checkForUpdatesAndNotify\n      // because we have our own notification system and want to ensure immediate download\n      void autoUpdater.checkForUpdates()\n    } else {\n      log.info('Auto-update is disabled, skipping automatic update check')\n    }\n  } catch (error) {\n    log.error('Failed to initialize auto-updater:', error)\n  }\n}\n\nconst gotSingleInstanceLock = app.requestSingleInstanceLock()\n\nif (gotSingleInstanceLock) {\n  app.on('second-instance', (_event, argv) => {\n    handleDeepLinkArgv(argv)\n    if (mainWindow) {\n      if (mainWindow.isMinimized()) {\n        mainWindow.restore()\n      }\n      mainWindow.show()\n      mainWindow.focus()\n    }\n  })\n} else {\n  app.quit()\n}\n\napp.on('open-url', (event, url) => {\n  event.preventDefault()\n  handleDeepLinkUrl(url)\n})\n\n// This method will be called when Electron has finished\n// initialization and is ready to create browser windows.\n// Some APIs can only be used after this event occurs.\napp.whenReady().then(async () => {\n  // Set app user model id for windows\n  electronApp.setAppUserModelId('com.vidbee')\n\n  registerVidbeeProtocol()\n\n  const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)\n  if (!registered) {\n    log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)\n  }\n\n  // Default open or close DevTools by F12 in development\n  // and ignore CommandOrControl + R in production.\n  app.on('browser-window-created', (_, window) => {\n    optimizer.watchWindowShortcuts(window)\n\n    // Enable F12 to toggle DevTools in both development and production\n    window.webContents.on('before-input-event', (_, input) => {\n      if (input.key === 'F12') {\n        if (window.webContents.isDevToolsOpened()) {\n          window.webContents.closeDevTools()\n        } else {\n          window.webContents.openDevTools()\n        }\n      }\n    })\n  })\n\n  // IPC services are automatically registered by electron-ipc-decorator when imported\n  log.info('IPC services available:', Object.keys(services))\n\n  // Initialize ffmpeg\n  try {\n    log.info('Initializing ffmpeg...')\n    await ffmpegManager.initialize()\n    log.info('ffmpeg initialized successfully')\n  } catch (error) {\n    log.error('Failed to initialize ffmpeg:', error)\n  }\n\n  // Initialize yt-dlp\n  try {\n    log.info('Initializing yt-dlp...')\n    await ytdlpManager.initialize()\n    isYtdlpReady = true\n    log.info('yt-dlp initialized successfully')\n  } catch (error) {\n    log.error('Failed to initialize yt-dlp:', error)\n  }\n\n  if (isYtdlpReady) {\n    downloadEngine.restoreActiveDownloads()\n    flushPendingOneClickDownloads()\n  }\n\n  await startExtensionApiServer()\n\n  applyDockVisibility(settingsManager.get('hideDockIcon'))\n  applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))\n\n  createWindow()\n\n  initAutoUpdater()\n\n  // Create system tray\n  createTray()\n\n  subscriptionScheduler.start()\n\n  handleDeepLinkArgv(process.argv)\n\n  app.on('activate', () => {\n    const existingWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())\n    if (existingWindow) {\n      if (existingWindow.isMinimized()) {\n        existingWindow.restore()\n      }\n      if (!existingWindow.isVisible()) {\n        existingWindow.show()\n      }\n      existingWindow.focus()\n      return\n    }\n\n    // On macOS it's common to re-create a window in the app when the\n    // dock icon is clicked and there are no other windows open.\n    createWindow()\n  })\n})\n\napp.on('before-quit', () => {\n  isQuitting = true\n  downloadEngine.flushDownloadSession()\n})\n\n// Quit when all windows are closed, except on macOS. There, it's common\n// for applications and their menu bar to stay active until the user quits\n// explicitly with Cmd + Q.\napp.on('window-all-closed', () => {\n  const closeToTray = settingsManager.get('closeToTray')\n\n  if (process.platform !== 'darwin') {\n    if (closeToTray) {\n      // Hide to tray instead of quitting\n      const mainWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())\n      if (mainWindow) {\n        mainWindow.hide()\n      }\n    } else {\n      app.quit()\n    }\n  }\n})\n\n// Cleanup tray on quit\napp.on('will-quit', () => {\n  destroyTray()\n  void stopExtensionApiServer()\n})\n\n// In this file you can include the rest of your app's specific main process\n// code. You can also put them in separate files and require them here.\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/index.ts",
    "content": "import { createServices, type MergeIpcService } from 'electron-ipc-decorator'\nimport { AppService } from './services/app-service'\nimport { BrowserCookiesService } from './services/browser-cookies-service'\nimport { DownloadService } from './services/download-service'\nimport { FileSystemService } from './services/file-system-service'\nimport { HistoryService } from './services/history-service'\nimport { SettingsService } from './services/settings-service'\nimport { SubscriptionService } from './services/subscription-service'\nimport { ThumbnailService } from './services/thumbnail-service'\nimport { UpdateService } from './services/update-service'\nimport { WindowService } from './services/window-service'\n\n// Create services with automatic type inference\nexport const services = createServices([\n  AppService,\n  BrowserCookiesService,\n  DownloadService,\n  FileSystemService,\n  HistoryService,\n  SettingsService,\n  SubscriptionService,\n  ThumbnailService,\n  UpdateService,\n  WindowService\n])\n\n// Generate type definition for all services\nexport type IpcServices = MergeIpcService<typeof services>\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/app-service.ts",
    "content": "import os from 'node:os'\nimport { app, BrowserWindow, dialog } from 'electron'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport { scopedLoggers } from '../../utils/logger'\n\nclass AppService extends IpcService {\n  static readonly groupName = 'app'\n\n  @IpcMethod()\n  getVersion(_context: IpcContext): string {\n    return app.getVersion()\n  }\n\n  @IpcMethod()\n  getPlatform(_context: IpcContext): string {\n    return os.platform()\n  }\n\n  @IpcMethod()\n  getOsVersion(_context: IpcContext): string {\n    const platform = os.platform()\n    const platformLabel =\n      platform === 'darwin'\n        ? 'macOS'\n        : platform === 'win32'\n          ? 'Windows'\n          : platform === 'linux'\n            ? 'Linux'\n            : platform\n    const systemVersion =\n      typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'\n        ? (process as { getSystemVersion: () => string }).getSystemVersion()\n        : typeof os.version === 'function'\n          ? os.version()\n          : os.release()\n\n    if (platform === 'win32') {\n      const buildToken = systemVersion.split('.').at(-1) ?? ''\n      const buildNumber = Number.parseInt(buildToken, 10)\n      const windowsName =\n        Number.isFinite(buildNumber) && buildNumber >= 22_000 ? 'Windows 11' : 'Windows 10'\n      return Number.isFinite(buildNumber)\n        ? `${windowsName} (build ${buildNumber})`\n        : `${platformLabel} ${systemVersion}`.trim()\n    }\n\n    return `${platformLabel} ${systemVersion}`.trim()\n  }\n\n  @IpcMethod()\n  quit(_context: IpcContext): void {\n    app.quit()\n  }\n\n  @IpcMethod()\n  async showMessageBox(\n    _context: IpcContext,\n    options: Electron.MessageBoxOptions\n  ): Promise<Electron.MessageBoxReturnValue> {\n    const window = BrowserWindow.getFocusedWindow()\n    if (window) {\n      return dialog.showMessageBox(window, options)\n    }\n\n    return dialog.showMessageBox(options)\n  }\n\n  @IpcMethod()\n  async getSiteIcon(_context: IpcContext, domain: string): Promise<string | null> {\n    try {\n      const iconUrl = `https://unavatar.io/${domain}`\n      const response = await fetch(iconUrl)\n      if (!response.ok) {\n        return null\n      }\n\n      const arrayBuffer = await response.arrayBuffer()\n      const buffer = Buffer.from(arrayBuffer)\n      const contentType = response.headers.get('content-type') || 'image/png'\n      const base64 = buffer.toString('base64')\n      return `data:${contentType};base64,${base64}`\n    } catch (error) {\n      scopedLoggers.system.error('Failed to fetch site icon:', error)\n      return null\n    }\n  }\n}\n\nexport { AppService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/browser-cookies-service.ts",
    "content": "import fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport { resolvePathWithHome } from '../../utils/path-helpers'\n\nclass BrowserCookiesService extends IpcService {\n  static readonly groupName = 'browserCookies'\n\n  private buildValidationResult(valid: boolean, reason?: string) {\n    if (valid) {\n      return { valid }\n    }\n    return { valid, reason }\n  }\n\n  private isDirectory(target: string): boolean {\n    try {\n      return fs.statSync(target).isDirectory()\n    } catch {\n      return false\n    }\n  }\n\n  private pickFirstDirectory(paths: string[]): string {\n    for (const candidate of paths) {\n      if (this.isDirectory(candidate)) {\n        return candidate\n      }\n    }\n    return ''\n  }\n\n  private normalizeProfileInput(value: string): string {\n    return value.trim().replace(/^['\"]|['\"]$/g, '')\n  }\n\n  private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {\n    if (platform === 'win32') {\n      if (browser === 'edge') {\n        return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]\n      }\n      if (browser === 'chrome') {\n        return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]\n      }\n      if (browser === 'chromium') {\n        return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]\n      }\n      if (browser === 'brave') {\n        return [\n          path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')\n        ]\n      }\n      if (browser === 'vivaldi') {\n        return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]\n      }\n      if (browser === 'whale') {\n        return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]\n      }\n      if (browser === 'opera') {\n        return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]\n      }\n      if (browser === 'firefox') {\n        return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]\n      }\n    }\n\n    if (platform === 'darwin') {\n      if (browser === 'edge') {\n        return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]\n      }\n      if (browser === 'chrome') {\n        return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]\n      }\n      if (browser === 'chromium') {\n        return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]\n      }\n      if (browser === 'brave') {\n        return [\n          path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')\n        ]\n      }\n      if (browser === 'vivaldi') {\n        return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]\n      }\n      if (browser === 'whale') {\n        return [\n          path.join(homeDir, 'Library', 'Application Support', 'Whale'),\n          path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')\n        ]\n      }\n      if (browser === 'opera') {\n        return [\n          path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),\n          path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')\n        ]\n      }\n      if (browser === 'firefox') {\n        return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]\n      }\n      if (browser === 'safari') {\n        return [path.join(homeDir, 'Library', 'Safari')]\n      }\n    }\n\n    if (platform === 'linux') {\n      if (browser === 'edge') {\n        return [path.join(homeDir, '.config', 'microsoft-edge')]\n      }\n      if (browser === 'chrome') {\n        return [path.join(homeDir, '.config', 'google-chrome')]\n      }\n      if (browser === 'chromium') {\n        return [path.join(homeDir, '.config', 'chromium')]\n      }\n      if (browser === 'brave') {\n        return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]\n      }\n      if (browser === 'vivaldi') {\n        return [path.join(homeDir, '.config', 'vivaldi')]\n      }\n      if (browser === 'whale') {\n        return [path.join(homeDir, '.config', 'naver-whale')]\n      }\n      if (browser === 'opera') {\n        return [path.join(homeDir, '.config', 'opera')]\n      }\n      if (browser === 'firefox') {\n        return [path.join(homeDir, '.mozilla', 'firefox')]\n      }\n    }\n\n    if (platform === 'freebsd' && browser === 'firefox') {\n      return [path.join(homeDir, '.mozilla', 'firefox')]\n    }\n\n    return []\n  }\n\n  private getDefaultProfilePath(baseDirs: string[], browser: string): string {\n    const base = baseDirs[0]\n    if (!base) {\n      return ''\n    }\n\n    if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {\n      return base\n    }\n\n    return path.join(base, 'Default')\n  }\n\n  private findFirefoxProfilePath(profilesDir: string): string {\n    if (!this.isDirectory(profilesDir)) {\n      return ''\n    }\n\n    const entries = fs\n      .readdirSync(profilesDir, { withFileTypes: true })\n      .filter((entry) => entry.isDirectory())\n      .map((entry) => entry.name)\n      .sort((a, b) => a.localeCompare(b))\n\n    const preferred =\n      entries.find((name) => name.endsWith('.default-release')) ??\n      entries.find((name) => name.endsWith('.default')) ??\n      entries[0]\n\n    return preferred ? path.join(profilesDir, preferred) : ''\n  }\n\n  @IpcMethod()\n  getBrowserProfilePath(_context: IpcContext, browser: string): string {\n    if (!browser || browser === 'none') {\n      return ''\n    }\n\n    const homeDir = os.homedir()\n    const platform = os.platform()\n    const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)\n    const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)\n\n    if (browser === 'firefox') {\n      const profilesDir = baseDirs[0]\n      const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''\n      return profilePath || fallbackPath\n    }\n\n    if (browser === 'safari') {\n      const safariPath = baseDirs[0]\n      if (safariPath && this.isDirectory(safariPath)) {\n        return safariPath\n      }\n      return fallbackPath\n    }\n\n    if (baseDirs.length === 0) {\n      return fallbackPath\n    }\n\n    let detectedPath = ''\n    for (const baseDir of baseDirs) {\n      if (!baseDir) {\n        continue\n      }\n      const candidates =\n        browser === 'opera'\n          ? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]\n          : [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]\n      detectedPath = this.pickFirstDirectory(candidates)\n      if (detectedPath) {\n        break\n      }\n    }\n\n    return detectedPath || fallbackPath\n  }\n\n  @IpcMethod()\n  validateBrowserProfilePath(\n    _context: IpcContext,\n    browser: string,\n    profilePath: string\n  ): { valid: boolean; reason?: string } {\n    if (!browser || browser === 'none') {\n      return this.buildValidationResult(false, 'browserUnsupported')\n    }\n\n    const normalizedInput = this.normalizeProfileInput(profilePath)\n    if (!normalizedInput) {\n      return this.buildValidationResult(false, 'empty')\n    }\n\n    const resolvedInput = resolvePathWithHome(normalizedInput)\n    if (resolvedInput && this.isDirectory(resolvedInput)) {\n      return this.buildValidationResult(true)\n    }\n\n    const looksLikePath =\n      resolvedInput &&\n      (path.isAbsolute(resolvedInput) ||\n        resolvedInput.includes('/') ||\n        resolvedInput.includes('\\\\'))\n    if (looksLikePath) {\n      return this.buildValidationResult(false, 'pathNotFound')\n    }\n\n    const platform = os.platform()\n    const homeDir = os.homedir()\n    const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)\n    if (baseDirs.length === 0) {\n      return this.buildValidationResult(false, 'browserUnsupported')\n    }\n    for (const baseDir of baseDirs) {\n      if (!baseDir) {\n        continue\n      }\n      const candidate = path.join(baseDir, normalizedInput)\n      if (this.isDirectory(candidate)) {\n        return this.buildValidationResult(true)\n      }\n    }\n\n    return this.buildValidationResult(false, 'profileNotFound')\n  }\n}\n\nexport { BrowserCookiesService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/download-service.ts",
    "content": "import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport type {\n  DownloadItem,\n  DownloadOptions,\n  PlaylistDownloadOptions,\n  PlaylistDownloadResult,\n  PlaylistInfo,\n  VideoInfo,\n  VideoInfoCommandResult\n} from '../../../shared/types'\nimport { downloadEngine } from '../../lib/download-engine'\n\nclass DownloadService extends IpcService {\n  static readonly groupName = 'download'\n\n  @IpcMethod()\n  async getVideoInfo(_context: IpcContext, url: string): Promise<VideoInfo> {\n    return downloadEngine.getVideoInfo(url)\n  }\n\n  @IpcMethod()\n  async getVideoInfoWithCommand(\n    _context: IpcContext,\n    url: string\n  ): Promise<VideoInfoCommandResult> {\n    return downloadEngine.getVideoInfoWithCommand(url)\n  }\n\n  @IpcMethod()\n  async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {\n    return downloadEngine.getPlaylistInfo(url)\n  }\n\n  @IpcMethod()\n  startDownload(_context: IpcContext, id: string, options: DownloadOptions): boolean {\n    return downloadEngine.startDownload(id, options)\n  }\n\n  @IpcMethod()\n  cancelDownload(_context: IpcContext, id: string): boolean {\n    return downloadEngine.cancelDownload(id)\n  }\n\n  @IpcMethod()\n  getQueueStatus(_context: IpcContext) {\n    return downloadEngine.getQueueStatus()\n  }\n\n  @IpcMethod()\n  getActiveDownloads(_context: IpcContext): DownloadItem[] {\n    return downloadEngine.getActiveDownloads()\n  }\n\n  @IpcMethod()\n  updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {\n    downloadEngine.updateDownloadInfo(id, updates)\n  }\n\n  @IpcMethod()\n  async startPlaylistDownload(\n    _context: IpcContext,\n    options: PlaylistDownloadOptions\n  ): Promise<PlaylistDownloadResult> {\n    return downloadEngine.startPlaylistDownload(options)\n  }\n}\n\nexport { DownloadService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/file-system-service.ts",
    "content": "import { execFile, execSync } from 'node:child_process'\nimport fs from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { promisify } from 'node:util'\nimport { clipboard, dialog, shell } from 'electron'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport { scopedLoggers } from '../../utils/logger'\n\nconst execFileAsync = promisify(execFile)\n\nclass FileSystemService extends IpcService {\n  static readonly groupName = 'fs'\n\n  @IpcMethod()\n  async selectDirectory(_context: IpcContext): Promise<string | null> {\n    const result = await dialog.showOpenDialog({\n      properties: ['openDirectory', 'createDirectory']\n    })\n\n    if (result.canceled || result.filePaths.length === 0) {\n      return null\n    }\n\n    return result.filePaths[0]\n  }\n\n  @IpcMethod()\n  async selectFile(_context: IpcContext): Promise<string | null> {\n    const result = await dialog.showOpenDialog({\n      properties: ['openFile']\n    })\n\n    if (result.canceled || result.filePaths.length === 0) {\n      return null\n    }\n\n    return result.filePaths[0]\n  }\n\n  @IpcMethod()\n  getDefaultDownloadPath(_context: IpcContext): string {\n    const fallbackPath = path.join(os.homedir(), 'Downloads')\n\n    if (process.platform === 'linux' || process.platform === 'freebsd') {\n      try {\n        const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()\n        if (xdgPath) {\n          return xdgPath\n        }\n      } catch (error) {\n        scopedLoggers.system.warn(\n          'Unable to resolve XDG download directory, falling back to default:',\n          error\n        )\n      }\n    }\n\n    return fallbackPath\n  }\n\n  @IpcMethod()\n  async openFileLocation(_context: IpcContext, filePath: string): Promise<boolean> {\n    try {\n      if (!filePath) {\n        return false\n      }\n\n      const sanitizedPath = this.sanitizePath(filePath)\n      const normalizedPath = path.normalize(sanitizedPath)\n      const stats = await fs.stat(normalizedPath).catch(() => null)\n\n      if (stats?.isFile()) {\n        shell.showItemInFolder(normalizedPath)\n        return true\n      }\n\n      if (stats?.isDirectory()) {\n        const result = await shell.openPath(normalizedPath)\n        if (result) {\n          scopedLoggers.system.error('Failed to open directory:', result)\n          return false\n        }\n        return true\n      }\n\n      // If the exact path doesn't exist, try to open the parent directory\n      const parentDirectory = path.dirname(normalizedPath)\n      const parentStats = await fs.stat(parentDirectory).catch(() => null)\n\n      if (parentStats?.isDirectory()) {\n        const result = await shell.openPath(parentDirectory)\n        if (result) {\n          scopedLoggers.system.error('Failed to open parent directory:', result)\n          return false\n        }\n        return true\n      }\n\n      scopedLoggers.system.error('File or directory does not exist:', normalizedPath)\n      return false\n    } catch (error) {\n      scopedLoggers.system.error('Failed to open file location:', error)\n      return false\n    }\n  }\n\n  @IpcMethod()\n  async openFile(_context: IpcContext, filePath: string): Promise<boolean> {\n    try {\n      if (!filePath) {\n        return false\n      }\n\n      const sanitizedPath = this.sanitizePath(filePath)\n      const normalizedPath = path.normalize(sanitizedPath)\n      const stats = await fs.stat(normalizedPath).catch(() => null)\n\n      if (!(stats && (stats.isFile() || stats.isDirectory()))) {\n        scopedLoggers.system.error('File does not exist:', normalizedPath)\n        return false\n      }\n\n      const result = await shell.openPath(normalizedPath)\n      if (result) {\n        scopedLoggers.system.error('Failed to open file:', result)\n        return false\n      }\n\n      return true\n    } catch (error) {\n      scopedLoggers.system.error('Failed to open file:', error)\n      return false\n    }\n  }\n\n  @IpcMethod()\n  async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {\n    try {\n      if (!filePath) {\n        return false\n      }\n\n      const sanitizedPath = this.sanitizePath(filePath)\n      const normalizedPath = path.normalize(sanitizedPath)\n      const stats = await fs.stat(normalizedPath)\n      if (!stats.isFile()) {\n        return false\n      }\n\n      const resolvedPath = path.resolve(normalizedPath)\n\n      await this.copyFileToClipboardByPlatform(resolvedPath)\n\n      return true\n    } catch (error) {\n      scopedLoggers.system.error('Failed to copy file to clipboard:', error)\n      return false\n    }\n  }\n\n  private sanitizePath(target: string): string {\n    return target.trim().replace(/^['\"]|['\"]$/g, '')\n  }\n\n  private async copyFileToClipboardByPlatform(resolvedPath: string): Promise<void> {\n    switch (process.platform) {\n      case 'win32':\n        await this.copyFileToClipboardWindows(resolvedPath)\n        return\n      case 'darwin':\n        await this.copyFileToClipboardMac(resolvedPath)\n        return\n      default:\n        await this.copyFileToClipboardLinux(resolvedPath)\n    }\n  }\n\n  @IpcMethod()\n  async openExternal(_context: IpcContext, url: string): Promise<boolean> {\n    try {\n      await shell.openExternal(url)\n      return true\n    } catch (error) {\n      scopedLoggers.system.error('Failed to open external URL:', error)\n      return false\n    }\n  }\n\n  private async copyFileToClipboardWindows(resolvedPath: string): Promise<void> {\n    const escaped = resolvedPath.replace(/'/g, \"''\")\n    try {\n      await execFileAsync('powershell.exe', [\n        '-NoLogo',\n        '-NoProfile',\n        '-Command',\n        `Set-Clipboard -Path '${escaped}'`\n      ])\n      return\n    } catch (error) {\n      scopedLoggers.system.error(\n        'PowerShell clipboard copy failed, falling back to manual buffer:',\n        error\n      )\n    }\n\n    const winPath = resolvedPath.replace(/\\//g, '\\\\')\n    const fileList = `${winPath}\\u0000\\u0000`\n    const encodedList = Buffer.from(fileList, 'ucs2')\n\n    const dropFilesStructSize = 20\n    const buffer = Buffer.alloc(dropFilesStructSize + encodedList.length)\n    buffer.writeUInt32LE(dropFilesStructSize, 0)\n    buffer.writeInt32LE(0, 4)\n    buffer.writeInt32LE(0, 8)\n    buffer.writeUInt32LE(0, 12)\n    buffer.writeUInt32LE(1, 16)\n    encodedList.copy(buffer, dropFilesStructSize)\n\n    clipboard.writeBuffer('CF_HDROP', buffer)\n    clipboard.writeBuffer('Preferred DropEffect', Buffer.from([1, 0, 0, 0]))\n    clipboard.writeBuffer('FileNameW', Buffer.from(`${path.basename(resolvedPath)}\\u0000`, 'ucs2'))\n    clipboard.writeBuffer('FileName', Buffer.from(`${path.basename(resolvedPath)}\\u0000`, 'ascii'))\n  }\n\n  private async copyFileToClipboardMac(resolvedPath: string): Promise<void> {\n    const escaped = resolvedPath.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n    try {\n      await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file \"${escaped}\")`])\n      return\n    } catch (error) {\n      scopedLoggers.system.error(\n        'osascript clipboard copy failed, falling back to manual buffer:',\n        error\n      )\n    }\n\n    const entries = [\n      '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n      '<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">',\n      '<plist version=\"1.0\">',\n      '<array>',\n      `  <string>${this.escapeForPlist(resolvedPath)}</string>`,\n      '</array>',\n      '</plist>'\n    ]\n    const plist = Buffer.from(entries.join('\\n'), 'utf8')\n    clipboard.writeBuffer('NSFilenamesPboardType', plist)\n\n    const fileUrl = pathToFileURL(resolvedPath).toString()\n    clipboard.writeBuffer('public.file-url', Buffer.from(`${fileUrl}\\n`, 'utf8'))\n  }\n\n  private async copyFileToClipboardLinux(resolvedPath: string): Promise<void> {\n    const fileUrl = pathToFileURL(resolvedPath).toString()\n    const content = `copy\\n${fileUrl}`\n    clipboard.writeBuffer('x-special/gnome-copied-files', Buffer.from(content, 'utf8'))\n    clipboard.writeBuffer('text/uri-list', Buffer.from(`${fileUrl}\\n`, 'utf8'))\n  }\n\n  private escapeForPlist(value: string): string {\n    return value\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/\"/g, '&quot;')\n      .replace(/'/g, '&apos;')\n  }\n\n  @IpcMethod()\n  async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {\n    try {\n      if (!filePath) {\n        return false\n      }\n\n      const sanitizedPath = this.sanitizePath(filePath)\n      const normalizedPath = path.normalize(sanitizedPath)\n\n      const stats = await fs.stat(normalizedPath).catch(() => null)\n\n      return stats?.isFile() ?? false\n    } catch (error) {\n      scopedLoggers.system.error('Failed to check file existence:', error)\n      return false\n    }\n  }\n\n  @IpcMethod()\n  async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {\n    try {\n      if (!filePath) {\n        return false\n      }\n\n      const sanitizedPath = this.sanitizePath(filePath)\n      const normalizedPath = path.normalize(sanitizedPath)\n\n      const stats = await fs.stat(normalizedPath).catch((error) => {\n        const err = error as NodeJS.ErrnoException\n        if (err?.code === 'ENOENT') {\n          return null\n        }\n        throw error\n      })\n\n      if (!stats) {\n        return false\n      }\n\n      if (stats.isFile()) {\n        await fs.unlink(normalizedPath).catch((error) => {\n          const err = error as NodeJS.ErrnoException\n          if (err?.code !== 'ENOENT') {\n            throw error\n          }\n        })\n        return true\n      }\n\n      if (stats.isDirectory()) {\n        const entries = await fs.readdir(normalizedPath)\n        if (entries.length === 0) {\n          await fs.rmdir(normalizedPath).catch((error) => {\n            const err = error as NodeJS.ErrnoException\n            if (err?.code !== 'ENOENT') {\n              throw error\n            }\n          })\n          return true\n        }\n      }\n\n      return false\n    } catch (error) {\n      scopedLoggers.system.error('Failed to delete file:', error)\n      return false\n    }\n  }\n}\n\nexport { FileSystemService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/history-service.ts",
    "content": "import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport type { DownloadHistoryItem } from '../../../shared/types'\nimport { historyManager } from '../../lib/history-manager'\n\nclass HistoryService extends IpcService {\n  static readonly groupName = 'history'\n\n  @IpcMethod()\n  getHistory(_context: IpcContext): DownloadHistoryItem[] {\n    return historyManager.getHistory()\n  }\n\n  @IpcMethod()\n  getHistoryById(_context: IpcContext, id: string): DownloadHistoryItem | undefined {\n    return historyManager.getHistoryById(id)\n  }\n\n  @IpcMethod()\n  addHistoryItem(_context: IpcContext, item: DownloadHistoryItem): void {\n    historyManager.addHistoryItem(item)\n  }\n\n  @IpcMethod()\n  removeHistoryItem(_context: IpcContext, id: string): boolean {\n    return historyManager.removeHistoryItem(id)\n  }\n\n  @IpcMethod()\n  removeHistoryItems(_context: IpcContext, ids: string[]): number {\n    return historyManager.removeHistoryItems(ids)\n  }\n\n  @IpcMethod()\n  removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {\n    return historyManager.removeHistoryByPlaylistId(playlistId)\n  }\n\n  @IpcMethod()\n  clearHistory(_context: IpcContext): void {\n    historyManager.clearHistory()\n  }\n\n  @IpcMethod()\n  getHistoryCount(_context: IpcContext): {\n    active: number\n    completed: number\n    error: number\n    cancelled: number\n    total: number\n  } {\n    return historyManager.getHistoryCount()\n  }\n}\n\nexport { HistoryService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/settings-service.ts",
    "content": "import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport type { AppSettings } from '../../../shared/types'\nimport { settingsManager } from '../../settings'\nimport { updateTrayMenu } from '../../tray'\nimport { applyAutoLaunchSetting } from '../../utils/auto-launch'\nimport { applyDockVisibility } from '../../utils/dock'\n\nclass SettingsService extends IpcService {\n  static readonly groupName = 'settings'\n\n  @IpcMethod()\n  get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {\n    return settingsManager.get(key)\n  }\n\n  @IpcMethod()\n  set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {\n    settingsManager.set(key, value)\n\n    if (key === 'language') {\n      updateTrayMenu()\n    }\n\n    if (key === 'hideDockIcon') {\n      applyDockVisibility(value as AppSettings['hideDockIcon'])\n    }\n\n    if (key === 'launchAtLogin') {\n      applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])\n    }\n  }\n\n  @IpcMethod()\n  getAll(_context: IpcContext): AppSettings {\n    return settingsManager.getAll()\n  }\n\n  @IpcMethod()\n  setAll(_context: IpcContext, settings: Partial<AppSettings>): void {\n    settingsManager.setAll(settings)\n\n    if (settings.language) {\n      updateTrayMenu()\n    }\n\n    if (typeof settings.hideDockIcon === 'boolean') {\n      applyDockVisibility(settings.hideDockIcon)\n    }\n\n    if (typeof settings.launchAtLogin === 'boolean') {\n      applyAutoLaunchSetting(settings.launchAtLogin)\n    }\n  }\n\n  @IpcMethod()\n  reset(_context: IpcContext): void {\n    settingsManager.reset()\n    applyDockVisibility(settingsManager.get('hideDockIcon'))\n    applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))\n  }\n}\n\nexport { SettingsService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/subscription-service.ts",
    "content": "import path from 'node:path'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport type {\n  SubscriptionCreatePayload,\n  SubscriptionResolvedFeed,\n  SubscriptionRule,\n  SubscriptionUpdatePayload\n} from '../../../shared/types'\nimport {\n  DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE,\n  SUBSCRIPTION_DUPLICATE_FEED_ERROR\n} from '../../../shared/types'\nimport { sanitizeFilenameTemplate } from '../../download-engine/args-builder'\nimport { subscriptionManager } from '../../lib/subscription-manager'\nimport { subscriptionScheduler } from '../../lib/subscription-scheduler'\nimport { settingsManager } from '../../settings'\n\ninterface CreateSubscriptionOptions {\n  url: string\n  keywords?: string[]\n  tags?: string[]\n  onlyDownloadLatest?: boolean\n  downloadDirectory?: string\n  namingTemplate?: string\n  enabled?: boolean\n}\n\nconst ensureUrlHasProtocol = (value: string): string => {\n  if (!value) {\n    return value\n  }\n  if (!/^https?:\\/\\//i.test(value)) {\n    return `https://${value}`\n  }\n  return value\n}\n\nconst resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {\n  const normalized = ensureUrlHasProtocol(rawUrl.trim())\n  const youTubeChannelMatch = normalized.match(/youtube\\.com\\/channel\\/([A-Za-z0-9_-]+)/i)\n  if (youTubeChannelMatch) {\n    return {\n      sourceUrl: normalized,\n      feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,\n      platform: 'youtube'\n    }\n  }\n\n  if (/youtube\\.com\\/feeds\\/videos\\.xml/i.test(normalized)) {\n    return {\n      sourceUrl: normalized,\n      feedUrl: normalized,\n      platform: 'youtube'\n    }\n  }\n\n  const youTubeUserMatch = normalized.match(/youtube\\.com\\/(?:user|c)\\/([^/?]+)/i)\n  if (youTubeUserMatch) {\n    return {\n      sourceUrl: normalized,\n      feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,\n      platform: 'youtube'\n    }\n  }\n\n  const youTubeHandleMatch = normalized.match(/youtube\\.com\\/(@[^/?]+)/i)\n  if (youTubeHandleMatch) {\n    const handle = youTubeHandleMatch[1].replace('@', '')\n    return {\n      sourceUrl: normalized,\n      feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,\n      platform: 'youtube'\n    }\n  }\n\n  const biliSpaceMatch = normalized.match(/bilibili\\.com\\/(?:space|user)\\/(\\d+)/i)\n  if (biliSpaceMatch) {\n    return {\n      sourceUrl: normalized,\n      feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,\n      platform: 'bilibili'\n    }\n  }\n\n  if (/rsshub\\.app\\/bilibili/i.test(normalized)) {\n    return {\n      sourceUrl: normalized,\n      feedUrl: normalized,\n      platform: 'bilibili'\n    }\n  }\n\n  return {\n    sourceUrl: normalized,\n    feedUrl: normalized,\n    platform: 'custom'\n  }\n}\n\nclass SubscriptionService extends IpcService {\n  static readonly groupName = 'subscriptions'\n\n  @IpcMethod()\n  list(_context: IpcContext): SubscriptionRule[] {\n    return subscriptionManager.getAll()\n  }\n\n  @IpcMethod()\n  resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {\n    return resolveFeedFromInput(url)\n  }\n\n  @IpcMethod()\n  async create(\n    _context: IpcContext,\n    options: CreateSubscriptionOptions\n  ): Promise<SubscriptionRule> {\n    const resolved = resolveFeedFromInput(options.url)\n    const duplicate = subscriptionManager.findDuplicateFeed(resolved.feedUrl)\n    if (duplicate) {\n      throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)\n    }\n    const settings = settingsManager.getAll()\n    const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')\n    const payload: SubscriptionCreatePayload = {\n      sourceUrl: resolved.sourceUrl,\n      feedUrl: resolved.feedUrl,\n      platform: resolved.platform,\n      keywords: options.keywords,\n      tags: options.tags,\n      onlyDownloadLatest:\n        options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,\n      downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,\n      namingTemplate: sanitizeFilenameTemplate(\n        options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE\n      ),\n      enabled: options.enabled ?? true\n    }\n\n    const created = subscriptionManager.add(payload)\n    void subscriptionScheduler.runNow(created.id)\n    return created\n  }\n\n  @IpcMethod()\n  update(\n    _context: IpcContext,\n    id: string,\n    updates: SubscriptionUpdatePayload\n  ): SubscriptionRule | undefined {\n    if (updates.feedUrl) {\n      const duplicate = subscriptionManager.findDuplicateFeed(updates.feedUrl, id)\n      if (duplicate) {\n        throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)\n      }\n    }\n    const normalized: SubscriptionUpdatePayload = { ...updates }\n    if (typeof normalized.namingTemplate === 'string') {\n      normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)\n    }\n    return subscriptionManager.update(id, normalized)\n  }\n\n  @IpcMethod()\n  remove(_context: IpcContext, id: string): boolean {\n    return subscriptionManager.remove(id)\n  }\n\n  @IpcMethod()\n  async refresh(_context: IpcContext, id?: string): Promise<void> {\n    await subscriptionScheduler.runNow(id)\n  }\n\n  @IpcMethod()\n  async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {\n    return subscriptionScheduler.queueItem(id, itemId)\n  }\n}\n\nexport { SubscriptionService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/thumbnail-service.ts",
    "content": "import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport { thumbnailCache } from '../../lib/thumbnail-cache'\n\nclass ThumbnailService extends IpcService {\n  static readonly groupName = 'thumbnail'\n\n  @IpcMethod()\n  async getThumbnailPath(\n    _context: IpcContext,\n    url: string | null | undefined\n  ): Promise<string | null> {\n    if (!url) {\n      return null\n    }\n\n    return thumbnailCache.getThumbnailUrl(url)\n  }\n}\n\nexport { ThumbnailService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/update-service.ts",
    "content": "import { app } from 'electron'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\nimport { autoUpdater } from 'electron-updater'\nimport { settingsManager } from '../../settings'\n\nconst isNewerVersion = (latest: string, current: string): boolean => {\n  const toSegments = (version: string) =>\n    version.split(/[.-]/).map((segment) => {\n      const parsed = Number.parseInt(segment, 10)\n      return Number.isNaN(parsed) ? 0 : parsed\n    })\n\n  const latestSegments = toSegments(latest)\n  const currentSegments = toSegments(current)\n  const maxLength = Math.max(latestSegments.length, currentSegments.length)\n\n  for (let index = 0; index < maxLength; index += 1) {\n    const latestValue = latestSegments[index] ?? 0\n    const currentValue = currentSegments[index] ?? 0\n\n    if (latestValue > currentValue) {\n      return true\n    }\n\n    if (latestValue < currentValue) {\n      return false\n    }\n  }\n\n  return false\n}\n\nclass UpdateService extends IpcService {\n  static readonly groupName = 'update'\n\n  @IpcMethod()\n  async checkForUpdates(\n    _context: IpcContext\n  ): Promise<{ available: boolean; version?: string; error?: string }> {\n    try {\n      const currentVersion = app.getVersion()\n      const result = await autoUpdater.checkForUpdates()\n      const latestVersion = result?.updateInfo?.version\n\n      if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {\n        return {\n          available: true,\n          version: latestVersion\n        }\n      }\n\n      return {\n        available: false,\n        version: latestVersion ?? currentVersion\n      }\n    } catch (error) {\n      return {\n        available: false,\n        error: error instanceof Error ? error.message : 'Unknown error'\n      }\n    }\n  }\n\n  @IpcMethod()\n  async downloadUpdate(_context: IpcContext): Promise<{ success: boolean; error?: string }> {\n    try {\n      await autoUpdater.downloadUpdate()\n      return { success: true }\n    } catch (error) {\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : 'Unknown error'\n      }\n    }\n  }\n\n  @IpcMethod()\n  quitAndInstall(_context: IpcContext): void {\n    autoUpdater.quitAndInstall()\n  }\n\n  @IpcMethod()\n  getCurrentVersion(_context: IpcContext): string {\n    return app.getVersion()\n  }\n\n  @IpcMethod()\n  isAutoUpdateEnabled(_context: IpcContext): boolean {\n    return settingsManager.get('autoUpdate')\n  }\n}\n\nexport { UpdateService }\n"
  },
  {
    "path": "apps/desktop/src/main/ipc/services/window-service.ts",
    "content": "import { BrowserWindow } from 'electron'\nimport { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'\n\nclass WindowService extends IpcService {\n  static readonly groupName = 'window'\n\n  @IpcMethod()\n  minimize(_context: IpcContext): void {\n    const window = BrowserWindow.getFocusedWindow()\n    if (window) {\n      window.minimize()\n    }\n  }\n\n  @IpcMethod()\n  maximize(_context: IpcContext): void {\n    const window = BrowserWindow.getFocusedWindow()\n    if (window) {\n      if (window.isMaximized()) {\n        window.unmaximize()\n      } else {\n        window.maximize()\n      }\n    }\n  }\n\n  @IpcMethod()\n  close(_context: IpcContext): void {\n    const window = BrowserWindow.getFocusedWindow()\n    if (window) {\n      window.close()\n    }\n  }\n\n  @IpcMethod()\n  isMaximized(_context: IpcContext): boolean {\n    const window = BrowserWindow.getFocusedWindow()\n    return window ? window.isMaximized() : false\n  }\n}\n\nexport { WindowService }\n"
  },
  {
    "path": "apps/desktop/src/main/lib/command-utils.ts",
    "content": "import {\n  appendYouTubeSafeExtractorArgs as appendSharedYouTubeSafeExtractorArgs,\n  buildVideoInfoArgs as buildSharedVideoInfoArgs,\n  formatYtDlpCommand,\n  resolveFfmpegLocationFromPath,\n  type YtDlpDownloadSettings\n} from '@vidbee/downloader-core/yt-dlp-args'\nimport type { settingsManager } from '../settings'\nimport { ytdlpManager } from './ytdlp-manager'\n\nconst toSharedSettings = (\n  settings: ReturnType<typeof settingsManager.getAll>\n): YtDlpDownloadSettings => ({\n  downloadPath: settings.downloadPath,\n  browserForCookies: settings.browserForCookies,\n  cookiesPath: settings.cookiesPath,\n  proxy: settings.proxy,\n  configPath: settings.configPath,\n  embedSubs: settings.embedSubs,\n  embedThumbnail: settings.embedThumbnail,\n  embedMetadata: settings.embedMetadata,\n  embedChapters: settings.embedChapters\n})\n\nexport { formatYtDlpCommand }\n\nexport const resolveFfmpegLocation = (ffmpegPath: string): string =>\n  resolveFfmpegLocationFromPath(ffmpegPath)\n\nexport const appendJsRuntimeArgs = (args: string[]): void => {\n  const runtimeArgs = ytdlpManager.getJsRuntimeArgs()\n  if (runtimeArgs.length > 0) {\n    args.push(...runtimeArgs)\n  }\n}\n\nexport const appendYouTubeSafeExtractorArgs = (args: string[], url: string): void =>\n  appendSharedYouTubeSafeExtractorArgs(args, url)\n\nexport const buildVideoInfoArgs = (\n  url: string,\n  settings: ReturnType<typeof settingsManager.getAll>\n): string[] =>\n  buildSharedVideoInfoArgs(url, toSharedSettings(settings), ytdlpManager.getJsRuntimeArgs())\n"
  },
  {
    "path": "apps/desktop/src/main/lib/database/migrate.ts",
    "content": "import { existsSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'\nimport { migrate } from 'drizzle-orm/better-sqlite3/migrator'\nimport { app } from 'electron'\n\nconst MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'\nconst MIGRATIONS_TABLE = '__drizzle_migrations'\n\nexport const runMigrations = (database: BetterSQLite3Database): void => {\n  const migrationsFolder = resolveMigrationsFolder()\n  if (!migrationsFolder) {\n    throw new Error('drizzle migrations folder not found for desktop')\n  }\n\n  migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })\n}\n\nconst resolveMigrationsFolder = (): string | null => {\n  const candidates = new Set<string>()\n  candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))\n  candidates.add(resolve(import.meta.dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))\n\n  if (process.resourcesPath) {\n    candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))\n    candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))\n  }\n\n  try {\n    candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))\n  } catch {\n    // app might not be ready yet, ignore\n  }\n\n  for (const candidate of candidates) {\n    if (existsSync(candidate)) {\n      return candidate\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/database/schema.ts",
    "content": "import {\n  type DownloadHistoryInsert,\n  type DownloadHistoryRow,\n  downloadHistoryTable\n} from '@vidbee/db/history'\nimport { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n\nexport const subscriptionsTable = sqliteTable('subscriptions', {\n  id: text('id').primaryKey(),\n  title: text('title').notNull(),\n  sourceUrl: text('source_url').notNull(),\n  feedUrl: text('feed_url').notNull(),\n  platform: text('platform').notNull(),\n  keywords: text('keywords').notNull(),\n  tags: text('tags').notNull(),\n  onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),\n  enabled: integer('enabled', { mode: 'number' }).notNull(),\n  coverUrl: text('cover_url'),\n  latestVideoTitle: text('latest_video_title'),\n  latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),\n  lastCheckedAt: integer('last_checked_at', { mode: 'number' }),\n  lastSuccessAt: integer('last_success_at', { mode: 'number' }),\n  status: text('status').notNull(),\n  lastError: text('last_error'),\n  createdAt: integer('created_at', { mode: 'number' }).notNull(),\n  updatedAt: integer('updated_at', { mode: 'number' }).notNull(),\n  downloadDirectory: text('download_directory'),\n  namingTemplate: text('naming_template')\n})\n\nexport const subscriptionItemsTable = sqliteTable(\n  'subscription_items',\n  {\n    subscriptionId: text('subscription_id').notNull(),\n    itemId: text('item_id').notNull(),\n    title: text('title').notNull(),\n    url: text('url').notNull(),\n    publishedAt: integer('published_at', { mode: 'number' }).notNull(),\n    thumbnail: text('thumbnail'),\n    added: integer('added', { mode: 'number' }).notNull(),\n    downloadId: text('download_id'),\n    createdAt: integer('created_at', { mode: 'number' }).notNull(),\n    updatedAt: integer('updated_at', { mode: 'number' }).notNull()\n  },\n  (table) => ({\n    pk: primaryKey({\n      columns: [table.subscriptionId, table.itemId],\n      name: 'subscription_items_pk'\n    }),\n    subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)\n  })\n)\n\nexport type SubscriptionRow = typeof subscriptionsTable.$inferSelect\nexport type SubscriptionInsert = typeof subscriptionsTable.$inferInsert\nexport type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect\nexport type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert\n\nexport { type DownloadHistoryInsert, type DownloadHistoryRow, downloadHistoryTable }\n"
  },
  {
    "path": "apps/desktop/src/main/lib/database-path.ts",
    "content": "import { join } from 'node:path'\nimport { app } from 'electron'\n\nexport const getDatabaseFilePath = (): string => {\n  return join(app.getPath('userData'), 'vidbee.db')\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/database.ts",
    "content": "import type { Database as BetterSqlite3Instance } from 'better-sqlite3'\nimport DatabaseConstructor from 'better-sqlite3'\nimport type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'\nimport { drizzle } from 'drizzle-orm/better-sqlite3'\nimport log from 'electron-log/main'\nimport { runMigrations } from './database/migrate'\nimport { getDatabaseFilePath } from './database-path'\n\nconst logger = log.scope('database')\n\ninterface DatabaseConnection {\n  sqlite: BetterSqlite3Instance\n  db: BetterSQLite3Database\n  path: string\n}\n\nlet connection: DatabaseConnection | null = null\nlet migrationsReady = false\n\nexport const getDatabaseConnection = (): DatabaseConnection => {\n  if (connection) {\n    if (!migrationsReady) {\n      runMigrations(connection.db)\n      migrationsReady = true\n    }\n    return connection\n  }\n\n  const databasePath = getDatabaseFilePath()\n  const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })\n  sqlite.pragma('journal_mode = WAL')\n  sqlite.pragma('foreign_keys = ON')\n\n  const db = drizzle(sqlite)\n  connection = { sqlite, db, path: databasePath }\n  runMigrations(db)\n  migrationsReady = true\n  logger.info(`database initialized at ${databasePath}`)\n  return connection\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/download-engine.ts",
    "content": "import { EventEmitter } from 'node:events'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'\nimport type {\n  DownloadHistoryItem,\n  DownloadItem,\n  DownloadOptions,\n  DownloadProgress,\n  PlaylistDownloadOptions,\n  PlaylistDownloadResult,\n  PlaylistInfo,\n  VideoFormat,\n  VideoInfo,\n  VideoInfoCommandResult\n} from '../../shared/types'\nimport { buildFilenameKey } from '../../shared/utils/download-file'\nimport { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'\nimport {\n  findFormatByIdCandidates,\n  parseSizeToBytes,\n  resolveSelectedFormat\n} from '../download-engine/format-utils'\nimport { settingsManager } from '../settings'\nimport { scopedLoggers } from '../utils/logger'\nimport { resolvePathWithHome } from '../utils/path-helpers'\nimport {\n  appendJsRuntimeArgs,\n  appendYouTubeSafeExtractorArgs,\n  buildVideoInfoArgs,\n  formatYtDlpCommand,\n  resolveFfmpegLocation\n} from './command-utils'\nimport { DownloadQueue } from './download-queue'\nimport {\n  type DownloadSessionItem,\n  loadDownloadSession,\n  saveDownloadSession\n} from './download-session-store'\nimport { ffmpegManager } from './ffmpeg-manager'\nimport { historyManager } from './history-manager'\nimport {\n  ensureDirectoryExists,\n  resolveAutoPlaylistDownloadPath,\n  resolveAutoVideoDownloadPath,\n  resolveHistoryDownloadPath,\n  sanitizeTemplateValue\n} from './path-resolver'\nimport { clampPercent, estimateProgressParts, isMuxedFormat } from './progress-utils'\nimport { applyShareWatermark } from './watermark-utils'\nimport { ytdlpManager } from './ytdlp-manager'\n\ninterface DownloadProcess {\n  controller: AbortController\n  process: YTDlpEventEmitter\n}\n\nclass DownloadEngine extends EventEmitter {\n  private readonly activeDownloads: Map<string, DownloadProcess> = new Map()\n  private readonly queue: DownloadQueue\n  private sessionPersistTimer: NodeJS.Timeout | null = null\n  private sessionRestored = false\n  private readonly prefetchTasks: Map<string, Promise<VideoInfo | null>> = new Map()\n  private readonly prefetchedInfo: Map<string, VideoInfo> = new Map()\n  private readonly cancelledDownloads: Set<string> = new Set()\n\n  constructor() {\n    super()\n    const maxConcurrent = settingsManager.get('maxConcurrentDownloads')\n    this.queue = new DownloadQueue(maxConcurrent)\n\n    this.queue.on('start-download', async (item) => {\n      await this.executeDownload(item.id, item.options)\n    })\n\n    this.queue.on('queue-updated', () => {\n      this.scheduleSessionPersist()\n    })\n  }\n\n  async getVideoInfo(url: string): Promise<VideoInfo> {\n    const ytdlp = ytdlpManager.getInstance()\n    const settings = settingsManager.getAll()\n\n    const args = buildVideoInfoArgs(url, settings)\n\n    return new Promise((resolve, reject) => {\n      const process = ytdlp.exec(args)\n      let stdout = ''\n      let stderr = ''\n\n      process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {\n        stdout += data.toString()\n      })\n\n      process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {\n        stderr += data.toString()\n      })\n\n      process.on('close', (code) => {\n        if (code === 0 && stdout) {\n          try {\n            const info = JSON.parse(stdout)\n\n            // Calculate estimated file size for formats missing filesize information\n            // Using tbr (total bitrate in kbps) and duration (in seconds)\n            // Formula: (tbr * 1000) / 8 * duration = size in bytes\n            if (info.formats && Array.isArray(info.formats) && info.duration) {\n              const duration = info.duration\n              for (const format of info.formats) {\n                if (\n                  !(format.filesize || format.filesize_approx) &&\n                  format.tbr &&\n                  typeof format.tbr === 'number' &&\n                  duration > 0\n                ) {\n                  // Calculate estimated size: tbr (kbps) * 1000 / 8 bits per byte * duration (seconds)\n                  const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)\n                  format.filesize_approx = estimatedSize\n                }\n              }\n            }\n\n            scopedLoggers.download.info('Successfully retrieved video info for:', url)\n            resolve(info)\n          } catch (error) {\n            scopedLoggers.download.error('Failed to parse video info for:', url, error)\n            reject(new Error(`Failed to parse video info: ${error}`))\n          }\n        } else {\n          scopedLoggers.download.error(\n            'Failed to fetch video info for:',\n            url,\n            'Exit code:',\n            code,\n            'Error:',\n            stderr\n          )\n          reject(new Error(stderr || 'Failed to fetch video info'))\n        }\n      })\n\n      process.on('error', (error) => {\n        scopedLoggers.download.error('yt-dlp process error for:', url, error)\n        reject(error)\n      })\n    })\n  }\n\n  async getVideoInfoWithCommand(url: string): Promise<VideoInfoCommandResult> {\n    const ytdlp = ytdlpManager.getInstance()\n    const settings = settingsManager.getAll()\n    const args = buildVideoInfoArgs(url, settings)\n    const ytDlpCommand = formatYtDlpCommand(args)\n\n    return new Promise((resolve) => {\n      let settled = false\n      const resolveOnce = (payload: VideoInfoCommandResult) => {\n        if (settled) {\n          return\n        }\n        settled = true\n        resolve(payload)\n      }\n\n      const process = ytdlp.exec(args)\n      let stdout = ''\n      let stderr = ''\n\n      process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {\n        stdout += data.toString()\n      })\n\n      process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {\n        stderr += data.toString()\n      })\n\n      process.on('close', (code) => {\n        if (code === 0 && stdout) {\n          try {\n            const info = JSON.parse(stdout)\n\n            // Calculate estimated file size for formats missing filesize information\n            // Using tbr (total bitrate in kbps) and duration (in seconds)\n            // Formula: (tbr * 1000) / 8 * duration = size in bytes\n            if (info.formats && Array.isArray(info.formats) && info.duration) {\n              const duration = info.duration\n              for (const format of info.formats) {\n                if (\n                  !(format.filesize || format.filesize_approx) &&\n                  format.tbr &&\n                  typeof format.tbr === 'number' &&\n                  duration > 0\n                ) {\n                  const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)\n                  format.filesize_approx = estimatedSize\n                }\n              }\n            }\n\n            scopedLoggers.download.info('Successfully retrieved video info for:', url)\n            resolveOnce({ info, ytDlpCommand })\n          } catch (error) {\n            scopedLoggers.download.error('Failed to parse video info for:', url, error)\n            resolveOnce({\n              ytDlpCommand,\n              error: `Failed to parse video info: ${error instanceof Error ? error.message : error}`\n            })\n          }\n        } else {\n          scopedLoggers.download.error(\n            'Failed to fetch video info for:',\n            url,\n            'Exit code:',\n            code,\n            'Error:',\n            stderr\n          )\n          resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' })\n        }\n      })\n\n      process.on('error', (error) => {\n        scopedLoggers.download.error('yt-dlp process error for:', url, error)\n        resolveOnce({\n          ytDlpCommand,\n          error: error instanceof Error ? error.message : 'Failed to fetch video info'\n        })\n      })\n    })\n  }\n\n  async getPlaylistInfo(url: string): Promise<PlaylistInfo> {\n    const ytdlp = ytdlpManager.getInstance()\n    const settings = settingsManager.getAll()\n\n    const args = ['-J', '--flat-playlist', '--no-warnings']\n\n    // Add encoding support for proper handling of non-ASCII characters\n    args.push('--encoding', 'utf-8')\n\n    // Add proxy if configured\n    if (settings.proxy) {\n      args.push('--proxy', settings.proxy)\n    }\n\n    // Add browser cookies if configured (skip if 'none')\n    if (settings.browserForCookies && settings.browserForCookies !== 'none') {\n      args.push('--cookies-from-browser', settings.browserForCookies)\n    }\n\n    const cookiesPath = settings.cookiesPath?.trim()\n    if (cookiesPath) {\n      args.push('--cookies', cookiesPath)\n    }\n\n    // Add config file if configured\n    const configPath = resolvePathWithHome(settings.configPath)\n    if (configPath) {\n      args.push('--config-location', configPath)\n    } else {\n      appendYouTubeSafeExtractorArgs(args, url)\n    }\n\n    appendJsRuntimeArgs(args)\n    args.push(url)\n\n    interface RawPlaylistEntry {\n      id?: string\n      title?: string\n      url?: string\n      webpage_url?: string\n      original_url?: string\n      ie_key?: string\n    }\n\n    const resolveEntryUrl = (entry: RawPlaylistEntry): string => {\n      if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) {\n        return entry.url\n      }\n      if (entry.webpage_url && typeof entry.webpage_url === 'string') {\n        return entry.webpage_url\n      }\n      if (entry.original_url && typeof entry.original_url === 'string') {\n        return entry.original_url\n      }\n      if (entry.url && typeof entry.url === 'string') {\n        if (entry.ie_key && typeof entry.ie_key === 'string') {\n          const extractor = entry.ie_key.toLowerCase()\n          if (extractor.includes('youtube')) {\n            return `https://www.youtube.com/watch?v=${entry.url}`\n          }\n          if (extractor.includes('youtubemusic')) {\n            return `https://music.youtube.com/watch?v=${entry.url}`\n          }\n        }\n        if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) {\n          return entry.url\n        }\n      }\n      if (entry.id && typeof entry.id === 'string') {\n        return entry.id\n      }\n      return ''\n    }\n\n    return new Promise((resolve, reject) => {\n      const process = ytdlp.exec(args)\n      let stdout = ''\n      let stderr = ''\n\n      process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {\n        stdout += data.toString()\n      })\n\n      process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {\n        stderr += data.toString()\n      })\n\n      process.on('close', (code) => {\n        if (code === 0 && stdout) {\n          try {\n            const parsed = JSON.parse(stdout) as {\n              id?: string\n              title?: string\n              entries?: RawPlaylistEntry[]\n            }\n            const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : []\n            const entries = rawEntries\n              .map((entry, index) => {\n                const resolvedUrl = resolveEntryUrl(entry)\n                return {\n                  id: entry.id || `${index}`,\n                  title: entry.title || `Entry ${index + 1}`,\n                  url: resolvedUrl,\n                  index: index + 1\n                }\n              })\n              .filter((entry) => entry.url)\n\n            scopedLoggers.download.info(\n              'Successfully retrieved playlist info for:',\n              url,\n              'entries:',\n              entries.length\n            )\n            resolve({\n              id: parsed.id || url,\n              title: parsed.title || 'Playlist',\n              entries,\n              entryCount: entries.length\n            })\n          } catch (error) {\n            scopedLoggers.download.error('Failed to parse playlist info for:', url, error)\n            reject(new Error(`Failed to parse playlist info: ${error}`))\n          }\n        } else {\n          scopedLoggers.download.error(\n            'Failed to fetch playlist info for:',\n            url,\n            'Exit code:',\n            code,\n            'Error:',\n            stderr\n          )\n          reject(new Error(stderr || 'Failed to fetch playlist info'))\n        }\n      })\n\n      process.on('error', (error) => {\n        scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)\n        reject(error)\n      })\n    })\n  }\n\n  async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {\n    const playlistInfo = await this.getPlaylistInfo(options.url)\n    const downloadEntries: PlaylistDownloadResult['entries'] = []\n    const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`\n\n    // Calculate the range of entries to download\n    const totalEntries = playlistInfo.entries.length\n    if (totalEntries === 0) {\n      scopedLoggers.download.warn('Playlist has no entries:', options.url)\n      return {\n        groupId,\n        playlistId: playlistInfo.id,\n        playlistTitle: playlistInfo.title,\n        type: options.type,\n        totalCount: 0,\n        startIndex: 0,\n        endIndex: 0,\n        entries: []\n      }\n    }\n\n    let rawEntries: PlaylistInfo['entries']\n    if (options.entryIds && options.entryIds.length > 0) {\n      const selectedIdSet = new Set(options.entryIds)\n      rawEntries = playlistInfo.entries.filter((entry) => selectedIdSet.has(entry.id))\n    } else {\n      const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0)\n      const requestedEnd = options.endIndex\n        ? Math.min(options.endIndex - 1, totalEntries - 1)\n        : totalEntries - 1\n      const rangeStart = Math.min(requestedStart, requestedEnd)\n      const rangeEnd = Math.max(requestedStart, requestedEnd)\n      rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)\n    }\n    const settings = settingsManager.getAll()\n    const resolvedDownloadPath =\n      options.customDownloadPath?.trim() ||\n      resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)\n    ensureDirectoryExists(resolvedDownloadPath)\n\n    const selectedEntries = rawEntries.filter((entry) => {\n      if (!entry.url) {\n        scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry)\n        return false\n      }\n      return true\n    })\n\n    const selectionSize = selectedEntries.length\n\n    scopedLoggers.download.info(\n      `Starting playlist download: ${selectionSize} items from \"${playlistInfo.title}\"`\n    )\n\n    const normalizedTitles = new Set<string>()\n    let hasDuplicateTitles = false\n    for (const entry of selectedEntries) {\n      const key = sanitizeTemplateValue(entry.title || '').toLowerCase()\n      if (normalizedTitles.has(key)) {\n        hasDuplicateTitles = true\n        break\n      }\n      normalizedTitles.add(key)\n    }\n    const indexWidth = hasDuplicateTitles\n      ? String(Math.max(...selectedEntries.map((entry) => entry.index))).length\n      : 0\n\n    // Create download items for each video in the playlist\n    for (const entry of selectedEntries) {\n      const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`\n      const customFilenameTemplate = hasDuplicateTitles\n        ? `${String(entry.index).padStart(indexWidth, '0')} - %(title)s via VidBee.%(ext)s`\n        : undefined\n\n      const downloadOptions: DownloadOptions = {\n        url: entry.url,\n        type: options.type,\n        format: options.format,\n        audioFormat: options.type === 'audio' ? options.format : undefined,\n        customDownloadPath: resolvedDownloadPath,\n        customFilenameTemplate\n      }\n\n      const createdAt = Date.now()\n      downloadEntries.push({\n        downloadId,\n        entryId: entry.id,\n        title: entry.title,\n        url: entry.url,\n        index: entry.index\n      })\n\n      // Add to queue\n      const queueItem: DownloadItem = {\n        id: downloadId,\n        url: entry.url,\n        title: entry.title,\n        type: options.type,\n        status: 'pending',\n        progress: { percent: 0 },\n        createdAt,\n        playlistId: groupId,\n        playlistTitle: playlistInfo.title,\n        playlistIndex: entry.index,\n        playlistSize: selectionSize\n      }\n      this.queue.add(downloadId, downloadOptions, queueItem)\n      this.emit('download-queued', { ...queueItem })\n\n      this.upsertHistoryEntry(downloadId, downloadOptions, {\n        title: entry.title,\n        status: 'pending',\n        downloadedAt: createdAt,\n        downloadPath: resolvedDownloadPath,\n        playlistId: groupId,\n        playlistTitle: playlistInfo.title,\n        playlistIndex: entry.index,\n        playlistSize: selectionSize\n      })\n    }\n\n    return {\n      groupId,\n      playlistId: playlistInfo.id,\n      playlistTitle: playlistInfo.title,\n      type: options.type,\n      totalCount: selectionSize,\n      startIndex: selectedEntries[0]?.index ?? 0,\n      endIndex: selectedEntries.at(-1)?.index ?? 0,\n      entries: downloadEntries\n    }\n  }\n\n  private normalizeDownloadValue(value?: string): string {\n    return value?.trim() ?? ''\n  }\n\n  private buildDownloadSignature(options: DownloadOptions): string {\n    const normalizeList = (values?: string[]): string =>\n      (values ?? [])\n        .map((value) => value.trim())\n        .filter((value) => value.length > 0)\n        .sort()\n        .join(',')\n\n    return [\n      this.normalizeDownloadValue(options.url),\n      options.type,\n      this.normalizeDownloadValue(options.format),\n      this.normalizeDownloadValue(options.audioFormat),\n      normalizeList(options.audioFormatIds),\n      this.normalizeDownloadValue(options.startTime),\n      this.normalizeDownloadValue(options.endTime),\n      this.normalizeDownloadValue(options.customDownloadPath),\n      this.normalizeDownloadValue(options.customFilenameTemplate),\n      this.normalizeDownloadValue(options.origin ?? 'manual'),\n      this.normalizeDownloadValue(options.subscriptionId)\n    ].join('|')\n  }\n\n  private hasDuplicateDownload(options: DownloadOptions): boolean {\n    const signature = this.buildDownloadSignature(options)\n    const entries = [...this.queue.getActiveEntries(), ...this.queue.getQueuedEntries()]\n    return entries.some((entry) => this.buildDownloadSignature(entry.options) === signature)\n  }\n\n  startDownload(id: string, options: DownloadOptions): boolean {\n    if (this.activeDownloads.has(id) || this.queue.getItemDetails(id)) {\n      scopedLoggers.engine.warn(`Download ${id} is already queued`)\n      return false\n    }\n\n    if (this.hasDuplicateDownload(options)) {\n      scopedLoggers.engine.warn('Duplicate download ignored:', {\n        id,\n        url: options.url,\n        type: options.type\n      })\n      return false\n    }\n\n    const createdAt = Date.now()\n    const settings = settingsManager.getAll()\n    const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath\n    const origin = options.origin ?? 'manual'\n    const historyDownloadPath = resolveHistoryDownloadPath(\n      targetDownloadPath,\n      options.customFilenameTemplate\n    )\n    ensureDirectoryExists(targetDownloadPath)\n    ensureDirectoryExists(historyDownloadPath)\n\n    const item: DownloadItem = {\n      id,\n      url: options.url,\n      title: 'Downloading...',\n      type: options.type,\n      status: 'pending' as const,\n      progress: { percent: 0 },\n      createdAt,\n      tags: options.tags,\n      origin,\n      subscriptionId: options.subscriptionId\n    }\n\n    this.queue.add(id, options, item)\n\n    this.upsertHistoryEntry(id, options, {\n      title: item.title,\n      status: 'pending',\n      downloadedAt: createdAt,\n      downloadPath: historyDownloadPath,\n      tags: options.tags,\n      origin,\n      subscriptionId: options.subscriptionId\n    })\n\n    this.emit('download-queued', { ...item })\n    void this.prefetchVideoInfo(id, options)\n    return true\n  }\n\n  private async prefetchVideoInfo(id: string, options: DownloadOptions): Promise<void> {\n    const url = options.url?.trim()\n    if (!url) {\n      return\n    }\n    if (this.prefetchTasks.has(id) || this.prefetchedInfo.has(id)) {\n      return\n    }\n\n    const task = (async () => {\n      try {\n        const info = await this.getVideoInfo(url)\n        this.prefetchedInfo.set(id, info)\n        this.updateDownloadInfo(id, {\n          title: info.title,\n          thumbnail: info.thumbnail,\n          duration: info.duration,\n          description: info.description,\n          uploader: info.uploader,\n          viewCount: info.view_count\n        })\n        return info\n      } catch (error) {\n        scopedLoggers.download.warn('Failed to prefetch video info for ID:', id, error)\n        return null\n      }\n    })()\n\n    this.prefetchTasks.set(id, task)\n    try {\n      await task\n    } finally {\n      this.prefetchTasks.delete(id)\n    }\n  }\n\n  private async executeDownload(id: string, options: DownloadOptions): Promise<void> {\n    scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)\n    const ytdlp = ytdlpManager.getInstance()\n    const settings = settingsManager.getAll()\n    const defaultDownloadPath = settings.downloadPath\n    let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath\n\n    // Set environment variables for proper encoding on Windows\n    if (process.platform === 'win32') {\n      process.env.PYTHONIOENCODING = 'utf-8'\n      process.env.LC_ALL = 'C.UTF-8'\n    }\n\n    let availableFormats: VideoFormat[] = []\n    let selectedFormat: VideoFormat | undefined\n    let actualFormat: string | null = null\n    let videoInfo: VideoInfo | undefined\n    let lastKnownOutputPath: string | undefined\n    const outputPathCandidates: string[] = []\n    let totalParts = estimateProgressParts(options)\n    let completedParts = 0\n    let lastPercent = 0\n    let ytDlpLog = ''\n    let logFlushTimer: NodeJS.Timeout | null = null\n    let lastFlushedLog = ''\n\n    const normalizeLogChunk = (chunk: string): string =>\n      chunk.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n')\n\n    const flushLogUpdate = (): void => {\n      if (logFlushTimer) {\n        clearTimeout(logFlushTimer)\n        logFlushTimer = null\n      }\n      if (ytDlpLog === lastFlushedLog) {\n        return\n      }\n      lastFlushedLog = ytDlpLog\n      this.updateDownloadInfo(id, { ytDlpLog })\n      this.emit('download-log', id, ytDlpLog)\n    }\n\n    const scheduleLogUpdate = (): void => {\n      if (logFlushTimer) {\n        return\n      }\n      logFlushTimer = setTimeout(() => {\n        flushLogUpdate()\n      }, 500)\n    }\n\n    const appendLogChunk = (chunk: string | Buffer): void => {\n      if (!chunk) {\n        return\n      }\n      const text = typeof chunk === 'string' ? chunk : chunk.toString()\n      if (!text) {\n        return\n      }\n      ytDlpLog += normalizeLogChunk(text)\n      scheduleLogUpdate()\n    }\n\n    const applyVideoInfo = (info: VideoInfo) => {\n      availableFormats = Array.isArray(info.formats) ? info.formats : []\n      selectedFormat = resolveSelectedFormat(availableFormats, options, settings)\n\n      if (selectedFormat) {\n        actualFormat = selectedFormat.ext || actualFormat\n      }\n\n      if (\n        options.type === 'video' &&\n        (!options.audioFormatIds || options.audioFormatIds.length === 0) &&\n        isMuxedFormat(selectedFormat)\n      ) {\n        totalParts = 1\n      }\n\n      this.updateDownloadInfo(id, {\n        title: info.title,\n        thumbnail: info.thumbnail,\n        duration: info.duration,\n        description: info.description,\n        uploader: info.uploader,\n        viewCount: info.view_count,\n        // Store only essential download info\n        selectedFormat\n      })\n\n      this.upsertHistoryEntry(id, options, {\n        title: info.title,\n        thumbnail: info.thumbnail,\n        duration: info.duration,\n        description: info.description,\n        uploader: info.uploader,\n        viewCount: info.view_count,\n        // Store only essential download info\n        selectedFormat\n      })\n    }\n\n    videoInfo = this.prefetchedInfo.get(id)\n    if (!videoInfo) {\n      const prefetchTask = this.prefetchTasks.get(id)\n      if (prefetchTask) {\n        try {\n          await prefetchTask\n        } catch {\n          // Ignore prefetch failures, download will attempt again below.\n        }\n        videoInfo = this.prefetchedInfo.get(id)\n      }\n    }\n\n    if (videoInfo) {\n      this.prefetchedInfo.delete(id)\n      applyVideoInfo(videoInfo)\n    } else {\n      // First, get detailed video info to capture basic metadata and formats\n      try {\n        const info = await this.getVideoInfo(options.url)\n        videoInfo = info\n        applyVideoInfo(info)\n      } catch (error) {\n        scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)\n      }\n    }\n\n    if (!options.customDownloadPath?.trim()) {\n      resolvedDownloadPath = resolveAutoVideoDownloadPath(defaultDownloadPath, videoInfo)\n      options.customDownloadPath = resolvedDownloadPath\n    }\n\n    const historyDownloadPath = resolveHistoryDownloadPath(\n      resolvedDownloadPath,\n      options.customFilenameTemplate,\n      videoInfo\n    )\n    ensureDirectoryExists(historyDownloadPath)\n    this.upsertHistoryEntry(id, options, { downloadPath: historyDownloadPath })\n\n    const applySelectedFormat = (formatId: string | undefined): boolean => {\n      if (!formatId) {\n        return false\n      }\n\n      const candidate = findFormatByIdCandidates(availableFormats, formatId)\n      if (!candidate) {\n        return false\n      }\n\n      if (selectedFormat?.format_id === candidate.format_id) {\n        return true\n      }\n\n      selectedFormat = candidate\n      actualFormat = candidate.ext || actualFormat\n\n      this.updateDownloadInfo(id, {\n        selectedFormat: candidate\n      })\n\n      return true\n    }\n\n    const args = buildDownloadArgs(\n      options,\n      resolvedDownloadPath,\n      settings,\n      ytdlpManager.getJsRuntimeArgs()\n    )\n\n    const captureOutputPath = (rawPath: string | undefined): void => {\n      if (!rawPath) {\n        return\n      }\n      const trimmed = rawPath.trim().replace(/^\"|\"$/g, '')\n      if (!trimmed) {\n        return\n      }\n      const resolvedPath = path.isAbsolute(trimmed)\n        ? trimmed\n        : path.join(resolvedDownloadPath, trimmed)\n      lastKnownOutputPath = resolvedPath\n      if (!outputPathCandidates.includes(resolvedPath)) {\n        outputPathCandidates.push(resolvedPath)\n      }\n    }\n\n    const extractOutputPathFromLog = (message: string): void => {\n      const destinationMatch = message.match(/Destination:\\s*(.+)$/)\n      if (destinationMatch) {\n        captureOutputPath(destinationMatch[1])\n        return\n      }\n\n      const mergingMatch = message.match(/Merging formats into\\s+\"(.+?)\"/)\n      if (mergingMatch) {\n        captureOutputPath(mergingMatch[1])\n        return\n      }\n\n      const movingMatch = message.match(/Moving file to\\s+\"(.+?)\"/)\n      if (movingMatch) {\n        captureOutputPath(movingMatch[1])\n      }\n    }\n\n    // Check if format selector contains '+' which means video and audio will be merged\n    const formatSelector =\n      options.type === 'video' ? resolveVideoFormatSelector(options) : undefined\n    const willMerge = formatSelector?.includes('+') ?? false\n\n    const urlArg = args.pop()\n    if (!urlArg) {\n      const missingUrlError = new Error('Download arguments missing URL.')\n      scopedLoggers.download.error('Missing URL argument for download ID:', id)\n      this.updateDownloadInfo(id, {\n        status: 'error',\n        completedAt: Date.now(),\n        error: missingUrlError.message\n      })\n      this.queue.downloadCompleted(id)\n      this.emit('download-error', id, missingUrlError)\n      this.addToHistory(id, options, 'error', missingUrlError.message)\n      return\n    }\n\n    let ffmpegPath: string\n    try {\n      ffmpegPath = ffmpegManager.getPath()\n    } catch (error) {\n      const ffmpegError = error instanceof Error ? error : new Error(String(error))\n      scopedLoggers.download.error('Failed to resolve ffmpeg for download ID:', id, ffmpegError)\n      this.updateDownloadInfo(id, {\n        status: 'error',\n        completedAt: Date.now(),\n        error: ffmpegError.message\n      })\n      this.queue.downloadCompleted(id)\n      this.emit('download-error', id, ffmpegError)\n      this.addToHistory(id, options, 'error', ffmpegError.message)\n      return\n    }\n\n    const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)\n    args.push('--ffmpeg-location', ffmpegLocation)\n    args.push(urlArg)\n\n    const ytDlpCommand = formatYtDlpCommand(args)\n    this.updateDownloadInfo(id, { ytDlpCommand })\n    scopedLoggers.download.info('yt-dlp command:', ytDlpCommand)\n\n    const controller = new AbortController()\n    const ytdlpProcess = ytdlp.exec(args, {\n      signal: controller.signal\n    })\n\n    this.activeDownloads.set(id, { controller, process: ytdlpProcess })\n\n    ytdlpProcess.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {\n      appendLogChunk(data)\n    })\n\n    ytdlpProcess.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {\n      appendLogChunk(data)\n    })\n\n    this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })\n    this.scheduleSessionPersist()\n    this.emit('download-started', id)\n\n    this.upsertHistoryEntry(id, options, {\n      status: 'downloading'\n    })\n\n    let latestKnownSizeBytes: number | undefined\n\n    // Handle progress\n    ytdlpProcess.on(\n      'progress',\n      (progress: {\n        percent?: number\n        currentSpeed?: string\n        eta?: string\n        downloaded?: string\n        total?: string\n      }) => {\n        const totalBytes = parseSizeToBytes(progress.total)\n        if (totalBytes !== undefined) {\n          latestKnownSizeBytes = totalBytes\n        }\n\n        const downloadedBytes = parseSizeToBytes(progress.downloaded)\n        if (downloadedBytes !== undefined) {\n          latestKnownSizeBytes =\n            latestKnownSizeBytes === undefined\n              ? downloadedBytes\n              : Math.max(latestKnownSizeBytes, downloadedBytes)\n        }\n\n        const normalizedPercent = clampPercent(progress.percent)\n        if (\n          totalParts > 1 &&\n          lastPercent >= 90 &&\n          normalizedPercent <= 10 &&\n          completedParts < totalParts - 1\n        ) {\n          completedParts += 1\n        }\n        lastPercent = normalizedPercent\n        const mergedPercent =\n          totalParts > 1\n            ? ((completedParts + normalizedPercent / 100) / totalParts) * 100\n            : normalizedPercent\n\n        const downloadProgress: DownloadProgress = {\n          percent: Math.min(100, mergedPercent),\n          currentSpeed: progress.currentSpeed || '',\n          eta: progress.eta || '',\n          downloaded: progress.downloaded || '',\n          total: progress.total || ''\n        }\n        this.queue.updateItemInfo(id, {\n          progress: downloadProgress,\n          speed: downloadProgress.currentSpeed || ''\n        })\n        this.scheduleSessionPersist()\n        this.emit('download-progress', id, downloadProgress)\n      }\n    )\n\n    // Handle yt-dlp events to capture format info\n    ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {\n      if (\n        eventType === 'postprocess' ||\n        eventData.toLowerCase().includes('merging formats') ||\n        eventData.toLowerCase().includes('post-process')\n      ) {\n        const snapshot = this.queue.getItemDetails(id)\n        if (snapshot?.item.status !== 'processing') {\n          this.updateDownloadInfo(id, { status: 'processing' })\n        }\n      }\n\n      // Look for format selection messages\n      if (eventType === 'info' && eventData.includes('format')) {\n        // Extract format info from yt-dlp output\n        const formatMatch = eventData.match(/\\[info\\]\\s*([^\\s:]+):\\s*(.+)/)\n        if (formatMatch) {\n          const formatId = formatMatch[1]\n          const formatInfo = formatMatch[2]\n\n          applySelectedFormat(formatId)\n\n          // Extract format details with better regex patterns\n          const extMatch = formatInfo.match(/(\\w+)(?:\\s|$)/)\n          if (extMatch && !actualFormat) {\n            actualFormat = extMatch[1]\n          }\n        }\n      }\n\n      // Also look for download progress messages that might contain format info\n      if (eventType === 'download' && eventData.includes('format')) {\n        const formatMatch = eventData.match(/format\\s*([0-9A-Za-z+-]+)/)\n        if (formatMatch) {\n          applySelectedFormat(formatMatch[1])\n        }\n      }\n\n      if (eventType === 'download' || eventType === 'info') {\n        extractOutputPathFromLog(eventData)\n      }\n    })\n\n    // Handle completion\n    ytdlpProcess.on('close', async (code: number | null) => {\n      flushLogUpdate()\n      const wasCancelled = controller.signal.aborted || this.cancelledDownloads.has(id)\n      this.activeDownloads.delete(id)\n      this.queue.downloadCompleted(id)\n\n      if (wasCancelled) {\n        this.cancelledDownloads.delete(id)\n        return\n      }\n\n      if (code === 0) {\n        // Generate file path using downloadPath + title + ext\n        const title = videoInfo?.title || 'Unknown'\n        const sanitizedTitle = title.replace(/[<>:\"/\\\\|?*]/g, '_').substring(0, 50)\n\n        // Determine file extension based on download type and format\n        // yt-dlp automatically chooses the best merge format (mkv/webm/mp4)\n        // based on codec compatibility, so we should use actualFormat when available\n        let extension: string\n        if (options.type === 'audio') {\n          // Use format extension from yt-dlp output (actualFormat contains the extension)\n          extension = actualFormat || 'm4a'\n        } else if (willMerge) {\n          // For merged files, yt-dlp auto-selects format (mkv/webm/mp4)\n          // Use actualFormat if available, otherwise default to mkv (most compatible)\n          extension = actualFormat || 'mkv'\n        } else {\n          extension = actualFormat || 'mp4'\n        }\n\n        const fallbackFileName = `${sanitizedTitle}.${extension}`\n        const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)\n\n        scopedLoggers.download.info(\n          'Resolved output paths for ID:',\n          id,\n          'Primary:',\n          lastKnownOutputPath ?? fallbackOutputPath,\n          'Fallback:',\n          fallbackOutputPath,\n          'Will merge:',\n          willMerge\n        )\n\n        let fileSize: number | undefined\n        let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath\n        const candidatePaths: string[] = [...outputPathCandidates].reverse()\n        const pushCandidatePath = (candidate: string | undefined) => {\n          if (!candidate) {\n            return\n          }\n          if (!candidatePaths.includes(candidate)) {\n            candidatePaths.push(candidate)\n          }\n        }\n        pushCandidatePath(lastKnownOutputPath)\n        pushCandidatePath(fallbackOutputPath)\n\n        try {\n          const fs = await import('node:fs/promises')\n          let located = false\n          for (const candidate of candidatePaths) {\n            if (!candidate) {\n              continue\n            }\n            try {\n              const stats = await fs.stat(candidate)\n              fileSize = stats.size\n              actualFilePath = candidate\n              located = true\n              break\n            } catch {}\n          }\n\n          if (!located) {\n            const files = await fs.readdir(resolvedDownloadPath)\n            const rawTitle = videoInfo?.title ?? this.queue.getItemDetails(id)?.item.title ?? ''\n            const titleKey = buildFilenameKey(rawTitle)\n            const normalizedExt = extension.toLowerCase()\n\n            const matchesTitle = (fileName: string): boolean => {\n              if (!titleKey) {\n                return false\n              }\n              const fileKey = buildFilenameKey(fileName)\n              if (!fileKey) {\n                return false\n              }\n              return fileKey.includes(titleKey) || titleKey.includes(fileKey)\n            }\n\n            const candidates = files\n              .map((file) => {\n                const ext = file.split('.').pop()?.toLowerCase()\n                return { file, ext }\n              })\n              .filter((entry) => entry.ext)\n\n            const withExtension = candidates.filter((entry) => entry.ext === normalizedExt)\n            const titleMatches = withExtension.filter((entry) => matchesTitle(entry.file))\n            const vidbeeMatches = withExtension.filter((entry) =>\n              entry.file.toLowerCase().includes('vidbee')\n            )\n            const fallbackMatches = withExtension.length > 0 ? withExtension : candidates\n            const pickFrom =\n              titleMatches.length > 0\n                ? titleMatches\n                : vidbeeMatches.length > 0\n                  ? vidbeeMatches\n                  : fallbackMatches\n\n            if (pickFrom.length > 0) {\n              const fileStats = await Promise.all(\n                pickFrom.map(async (entry) => {\n                  const filePath = path.join(resolvedDownloadPath, entry.file)\n                  const stats = await fs.stat(filePath)\n                  if (!stats.isFile()) {\n                    return null\n                  }\n                  return { path: filePath, mtime: stats.mtime, size: stats.size }\n                })\n              )\n              const existingStats = fileStats.filter(\n                (entry): entry is { path: string; mtime: Date; size: number } => Boolean(entry)\n              )\n              if (existingStats.length > 0) {\n                const mostRecent = existingStats.sort(\n                  (a, b) => b.mtime.getTime() - a.mtime.getTime()\n                )[0]\n                actualFilePath = mostRecent.path\n                fileSize = mostRecent.size\n                located = true\n                scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)\n              }\n            }\n          }\n\n          if (!fileSize && latestKnownSizeBytes !== undefined) {\n            fileSize = latestKnownSizeBytes\n            if (!located) {\n              scopedLoggers.download.warn('File not found, using estimated size:', fileSize)\n            }\n          } else if (!fileSize) {\n            scopedLoggers.download.warn('Failed to find file for ID:', id)\n          }\n        } catch (error) {\n          scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)\n          if (latestKnownSizeBytes !== undefined) {\n            fileSize = latestKnownSizeBytes\n          }\n        }\n\n        if (fileSize === undefined && latestKnownSizeBytes !== undefined) {\n          fileSize = latestKnownSizeBytes\n        }\n\n        let finalFilePath = actualFilePath\n        let finalFileSize = fileSize\n\n        if (settings.shareWatermark && options.type === 'video') {\n          if (fs.existsSync(actualFilePath)) {\n            this.updateDownloadInfo(id, { status: 'processing' })\n            const snapshot = this.queue.getItemDetails(id)\n            const watermarkTitle = videoInfo?.title ?? snapshot?.item.title\n            const watermarkAuthor = videoInfo?.uploader ?? snapshot?.item.uploader\n            try {\n              const watermarkResult = await applyShareWatermark({\n                inputPath: actualFilePath,\n                ffmpegPath,\n                title: watermarkTitle,\n                author: watermarkAuthor\n              })\n              if (watermarkResult) {\n                finalFilePath = watermarkResult.outputPath\n                finalFileSize = watermarkResult.fileSize\n              }\n            } catch (error) {\n              scopedLoggers.download.warn('Failed to apply share watermark for ID:', id, error)\n            }\n          } else {\n            scopedLoggers.download.warn(\n              'Watermark skipped because file was not found:',\n              actualFilePath\n            )\n          }\n        }\n\n        const savedFileName = path.basename(finalFilePath)\n\n        this.updateDownloadInfo(id, {\n          status: 'completed',\n          completedAt: Date.now(),\n          fileSize: finalFileSize,\n          savedFileName\n        })\n        scopedLoggers.download.info('Download completed successfully for ID:', id)\n        this.emit('download-completed', id)\n        this.addToHistory(id, options, 'completed', undefined)\n      } else {\n        scopedLoggers.download.error(\n          'Download failed with exit code for ID:',\n          id,\n          'Exit code:',\n          code\n        )\n        this.emit('download-error', id, new Error(`Download exited with code ${code}`))\n        this.addToHistory(id, options, 'error', `Download exited with code ${code}`)\n      }\n    })\n\n    // Handle errors\n    ytdlpProcess.on('error', (error: Error) => {\n      flushLogUpdate()\n      const wasCancelled = controller.signal.aborted || this.cancelledDownloads.has(id)\n      scopedLoggers.download.error('Download process error for ID:', id, error)\n      this.activeDownloads.delete(id)\n      this.queue.downloadCompleted(id)\n      if (wasCancelled) {\n        this.cancelledDownloads.delete(id)\n        return\n      }\n      this.emit('download-error', id, error)\n      this.addToHistory(id, options, 'error', error.message)\n    })\n  }\n\n  cancelDownload(id: string): boolean {\n    scopedLoggers.download.info('Cancelling download for ID:', id)\n\n    const download = this.activeDownloads.get(id)\n    if (download) {\n      this.cancelledDownloads.add(id)\n      download.controller.abort()\n      const removedFromQueue = this.queue.remove(id)\n      this.activeDownloads.delete(id)\n      scopedLoggers.download.info('Download cancelled successfully for ID:', id)\n      this.emit('download-cancelled', id)\n      historyManager.removeHistoryItem(id)\n      this.prefetchTasks.delete(id)\n      this.prefetchedInfo.delete(id)\n      return removedFromQueue\n    }\n    const removed = this.queue.remove(id)\n    if (removed) {\n      this.emit('download-cancelled', id)\n      historyManager.removeHistoryItem(id)\n      this.prefetchTasks.delete(id)\n      this.prefetchedInfo.delete(id)\n    }\n    return removed\n  }\n\n  updateMaxConcurrent(max: number): void {\n    this.queue.setMaxConcurrent(max)\n  }\n\n  getQueueStatus() {\n    return this.queue.getQueueStatus()\n  }\n\n  getActiveDownloads(): DownloadItem[] {\n    const items = new Map<string, DownloadItem>()\n    for (const item of this.queue.getActiveItems()) {\n      items.set(item.id, item)\n    }\n    for (const item of this.queue.getQueuedItems()) {\n      items.set(item.id, item)\n    }\n    return Array.from(items.values()).sort((a, b) => b.createdAt - a.createdAt)\n  }\n\n  restoreActiveDownloads(): void {\n    if (this.sessionRestored) {\n      return\n    }\n    this.sessionRestored = true\n\n    const sessionItems = loadDownloadSession()\n    if (sessionItems.length === 0) {\n      return\n    }\n\n    for (const entry of sessionItems) {\n      if (!(entry?.id && entry.options?.url && entry.options.type)) {\n        continue\n      }\n      if (this.queue.getItemDetails(entry.id)) {\n        continue\n      }\n\n      const historyItem = historyManager.getHistoryById(entry.id)\n      if (historyItem && ['completed', 'error', 'cancelled'].includes(historyItem.status)) {\n        continue\n      }\n\n      const createdAt = entry.item?.createdAt ?? Date.now()\n      const restoredItem: DownloadItem = {\n        ...entry.item,\n        id: entry.id,\n        url: entry.options.url,\n        type: entry.options.type,\n        status: 'pending',\n        createdAt,\n        completedAt: undefined\n      }\n\n      this.queue.add(entry.id, entry.options, restoredItem)\n\n      this.upsertHistoryEntry(entry.id, entry.options, {\n        title: restoredItem.title || historyItem?.title || `Download ${entry.id}`,\n        status: 'pending',\n        downloadedAt: historyItem?.downloadedAt ?? createdAt\n      })\n    }\n\n    this.scheduleSessionPersist()\n  }\n\n  flushDownloadSession(): void {\n    if (this.sessionPersistTimer) {\n      clearTimeout(this.sessionPersistTimer)\n      this.sessionPersistTimer = null\n    }\n    this.persistSession()\n  }\n\n  updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {\n    this.queue.updateItemInfo(id, updates)\n\n    const snapshot = this.queue.getItemDetails(id)\n    if (!snapshot) {\n      return\n    }\n\n    const historyUpdates: Partial<DownloadHistoryItem> = {}\n\n    if (updates.title !== undefined) {\n      historyUpdates.title = updates.title\n    }\n    if (updates.thumbnail !== undefined) {\n      historyUpdates.thumbnail = updates.thumbnail\n    }\n    if (updates.duration !== undefined) {\n      historyUpdates.duration = updates.duration\n    }\n    if (updates.fileSize !== undefined) {\n      historyUpdates.fileSize = updates.fileSize\n    }\n    if (updates.description !== undefined) {\n      historyUpdates.description = updates.description\n    }\n    if (updates.channel !== undefined) {\n      historyUpdates.channel = updates.channel\n    }\n    if (updates.uploader !== undefined) {\n      historyUpdates.uploader = updates.uploader\n    }\n    if (updates.viewCount !== undefined) {\n      historyUpdates.viewCount = updates.viewCount\n    }\n    if (updates.tags !== undefined) {\n      historyUpdates.tags = updates.tags\n    }\n    if (updates.playlistId !== undefined) {\n      historyUpdates.playlistId = updates.playlistId\n    }\n    if (updates.playlistTitle !== undefined) {\n      historyUpdates.playlistTitle = updates.playlistTitle\n    }\n    if (updates.playlistIndex !== undefined) {\n      historyUpdates.playlistIndex = updates.playlistIndex\n    }\n    if (updates.playlistSize !== undefined) {\n      historyUpdates.playlistSize = updates.playlistSize\n    }\n    if (updates.selectedFormat !== undefined) {\n      historyUpdates.selectedFormat = updates.selectedFormat\n    }\n    if (updates.status !== undefined) {\n      historyUpdates.status = updates.status\n    }\n    if (updates.completedAt !== undefined) {\n      historyUpdates.completedAt = updates.completedAt\n    }\n    if (updates.error !== undefined) {\n      historyUpdates.error = updates.error\n    }\n    if (updates.ytDlpCommand !== undefined) {\n      historyUpdates.ytDlpCommand = updates.ytDlpCommand\n    }\n    if (updates.ytDlpLog !== undefined) {\n      historyUpdates.ytDlpLog = updates.ytDlpLog\n    }\n    if (updates.savedFileName !== undefined) {\n      historyUpdates.savedFileName = updates.savedFileName\n    }\n\n    if (Object.keys(historyUpdates).length > 0) {\n      this.upsertHistoryEntry(id, snapshot.options, historyUpdates)\n    }\n\n    if (Object.keys(updates).length > 0) {\n      this.emit('download-updated', id, { ...updates })\n    }\n\n    this.scheduleSessionPersist()\n  }\n\n  private scheduleSessionPersist(): void {\n    if (this.sessionPersistTimer) {\n      return\n    }\n    this.sessionPersistTimer = setTimeout(() => {\n      this.sessionPersistTimer = null\n      this.persistSession()\n    }, 1000)\n  }\n\n  private persistSession(): void {\n    const entries: DownloadSessionItem[] = []\n    const activeEntries = this.queue.getActiveEntries()\n    const queuedEntries = this.queue.getQueuedEntries()\n\n    for (const entry of [...activeEntries, ...queuedEntries]) {\n      if (!entry?.item?.id) {\n        continue\n      }\n      entries.push({\n        id: entry.item.id,\n        options: entry.options,\n        item: entry.item\n      })\n    }\n\n    saveDownloadSession(entries)\n  }\n\n  private addToHistory(\n    id: string,\n    options: DownloadOptions,\n    status: DownloadHistoryItem['status'],\n    error?: string\n  ): void {\n    // Get the download item from the queue to get additional info\n    const completedDownload = this.queue.getCompletedDownload(id)\n    // scopedLoggers.download.info('Completed download:', completedDownload)\n    const completedAt = Date.now()\n\n    this.upsertHistoryEntry(id, options, {\n      title: completedDownload?.item.title || `Download ${id}`,\n      thumbnail: completedDownload?.item.thumbnail,\n      status,\n      completedAt,\n      error,\n      duration: completedDownload?.item.duration,\n      fileSize: completedDownload?.item.fileSize,\n      description: completedDownload?.item.description,\n      channel: completedDownload?.item.channel,\n      uploader: completedDownload?.item.uploader,\n      viewCount: completedDownload?.item.viewCount,\n      tags: completedDownload?.item.tags,\n      origin: completedDownload?.item.origin,\n      subscriptionId: completedDownload?.item.subscriptionId,\n      playlistId: completedDownload?.item.playlistId,\n      playlistTitle: completedDownload?.item.playlistTitle,\n      playlistIndex: completedDownload?.item.playlistIndex,\n      playlistSize: completedDownload?.item.playlistSize\n    })\n  }\n\n  private upsertHistoryEntry(\n    id: string,\n    options: DownloadOptions,\n    updates: Partial<DownloadHistoryItem>\n  ): void {\n    const existing = historyManager.getHistoryById(id)\n    const resolvedDownloadPath =\n      updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath\n    const base: DownloadHistoryItem = existing ?? {\n      id,\n      url: options.url,\n      title: updates.title || `Download ${id}`,\n      thumbnail: updates.thumbnail,\n      type: options.type,\n      status: updates.status || 'pending',\n      downloadPath: resolvedDownloadPath,\n      savedFileName: updates.savedFileName,\n      fileSize: updates.fileSize,\n      duration: updates.duration,\n      downloadedAt: updates.downloadedAt ?? Date.now(),\n      completedAt: updates.completedAt,\n      error: updates.error,\n      ytDlpCommand: updates.ytDlpCommand,\n      ytDlpLog: updates.ytDlpLog,\n      description: updates.description,\n      channel: updates.channel,\n      uploader: updates.uploader,\n      viewCount: updates.viewCount,\n      tags: updates.tags ?? options.tags,\n      origin: updates.origin ?? options.origin,\n      subscriptionId: updates.subscriptionId ?? options.subscriptionId,\n      // Download-specific format info\n      selectedFormat: updates.selectedFormat,\n      playlistId: updates.playlistId,\n      playlistTitle: updates.playlistTitle,\n      playlistIndex: updates.playlistIndex,\n      playlistSize: updates.playlistSize\n    }\n\n    const merged: DownloadHistoryItem = {\n      ...base,\n      ...updates,\n      id,\n      url: updates.url ?? base.url,\n      type: updates.type ?? base.type,\n      title: updates.title ?? base.title,\n      status: updates.status ?? base.status,\n      downloadedAt: updates.downloadedAt ?? base.downloadedAt,\n      downloadPath: resolvedDownloadPath ?? base.downloadPath,\n      tags: updates.tags ?? base.tags,\n      origin: updates.origin ?? base.origin,\n      subscriptionId: updates.subscriptionId ?? base.subscriptionId\n    }\n\n    historyManager.addHistoryItem(merged)\n  }\n}\n\nexport const downloadEngine = new DownloadEngine()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/download-queue.ts",
    "content": "import { EventEmitter } from 'node:events'\nimport type { DownloadItem, DownloadOptions } from '../../shared/types'\n\ninterface QueueItem {\n  id: string\n  options: DownloadOptions\n  item: DownloadItem\n}\n\nexport class DownloadQueue extends EventEmitter {\n  private queue: QueueItem[] = []\n  private readonly activeDownloads: Map<string, QueueItem> = new Map()\n  private readonly completedDownloads: Map<string, QueueItem> = new Map()\n  private maxConcurrent: number\n\n  constructor(maxConcurrent = 5) {\n    super()\n    this.maxConcurrent = maxConcurrent\n  }\n\n  setMaxConcurrent(max: number): void {\n    this.maxConcurrent = max\n    this.processQueue()\n  }\n\n  add(id: string, options: DownloadOptions, item: DownloadItem): void {\n    this.queue.push({ id, options, item })\n    this.emit('queue-updated', this.getQueueStatus())\n    this.processQueue()\n  }\n\n  remove(id: string): boolean {\n    // Remove from queue\n    const queueIndex = this.queue.findIndex((item) => item.id === id)\n    if (queueIndex !== -1) {\n      this.queue.splice(queueIndex, 1)\n      this.emit('queue-updated', this.getQueueStatus())\n      return true\n    }\n\n    // Remove from active downloads\n    if (this.activeDownloads.has(id)) {\n      this.activeDownloads.delete(id)\n      this.emit('queue-updated', this.getQueueStatus())\n      this.processQueue()\n      return true\n    }\n\n    return false\n  }\n\n  downloadCompleted(id: string): void {\n    const activeDownload = this.activeDownloads.get(id)\n    if (activeDownload) {\n      this.completedDownloads.set(id, activeDownload)\n      this.activeDownloads.delete(id)\n    }\n    this.emit('queue-updated', this.getQueueStatus())\n    this.processQueue()\n  }\n\n  private processQueue(): void {\n    while (this.activeDownloads.size < this.maxConcurrent && this.queue.length > 0) {\n      const item = this.queue.shift()\n      if (item) {\n        this.activeDownloads.set(item.id, item)\n        this.emit('start-download', item)\n      }\n    }\n    this.emit('queue-updated', this.getQueueStatus())\n  }\n\n  getQueueStatus(): {\n    queued: number\n    active: number\n    activeIds: string[]\n  } {\n    return {\n      queued: this.queue.length,\n      active: this.activeDownloads.size,\n      activeIds: Array.from(this.activeDownloads.keys())\n    }\n  }\n\n  getActiveItems(): DownloadItem[] {\n    return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item }))\n  }\n\n  getQueuedItems(): DownloadItem[] {\n    return this.queue.map((item) => ({ ...item.item }))\n  }\n\n  getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {\n    return Array.from(this.activeDownloads.values()).map((entry) => ({\n      options: { ...entry.options },\n      item: { ...entry.item }\n    }))\n  }\n\n  getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {\n    return this.queue.map((entry) => ({\n      options: { ...entry.options },\n      item: { ...entry.item }\n    }))\n  }\n\n  isDownloading(id: string): boolean {\n    return this.activeDownloads.has(id)\n  }\n\n  getCompletedDownload(id: string): QueueItem | undefined {\n    return this.completedDownloads.get(id)\n  }\n\n  getItemDetails(id: string): { options: DownloadOptions; item: DownloadItem } | undefined {\n    const inActive = this.activeDownloads.get(id)\n    if (inActive) {\n      return {\n        options: inActive.options,\n        item: { ...inActive.item }\n      }\n    }\n\n    const inQueue = this.queue.find((entry) => entry.id === id)\n    if (inQueue) {\n      return {\n        options: inQueue.options,\n        item: { ...inQueue.item }\n      }\n    }\n\n    const completed = this.completedDownloads.get(id)\n    if (completed) {\n      return {\n        options: completed.options,\n        item: { ...completed.item }\n      }\n    }\n\n    return undefined\n  }\n\n  updateItemInfo(id: string, updates: Partial<DownloadItem>): void {\n    // Update in queue\n    const queueItem = this.queue.find((item) => item.id === id)\n    if (queueItem) {\n      Object.assign(queueItem.item, updates)\n    }\n\n    // Update in active downloads\n    const activeItem = this.activeDownloads.get(id)\n    if (activeItem) {\n      Object.assign(activeItem.item, updates)\n    }\n\n    // Update in completed downloads\n    const completedItem = this.completedDownloads.get(id)\n    if (completedItem) {\n      Object.assign(completedItem.item, updates)\n    }\n  }\n\n  clear(): void {\n    this.queue = []\n    this.emit('queue-updated', this.getQueueStatus())\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/download-session-store.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { app } from 'electron'\nimport type { DownloadItem, DownloadOptions } from '../../shared/types'\nimport { scopedLoggers } from '../utils/logger'\n\nexport interface DownloadSessionItem {\n  id: string\n  options: DownloadOptions\n  item: DownloadItem\n}\n\ninterface DownloadSessionPayload {\n  version: 1\n  updatedAt: number\n  items: DownloadSessionItem[]\n}\n\nconst SESSION_FILE_NAME = 'download-session.json'\n\nconst getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME)\n\nconst isValidItem = (item: DownloadSessionItem): boolean =>\n  Boolean(item?.id && item.options && item.item)\n\nexport const loadDownloadSession = (): DownloadSessionItem[] => {\n  const filePath = getSessionFilePath()\n  if (!fs.existsSync(filePath)) {\n    return []\n  }\n\n  try {\n    const raw = fs.readFileSync(filePath, 'utf-8')\n    const payload = JSON.parse(raw) as DownloadSessionPayload\n    if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) {\n      return []\n    }\n    return payload.items.filter(isValidItem)\n  } catch (error) {\n    scopedLoggers.download.warn('Failed to load download session:', error)\n    return []\n  }\n}\n\nexport const saveDownloadSession = (items: DownloadSessionItem[]): void => {\n  const filePath = getSessionFilePath()\n  if (items.length === 0) {\n    try {\n      fs.rmSync(filePath, { force: true })\n    } catch (error) {\n      scopedLoggers.download.warn('Failed to clear download session:', error)\n    }\n    return\n  }\n\n  const payload: DownloadSessionPayload = {\n    version: 1,\n    updatedAt: Date.now(),\n    items\n  }\n\n  try {\n    fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8')\n  } catch (error) {\n    scopedLoggers.download.warn('Failed to save download session:', error)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/ffmpeg-manager.ts",
    "content": "import { execSync } from 'node:child_process'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { scopedLoggers } from '../utils/logger'\n\nclass FfmpegManager {\n  private ffmpegPath: string | null = null\n\n  async initialize(): Promise<void> {\n    this.ffmpegPath = await this.findFfmpegBinary()\n    scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)\n  }\n\n  getPath(): string {\n    if (!this.ffmpegPath) {\n      throw new Error('ffmpeg not initialized. Call initialize() first.')\n    }\n    return this.ffmpegPath\n  }\n\n  private getResourcesPath(): string {\n    if (process.env.NODE_ENV === 'development') {\n      return path.join(process.cwd(), 'resources')\n    }\n    const asarUnpackedPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')\n    if (fs.existsSync(asarUnpackedPath)) {\n      return asarUnpackedPath\n    }\n    return path.join(process.resourcesPath, 'resources')\n  }\n\n  private async findFfmpegBinary(): Promise<string> {\n    const platform = os.platform()\n    const ffmpegFileName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'\n    const ffprobeFileName = platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'\n\n    const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => {\n      const ffmpegPath = path.join(dirPath, ffmpegFileName)\n      const ffprobePath = path.join(dirPath, ffprobeFileName)\n      if (!(fs.existsSync(ffmpegPath) && fs.existsSync(ffprobePath))) {\n        return null\n      }\n      if (platform !== 'win32') {\n        try {\n          fs.chmodSync(ffmpegPath, 0o755)\n          fs.chmodSync(ffprobePath, 0o755)\n        } catch (error) {\n          scopedLoggers.engine.warn(`Failed to set executable permission on ${label}:`, error)\n        }\n      }\n      scopedLoggers.engine.info(`Using ${label}:`, ffmpegPath)\n      return ffmpegPath\n    }\n\n    const envPath = process.env.FFMPEG_PATH\n    if (envPath) {\n      if (!fs.existsSync(envPath)) {\n        throw new Error(\n          'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.'\n        )\n      }\n      const stats = fs.statSync(envPath)\n      if (!stats.isDirectory()) {\n        throw new Error('FFMPEG_PATH must be a directory containing ffmpeg and ffprobe.')\n      }\n      const resolved = resolveBundledFfmpeg(envPath, 'ffmpeg from FFMPEG_PATH directory')\n      if (resolved) {\n        return resolved\n      }\n      throw new Error('FFMPEG_PATH must contain both ffmpeg and ffprobe.')\n    }\n\n    const resourcesPath = this.getResourcesPath()\n    const bundledDir = path.join(resourcesPath, 'ffmpeg')\n    const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg')\n    if (bundledResolved) {\n      return bundledResolved\n    }\n\n    if (platform === 'darwin') {\n      const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin']\n      for (const candidate of commonDirs) {\n        const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg')\n        if (resolved) {\n          return resolved\n        }\n      }\n    }\n\n    if (platform === 'linux' || platform === 'freebsd') {\n      try {\n        const systemPath = execSync('which ffmpeg').toString().trim()\n        if (systemPath && fs.existsSync(systemPath)) {\n          const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')\n          if (resolved) {\n            return resolved\n          }\n        }\n      } catch (_error) {\n        // Ignore error and continue\n      }\n    }\n\n    if (platform === 'win32') {\n      try {\n        const output = execSync('where ffmpeg').toString().split(/\\r?\\n/)[0]\n        if (output && fs.existsSync(output)) {\n          const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')\n          if (resolved) {\n            return resolved\n          }\n        }\n      } catch (_error) {\n        // Ignore error and continue\n      }\n    }\n\n    throw new Error(\n      'ffmpeg/ffprobe not found. Bundle them under resources/ffmpeg/ in the build output or set FFMPEG_PATH to a directory containing both.'\n    )\n  }\n}\n\nexport const ffmpegManager = new FfmpegManager()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/history-manager.ts",
    "content": "import { eq, inArray } from 'drizzle-orm'\nimport type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'\nimport log from 'electron-log/main'\nimport type { DownloadHistoryItem } from '../../shared/types'\nimport { getDatabaseConnection } from './database'\nimport {\n  type DownloadHistoryInsert,\n  type DownloadHistoryRow,\n  downloadHistoryTable\n} from './database/schema'\n\nconst logger = log.scope('history-manager')\n\nconst TAG_SEPARATOR = '\\n'\n\nconst sanitizeList = (values?: string[]): string[] => {\n  if (!values || values.length === 0) {\n    return []\n  }\n  return values\n    .map((value) => value.trim())\n    .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)\n}\n\nconst serializeTags = (values?: string[]): string | null => {\n  const sanitized = sanitizeList(values)\n  return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null\n}\n\nconst parseTags = (value: string | null): string[] | undefined => {\n  if (!value) {\n    return undefined\n  }\n  const parsed = value\n    .split(TAG_SEPARATOR)\n    .map((tag) => tag.trim())\n    .filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)\n  return parsed.length > 0 ? parsed : undefined\n}\n\nclass HistoryManager {\n  private db: BetterSQLite3Database | null = null\n  private history: Map<string, DownloadHistoryItem> = new Map()\n\n  constructor() {\n    this.initialize()\n  }\n\n  private initialize(): void {\n    try {\n      this.getDatabase()\n      this.loadHistoryFromDatabase()\n    } catch (error) {\n      logger.error('history-db failed to initialize', error)\n    }\n  }\n\n  private getDatabase(): BetterSQLite3Database {\n    if (this.db) {\n      return this.db\n    }\n    const { db } = getDatabaseConnection()\n    this.db = db\n    return this.db\n  }\n\n  private loadHistoryFromDatabase(): void {\n    try {\n      const database = this.getDatabase()\n      const rows = database.select().from(downloadHistoryTable).all()\n      this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))\n    } catch (error) {\n      logger.error('history-db failed to load rows', error)\n      this.history = new Map()\n    }\n  }\n\n  private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {\n    const fallbackTimestamp = Date.now()\n    const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp\n    const status = item.status ?? 'pending'\n\n    return {\n      ...item,\n      status,\n      downloadedAt\n    }\n  }\n\n  private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {\n    return {\n      id: item.id,\n      url: item.url,\n      title: item.title,\n      thumbnail: item.thumbnail ?? null,\n      type: item.type,\n      status: item.status,\n      downloadPath: item.downloadPath ?? null,\n      savedFileName: item.savedFileName ?? null,\n      fileSize: item.fileSize ?? null,\n      duration: item.duration ?? null,\n      downloadedAt: item.downloadedAt,\n      completedAt: item.completedAt ?? null,\n      sortKey: item.completedAt ?? item.downloadedAt,\n      error: item.error ?? null,\n      ytDlpCommand: item.ytDlpCommand ?? null,\n      ytDlpLog: item.ytDlpLog ?? null,\n      description: item.description ?? null,\n      channel: item.channel ?? null,\n      uploader: item.uploader ?? null,\n      viewCount: item.viewCount ?? null,\n      tags: serializeTags(item.tags) ?? null,\n      origin: item.origin ?? null,\n      subscriptionId: item.subscriptionId ?? null,\n      selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,\n      playlistId: item.playlistId ?? null,\n      playlistTitle: item.playlistTitle ?? null,\n      playlistIndex: item.playlistIndex ?? null,\n      playlistSize: item.playlistSize ?? null\n    }\n  }\n\n  private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {\n    const { id: _id, ...rest } = payload\n    return rest\n  }\n\n  private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {\n    let selectedFormat: DownloadHistoryItem['selectedFormat']\n    if (row.selectedFormat) {\n      try {\n        selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']\n      } catch (error) {\n        logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })\n      }\n    }\n\n    const tags = parseTags(row.tags ?? null)\n\n    return {\n      id: row.id,\n      url: row.url,\n      title: row.title,\n      thumbnail: row.thumbnail ?? undefined,\n      type: row.type as DownloadHistoryItem['type'],\n      status: row.status as DownloadHistoryItem['status'],\n      downloadPath: row.downloadPath ?? undefined,\n      savedFileName: row.savedFileName ?? undefined,\n      fileSize: row.fileSize ?? undefined,\n      duration: row.duration ?? undefined,\n      downloadedAt: row.downloadedAt,\n      completedAt: row.completedAt ?? undefined,\n      error: row.error ?? undefined,\n      ytDlpCommand: row.ytDlpCommand ?? undefined,\n      ytDlpLog: row.ytDlpLog ?? undefined,\n      description: row.description ?? undefined,\n      channel: row.channel ?? undefined,\n      uploader: row.uploader ?? undefined,\n      viewCount: row.viewCount ?? undefined,\n      tags,\n      origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,\n      subscriptionId: row.subscriptionId ?? undefined,\n      selectedFormat,\n      playlistId: row.playlistId ?? undefined,\n      playlistTitle: row.playlistTitle ?? undefined,\n      playlistIndex: row.playlistIndex ?? undefined,\n      playlistSize: row.playlistSize ?? undefined\n    }\n  }\n\n  addHistoryItem(item: DownloadHistoryItem): void {\n    const normalized = this.normalizeItem(item)\n    const insertPayload = this.mapItemToInsert(normalized)\n    try {\n      const database = this.getDatabase()\n      database\n        .insert(downloadHistoryTable)\n        .values(insertPayload)\n        .onConflictDoUpdate({\n          target: downloadHistoryTable.id,\n          set: this.mapItemToUpdate(insertPayload)\n        })\n        .run()\n      this.history.set(normalized.id, normalized)\n    } catch (error) {\n      logger.error('history-db failed to upsert item', { id: normalized.id, error })\n    }\n  }\n\n  getHistory(): DownloadHistoryItem[] {\n    return Array.from(this.history.values()).sort((a, b) => {\n      const aTime = a.completedAt ?? a.downloadedAt\n      const bTime = b.completedAt ?? b.downloadedAt\n      return bTime - aTime\n    })\n  }\n\n  getHistoryById(id: string): DownloadHistoryItem | undefined {\n    return this.history.get(id)\n  }\n\n  removeHistoryItem(id: string): boolean {\n    try {\n      const database = this.getDatabase()\n      const result = database\n        .delete(downloadHistoryTable)\n        .where(eq(downloadHistoryTable.id, id))\n        .run()\n      const removedFromMap = this.history.delete(id)\n      return result.changes > 0 || removedFromMap\n    } catch (error) {\n      logger.error('history-db failed to delete item', { id, error })\n      return false\n    }\n  }\n\n  removeHistoryItems(ids: string[]): number {\n    const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)\n    if (uniqueIds.length === 0) {\n      return 0\n    }\n    let removedCount = 0\n    try {\n      const database = this.getDatabase()\n      const result = database\n        .delete(downloadHistoryTable)\n        .where(inArray(downloadHistoryTable.id, uniqueIds))\n        .run()\n      for (const id of uniqueIds) {\n        if (this.history.delete(id)) {\n          removedCount++\n        }\n      }\n      if ((result.changes ?? 0) > removedCount) {\n        removedCount = result.changes ?? removedCount\n      }\n      return removedCount\n    } catch (error) {\n      logger.error('history-db failed to delete items', { count: uniqueIds.length, error })\n      return removedCount\n    }\n  }\n\n  removeHistoryByPlaylistId(playlistId: string): number {\n    const normalized = playlistId.trim()\n    if (!normalized) {\n      return 0\n    }\n    let removedCount = 0\n    try {\n      const database = this.getDatabase()\n      const result = database\n        .delete(downloadHistoryTable)\n        .where(eq(downloadHistoryTable.playlistId, normalized))\n        .run()\n      for (const [id, item] of this.history.entries()) {\n        if (item.playlistId === normalized) {\n          this.history.delete(id)\n          removedCount++\n        }\n      }\n      if ((result.changes ?? 0) > removedCount) {\n        removedCount = result.changes ?? removedCount\n      }\n      return removedCount\n    } catch (error) {\n      logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })\n      return removedCount\n    }\n  }\n\n  clearHistory(): void {\n    try {\n      const database = this.getDatabase()\n      database.delete(downloadHistoryTable).run()\n      this.history.clear()\n    } catch (error) {\n      logger.error('history-db failed to clear items', error)\n    }\n  }\n\n  clearHistoryByStatus(status: DownloadHistoryItem['status']): number {\n    let removedCount = 0\n    try {\n      const database = this.getDatabase()\n      const result = database\n        .delete(downloadHistoryTable)\n        .where(eq(downloadHistoryTable.status, status))\n        .run()\n      for (const [id, item] of this.history.entries()) {\n        if (item.status === status) {\n          this.history.delete(id)\n          removedCount++\n        }\n      }\n      if ((result.changes ?? 0) > removedCount) {\n        removedCount = result.changes ?? removedCount\n      }\n      return removedCount\n    } catch (error) {\n      logger.error('history-db failed to clear items by status', { status, error })\n      return removedCount\n    }\n  }\n\n  getHistoryCount(): {\n    active: number\n    completed: number\n    error: number\n    cancelled: number\n    total: number\n  } {\n    const counts = {\n      active: 0,\n      completed: 0,\n      error: 0,\n      cancelled: 0,\n      total: this.history.size\n    }\n\n    for (const item of this.history.values()) {\n      if (item.status === 'completed') {\n        counts.completed++\n      } else if (item.status === 'error') {\n        counts.error++\n      } else if (item.status === 'cancelled') {\n        counts.cancelled++\n      } else {\n        counts.active++\n      }\n    }\n\n    return counts\n  }\n\n  hasHistoryForUrl(url: string): boolean {\n    for (const item of this.history.values()) {\n      if (item.url === url) {\n        return true\n      }\n    }\n    return false\n  }\n}\n\nexport const historyManager = new HistoryManager()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/path-resolver.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport type { PlaylistInfo, VideoInfo } from '../../shared/types'\nimport { sanitizeFilenameTemplate } from '../download-engine/args-builder'\nimport { scopedLoggers } from '../utils/logger'\n\nexport const ensureDirectoryExists = (dir?: string): void => {\n  if (!dir) {\n    return\n  }\n  try {\n    fs.mkdirSync(dir, { recursive: true })\n  } catch (error) {\n    scopedLoggers.download.error('Failed to ensure download directory:', error)\n  }\n}\n\nexport const sanitizeFolderName = (value: string, fallback: string): string => {\n  const trimmed = value.trim()\n  if (!trimmed) {\n    return fallback\n  }\n  const sanitized = trimmed\n    .replace(/[\\\\/:*?\"<>|]+/g, '-')\n    .replace(/\\s+/g, ' ')\n    .replace(/[. ]+$/g, '')\n  return sanitized || fallback\n}\n\nexport const sanitizeTemplateValue = (value: string): string =>\n  value\n    .replace(/[\\\\/:*?\"<>|]+/g, '-')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .replace(/[. ]+$/g, '')\n\nconst resolveTemplateToken = (token: string, info?: VideoInfo): string | undefined => {\n  if (!info) {\n    return undefined\n  }\n  switch (token) {\n    case 'uploader':\n      return info.uploader\n    case 'title':\n      return info.title\n    case 'id':\n      return info.id\n    case 'channel':\n      return info.uploader\n    case 'extractor':\n      return info.extractor_key\n    default:\n      return undefined\n  }\n}\n\nexport const isLikelyChannelUrl = (url: string): boolean => {\n  const normalized = url.toLowerCase()\n  if (normalized.includes('list=')) {\n    return false\n  }\n  return /youtube\\.com\\/(channel\\/|c\\/|user\\/|@)/.test(normalized)\n}\n\nexport const resolveAutoPlaylistDownloadPath = (\n  basePath: string,\n  info: PlaylistInfo,\n  url: string\n): string => {\n  const kindFolder = isLikelyChannelUrl(url) ? 'Channels' : 'Playlists'\n  const title = sanitizeFolderName(\n    info.title || (kindFolder === 'Channels' ? 'Channel' : 'Playlist'),\n    kindFolder === 'Channels' ? 'Channel' : 'Playlist'\n  )\n  return path.join(basePath, kindFolder, title)\n}\n\nexport const resolveAutoVideoDownloadPath = (basePath: string, info?: VideoInfo): string => {\n  const root = path.join(basePath, 'Videos')\n  if (!info) {\n    return root\n  }\n  const label = info.uploader?.trim() || info.title?.trim()\n  if (!label) {\n    return root\n  }\n  return path.join(root, sanitizeFolderName(label, 'Video'))\n}\n\nexport const resolveHistoryDownloadPath = (\n  basePath: string,\n  filenameTemplate?: string,\n  info?: VideoInfo\n): string => {\n  if (!filenameTemplate?.trim()) {\n    return basePath\n  }\n  const safeTemplate = sanitizeFilenameTemplate(filenameTemplate)\n  const resolvedTemplate = safeTemplate.replace(/%\\(([^)]+)\\)s/g, (match, token) => {\n    const value = resolveTemplateToken(token, info)\n    if (!value) {\n      return match\n    }\n    return sanitizeTemplateValue(value)\n  })\n  const templateDir = path.posix.dirname(resolvedTemplate)\n  if (templateDir === '.' || templateDir === '/') {\n    return basePath\n  }\n  if (/%\\([^)]+\\)s/.test(templateDir)) {\n    return basePath\n  }\n  return path.join(basePath, templateDir)\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/progress-utils.ts",
    "content": "import type { DownloadOptions, VideoFormat } from '../../shared/types'\n\nexport const clampPercent = (value?: number): number => {\n  const normalized = typeof value === 'number' ? value : 0\n  if (Number.isNaN(normalized)) {\n    return 0\n  }\n  return Math.min(100, Math.max(0, normalized))\n}\n\nexport const isMuxedFormat = (format?: VideoFormat): boolean => {\n  if (!format) {\n    return false\n  }\n  const hasVideo = !!format.vcodec && format.vcodec !== 'none'\n  const hasAudio = !!format.acodec && format.acodec !== 'none'\n  return hasVideo && hasAudio\n}\n\nexport const estimateProgressParts = (options: DownloadOptions): number => {\n  if (options.type === 'audio') {\n    return 1\n  }\n\n  const audioFormatCount = options.audioFormatIds?.filter((id) => id.trim() !== '').length ?? 0\n  if (audioFormatCount > 0) {\n    return 1 + audioFormatCount\n  }\n\n  const selector = options.format?.trim()\n  if (!selector) {\n    return 2\n  }\n\n  const primary = selector.split('/')[0]?.trim()\n  if (!primary) {\n    return 2\n  }\n\n  const parts = primary\n    .split('+')\n    .map((part) => part.trim())\n    .filter((part) => part !== '')\n\n  if (parts.length <= 1) {\n    return 1\n  }\n\n  if (parts.some((part) => part === 'none')) {\n    return 1\n  }\n\n  return parts.length\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/subscription-manager.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { EventEmitter } from 'node:events'\nimport fs from 'node:fs'\nimport { and, desc, eq, inArray } from 'drizzle-orm'\nimport type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'\nimport log from 'electron-log/main'\nimport type {\n  SubscriptionCreatePayload,\n  SubscriptionFeedItem,\n  SubscriptionRule,\n  SubscriptionStatus,\n  SubscriptionUpdatePayload\n} from '../../shared/types'\nimport { sanitizeFilenameTemplate } from '../download-engine/args-builder'\nimport { getDatabaseConnection } from './database'\nimport {\n  type SubscriptionInsert,\n  type SubscriptionItemRow,\n  type SubscriptionRow,\n  subscriptionItemsTable,\n  subscriptionsTable\n} from './database/schema'\n\nconst sanitizeList = (values?: string[]): string[] => {\n  if (!values || values.length === 0) {\n    return []\n  }\n  return values\n    .map((value) => value.trim())\n    .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)\n}\n\nconst ensureDirectoryExists = (dir?: string): void => {\n  if (!dir) {\n    return\n  }\n  try {\n    fs.mkdirSync(dir, { recursive: true })\n  } catch (error) {\n    log.error('Failed to ensure subscription directory:', error)\n  }\n}\n\nconst booleanToNumber = (value: boolean): number => (value ? 1 : 0)\nconst numberToBoolean = (value: number | null | undefined): boolean => value === 1\n\nconst parseStringArray = (value: string | null | undefined): string[] => {\n  if (!value) {\n    return []\n  }\n  try {\n    const parsed = JSON.parse(value) as unknown\n    return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []\n  } catch {\n    return []\n  }\n}\n\nconst stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))\n\nexport class SubscriptionManager extends EventEmitter {\n  private db: BetterSQLite3Database | null = null\n\n  constructor() {\n    super()\n    try {\n      this.getDatabase()\n    } catch (error) {\n      log.error('subscriptions: failed to initialize database', error)\n    }\n  }\n\n  getAll(): SubscriptionRule[] {\n    const database = this.getDatabase()\n    const rows = database\n      .select()\n      .from(subscriptionsTable)\n      .orderBy(desc(subscriptionsTable.updatedAt))\n      .all()\n    return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))\n  }\n\n  getById(id: string): SubscriptionRule | undefined {\n    const database = this.getDatabase()\n    const row = database\n      .select()\n      .from(subscriptionsTable)\n      .where(eq(subscriptionsTable.id, id))\n      .get()\n    if (!row) {\n      return undefined\n    }\n    return this.attachFeedItems([this.mapRowToRecord(row)])[0]\n  }\n\n  findDuplicateFeed(\n    feedUrl: string,\n    ignoreId?: string\n  ): { id: string; feedUrl: string } | undefined {\n    const database = this.getDatabase()\n    const rows = database\n      .select({ id: subscriptionsTable.id, feedUrl: subscriptionsTable.feedUrl })\n      .from(subscriptionsTable)\n      .all()\n    const targetKey = this.buildFeedKey(feedUrl)\n    if (!targetKey) {\n      return undefined\n    }\n    return rows.find((row) => row.id !== ignoreId && this.buildFeedKey(row.feedUrl) === targetKey)\n  }\n\n  add(payload: SubscriptionCreatePayload): SubscriptionRule {\n    const timestamp = Date.now()\n    const keywords = sanitizeList(payload.keywords)\n    const tags = sanitizeList(payload.tags)\n    const record: SubscriptionRule = {\n      id: randomUUID(),\n      title: payload.sourceUrl,\n      sourceUrl: payload.sourceUrl,\n      feedUrl: payload.feedUrl,\n      platform: payload.platform,\n      keywords,\n      tags,\n      onlyDownloadLatest: payload.onlyDownloadLatest ?? true,\n      enabled: payload.enabled ?? true,\n      coverUrl: undefined,\n      latestVideoTitle: undefined,\n      latestVideoPublishedAt: undefined,\n      lastCheckedAt: undefined,\n      lastSuccessAt: undefined,\n      status: 'idle',\n      lastError: undefined,\n      createdAt: timestamp,\n      updatedAt: timestamp,\n      downloadDirectory: payload.downloadDirectory,\n      namingTemplate: payload.namingTemplate\n        ? sanitizeFilenameTemplate(payload.namingTemplate)\n        : undefined,\n      items: []\n    }\n\n    ensureDirectoryExists(payload.downloadDirectory)\n    this.insertRecord(record)\n    this.emitUpdates()\n    return record\n  }\n\n  update(\n    id: string,\n    updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>\n  ): SubscriptionRule | undefined {\n    const existing = this.getById(id)\n    if (!existing) {\n      return undefined\n    }\n\n    const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined\n    const tags = updates.tags ? sanitizeList(updates.tags) : undefined\n    const next: SubscriptionRule = {\n      ...existing,\n      ...updates,\n      keywords: keywords ?? existing.keywords,\n      tags: tags ?? existing.tags,\n      updatedAt: Date.now()\n    }\n\n    // If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl\n    if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {\n      next.title = updates.sourceUrl\n    }\n\n    if (updates.namingTemplate) {\n      next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)\n    }\n\n    ensureDirectoryExists(next.downloadDirectory)\n    this.updateRecord(next)\n    this.emitUpdates()\n    return next\n  }\n\n  remove(id: string): boolean {\n    const database = this.getDatabase()\n    const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()\n    if ((result.changes ?? 0) > 0) {\n      database\n        .delete(subscriptionItemsTable)\n        .where(eq(subscriptionItemsTable.subscriptionId, id))\n        .run()\n      this.emitUpdates()\n      return true\n    }\n    return false\n  }\n\n  replaceFeedItems(subscriptionId: string, items: SubscriptionFeedItem[], silent = false): void {\n    const database = this.getDatabase()\n    const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)\n    const now = Date.now()\n    database.transaction((tx) => {\n      tx.delete(subscriptionItemsTable)\n        .where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))\n        .run()\n      for (const item of orderedItems) {\n        tx.insert(subscriptionItemsTable)\n          .values({\n            subscriptionId,\n            itemId: item.id,\n            title: item.title,\n            url: item.url,\n            publishedAt: item.publishedAt,\n            thumbnail: item.thumbnail ?? null,\n            added: booleanToNumber(item.addedToQueue),\n            downloadId: item.downloadId ?? null,\n            createdAt: item.publishedAt,\n            updatedAt: now\n          })\n          .run()\n      }\n    })\n    if (!silent) {\n      this.emitUpdates()\n    }\n  }\n\n  updateFeedItemQueueState(\n    subscriptionId: string,\n    itemId: string,\n    updates: { added?: boolean; downloadId?: string | null }\n  ): void {\n    if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {\n      return\n    }\n\n    const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {\n      updatedAt: Date.now()\n    }\n\n    if (updates.added !== undefined) {\n      setPayload.added = booleanToNumber(updates.added)\n    }\n    if (Object.hasOwn(updates, 'downloadId')) {\n      setPayload.downloadId = updates.downloadId ?? null\n    }\n\n    const database = this.getDatabase()\n    const result = database\n      .update(subscriptionItemsTable)\n      .set(setPayload)\n      .where(\n        and(\n          eq(subscriptionItemsTable.subscriptionId, subscriptionId),\n          eq(subscriptionItemsTable.itemId, itemId)\n        )\n      )\n      .run()\n\n    if ((result.changes ?? 0) > 0) {\n      this.emitUpdates()\n    }\n  }\n\n  private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {\n    if (records.length === 0) {\n      return records\n    }\n    const ids = records.map((record) => record.id)\n    const database = this.getDatabase()\n    const rows = database\n      .select()\n      .from(subscriptionItemsTable)\n      .where(inArray(subscriptionItemsTable.subscriptionId, ids))\n      .orderBy(desc(subscriptionItemsTable.publishedAt))\n      .all()\n\n    const grouped = new Map<string, SubscriptionFeedItem[]>()\n    for (const row of rows) {\n      const item = this.mapItemRowToFeedItem(row)\n      const list = grouped.get(row.subscriptionId)\n      if (list) {\n        list.push(item)\n      } else {\n        grouped.set(row.subscriptionId, [item])\n      }\n    }\n\n    return records.map((record) => ({\n      ...record,\n      items: grouped.get(record.id) ?? []\n    }))\n  }\n\n  private getDatabase(): BetterSQLite3Database {\n    if (this.db) {\n      return this.db\n    }\n\n    const connection = getDatabaseConnection()\n    this.db = connection.db\n    return this.db\n  }\n\n  private buildFeedKey(feedUrl: string): string {\n    const trimmed = feedUrl.trim()\n    if (!trimmed) {\n      return ''\n    }\n    const normalized = /^https?:\\/\\//i.test(trimmed) ? trimmed : `https://${trimmed}`\n    try {\n      const url = new URL(normalized)\n      let pathname = url.pathname || '/'\n      pathname = pathname.replace(/\\/+$/, '')\n      if (!pathname) {\n        pathname = '/'\n      }\n      return `${url.host.toLowerCase()}${pathname}${url.search}`\n    } catch {\n      return trimmed.toLowerCase()\n    }\n  }\n\n  private insertRecord(record: SubscriptionRule): void {\n    const database = this.getDatabase()\n    const payload = this.mapRecordToInsert(record)\n    database\n      .insert(subscriptionsTable)\n      .values(payload)\n      .onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })\n      .run()\n  }\n\n  private updateRecord(record: SubscriptionRule): void {\n    const database = this.getDatabase()\n    const payload = this.mapRecordToInsert(record)\n    database\n      .insert(subscriptionsTable)\n      .values(payload)\n      .onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })\n      .run()\n  }\n\n  private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {\n    return {\n      id: record.id,\n      title: record.title,\n      sourceUrl: record.sourceUrl,\n      feedUrl: record.feedUrl,\n      platform: record.platform,\n      keywords: stringifyArray(record.keywords),\n      tags: stringifyArray(record.tags),\n      onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),\n      enabled: booleanToNumber(record.enabled),\n      coverUrl: record.coverUrl,\n      latestVideoTitle: record.latestVideoTitle,\n      latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,\n      lastCheckedAt: record.lastCheckedAt ?? null,\n      lastSuccessAt: record.lastSuccessAt ?? null,\n      status: record.status,\n      lastError: record.lastError,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n      downloadDirectory: record.downloadDirectory,\n      namingTemplate: record.namingTemplate\n        ? sanitizeFilenameTemplate(record.namingTemplate)\n        : undefined\n    }\n  }\n\n  private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {\n    return {\n      id: row.id,\n      title: row.title,\n      sourceUrl: row.sourceUrl,\n      feedUrl: row.feedUrl,\n      platform: row.platform as SubscriptionRule['platform'],\n      keywords: parseStringArray(row.keywords),\n      tags: parseStringArray(row.tags),\n      onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),\n      enabled: numberToBoolean(row.enabled),\n      coverUrl: row.coverUrl ?? undefined,\n      latestVideoTitle: row.latestVideoTitle ?? undefined,\n      latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,\n      lastCheckedAt: row.lastCheckedAt ?? undefined,\n      lastSuccessAt: row.lastSuccessAt ?? undefined,\n      status: row.status as SubscriptionStatus,\n      lastError: row.lastError ?? undefined,\n      createdAt: row.createdAt,\n      updatedAt: row.updatedAt,\n      downloadDirectory: row.downloadDirectory ?? undefined,\n      namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,\n      items: []\n    }\n  }\n\n  private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {\n    return {\n      id: row.itemId,\n      url: row.url,\n      title: row.title,\n      publishedAt: row.publishedAt,\n      thumbnail: row.thumbnail ?? undefined,\n      addedToQueue: numberToBoolean(row.added),\n      downloadId: row.downloadId ?? undefined\n    }\n  }\n\n  private emitUpdates(): void {\n    this.emit('subscriptions:updated', this.getAll())\n  }\n}\n\nexport const subscriptionManager = new SubscriptionManager()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/subscription-scheduler.ts",
    "content": "import { EventEmitter } from 'node:events'\nimport fs from 'node:fs'\nimport log from 'electron-log/main'\nimport Parser from 'rss-parser'\nimport type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'\nimport { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'\nimport {\n  buildAudioFormatPreference,\n  buildVideoFormatPreference\n} from '../../shared/utils/format-preferences'\nimport { settingsManager } from '../settings'\nimport { downloadEngine } from './download-engine'\nimport { historyManager } from './history-manager'\nimport { subscriptionManager } from './subscription-manager'\n\nconst logger = log.scope('subscriptions')\n\ninterface ParserItem {\n  title?: string\n  link?: string\n  guid?: string\n  id?: string\n  isoDate?: string\n  pubDate?: string\n  youtubeId?: string\n  content?: string\n  contentSnippet?: string\n  contentEncoded?: string\n  summary?: string\n  description?: string\n  mediaThumbnail?: Array<{ url?: string }> | { url?: string }\n  mediaContent?: Array<{ url?: string }> | { url?: string }\n  enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }\n  [key: string]: unknown\n}\n\ninterface TrackedDownload {\n  subscriptionId: string\n  itemId: string\n  url: string\n  retries: number\n  downloadId: string\n}\n\ninterface FeedItem {\n  id: string\n  url: string\n  title: string\n  publishedAt: number\n  thumbnail?: string\n}\n\nconst parser = new Parser<Record<string, never>, ParserItem>({\n  customFields: {\n    item: [\n      ['yt:videoId', 'youtubeId'],\n      ['media:thumbnail', 'mediaThumbnail'],\n      ['media:content', 'mediaContent'],\n      ['enclosure', 'enclosure'],\n      ['content:encoded', 'contentEncoded'],\n      ['description', 'description']\n    ]\n  }\n})\n\nconst sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {\n  const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')\n  return `sub_${base}`\n}\n\nconst ensureDirectoryExists = (dir?: string): void => {\n  if (!dir) {\n    return\n  }\n  try {\n    fs.mkdirSync(dir, { recursive: true })\n  } catch (error) {\n    logger.warn('Failed to ensure subscription download directory:', error)\n  }\n}\n\nexport class SubscriptionScheduler extends EventEmitter {\n  private timer?: NodeJS.Timeout\n  private checking = false\n  private pendingRun = false\n  private readonly downloads: Map<string, TrackedDownload> = new Map()\n\n  constructor() {\n    super()\n    downloadEngine.on('download-completed', (id: string) => {\n      const tracked = this.downloads.get(id)\n      if (!tracked) {\n        return\n      }\n      this.downloads.delete(id)\n      subscriptionManager.update(tracked.subscriptionId, {\n        status: 'up-to-date',\n        lastSuccessAt: Date.now()\n      })\n      subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {\n        downloadId: id\n      })\n    })\n\n    downloadEngine.on('download-error', (id: string, error: Error) => {\n      const tracked = this.downloads.get(id)\n      if (!tracked) {\n        return\n      }\n      const currentRetries = tracked.retries ?? 0\n      if (currentRetries < 1) {\n        logger.warn('Retrying failed subscription download', { id, error })\n        this.queueDownload(\n          tracked.subscriptionId,\n          tracked.itemId,\n          tracked.url,\n          currentRetries + 1\n        ).catch((queueError) => {\n          logger.error('Retry queue failed:', queueError)\n        })\n        return\n      }\n\n      this.downloads.delete(id)\n      subscriptionManager.update(tracked.subscriptionId, {\n        status: 'failed',\n        lastCheckedAt: Date.now()\n      })\n      subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {\n        downloadId: null\n      })\n    })\n  }\n\n  start(): void {\n    this.scheduleNextRun(0)\n  }\n\n  refreshInterval(): void {\n    if (this.timer) {\n      clearTimeout(this.timer)\n    }\n    this.scheduleNextRun()\n  }\n\n  async runNow(subscriptionId?: string): Promise<void> {\n    if (subscriptionId) {\n      const target = subscriptionManager.getById(subscriptionId)\n      if (target?.enabled) {\n        await this.checkSubscription(target)\n      }\n      return\n    }\n    await this.checkAll()\n  }\n\n  async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {\n    const subscription = subscriptionManager.getById(subscriptionId)\n    if (!subscription) {\n      return false\n    }\n    const item = subscription.items.find((entry) => entry.id === itemId)\n    if (!item || item.addedToQueue) {\n      return false\n    }\n    await this.queueDownload(subscriptionId, itemId, item.url)\n    return true\n  }\n\n  private scheduleNextRun(initialDelay?: number): void {\n    if (this.timer) {\n      clearTimeout(this.timer)\n    }\n    const intervalHours = 3 // Default check interval: 3 hours\n    const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000\n    this.timer = setTimeout(() => {\n      void this.checkAll().finally(() => this.scheduleNextRun())\n    }, delayMs)\n  }\n\n  private async checkAll(): Promise<void> {\n    if (this.checking) {\n      this.pendingRun = true\n      return\n    }\n    this.checking = true\n    try {\n      const subscriptions = subscriptionManager\n        .getAll()\n        .filter((subscription) => subscription.enabled)\n      for (const subscription of subscriptions) {\n        await this.checkSubscription(subscription)\n      }\n    } catch (error) {\n      logger.error('Failed to run subscription sync', error)\n    } finally {\n      this.checking = false\n      if (this.pendingRun) {\n        this.pendingRun = false\n        void this.checkAll()\n      }\n    }\n  }\n\n  private async checkSubscription(subscription: SubscriptionRule): Promise<void> {\n    const startedAt = Date.now()\n    subscriptionManager.update(subscription.id, {\n      status: 'checking',\n      lastCheckedAt: startedAt,\n      lastError: undefined\n    })\n\n    try {\n      const feed = await parser.parseURL(subscription.feedUrl)\n      const feedItems = Array.isArray(feed.items) ? feed.items : []\n      const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])\n      const recentItems = this.filterRecentItems(subscription, normalizedItems)\n      const unseenItems = this.filterNewItems(subscription, recentItems)\n      const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())\n      const keywordFiltered =\n        keywords.length > 0\n          ? unseenItems.filter((item) => {\n              const lowered = item.title.toLowerCase()\n              return keywords.some((keyword) => lowered.includes(keyword))\n            })\n          : unseenItems\n\n      const deduped = keywordFiltered\n        .filter((item) => !historyManager.hasHistoryForUrl(item.url))\n        .sort((a, b) => b.publishedAt - a.publishedAt)\n\n      const itemsToDownload =\n        subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped\n\n      subscriptionManager.replaceFeedItems(\n        subscription.id,\n        this.buildFeedItems(normalizedItems, subscription)\n      )\n\n      if (itemsToDownload.length > 0) {\n        for (const item of itemsToDownload) {\n          await this.queueDownload(subscription.id, item.id, item.url)\n        }\n      }\n\n      const latestItem = normalizedItems[0]\n      const coverUrl = this.resolveSubscriptionCover(\n        feed,\n        normalizedItems,\n        feedItems as ParserItem[]\n      )\n      subscriptionManager.update(subscription.id, {\n        status: 'up-to-date',\n        lastSuccessAt: Date.now(),\n        lastError: undefined,\n        latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,\n        latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,\n        coverUrl: coverUrl ?? subscription.coverUrl,\n        title:\n          typeof feed.title === 'string' && feed.title.trim().length > 0\n            ? feed.title.trim()\n            : subscription.title,\n        sourceUrl:\n          typeof feed.link === 'string' && feed.link.trim().length > 0\n            ? feed.link.trim()\n            : subscription.sourceUrl\n      })\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Unknown RSS error'\n      subscriptionManager.update(subscription.id, {\n        status: 'failed',\n        lastError: message,\n        lastCheckedAt: Date.now()\n      })\n      logger.error('Subscription check failed:', { id: subscription.id, error })\n    }\n  }\n\n  private normalizeFeedItems(items: ParserItem[]): FeedItem[] {\n    const normalized: FeedItem[] = []\n    for (const item of items) {\n      const id = this.resolveItemId(item)\n      if (!(id && item.link && item.title)) {\n        continue\n      }\n      normalized.push({\n        id,\n        url: item.link,\n        title: item.title,\n        publishedAt: this.resolvePublishedAt(item),\n        thumbnail: this.resolveThumbnail(item)\n      })\n    }\n\n    return normalized.sort((a, b) => b.publishedAt - a.publishedAt)\n  }\n\n  private buildFeedItems(\n    items: FeedItem[],\n    subscription: SubscriptionRule\n  ): SubscriptionFeedItem[] {\n    const existingItems = new Map(subscription.items.map((item) => [item.id, item]))\n    return items.map((item) => {\n      const tracked = this.getTrackedDownloadByUrl(item.url)\n      const existing = existingItems.get(item.id)\n      return {\n        id: item.id,\n        url: item.url,\n        title: item.title,\n        publishedAt: item.publishedAt,\n        thumbnail: item.thumbnail,\n        addedToQueue:\n          Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),\n        downloadId: tracked?.downloadId ?? existing?.downloadId\n      }\n    })\n  }\n\n  private resolveItemId(item: ParserItem): string | null {\n    const idCandidate =\n      item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)\n    if (!idCandidate) {\n      return null\n    }\n    return idCandidate.trim()\n  }\n\n  private resolvePublishedAt(item: ParserItem): number {\n    const candidates = [item.isoDate, item.pubDate]\n    for (const candidate of candidates) {\n      if (!candidate) {\n        continue\n      }\n      const timestamp = Date.parse(candidate)\n      if (!Number.isNaN(timestamp)) {\n        return timestamp\n      }\n    }\n    return Date.now()\n  }\n\n  private resolveThumbnail(item: ParserItem): string | undefined {\n    // Try media:thumbnail first\n    const thumbnail = item.mediaThumbnail\n    if (Array.isArray(thumbnail)) {\n      const found = thumbnail.find((entry) => entry?.url)\n      if (found?.url) {\n        return found.url\n      }\n    }\n    if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {\n      return thumbnail.url as string | undefined\n    }\n\n    // Try enclosure (for RSS feeds with image/jpeg type)\n    const enclosure = item.enclosure\n    if (Array.isArray(enclosure)) {\n      const imageEnclosure = enclosure.find(\n        (entry) => entry?.url && entry?.type?.startsWith('image/')\n      )\n      if (imageEnclosure?.url) {\n        return imageEnclosure.url\n      }\n    }\n    if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {\n      const enc = enclosure as { url?: string; type?: string }\n      if (enc.url && enc.type?.startsWith('image/')) {\n        return enc.url\n      }\n    }\n\n    // Try media:content as fallback\n    const mediaContent = item.mediaContent\n    if (Array.isArray(mediaContent)) {\n      const found = mediaContent.find((entry) => entry?.url)\n      if (found?.url) {\n        return found.url\n      }\n    }\n    if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {\n      return mediaContent.url as string | undefined\n    }\n\n    // Try to parse an image from HTML fields\n    const htmlCandidates = [\n      item.content,\n      item.contentEncoded,\n      item.description,\n      item.summary,\n      item.contentSnippet\n    ]\n    for (const html of htmlCandidates) {\n      const imageUrl = this.extractImageFromHtml(html)\n      if (imageUrl) {\n        return imageUrl\n      }\n    }\n\n    return undefined\n  }\n\n  private resolveSubscriptionCover(\n    feed: Parser.Output<ParserItem>,\n    items: FeedItem[],\n    rawItems: ParserItem[]\n  ): string | undefined {\n    const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined\n    if (feedImageUrl) {\n      return feedImageUrl\n    }\n\n    const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined\n    if (itunesImageUrl) {\n      return itunesImageUrl\n    }\n\n    const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail\n    if (itemThumbnail) {\n      return itemThumbnail\n    }\n\n    for (const item of rawItems) {\n      const thumbnail = this.resolveThumbnail(item)\n      if (thumbnail) {\n        return thumbnail\n      }\n    }\n\n    return undefined\n  }\n\n  private extractImageFromHtml(html?: string): string | undefined {\n    if (!html) {\n      return undefined\n    }\n\n    const srcMatch = html.match(\n      /<img\\b[^>]*\\b(?:src|data-src|data-original)\\b\\s*=\\s*(['\"]?)([^'\">\\s]+)\\1/i\n    )\n    if (srcMatch?.[2]) {\n      return srcMatch[2]\n    }\n\n    const srcsetMatch = html.match(/<img[^>]+srcset\\s*=\\s*(['\"])([^'\"]+)\\1/i)\n    if (srcsetMatch?.[2]) {\n      const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\\s+/)[0]\n      if (firstCandidate) {\n        return firstCandidate\n      }\n    }\n\n    return undefined\n  }\n\n  private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {\n    const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)\n    if (lastKnownPublishedAt === 0) {\n      return subscription.onlyDownloadLatest ? items.slice(0, 1) : items\n    }\n    return items.filter((item) => item.publishedAt > lastKnownPublishedAt)\n  }\n\n  private getLastKnownPublishedAt(subscription: SubscriptionRule): number {\n    const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)\n    return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)\n  }\n\n  private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {\n    const seenIds = new Set(subscription.items.map((item) => item.id))\n    return items.filter((item) => !seenIds.has(item.id))\n  }\n\n  private async queueDownload(\n    subscriptionId: string,\n    itemId: string,\n    url: string,\n    retryCount = 0\n  ): Promise<void> {\n    const downloadId = sanitizeDownloadId(subscriptionId, itemId)\n    const isRetry = retryCount > 0\n    if (this.downloads.has(downloadId) && !isRetry) {\n      return\n    }\n\n    const subscription = subscriptionManager.getById(subscriptionId)\n    if (!subscription) {\n      return\n    }\n\n    const settings = settingsManager.getAll()\n    const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath\n    const namingTemplate =\n      subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE\n    const downloadType = settings.oneClickDownloadType ?? 'video'\n    const formatPreference =\n      downloadType === 'video'\n        ? buildVideoFormatPreference(settings)\n        : buildAudioFormatPreference(settings)\n    ensureDirectoryExists(downloadDirectory)\n\n    const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))\n\n    try {\n      const started = downloadEngine.startDownload(downloadId, {\n        url,\n        type: downloadType,\n        format: formatPreference,\n        customDownloadPath: downloadDirectory,\n        customFilenameTemplate: namingTemplate,\n        tags,\n        origin: 'subscription',\n        subscriptionId\n      })\n      if (!started) {\n        logger.info('Subscription download already queued', { subscriptionId, itemId, url })\n        return\n      }\n\n      this.downloads.set(downloadId, {\n        subscriptionId,\n        itemId,\n        url,\n        retries: retryCount,\n        downloadId\n      })\n      subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {\n        added: true,\n        downloadId\n      })\n    } catch (error) {\n      logger.error('Failed to start subscription download', { subscriptionId, itemId, error })\n      subscriptionManager.update(subscriptionId, {\n        status: 'failed'\n      })\n      subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {\n        added: false,\n        downloadId: null\n      })\n    }\n  }\n\n  private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {\n    for (const tracked of this.downloads.values()) {\n      if (tracked.url === url) {\n        return tracked\n      }\n    }\n    return undefined\n  }\n}\n\nexport const subscriptionScheduler = new SubscriptionScheduler()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/thumbnail-cache.ts",
    "content": "import crypto from 'node:crypto'\nimport fs from 'node:fs'\nimport fsPromises from 'node:fs/promises'\nimport path from 'node:path'\nimport { APP_PROTOCOL_SCHEME } from '@shared/constants'\nimport { app } from 'electron'\nimport { scopedLoggers } from '../utils/logger'\n\nconst SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])\n\nconst contentTypeToExtension = (contentType?: string): string => {\n  if (!contentType) {\n    return '.jpg'\n  }\n  if (contentType.includes('jpeg')) {\n    return '.jpg'\n  }\n  if (contentType.includes('png')) {\n    return '.png'\n  }\n  if (contentType.includes('webp')) {\n    return '.webp'\n  }\n  if (contentType.includes('gif')) {\n    return '.gif'\n  }\n  return '.jpg'\n}\n\nexport class ThumbnailCache {\n  private cacheDir?: string\n  private readonly pending: Map<string, Promise<string | null>> = new Map()\n\n  private ensureCacheDir(): string {\n    if (this.cacheDir) {\n      return this.cacheDir\n    }\n    const dir = path.join(app.getPath('userData'), 'thumbnails')\n    if (!fs.existsSync(dir)) {\n      fs.mkdirSync(dir, { recursive: true })\n    }\n    this.cacheDir = dir\n    return dir\n  }\n\n  async getThumbnailUrl(originalUrl: string): Promise<string | null> {\n    if (!originalUrl) {\n      return null\n    }\n\n    if (\n      originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||\n      originalUrl.startsWith('file://') ||\n      originalUrl.startsWith('data:')\n    ) {\n      return originalUrl\n    }\n\n    if (this.pending.has(originalUrl)) {\n      return this.pending.get(originalUrl) ?? null\n    }\n\n    const task = this.fetchAndCache(originalUrl).finally(() => {\n      this.pending.delete(originalUrl)\n    })\n    this.pending.set(originalUrl, task)\n    return task\n  }\n\n  private async fetchAndCache(originalUrl: string): Promise<string | null> {\n    try {\n      const cacheDir = this.ensureCacheDir()\n      const { basePath, defaultExtension } = this.getBasePath(cacheDir, originalUrl)\n\n      const existingPath = await this.findExistingPath(basePath, defaultExtension)\n      if (existingPath) {\n        return this.toAppProtocolUrl(existingPath)\n      }\n\n      const response = await fetch(originalUrl)\n      if (!response.ok) {\n        throw new Error(`Failed to fetch thumbnail (${response.status})`)\n      }\n\n      const arrayBuffer = await response.arrayBuffer()\n      const buffer = Buffer.from(arrayBuffer)\n      const extension =\n        contentTypeToExtension(response.headers.get('content-type') ?? undefined) ||\n        defaultExtension\n      const finalPath = `${basePath}${extension}`\n\n      await fsPromises.writeFile(finalPath, buffer)\n      return this.toAppProtocolUrl(finalPath)\n    } catch (error) {\n      scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)\n      return null\n    }\n  }\n\n  private async findExistingPath(\n    basePath: string,\n    defaultExtension: string\n  ): Promise<string | null> {\n    for (const ext of SUPPORTED_EXTENSIONS) {\n      const candidate = `${basePath}${ext}`\n      if (await this.exists(candidate)) {\n        return candidate\n      }\n    }\n\n    const fallback = `${basePath}${defaultExtension}`\n    if (await this.exists(fallback)) {\n      return fallback\n    }\n\n    return null\n  }\n\n  private async exists(filePath: string): Promise<boolean> {\n    try {\n      await fsPromises.access(filePath)\n      return true\n    } catch {\n      return false\n    }\n  }\n\n  private getBasePath(\n    cacheDir: string,\n    url: string\n  ): {\n    basePath: string\n    defaultExtension: string\n  } {\n    const hash = crypto.createHash('sha1').update(url).digest('hex')\n    let extension = '.jpg'\n    try {\n      const parsedUrl = new URL(url)\n      const urlExt = path.extname(parsedUrl.pathname).toLowerCase()\n      if (SUPPORTED_EXTENSIONS.has(urlExt)) {\n        extension = urlExt\n      }\n    } catch {\n      // Ignore parsing errors and keep default extension\n    }\n\n    const basePath = path.join(cacheDir, `${hash}`)\n    return { basePath, defaultExtension: extension }\n  }\n\n  private toAppProtocolUrl(filePath: string): string {\n    const userDataPath = app.getPath('userData')\n    const relativePath = path.relative(userDataPath, filePath).replace(/\\\\/g, '/')\n\n    return `${APP_PROTOCOL_SCHEME}${relativePath}`\n  }\n}\n\nexport const thumbnailCache = new ThumbnailCache()\n"
  },
  {
    "path": "apps/desktop/src/main/lib/watermark-utils.ts",
    "content": "import { spawn } from 'node:child_process'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { scopedLoggers } from '../utils/logger'\n\nconst WATERMARK_TITLE_MAX = 28\nconst WATERMARK_AUTHOR_MAX = 60\n\nconst sanitizeWatermarkLine = (value: string): string => {\n  return value\n    .replace(/[\\p{C}]/gu, '') // Control characters\n    .replace(/\\p{Extended_Pictographic}/gu, '') // Emoji/pictographs\n    .replace(/[\\u200B-\\u200F\\u2028-\\u202F\\u2060-\\u206F\\uFEFF\\uFFFD]/g, '') // Zero-width & invisible formatting\n    .replace(/[\\uFE00-\\uFE0F]/g, '') // Variation selectors\n}\n\nexport const normalizeWatermarkLine = (\n  value: string | undefined,\n  fallback: string,\n  maxLength: number\n) => {\n  const cleaned = sanitizeWatermarkLine(value ?? '')\n  const trimmed = cleaned.replace(/\\s+/g, ' ').trim()\n  const resolved = trimmed || fallback\n  if (resolved.length <= maxLength) {\n    return resolved\n  }\n  return `${resolved.slice(0, Math.max(0, maxLength - 3))}...`\n}\n\nexport const buildShareWatermarkText = (title?: string, author?: string): string => {\n  const titleLine = normalizeWatermarkLine(title, 'Untitled video', WATERMARK_TITLE_MAX)\n  const authorLine = normalizeWatermarkLine(`by ${author}`, 'Unknown author', WATERMARK_AUTHOR_MAX)\n  return [titleLine, authorLine, 'Downloaded with VidBee'].join(' ')\n}\n\nconst containsCjk = (text: string): boolean =>\n  /[\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af]/.test(text)\n\nconst containsCyrillic = (text: string): boolean => /[\\u0400-\\u04ff]/.test(text)\n\nconst buildWatermarkFontCandidates = (text: string): string[] => {\n  const platform = process.platform\n  const base: string[] = []\n  const cjk: string[] = []\n  const cyrillic: string[] = []\n\n  if (platform === 'darwin') {\n    base.push(\n      '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',\n      '/Library/Fonts/Arial Unicode.ttf',\n      '/System/Library/Fonts/Supplemental/Arial.ttf',\n      '/System/Library/Fonts/Helvetica.ttc',\n      '/System/Library/Fonts/Supplemental/Helvetica.ttf'\n    )\n    cjk.push(\n      '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',\n      '/System/Library/Fonts/STHeiti Medium.ttc',\n      '/System/Library/Fonts/STHeiti Light.ttc',\n      '/System/Library/Fonts/Hiragino Sans GB.ttc',\n      '/System/Library/Fonts/Supplemental/Hiragino Sans GB.ttc',\n      '/Library/Fonts/Arial Unicode.ttf',\n      '/System/Library/Fonts/Supplemental/STHeiti Medium.ttc',\n      '/System/Library/Fonts/PingFang.ttc',\n      '/System/Library/Fonts/Supplemental/PingFang.ttc',\n      '/System/Library/Fonts/PingFangSC.ttc',\n      '/System/Library/Fonts/PingFangTC.ttc',\n      '/System/Library/Fonts/AppleSDGothicNeo.ttc'\n    )\n    cyrillic.push(\n      '/System/Library/Fonts/Supplemental/Arial.ttf',\n      '/System/Library/Fonts/Supplemental/Arial Unicode.ttf'\n    )\n  } else if (platform === 'win32') {\n    base.push(\n      'C:\\\\Windows\\\\Fonts\\\\segoeui.ttf',\n      'C:\\\\Windows\\\\Fonts\\\\arial.ttf',\n      'C:\\\\Windows\\\\Fonts\\\\tahoma.ttf'\n    )\n    cjk.push(\n      'C:\\\\Windows\\\\Fonts\\\\msyh.ttc',\n      'C:\\\\Windows\\\\Fonts\\\\msyh.ttf',\n      'C:\\\\Windows\\\\Fonts\\\\msyhbd.ttc',\n      'C:\\\\Windows\\\\Fonts\\\\simhei.ttf',\n      'C:\\\\Windows\\\\Fonts\\\\simsun.ttc',\n      'C:\\\\Windows\\\\Fonts\\\\meiryo.ttc',\n      'C:\\\\Windows\\\\Fonts\\\\yugothr.ttc',\n      'C:\\\\Windows\\\\Fonts\\\\malgun.ttf'\n    )\n    cyrillic.push('C:\\\\Windows\\\\Fonts\\\\arial.ttf', 'C:\\\\Windows\\\\Fonts\\\\segoeui.ttf')\n  } else {\n    base.push(\n      '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',\n      '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf',\n      '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'\n    )\n    cjk.push(\n      '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',\n      '/usr/share/fonts/opentype/noto/NotoSansCJK.ttc',\n      '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',\n      '/usr/share/fonts/truetype/noto/NotoSansCJK.ttc',\n      '/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf',\n      '/usr/share/fonts/truetype/noto/NotoSansTC-Regular.ttf',\n      '/usr/share/fonts/truetype/arphic/uming.ttc',\n      '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',\n      '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc'\n    )\n    cyrillic.push('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')\n  }\n\n  const preferred = containsCjk(text) ? cjk : containsCyrillic(text) ? cyrillic : []\n  const seen = new Set<string>()\n  const ordered = [...preferred, ...base]\n  return ordered.filter((candidate) => {\n    if (seen.has(candidate)) {\n      return false\n    }\n    seen.add(candidate)\n    return true\n  })\n}\n\nconst resolveWatermarkFontFile = (text: string): string | null => {\n  const candidates = buildWatermarkFontCandidates(text)\n  scopedLoggers.download.info(`Searching for fonts. Candidates: ${candidates.length}`)\n\n  for (const candidate of candidates) {\n    scopedLoggers.download.info(`Checking font: ${candidate}`)\n    if (fs.existsSync(candidate)) {\n      scopedLoggers.download.info(`✓ Using font: ${candidate}`)\n      return candidate\n    }\n    scopedLoggers.download.info(`✗ Font not found: ${candidate}`)\n  }\n\n  scopedLoggers.download.warn(\n    `No suitable font found for text containing CJK/Cyrillic characters. Tried ${candidates.length} candidates. Text rendering may be incomplete.`\n  )\n  return null\n}\n\nconst escapeFilterValue = (value: string): string => {\n  return value.replace(/\\\\/g, '\\\\\\\\').replace(/:/g, '\\\\:').replace(/'/g, \"\\\\'\")\n}\n\nconst buildDrawTextFilter = (textFilePath: string, fontFile?: string): string => {\n  const fontSize = 'max(14\\\\, min(44\\\\, h*0.024))'\n  const edgePadding = 'max(8\\\\, h*0.018)'\n  const options = [\n    `textfile=${escapeFilterValue(textFilePath)}`,\n    fontFile ? `fontfile=${escapeFilterValue(fontFile)}` : null,\n    'fontcolor=white',\n    'text_align=right',\n    'shadowcolor=black@0.7',\n    'shadowx=1',\n    'shadowy=1',\n    `fontsize=${fontSize}`,\n    `x=w-tw-${edgePadding}`,\n    `y=h-th-${edgePadding}`\n  ].filter(Boolean)\n  return `drawtext=${options.join(':')}`\n}\n\nconst resolveWatermarkOutputPaths = (inputPath: string) => {\n  const dir = path.dirname(inputPath)\n  const ext = path.extname(inputPath).slice(1).toLowerCase()\n  const base = path.basename(inputPath, path.extname(inputPath))\n  const outputExt = ['mp4', 'm4v', 'mov', 'mkv'].includes(ext) ? ext : 'mp4'\n  const outputPath = path.join(dir, `${base}.${outputExt}`)\n  const tempOutputPath = path.join(dir, `${base}.vidbee-watermark.${Date.now()}.${outputExt}`)\n  return { outputPath, tempOutputPath, outputExt }\n}\n\nconst runFfmpeg = async (ffmpegPath: string, args: string[]): Promise<void> => {\n  await new Promise<void>((resolve, reject) => {\n    const process = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })\n    let stderr = ''\n\n    process.stderr?.on('data', (data: Buffer) => {\n      stderr += data.toString()\n    })\n\n    process.on('error', (error) => {\n      reject(error)\n    })\n\n    process.on('close', (code) => {\n      if (code === 0) {\n        resolve()\n        return\n      }\n      reject(new Error(`ffmpeg exited with code ${code ?? 'unknown'}: ${stderr.trim()}`))\n    })\n  })\n}\n\nconst replaceOutputFile = async (outputPath: string, tempPath: string): Promise<void> => {\n  let backupPath: string | null = null\n  if (fs.existsSync(outputPath)) {\n    backupPath = `${outputPath}.vidbee-backup-${Date.now()}`\n    await fs.promises.rename(outputPath, backupPath)\n  }\n\n  try {\n    await fs.promises.rename(tempPath, outputPath)\n    if (backupPath) {\n      await fs.promises.unlink(backupPath).catch(() => {})\n    }\n  } catch (error) {\n    if (backupPath) {\n      await fs.promises.rename(backupPath, outputPath).catch(() => {})\n    }\n    throw error\n  }\n}\n\nexport const applyShareWatermark = async (params: {\n  inputPath: string\n  ffmpegPath: string\n  title?: string\n  author?: string\n}): Promise<{ outputPath: string; fileSize: number } | null> => {\n  const { inputPath, ffmpegPath, title, author } = params\n  if (!inputPath) {\n    return null\n  }\n\n  const { outputPath, tempOutputPath, outputExt } = resolveWatermarkOutputPaths(inputPath)\n  const textFilePath = path.join(\n    os.tmpdir(),\n    `vidbee-watermark-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`\n  )\n  const watermarkText = buildShareWatermarkText(title, author)\n  let outputReady = false\n\n  try {\n    await fs.promises.writeFile(textFilePath, watermarkText, 'utf8')\n    const fontFile = resolveWatermarkFontFile(watermarkText)\n    const filter = buildDrawTextFilter(textFilePath, fontFile ?? undefined)\n    const args = [\n      '-y',\n      '-hide_banner',\n      '-i',\n      inputPath,\n      '-vf',\n      filter,\n      '-c:v',\n      'libx264',\n      '-preset',\n      'veryfast',\n      '-crf',\n      '23',\n      '-c:a',\n      'aac',\n      '-b:a',\n      '192k'\n    ]\n\n    if (['mp4', 'm4v', 'mov'].includes(outputExt)) {\n      args.push('-movflags', '+faststart')\n    }\n\n    args.push(tempOutputPath)\n    await runFfmpeg(ffmpegPath, args)\n    await replaceOutputFile(outputPath, tempOutputPath)\n    outputReady = true\n\n    if (outputPath !== inputPath) {\n      await fs.promises.unlink(inputPath).catch(() => {})\n    }\n\n    const stats = await fs.promises.stat(outputPath)\n    return { outputPath, fileSize: stats.size }\n  } catch (error) {\n    scopedLoggers.download.error('Failed to apply watermark:', error)\n    throw error\n  } finally {\n    await fs.promises.unlink(textFilePath).catch(() => {})\n    if (!outputReady) {\n      await fs.promises.unlink(tempOutputPath).catch(() => {})\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/youtube-extractor-args.ts",
    "content": "const YOUTUBE_HOST_SUFFIXES = ['youtube.com', 'youtu.be', 'youtube-nocookie.com'] as const\nconst YOUTUBE_SAFE_PLAYER_CLIENTS = 'default,-web,-web_safari'\n\nconst hasYouTubeHost = (host: string): boolean =>\n  YOUTUBE_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))\n\nexport const isYouTubeUrl = (url: string): boolean => {\n  try {\n    const host = new URL(url).hostname.toLowerCase()\n    return hasYouTubeHost(host)\n  } catch {\n    return false\n  }\n}\n\nexport const appendYouTubeSafeExtractorArgs = (args: string[], url: string): void => {\n  if (!isYouTubeUrl(url)) {\n    return\n  }\n  args.push('--extractor-args', `youtube:player_client=${YOUTUBE_SAFE_PLAYER_CLIENTS}`)\n}\n"
  },
  {
    "path": "apps/desktop/src/main/lib/ytdlp-manager.ts",
    "content": "import { execSync } from 'node:child_process'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport type YTDlpWrap from 'yt-dlp-wrap-plus'\nimport { scopedLoggers } from '../utils/logger'\n\n// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility\nconst YTDlpWrapModule = require('yt-dlp-wrap-plus')\nconst YTDlpWrapCtor: typeof YTDlpWrap = YTDlpWrapModule.default || YTDlpWrapModule\ntype YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>\n\nclass YtDlpManager {\n  private ytdlpPath: string | null = null\n  private ytdlpInstance: YTDlpWrapInstance | null = null\n  private jsRuntimeArgs: string[] = []\n\n  async initialize(): Promise<void> {\n    this.ytdlpPath = this.resolveBundledYtDlp()\n    this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)\n    this.jsRuntimeArgs = this.resolveJsRuntimeArgs()\n    scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)\n  }\n\n  getInstance(): YTDlpWrapInstance {\n    if (!this.ytdlpInstance) {\n      throw new Error('yt-dlp not initialized. Call initialize() first.')\n    }\n    return this.ytdlpInstance\n  }\n\n  getPath(): string {\n    if (!this.ytdlpPath) {\n      throw new Error('yt-dlp not initialized. Call initialize() first.')\n    }\n    return this.ytdlpPath\n  }\n\n  getJsRuntimeArgs(): string[] {\n    return [...this.jsRuntimeArgs]\n  }\n\n  private getResourcesPath(): string {\n    // In development, read from project root's resources\n    if (process.env.NODE_ENV === 'development') {\n      return path.join(process.cwd(), 'resources')\n    }\n    // In production, resources may be bundled under app.asar.unpacked or extraResources.\n    const asarUnpackedPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')\n    if (fs.existsSync(asarUnpackedPath)) {\n      return asarUnpackedPath\n    }\n    return path.join(process.resourcesPath, 'resources')\n  }\n\n  private resolveBundledYtDlp(): string {\n    const platform = os.platform()\n    let bundledName: string\n\n    // Determine the binary name based on platform\n    if (platform === 'win32') {\n      bundledName = 'yt-dlp.exe'\n    } else if (platform === 'darwin') {\n      bundledName = 'yt-dlp_macos'\n    } else {\n      bundledName = 'yt-dlp_linux'\n    }\n\n    // Desktop only supports the bundled yt-dlp binary shipped in resources.\n    const resourcesPath = this.getResourcesPath()\n    const bundledPath = path.join(resourcesPath, bundledName)\n    if (fs.existsSync(bundledPath)) {\n      scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)\n      // Make executable on Unix-like systems if needed\n      if (platform !== 'win32') {\n        try {\n          fs.chmodSync(bundledPath, 0o755)\n        } catch (error) {\n          scopedLoggers.engine.warn('Failed to set executable permission:', error)\n        }\n      }\n      return bundledPath\n    }\n\n    const message = `Bundled yt-dlp not found at ${bundledPath}. Ensure it is packaged in resources.`\n    scopedLoggers.engine.error(message)\n    throw new Error(message)\n  }\n\n  private resolveJsRuntimeArgs(): string[] {\n    const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()\n    if (!runtime || runtime === 'none') {\n      return []\n    }\n\n    const runtimePath = this.resolveJsRuntimePath(runtime)\n    if (runtimePath) {\n      return ['--js-runtimes', `${runtime}:${runtimePath}`]\n    }\n\n    if (process.env.YTDLP_JS_RUNTIME) {\n      scopedLoggers.engine.warn(\n        `Requested JS runtime \"${runtime}\" was not found. Falling back to yt-dlp default detection.`\n      )\n    } else {\n      scopedLoggers.engine.warn(\n        'JS runtime not found. YouTube support may be limited without an external JS runtime.'\n      )\n    }\n\n    return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []\n  }\n\n  private resolveJsRuntimePath(runtime: string): string | null {\n    const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()\n    if (envPath && fs.existsSync(envPath)) {\n      scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)\n      return envPath\n    }\n\n    const platform = os.platform()\n    const resourcesPath = this.getResourcesPath()\n    const resourceCandidates: string[] = []\n\n    if (runtime === 'deno') {\n      resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')\n    } else if (runtime === 'node') {\n      resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')\n    } else if (runtime === 'bun') {\n      resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')\n    } else if (runtime === 'quickjs') {\n      resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')\n    } else {\n      resourceCandidates.push(runtime)\n      if (platform === 'win32' && !runtime.endsWith('.exe')) {\n        resourceCandidates.push(`${runtime}.exe`)\n      }\n    }\n\n    for (const candidate of resourceCandidates) {\n      const fullPath = path.join(resourcesPath, candidate)\n      if (fs.existsSync(fullPath)) {\n        if (platform !== 'win32') {\n          try {\n            fs.chmodSync(fullPath, 0o755)\n          } catch (error) {\n            scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)\n          }\n        }\n        scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)\n        return fullPath\n      }\n    }\n\n    try {\n      if (platform === 'win32') {\n        const output = execSync(`where ${runtime}`).toString().split(/\\r?\\n/)[0]\n        if (output && fs.existsSync(output)) {\n          scopedLoggers.engine.info('Using system JS runtime:', output)\n          return output\n        }\n      } else {\n        const systemPath = execSync(`which ${runtime}`).toString().trim()\n        if (systemPath && fs.existsSync(systemPath)) {\n          scopedLoggers.engine.info('Using system JS runtime:', systemPath)\n          return systemPath\n        }\n      }\n    } catch (_error) {\n      // Runtime not found in PATH\n    }\n\n    return null\n  }\n\n  // Removed runtime download/update to avoid network dependency in production builds\n}\n\nexport const ytdlpManager = new YtDlpManager()\n"
  },
  {
    "path": "apps/desktop/src/main/local-api.ts",
    "content": "import crypto from 'node:crypto'\nimport http from 'node:http'\nimport type { AddressInfo } from 'node:net'\nimport log from 'electron-log/main'\nimport { downloadEngine } from './lib/download-engine'\n\nconst PORT_RANGE_START = 27_100\nconst PORT_RANGE_END = 27_120\nconst TOKEN_TTL_MS = 60_000\n\ninterface TokenRecord {\n  expiresAt: number\n}\n\nlet server: http.Server | null = null\nlet serverPort: number | null = null\nconst tokens = new Map<string, TokenRecord>()\n\nconst isLoopbackAddress = (address?: string | null): boolean => {\n  if (!address) {\n    return false\n  }\n  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'\n}\n\nconst writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {\n  res.writeHead(status, {\n    'Content-Type': 'application/json; charset=utf-8',\n    'Access-Control-Allow-Origin': '*',\n    'Access-Control-Allow-Methods': 'GET, OPTIONS',\n    'Access-Control-Allow-Headers': 'Content-Type'\n  })\n  res.end(JSON.stringify(body))\n}\n\nconst writeEmpty = (res: http.ServerResponse, status: number): void => {\n  res.writeHead(status, {\n    'Access-Control-Allow-Origin': '*',\n    'Access-Control-Allow-Methods': 'GET, OPTIONS',\n    'Access-Control-Allow-Headers': 'Content-Type'\n  })\n  res.end()\n}\n\nconst issueToken = (): string => {\n  const token = crypto.randomBytes(16).toString('hex')\n  tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })\n  return token\n}\n\nconst consumeToken = (token?: string | null): boolean => {\n  if (!token) {\n    return false\n  }\n  const record = tokens.get(token)\n  if (!record) {\n    return false\n  }\n  if (Date.now() > record.expiresAt) {\n    tokens.delete(token)\n    return false\n  }\n  tokens.delete(token)\n  return true\n}\n\nconst handleRequest = async (\n  req: http.IncomingMessage,\n  res: http.ServerResponse\n): Promise<void> => {\n  try {\n    if (!isLoopbackAddress(req.socket.remoteAddress)) {\n      writeJson(res, 403, { error: 'Forbidden' })\n      return\n    }\n\n    if (req.method === 'OPTIONS') {\n      writeEmpty(res, 204)\n      return\n    }\n\n    if (!req.url) {\n      writeJson(res, 400, { error: 'Missing URL' })\n      return\n    }\n\n    const requestUrl = new URL(req.url, 'http://127.0.0.1')\n    const pathname = requestUrl.pathname\n\n    if (req.method !== 'GET') {\n      writeJson(res, 405, { error: 'Method not allowed' })\n      return\n    }\n\n    if (pathname === '/token') {\n      const token = issueToken()\n      writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })\n      return\n    }\n\n    if (pathname === '/video-info') {\n      const token = requestUrl.searchParams.get('token')\n      if (!consumeToken(token)) {\n        writeJson(res, 401, { error: 'Invalid token' })\n        return\n      }\n\n      const targetUrl = requestUrl.searchParams.get('url')\n      if (!targetUrl?.trim()) {\n        writeJson(res, 400, { error: 'Missing url' })\n        return\n      }\n\n      try {\n        const info = await downloadEngine.getVideoInfo(targetUrl.trim())\n        writeJson(res, 200, {\n          title: info.title,\n          thumbnail: info.thumbnail,\n          duration: info.duration,\n          formats: info.formats ?? []\n        })\n      } catch (error) {\n        const message = error instanceof Error ? error.message : 'Failed to fetch video info'\n        const details =\n          error instanceof Error\n            ? error.stack\n            : typeof error === 'object' && error && 'stderr' in error\n              ? String((error as { stderr?: unknown }).stderr ?? '')\n              : undefined\n        writeJson(res, 500, { error: message, details })\n      }\n      return\n    }\n\n    if (pathname === '/status') {\n      writeJson(res, 200, { ok: true })\n      return\n    }\n\n    writeJson(res, 404, { error: 'Not found' })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Unhandled request error'\n    writeJson(res, 500, { error: message })\n  }\n}\n\nconst startServerOnPort = (port: number): Promise<http.Server> =>\n  new Promise((resolve, reject) => {\n    const httpServer = http.createServer((req, res) => {\n      void handleRequest(req, res)\n    })\n\n    httpServer.once('error', (error) => {\n      httpServer.close()\n      reject(error)\n    })\n\n    httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))\n  })\n\nexport async function startExtensionApiServer(): Promise<number | null> {\n  if (server && serverPort) {\n    return serverPort\n  }\n\n  for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {\n    try {\n      server = await startServerOnPort(port)\n      const address = server.address() as AddressInfo | null\n      serverPort = address?.port ?? port\n      log.info(`Extension API listening on 127.0.0.1:${serverPort}`)\n      return serverPort\n    } catch (error) {\n      const err = error as NodeJS.ErrnoException\n      if (err.code !== 'EADDRINUSE') {\n        log.warn('Extension API failed to start on port:', port, err)\n      }\n    }\n  }\n\n  log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)\n  return null\n}\n\nexport async function stopExtensionApiServer(): Promise<void> {\n  if (!server) {\n    return\n  }\n\n  await new Promise<void>((resolve) => {\n    server?.close(() => resolve())\n  })\n\n  server = null\n  serverPort = null\n  tokens.clear()\n}\n"
  },
  {
    "path": "apps/desktop/src/main/settings.ts",
    "content": "import fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport type { AppSettings } from '../shared/types'\nimport { defaultSettings } from '../shared/types'\nimport { scopedLoggers } from './utils/logger'\n\n// Use require for electron-store to avoid CommonJS/ESM issues\nconst ElectronStore = require('electron-store')\n// Access the default export\nconst Store = ElectronStore.default || ElectronStore\n\nconst OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')\nconst ensureDirectoryExists = (dir: string) => {\n  try {\n    fs.mkdirSync(dir, { recursive: true })\n  } catch (error) {\n    scopedLoggers.system.error('Failed to ensure download directory:', error)\n  }\n}\n\nconst resolveDefaultDownloadPath = () => {\n  return path.join(os.homedir(), 'Downloads', 'VidBee')\n}\n\nconst DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()\n\nclass SettingsManager {\n  // biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import\n  private readonly store: any\n\n  constructor() {\n    this.store = new Store({\n      defaults: {\n        ...defaultSettings,\n        downloadPath: DEFAULT_DOWNLOAD_PATH\n      }\n    })\n    this.ensureDownloadDirectory()\n  }\n\n  get<K extends keyof AppSettings>(key: K): AppSettings[K] {\n    return this.store.get(key)\n  }\n\n  set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {\n    if (key === 'downloadPath' && typeof value === 'string') {\n      ensureDirectoryExists(value)\n    }\n    this.store.set(key, value)\n  }\n\n  getAll(): AppSettings {\n    return {\n      ...defaultSettings,\n      downloadPath: DEFAULT_DOWNLOAD_PATH,\n      ...this.store.store\n    }\n  }\n\n  setAll(settings: Partial<AppSettings>): void {\n    for (const [key, value] of Object.entries(settings)) {\n      if (key === 'downloadPath' && typeof value === 'string') {\n        ensureDirectoryExists(value)\n      }\n      this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])\n    }\n  }\n\n  reset(): void {\n    this.store.clear()\n    this.store.set({\n      ...defaultSettings,\n      downloadPath: DEFAULT_DOWNLOAD_PATH\n    })\n  }\n\n  private ensureDownloadDirectory(): void {\n    try {\n      const currentPath: string | undefined = this.store.get('downloadPath')\n      const normalizedDownloadPath =\n        !currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH\n          ? DEFAULT_DOWNLOAD_PATH\n          : currentPath\n      if (normalizedDownloadPath !== currentPath) {\n        this.store.set('downloadPath', normalizedDownloadPath)\n      }\n    } catch (error) {\n      scopedLoggers.system.error('Failed to verify download directory:', error)\n    }\n  }\n}\n\nexport const settingsManager = new SettingsManager()\n"
  },
  {
    "path": "apps/desktop/src/main/tray.ts",
    "content": "import { type LanguageCode, normalizeLanguageCode } from '@vidbee/i18n/languages'\nimport { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'\nimport appIcon from '../../resources/icon.png?asset'\nimport trayIcon from '../../resources/tray-icon.png?asset'\nimport { settingsManager } from './settings'\n\nlet tray: Tray | null = null\n\n/**\n * Get translated text based on current language setting\n */\nfunction t(key: 'showHome' | 'quit'): string {\n  const language = normalizeLanguageCode(settingsManager.get('language'))\n\n  const translations: Record<LanguageCode, Record<'showHome' | 'quit', string>> = {\n    en: {\n      showHome: 'Show Home',\n      quit: 'Quit'\n    },\n    es: {\n      showHome: 'Mostrar inicio',\n      quit: 'Salir'\n    },\n    ar: {\n      showHome: 'إظهار الصفحة الرئيسية',\n      quit: 'إنهاء'\n    },\n    id: {\n      showHome: 'Tampilkan Beranda',\n      quit: 'Keluar'\n    },\n    pt: {\n      showHome: 'Mostrar página inicial',\n      quit: 'Sair'\n    },\n    fr: {\n      showHome: \"Afficher l'accueil\",\n      quit: 'Quitter'\n    },\n    it: {\n      showHome: 'Mostra Home',\n      quit: 'Esci'\n    },\n    tr: {\n      showHome: 'Ana Sayfayı Göster',\n      quit: 'Çıkış'\n    },\n    zh: {\n      showHome: '显示主页',\n      quit: '退出应用'\n    },\n    'zh-TW': {\n      showHome: '顯示主頁',\n      quit: '退出應用程式'\n    },\n    ko: {\n      showHome: '홈 표시',\n      quit: '종료'\n    },\n    ja: {\n      showHome: 'ホームを表示',\n      quit: '終了'\n    },\n    ru: {\n      showHome: 'Показать главную',\n      quit: 'Выход'\n    },\n    de: {\n      showHome: 'Startseite anzeigen',\n      quit: 'Beenden'\n    }\n  }\n\n  return translations[language][key]\n}\n\n/**\n * Find the main window\n */\nfunction findMainWindow(): BrowserWindow | null {\n  const windows = BrowserWindow.getAllWindows()\n  return windows.find((window) => !window.isDestroyed()) || null\n}\n\n/**\n * Create context menu for tray\n */\nfunction createContextMenu(): Menu {\n  return Menu.buildFromTemplate([\n    {\n      label: t('showHome'),\n      click: () => {\n        const mainWindow = findMainWindow()\n        if (mainWindow) {\n          if (mainWindow.isMinimized()) {\n            mainWindow.restore()\n          }\n          mainWindow.show()\n          mainWindow.focus()\n        }\n      }\n    },\n    {\n      type: 'separator'\n    },\n    {\n      label: t('quit'),\n      click: () => {\n        // Force close all windows before quitting\n        const windows = BrowserWindow.getAllWindows()\n\n        // Close all windows forcefully\n        for (const window of windows) {\n          window.destroy()\n        }\n\n        // Quit the application and exit the process\n        app.quit()\n\n        // Force exit if quit doesn't work\n        setTimeout(() => {\n          app.exit(0)\n        }, 1000)\n      }\n    }\n  ])\n}\n\n/**\n * Create system tray icon\n */\nexport function createTray(): void {\n  if (tray) {\n    return\n  }\n\n  // Use 16x16 icon for macOS tray, fallback to app icon for other platforms\n  const iconPath = process.platform === 'darwin' ? trayIcon : appIcon\n  const trayIconImage = nativeImage.createFromPath(iconPath)\n\n  // For macOS, ensure the icon is properly sized\n  if (process.platform === 'darwin') {\n    // Resize to 16x16 for macOS tray\n    trayIconImage.setTemplateImage(true)\n  }\n\n  tray = new Tray(trayIconImage)\n\n  // Set tooltip\n  tray.setToolTip('VidBee')\n\n  // Set context menu\n  tray.setContextMenu(createContextMenu())\n\n  // On Windows/Linux: click to show/hide main window\n  tray.on('click', async () => {\n    const mainWindow = findMainWindow()\n    if (mainWindow) {\n      if (mainWindow.isVisible()) {\n        // If window is visible, hide it\n        mainWindow.hide()\n      } else {\n        // If window is hidden or minimized, show it\n        if (mainWindow.isMinimized()) {\n          mainWindow.restore()\n        }\n        mainWindow.show()\n        mainWindow.focus()\n      }\n    } else {\n      // If no main window exists, create a new one\n      const { createWindow } = await import('./index')\n      createWindow()\n    }\n  })\n}\n\n/**\n * Update tray menu (call this when language changes)\n */\nexport function updateTrayMenu(): void {\n  if (tray) {\n    tray.setContextMenu(createContextMenu())\n  }\n}\n\n/**\n * Destroy tray icon\n */\nexport function destroyTray(): void {\n  if (tray) {\n    tray.destroy()\n    tray = null\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/utils/auto-launch.ts",
    "content": "import { app } from 'electron'\nimport log from 'electron-log/main'\n\nconst SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])\n\nexport function isAutoLaunchSupported(): boolean {\n  return SUPPORTED_PLATFORMS.has(process.platform)\n}\n\nexport function applyAutoLaunchSetting(enabled: boolean): void {\n  if (!isAutoLaunchSupported()) {\n    log.info('Auto launch is not supported on this platform, skipping setting update')\n    return\n  }\n\n  const updateSetting = () => {\n    try {\n      const options: Parameters<typeof app.setLoginItemSettings>[0] = {\n        openAtLogin: enabled\n      }\n\n      if (process.platform === 'darwin') {\n        options.openAsHidden = true\n      }\n\n      app.setLoginItemSettings(options)\n      log.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)\n    } catch (error) {\n      log.error('Failed to update login item settings:', error)\n    }\n  }\n\n  if (app.isReady()) {\n    updateSetting()\n  } else {\n    app.once('ready', updateSetting)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/utils/dock.ts",
    "content": "import { app } from 'electron'\n\n/**\n * Apply Dock visibility preference on macOS.\n */\nexport function applyDockVisibility(hideDockIcon: boolean): void {\n  if (process.platform !== 'darwin' || !app.dock) {\n    return\n  }\n\n  if (hideDockIcon) {\n    app.dock.hide()\n  } else {\n    app.dock.show()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/main/utils/logger.ts",
    "content": "/**\n * Main process logger utility\n * Directly use electron-log/main\n */\n\nimport log from 'electron-log/main'\n\n// Export electron-log instance\nexport default log\n\n// Export commonly used logging methods\nexport const logger = log\n\n// Predefined scoped loggers\nexport const scopedLoggers = {\n  main: log.scope('main'),\n  ipc: log.scope('ipc'),\n  window: log.scope('window'),\n  download: log.scope('download'),\n  engine: log.scope('engine'),\n  system: log.scope('system'),\n  storage: log.scope('storage'),\n  thumbnail: log.scope('thumbnail'),\n  history: log.scope('history')\n}\n"
  },
  {
    "path": "apps/desktop/src/main/utils/path-helpers.ts",
    "content": "import os from 'node:os'\nimport path from 'node:path'\n\nexport const resolvePathWithHome = (rawPath?: string | null): string | undefined => {\n  const trimmed = rawPath?.trim()\n  if (!trimmed) {\n    return undefined\n  }\n\n  if (trimmed === '~') {\n    return os.homedir()\n  }\n\n  if (trimmed.startsWith('~/') || trimmed.startsWith('~\\\\')) {\n    return path.join(os.homedir(), trimmed.slice(2))\n  }\n\n  return trimmed\n}\n"
  },
  {
    "path": "apps/desktop/src/preload/index.d.ts",
    "content": "import type { ElectronAPI } from '@electron-toolkit/preload'\nimport type { IpcServices } from '../main/ipc'\n\ndeclare global {\n  interface Window {\n    electron: ElectronAPI\n    api: IpcServices & {\n      on: (channel: string, callback: (...args: unknown[]) => void) => (...args: unknown[]) => void\n      removeListener: (channel: string, callback: (...args: unknown[]) => void) => void\n      send: (channel: string, ...args: unknown[]) => void\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/preload/index.ts",
    "content": "import { electronAPI } from '@electron-toolkit/preload'\nimport { contextBridge, ipcRenderer } from 'electron'\nimport { createIpcProxy } from 'electron-ipc-decorator/client'\nimport type { IpcServices } from '../main/ipc'\n\n// Create type-safe IPC proxy using electron-ipc-decorator\nconst ipcServices = createIpcProxy<IpcServices>(ipcRenderer)\n\n// Custom APIs for renderer\nconst api = {\n  // IPC Services (type-safe, using decorators)\n  ...ipcServices,\n\n  // Event listening API\n  on: (channel: string, callback: (...args: unknown[]) => void) => {\n    const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) =>\n      callback(...args)\n    ipcRenderer.on(channel, subscription)\n    return subscription\n  },\n  removeListener: (channel: string, callback: (...args: unknown[]) => void) => {\n    ipcRenderer.removeListener(channel, callback)\n  },\n  // Send message to main process\n  send: (channel: string, ...args: unknown[]) => {\n    ipcRenderer.send(channel, ...args)\n  }\n}\n\n// Use `contextBridge` APIs to expose Electron APIs to\n// renderer only if context isolation is enabled, otherwise\n// just add to the DOM global.\nif (process.contextIsolated) {\n  try {\n    contextBridge.exposeInMainWorld('electron', electronAPI)\n    contextBridge.exposeInMainWorld('api', api)\n  } catch (error) {\n    console.error('Failed to expose APIs in context bridge:', error)\n  }\n} else {\n  // @ts-expect-error (define in dts)\n  window.electron = electronAPI\n  // @ts-expect-error (define in dts)\n  window.api = api\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <title>VidBee</title>\n    <meta\n      http-equiv=\"Content-Security-Policy\"\n      content=\"default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz\"\n    >\n  </head>\n\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/App.tsx",
    "content": "import { Sidebar } from '@renderer/components/ui/sidebar'\nimport { Toaster } from '@renderer/components/ui/sonner'\nimport { TitleBar } from '@renderer/components/ui/title-bar'\nimport type { SubscriptionRule } from '@shared/types'\nimport { useAtom, useSetAtom } from 'jotai'\nimport { ThemeProvider } from 'next-themes'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'\nimport { toast } from 'sonner'\nimport { ErrorBoundary } from './components/error/ErrorBoundary'\nimport { useDownloadEvents } from './hooks/use-download-events'\nimport { ipcEvents, ipcServices } from './lib/ipc'\nimport { About } from './pages/About'\nimport { Home } from './pages/Home'\nimport { Settings } from './pages/Settings'\nimport { Subscriptions } from './pages/Subscriptions'\nimport { loadSettingsAtom, settingsAtom } from './store/settings'\nimport { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'\nimport { updateAvailableAtom, updateReadyAtom } from './store/update'\n\ntype Page = 'home' | 'subscriptions' | 'settings' | 'about'\n\nconst pageToPath: Record<Page, string> = {\n  home: '/',\n  subscriptions: '/subscriptions',\n  settings: '/settings',\n  about: '/about'\n}\n\nconst normalizePathname = (pathname: string): string => {\n  const trimmed = pathname.replace(/\\/+$/, '')\n  return trimmed === '' ? '/' : trimmed\n}\n\nconst pathToPage = (pathname: string): Page => {\n  const normalized = normalizePathname(pathname)\n  switch (normalized) {\n    case '/subscriptions':\n      return 'subscriptions'\n    case '/settings':\n      return 'settings'\n    case '/about':\n      return 'about'\n    default:\n      return 'home'\n  }\n}\n\nfunction AppContent() {\n  const [platform, setPlatform] = useState<string>('')\n  const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)\n  const setSubscriptions = useSetAtom(setSubscriptionsAtom)\n  const [settings] = useAtom(settingsAtom)\n  const loadSettings = useSetAtom(loadSettingsAtom)\n  const setUpdateReady = useSetAtom(updateReadyAtom)\n  const setUpdateAvailable = useSetAtom(updateAvailableAtom)\n  const { i18n } = useTranslation()\n  const updateDownloadInProgressRef = useRef(false)\n  const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)\n  const navigate = useNavigate()\n  const location = useLocation()\n  const currentPage = pathToPage(location.pathname)\n  const supportedSitesUrl = 'https://vidbee.org/supported-sites/'\n\n  useDownloadEvents()\n\n  const handlePageChange = useCallback(\n    (page: Page) => {\n      const targetPath = pageToPath[page] ?? '/'\n      if (normalizePathname(location.pathname) !== targetPath) {\n        navigate(targetPath)\n      }\n    },\n    [location.pathname, navigate]\n  )\n\n  const handleOpenCookiesSettings = useCallback(() => {\n    navigate('/settings?tab=cookies')\n  }, [navigate])\n\n  const handleOpenSupportedSites = () => {\n    window.open(supportedSitesUrl, '_blank')\n  }\n\n  useEffect(() => {\n    loadSettings()\n  }, [loadSettings])\n\n  useEffect(() => {\n    const handleDeepLink = (rawUrl: unknown) => {\n      const url = typeof rawUrl === 'string' ? rawUrl.trim() : ''\n      if (!url) {\n        return\n      }\n      // Switch to home page to show download dialog\n      handlePageChange('home')\n      // The DownloadDialog component will handle opening the dialog and parsing the video\n    }\n\n    ipcEvents.on('download:deeplink', handleDeepLink)\n    return () => {\n      ipcEvents.removeListener('download:deeplink', handleDeepLink)\n    }\n  }, [handlePageChange])\n\n  useEffect(() => {\n    loadSubscriptions()\n\n    const handleSubscriptions = (...args: unknown[]) => {\n      const list = args[0]\n      if (Array.isArray(list)) {\n        setSubscriptions(list as SubscriptionRule[])\n      }\n    }\n\n    ipcEvents.on('subscriptions:updated', handleSubscriptions)\n\n    return () => {\n      ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)\n    }\n  }, [loadSubscriptions, setSubscriptions])\n\n  // Load or remove analytics script based on settings\n  useEffect(() => {\n    const scriptId = 'analytics-script'\n    const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null\n\n    if (settings.enableAnalytics) {\n      // Remove existing script if it exists\n      if (existingScript) {\n        existingScript.remove()\n      }\n\n      // Create and append new script\n      const script = document.createElement('script')\n      script.id = scriptId\n      script.src = 'https://rybbit.102417.xyz/api/script.js'\n      script.setAttribute('data-site-id', '7bc6f6d625a4')\n      script.defer = true\n      script.async = true\n      document.head.appendChild(script)\n      analyticsScriptRef.current = script\n    } else {\n      // Remove script if analytics is disabled\n      if (existingScript) {\n        existingScript.remove()\n        analyticsScriptRef.current = null\n      }\n    }\n\n    return () => {\n      // Cleanup on unmount\n      const script = document.getElementById(scriptId)\n      if (script) {\n        script.remove()\n      }\n    }\n  }, [settings.enableAnalytics])\n\n  useEffect(() => {\n    // Get platform info to determine if we should show title bar\n    const getPlatform = async () => {\n      try {\n        const platformInfo = await ipcServices.app.getPlatform()\n        setPlatform(platformInfo)\n      } catch (error) {\n        console.error('Failed to get platform info:', error)\n        // Default to showing title bar if platform detection fails\n        setPlatform('unknown')\n      }\n    }\n    getPlatform()\n  }, [])\n\n  useEffect(() => {\n    if (!window?.api) {\n      return\n    }\n\n    const resetDownloadState = () => {\n      if (updateDownloadInProgressRef.current) {\n        updateDownloadInProgressRef.current = false\n      }\n    }\n\n    const handleUpdateAvailable = (rawInfo: unknown) => {\n      const info = (rawInfo ?? {}) as { version?: string }\n      setUpdateAvailable({\n        available: true,\n        version: info.version\n      })\n    }\n\n    const handleUpdateDownloaded = (rawInfo: unknown) => {\n      const info = (rawInfo ?? {}) as { version?: string }\n      resetDownloadState()\n      setUpdateReady({\n        ready: true,\n        version: info.version\n      })\n      setUpdateAvailable({\n        available: true,\n        version: info.version\n      })\n\n      const versionLabel = info?.version ?? ''\n      const downloadedMessage = versionLabel\n        ? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })\n        : i18n.t('about.notifications.updateDownloaded')\n      toast.info(downloadedMessage, {\n        action: {\n          label: i18n.t('about.notifications.restartNowAction'),\n          onClick: () => {\n            void ipcServices.update.quitAndInstall()\n          }\n        }\n      })\n    }\n\n    const handleUpdateError = (rawMessage: unknown) => {\n      const message = typeof rawMessage === 'string' ? rawMessage : ''\n      resetDownloadState()\n\n      const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')\n      toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))\n    }\n\n    const handleDownloadProgress = (rawProgress: unknown) => {\n      const progress = (rawProgress ?? {}) as { percent?: number }\n      if (typeof progress?.percent === 'number') {\n        console.info('Update download progress:', progress.percent.toFixed(2))\n      }\n    }\n\n    // Only listen to update events that should be shown globally\n    // update:available shows a visual indicator in the sidebar\n    ipcEvents.on('update:available', handleUpdateAvailable)\n    ipcEvents.on('update:downloaded', handleUpdateDownloaded)\n    ipcEvents.on('update:error', handleUpdateError)\n    ipcEvents.on('update:download-progress', handleDownloadProgress)\n\n    return () => {\n      ipcEvents.removeListener('update:available', handleUpdateAvailable)\n      ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)\n      ipcEvents.removeListener('update:error', handleUpdateError)\n      ipcEvents.removeListener('update:download-progress', handleDownloadProgress)\n    }\n  }, [i18n, setUpdateAvailable, setUpdateReady])\n\n  return (\n    <div className=\"flex h-screen flex-row\">\n      {/* Sidebar Navigation */}\n      <Sidebar\n        currentPage={currentPage}\n        onOpenSupportedSites={handleOpenSupportedSites}\n        onPageChange={handlePageChange}\n      />\n\n      {/* Main Content */}\n      <main className=\"flex min-h-0 flex-1 flex-col overflow-hidden bg-background\">\n        {/* Custom Title Bar */}\n        <TitleBar platform={platform} />\n\n        <div className=\"h-full flex-1 overflow-y-auto overflow-x-hidden\">\n          <Routes>\n            <Route\n              element={\n                <Home\n                  onOpenCookiesSettings={handleOpenCookiesSettings}\n                  onOpenSettings={() => handlePageChange('settings')}\n                  onOpenSupportedSites={handleOpenSupportedSites}\n                />\n              }\n              path=\"/\"\n            />\n            <Route element={<Subscriptions />} path=\"/subscriptions\" />\n            <Route element={<Settings />} path=\"/settings\" />\n            <Route element={<About />} path=\"/about\" />\n            <Route element={<Navigate replace to=\"/\" />} path=\"*\" />\n          </Routes>\n        </div>\n      </main>\n\n      <Toaster richColors={true} />\n    </div>\n  )\n}\n\nfunction App() {\n  return (\n    <ErrorBoundary>\n      <ThemeProvider attribute=\"class\" defaultTheme=\"system\" enableSystem>\n        <HashRouter>\n          <AppContent />\n        </HashRouter>\n      </ThemeProvider>\n    </ErrorBoundary>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/assets/global.css",
    "content": "@import \"tailwindcss\";\n@import \"@vidbee/ui/theme.css\";\n@import \"tw-animate-css\";\n@import \"@vidbee/ui/base.css\";\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/assets/main.css",
    "content": "body {\n  margin: 0;\n  padding: 0;\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Roboto\", \"Oxygen\", \"Ubuntu\",\n    \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n  font-family:\n    source-code-pro, Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\n\n* {\n  box-sizing: border-box;\n}\n\n#root {\n  width: 100vw;\n  height: 100vh;\n  overflow: hidden;\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/assets/theme.css",
    "content": ":root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.1884 0.0128 248.5103);\n  --card: oklch(0.9881 0 0);\n  --card-foreground: oklch(0.1884 0.0128 248.5103);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.1884 0.0128 248.5103);\n  --primary: oklch(0.8223 0.1704 79.8747);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.1884 0.0128 248.5103);\n  --secondary-foreground: oklch(1 0 0);\n  --muted: oklch(0.9227 0.0011 17.1793);\n  --muted-foreground: oklch(0.1884 0.0128 248.5103);\n  --accent: oklch(0.9485 0.0162 64.6689);\n  --accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --destructive: oklch(0.6188 0.2376 25.7658);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.8929 0.0133 82.4013);\n  --input: oklch(0.9823 0.0029 84.5589);\n  --ring: oklch(0.8223 0.1704 79.8747);\n  --chart-1: oklch(0.6723 0.1606 244.9955);\n  --chart-2: oklch(0.6907 0.1554 160.3454);\n  --chart-3: oklch(0.8214 0.16 82.5337);\n  --chart-4: oklch(0.7064 0.1822 151.7125);\n  --chart-5: oklch(0.5919 0.2186 10.5826);\n  --sidebar: oklch(0.9784 0.0011 197.1387);\n  --sidebar-foreground: oklch(0.1884 0.0128 248.5103);\n  --sidebar-primary: oklch(0.8223 0.1704 79.8747);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.9485 0.0162 64.6689);\n  --sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --sidebar-border: oklch(0.933 0.0108 76.5962);\n  --sidebar-ring: oklch(0.8223 0.1704 79.8747);\n  --font-sans: Open Sans, sans-serif;\n  --font-serif: Georgia, serif;\n  --font-mono: Menlo, monospace;\n  --radius: 0.625rem;\n  --shadow-x: 0px;\n  --shadow-y: 2px;\n  --shadow-blur: 0px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0;\n  --shadow-color: #1da1f2;\n  --shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-sm:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-md:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-lg:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-xl:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --tracking-normal: 0em;\n  --spacing: 0.25rem;\n}\n\n.dark {\n  --background: oklch(0 0 0);\n  --foreground: oklch(0.9328 0.0025 228.7857);\n  --card: oklch(0.2097 0.008 274.5332);\n  --card-foreground: oklch(0.8853 0 0);\n  --popover: oklch(0 0 0);\n  --popover-foreground: oklch(0.9328 0.0025 228.7857);\n  --primary: oklch(0.8223 0.1704 79.8747);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.9622 0.0035 219.5331);\n  --secondary-foreground: oklch(0.1884 0.0128 248.5103);\n  --muted: oklch(0.3485 0 0);\n  --muted-foreground: oklch(0.5637 0.0078 247.9662);\n  --accent: oklch(0.1928 0.0331 242.5459);\n  --accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --destructive: oklch(0.6188 0.2376 25.7658);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.2674 0.0047 248.0045);\n  --input: oklch(0.302 0.0288 244.8244);\n  --ring: oklch(0.8223 0.1704 79.8747);\n  --chart-1: oklch(0.8223 0.1704 79.8747);\n  --chart-2: oklch(0.6907 0.1554 160.3454);\n  --chart-3: oklch(0.8214 0.16 82.5337);\n  --chart-4: oklch(0.7064 0.1822 151.7125);\n  --chart-5: oklch(0.5919 0.2186 10.5826);\n  --sidebar: oklch(0.2097 0.008 274.5332);\n  --sidebar-foreground: oklch(0.8853 0 0);\n  --sidebar-primary: oklch(0.8223 0.1704 79.8747);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.1928 0.0331 242.5459);\n  --sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --sidebar-border: oklch(0.3795 0.022 240.5943);\n  --sidebar-ring: oklch(0.8223 0.1704 79.8747);\n  --font-sans: Open Sans, sans-serif;\n  --font-serif: Georgia, serif;\n  --font-mono: Menlo, monospace;\n}\n\n@theme inline {\n  --color-being-green-50: #effaf4;\n  --color-being-green-100: #d8f3e3;\n  --color-being-green-200: #b4e6cb;\n  --color-being-green-300: #67c99a;\n  --color-being-green-400: #4fb889;\n  --color-being-green-500: #2c9d6e;\n  --color-being-green-600: #1d7e58;\n  --color-being-green-700: #176549;\n  --color-being-green-800: #15503a;\n  --color-being-green-900: #124231;\n  --color-being-green-950: #09251c;\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n\n  --font-sans: var(--font-sans);\n  --font-mono: var(--font-mono);\n  --font-serif: var(--font-serif);\n\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --radius-2xl: calc(var(--radius) + 8px);\n  --radius-3xl: calc(var(--radius) + 12px);\n  --radius-4xl: calc(var(--radius) + 16px);\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/assets/title-bar.css",
    "content": "/* Title bar drag region styles */\n.drag-region {\n  -webkit-app-region: drag;\n  app-region: drag;\n}\n\n.no-drag {\n  -webkit-app-region: no-drag;\n  app-region: no-drag;\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/DownloadDialog.tsx",
    "content": "import { AddUrlPopover } from '@renderer/components/ui/add-url-popover'\nimport { Button } from '@renderer/components/ui/button'\nimport { Checkbox } from '@renderer/components/ui/checkbox'\nimport { DownloadDialogLayout } from '@renderer/components/ui/download-dialog-layout'\nimport { Input } from '@renderer/components/ui/input'\nimport { Label } from '@renderer/components/ui/label'\nimport type { PlaylistInfo, VideoFormat } from '@shared/types'\nimport {\n  buildAudioFormatPreference,\n  buildVideoFormatPreference\n} from '@shared/utils/format-preferences'\nimport { useAddUrlInteraction } from '@vidbee/ui/lib/use-add-url-interaction'\nimport { useAtom, useSetAtom } from 'jotai'\nimport { FolderOpen, Loader2 } from 'lucide-react'\nimport { useCallback, useEffect, useId, useMemo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { ipcEvents, ipcServices } from '../../lib/ipc'\nimport { addDownloadAtom } from '../../store/downloads'\nimport { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../../store/settings'\nimport {\n  currentVideoInfoAtom,\n  fetchVideoInfoAtom,\n  videoInfoCommandAtom,\n  videoInfoErrorAtom,\n  videoInfoLoadingAtom\n} from '../../store/video'\nimport { PlaylistDownload } from './PlaylistDownload'\nimport { SingleVideoDownload, type SingleVideoState } from './SingleVideoDownload'\n\nconst isMuxedVideoFormat = (format: VideoFormat | undefined): boolean =>\n  Boolean(format?.vcodec && format.vcodec !== 'none' && format.acodec && format.acodec !== 'none')\n\nconst resolvePreferredAudioExt = (videoExt: string | undefined): string | undefined => {\n  if (!videoExt) {\n    return undefined\n  }\n\n  const normalizedExt = videoExt.toLowerCase()\n  if (normalizedExt === 'mp4') {\n    return 'm4a'\n  }\n  if (normalizedExt === 'webm') {\n    return 'webm'\n  }\n  return undefined\n}\n\nconst buildSingleVideoFormatSelector = (\n  formatId: string,\n  format: VideoFormat | undefined\n): string => {\n  if (!format || isMuxedVideoFormat(format)) {\n    return formatId\n  }\n\n  const preferredAudioExt = resolvePreferredAudioExt(format.ext)\n  if (!preferredAudioExt) {\n    return `${formatId}+bestaudio`\n  }\n\n  // Prefer same-container audio and keep a fallback when not available.\n  return `${formatId}+bestaudio[ext=${preferredAudioExt}]/${formatId}+bestaudio`\n}\n\ninterface DownloadDialogProps {\n  onOpenSupportedSites?: () => void\n  onOpenSettings?: () => void\n}\n\nexport function DownloadDialog({\n  onOpenSupportedSites: _onOpenSupportedSites,\n  onOpenSettings: _onOpenSettings\n}: DownloadDialogProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n  const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)\n  const [videoInfoCommand] = useAtom(videoInfoCommandAtom)\n  const [loading] = useAtom(videoInfoLoadingAtom)\n  const [error] = useAtom(videoInfoErrorAtom)\n  const [settings] = useAtom(settingsAtom)\n  const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)\n  const loadSettings = useSetAtom(loadSettingsAtom)\n  const addDownload = useSetAtom(addDownloadAtom)\n  const saveSetting = useSetAtom(saveSettingAtom)\n\n  const [url, setUrl] = useState('')\n  const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')\n\n  // Single video state\n  const [singleVideoState, setSingleVideoState] = useState<SingleVideoState>({\n    title: '',\n    activeTab: 'video',\n    selectedVideoFormat: '',\n    selectedAudioFormat: '',\n    customDownloadPath: '',\n    selectedContainer: undefined,\n    selectedCodec: undefined,\n    selectedFps: undefined\n  })\n\n  // Playlist states\n  const downloadTypeId = useId()\n  const advancedOptionsId = useId()\n  const [playlistUrl, setPlaylistUrl] = useState('')\n  const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')\n  const [startIndex, setStartIndex] = useState('1')\n  const [endIndex, setEndIndex] = useState('')\n  const [playlistCustomDownloadPath, setPlaylistCustomDownloadPath] = useState('')\n  const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)\n  const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)\n  const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)\n  const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)\n  const playlistBusy = playlistPreviewLoading || playlistDownloadLoading\n  const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)\n  const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set())\n  const lockDialogHeight =\n    activeTab === 'playlist' && (playlistPreviewLoading || playlistInfo !== null)\n\n  const computePlaylistRange = useCallback(\n    (info: PlaylistInfo) => {\n      const parsedStart = Math.max(Number.parseInt(startIndex, 10) || 1, 1)\n      const rawEnd = endIndex ? Math.max(Number.parseInt(endIndex, 10), parsedStart) : undefined\n      const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart\n      const endValue =\n        rawEnd === undefined\n          ? undefined\n          : info.entryCount > 0\n            ? Math.min(rawEnd, info.entryCount)\n            : rawEnd\n      return { start, end: endValue }\n    },\n    [startIndex, endIndex]\n  )\n\n  const selectedPlaylistEntries = useMemo(() => {\n    if (!playlistInfo) {\n      return []\n    }\n    // If manual selection is active (has selected entries), use that\n    if (selectedEntryIds.size > 0) {\n      return playlistInfo.entries.filter((entry) => selectedEntryIds.has(entry.id))\n    }\n    // Otherwise, use range-based selection\n    const range = computePlaylistRange(playlistInfo)\n    const previewEnd = range.end ?? playlistInfo.entryCount\n    return playlistInfo.entries.filter(\n      (entry) => entry.index >= range.start && entry.index <= previewEnd\n    )\n  }, [playlistInfo, computePlaylistRange, selectedEntryIds])\n\n  // Listen for deep link events\n  useEffect(() => {\n    const handleDeepLink = async (data: unknown) => {\n      // Support both old format (string) and new format (object with url and type)\n      let url: string\n      let type: 'single' | 'playlist' = 'single'\n\n      if (typeof data === 'string') {\n        // Legacy format: just URL string\n        url = data.trim()\n      } else if (data && typeof data === 'object' && 'url' in data) {\n        // New format: object with url and type\n        url = typeof data.url === 'string' ? data.url.trim() : ''\n        if ('type' in data && data.type === 'playlist') {\n          type = 'playlist'\n        }\n      } else {\n        return\n      }\n\n      if (!url) {\n        return\n      }\n\n      // Open dialog and set URL\n      setOpen(true)\n      setActiveTab(type)\n\n      if (type === 'playlist') {\n        // Handle playlist\n        setPlaylistUrl(url)\n        setPlaylistInfo(null)\n        setPlaylistPreviewError(null)\n        setPlaylistCustomDownloadPath('')\n        setSelectedEntryIds(new Set())\n\n        // Wait for dialog to open, then fetch playlist info\n        setTimeout(async () => {\n          setPlaylistPreviewError(null)\n          setPlaylistPreviewLoading(true)\n          try {\n            const info = await ipcServices.download.getPlaylistInfo(url)\n            setPlaylistInfo(info)\n            if (info.entryCount === 0) {\n              toast.error(t('playlist.noEntries'))\n              return\n            }\n            toast.success(t('playlist.foundVideos', { count: info.entryCount }))\n          } catch (error) {\n            console.error('Failed to fetch playlist info:', error)\n            const message =\n              error instanceof Error && error.message ? error.message : t('playlist.previewFailed')\n            setPlaylistPreviewError(message)\n            setPlaylistInfo(null)\n            toast.error(t('playlist.previewFailed'))\n          } finally {\n            setPlaylistPreviewLoading(false)\n          }\n        }, 100)\n      } else {\n        // Handle single video\n        setUrl(url)\n\n        // Wait for dialog to open and settings to load, then fetch video info\n        setTimeout(async () => {\n          setSingleVideoState((prev) => ({\n            ...prev,\n            selectedVideoFormat: '',\n            selectedAudioFormat: '',\n            selectedContainer: undefined,\n            selectedCodec: undefined,\n            selectedFps: undefined\n          }))\n          await fetchVideoInfo(url)\n        }, 100)\n      }\n    }\n\n    ipcEvents.on('download:deeplink', handleDeepLink)\n    return () => {\n      ipcEvents.removeListener('download:deeplink', handleDeepLink)\n    }\n  }, [fetchVideoInfo, t])\n\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n    loadSettings()\n  }, [open, loadSettings])\n\n  const startOneClickDownload = useCallback(\n    async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {\n      const trimmedUrl = targetUrl.trim()\n      if (!trimmedUrl) {\n        toast.error(t('errors.emptyUrl'))\n        return\n      }\n\n      if (options?.setInputValue) {\n        setUrl(trimmedUrl)\n      }\n\n      const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`\n\n      const downloadItem = {\n        id,\n        url: trimmedUrl,\n        title: t('download.fetchingVideoInfo'),\n        type: settings.oneClickDownloadType,\n        status: 'pending' as const,\n        progress: { percent: 0 },\n        createdAt: Date.now()\n      }\n\n      const format =\n        settings.oneClickDownloadType === 'video'\n          ? buildVideoFormatPreference(settings)\n          : buildAudioFormatPreference(settings)\n\n      try {\n        const started = await ipcServices.download.startDownload(id, {\n          url: trimmedUrl,\n          type: settings.oneClickDownloadType,\n          format\n        })\n        if (!started) {\n          toast.info(t('notifications.downloadAlreadyQueued'))\n          return\n        }\n        addDownload(downloadItem)\n\n        toast.success(t('download.oneClickDownloadStarted'))\n        if (options?.clearInput) {\n          setUrl('')\n        }\n      } catch (error) {\n        console.error('Failed to start one-click download:', error)\n        toast.error(t('notifications.downloadFailed'))\n      }\n    },\n    [settings, addDownload, t]\n  )\n\n  const handleFetchVideo = useCallback(async () => {\n    if (!url.trim()) {\n      toast.error(t('errors.emptyUrl'))\n      return\n    }\n    setSingleVideoState((prev) => ({\n      ...prev,\n      selectedVideoFormat: '',\n      selectedAudioFormat: '',\n      selectedContainer: undefined,\n      selectedCodec: undefined,\n      selectedFps: undefined\n    }))\n    await fetchVideoInfo(url.trim())\n  }, [url, fetchVideoInfo, t])\n\n  const handleParsePlaylistUrl = useCallback(\n    async (trimmedUrl: string) => {\n      setOpen(true)\n      setPlaylistUrl(trimmedUrl)\n      setPlaylistInfo(null)\n      setPlaylistPreviewError(null)\n      setPlaylistCustomDownloadPath('')\n      setSelectedEntryIds(new Set())\n\n      setPlaylistPreviewError(null)\n      setPlaylistPreviewLoading(true)\n      try {\n        const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)\n        setPlaylistInfo(info)\n        if (info.entryCount === 0) {\n          toast.error(t('playlist.noEntries'))\n          return\n        }\n        toast.success(t('playlist.foundVideos', { count: info.entryCount }))\n      } catch (error) {\n        console.error('Failed to fetch playlist info:', error)\n        const message =\n          error instanceof Error && error.message ? error.message : t('playlist.previewFailed')\n        setPlaylistPreviewError(message)\n        setPlaylistInfo(null)\n        toast.error(t('playlist.previewFailed'))\n      } finally {\n        setPlaylistPreviewLoading(false)\n      }\n    },\n    [t]\n  )\n\n  const handleParseSingleUrl = useCallback(\n    async (trimmedUrl: string) => {\n      setOpen(true)\n      setUrl(trimmedUrl)\n      setSingleVideoState((prev) => ({\n        ...prev,\n        selectedVideoFormat: '',\n        selectedAudioFormat: '',\n        selectedContainer: undefined,\n        selectedCodec: undefined,\n        selectedFps: undefined\n      }))\n      await fetchVideoInfo(trimmedUrl)\n    },\n    [fetchVideoInfo]\n  )\n\n  const handleOneClickFromAddUrl = useCallback(\n    async (trimmedUrl: string) => {\n      await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false })\n    },\n    [startOneClickDownload]\n  )\n\n  const {\n    addUrlPopoverOpen,\n    addUrlValue,\n    canConfirmAddUrl,\n    handleConfirmAddUrl,\n    handleOpenAddUrlPopover,\n    hasAddUrlValue,\n    setAddUrlPopoverOpen,\n    setAddUrlValue\n  } = useAddUrlInteraction({\n    activeTab,\n    isOneClickDownloadEnabled: settings.oneClickDownload,\n    isPlaylistBusy: playlistBusy,\n    onEmptyUrl: () => {\n      toast.error(t('errors.emptyUrl'))\n    },\n    onInvalidUrl: () => {\n      toast.error(t('errors.invalidUrl'))\n    },\n    onOneClickDownload: handleOneClickFromAddUrl,\n    onParsePlaylist: handleParsePlaylistUrl,\n    onParseSingle: handleParseSingleUrl\n  })\n\n  const handleOneClickDownload = useCallback(async () => {\n    await startOneClickDownload(url, { clearInput: true })\n    setOpen(false) // Close dialog after download starts\n  }, [startOneClickDownload, url])\n\n  // Playlist handlers\n  const handleSelectPlaylistDirectory = useCallback(async () => {\n    if (playlistBusy) {\n      return\n    }\n    try {\n      const path = await ipcServices.fs.selectDirectory()\n      if (path) {\n        setPlaylistCustomDownloadPath(path)\n      }\n    } catch (error) {\n      console.error('Failed to select directory:', error)\n      toast.error(t('settings.directorySelectError'))\n    }\n  }, [playlistBusy, t])\n\n  const handlePreviewPlaylist = useCallback(async () => {\n    if (!playlistUrl.trim()) {\n      toast.error(t('errors.emptyUrl'))\n      return\n    }\n    setPlaylistPreviewError(null)\n    setPlaylistPreviewLoading(true)\n    try {\n      const trimmedUrl = playlistUrl.trim()\n      const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)\n      setPlaylistInfo(info)\n      setSelectedEntryIds(new Set())\n      if (info.entryCount === 0) {\n        toast.error(t('playlist.noEntries'))\n        return\n      }\n      toast.success(t('playlist.foundVideos', { count: info.entryCount }))\n    } catch (error) {\n      console.error('Failed to fetch playlist info:', error)\n      const message =\n        error instanceof Error && error.message ? error.message : t('playlist.previewFailed')\n      setPlaylistPreviewError(message)\n      setPlaylistInfo(null)\n      toast.error(t('playlist.previewFailed'))\n    } finally {\n      setPlaylistPreviewLoading(false)\n    }\n  }, [playlistUrl, t])\n\n  const handleDownloadPlaylist = useCallback(async () => {\n    const trimmedUrl = playlistUrl.trim()\n    if (!trimmedUrl) {\n      toast.error(t('errors.emptyUrl'))\n      return\n    }\n\n    if (!playlistInfo) {\n      toast.error(t('playlist.previewRequired'))\n      return\n    }\n\n    setPlaylistPreviewError(null)\n    setPlaylistDownloadLoading(true)\n    try {\n      const info = playlistInfo\n      setPlaylistInfo(info)\n\n      if (info.entryCount === 0) {\n        toast.error(t('playlist.noEntries'))\n        return\n      }\n\n      // Use manual selection if available, otherwise use range\n      let startIndex: number | undefined\n      let endIndex: number | undefined\n      let entryIds: string[] | undefined\n\n      if (selectedEntryIds.size > 0) {\n        const selectedEntries = info.entries\n          .filter((entry) => selectedEntryIds.has(entry.id))\n          .sort((a, b) => a.index - b.index)\n        const selectedIndices = selectedEntries.map((entry) => entry.index).sort((a, b) => a - b)\n\n        if (selectedEntries.length === 0) {\n          toast.error(t('playlist.noEntriesSelected'))\n          return\n        }\n\n        entryIds = selectedEntries.map((entry) => entry.id)\n        startIndex = selectedIndices[0]\n        endIndex = selectedIndices.at(-1)\n      } else {\n        // Range-based selection\n        const range = computePlaylistRange(info)\n        const previewEnd = range.end ?? info.entryCount\n\n        if (previewEnd < range.start || previewEnd === 0) {\n          toast.error(t('playlist.noEntriesInRange'))\n          return\n        }\n\n        startIndex = range.start\n        endIndex = range.end\n      }\n\n      const format =\n        downloadType === 'video'\n          ? buildVideoFormatPreference(settings)\n          : buildAudioFormatPreference(settings)\n\n      const result = await ipcServices.download.startPlaylistDownload({\n        url: trimmedUrl,\n        type: downloadType,\n        format,\n        startIndex,\n        endIndex,\n        entryIds,\n        customDownloadPath: playlistCustomDownloadPath.trim() || undefined\n      })\n\n      if (result.totalCount === 0) {\n        toast.error(t('playlist.noEntriesInRange'))\n        return\n      }\n\n      const baseCreatedAt = Date.now()\n      result.entries.forEach((entry, index) => {\n        const downloadItem = {\n          id: entry.downloadId,\n          url: entry.url,\n          title: entry.title || t('download.fetchingVideoInfo'),\n          type: downloadType,\n          status: 'pending' as const,\n          progress: { percent: 0 },\n          createdAt: baseCreatedAt + index,\n          playlistId: result.groupId,\n          playlistTitle: result.playlistTitle,\n          playlistIndex: entry.index,\n          playlistSize: result.totalCount\n        }\n        addDownload(downloadItem)\n      })\n\n      setOpen(false) // Close dialog after download starts\n    } catch (error) {\n      console.error('Failed to start playlist download:', error)\n      toast.error(t('playlist.downloadFailed'))\n    } finally {\n      setPlaylistDownloadLoading(false)\n    }\n  }, [\n    playlistUrl,\n    playlistInfo,\n    computePlaylistRange,\n    downloadType,\n    settings,\n    addDownload,\n    t,\n    playlistCustomDownloadPath,\n    selectedEntryIds\n  ])\n\n  // Update single video title when videoInfo changes\n  useEffect(() => {\n    if (videoInfo) {\n      setSingleVideoState((prev) => ({\n        ...prev,\n        title: videoInfo.title || prev.title\n      }))\n    }\n  }, [videoInfo])\n\n  const handleSingleVideoDownload = useCallback(async () => {\n    if (!videoInfo) {\n      return\n    }\n\n    const type = singleVideoState.activeTab\n    const selectedFormat =\n      type === 'video' ? singleVideoState.selectedVideoFormat : singleVideoState.selectedAudioFormat\n    if (!selectedFormat) {\n      return\n    }\n    const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`\n\n    const downloadItem = {\n      id,\n      url: videoInfo.webpage_url || '',\n      title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'),\n      thumbnail: videoInfo.thumbnail,\n      type,\n      status: 'pending' as const,\n      progress: { percent: 0 },\n      duration: videoInfo.duration,\n      description: videoInfo.description,\n      channel: videoInfo.extractor_key,\n      uploader: videoInfo.extractor_key,\n      createdAt: Date.now()\n    }\n\n    const selectedVideoFormat =\n      type === 'video'\n        ? (videoInfo.formats || []).find((format) => format.format_id === selectedFormat)\n        : undefined\n    const resolvedFormat =\n      type === 'video'\n        ? buildSingleVideoFormatSelector(selectedFormat, selectedVideoFormat)\n        : selectedFormat\n\n    const options = {\n      url: videoInfo.webpage_url || '',\n      type,\n      format: resolvedFormat || undefined,\n      audioFormat: type === 'video' && isMuxedVideoFormat(selectedVideoFormat) ? '' : undefined,\n      customDownloadPath: singleVideoState.customDownloadPath.trim() || undefined\n    }\n\n    try {\n      const started = await ipcServices.download.startDownload(id, options)\n      if (!started) {\n        toast.info(t('notifications.downloadAlreadyQueued'))\n        return\n      }\n      addDownload(downloadItem)\n\n      setOpen(false) // Close dialog after download starts\n    } catch (error) {\n      console.error('Failed to start download:', error)\n      toast.error(t('notifications.downloadFailed'))\n    }\n  }, [videoInfo, singleVideoState, addDownload, t])\n\n  // Reset form when dialog closes\n  useEffect(() => {\n    if (!open) {\n      // Reset single video states\n      setUrl('')\n      setActiveTab('single')\n      setSingleVideoState({\n        title: '',\n        activeTab: 'video',\n        selectedVideoFormat: '',\n        selectedAudioFormat: '',\n        customDownloadPath: '',\n        selectedContainer: undefined,\n        selectedCodec: undefined,\n        selectedFps: undefined\n      })\n\n      // Reset playlist states\n      setPlaylistUrl('')\n      setPlaylistInfo(null)\n      setPlaylistPreviewError(null)\n      setPlaylistCustomDownloadPath('')\n      setStartIndex('1')\n      setEndIndex('')\n      setSelectedEntryIds(new Set())\n    }\n  }, [open])\n\n  const handleSingleVideoStateChange = useCallback((updates: Partial<SingleVideoState>) => {\n    setSingleVideoState((prev) => ({ ...prev, ...updates }))\n  }, [])\n  const selectedSingleFormat =\n    singleVideoState.activeTab === 'video'\n      ? singleVideoState.selectedVideoFormat\n      : singleVideoState.selectedAudioFormat\n\n  return (\n    <DownloadDialogLayout\n      activeTab={activeTab}\n      addUrlPopover={\n        <AddUrlPopover\n          cancelLabel={t('download.cancel')}\n          confirmDisabled={!canConfirmAddUrl}\n          confirmLabel={t('download.fetch')}\n          invalidMessage={hasAddUrlValue && !canConfirmAddUrl ? t('errors.invalidUrl') : undefined}\n          onCancel={() => {\n            setAddUrlPopoverOpen(false)\n          }}\n          onConfirm={() => {\n            void handleConfirmAddUrl()\n          }}\n          onOpenChange={setAddUrlPopoverOpen}\n          onTriggerClick={() => {\n            void handleOpenAddUrlPopover()\n          }}\n          onValueChange={setAddUrlValue}\n          open={addUrlPopoverOpen}\n          placeholder={t('download.urlPlaceholder')}\n          title={t('download.enterUrl')}\n          triggerLabel={t('download.pasteUrlButton')}\n          value={addUrlValue}\n        />\n      }\n      footer={\n        <div className=\"flex w-full items-center justify-between gap-3\">\n          <div className=\"flex items-center gap-3\">\n            {/* Download Location - Single Video */}\n            {activeTab === 'single' && videoInfo && !loading && (\n              <div className=\"flex items-center gap-2\">\n                <div className=\"relative w-[240px]\">\n                  <Input\n                    className=\"pr-7\"\n                    placeholder={t('download.autoFolderPlaceholder')}\n                    readOnly\n                    value={singleVideoState.customDownloadPath || settings.downloadPath}\n                  />\n                  <div className=\"absolute top-1/2 right-0 -translate-y-1/2\">\n                    <Button\n                      onClick={async () => {\n                        try {\n                          const path = await ipcServices.fs.selectDirectory()\n                          if (path) {\n                            setSingleVideoState((prev) => ({\n                              ...prev,\n                              customDownloadPath: path\n                            }))\n                          }\n                        } catch (error) {\n                          console.error('Failed to select directory:', error)\n                          toast.error(t('settings.directorySelectError'))\n                        }\n                      }}\n                      size=\"icon\"\n                      variant=\"ghost\"\n                    >\n                      <FolderOpen className=\"h-4 w-4 text-muted-foreground\" />\n                    </Button>\n                  </div>\n                </div>\n\n                {singleVideoState.customDownloadPath && (\n                  <Button\n                    className=\"h-8 text-xs\"\n                    onClick={() =>\n                      setSingleVideoState((prev) => ({\n                        ...prev,\n                        customDownloadPath: ''\n                      }))\n                    }\n                    size=\"sm\"\n                    variant=\"ghost\"\n                  >\n                    {t('download.useAutoFolder')}\n                  </Button>\n                )}\n              </div>\n            )}\n\n            {/* Download Location - Playlist */}\n            {activeTab === 'playlist' && playlistInfo && !playlistPreviewLoading && (\n              <div className=\"flex items-center gap-2\">\n                <div className=\"relative w-[200px]\">\n                  <Input\n                    className=\"h-8 bg-muted/30 pr-7 text-xs\"\n                    placeholder={t('download.autoFolderPlaceholder')}\n                    readOnly\n                    value={playlistCustomDownloadPath || settings.downloadPath}\n                  />\n                  <div className=\"absolute top-1/2 right-2 -translate-y-1/2\">\n                    <FolderOpen className=\"h-3 w-3 text-muted-foreground\" />\n                  </div>\n                </div>\n                <Button\n                  className=\"h-8\"\n                  disabled={playlistBusy}\n                  onClick={handleSelectPlaylistDirectory}\n                  size=\"sm\"\n                  variant=\"outline\"\n                >\n                  {t('settings.selectPath')}\n                </Button>\n                {playlistCustomDownloadPath && (\n                  <Button\n                    className=\"h-8 text-xs\"\n                    disabled={playlistBusy}\n                    onClick={() => setPlaylistCustomDownloadPath('')}\n                    size=\"sm\"\n                    variant=\"ghost\"\n                  >\n                    {t('download.useAutoFolder')}\n                  </Button>\n                )}\n              </div>\n            )}\n\n            {/* Advanced Options - Playlist (when no playlist info) */}\n            {activeTab === 'playlist' && !playlistInfo && !playlistPreviewLoading && (\n              <div className=\"flex items-center gap-2\">\n                <Checkbox\n                  checked={advancedOptionsOpen}\n                  id={advancedOptionsId}\n                  onCheckedChange={(checked) => {\n                    setAdvancedOptionsOpen(checked === true)\n                  }}\n                />\n                <Label className=\"cursor-pointer text-xs\" htmlFor={advancedOptionsId}>\n                  {t('advancedOptions.title')}\n                </Label>\n              </div>\n            )}\n          </div>\n          <div className=\"ml-auto flex gap-2\">\n            {activeTab === 'single' ? (\n              videoInfo || loading ? (\n                !loading && videoInfo ? (\n                  <Button\n                    disabled={loading || !selectedSingleFormat}\n                    onClick={handleSingleVideoDownload}\n                  >\n                    {singleVideoState.activeTab === 'video'\n                      ? t('download.downloadVideo')\n                      : t('download.downloadAudio')}\n                  </Button>\n                ) : null\n              ) : (\n                <Button\n                  disabled={loading || !url.trim()}\n                  onClick={settings.oneClickDownload ? handleOneClickDownload : handleFetchVideo}\n                >\n                  {settings.oneClickDownload\n                    ? t('download.oneClickDownloadNow')\n                    : t('download.startDownload')}\n                </Button>\n              )\n            ) : playlistInfo && !playlistPreviewLoading ? (\n              <Button\n                disabled={playlistDownloadLoading || selectedPlaylistEntries.length === 0}\n                onClick={handleDownloadPlaylist}\n              >\n                {playlistDownloadLoading ? (\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                ) : (\n                  t('playlist.downloadCurrentRange')\n                )}\n              </Button>\n            ) : playlistPreviewLoading ? null : (\n              <Button\n                disabled={playlistBusy || !playlistUrl.trim()}\n                onClick={handlePreviewPlaylist}\n              >\n                {playlistPreviewLoading ? (\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                ) : (\n                  t('download.startDownload')\n                )}\n              </Button>\n            )}\n          </div>\n        </div>\n      }\n      lockDialogHeight={lockDialogHeight}\n      onActiveTabChange={setActiveTab}\n      oneClickDownloadEnabled={settings.oneClickDownload}\n      oneClickTooltip={t('download.oneClickDownloadTooltip')}\n      onOpenChange={setOpen}\n      onToggleOneClickDownload={() => {\n        saveSetting({ key: 'oneClickDownload', value: !settings.oneClickDownload })\n      }}\n      open={open}\n      playlistTabContent={\n        <PlaylistDownload\n          advancedOptionsOpen={advancedOptionsOpen}\n          downloadType={downloadType}\n          downloadTypeId={downloadTypeId}\n          endIndex={endIndex}\n          playlistBusy={playlistBusy}\n          playlistInfo={playlistInfo}\n          playlistPreviewError={playlistPreviewError}\n          playlistPreviewLoading={playlistPreviewLoading}\n          selectedEntryIds={selectedEntryIds}\n          selectedPlaylistEntries={selectedPlaylistEntries}\n          setDownloadType={setDownloadType}\n          setEndIndex={setEndIndex}\n          setSelectedEntryIds={setSelectedEntryIds}\n          setStartIndex={setStartIndex}\n          startIndex={startIndex}\n        />\n      }\n      playlistTabLabel={t('download.metadata.playlist')}\n      singleTabContent={\n        <SingleVideoDownload\n          error={error}\n          feedbackSourceUrl={url}\n          loading={loading}\n          onStateChange={handleSingleVideoStateChange}\n          state={singleVideoState}\n          videoInfo={videoInfo}\n          ytDlpCommand={videoInfoCommand ?? undefined}\n        />\n      }\n      singleTabLabel={t('download.singleVideo')}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/DownloadItem.tsx",
    "content": "import { Badge } from '@renderer/components/ui/badge'\nimport { Button } from '@renderer/components/ui/button'\nimport { Checkbox } from '@renderer/components/ui/checkbox'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger\n} from '@renderer/components/ui/context-menu'\nimport { Progress } from '@renderer/components/ui/progress'\nimport { RemoteImage } from '@renderer/components/ui/remote-image'\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle\n} from '@renderer/components/ui/sheet'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'\nimport type { DownloadItem as DownloadItemPayload } from '@shared/types'\nimport {\n  DOWNLOAD_FEEDBACK_ISSUE_TITLE,\n  FeedbackLinkButtons\n} from '@vidbee/ui/components/ui/feedback-link-buttons'\nimport { useAtomValue, useSetAtom } from 'jotai'\nimport {\n  AlertCircle,\n  CheckCircle2,\n  Copy,\n  File,\n  FolderOpen,\n  Loader2,\n  Play,\n  RotateCw,\n  Trash2,\n  X\n} from 'lucide-react'\nimport { type ReactNode, useEffect, useRef, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport {\n  buildFilePathCandidates,\n  normalizeSavedFileName\n} from '../../../../shared/utils/download-file'\nimport { ipcServices } from '../../lib/ipc'\nimport {\n  addDownloadAtom,\n  type DownloadRecord,\n  removeDownloadAtom,\n  removeHistoryRecordAtom\n} from '../../store/downloads'\nimport { settingsAtom } from '../../store/settings'\nimport { useAppInfo } from '../feedback/FeedbackLinks'\n\nconst tryFileOperation = async (\n  paths: string[],\n  operation: (filePath: string) => Promise<boolean>\n): Promise<boolean> => {\n  for (const filePath of paths) {\n    const success = await operation(filePath)\n    if (success) {\n      return true\n    }\n  }\n  return false\n}\n\nconst getSavedFileExtension = (fileName?: string): string | undefined => {\n  const normalized = normalizeSavedFileName(fileName)\n  if (!normalized) {\n    return undefined\n  }\n  if (!normalized.includes('.')) {\n    return undefined\n  }\n  const ext = normalized.split('.').pop()\n  return ext?.toLowerCase()\n}\n\nconst resolveDownloadExtension = (download: DownloadRecord): string => {\n  const savedExt = getSavedFileExtension(download.savedFileName)\n  if (savedExt) {\n    return savedExt\n  }\n  const selectedExt = download.selectedFormat?.ext?.toLowerCase()\n  if (selectedExt) {\n    return selectedExt\n  }\n  return download.type === 'audio' ? 'mp3' : 'mp4'\n}\n\nconst getFormatLabel = (download: DownloadRecord): string | undefined => {\n  if (download.selectedFormat?.ext) {\n    return download.selectedFormat.ext.toUpperCase()\n  }\n  const savedExt = getSavedFileExtension(download.savedFileName)\n  return savedExt ? savedExt.toUpperCase() : undefined\n}\n\nconst getQualityLabel = (download: DownloadRecord): string | undefined => {\n  const format = download.selectedFormat\n  if (!format) {\n    return undefined\n  }\n  if (format.height) {\n    return `${format.height}p${format.fps === 60 ? '60' : ''}`\n  }\n  if (format.format_note) {\n    return format.format_note\n  }\n  if (typeof format.quality === 'number') {\n    return format.quality.toString()\n  }\n  return undefined\n}\n\nconst sanitizeCodec = (codec?: string | null): string | undefined => {\n  if (!codec || codec === 'none') {\n    return undefined\n  }\n  return codec\n}\n\nconst getCodecLabel = (download: DownloadRecord): string | undefined => {\n  const format = download.selectedFormat\n  if (!format) {\n    return undefined\n  }\n  if (download.type === 'audio') {\n    return sanitizeCodec(format.acodec)\n  }\n  return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)\n}\n\ninterface DownloadItemProps {\n  download: DownloadRecord\n  isSelected?: boolean\n  onToggleSelect?: (id: string) => void\n}\n\ninterface MetadataDetail {\n  label: string\n  value: ReactNode\n}\n\nconst formatFileSize = (bytes?: number) => {\n  if (!bytes) {\n    return ''\n  }\n  const sizes = ['B', 'KB', 'MB', 'GB']\n  const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1)\n  return `${(bytes / 1024 ** order).toFixed(1)} ${sizes[order]}`\n}\n\nconst formatDuration = (seconds?: number) => {\n  if (!seconds) {\n    return ''\n  }\n  const h = Math.floor(seconds / 3600)\n  const m = Math.floor((seconds % 3600) / 60)\n  const s = Math.floor(seconds % 60)\n  if (h > 0) {\n    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`\n  }\n  return `${m}:${s.toString().padStart(2, '0')}`\n}\n\nconst formatDate = (timestamp?: number) => {\n  if (!timestamp) {\n    return ''\n  }\n  return new Date(timestamp).toLocaleString()\n}\n\nconst formatDateShort = (timestamp?: number) => {\n  if (!timestamp) {\n    return ''\n  }\n  const date = new Date(timestamp)\n  return date.toLocaleString(undefined, {\n    month: 'numeric',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n}\n\nexport function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {\n  const { t } = useTranslation()\n  const appInfo = useAppInfo()\n  const settings = useAtomValue(settingsAtom)\n  const addDownload = useSetAtom(addDownloadAtom)\n  const removeDownload = useSetAtom(removeDownloadAtom)\n  const removeHistory = useSetAtom(removeHistoryRecordAtom)\n  const isHistory = download.entryType === 'history'\n  const isSubscriptionDownload = download.origin === 'subscription'\n  const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')\n  const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt\n  const actionsContainerClass =\n    'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity'\n  const resolvedExtension = resolveDownloadExtension(download)\n  const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)\n  const selectionEnabled = isHistory && Boolean(onToggleSelect)\n\n  // Track if the file exists\n  const [fileExists, setFileExists] = useState(false)\n  const [sheetOpen, setSheetOpen] = useState(false)\n  const [activeTab, setActiveTab] = useState<'details' | 'logs'>('details')\n  const [pendingTab, setPendingTab] = useState<'details' | 'logs' | null>(null)\n  const [logAutoScroll, setLogAutoScroll] = useState(true)\n  const logContainerRef = useRef<HTMLDivElement | null>(null)\n  const lastSheetOpenRef = useRef(false)\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n\n  // Check if file exists when download data changes\n  useEffect(() => {\n    const checkFileExists = async () => {\n      if (!(download.title && download.downloadPath)) {\n        setFileExists(false)\n        return\n      }\n\n      try {\n        const formatForPath = resolvedExtension\n        const filePaths = buildFilePathCandidates(\n          download.downloadPath,\n          download.title,\n          formatForPath,\n          download.savedFileName\n        )\n        for (const filePath of filePaths) {\n          const exists = await ipcServices.fs.fileExists(filePath)\n          if (exists) {\n            setFileExists(true)\n            return\n          }\n        }\n        setFileExists(false)\n      } catch (error) {\n        console.error('Failed to check file existence:', error)\n        setFileExists(false)\n      }\n    }\n\n    checkFileExists()\n  }, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])\n\n  const handleCancel = async () => {\n    if (isHistory) {\n      return\n    }\n    try {\n      await ipcServices.download.cancelDownload(download.id)\n      removeDownload(download.id)\n    } catch (error) {\n      console.error('Failed to cancel download:', error)\n    }\n  }\n\n  const handleRetryDownload = async () => {\n    if (!download.url) {\n      toast.error(t('errors.emptyUrl'))\n      return\n    }\n    const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`\n    const customDownloadPath = download.downloadPath?.trim() || undefined\n    const formatId = download.selectedFormat?.format_id\n\n    const downloadItem: DownloadItemPayload = {\n      id,\n      url: download.url,\n      title: download.title || t('download.fetchingVideoInfo'),\n      thumbnail: download.thumbnail,\n      type: download.type,\n      status: 'pending',\n      progress: { percent: 0 },\n      duration: download.duration,\n      description: download.description,\n      channel: download.channel,\n      uploader: download.uploader,\n      viewCount: download.viewCount,\n      tags: download.tags,\n      selectedFormat: download.selectedFormat,\n      playlistId: download.playlistId,\n      playlistTitle: download.playlistTitle,\n      playlistIndex: download.playlistIndex,\n      playlistSize: download.playlistSize,\n      origin: download.origin,\n      subscriptionId: download.subscriptionId,\n      createdAt: Date.now()\n    }\n\n    try {\n      const started = await ipcServices.download.startDownload(id, {\n        url: download.url,\n        type: download.type,\n        format: formatId,\n        audioFormat: download.type === 'video' ? 'best' : undefined,\n        customDownloadPath,\n        tags: download.tags,\n        origin: download.origin,\n        subscriptionId: download.subscriptionId\n      })\n      if (!started) {\n        toast.info(t('notifications.downloadAlreadyQueued'))\n        return\n      }\n      addDownload(downloadItem)\n    } catch (error) {\n      console.error('Failed to retry download:', error)\n      toast.error(t('notifications.downloadFailed'))\n    }\n  }\n\n  const handleOpenFolder = async () => {\n    try {\n      const downloadPath = download.downloadPath || settings.downloadPath\n      const format = resolvedExtension\n      const filePaths = buildFilePathCandidates(\n        downloadPath,\n        download.title,\n        format,\n        download.savedFileName\n      )\n\n      const success = await tryFileOperation(filePaths, (filePath) =>\n        ipcServices.fs.openFileLocation(filePath)\n      )\n      if (!success) {\n        toast.error(t('notifications.openFolderFailed'))\n      }\n    } catch (error) {\n      console.error('Failed to open file location:', error)\n      toast.error(t('notifications.openFolderFailed'))\n    }\n  }\n\n  const handleOpenFile = async () => {\n    try {\n      const downloadPath = download.downloadPath || settings.downloadPath\n      if (!(downloadPath && download.title)) {\n        toast.error(t('notifications.openFileFailed'))\n        return\n      }\n      const format = resolvedExtension\n      const filePaths = buildFilePathCandidates(\n        downloadPath,\n        download.title,\n        format,\n        download.savedFileName\n      )\n\n      const success = await tryFileOperation(filePaths, (filePath) =>\n        ipcServices.fs.openFile(filePath)\n      )\n      if (!success) {\n        toast.error(t('notifications.openFileFailed'))\n      }\n    } catch (error) {\n      console.error('Failed to open file:', error)\n      toast.error(t('notifications.openFileFailed'))\n    }\n  }\n\n  const handleCopyLink = async () => {\n    if (!download.url) {\n      toast.error(t('notifications.copyFailed'))\n      return\n    }\n\n    if (!navigator.clipboard?.writeText) {\n      toast.error(t('notifications.copyFailed'))\n      return\n    }\n\n    try {\n      await navigator.clipboard.writeText(download.url)\n      toast.success(t('notifications.urlCopied'))\n    } catch (error) {\n      console.error('Failed to copy link:', error)\n      toast.error(t('notifications.copyFailed'))\n    }\n  }\n  // Check if copy to clipboard is available\n  const canCopyToClipboard = () => {\n    return Boolean(download.title && download.downloadPath && fileExists)\n  }\n\n  // need title, downloadPath, format\n  const handleCopyToClipboard = async () => {\n    if (!canCopyToClipboard()) {\n      toast.error(t('notifications.copyFailed'))\n      return\n    }\n\n    // Type guard: these values are guaranteed to exist after canCopyToClipboard() check\n    const downloadPath = download.downloadPath\n    const format = resolvedExtension\n    const title = download.title\n\n    if (!(downloadPath && title)) {\n      toast.error(t('notifications.copyFailed'))\n      return\n    }\n\n    try {\n      // Generate file path using downloadPath + title + ext\n      const filePaths = buildFilePathCandidates(downloadPath, title, format, download.savedFileName)\n\n      const success = await tryFileOperation(filePaths, (filePath) =>\n        ipcServices.fs.copyFileToClipboard(filePath)\n      )\n      if (!success) {\n        toast.error(t('notifications.copyFailed'))\n        return\n      }\n      toast.success(t('notifications.videoCopied'))\n    } catch (error) {\n      console.error('Failed to copy file to clipboard:', error)\n      toast.error(t('notifications.copyFailed'))\n    }\n  }\n\n  const handleDeleteFile = async () => {\n    try {\n      const downloadPath = download.downloadPath || settings.downloadPath\n      if (!(downloadPath && download.title)) {\n        toast.error(t('notifications.removeFailed'))\n        return\n      }\n\n      const format = resolvedExtension\n      const filePaths = buildFilePathCandidates(\n        downloadPath,\n        download.title,\n        format,\n        download.savedFileName\n      )\n\n      const deleted = await tryFileOperation(filePaths, (filePath) =>\n        ipcServices.fs.deleteFile(filePath)\n      )\n\n      if (!deleted) {\n        toast.error(t('notifications.removeFailed'))\n        return\n      }\n\n      setFileExists(false)\n      if (isHistory) {\n        await ipcServices.history.removeHistoryItem(download.id)\n        removeHistory(download.id)\n      } else {\n        removeDownload(download.id)\n      }\n    } catch (error) {\n      console.error('Failed to delete file:', error)\n      toast.error(t('notifications.removeFailed'))\n    }\n  }\n\n  const handleDeleteRecord = async () => {\n    try {\n      if (isHistory) {\n        await ipcServices.history.removeHistoryItem(download.id)\n        removeHistory(download.id)\n      } else {\n        removeDownload(download.id)\n      }\n    } catch (error) {\n      console.error('Failed to remove record:', error)\n      toast.error(t('notifications.removeFailed'))\n    }\n  }\n\n  const getStatusIcon = () => {\n    switch (download.status) {\n      case 'completed':\n        return <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n      case 'error':\n        return <AlertCircle className=\"h-4 w-4 text-destructive\" />\n      case 'downloading':\n      case 'processing':\n        return <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n      case 'pending':\n        return <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n      case 'cancelled':\n        return <X className=\"h-4 w-4 text-muted-foreground\" />\n      default:\n        return null\n    }\n  }\n\n  const getStatusText = () => {\n    switch (download.status) {\n      case 'completed':\n        return t('download.completed')\n      case 'error':\n        return t('download.error')\n      case 'downloading':\n        return t('download.downloading')\n      case 'processing':\n        return t('download.processing')\n      case 'pending':\n        return t('download.downloadPending')\n      case 'cancelled':\n        return t('download.cancelled')\n      default:\n        return ''\n    }\n  }\n\n  const statusIcon = getStatusIcon()\n  const statusText = getStatusText()\n  const progressInfo = download.progress\n  const isInProgressStatus =\n    download.status === 'downloading' ||\n    download.status === 'processing' ||\n    download.status === 'pending'\n  const isCompletedStatus = download.status === 'completed'\n  const canRetry = download.status === 'error'\n  const showCopyAction = download.status === 'completed' && fileExists\n  const showOpenFolderAction = Boolean(\n    download.title && (download.downloadPath || settings.downloadPath)\n  )\n  const showInlineProgress = Boolean(\n    progressInfo && download.status !== 'completed' && download.status !== 'error'\n  )\n  const canCopyLink = Boolean(download.url)\n  const canOpenFile = isCompletedStatus && fileExists\n  const canDeleteFile = isCompletedStatus && fileExists\n  const sourceDisplay =\n    download.uploader && download.channel && download.uploader !== download.channel\n      ? `${download.uploader} • ${download.channel}`\n      : download.uploader || download.channel || ''\n\n  const metadataDetails: MetadataDetail[] = []\n\n  if (timestamp) {\n    metadataDetails.push({\n      label: t('history.date'),\n      value: formatDate(timestamp)\n    })\n  }\n\n  if (sourceDisplay) {\n    metadataDetails.push({\n      label: t('download.metadata.source'),\n      value: sourceDisplay\n    })\n  }\n\n  if (download.playlistId) {\n    metadataDetails.push({\n      label: t('download.metadata.playlist'),\n      value: (\n        <span>\n          {download.playlistTitle || t('playlist.untitled')}\n          {download.playlistIndex !== undefined && download.playlistSize !== undefined ? (\n            <span className=\"text-muted-foreground/80\">\n              {` ${t('playlist.positionLabel', {\n                index: download.playlistIndex,\n                total: download.playlistSize\n              })}`}\n            </span>\n          ) : null}\n        </span>\n      )\n    })\n  }\n\n  if (download.duration) {\n    metadataDetails.push({\n      label: t('history.duration'),\n      value: formatDuration(download.duration)\n    })\n  }\n\n  const selectedFormatSize =\n    download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx\n  const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined\n\n  const formatLabelValue = getFormatLabel(download)\n\n  if (formatLabelValue) {\n    metadataDetails.push({\n      label: t('download.metadata.format'),\n      value: formatLabelValue\n    })\n  }\n\n  const qualityLabel = getQualityLabel(download)\n\n  if (qualityLabel) {\n    metadataDetails.push({\n      label: t('download.metadata.quality'),\n      value: qualityLabel\n    })\n  }\n\n  if (inlineFileSize) {\n    metadataDetails.push({\n      label: t('history.fileSize'),\n      value: inlineFileSize\n    })\n  }\n\n  const codecValue = getCodecLabel(download)\n  if (codecValue) {\n    metadataDetails.push({\n      label: t('download.metadata.codec'),\n      value: codecValue\n    })\n  }\n\n  if (normalizedSavedFileName || download.savedFileName) {\n    metadataDetails.push({\n      label: t('download.metadata.savedFile'),\n      value: normalizedSavedFileName ?? download.savedFileName\n    })\n  }\n\n  if (download.url) {\n    metadataDetails.push({\n      label: t('download.metadata.url'),\n      value: (\n        <a\n          className=\"wrap-break-word relative z-20 text-primary hover:underline\"\n          href={download.url}\n          rel=\"noopener noreferrer\"\n          target=\"_blank\"\n        >\n          {download.url}\n        </a>\n      )\n    })\n  }\n\n  // Additional metadata fields\n  if (download.description) {\n    metadataDetails.push({\n      label: t('download.metadata.description'),\n      value: <span className=\"wrap-break-word\">{download.description}</span>\n    })\n  }\n\n  if (download.viewCount !== undefined && download.viewCount !== null) {\n    metadataDetails.push({\n      label: t('download.metadata.views'),\n      value: download.viewCount.toLocaleString()\n    })\n  }\n\n  if (download.tags && download.tags.length > 0) {\n    metadataDetails.push({\n      label: t('download.metadata.tags'),\n      value: (\n        <div className=\"flex flex-wrap gap-1\">\n          {download.tags.map((tag) => (\n            <Badge className=\"px-1.5 py-0.5 text-[10px]\" key={tag} variant=\"secondary\">\n              {tag}\n            </Badge>\n          ))}\n        </div>\n      )\n    })\n  }\n\n  if (download.downloadPath) {\n    metadataDetails.push({\n      label: t('download.metadata.downloadPath'),\n      value: <span className=\"wrap-break-word font-mono text-xs\">{download.downloadPath}</span>\n    })\n  }\n\n  // Timestamps\n  if (download.createdAt && download.createdAt !== timestamp) {\n    metadataDetails.push({\n      label: t('download.metadata.createdAt'),\n      value: formatDate(download.createdAt)\n    })\n  }\n\n  if (download.startedAt) {\n    metadataDetails.push({\n      label: t('download.metadata.startedAt'),\n      value: formatDate(download.startedAt)\n    })\n  }\n\n  if (download.completedAt && download.completedAt !== timestamp) {\n    metadataDetails.push({\n      label: t('download.metadata.completedAt'),\n      value: formatDate(download.completedAt)\n    })\n  }\n\n  // Speed\n  if (download.speed) {\n    metadataDetails.push({\n      label: t('download.metadata.speed'),\n      value: download.speed\n    })\n  }\n\n  // File size (if different from inlineFileSize)\n  if (download.fileSize && download.fileSize !== selectedFormatSize) {\n    metadataDetails.push({\n      label: t('download.metadata.fileSize'),\n      value: formatFileSize(download.fileSize)\n    })\n  }\n\n  // Selected format details\n  if (download.selectedFormat) {\n    if (download.selectedFormat.width) {\n      metadataDetails.push({\n        label: t('download.metadata.width'),\n        value: `${download.selectedFormat.width}px`\n      })\n    }\n\n    if (download.selectedFormat.height && !qualityLabel) {\n      metadataDetails.push({\n        label: t('download.metadata.height'),\n        value: `${download.selectedFormat.height}px`\n      })\n    }\n\n    if (download.selectedFormat.fps) {\n      metadataDetails.push({\n        label: t('download.metadata.fps'),\n        value: `${download.selectedFormat.fps}`\n      })\n    }\n\n    if (download.selectedFormat.vcodec) {\n      metadataDetails.push({\n        label: t('download.metadata.videoCodec'),\n        value: download.selectedFormat.vcodec\n      })\n    }\n\n    if (download.selectedFormat.acodec) {\n      metadataDetails.push({\n        label: t('download.metadata.audioCodec'),\n        value: download.selectedFormat.acodec\n      })\n    }\n\n    if (download.selectedFormat.format_note) {\n      metadataDetails.push({\n        label: t('download.metadata.formatNote'),\n        value: download.selectedFormat.format_note\n      })\n    }\n\n    if (download.selectedFormat.protocol) {\n      metadataDetails.push({\n        label: t('download.metadata.protocol'),\n        value: download.selectedFormat.protocol.toUpperCase()\n      })\n    }\n  }\n\n  if (isSubscriptionDownload) {\n    metadataDetails.push({\n      label: t('download.metadata.subscription'),\n      value: subscriptionLabel\n    })\n  }\n\n  const hasMetadataDetails = metadataDetails.length > 0\n  const logContent = download.ytDlpLog ?? ''\n  const hasLogContent = logContent.trim().length > 0\n  const ytDlpCommand = download.ytDlpCommand?.trim()\n  const hasYtDlpCommand = Boolean(ytDlpCommand)\n  const canShowSheet = hasMetadataDetails || isInProgressStatus || hasLogContent\n\n  const isSelectedHistory = selectionEnabled && isSelected\n\n  useEffect(() => {\n    const wasOpen = lastSheetOpenRef.current\n    lastSheetOpenRef.current = sheetOpen\n    if (!sheetOpen || wasOpen) {\n      return\n    }\n    const defaultTab = hasMetadataDetails ? 'details' : 'logs'\n    setActiveTab(pendingTab ?? defaultTab)\n    setPendingTab(null)\n    setLogAutoScroll(true)\n  }, [hasMetadataDetails, pendingTab, sheetOpen])\n\n  useEffect(() => {\n    if (!(sheetOpen && logAutoScroll && logContent)) {\n      return\n    }\n    const container = logContainerRef.current\n    if (container) {\n      container.scrollTop = container.scrollHeight\n    }\n  }, [logAutoScroll, logContent, sheetOpen])\n\n  const handleLogScroll = () => {\n    const container = logContainerRef.current\n    if (!container) {\n      return\n    }\n    const { scrollTop, scrollHeight, clientHeight } = container\n    const isNearBottom = scrollHeight - scrollTop - clientHeight < 24\n    setLogAutoScroll(isNearBottom)\n  }\n\n  const openLogsSheet = () => {\n    if (!canShowSheet) {\n      return\n    }\n    setPendingTab(sheetOpen ? null : 'logs')\n    setActiveTab('logs')\n    setLogAutoScroll(true)\n    setSheetOpen(true)\n  }\n\n  return (\n    <ContextMenu onOpenChange={setIsContextMenuOpen}>\n      <ContextMenuTrigger asChild>\n        <div\n          className={`group relative w-full max-w-full overflow-hidden px-6 py-2 transition-colors ${\n            isSelectedHistory || isContextMenuOpen ? 'bg-primary/10' : ''\n          }`}\n        >\n          <div\n            className={`flex w-full flex-col gap-2 sm:flex-row sm:gap-3 ${\n              selectionEnabled ? 'cursor-pointer' : ''\n            }`}\n            {...(selectionEnabled\n              ? {\n                  onClick: () => onToggleSelect?.(download.id),\n                  onKeyDown: (e: React.KeyboardEvent) => {\n                    if (e.key === 'Enter' || e.key === ' ') {\n                      e.preventDefault()\n                      onToggleSelect?.(download.id)\n                    }\n                  },\n                  role: 'button',\n                  tabIndex: 0,\n                  'aria-label': t('history.selectItem')\n                }\n              : {})}\n          >\n            {/* Thumbnail */}\n            <div className=\"pointer-events-none relative z-20 aspect-video h-14 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60\">\n              {selectionEnabled && (\n                <div\n                  className={`pointer-events-auto absolute top-1 left-1 z-30 rounded-md transition ${\n                    isSelected\n                      ? 'opacity-100'\n                      : 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100'\n                  }`}\n                >\n                  <Checkbox\n                    aria-label={t('history.selectItem')}\n                    checked={Boolean(isSelected)}\n                    onCheckedChange={() => onToggleSelect?.(download.id)}\n                    onClick={(event) => event.stopPropagation()}\n                  />\n                </div>\n              )}\n              <RemoteImage\n                alt={download.title}\n                className=\"h-full w-full object-cover\"\n                fallbackIcon={<Play className=\"h-4 w-4\" />}\n                src={download.thumbnail}\n              />\n            </div>\n\n            {/* Content */}\n            <div className=\"pointer-events-none min-w-0 max-w-full flex-1 overflow-hidden\">\n              <div className=\"flex h-14 w-full flex-col items-center justify-center gap-1.5 sm:flex-row sm:justify-between sm:gap-2\">\n                <div className=\"min-w-0 max-w-full flex-1 items-center space-y-1.5 overflow-hidden\">\n                  <div className=\"flex w-full min-w-0 flex-wrap items-center gap-1.5 overflow-hidden\">\n                    <p className=\"wrap-break-word line-clamp-1 flex-1 font-medium text-sm\">\n                      {download.title}\n                    </p>\n                    {download.type === 'audio' && (\n                      <Badge className=\"shrink-0 px-1.5 py-0.5 text-[10px]\" variant=\"secondary\">\n                        {t('download.audio')}\n                      </Badge>\n                    )}\n                    {isSubscriptionDownload && (\n                      <Badge className=\"shrink-0 px-1.5 py-0.5 text-[10px]\" variant=\"secondary\">\n                        {t('subscriptions.labels.subscription')}\n                      </Badge>\n                    )}\n                  </div>\n                  <div className=\"flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground\">\n                    {/* Status */}\n                    {statusIcon && (\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <div className=\"flex shrink-0 items-center\">{statusIcon}</div>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          <p>{statusText}</p>\n                        </TooltipContent>\n                      </Tooltip>\n                    )}\n                    {showInlineProgress && (\n                      <div className=\"flex min-w-0 items-center gap-2\">\n                        <span className=\"shrink-0 font-medium\">\n                          {(progressInfo?.percent ?? 0).toFixed(1)}%\n                        </span>\n                        {progressInfo?.downloaded && progressInfo?.total && (\n                          <span className=\"max-w-[120px] truncate\">\n                            {progressInfo.downloaded} / {progressInfo.total}\n                          </span>\n                        )}\n                        {progressInfo?.currentSpeed && (\n                          <span className=\"max-w-[80px] truncate\">{progressInfo.currentSpeed}</span>\n                        )}\n                        {progressInfo?.eta && (\n                          <span className=\"max-w-[80px] truncate\">ETA: {progressInfo.eta}</span>\n                        )}\n                      </div>\n                    )}\n                    {/* Timestamp */}\n                    {timestamp && (\n                      <span className=\"shrink-0 truncate\">{formatDateShort(timestamp)}</span>\n                    )}\n                    {/* Quality */}\n                    {qualityLabel && (\n                      <>\n                        {(statusIcon || timestamp) && (\n                          <span className=\"shrink-0 text-muted-foreground/60\">•</span>\n                        )}\n                        <span className=\"shrink-0\">{qualityLabel}</span>\n                      </>\n                    )}\n                    {/* File size */}\n                    {inlineFileSize && (\n                      <>\n                        {(statusIcon || timestamp || qualityLabel) && (\n                          <span className=\"shrink-0 text-muted-foreground/60\">•</span>\n                        )}\n                        <span className=\"shrink-0\">{inlineFileSize}</span>\n                      </>\n                    )}\n                  </div>\n                </div>\n                <div className={`${actionsContainerClass} pointer-events-auto`}>\n                  {canRetry && (\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <Button\n                          className=\"h-8 w-8 shrink-0 rounded-full\"\n                          onClick={(e) => {\n                            e.stopPropagation()\n                            void handleRetryDownload()\n                          }}\n                          size=\"icon\"\n                          variant=\"ghost\"\n                        >\n                          <RotateCw className=\"h-4 w-4\" />\n                        </Button>\n                      </TooltipTrigger>\n                      <TooltipContent>\n                        <p>{t('download.retry')}</p>\n                      </TooltipContent>\n                    </Tooltip>\n                  )}\n                  {isHistory ? (\n                    <>\n                      {showCopyAction && (\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              className=\"h-8 w-8 shrink-0 rounded-full\"\n                              disabled={!canCopyToClipboard()}\n                              onClick={(e) => {\n                                e.stopPropagation()\n                                handleCopyToClipboard()\n                              }}\n                              size=\"icon\"\n                              variant=\"ghost\"\n                            >\n                              <Copy className=\"h-4 w-4\" />\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            <p>{t('history.copyToClipboard')}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      )}\n                      {showOpenFolderAction && (\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              className=\"h-8 w-8 shrink-0 rounded-full\"\n                              onClick={(e) => {\n                                e.stopPropagation()\n                                handleOpenFolder()\n                              }}\n                              size=\"icon\"\n                              variant=\"ghost\"\n                            >\n                              <FolderOpen className=\"h-4 w-4\" />\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            <p>{t('history.openFolder')}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      )}\n                    </>\n                  ) : (\n                    <>\n                      {showCopyAction && (\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              className=\"h-8 w-8 shrink-0 rounded-full\"\n                              disabled={!canCopyToClipboard()}\n                              onClick={(e) => {\n                                e.stopPropagation()\n                                handleCopyToClipboard()\n                              }}\n                              size=\"icon\"\n                              variant=\"ghost\"\n                            >\n                              <Copy className=\"h-4 w-4\" />\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            <p>{t('history.copyToClipboard')}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      )}\n                      {showOpenFolderAction && (\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              className=\"h-8 w-8 shrink-0 rounded-full\"\n                              onClick={(e) => {\n                                e.stopPropagation()\n                                handleOpenFolder()\n                              }}\n                              size=\"icon\"\n                              variant=\"ghost\"\n                            >\n                              <FolderOpen className=\"h-4 w-4\" />\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            <p>{t('history.openFolder')}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      )}\n                      {(download.status === 'downloading' ||\n                        download.status === 'pending' ||\n                        download.status === 'processing') && (\n                        <Button\n                          className=\"h-8 w-8 shrink-0 rounded-full\"\n                          onClick={(e) => {\n                            e.stopPropagation()\n                            handleCancel()\n                          }}\n                          size=\"icon\"\n                          variant=\"ghost\"\n                        >\n                          <X className=\"h-4 w-4\" />\n                        </Button>\n                      )}\n                    </>\n                  )}\n                </div>\n              </div>\n\n              {/* Progress */}\n              {download.progress &&\n                download.status !== 'completed' &&\n                download.status !== 'error' && (\n                  <div className=\"w-full overflow-hidden bg-background/60\">\n                    <Progress className=\"h-1 w-full\" value={download.progress.percent} />\n                  </div>\n                )}\n\n              {/* Error message */}\n              {download.status === 'error' && download.error && (\n                <div className=\"flex flex-col gap-1.5\">\n                  <p className=\"line-clamp-2 w-full overflow-hidden text-destructive text-xs\">\n                    {download.error}\n                  </p>\n                  <div className=\"pointer-events-auto flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs\">\n                    <span className=\"shrink-0 font-medium text-muted-foreground text-xs\">\n                      {t('download.feedback.title')}:\n                    </span>\n                    {canShowSheet && (\n                      <Button\n                        className=\"h-6 px-1.5 text-[10px]\"\n                        onClick={(event) => {\n                          event.stopPropagation()\n                          openLogsSheet()\n                        }}\n                        size=\"sm\"\n                        variant=\"outline\"\n                      >\n                        {t('download.viewLogs')}\n                      </Button>\n                    )}\n                    <FeedbackLinkButtons\n                      appInfo={appInfo}\n                      buttonClassName=\"h-6 gap-1 px-1.5 text-[10px]\"\n                      buttonSize=\"sm\"\n                      buttonVariant=\"outline\"\n                      error={download.error}\n                      iconClassName=\"h-3 w-3\"\n                      includeAppInfo\n                      issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}\n                      onLinkClick={(event) => event.stopPropagation()}\n                      showGroupSeparator={canShowSheet}\n                      sourceUrl={download.url}\n                      wrapperClassName=\"flex flex-wrap items-center gap-1.5\"\n                      ytDlpCommand={download.ytDlpCommand}\n                    />\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Video Details Sheet */}\n          {canShowSheet && (\n            <Sheet onOpenChange={setSheetOpen} open={sheetOpen}>\n              <SheetContent className=\"flex w-full flex-col p-0 sm:max-w-lg\" side=\"right\">\n                <div className=\"flex h-full flex-col overflow-hidden\">\n                  <SheetHeader className=\"shrink-0 border-b px-6 pt-6 pb-4\">\n                    <SheetTitle className=\"line-clamp-2\">{download.title}</SheetTitle>\n                    <SheetDescription>{t('download.videoInfo')}</SheetDescription>\n                  </SheetHeader>\n                  <Tabs\n                    className=\"flex-1 overflow-hidden\"\n                    onValueChange={(value) => setActiveTab(value as 'details' | 'logs')}\n                    value={activeTab}\n                  >\n                    <div className=\"px-6 pt-4\">\n                      <TabsList>\n                        <TabsTrigger disabled={!hasMetadataDetails} value=\"details\">\n                          {t('download.detailsTab')}\n                        </TabsTrigger>\n                        <TabsTrigger value=\"logs\">{t('download.logsTab')}</TabsTrigger>\n                      </TabsList>\n                    </div>\n                    <TabsContent className=\"flex-1 overflow-y-auto px-6 py-4\" value=\"details\">\n                      <div className=\"space-y-4\">\n                        {metadataDetails.map((item) => (\n                          <div className=\"flex flex-col gap-1\" key={item.label}>\n                            <span className=\"font-medium text-muted-foreground text-sm\">\n                              {item.label}\n                            </span>\n                            <div className=\"break-words text-foreground text-sm\">{item.value}</div>\n                          </div>\n                        ))}\n                      </div>\n                    </TabsContent>\n                    <TabsContent\n                      className=\"flex flex-1 flex-col gap-3 overflow-hidden px-6 py-4\"\n                      value=\"logs\"\n                    >\n                      <div className=\"flex items-center justify-between text-muted-foreground text-xs\">\n                        <span>\n                          {isInProgressStatus\n                            ? t('download.logs.live')\n                            : t('download.logs.history')}\n                        </span>\n                        {logAutoScroll ? null : (\n                          <span className=\"text-muted-foreground/70\">\n                            {t('download.logs.scrollPaused')}\n                          </span>\n                        )}\n                      </div>\n                      {hasYtDlpCommand && (\n                        <div className=\"rounded-md border border-border/60 bg-muted/20 p-2\">\n                          <div className=\"font-medium text-[11px] text-muted-foreground\">\n                            {t('download.logs.command')}\n                          </div>\n                          <div className=\"mt-1 whitespace-pre-wrap break-words font-mono text-xs\">\n                            {ytDlpCommand}\n                          </div>\n                        </div>\n                      )}\n                      <div className=\"min-h-0 flex-1 rounded-md border border-border/60 bg-muted/30\">\n                        <div\n                          className=\"h-full overflow-y-auto whitespace-pre-wrap break-words p-3 font-mono text-xs leading-relaxed\"\n                          onScroll={handleLogScroll}\n                          ref={logContainerRef}\n                        >\n                          {hasLogContent ? logContent : t('download.logs.empty')}\n                        </div>\n                      </div>\n                    </TabsContent>\n                  </Tabs>\n                </div>\n              </SheetContent>\n            </Sheet>\n          )}\n        </div>\n      </ContextMenuTrigger>\n      <ContextMenuContent>\n        {isInProgressStatus ? (\n          <>\n            {canRetry && (\n              <ContextMenuItem onClick={handleRetryDownload}>\n                <RotateCw className=\"h-4 w-4\" />\n                {t('download.retry')}\n              </ContextMenuItem>\n            )}\n            <ContextMenuItem disabled={!showOpenFolderAction} onClick={handleOpenFolder}>\n              <FolderOpen className=\"h-4 w-4\" />\n              {t('history.openFileLocation')}\n            </ContextMenuItem>\n            <ContextMenuItem disabled={!canCopyLink} onClick={handleCopyLink}>\n              <span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n              {t('history.copyUrl')}\n            </ContextMenuItem>\n            {canShowSheet && (\n              <ContextMenuItem onClick={() => setSheetOpen(true)}>\n                <span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n                {t('download.showDetails')}\n              </ContextMenuItem>\n            )}\n            <ContextMenuSeparator />\n            <ContextMenuItem onClick={handleCancel}>\n              <X className=\"h-4 w-4\" />\n              {t('download.cancel')}\n            </ContextMenuItem>\n          </>\n        ) : (\n          <>\n            {isCompletedStatus && (\n              <ContextMenuItem disabled={!showCopyAction} onClick={handleCopyToClipboard}>\n                <Copy className=\"h-4 w-4\" />\n                {t('history.copyToClipboard')}\n              </ContextMenuItem>\n            )}\n            {canRetry && (\n              <ContextMenuItem onClick={handleRetryDownload}>\n                <RotateCw className=\"h-4 w-4\" />\n                {t('download.retry')}\n              </ContextMenuItem>\n            )}\n            <ContextMenuItem disabled={!canOpenFile} onClick={handleOpenFile}>\n              <File className=\"h-4 w-4\" />\n              {t('history.openFile')}\n            </ContextMenuItem>\n            <ContextMenuSeparator />\n            <ContextMenuItem disabled={!showOpenFolderAction} onClick={handleOpenFolder}>\n              <FolderOpen className=\"h-4 w-4\" />\n              {t('history.openFileLocation')}\n            </ContextMenuItem>\n            <ContextMenuItem disabled={!canCopyLink} onClick={handleCopyLink}>\n              <span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n              {t('history.copyUrl')}\n            </ContextMenuItem>\n            {canShowSheet && (\n              <ContextMenuItem onClick={() => setSheetOpen(true)}>\n                <span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n                {t('download.showDetails')}\n              </ContextMenuItem>\n            )}\n            <ContextMenuSeparator />\n            <ContextMenuItem disabled={!canDeleteFile} onClick={handleDeleteFile}>\n              <Trash2 className=\"h-4 w-4\" />\n              {t('history.deleteFile')}\n            </ContextMenuItem>\n            <ContextMenuItem onClick={handleDeleteRecord}>\n              <span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n              {t('history.deleteRecord')}\n            </ContextMenuItem>\n          </>\n        )}\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx",
    "content": "import { Checkbox } from '@renderer/components/ui/checkbox'\nimport { Input } from '@renderer/components/ui/input'\nimport { Label } from '@renderer/components/ui/label'\nimport { ScrollArea } from '@renderer/components/ui/scroll-area'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@renderer/components/ui/select'\nimport { cn } from '@renderer/lib/utils'\nimport type { PlaylistInfo } from '@shared/types'\nimport { AlertCircle, List, Loader2 } from 'lucide-react'\nimport type { Dispatch, SetStateAction } from 'react'\nimport { useTranslation } from 'react-i18next'\n\ninterface PlaylistDownloadProps {\n  playlistPreviewLoading: boolean\n  playlistPreviewError: string | null\n  playlistInfo: PlaylistInfo | null\n  playlistBusy: boolean\n  selectedPlaylistEntries: PlaylistInfo['entries']\n  selectedEntryIds: Set<string>\n  downloadType: 'video' | 'audio'\n  downloadTypeId: string\n  startIndex: string\n  endIndex: string\n  advancedOptionsOpen: boolean\n  setSelectedEntryIds: Dispatch<SetStateAction<Set<string>>>\n  setStartIndex: Dispatch<SetStateAction<string>>\n  setEndIndex: Dispatch<SetStateAction<string>>\n  setDownloadType: Dispatch<SetStateAction<'video' | 'audio'>>\n}\n\nexport function PlaylistDownload({\n  playlistPreviewLoading,\n  playlistPreviewError,\n  playlistInfo,\n  playlistBusy,\n  selectedPlaylistEntries,\n  selectedEntryIds,\n  downloadType,\n  downloadTypeId,\n  startIndex,\n  endIndex,\n  advancedOptionsOpen,\n  setSelectedEntryIds,\n  setStartIndex,\n  setEndIndex,\n  setDownloadType\n}: PlaylistDownloadProps) {\n  const { t } = useTranslation()\n\n  return (\n    <>\n      {playlistPreviewLoading && !playlistPreviewError && (\n        <div className=\"flex min-h-[200px] flex-1 flex-col items-center justify-center gap-3\">\n          <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n          <p className=\"text-muted-foreground text-sm\">{t('playlist.fetchingInfo')}</p>\n        </div>\n      )}\n\n      {playlistPreviewError && (\n        <div className=\"mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-3\">\n          <div className=\"flex items-start gap-2\">\n            <AlertCircle className=\"mt-0.5 h-4 w-4 shrink-0 text-destructive\" />\n            <div className=\"flex-1 space-y-1\">\n              <p className=\"font-medium text-destructive text-sm\">{t('playlist.previewFailed')}</p>\n              <p className=\"text-muted-foreground/80 text-xs\">{playlistPreviewError}</p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {playlistInfo && !playlistPreviewLoading && (\n        <div className=\"flex min-h-0 flex-1 flex-col gap-3\">\n          <div className=\"shrink-0 space-y-0.5\">\n            <h3 className=\"line-clamp-1 font-bold text-sm leading-tight\">{playlistInfo.title}</h3>\n            <div className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n              <List className=\"h-3 w-3\" />\n              <span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>\n              {selectedPlaylistEntries.length !== playlistInfo.entryCount && (\n                <>\n                  <span>•</span>\n                  <span className=\"font-medium text-primary\">\n                    {t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })}\n                  </span>\n                </>\n              )}\n            </div>\n          </div>\n\n          <ScrollArea className=\"min-h-0 w-full flex-1 rounded-md border\">\n            <div className=\"p-1\">\n              {playlistInfo.entries.map((entry) => {\n                const isSelected = selectedEntryIds.has(entry.id)\n                const isInRange =\n                  selectedEntryIds.size === 0 &&\n                  selectedPlaylistEntries.some((playlistEntry) => playlistEntry.id === entry.id)\n\n                const handleToggle = () => {\n                  setSelectedEntryIds((prev) => {\n                    const next = new Set(prev)\n                    if (next.has(entry.id)) {\n                      next.delete(entry.id)\n                    } else {\n                      next.add(entry.id)\n                    }\n                    return next\n                  })\n                  if (selectedEntryIds.size === 0) {\n                    setStartIndex('1')\n                    setEndIndex('')\n                  }\n                }\n\n                return (\n                  <button\n                    aria-label={t('playlist.selectEntry', { index: entry.index })}\n                    className={cn(\n                      'flex w-full cursor-pointer items-center gap-3 rounded px-2.5 py-1.5 text-left transition-colors',\n                      isSelected || isInRange ? 'bg-primary/10' : 'hover:bg-muted/50'\n                    )}\n                    key={entry.id}\n                    onClick={handleToggle}\n                    onKeyDown={(event) => {\n                      if (event.key === 'Enter' || event.key === ' ') {\n                        event.preventDefault()\n                        handleToggle()\n                      }\n                    }}\n                    type=\"button\"\n                  >\n                    <Checkbox\n                      checked={isSelected || isInRange}\n                      className=\"shrink-0\"\n                      onCheckedChange={(checked) => {\n                        setSelectedEntryIds((prev) => {\n                          const next = new Set(prev)\n                          if (checked) {\n                            next.add(entry.id)\n                          } else {\n                            next.delete(entry.id)\n                          }\n                          return next\n                        })\n                        if (selectedEntryIds.size === 0) {\n                          setStartIndex('1')\n                          setEndIndex('')\n                        }\n                      }}\n                      onClick={(event) => event.stopPropagation()}\n                    />\n                    <div className=\"w-8 shrink-0 font-medium text-muted-foreground/70 text-xs tabular-nums\">\n                      #{entry.index}\n                    </div>\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"line-clamp-1 font-medium text-xs leading-tight\">\n                        {entry.title || t('download.fetchingVideoInfo')}\n                      </p>\n                    </div>\n                  </button>\n                )\n              })}\n            </div>\n          </ScrollArea>\n\n          <div\n            aria-hidden={!advancedOptionsOpen}\n            className={cn(\n              'grid shrink-0 overflow-hidden transition-all duration-300 ease-out',\n              advancedOptionsOpen ? 'grid-rows-[1fr] py-3 opacity-100' : 'grid-rows-[0fr] opacity-0'\n            )}\n            data-state={advancedOptionsOpen ? 'open' : 'closed'}\n          >\n            <div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>\n              <div className=\"w-full border-t pt-3\">\n                <div className=\"space-y-3\">\n                  <div className=\"grid grid-cols-2 gap-3\">\n                    <div className=\"space-y-1.5\">\n                      <Label\n                        className=\"font-medium text-muted-foreground text-xs\"\n                        htmlFor={downloadTypeId}\n                      >\n                        {t('playlist.downloadType')}\n                      </Label>\n                      <Select\n                        disabled={playlistBusy}\n                        onValueChange={(value) => setDownloadType(value as 'video' | 'audio')}\n                        value={downloadType}\n                      >\n                        <SelectTrigger className=\"h-8 text-xs\" id={downloadTypeId}>\n                          <SelectValue />\n                        </SelectTrigger>\n                        <SelectContent>\n                          <SelectItem className=\"text-xs\" value=\"video\">\n                            {t('download.video')}\n                          </SelectItem>\n                          <SelectItem className=\"text-xs\" value=\"audio\">\n                            {t('download.audio')}\n                          </SelectItem>\n                        </SelectContent>\n                      </Select>\n                    </div>\n\n                    <div className=\"space-y-1.5\">\n                      <Label className=\"font-medium text-muted-foreground text-xs\">\n                        {t('playlist.range')}\n                      </Label>\n                      <div className=\"flex items-center gap-2\">\n                        <Input\n                          className=\"h-8 text-center text-xs\"\n                          disabled={playlistBusy}\n                          onChange={(event) => {\n                            setStartIndex(event.target.value)\n                            if (selectedEntryIds.size > 0) {\n                              setSelectedEntryIds(new Set())\n                            }\n                          }}\n                          placeholder=\"1\"\n                          value={startIndex}\n                        />\n                        <span className=\"text-muted-foreground text-xs\">-</span>\n                        <Input\n                          className=\"h-8 text-center text-xs\"\n                          disabled={playlistBusy}\n                          onChange={(event) => {\n                            setEndIndex(event.target.value)\n                            if (selectedEntryIds.size > 0) {\n                              setSelectedEntryIds(new Set())\n                            }\n                          }}\n                          placeholder={playlistInfo?.entryCount.toString() || 'End'}\n                          value={endIndex}\n                        />\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx",
    "content": "import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { DownloadRecord } from '../../store/downloads'\nimport { Button } from '../ui/button'\nimport { Progress } from '../ui/progress'\nimport { DownloadItem } from './DownloadItem'\n\ninterface PlaylistDownloadGroupProps {\n  groupId: string\n  title: string\n  records: DownloadRecord[]\n  totalCount: number\n  selectedIds?: Set<string>\n  onToggleSelect?: (id: string) => void\n  onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void\n}\n\nconst STORAGE_KEY_PREFIX = 'playlist_expanded_'\n\nconst getStorageKey = (groupId: string): string => {\n  return `${STORAGE_KEY_PREFIX}${groupId}`\n}\n\nconst loadExpandedState = (groupId: string): boolean => {\n  try {\n    const stored = localStorage.getItem(getStorageKey(groupId))\n    return stored === 'true'\n  } catch (error) {\n    console.error('Failed to load playlist expanded state:', error)\n    return false\n  }\n}\n\nconst saveExpandedState = (groupId: string, isExpanded: boolean): void => {\n  try {\n    localStorage.setItem(getStorageKey(groupId), String(isExpanded))\n  } catch (error) {\n    console.error('Failed to save playlist expanded state:', error)\n  }\n}\n\nexport function PlaylistDownloadGroup({\n  groupId,\n  title,\n  records,\n  totalCount,\n  selectedIds,\n  onToggleSelect,\n  onDeletePlaylist\n}: PlaylistDownloadGroupProps) {\n  const { t } = useTranslation()\n  const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId))\n\n  useEffect(() => {\n    saveExpandedState(groupId, isExpanded)\n  }, [groupId, isExpanded])\n\n  const completedCount = records.filter((record) => record.status === 'completed').length\n  const errorCount = records.filter((record) => record.status === 'error').length\n  const activeCount = records.filter((record) =>\n    ['downloading', 'processing', 'pending'].includes(record.status)\n  ).length\n\n  const displayTitle = title || t('playlist.untitled')\n  const historyRecords = records.filter((record) => record.entryType === 'history')\n  const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist)\n  const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand')\n  const totalProgress = records.reduce((acc, record) => {\n    if (record.status === 'completed') {\n      return acc + 1\n    }\n    if (record.progress?.percent && record.progress.percent > 0) {\n      return acc + Math.min(record.progress.percent, 100) / 100\n    }\n    return acc\n  }, 0)\n  const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0\n\n  return (\n    <div className=\"mx-6 rounded-md bg-muted/30\">\n      <div className=\"flex items-center justify-between gap-2\">\n        <button\n          aria-expanded={isExpanded}\n          aria-label={toggleLabel}\n          className=\"flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 px-3 transition-colors hover:bg-muted/40 active:bg-muted/60\"\n          onClick={() => setIsExpanded((prev) => !prev)}\n          title={toggleLabel}\n          type=\"button\"\n        >\n          <div className=\"flex shrink-0 items-center justify-center text-muted-foreground\">\n            {isExpanded ? (\n              <ChevronDown className=\"h-5 w-5\" />\n            ) : (\n              <ChevronRight className=\"h-5 w-5\" />\n            )}\n          </div>\n          <div className=\"min-w-0 flex-1 text-left\">\n            <p className=\"truncate font-medium text-foreground text-sm\">{displayTitle}</p>\n            <div className=\"flex items-center gap-1.5 text-[11px] text-muted-foreground\">\n              <span>\n                {t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}\n              </span>\n              {activeCount > 0 && (\n                <>\n                  <span className=\"text-muted-foreground/50\">•</span>\n                  <span>{t('playlist.groupActive', { count: activeCount })}</span>\n                </>\n              )}\n              {errorCount > 0 && (\n                <>\n                  <span className=\"text-muted-foreground/50\">•</span>\n                  <span className=\"text-destructive\">\n                    {t('playlist.groupErrors', { count: errorCount })}\n                  </span>\n                </>\n              )}\n            </div>\n          </div>\n        </button>\n        <div className=\"flex shrink-0 items-center gap-1 pr-3\">\n          {canDeletePlaylist && (\n            <Button\n              aria-label={t('history.deletePlaylist')}\n              className=\"h-6 w-6 rounded-full\"\n              onClick={(e) => {\n                e.stopPropagation()\n                onDeletePlaylist?.(\n                  groupId,\n                  displayTitle,\n                  historyRecords.map((record) => record.id)\n                )\n              }}\n              size=\"icon\"\n              title={t('history.deletePlaylist')}\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <Trash2 className=\"h-3.5 w-3.5\" />\n            </Button>\n          )}\n        </div>\n      </div>\n\n      {!isExpanded && totalCount > 0 && (\n        <Progress className=\"h-0.5 w-full\" value={aggregatePercent} />\n      )}\n\n      <div\n        className=\"grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out\"\n        style={{\n          gridTemplateRows: isExpanded ? '1fr' : '0fr'\n        }}\n      >\n        <div className=\"min-h-0\">\n          {records.map((record) => (\n            <div key={`${groupId}:${record.entryType}:${record.id}`}>\n              <DownloadItem\n                download={record}\n                isSelected={selectedIds?.has(record.id) ?? false}\n                onToggleSelect={onToggleSelect}\n              />\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'\nimport { Label } from '@renderer/components/ui/label'\nimport { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'\nimport { ScrollArea } from '@renderer/components/ui/scroll-area'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@renderer/components/ui/select'\nimport { Separator } from '@renderer/components/ui/separator'\nimport { cn } from '@renderer/lib/utils'\nimport type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types'\nimport {\n  DOWNLOAD_FEEDBACK_ISSUE_TITLE,\n  FeedbackLinkButtons\n} from '@vidbee/ui/components/ui/feedback-link-buttons'\nimport { useAtom } from 'jotai'\nimport { AlertCircle, ExternalLink, Loader2, Settings2 } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'\nimport { settingsAtom } from '../../store/settings'\nimport { useAppInfo } from '../feedback/FeedbackLinks'\n\nexport interface SingleVideoState {\n  title: string\n  activeTab: 'video' | 'audio'\n  selectedVideoFormat: string\n  selectedAudioFormat: string\n  customDownloadPath: string\n  selectedContainer?: string\n  selectedCodec?: string\n  selectedFps?: string\n}\n\ninterface SingleVideoDownloadProps {\n  loading: boolean\n  error: string | null\n  videoInfo: VideoInfo | null\n  state: SingleVideoState\n  feedbackSourceUrl?: string | null\n  ytDlpCommand?: string\n  onStateChange: (state: Partial<SingleVideoState>) => void\n}\n\nconst qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {\n  best: null,\n  good: 1080,\n  normal: 720,\n  bad: 480,\n  worst: 360\n}\n\nconst formatDuration = (seconds?: number): string => {\n  if (!seconds) {\n    return '00:00'\n  }\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  const remainingSeconds = Math.floor(seconds % 60)\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds\n      .toString()\n      .padStart(2, '0')}`\n  }\n  return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`\n}\n\nconst getCodecShortName = (codec?: string): string => {\n  if (!codec || codec === 'none') {\n    return 'Unknown'\n  }\n  return codec.split('.')[0].toUpperCase()\n}\n\nconst isHlsFormat = (format: VideoFormat): boolean =>\n  format.protocol === 'm3u8' || format.protocol === 'm3u8_native'\n\nconst isHttpProtocol = (format: VideoFormat): boolean =>\n  !!format.protocol && format.protocol.startsWith('http')\n\nconst filterFormatsByType = (\n  formats: VideoInfo['formats'],\n  activeTab: 'video' | 'audio'\n): VideoInfo['formats'] => {\n  if (!formats) {\n    return []\n  }\n\n  return formats.filter((format) => {\n    if (activeTab === 'video') {\n      return format.vcodec && format.vcodec !== 'none'\n    }\n\n    return (\n      format.acodec &&\n      format.acodec !== 'none' &&\n      (format.video_ext === 'none' ||\n        !format.video_ext ||\n        !format.vcodec ||\n        format.vcodec === 'none')\n    )\n  })\n}\n\ninterface FormatListProps {\n  formats: VideoFormat[]\n  type: 'video' | 'audio'\n  codec?: string\n  selectedFormat: string\n  onFormatChange: (formatId: string) => void\n}\n\nconst FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: FormatListProps) => {\n  const { t } = useTranslation()\n  const [settings] = useAtom(settingsAtom)\n  const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])\n  const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])\n\n  const getFileSize = useCallback((format: VideoFormat): number => {\n    return format.filesize ?? format.filesize_approx ?? 0\n  }, [])\n\n  const sortVideoFormatsByQuality = useCallback(\n    (a: VideoFormat, b: VideoFormat) => {\n      const aHeight = a.height ?? 0\n      const bHeight = b.height ?? 0\n      if (aHeight !== bHeight) {\n        return bHeight - aHeight\n      }\n      const aFps = a.fps ?? 0\n      const bFps = b.fps ?? 0\n      if (aFps !== bFps) {\n        return bFps - aFps\n      }\n      const aHasSize = !!(a.filesize || a.filesize_approx)\n      const bHasSize = !!(b.filesize || b.filesize_approx)\n      if (aHasSize !== bHasSize) {\n        return bHasSize ? 1 : -1\n      }\n      return getFileSize(b) - getFileSize(a)\n    },\n    [getFileSize]\n  )\n\n  const sortAudioFormatsByQuality = useCallback(\n    (a: VideoFormat, b: VideoFormat) => {\n      const aQuality = a.tbr ?? a.quality ?? 0\n      const bQuality = b.tbr ?? b.quality ?? 0\n      if (aQuality !== bQuality) {\n        return bQuality - aQuality\n      }\n      const aHasSize = !!(a.filesize || a.filesize_approx)\n      const bHasSize = !!(b.filesize || b.filesize_approx)\n      if (aHasSize !== bHasSize) {\n        return bHasSize ? 1 : -1\n      }\n      return getFileSize(b) - getFileSize(a)\n    },\n    [getFileSize]\n  )\n\n  const pickVideoFormatForPreset = useCallback(\n    (presetFormats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {\n      if (presetFormats.length === 0) {\n        return null\n      }\n\n      const heightLimit = qualityPresetToVideoHeight[preset]\n      const sorted = [...presetFormats].sort(sortVideoFormatsByQuality)\n\n      if (preset === 'worst') {\n        return sorted.at(-1) ?? sorted[0]\n      }\n\n      if (!heightLimit) {\n        return sorted[0]\n      }\n\n      const matchingLimit = sorted.find((format) => {\n        if (!format.height) {\n          return false\n        }\n        return format.height <= heightLimit\n      })\n\n      return matchingLimit ?? sorted[0]\n    },\n    [sortVideoFormatsByQuality]\n  )\n\n  useEffect(() => {\n    const isVideoFormat = (format: VideoFormat) =>\n      format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'\n    const isAudioFormat = (format: VideoFormat) =>\n      format.acodec &&\n      format.acodec !== 'none' &&\n      (format.video_ext === 'none' ||\n        !format.video_ext ||\n        !format.vcodec ||\n        format.vcodec === 'none')\n\n    const videos = formats.filter(isVideoFormat)\n    const audios = formats.filter(isAudioFormat)\n\n    const groupedByHeight = new Map<number, VideoFormat[]>()\n    videos.forEach((format) => {\n      const height = format.height ?? 0\n      const existing = groupedByHeight.get(height) || []\n      existing.push(format)\n      groupedByHeight.set(height, existing)\n    })\n\n    const finalVideos = Array.from(groupedByHeight.values()).map((group) => {\n      return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]\n    })\n\n    let finalAudios = audios\n\n    if (codec === 'auto' && type === 'audio') {\n      const groupedByQuality = new Map<string, VideoFormat[]>()\n      audios.forEach((format) => {\n        const qualityKey = format.tbr\n          ? `tbr_${format.tbr}`\n          : format.quality\n            ? `quality_${format.quality}`\n            : 'unknown'\n        const existing = groupedByQuality.get(qualityKey) || []\n        existing.push(format)\n        groupedByQuality.set(qualityKey, existing)\n      })\n\n      finalAudios = Array.from(groupedByQuality.values()).map((group) => {\n        return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]\n      })\n    }\n\n    finalVideos.sort(sortVideoFormatsByQuality)\n    finalAudios.sort(sortAudioFormatsByQuality)\n\n    setVideoFormats(finalVideos)\n    setAudioFormats(finalAudios)\n\n    if (type === 'video') {\n      const videosWithAudio = finalVideos.filter(\n        (format) => format.acodec && format.acodec !== 'none'\n      )\n      const autoVideos =\n        finalAudios.length > 0\n          ? finalVideos\n          : videosWithAudio.length > 0\n            ? videosWithAudio\n            : finalVideos\n\n      const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedFormat)\n      if (autoVideos.length > 0 && !(selectedFormat && hasSelectedVideo)) {\n        const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)\n        if (preferred) {\n          onFormatChange(preferred.format_id)\n        }\n      }\n    } else {\n      const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedFormat)\n      if (finalAudios.length > 0 && !(selectedFormat && hasSelectedAudio)) {\n        const best = finalAudios[0]\n        onFormatChange(best.format_id)\n      }\n    }\n  }, [\n    formats,\n    settings.oneClickQuality,\n    type,\n    selectedFormat,\n    onFormatChange,\n    pickVideoFormatForPreset,\n    codec,\n    getFileSize,\n    sortVideoFormatsByQuality,\n    sortAudioFormatsByQuality\n  ])\n\n  const formatSize = (bytes?: number) => {\n    if (!bytes) {\n      return t('download.unknownSize')\n    }\n    const mb = bytes / 1_000_000\n    return `${mb.toFixed(2)} MB`\n  }\n\n  const formatMetaLabel = (format: VideoFormat) => {\n    const parts: string[] = []\n    const pushPart = (label: string, value?: string) => {\n      if (!value) {\n        return\n      }\n      parts.push(`${label}:${value}`)\n    }\n    pushPart('proto', format.protocol)\n    pushPart('lang', format.language?.trim())\n    if (format.tbr) {\n      pushPart('tbr', `${Math.round(format.tbr)}k`)\n    }\n    if (typeof format.quality === 'number') {\n      pushPart('q', String(format.quality))\n    }\n    if (format.vcodec && format.vcodec !== 'none') {\n      pushPart('vcodec', format.vcodec)\n    }\n    if (format.acodec && format.acodec !== 'none') {\n      pushPart('acodec', format.acodec)\n    }\n\n    return parts.join(' • ')\n  }\n\n  const formatVideoQuality = (format: VideoFormat) => {\n    if (format.height) {\n      return `${format.height}p${format.fps === 60 ? '60' : ''}`\n    }\n    if (format.format_note) {\n      return format.format_note\n    }\n    if (typeof format.quality === 'number') {\n      return format.quality.toString()\n    }\n    return t('download.unknownQuality')\n  }\n\n  const formatAudioQuality = (format: VideoFormat) => {\n    if (format.tbr) {\n      return `${Math.round(format.tbr)} kbps`\n    }\n    if (format.format_note) {\n      return format.format_note\n    }\n    if (typeof format.quality === 'number') {\n      return format.quality.toString()\n    }\n    return t('download.unknownQuality')\n  }\n\n  const formatVideoDetail = (format: VideoFormat) => {\n    const parts: string[] = []\n    parts.push(format.ext.toUpperCase())\n    if (format.vcodec) {\n      parts.push(format.vcodec.split('.')[0].toUpperCase())\n    }\n    if (format.acodec && format.acodec !== 'none') {\n      parts.push(format.acodec.split('.')[0].toUpperCase())\n    }\n    return parts.join(' • ')\n  }\n\n  const formatAudioDetail = (format: VideoFormat) => {\n    const parts: string[] = []\n    const ext = format.ext === 'webm' ? 'opus' : format.ext\n    parts.push(ext.toUpperCase())\n    if (format.acodec) {\n      parts.push(format.acodec.split('.')[0].toUpperCase())\n    }\n    return parts.join(' • ')\n  }\n\n  const list = type === 'video' ? videoFormats : audioFormats\n\n  if (list.length === 0) {\n    return null\n  }\n\n  return (\n    <RadioGroup className=\"w-full gap-1\" onValueChange={onFormatChange} value={selectedFormat}>\n      {list.map((format) => {\n        const qualityLabel =\n          type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format)\n        const detailLabel = type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format)\n        const thirdColumnLabel =\n          type === 'video'\n            ? format.fps\n              ? `${format.fps}fps`\n              : ''\n            : format.acodec\n              ? format.acodec.split('.')[0].toUpperCase()\n              : ''\n        const sizeLabel = formatSize(format.filesize || format.filesize_approx)\n        const metaLabel = formatMetaLabel(format)\n        const isSelected = selectedFormat === format.format_id\n\n        return (\n          <label\n            className={cn(\n              'relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 transition-colors',\n              isSelected ? 'bg-primary/10' : 'hover:bg-muted'\n            )}\n            htmlFor={`${type}-${format.format_id}`}\n            key={format.format_id}\n          >\n            <RadioGroupItem\n              className=\"hidden shrink-0\"\n              id={`${type}-${format.format_id}`}\n              value={format.format_id}\n            />\n\n            <div className=\"flex min-w-0 flex-1 items-center gap-4\">\n              <span\n                className={cn('w-16 shrink-0 font-medium text-sm', isSelected && 'text-primary')}\n              >\n                {qualityLabel}\n              </span>\n\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <span className=\"truncate text-muted-foreground text-xs\">{detailLabel}</span>\n                  {thirdColumnLabel && thirdColumnLabel !== '-' && (\n                    <span className=\"shrink-0 rounded bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground\">\n                      {thirdColumnLabel}\n                    </span>\n                  )}\n                </div>\n                {metaLabel && (\n                  <div className=\"mt-0.5 break-words text-[10px] text-muted-foreground/70 leading-snug\">\n                    {metaLabel}\n                  </div>\n                )}\n              </div>\n\n              <span className=\"w-20 shrink-0 text-right text-muted-foreground text-xs tabular-nums\">\n                {sizeLabel}\n              </span>\n            </div>\n          </label>\n        )\n      })}\n    </RadioGroup>\n  )\n}\n\nexport function SingleVideoDownload({\n  loading,\n  error,\n  videoInfo,\n  state,\n  feedbackSourceUrl,\n  ytDlpCommand,\n  onStateChange\n}: SingleVideoDownloadProps) {\n  const { t } = useTranslation()\n  const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)\n  const [showAdvanced, setShowAdvanced] = useState(false)\n  const appInfo = useAppInfo()\n\n  const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state\n  const displayTitle = title || videoInfo?.title || t('download.fetchingVideoInfo')\n\n  const relevantFormats = useMemo(() => {\n    if (!videoInfo?.formats) {\n      return []\n    }\n    const baseFormats = filterFormatsByType(videoInfo.formats, activeTab)\n    if (baseFormats.length === 0) {\n      return []\n    }\n\n    const hasHttpFormats = baseFormats.some(isHttpProtocol)\n    if (!hasHttpFormats) {\n      return baseFormats\n    }\n\n    const nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format))\n    return nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats\n  }, [videoInfo?.formats, activeTab])\n\n  const containers = useMemo(() => {\n    if (relevantFormats.length === 0) {\n      return []\n    }\n    const exts = new Set(relevantFormats.map((format) => format.ext))\n    return Array.from(exts).sort()\n  }, [relevantFormats])\n\n  useEffect(() => {\n    if (containers.length === 0) {\n      return undefined\n    }\n\n    if (selectedContainer && !containers.includes(selectedContainer)) {\n      let defaultContainer: string\n      if (activeTab === 'video') {\n        defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]\n      } else {\n        defaultContainer = containers.includes('m4a')\n          ? 'm4a'\n          : containers.includes('mp3')\n            ? 'mp3'\n            : containers[0]\n      }\n      const timer = setTimeout(() => {\n        onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' })\n      }, 0)\n      return () => clearTimeout(timer)\n    }\n\n    if (!selectedContainer) {\n      let defaultContainer: string\n      if (activeTab === 'video') {\n        defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]\n      } else {\n        defaultContainer = containers.includes('m4a')\n          ? 'm4a'\n          : containers.includes('mp3')\n            ? 'mp3'\n            : containers[0]\n      }\n      const timer = setTimeout(() => {\n        onStateChange({ selectedContainer: defaultContainer })\n      }, 0)\n      return () => clearTimeout(timer)\n    }\n\n    return undefined\n  }, [containers, selectedContainer, activeTab, onStateChange])\n\n  const formatsByContainer = useMemo(() => {\n    if (relevantFormats.length === 0) {\n      return []\n    }\n\n    if (!selectedContainer) {\n      return relevantFormats\n    }\n\n    return relevantFormats.filter((format) => format.ext === selectedContainer)\n  }, [relevantFormats, selectedContainer])\n\n  const codecs = useMemo(() => {\n    if (formatsByContainer.length === 0) {\n      return []\n    }\n\n    const SetVals = new Set<string>()\n    formatsByContainer.forEach((format) => {\n      if (activeTab === 'video') {\n        const c = format.vcodec\n        if (c && c !== 'none') {\n          SetVals.add(getCodecShortName(c))\n        }\n      } else {\n        const c = format.acodec\n        if (c && c !== 'none') {\n          SetVals.add(getCodecShortName(c))\n        }\n      }\n    })\n    return Array.from(SetVals).sort()\n  }, [formatsByContainer, activeTab])\n\n  useEffect(() => {\n    if (codecs.length === 0) {\n      return undefined\n    }\n    if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) {\n      const timer = setTimeout(() => {\n        onStateChange({ selectedCodec: 'auto' })\n      }, 0)\n      return () => clearTimeout(timer)\n    }\n    return undefined\n  }, [codecs, selectedCodec, onStateChange])\n\n  const formatsByCodec = useMemo(() => {\n    if (!selectedCodec || selectedCodec === 'auto') {\n      return formatsByContainer\n    }\n    return formatsByContainer.filter((format) => {\n      if (activeTab === 'video') {\n        const c = format.vcodec\n        return c && c !== 'none' && getCodecShortName(c) === selectedCodec\n      }\n      const c = format.acodec\n      return c && c !== 'none' && getCodecShortName(c) === selectedCodec\n    })\n  }, [formatsByContainer, selectedCodec, activeTab])\n\n  const framerates = useMemo(() => {\n    if (activeTab !== 'video') {\n      return []\n    }\n    const SetVals = new Set<number>()\n    formatsByCodec.forEach((format) => {\n      if (format.fps) {\n        SetVals.add(format.fps)\n      }\n    })\n    return Array.from(SetVals).sort((a, b) => b - a)\n  }, [formatsByCodec, activeTab])\n\n  const filteredFormats = useMemo(() => {\n    let res = formatsByCodec\n    if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') {\n      res = res.filter((format) => format.fps === Number(selectedFps))\n    }\n    return res\n  }, [formatsByCodec, selectedFps, activeTab])\n\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col\">\n      {loading && !error && (\n        <div className=\"flex min-h-[200px] flex-1 flex-col items-center justify-center gap-3\">\n          <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n          <p className=\"text-muted-foreground text-sm\">{t('download.fetchingVideoInfo')}</p>\n        </div>\n      )}\n\n      {error && (\n        <div className=\"mb-3 shrink-0 rounded-md border border-destructive/30 bg-destructive/5 p-3\">\n          <div className=\"flex items-start gap-2\">\n            <AlertCircle className=\"mt-0.5 h-4 w-4 shrink-0 text-destructive\" />\n            <div className=\"min-w-0 flex-1 space-y-1\">\n              <p className=\"font-medium text-destructive text-sm\">{t('errors.fetchInfoFailed')}</p>\n              <p className=\"break-words text-muted-foreground/80 text-xs\">{error}</p>\n            </div>\n          </div>\n          <div className=\"mt-2.5 flex flex-wrap items-center gap-1.5\">\n            <span className=\"font-medium text-[10px] text-muted-foreground/70\">\n              {t('download.feedback.title')}\n            </span>\n            <div className=\"flex flex-wrap gap-1.5\">\n              <FeedbackLinkButtons\n                appInfo={appInfo}\n                buttonClassName=\"h-5 gap-1 px-1.5 text-[10px]\"\n                buttonSize=\"sm\"\n                buttonVariant=\"outline\"\n                error={error}\n                iconClassName=\"h-2.5 w-2.5\"\n                includeAppInfo\n                issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}\n                sourceUrl={feedbackSourceUrl}\n                ytDlpCommand={ytDlpCommand}\n              />\n            </div>\n          </div>\n        </div>\n      )}\n\n      {!loading && videoInfo && (\n        <div className=\"flex min-h-0 flex-1 flex-col\">\n          <div className=\"flex shrink-0 gap-4 py-4\">\n            <div className=\"relative w-32 shrink-0 overflow-hidden rounded-md bg-muted\">\n              <ImageWithPlaceholder\n                alt={displayTitle}\n                className=\"aspect-video h-full w-full object-cover\"\n                src={cachedThumbnail}\n              />\n              <div className=\"absolute right-1 bottom-1 rounded bg-black/80 px-1 text-[10px] text-white\">\n                {formatDuration(videoInfo.duration)}\n              </div>\n            </div>\n\n            <div className=\"flex min-w-0 flex-1 flex-col justify-between py-0.5\">\n              <div className=\"space-y-0.5\">\n                <h3 className=\"line-clamp-2 font-bold text-[13px] leading-tight\">{displayTitle}</h3>\n                <div className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n                  {videoInfo.uploader && (\n                    <span className=\"max-w-[140px] truncate font-semibold uppercase tracking-wider opacity-70\">\n                      {videoInfo.uploader}\n                    </span>\n                  )}\n                  {videoInfo.webpage_url && (\n                    <a\n                      className=\"transition-colors hover:text-primary\"\n                      href={videoInfo.webpage_url}\n                      rel=\"noreferrer\"\n                      target=\"_blank\"\n                    >\n                      <ExternalLink className=\"h-3 w-3\" />\n                    </a>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex gap-0.5 rounded-md bg-muted p-0.5\">\n                  <Button\n                    className={cn(\n                      'h-5 rounded-sm px-2 text-[11px]',\n                      activeTab === 'video'\n                        ? 'bg-background text-foreground'\n                        : 'text-muted-foreground/60'\n                    )}\n                    onClick={() => onStateChange({ activeTab: 'video' })}\n                    size=\"sm\"\n                    variant={activeTab === 'video' ? 'secondary' : 'ghost'}\n                  >\n                    {t('download.video')}\n                  </Button>\n                  <Button\n                    className={cn(\n                      'h-5 rounded-sm px-2 text-[11px]',\n                      activeTab === 'audio'\n                        ? 'bg-background text-foreground'\n                        : 'text-muted-foreground/60'\n                    )}\n                    onClick={() => onStateChange({ activeTab: 'audio' })}\n                    size=\"sm\"\n                    variant={activeTab === 'audio' ? 'secondary' : 'ghost'}\n                  >\n                    {t('download.audio')}\n                  </Button>\n                </div>\n\n                <Button\n                  className={cn(\n                    'h-6 w-6 rounded-full p-0 font-normal text-muted-foreground transition-colors hover:bg-muted',\n                    showAdvanced && 'bg-muted text-foreground'\n                  )}\n                  onClick={() => setShowAdvanced(!showAdvanced)}\n                  size=\"sm\"\n                  variant=\"ghost\"\n                >\n                  <Settings2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n            <div\n              className={cn(\n                'grid transition-all duration-300 ease-in-out',\n                showAdvanced ? 'grid-rows-[1fr] border-b py-3' : 'grid-rows-[0fr]'\n              )}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <div className=\"flex flex-wrap items-end gap-3\">\n                  <div className=\"min-w-[120px] flex-1 space-y-1.5\">\n                    <Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n                      {t('download.container') || 'Format'}\n                    </Label>\n                    <Select\n                      disabled={containers.length <= 1}\n                      onValueChange={(value) => onStateChange({ selectedContainer: value })}\n                      value={selectedContainer || ''}\n                    >\n                      <SelectTrigger className=\"h-8 text-xs\">\n                        <SelectValue placeholder=\"Container\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {containers.map((ext) => (\n                          <SelectItem className=\"text-xs\" key={ext} value={ext}>\n                            {ext.toUpperCase()}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </div>\n\n                  <div className=\"min-w-[120px] flex-1 space-y-1.5\">\n                    <Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n                      Codec\n                    </Label>\n                    <Select\n                      disabled={codecs.length <= 1}\n                      onValueChange={(value) => onStateChange({ selectedCodec: value })}\n                      value={selectedCodec || 'auto'}\n                    >\n                      <SelectTrigger className=\"h-8 text-xs\">\n                        <SelectValue placeholder=\"Auto\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectItem className=\"text-xs\" value=\"auto\">\n                          Auto\n                        </SelectItem>\n                        {codecs.map((codecName) => (\n                          <SelectItem className=\"text-xs\" key={codecName} value={codecName}>\n                            {codecName}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </div>\n\n                  {activeTab === 'video' && (\n                    <div className=\"min-w-[120px] flex-1 space-y-1.5\">\n                      <Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n                        Frame Rate\n                      </Label>\n                      <Select\n                        disabled={framerates.length === 0}\n                        onValueChange={(value) => onStateChange({ selectedFps: value })}\n                        value={selectedFps || 'highest'}\n                      >\n                        <SelectTrigger className=\"h-8 text-xs\">\n                          <SelectValue placeholder=\"Highest\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                          <SelectItem className=\"text-xs\" value=\"highest\">\n                            Highest\n                          </SelectItem>\n                          {framerates.map((fps) => (\n                            <SelectItem className=\"text-xs\" key={fps} value={String(fps)}>\n                              {fps} fps\n                            </SelectItem>\n                          ))}\n                        </SelectContent>\n                      </Select>\n                    </div>\n                  )}\n                </div>\n              </div>\n            </div>\n\n            <ScrollArea className=\"my-3 max-h-72 flex-1 overflow-y-auto\">\n              <FormatList\n                codec={selectedCodec}\n                formats={filteredFormats}\n                onFormatChange={(formatId) =>\n                  onStateChange(\n                    activeTab === 'video'\n                      ? { selectedVideoFormat: formatId }\n                      : { selectedAudioFormat: formatId }\n                  )\n                }\n                selectedFormat={\n                  activeTab === 'video' ? state.selectedVideoFormat : state.selectedAudioFormat\n                }\n                type={activeTab}\n              />\n            </ScrollArea>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport { CardContent, CardHeader } from '@renderer/components/ui/card'\nimport { Checkbox } from '@renderer/components/ui/checkbox'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@renderer/components/ui/dialog'\nimport { cn } from '@renderer/lib/utils'\nimport { DownloadEmptyState } from '@vidbee/ui/components/ui/download-empty-state'\nimport {\n  DownloadFilterBar,\n  type DownloadFilterItem\n} from '@vidbee/ui/components/ui/download-filter-bar'\nimport { useAtomValue, useSetAtom } from 'jotai'\nimport { useEffect, useId, useMemo, useState } from 'react'\nimport { Trans, useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport {\n  buildFilePathCandidates,\n  normalizeSavedFileName\n} from '../../../../shared/utils/download-file'\nimport { useHistorySync } from '../../hooks/use-history-sync'\nimport { ipcServices } from '../../lib/ipc'\nimport type { DownloadRecord } from '../../store/downloads'\nimport {\n  downloadStatsAtom,\n  downloadsArrayAtom,\n  removeHistoryRecordsAtom,\n  removeHistoryRecordsByPlaylistAtom\n} from '../../store/downloads'\nimport { settingsAtom } from '../../store/settings'\nimport { ScrollArea } from '../ui/scroll-area'\nimport { DownloadDialog } from './DownloadDialog'\nimport { DownloadItem } from './DownloadItem'\nimport { PlaylistDownloadGroup } from './PlaylistDownloadGroup'\n\ntype StatusFilter = 'all' | 'active' | 'completed' | 'error'\ntype ConfirmAction =\n  | { type: 'delete-selected'; ids: string[] }\n  | { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }\n\nconst tryFileOperation = async (\n  paths: string[],\n  operation: (filePath: string) => Promise<boolean>\n): Promise<boolean> => {\n  for (const filePath of paths) {\n    const success = await operation(filePath)\n    if (success) {\n      return true\n    }\n  }\n  return false\n}\n\nconst getSavedFileExtension = (fileName?: string): string | undefined => {\n  const normalized = normalizeSavedFileName(fileName)\n  if (!normalized) {\n    return undefined\n  }\n  if (!normalized.includes('.')) {\n    return undefined\n  }\n  const ext = normalized.split('.').pop()\n  return ext?.toLowerCase()\n}\n\nconst resolveDownloadExtension = (download: DownloadRecord): string => {\n  const savedExt = getSavedFileExtension(download.savedFileName)\n  if (savedExt) {\n    return savedExt\n  }\n  const selectedExt = download.selectedFormat?.ext?.toLowerCase()\n  if (selectedExt) {\n    return selectedExt\n  }\n  return download.type === 'audio' ? 'mp3' : 'mp4'\n}\n\nconst isEditableTarget = (target: EventTarget | null): boolean => {\n  if (!(target && target instanceof HTMLElement)) {\n    return false\n  }\n  if (target.isContentEditable) {\n    return true\n  }\n  const tagName = target.tagName\n  return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'\n}\n\ninterface UnifiedDownloadHistoryProps {\n  onOpenSupportedSites?: () => void\n  onOpenSettings?: () => void\n  onOpenCookiesSettings?: () => void\n}\n\nexport function UnifiedDownloadHistory({\n  onOpenSupportedSites,\n  onOpenSettings,\n  onOpenCookiesSettings\n}: UnifiedDownloadHistoryProps) {\n  const { t } = useTranslation()\n  const allRecords = useAtomValue(downloadsArrayAtom)\n  const downloadStats = useAtomValue(downloadStatsAtom)\n  const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)\n  const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)\n  const settings = useAtomValue(settingsAtom)\n  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())\n  const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)\n  const [confirmBusy, setConfirmBusy] = useState(false)\n  const [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false)\n  const alsoDeleteFilesId = useId()\n  const hasCookieConfig = useMemo(() => {\n    const cookiesPath = settings.cookiesPath?.trim()\n    if (cookiesPath) {\n      return true\n    }\n    const browserSetting = settings.browserForCookies?.trim()\n    return Boolean(browserSetting && browserSetting !== 'none')\n  }, [settings.browserForCookies, settings.cookiesPath])\n  const showCookiesTip = !hasCookieConfig\n  const canOpenCookiesSettings = Boolean(onOpenCookiesSettings ?? onOpenSettings)\n\n  useHistorySync()\n\n  const historyRecords = useMemo(\n    () => allRecords.filter((record) => record.entryType === 'history'),\n    [allRecords]\n  )\n  const selectedCount = selectedIds.size\n\n  const filteredRecords = useMemo(() => {\n    return allRecords.filter((record) => {\n      switch (statusFilter) {\n        case 'all':\n          return true\n        case 'active':\n          return (\n            record.status === 'downloading' ||\n            record.status === 'processing' ||\n            record.status === 'pending'\n          )\n        case 'completed':\n        case 'error':\n          return record.status === statusFilter\n        default:\n          return true\n      }\n    })\n  }, [allRecords, statusFilter])\n\n  const visibleHistoryIds = useMemo(\n    () =>\n      filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),\n    [filteredRecords]\n  )\n\n  const filters: DownloadFilterItem<StatusFilter>[] = [\n    { key: 'all', label: t('download.all'), count: downloadStats.total },\n    { key: 'active', label: t('download.active'), count: downloadStats.active },\n    { key: 'completed', label: t('download.completed'), count: downloadStats.completed },\n    { key: 'error', label: t('download.error'), count: downloadStats.error }\n  ]\n\n  const selectableIds = useMemo(() => {\n    if (visibleHistoryIds.length === 0) {\n      return []\n    }\n    const ids = new Set(visibleHistoryIds)\n    const playlistIds = new Set(\n      filteredRecords\n        .filter((record) => record.entryType === 'history' && record.playlistId)\n        .map((record) => record.playlistId as string)\n    )\n    if (playlistIds.size === 0) {\n      return Array.from(ids)\n    }\n    for (const record of historyRecords) {\n      if (record.playlistId && playlistIds.has(record.playlistId)) {\n        ids.add(record.id)\n      }\n    }\n    return Array.from(ids)\n  }, [filteredRecords, historyRecords, visibleHistoryIds])\n  const selectableCount = selectableIds.length\n  const visibleSelectableCount = visibleHistoryIds.length\n  const selectionSummary =\n    selectableCount === 0\n      ? t('history.selectedCount', { count: selectedCount })\n      : selectableCount > visibleSelectableCount\n        ? t('history.selectedCount', { count: selectedCount })\n        : t('history.selectionSummary', { selected: selectedCount, total: selectableCount })\n\n  useEffect(() => {\n    if (selectedIds.size === 0) {\n      return\n    }\n    const historyIdSet = new Set(historyRecords.map((record) => record.id))\n    setSelectedIds((prev) => {\n      let changed = false\n      const next = new Set<string>()\n      for (const id of prev) {\n        if (historyIdSet.has(id)) {\n          next.add(id)\n        } else {\n          changed = true\n        }\n      }\n      return changed ? next : prev\n    })\n  }, [historyRecords, selectedIds.size])\n\n  const handleToggleSelect = (id: string) => {\n    setSelectedIds((prev) => {\n      const next = new Set(prev)\n      if (next.has(id)) {\n        next.delete(id)\n      } else {\n        next.add(id)\n      }\n      return next\n    })\n  }\n\n  const handleClearSelection = () => {\n    setSelectedIds(new Set())\n  }\n\n  const handleRequestDeleteSelected = () => {\n    if (selectedIds.size === 0) {\n      return\n    }\n    setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) })\n  }\n\n  const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => {\n    if (ids.length === 0) {\n      return\n    }\n    setConfirmAction({ type: 'delete-playlist', playlistId, title, ids })\n  }\n\n  const pruneSelectedIds = (ids: string[]) => {\n    if (ids.length === 0) {\n      return\n    }\n    setSelectedIds((prev) => {\n      const next = new Set(prev)\n      let changed = false\n      ids.forEach((id) => {\n        if (next.delete(id)) {\n          changed = true\n        }\n      })\n      return changed ? next : prev\n    })\n  }\n\n  const confirmContent = useMemo(() => {\n    if (!confirmAction) {\n      return null\n    }\n    switch (confirmAction.type) {\n      case 'delete-selected': {\n        return {\n          title: t('history.confirmDeleteSelectedTitle'),\n          description: t('history.confirmDeleteSelectedDescription', {\n            count: confirmAction.ids.length\n          }),\n          actionLabel: t('history.removeAction')\n        }\n      }\n      case 'delete-playlist': {\n        return {\n          title: t('history.confirmDeletePlaylistTitle'),\n          description: t('history.confirmDeletePlaylistDescription', {\n            count: confirmAction.ids.length,\n            title: confirmAction.title\n          }),\n          actionLabel: t('history.removeAction')\n        }\n      }\n      default:\n        return null\n    }\n  }, [confirmAction, t])\n\n  const deleteHistoryFiles = async (records: DownloadRecord[]) => {\n    const failedIds: string[] = []\n    for (const record of records) {\n      if (!record.title) {\n        continue\n      }\n      const downloadPath = record.downloadPath || settings.downloadPath\n      if (!downloadPath) {\n        continue\n      }\n      const formatForPath = resolveDownloadExtension(record)\n      const filePaths = buildFilePathCandidates(\n        downloadPath,\n        record.title,\n        formatForPath,\n        record.savedFileName\n      )\n      const deleted = await tryFileOperation(filePaths, (filePath) =>\n        ipcServices.fs.deleteFile(filePath)\n      )\n      if (!deleted) {\n        failedIds.push(record.id)\n      }\n    }\n    if (failedIds.length > 0) {\n      console.warn('Failed to delete some playlist files:', failedIds)\n    }\n  }\n\n  const handleConfirmAction = async () => {\n    if (!confirmAction) {\n      return\n    }\n    setConfirmBusy(true)\n    try {\n      if (confirmAction.type === 'delete-selected') {\n        await ipcServices.history.removeHistoryItems(confirmAction.ids)\n        removeHistoryRecords(confirmAction.ids)\n        if (alsoDeleteFiles) {\n          const idSet = new Set(confirmAction.ids)\n          const recordsToDelete = historyRecords.filter((record) => idSet.has(record.id))\n          await deleteHistoryFiles(recordsToDelete)\n        }\n        pruneSelectedIds(confirmAction.ids)\n        toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length }))\n      }\n      if (confirmAction.type === 'delete-playlist') {\n        const idSet = new Set(confirmAction.ids)\n        const playlistRecords = historyRecords.filter((record) => idSet.has(record.id))\n        await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId)\n        removeHistoryRecordsByPlaylist(confirmAction.playlistId)\n        await deleteHistoryFiles(playlistRecords)\n        pruneSelectedIds(confirmAction.ids)\n        toast.success(\n          t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length })\n        )\n      }\n      setConfirmAction(null)\n      setAlsoDeleteFiles(false)\n    } catch (error) {\n      if (confirmAction.type === 'delete-selected') {\n        console.error('Failed to remove selected history items:', error)\n        toast.error(t('notifications.itemsRemoveFailed'))\n      }\n      if (confirmAction.type === 'delete-playlist') {\n        console.error('Failed to remove playlist history:', error)\n        toast.error(t('notifications.playlistHistoryRemoveFailed'))\n      }\n    } finally {\n      setConfirmBusy(false)\n    }\n  }\n\n  const groupedView = useMemo(() => {\n    const groups = new Map<\n      string,\n      { id: string; title: string; totalCount: number; records: DownloadRecord[] }\n    >()\n    const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =\n      []\n\n    for (const record of filteredRecords) {\n      if (record.playlistId) {\n        let group = groups.get(record.playlistId)\n        if (!group) {\n          group = {\n            id: record.playlistId,\n            title: record.playlistTitle || record.title,\n            totalCount: record.playlistSize || 0,\n            records: []\n          }\n          groups.set(record.playlistId, group)\n          order.push({ type: 'group', id: record.playlistId })\n        }\n        group.records.push(record)\n        if (!group.title && record.playlistTitle) {\n          group.title = record.playlistTitle\n        }\n        if (!group.totalCount && record.playlistSize) {\n          group.totalCount = record.playlistSize\n        }\n      } else {\n        order.push({ type: 'single', record })\n      }\n    }\n\n    for (const group of groups.values()) {\n      group.records.sort((a, b) => {\n        const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER\n        const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER\n        if (aIndex !== bIndex) {\n          return aIndex - bIndex\n        }\n        return b.createdAt - a.createdAt\n      })\n      if (!group.totalCount) {\n        group.totalCount = group.records.length\n      }\n    }\n\n    return { order, groups }\n  }, [filteredRecords])\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented) {\n        return\n      }\n      if (isEditableTarget(event.target)) {\n        return\n      }\n      if (event.key === 'Escape') {\n        if (confirmAction) {\n          return\n        }\n        if (selectedIds.size === 0) {\n          return\n        }\n        setSelectedIds(new Set())\n        return\n      }\n      if (!(event.metaKey || event.ctrlKey)) {\n        return\n      }\n      if (event.key.toLowerCase() !== 'a') {\n        return\n      }\n      if (selectableIds.length === 0) {\n        return\n      }\n      event.preventDefault()\n      setSelectedIds(new Set(selectableIds))\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [confirmAction, selectableIds, selectedIds])\n\n  return (\n    <div className={cn('flex h-full flex-col')}>\n      <CardHeader className=\"z-50 gap-4 bg-background p-0 px-6 py-4 backdrop-blur\">\n        <DownloadFilterBar\n          actions={\n            <DownloadDialog\n              onOpenSettings={onOpenSettings}\n              onOpenSupportedSites={onOpenSupportedSites}\n            />\n          }\n          activeFilter={statusFilter}\n          filters={filters}\n          onFilterChange={setStatusFilter}\n        />\n      </CardHeader>\n      <ScrollArea className=\"flex-1 overflow-y-auto\">\n        <CardContent className=\"w-full space-y-3 overflow-x-hidden p-0\">\n          {showCookiesTip && (\n            <div className=\"mx-6 mt-4 rounded-xl bg-muted/40 px-6 py-5\">\n              <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n                <div className=\"flex flex-col items-start gap-2.5\">\n                  <div className=\"space-y-1\">\n                    <p className=\"font-bold text-[10px] text-muted-foreground/70 uppercase tracking-wider\">\n                      {t('history.cookiesTipTitle')}\n                    </p>\n                    <p className=\"max-w-[540px] text-foreground/85 text-sm leading-relaxed\">\n                      <Trans\n                        components={{\n                          strong: <strong className=\"font-semibold text-foreground\" />\n                        }}\n                        i18nKey=\"history.cookiesTipDescription\"\n                      />\n                    </p>\n                  </div>\n                  <Button\n                    className=\"h-8 rounded-lg px-4 font-medium text-xs\"\n                    disabled={!canOpenCookiesSettings}\n                    onClick={() => {\n                      if (onOpenCookiesSettings) {\n                        onOpenCookiesSettings()\n                        return\n                      }\n                      onOpenSettings?.()\n                    }}\n                    size=\"sm\"\n                    variant=\"secondary\"\n                  >\n                    {t('history.cookiesTipCta')}\n                  </Button>\n                </div>\n              </div>\n            </div>\n          )}\n          {filteredRecords.length === 0 ? (\n            <DownloadEmptyState message={t('download.noItems')} />\n          ) : (\n            <div className=\"w-full pb-4\">\n              {groupedView.order.map((item) => {\n                if (item.type === 'single') {\n                  return (\n                    <DownloadItem\n                      download={item.record}\n                      isSelected={selectedIds.has(item.record.id)}\n                      key={`${item.record.entryType}:${item.record.id}`}\n                      onToggleSelect={handleToggleSelect}\n                    />\n                  )\n                }\n\n                const group = groupedView.groups.get(item.id)\n                if (!group) {\n                  return null\n                }\n\n                return (\n                  <PlaylistDownloadGroup\n                    groupId={group.id}\n                    key={`group:${group.id}`}\n                    onDeletePlaylist={handleRequestDeletePlaylist}\n                    onToggleSelect={handleToggleSelect}\n                    records={group.records}\n                    selectedIds={selectedIds}\n                    title={group.title}\n                    totalCount={group.totalCount}\n                  />\n                )\n              })}\n            </div>\n          )}\n        </CardContent>\n      </ScrollArea>\n      {selectedCount > 0 && (\n        <div className=\"fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:w-auto sm:translate-x-0\">\n          <div className=\"flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 py-2 pr-2 pl-5 shadow-lg backdrop-blur\">\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <span className=\"text-muted-foreground text-xs\">{selectionSummary}</span>\n            </div>\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <Button\n                className=\"h-8 rounded-full px-3\"\n                onClick={handleClearSelection}\n                size=\"sm\"\n                variant=\"ghost\"\n              >\n                {t('history.clearSelection')}\n              </Button>\n              <Button\n                className=\"h-8 rounded-full px-3\"\n                onClick={handleRequestDeleteSelected}\n                size=\"sm\"\n                variant=\"destructive\"\n              >\n                {t('history.deleteSelected')}\n              </Button>\n            </div>\n          </div>\n        </div>\n      )}\n      <Dialog\n        onOpenChange={(open) => {\n          if (!(open || confirmBusy)) {\n            setConfirmAction(null)\n            setAlsoDeleteFiles(false)\n          }\n        }}\n        open={Boolean(confirmAction)}\n      >\n        {confirmContent && (\n          <DialogContent>\n            <DialogHeader>\n              <DialogTitle>{confirmContent.title}</DialogTitle>\n              <DialogDescription>{confirmContent.description}</DialogDescription>\n            </DialogHeader>\n            {confirmAction?.type === 'delete-selected' && (\n              <div className=\"flex items-center space-x-2\">\n                <Checkbox\n                  checked={alsoDeleteFiles}\n                  id={alsoDeleteFilesId}\n                  onCheckedChange={(checked) => setAlsoDeleteFiles(checked === true)}\n                />\n                <label\n                  className=\"cursor-pointer font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                  htmlFor={alsoDeleteFilesId}\n                >\n                  {t('history.alsoDeleteFiles')}\n                </label>\n              </div>\n            )}\n            <DialogFooter>\n              <Button\n                disabled={confirmBusy}\n                onClick={() => {\n                  setConfirmAction(null)\n                  setAlsoDeleteFiles(false)\n                }}\n                variant=\"outline\"\n              >\n                {t('download.cancel')}\n              </Button>\n              <Button disabled={confirmBusy} onClick={handleConfirmAction} variant=\"destructive\">\n                {confirmContent.actionLabel}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        )}\n      </Dialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx",
    "content": "import { ipcServices } from '@renderer/lib/ipc'\nimport { logger } from '@renderer/lib/logger'\nimport { Component, type ErrorInfo, type ReactNode } from 'react'\nimport { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage'\n\ninterface Props {\n  children: ReactNode\n  onError?: (error: Error, errorInfo: ErrorInfo) => void\n  fallback?: (errorInfo: ErrorInfoType) => ReactNode\n}\n\ninterface State {\n  hasError: boolean\n  errorInfo: ErrorInfoType | null\n}\n\nexport class ErrorBoundary extends Component<Props, State> {\n  constructor(props: Props) {\n    super(props)\n    this.state = {\n      hasError: false,\n      errorInfo: null\n    }\n  }\n\n  static getDerivedStateFromError(error: Error): Partial<State> {\n    const errorInfo = {\n      error,\n      timestamp: Date.now(),\n      context: {\n        url: window.location.href,\n        userAgent: navigator.userAgent,\n        platform: navigator.platform\n      }\n    }\n\n    // Log error details immediately\n    logger.error('ErrorBoundary: getDerivedStateFromError called', {\n      errorName: error.name,\n      errorMessage: error.message,\n      errorStack: error.stack,\n      url: errorInfo.context.url,\n      timestamp: errorInfo.timestamp\n    })\n\n    return {\n      hasError: true,\n      errorInfo\n    }\n  }\n\n  async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise<void> {\n    logger.error('ErrorBoundary caught an error:', {\n      errorName: error.name,\n      errorMessage: error.message,\n      errorStack: error.stack,\n      componentStack: errorInfo.componentStack,\n      errorInfo: JSON.stringify(errorInfo, null, 2)\n    })\n\n    // Get app version if available\n    let appVersion: string | undefined\n    try {\n      if (window?.api && ipcServices?.app) {\n        appVersion = await ipcServices.app.getVersion()\n        logger.info('ErrorBoundary: App version retrieved', { appVersion })\n      }\n    } catch (err) {\n      logger.warn('Failed to get app version:', err)\n    }\n\n    // Update state with component stack and version\n    if (this.state.errorInfo) {\n      this.setState({\n        errorInfo: {\n          ...this.state.errorInfo,\n          context: {\n            ...this.state.errorInfo.context,\n            version: appVersion\n          },\n          errorInfo: {\n            componentStack: errorInfo.componentStack || undefined\n          }\n        }\n      })\n    }\n\n    // Call optional error handler\n    if (this.props.onError) {\n      this.props.onError(error, errorInfo)\n    }\n\n    // Send error to main process if available\n    if (window?.api) {\n      try {\n        window.api.send('error:renderer', {\n          error: {\n            name: error.name,\n            message: error.message,\n            stack: error.stack\n          },\n          errorInfo: {\n            componentStack: errorInfo.componentStack\n          },\n          timestamp: Date.now(),\n          context: {\n            url: window.location.href,\n            userAgent: navigator.userAgent,\n            platform: navigator.platform,\n            version: appVersion\n          }\n        })\n      } catch (err) {\n        logger.error('Failed to send error to main process:', err)\n      }\n    }\n  }\n\n  handleReload = (): void => {\n    this.setState({\n      hasError: false,\n      errorInfo: null\n    })\n    window.location.reload()\n  }\n\n  handleGoHome = (): void => {\n    this.setState({\n      hasError: false,\n      errorInfo: null\n    })\n    window.location.hash = '/'\n    window.location.reload()\n  }\n\n  render(): ReactNode {\n    if (this.state.hasError && this.state.errorInfo) {\n      if (this.props.fallback) {\n        return this.props.fallback(this.state.errorInfo)\n      }\n      return (\n        <ErrorPage\n          errorInfo={this.state.errorInfo}\n          onGoHome={this.handleGoHome}\n          onReload={this.handleReload}\n        />\n      )\n    }\n\n    return this.props.children\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/error/ErrorPage.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle\n} from '@renderer/components/ui/card'\nimport { ScrollArea } from '@renderer/components/ui/scroll-area'\nimport { Textarea } from '@renderer/components/ui/textarea'\nimport { logger } from '@renderer/lib/logger'\nimport { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react'\nimport { useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\n\nexport interface ErrorInfo {\n  error: Error\n  errorInfo?: {\n    componentStack?: string\n  }\n  timestamp: number\n  context?: {\n    url?: string\n    userAgent?: string\n    platform?: string\n    version?: string\n  }\n}\n\ninterface ErrorPageProps {\n  errorInfo: ErrorInfo\n  onReload?: () => void\n  onGoHome?: () => void\n}\n\nexport function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) {\n  const { t } = useTranslation()\n  const [showDetails, setShowDetails] = useState(false)\n  const [copied, setCopied] = useState(false)\n\n  const errorReport = generateErrorReport(errorInfo)\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(errorReport)\n      setCopied(true)\n      toast.success(t('error.copySuccess'))\n      setTimeout(() => setCopied(false), 2000)\n    } catch (error) {\n      logger.error('Failed to copy error report:', error)\n      toast.error(t('error.copyFailed'))\n    }\n  }\n\n  const handleReload = () => {\n    if (onReload) {\n      onReload()\n    } else {\n      window.location.reload()\n    }\n  }\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-background p-4\">\n      <Card className=\"w-full max-w-3xl\">\n        <CardHeader>\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex-shrink-0\">\n              <AlertTriangle className=\"h-8 w-8 text-destructive\" />\n            </div>\n            <div className=\"flex-1\">\n              <CardTitle className=\"text-2xl\">{t('error.title')}</CardTitle>\n              <CardDescription className=\"mt-2\">{t('error.description')}</CardDescription>\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          {/* Error Message */}\n          <div className=\"rounded-md border border-destructive/20 bg-destructive/10 p-4\">\n            <p className=\"mb-1 font-medium text-destructive text-sm\">{t('error.message')}</p>\n            <p className=\"break-words text-foreground text-sm\">\n              {errorInfo.error.message || t('error.unknownError')}\n            </p>\n          </div>\n\n          {/* Actions */}\n          <div className=\"flex flex-wrap gap-2\">\n            {onGoHome && (\n              <Button onClick={onGoHome} variant=\"outline\">\n                <Home className=\"mr-2 h-4 w-4\" />\n                {t('error.goHome')}\n              </Button>\n            )}\n            <Button onClick={handleReload} variant=\"outline\">\n              <RefreshCw className=\"mr-2 h-4 w-4\" />\n              {t('error.reload')}\n            </Button>\n            <Button onClick={handleCopy} variant=\"outline\">\n              <Copy className=\"mr-2 h-4 w-4\" />\n              {copied ? t('error.copied') : t('error.copyReport')}\n            </Button>\n            <Button onClick={() => setShowDetails(!showDetails)} variant=\"ghost\">\n              {showDetails ? t('error.hideDetails') : t('error.showDetails')}\n            </Button>\n          </div>\n\n          {/* Error Details */}\n          {showDetails && (\n            <div className=\"space-y-4\">\n              <div>\n                <p className=\"mb-2 font-medium text-sm\">{t('error.stackTrace')}</p>\n                <ScrollArea className=\"h-48 rounded-md border bg-muted/50 p-4\">\n                  <pre className=\"whitespace-pre-wrap break-words font-mono text-xs\">\n                    {errorInfo.error.stack || t('error.noStackTrace')}\n                  </pre>\n                </ScrollArea>\n              </div>\n\n              {errorInfo.errorInfo?.componentStack && (\n                <div>\n                  <p className=\"mb-2 font-medium text-sm\">{t('error.componentStack')}</p>\n                  <ScrollArea className=\"h-32 rounded-md border bg-muted/50 p-4\">\n                    <pre className=\"whitespace-pre-wrap break-words font-mono text-xs\">\n                      {errorInfo.errorInfo.componentStack}\n                    </pre>\n                  </ScrollArea>\n                </div>\n              )}\n\n              <div>\n                <p className=\"mb-2 font-medium text-sm\">{t('error.fullReport')}</p>\n                <Textarea\n                  className=\"min-h-48 font-mono text-xs\"\n                  onClick={(e) => {\n                    const target = e.target as HTMLTextAreaElement\n                    target.select()\n                  }}\n                  readOnly\n                  value={errorReport}\n                />\n              </div>\n            </div>\n          )}\n\n          {/* Help Text */}\n          <div className=\"rounded-md border bg-muted/50 p-4\">\n            <p className=\"text-muted-foreground text-sm\">{t('error.helpText')}</p>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n\nfunction generateErrorReport(errorInfo: ErrorInfo): string {\n  const lines: string[] = []\n\n  lines.push('=== VidBee Error Report ===')\n  lines.push(`Timestamp: ${new Date(errorInfo.timestamp).toISOString()}`)\n  lines.push('')\n\n  if (errorInfo.context) {\n    lines.push('--- Context ---')\n    if (errorInfo.context.version) {\n      lines.push(`App Version: ${errorInfo.context.version}`)\n    }\n    if (errorInfo.context.platform) {\n      lines.push(`Platform: ${errorInfo.context.platform}`)\n    }\n    if (errorInfo.context.url) {\n      lines.push(`URL: ${errorInfo.context.url}`)\n    }\n    if (errorInfo.context.userAgent) {\n      lines.push(`User Agent: ${errorInfo.context.userAgent}`)\n    }\n    lines.push('')\n  }\n\n  lines.push('--- Error ---')\n  lines.push(`Name: ${errorInfo.error.name}`)\n  lines.push(`Message: ${errorInfo.error.message}`)\n  lines.push('')\n\n  if (errorInfo.error.stack) {\n    lines.push('--- Stack Trace ---')\n    lines.push(errorInfo.error.stack)\n    lines.push('')\n  }\n\n  if (errorInfo.errorInfo?.componentStack) {\n    lines.push('--- Component Stack ---')\n    lines.push(errorInfo.errorInfo.componentStack)\n    lines.push('')\n  }\n\n  lines.push('=== End of Report ===')\n\n  return lines.join('\\n')\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/feedback/FeedbackLinks.tsx",
    "content": "import { ipcServices } from '@renderer/lib/ipc'\nimport { useEffect, useState } from 'react'\n\ninterface AppInfo {\n  appVersion: string\n  osVersion: string\n}\n\nconst DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }\nlet cachedAppInfo: AppInfo | null = null\nlet appInfoPromise: Promise<AppInfo> | null = null\n\nconst loadAppInfo = async (): Promise<AppInfo> => {\n  if (cachedAppInfo) {\n    return cachedAppInfo\n  }\n  if (appInfoPromise) {\n    return appInfoPromise\n  }\n\n  appInfoPromise = (async () => {\n    try {\n      const [version, osRelease] = await Promise.all([\n        ipcServices.app.getVersion(),\n        ipcServices.app.getOsVersion()\n      ])\n      cachedAppInfo = { appVersion: version, osVersion: osRelease }\n    } catch (error) {\n      console.error('Failed to load app info for feedback links:', error)\n      cachedAppInfo = DEFAULT_APP_INFO\n    }\n    return cachedAppInfo\n  })()\n\n  return appInfoPromise\n}\n\nexport const useAppInfo = (): AppInfo => {\n  const [appInfo, setAppInfo] = useState<AppInfo>(DEFAULT_APP_INFO)\n\n  useEffect(() => {\n    let isActive = true\n\n    const loadInfo = async () => {\n      const info = await loadAppInfo()\n      if (isActive) {\n        setAppInfo(info)\n      }\n    }\n\n    void loadInfo()\n\n    return () => {\n      isActive = false\n    }\n  }, [])\n\n  return appInfo\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/playlist/PlaylistPreviewCard.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle\n} from '@renderer/components/ui/card'\nimport { ScrollArea } from '@renderer/components/ui/scroll-area'\nimport type { PlaylistEntry, PlaylistInfo } from '@shared/types'\nimport { useTranslation } from 'react-i18next'\n\ninterface PlaylistPreviewCardProps {\n  playlist: PlaylistInfo\n  entries: PlaylistEntry[]\n  onClear?: () => void\n}\n\nexport function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {\n  const { t } = useTranslation()\n\n  const totalCount = playlist.entryCount\n  const selectedCount = entries.length\n  const firstIndex = entries[0]?.index ?? null\n  const lastIndex = entries.at(-1)?.index ?? firstIndex ?? null\n\n  return (\n    <Card className=\"overflow-hidden border border-border/60 bg-background/80 shadow-sm\">\n      <CardHeader className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\">\n        <div className=\"min-w-0 flex-1 space-y-1\">\n          <CardTitle\n            className=\"wrap-break-word truncate font-semibold text-base sm:text-lg\"\n            title={playlist.title}\n          >\n            {playlist.title || t('playlist.untitled')}\n          </CardTitle>\n          <CardDescription className=\"text-muted-foreground text-xs sm:text-sm\">\n            <div className=\"flex min-w-0 flex-wrap items-center gap-3 text-muted-foreground text-xs sm:text-sm\">\n              <span className=\"truncate\">{t('playlist.totalVideos', { count: totalCount })}</span>\n              {firstIndex !== null && lastIndex !== null ? (\n                <span className=\"truncate\">\n                  {t('playlist.selectedRange', {\n                    start: firstIndex,\n                    end: lastIndex\n                  })}\n                </span>\n              ) : (\n                <span className=\"truncate\">{t('playlist.noRangeSelected')}</span>\n              )}\n              <span className=\"truncate\">\n                {t('playlist.showingCount', { count: selectedCount })}\n              </span>\n            </div>\n          </CardDescription>\n        </div>\n        {onClear && (\n          <Button className=\"shrink-0\" onClick={onClear} size=\"sm\" variant=\"ghost\">\n            {t('playlist.clearPreview')}\n          </Button>\n        )}\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <div className=\"rounded-md border border-border/60 bg-muted/20\">\n          <ScrollArea className=\"max-h-64 w-full overflow-y-auto overflow-x-hidden pr-1\">\n            <ol className=\"w-full min-w-0 divide-y divide-border/60 text-sm leading-snug\">\n              {entries.length === 0 ? (\n                <li className=\"px-4 py-6 text-center text-muted-foreground text-xs\">\n                  {t('playlist.noEntriesInRange')}\n                </li>\n              ) : (\n                entries.map((entry) => (\n                  <li\n                    className=\"flex w-full min-w-0 max-w-full items-start gap-3 overflow-hidden px-4 py-2\"\n                    key={`${entry.index}-${entry.id}`}\n                  >\n                    <span className=\"w-12 shrink-0 text-center font-semibold text-muted-foreground text-xs\">\n                      #{entry.index}\n                    </span>\n                    <span\n                      className=\"wrap-break-word min-w-0 flex-1 overflow-hidden truncate text-sm\"\n                      title={entry.title}\n                    >\n                      {entry.title}\n                    </span>\n                  </li>\n                ))\n              )}\n            </ol>\n          </ScrollArea>\n        </div>\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport { Checkbox } from '@renderer/components/ui/checkbox'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@renderer/components/ui/dialog'\nimport { Input } from '@renderer/components/ui/input'\nimport { Label } from '@renderer/components/ui/label'\nimport { Switch } from '@renderer/components/ui/switch'\nimport { ipcServices } from '@renderer/lib/ipc'\nimport { cn } from '@renderer/lib/utils'\nimport { settingsAtom } from '@renderer/store/settings'\nimport { resolveFeedAtom } from '@renderer/store/subscriptions'\nimport { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'\nimport { useAtom, useSetAtom } from 'jotai'\nimport { ChevronRight } from 'lucide-react'\nimport { useEffect, useId, useRef, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\n\nconst sanitizeCommaList = (value: string) =>\n  value\n    .split(',')\n    .map((entry) => entry.trim())\n    .filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)\n\nconst sanitizeTemplateInput = (value: string) => value.replace(/\\\\/g, '/').replace(/\\/{2,}/g, '/')\n\nconst buildDefaultSubscriptionDirectory = (downloadPath: string) => {\n  const trimmed = downloadPath.trim().replace(/[\\\\/]+$/, '')\n  if (!trimmed) {\n    return 'Subscriptions'\n  }\n  return `${trimmed}/Subscriptions`\n}\n\nexport interface SubscriptionFormData {\n  url?: string\n  keywords?: string[]\n  tags?: string[]\n  onlyDownloadLatest?: boolean\n  downloadDirectory?: string\n  namingTemplate?: string\n  enabled?: boolean\n}\n\ninterface SubscriptionFormDialogProps {\n  mode: 'add' | 'edit'\n  subscription?: SubscriptionRule\n  open: boolean\n  onSave: (data: SubscriptionFormData) => Promise<void>\n  onClose: () => void\n}\n\nexport function SubscriptionFormDialog({\n  mode,\n  subscription,\n  open,\n  onSave,\n  onClose\n}: SubscriptionFormDialogProps) {\n  const { t } = useTranslation()\n  const [settings] = useAtom(settingsAtom)\n  const resolveFeed = useSetAtom(resolveFeedAtom)\n\n  // Form state\n  const [url, setUrl] = useState('')\n  const [keywords, setKeywords] = useState('')\n  const [tags, setTags] = useState('')\n  const [onlyLatest, setOnlyLatest] = useState(false)\n  const [downloadDirectory, setDownloadDirectory] = useState('')\n  const [namingTemplate, setNamingTemplate] = useState('')\n\n  // Feed detection state\n  const [detectingFeed, setDetectingFeed] = useState(false)\n\n  const detectTimeout = useRef<NodeJS.Timeout | null>(null)\n  const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))\n  const urlInputId = useId()\n  const advancedOptionsId = useId()\n  const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)\n\n  // Initialize form values based on mode\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n\n    setAdvancedOptionsOpen(false)\n\n    if (mode === 'edit' && subscription) {\n      setUrl(subscription.feedUrl)\n      setKeywords(subscription.keywords.join(', '))\n      setTags(subscription.tags.join(', '))\n      setOnlyLatest(subscription.onlyDownloadLatest)\n      setDownloadDirectory(subscription.downloadDirectory || '')\n      setNamingTemplate(subscription.namingTemplate || '')\n    } else {\n      // Add mode - use defaults from settings\n      setUrl('')\n      setKeywords('')\n      setTags('')\n      setOnlyLatest(settings.subscriptionOnlyLatestDefault)\n      setDownloadDirectory(buildDefaultSubscriptionDirectory(settings.downloadPath))\n      setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)\n    }\n  }, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])\n\n  // Sync download directory with settings changes (only in add mode)\n  useEffect(() => {\n    if (mode === 'add') {\n      const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)\n      setDownloadDirectory((prev) => {\n        if (!prev || prev === prevDefaultPathRef.current) {\n          return newPath\n        }\n        return prev\n      })\n      prevDefaultPathRef.current = newPath\n    }\n  }, [settings.downloadPath, mode])\n\n  // Sync onlyLatest with settings changes (only in add mode)\n  useEffect(() => {\n    if (mode === 'add') {\n      setOnlyLatest(settings.subscriptionOnlyLatestDefault)\n    }\n  }, [settings.subscriptionOnlyLatestDefault, mode])\n\n  // Feed detection logic\n  useEffect(() => {\n    if (!url.trim()) {\n      return\n    }\n\n    // In edit mode, don't detect if URL hasn't changed\n    if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {\n      return\n    }\n\n    if (detectTimeout.current) {\n      clearTimeout(detectTimeout.current)\n    }\n\n    detectTimeout.current = setTimeout(async () => {\n      setDetectingFeed(true)\n      try {\n        await resolveFeed(url.trim())\n      } catch (error) {\n        console.error('Failed to resolve feed:', error)\n      } finally {\n        setDetectingFeed(false)\n      }\n    }, 500)\n\n    return () => {\n      if (detectTimeout.current) {\n        clearTimeout(detectTimeout.current)\n      }\n    }\n  }, [url, resolveFeed, mode, subscription])\n\n  const handleSelectDirectory = async () => {\n    try {\n      const path = await ipcServices.fs.selectDirectory()\n      if (path) {\n        setDownloadDirectory(path)\n      }\n    } catch (error) {\n      console.error('Failed to select directory:', error)\n      toast.error(t('subscriptions.notifications.directoryError'))\n    }\n  }\n\n  const handleOpenRSSHubDocs = async () => {\n    try {\n      await ipcServices.fs.openExternal('https://docs.vidbee.org/rss')\n    } catch (error) {\n      console.error('Failed to open RSS documentation:', error)\n      toast.error(t('subscriptions.notifications.openLinkError'))\n    }\n  }\n\n  const handleSave = async () => {\n    // Validate URL for add mode\n    if (mode === 'add' && !url.trim()) {\n      toast.error(t('subscriptions.notifications.missingUrl'))\n      return\n    }\n\n    const formData: SubscriptionFormData = {\n      keywords: sanitizeCommaList(keywords),\n      tags: sanitizeCommaList(tags),\n      onlyDownloadLatest: onlyLatest,\n      downloadDirectory: downloadDirectory || undefined,\n      namingTemplate: namingTemplate || undefined\n    }\n\n    // Include URL if it's provided and different from current (for edit mode)\n    if (\n      url.trim() &&\n      (mode === 'add' || (mode === 'edit' && subscription && url.trim() !== subscription.feedUrl))\n    ) {\n      try {\n        await resolveFeed(url.trim())\n        formData.url = url.trim()\n      } catch (error) {\n        console.error('Failed to resolve feed:', error)\n        toast.error(t('subscriptions.notifications.resolveError'))\n        return\n      }\n    }\n\n    await onSave(formData)\n  }\n\n  const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'\n  const descriptionKey =\n    mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'\n  const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'\n\n  return (\n    <Dialog onOpenChange={(isOpen) => !isOpen && onClose()} open={open}>\n      <DialogContent className=\"max-h-[90vh] max-w-2xl overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle>\n            {mode === 'edit' && subscription\n              ? t(titleKey, { name: subscription.title })\n              : t(titleKey)}\n          </DialogTitle>\n          <DialogDescription>{t(descriptionKey)}</DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-4 py-2\">\n          <div className=\"space-y-2\">\n            <Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>\n            <Input\n              id={urlInputId}\n              onChange={(event) => setUrl(event.target.value)}\n              placeholder=\"https://docs.rsshub.app/routes/youtube/user/@FKJ\"\n              value={url}\n            />\n            {detectingFeed && (\n              <p className=\"text-muted-foreground text-xs\">{t('subscriptions.detecting')}</p>\n            )}\n            {mode === 'add' && !url.trim() && (\n              <div className=\"flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2\">\n                <p className=\"flex-1 text-muted-foreground text-xs\">\n                  {t('subscriptions.rssHub.hint')}\n                </p>\n                <Button\n                  className=\"h-5 w-5 shrink-0 p-0\"\n                  onClick={() => void handleOpenRSSHubDocs()}\n                  size=\"sm\"\n                  title={t('subscriptions.rssHub.openDocs')}\n                  variant=\"ghost\"\n                >\n                  <ChevronRight className=\"h-3 w-3\" />\n                </Button>\n              </div>\n            )}\n          </div>\n          <div className=\"space-y-2\">\n            <Label>{t('subscriptions.fields.customDirectory')}</Label>\n            <div className=\"flex gap-2\">\n              <Input readOnly value={downloadDirectory} />\n              <Button onClick={() => void handleSelectDirectory()} variant=\"secondary\">\n                {t('subscriptions.actions.selectDirectory')}\n              </Button>\n            </div>\n          </div>\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between gap-4 rounded-md border px-3 py-2\">\n              <p className=\"text-sm\">{t('subscriptions.fields.onlyLatest')}</p>\n              <Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />\n            </div>\n          </div>\n          <div\n            aria-hidden={!advancedOptionsOpen}\n            className={cn(\n              'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',\n              advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n            )}\n            data-state={advancedOptionsOpen ? 'open' : 'closed'}\n          >\n            <div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>\n              <div className=\"space-y-3 border-t pt-4\">\n                <div className=\"space-y-2\">\n                  <Label>{t('subscriptions.fields.keywords')}</Label>\n                  <Input onChange={(event) => setKeywords(event.target.value)} value={keywords} />\n                </div>\n                <div className=\"space-y-2\">\n                  <Label>{t('subscriptions.fields.tags')}</Label>\n                  <Input onChange={(event) => setTags(event.target.value)} value={tags} />\n                </div>\n                <div className=\"space-y-2\">\n                  <Label>{t('subscriptions.fields.namingTemplate')}</Label>\n                  <Input\n                    onChange={(event) =>\n                      setNamingTemplate(sanitizeTemplateInput(event.target.value))\n                    }\n                    value={namingTemplate}\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n        <DialogFooter>\n          <div className=\"flex w-full items-center justify-between gap-4\">\n            <div className=\"flex items-center gap-2\">\n              <Checkbox\n                checked={advancedOptionsOpen}\n                id={advancedOptionsId}\n                onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}\n              />\n              <Label className=\"cursor-pointer\" htmlFor={advancedOptionsId}>\n                {t('advancedOptions.title')}\n              </Label>\n            </div>\n            <div className=\"ml-auto flex gap-2\">\n              {mode === 'add' && (\n                <Button onClick={onClose} variant=\"outline\">\n                  {t('download.cancel')}\n                </Button>\n              )}\n              <Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>\n            </div>\n          </div>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/accordion.tsx",
    "content": "import {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger\n} from '@vidbee/ui/components/ui/accordion'\n\nexport { Accordion, AccordionContent, AccordionItem, AccordionTrigger }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/add-url-popover.tsx",
    "content": "import { AddUrlPopover } from '@vidbee/ui/components/ui/add-url-popover'\n\nexport { AddUrlPopover }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/badge.tsx",
    "content": "import { Badge, badgeVariants } from '@vidbee/ui/components/ui/badge'\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/button.tsx",
    "content": "import { Button, type ButtonProps, buttonVariants } from '@vidbee/ui/components/ui/button'\n\nexport { Button, type ButtonProps, buttonVariants }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/card.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle\n} from '@vidbee/ui/components/ui/card'\n\nexport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/checkbox.tsx",
    "content": "import { Checkbox } from '@vidbee/ui/components/ui/checkbox'\n\nexport { Checkbox }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/context-menu.tsx",
    "content": "import {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuGroup,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuPortal,\n  ContextMenuRadioGroup,\n  ContextMenuRadioItem,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger\n} from '@vidbee/ui/components/ui/context-menu'\n\nexport {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuGroup,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuPortal,\n  ContextMenuRadioGroup,\n  ContextMenuRadioItem,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/dialog.tsx",
    "content": "import {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger\n} from '@vidbee/ui/components/ui/dialog'\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/download-dialog-layout.tsx",
    "content": "import { DownloadDialogLayout } from '@vidbee/ui/components/ui/download-dialog-layout'\n\nexport { DownloadDialogLayout }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/dropdown-menu.tsx",
    "content": "import {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger\n} from '@vidbee/ui/components/ui/dropdown-menu'\n\nexport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/hover-card.tsx",
    "content": "import { HoverCard, HoverCardContent, HoverCardTrigger } from '@vidbee/ui/components/ui/hover-card'\n\nexport { HoverCard, HoverCardContent, HoverCardTrigger }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/image-with-placeholder.tsx",
    "content": "import { ImageWithPlaceholder } from '@vidbee/ui/components/ui/image-with-placeholder'\n\nexport { ImageWithPlaceholder }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/input.tsx",
    "content": "import { Input } from '@vidbee/ui/components/ui/input'\n\nexport { Input }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/item.tsx",
    "content": "import {\n  Item,\n  ItemActions,\n  ItemContent,\n  ItemDescription,\n  ItemFooter,\n  ItemGroup,\n  ItemHeader,\n  ItemMedia,\n  ItemSeparator,\n  ItemTitle\n} from '@vidbee/ui/components/ui/item'\n\nexport {\n  Item,\n  ItemActions,\n  ItemContent,\n  ItemDescription,\n  ItemFooter,\n  ItemGroup,\n  ItemHeader,\n  ItemMedia,\n  ItemSeparator,\n  ItemTitle\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/label.tsx",
    "content": "import { Label } from '@vidbee/ui/components/ui/label'\n\nexport { Label }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/popover.tsx",
    "content": "import {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n  PopoverTrigger\n} from '@vidbee/ui/components/ui/popover'\n\nexport { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/progress.tsx",
    "content": "import { Progress } from '@vidbee/ui/components/ui/progress'\n\nexport { Progress }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/radio-group.tsx",
    "content": "import { RadioGroup, RadioGroupItem } from '@vidbee/ui/components/ui/radio-group'\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/remote-image.tsx",
    "content": "import { APP_PROTOCOL_SCHEME } from '@shared/constants'\nimport {\n  RemoteImage as SharedRemoteImage,\n  type RemoteImageProps as SharedRemoteImageProps\n} from '@vidbee/ui/components/ui/remote-image'\nimport { useCallback } from 'react'\nimport { ipcServices } from '../../lib/ipc'\n\ntype RemoteImageProps = Omit<SharedRemoteImageProps, 'cacheResolver' | 'localUrlPrefixes'>\n\nconst desktopLocalPrefixes = [APP_PROTOCOL_SCHEME, 'file://', 'data:', 'blob:']\n\nexport function RemoteImage(props: RemoteImageProps) {\n  const cacheResolver = useCallback(async (url: string): Promise<string | undefined> => {\n    try {\n      const localPath = await ipcServices.thumbnail.getThumbnailPath(url)\n      return localPath ?? undefined\n    } catch {\n      return undefined\n    }\n  }, [])\n\n  return (\n    <SharedRemoteImage\n      cacheResolver={cacheResolver}\n      localUrlPrefixes={desktopLocalPrefixes}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/scroll-area.tsx",
    "content": "import { ScrollArea, ScrollBar } from '@vidbee/ui/components/ui/scroll-area'\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/select.tsx",
    "content": "import {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue\n} from '@vidbee/ui/components/ui/select'\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/separator.tsx",
    "content": "import { Separator } from '@vidbee/ui/components/ui/separator'\n\nexport { Separator }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/sheet.tsx",
    "content": "import {\n  Sheet,\n  SheetClose,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger\n} from '@vidbee/ui/components/ui/sheet'\n\nexport {\n  Sheet,\n  SheetClose,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/sidebar.tsx",
    "content": "import { AppSidebar, type AppSidebarItem } from '@vidbee/ui/components/ui/app-sidebar'\nimport { appSidebarIcons } from '@vidbee/ui/components/ui/app-sidebar-icons'\nimport { useAtomValue } from 'jotai'\nimport { useTranslation } from 'react-i18next'\nimport '../../assets/title-bar.css'\nimport { updateAvailableAtom } from '@renderer/store/update'\n\ntype Page = 'home' | 'subscriptions' | 'settings' | 'about'\n\ninterface SidebarProps {\n  currentPage: Page\n  onPageChange: (page: Page) => void\n  onOpenSupportedSites: () => void\n}\n\nexport function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {\n  const { t } = useTranslation()\n  const updateAvailable = useAtomValue(updateAvailableAtom)\n\n  const items: AppSidebarItem[] = [\n    {\n      id: 'home',\n      active: currentPage === 'home',\n      icon: appSidebarIcons.home,\n      label: t('menu.download'),\n      onClick: () => onPageChange('home')\n    },\n    {\n      id: 'subscriptions',\n      active: currentPage === 'subscriptions',\n      icon: appSidebarIcons.subscriptions,\n      label: t('menu.rss'),\n      onClick: () => onPageChange('subscriptions')\n    },\n    {\n      id: 'supported-sites',\n      icon: appSidebarIcons.supportedSites,\n      label: t('menu.supportedSites'),\n      onClick: onOpenSupportedSites\n    }\n  ]\n\n  const bottomItems: AppSidebarItem[] = [\n    {\n      id: 'settings',\n      active: currentPage === 'settings',\n      icon: appSidebarIcons.settings,\n      label: t('menu.preferences'),\n      onClick: () => onPageChange('settings'),\n      showLabel: false,\n      showTooltip: true\n    },\n    {\n      id: 'about',\n      active: currentPage === 'about',\n      icon: appSidebarIcons.about,\n      indicator: updateAvailable.available,\n      label: t('menu.about'),\n      onClick: () => onPageChange('about'),\n      showLabel: false,\n      showTooltip: true\n    }\n  ]\n\n  return <AppSidebar appName=\"VidBee\" bottomItems={bottomItems} items={items} logoAlt=\"VidBee\" />\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/sonner.tsx",
    "content": "import { Toaster } from '@vidbee/ui/components/ui/sonner'\n\nexport { Toaster }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/switch.tsx",
    "content": "import { Switch } from '@vidbee/ui/components/ui/switch'\n\nexport { Switch }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/table.tsx",
    "content": "import {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableFooter,\n  TableHead,\n  TableHeader,\n  TableRow\n} from '@vidbee/ui/components/ui/table'\n\nexport { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/tabs.tsx",
    "content": "import { Tabs, TabsContent, TabsList, TabsTrigger } from '@vidbee/ui/components/ui/tabs'\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/textarea.tsx",
    "content": "import { Textarea } from '@vidbee/ui/components/ui/textarea'\n\nexport { Textarea }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/title-bar.tsx",
    "content": "import { TitleBar as SharedTitleBar } from '@vidbee/ui/components/ui/title-bar'\nimport { useEffect, useState } from 'react'\nimport IconFluentDismiss20Regular from '~icons/fluent/dismiss-20-regular'\nimport IconFluentMaximize20Regular from '~icons/fluent/maximize-20-regular'\nimport IconFluentSquareMultiple20Regular from '~icons/fluent/square-multiple-20-regular'\nimport IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'\nimport { ipcEvents, ipcServices } from '../../lib/ipc'\nimport '../../assets/title-bar.css'\n\ninterface TitleBarProps {\n  platform?: string\n}\n\nexport function TitleBar({ platform }: TitleBarProps) {\n  const [isMaximized, setIsMaximized] = useState(false)\n\n  useEffect(() => {\n    const handleMaximized = () => {\n      setIsMaximized(true)\n    }\n\n    const handleUnmaximized = () => {\n      setIsMaximized(false)\n    }\n\n    ipcEvents.on('window-maximized', handleMaximized)\n    ipcEvents.on('window-unmaximized', handleUnmaximized)\n\n    return () => {\n      ipcEvents.removeListener('window-maximized', handleMaximized)\n      ipcEvents.removeListener('window-unmaximized', handleUnmaximized)\n    }\n  }, [])\n\n  return (\n    <SharedTitleBar\n      icons={{\n        close: IconFluentDismiss20Regular,\n        maximize: IconFluentMaximize20Regular,\n        minimize: IconFluentSubtract20Regular,\n        restore: IconFluentSquareMultiple20Regular\n      }}\n      isMaximized={isMaximized}\n      onClose={() => ipcServices.window.close()}\n      onMaximize={() => ipcServices.window.maximize()}\n      onMinimize={() => ipcServices.window.minimize()}\n      platform={platform}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/ui/tooltip.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@vidbee/ui/components/ui/tooltip'\n\nexport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/components/video/AdvancedOptions.tsx",
    "content": "import {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger\n} from '@renderer/components/ui/accordion'\nimport { Input } from '@renderer/components/ui/input'\nimport { Label } from '@renderer/components/ui/label'\nimport { Switch } from '@renderer/components/ui/switch'\nimport { useTranslation } from 'react-i18next'\n\ninterface AdvancedOptionsProps {\n  startTime: string\n  endTime: string\n  downloadSubs: boolean\n  onStartTimeChange: (value: string) => void\n  onEndTimeChange: (value: string) => void\n  onDownloadSubsChange: (value: boolean) => void\n  showAccordion?: boolean\n}\n\nexport function AdvancedOptions({\n  startTime,\n  endTime,\n  downloadSubs,\n  onStartTimeChange,\n  onEndTimeChange,\n  onDownloadSubsChange,\n  showAccordion = true\n}: AdvancedOptionsProps) {\n  const { t } = useTranslation()\n\n  const content = (\n    <div className=\"space-y-6\">\n      {/* Time Range */}\n      <div className=\"space-y-2\">\n        <Label className=\"ml-1 font-medium text-muted-foreground text-xs\">\n          {t('advancedOptions.timeRange')}\n        </Label>\n        <div className=\"flex items-center gap-4\">\n          <div className=\"group relative flex-1\">\n            <Input\n              className=\"h-9 text-center\"\n              onChange={(e) => onStartTimeChange(e.target.value)}\n              placeholder={t('advancedOptions.startPlaceholder')}\n              title={t('advancedOptions.startHint')}\n              value={startTime}\n            />\n          </div>\n          <span className=\"text-muted-foreground text-xs\">-</span>\n          <div className=\"group relative flex-1\">\n            <Input\n              className=\"h-9 text-center\"\n              onChange={(e) => onEndTimeChange(e.target.value)}\n              placeholder={t('advancedOptions.endPlaceholder')}\n              title={t('advancedOptions.endHint')}\n              value={endTime}\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* Subtitles */}\n      <div className=\"flex items-center justify-between rounded-md border bg-muted/30 p-3\">\n        <div className=\"space-y-0.5\">\n          <Label className=\"font-semibold text-sm\">{t('advancedOptions.downloadSubs')}</Label>\n          <p className=\"text-[11px] text-muted-foreground\">\n            {t('advancedOptions.downloadSubsHint')}\n          </p>\n        </div>\n        <Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />\n      </div>\n    </div>\n  )\n\n  if (!showAccordion) {\n    return content\n  }\n\n  return (\n    <Accordion className=\"w-full\" collapsible type=\"single\">\n      <AccordionItem className=\"border-b\" value=\"advanced\">\n        <AccordionTrigger className=\"flex items-center gap-2 py-4 font-semibold text-sm hover:no-underline\">\n          <span className=\"flex-1 text-left\">{t('advancedOptions.title')}</span>\n        </AccordionTrigger>\n        <AccordionContent className=\"pt-2 pb-6\">{content}</AccordionContent>\n      </AccordionItem>\n    </Accordion>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/data/popularSites.ts",
    "content": "export interface PopularSite {\n  id: string\n  url: string\n  domain: string\n}\n\nexport const popularSites: PopularSite[] = [\n  { id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },\n  { id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },\n  { id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },\n  { id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },\n  { id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },\n  { id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },\n  { id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },\n  { id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },\n  { id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },\n  { id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },\n  { id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },\n  { id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },\n  { id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },\n  { id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },\n  { id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },\n  { id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },\n  { id: 'kick', url: 'https://kick.com', domain: 'kick.com' },\n  { id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }\n]\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/hooks/use-cached-thumbnail.ts",
    "content": "import { APP_PROTOCOL_SCHEME } from '@shared/constants'\nimport { useEffect, useState } from 'react'\nimport { ipcServices } from '../lib/ipc'\n\nexport const useCachedThumbnail = (url?: string | null): string | undefined => {\n  const [cachedUrl, setCachedUrl] = useState<string | undefined>()\n\n  useEffect(() => {\n    let isActive = true\n\n    const loadThumbnail = async () => {\n      if (!url) {\n        setCachedUrl(undefined)\n        return\n      }\n\n      if (\n        url.startsWith(APP_PROTOCOL_SCHEME) ||\n        url.startsWith('file://') ||\n        url.startsWith('data:')\n      ) {\n        setCachedUrl(url)\n        return\n      }\n\n      try {\n        const localUrl = await ipcServices.thumbnail.getThumbnailPath(url)\n        if (!isActive) {\n          return\n        }\n        setCachedUrl(localUrl ?? undefined)\n      } catch (error) {\n        console.error('Failed to load cached thumbnail:', error)\n        if (!isActive) {\n          return\n        }\n        setCachedUrl(undefined)\n      }\n    }\n\n    void loadThumbnail()\n\n    return () => {\n      isActive = false\n    }\n  }, [url])\n\n  return cachedUrl\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/hooks/use-download-events.ts",
    "content": "import type { DownloadItem } from '@shared/types'\nimport { useSetAtom, useStore } from 'jotai'\nimport { useCallback, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { ipcEvents, ipcServices } from '../lib/ipc'\nimport {\n  addDownloadAtom,\n  addHistoryRecordAtom,\n  downloadRecordsAtom,\n  removeDownloadAtom,\n  removeHistoryRecordAtom,\n  updateDownloadAtom\n} from '../store/downloads'\n\nconst isFinalStatus = (status?: string): boolean =>\n  status === 'completed' || status === 'error' || status === 'cancelled'\n\nexport function useDownloadEvents() {\n  const updateDownload = useSetAtom(updateDownloadAtom)\n  const addDownload = useSetAtom(addDownloadAtom)\n  const addHistoryRecord = useSetAtom(addHistoryRecordAtom)\n  const removeDownload = useSetAtom(removeDownloadAtom)\n  const removeHistoryRecord = useSetAtom(removeHistoryRecordAtom)\n  const { t } = useTranslation()\n  const store = useStore()\n\n  const syncHistoryItem = useCallback(\n    async (id: string) => {\n      try {\n        const historyItem = await ipcServices.history.getHistoryById(id)\n        if (!historyItem) {\n          return\n        }\n        addHistoryRecord(historyItem)\n        if (isFinalStatus(historyItem.status)) {\n          removeDownload(id)\n        }\n      } catch (error) {\n        console.error('Failed to sync history item:', error)\n      }\n    },\n    [addHistoryRecord, removeDownload]\n  )\n\n  useEffect(() => {\n    const syncActiveDownloads = async () => {\n      try {\n        const activeDownloads = await ipcServices.download.getActiveDownloads()\n        activeDownloads.forEach((item) => {\n          addDownload(item)\n        })\n      } catch (error) {\n        console.error('Failed to load active downloads:', error)\n      }\n    }\n\n    void syncActiveDownloads()\n  }, [addDownload])\n\n  useEffect(() => {\n    const handleStarted = (rawId: unknown) => {\n      const id = typeof rawId === 'string' ? rawId : ''\n      if (!id) {\n        return\n      }\n      updateDownload({\n        id,\n        changes: {\n          status: 'downloading',\n          startedAt: Date.now()\n        }\n      })\n    }\n\n    const handleProgress = (rawData: unknown) => {\n      const data = rawData as { id?: string; progress?: unknown }\n      const id = typeof data?.id === 'string' ? data.id : ''\n      if (!id) {\n        return\n      }\n      const progress = (data.progress ?? {}) as {\n        percent?: number\n        currentSpeed?: string\n        eta?: string\n        downloaded?: string\n        total?: string\n      }\n      updateDownload({\n        id,\n        changes: {\n          progress: {\n            percent: typeof progress.percent === 'number' ? progress.percent : 0,\n            currentSpeed: progress.currentSpeed || '',\n            eta: progress.eta || '',\n            downloaded: progress.downloaded || '',\n            total: progress.total || ''\n          },\n          speed: progress.currentSpeed || ''\n        }\n      })\n    }\n\n    const handleLog = (rawData: unknown) => {\n      const data = rawData as { id?: string; log?: string }\n      const id = typeof data?.id === 'string' ? data.id : ''\n      if (!id) {\n        return\n      }\n      const logText = typeof data?.log === 'string' ? data.log : ''\n      updateDownload({ id, changes: { ytDlpLog: logText } })\n    }\n\n    const handleCompleted = (rawId: unknown) => {\n      const id = typeof rawId === 'string' ? rawId : ''\n      if (!id) {\n        return\n      }\n      updateDownload({ id, changes: { status: 'completed', completedAt: Date.now() } })\n      toast.success(t('notifications.downloadCompleted'))\n      void syncHistoryItem(id)\n    }\n\n    const handleError = (rawData: unknown) => {\n      const data = rawData as { id?: string; error?: string }\n      const id = typeof data?.id === 'string' ? data.id : ''\n      if (!id) {\n        return\n      }\n      const errorMessage = typeof data?.error === 'string' ? data.error : ''\n      updateDownload({ id, changes: { status: 'error', error: errorMessage } })\n      toast.error(t('notifications.downloadFailed'))\n      void syncHistoryItem(id)\n    }\n\n    const handleCancelled = (rawId: unknown) => {\n      const id = typeof rawId === 'string' ? rawId : ''\n      if (!id) {\n        return\n      }\n      removeDownload(id)\n      removeHistoryRecord(id)\n    }\n\n    const handleQueued = (rawItem: unknown) => {\n      const item = rawItem as DownloadItem\n      if (!item || typeof item.id !== 'string') {\n        return\n      }\n      const records = store.get(downloadRecordsAtom)\n      if (records.has(`active:${item.id}`)) {\n        return\n      }\n      addDownload(item)\n    }\n\n    const handleUpdated = (rawData: unknown) => {\n      const data = rawData as { id?: string; updates?: Partial<DownloadItem> }\n      const id = typeof data?.id === 'string' ? data.id : ''\n      if (!(id && data?.updates)) {\n        return\n      }\n      updateDownload({ id, changes: data.updates })\n    }\n\n    const queuedSubscription = ipcEvents.on('download:queued', handleQueued)\n    const updatedSubscription = ipcEvents.on('download:updated', handleUpdated)\n    const startedSubscription = ipcEvents.on('download:started', handleStarted)\n    const progressSubscription = ipcEvents.on('download:progress', handleProgress)\n    const logSubscription = ipcEvents.on('download:log', handleLog)\n    const completedSubscription = ipcEvents.on('download:completed', handleCompleted)\n    const errorSubscription = ipcEvents.on('download:error', handleError)\n    const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled)\n    return () => {\n      ipcEvents.removeListener('download:queued', queuedSubscription)\n      ipcEvents.removeListener('download:updated', updatedSubscription)\n      ipcEvents.removeListener('download:started', startedSubscription)\n      ipcEvents.removeListener('download:progress', progressSubscription)\n      ipcEvents.removeListener('download:log', logSubscription)\n      ipcEvents.removeListener('download:completed', completedSubscription)\n      ipcEvents.removeListener('download:error', errorSubscription)\n      ipcEvents.removeListener('download:cancelled', cancelledSubscription)\n    }\n  }, [addDownload, removeDownload, removeHistoryRecord, store, syncHistoryItem, t, updateDownload])\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/hooks/use-history-sync.ts",
    "content": "import { useSetAtom } from 'jotai'\nimport { useEffect } from 'react'\n// import type { DownloadHistoryItem } from '../../../shared/types'\nimport { ipcServices } from '../lib/ipc'\nimport { addHistoryRecordAtom, clearHistoryRecordsAtom } from '../store/downloads'\n\nexport function useHistorySync() {\n  const addHistoryItem = useSetAtom(addHistoryRecordAtom)\n  const clearHistory = useSetAtom(clearHistoryRecordsAtom)\n\n  useEffect(() => {\n    // Load initial history from main process\n    const loadHistory = async () => {\n      try {\n        const historyData = await ipcServices.history.getHistory()\n        // Clear existing history and load from main process\n        clearHistory()\n        historyData.forEach((item) => {\n          addHistoryItem(item)\n        })\n      } catch (error) {\n        console.error('Failed to load history:', error)\n      }\n    }\n\n    loadHistory()\n\n    // Listen for new history items from main process\n    // const _handleHistoryAdded = (item: DownloadHistoryItem) => {\n    //   addHistoryItem(item)\n    // }\n\n    // Note: We would need to add IPC events for real-time updates\n    // For now, we'll rely on manual refresh or page navigation\n  }, [addHistoryItem, clearHistory])\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/hooks/use-ipc-example.ts",
    "content": "import { ipcServices } from '@renderer/lib/ipc'\nimport { useState } from 'react'\nimport { toast } from 'sonner'\n\n/**\n * Example custom hook demonstrating IPC communication using electron-ipc-decorator\n * This shows how to create reusable hooks for IPC calls with type safety\n *\n * 展示两种服务的使用：\n * 1. AppService - 应用相关功能（版本、语言切换等）\n * 2. ExampleService - 示例功能（ping、问候等）\n */\nexport function useIpcExample() {\n  const [loading, setLoading] = useState(false)\n  const [response, setResponse] = useState<string>('')\n\n  // Example Service Methods - These are commented out as the example service doesn't exist\n  const ping = async () => {\n    setLoading(true)\n    try {\n      // Example service not available\n      setResponse('Example service not available')\n      toast.info('Example service not available')\n      return 'Example service not available'\n    } catch (error) {\n      toast.error('Failed to ping')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const greet = async (name: string) => {\n    setLoading(true)\n    try {\n      // Example service not available\n      setResponse(`Hello ${name}!`)\n      toast.success(`Hello ${name}!`)\n      return `Hello ${name}!`\n    } catch (error) {\n      toast.error('Failed to greet')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const getSystemInfo = async () => {\n    setLoading(true)\n    try {\n      // Get platform info from app service\n      const platform = await ipcServices?.app.getPlatform()\n      toast.success(`Platform: ${platform}`)\n      return { platform }\n    } catch (error) {\n      toast.error('Failed to get system info')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  // App Service Methods - 展示应用服务的使用\n  const getAppVersion = async () => {\n    setLoading(true)\n    try {\n      const version = await ipcServices?.app.getVersion()\n      setResponse(`App Version: ${version}`)\n      toast.success(`应用版本: ${version}`)\n      return version\n    } catch (error) {\n      toast.error('获取应用版本失败')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const getAppInfo = async () => {\n    setLoading(true)\n    try {\n      const version = await ipcServices?.app.getVersion()\n      const platform = await ipcServices?.app.getPlatform()\n      const info = { name: 'VidBee', version, platform }\n      setResponse(`App: ${info.name} v${info.version} (${info.platform})`)\n      toast.success(`应用: ${info.name} v${info.version}`)\n      return info\n    } catch (error) {\n      toast.error('获取应用信息失败')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const switchAppLocale = async (_locale: string) => {\n    setLoading(true)\n    try {\n      // Language switching not implemented in app service\n      setResponse('Language switching not implemented')\n      toast.info('语言切换功能未实现')\n      return true\n    } catch (error) {\n      toast.error('切换语言失败')\n      console.error(error)\n      throw error\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return {\n    loading,\n    response,\n    // Example Service\n    ping,\n    greet,\n    getSystemInfo,\n    // App Service\n    getAppVersion,\n    getAppInfo,\n    switchAppLocale\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/i18n.ts",
    "content": "import { initSharedI18n } from '@vidbee/i18n'\nimport i18n from 'i18next'\n\nvoid initSharedI18n(i18n)\n\nexport default i18n\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/lib/ipc.ts",
    "content": "/**\n * IPC Services for Renderer Process\n *\n * This file provides a convenient way to access IPC services in the renderer process.\n * All services are type-safe and automatically generated from the main process.\n *\n * Usage:\n * import { ipcServices, ipcEvents } from '@renderer/lib/ipc'\n *\n * const version = await ipcServices.app.getAppVersion()\n * const info = await ipcServices.app.getAppInfo()\n * await ipcServices.app.switchAppLocale('zh-CN')\n *\n * // Event listening\n * const unsubscribe = ipcEvents.on('download:started', (id: string) => {\n *   console.log('Download started:', id)\n * })\n * ipcEvents.removeListener('download:started', unsubscribe)\n */\n\nimport type { IpcServices } from '@shared/types/ipc'\n\nimport { createIpcProxy } from 'electron-ipc-decorator/client'\n\n// ipcRenderer should be exposed through electron's context bridge\n// Create type-safe IPC proxy for renderer process\nexport const ipcServices = createIpcProxy<IpcServices>(\n  window.electron.ipcRenderer as unknown as Electron.IpcRenderer\n) as NonNullable<ReturnType<typeof createIpcProxy<IpcServices>>>\n\n// Export event listening utilities\nexport const ipcEvents = {\n  on: (channel: string, callback: (...args: unknown[]) => void) => {\n    return window.api.on(channel, callback)\n  },\n  removeListener: (channel: string, callback: (...args: unknown[]) => void) => {\n    window.api.removeListener(channel, callback)\n  },\n  send: (channel: string, ...args: unknown[]) => {\n    window.api.send(channel, ...args)\n  }\n}\n\n// Export types for use in other files\nexport type { IpcServices }\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/lib/logger.ts",
    "content": "/**\n * Renderer process logger utility\n * Use electron-log/renderer which automatically forwards logs to main process\n */\n\nimport log from 'electron-log/renderer'\n\n// Export electron-log instance\nexport default log\n\n// Export commonly used logging methods\nexport const logger = log\n\n// Predefined scoped loggers\nexport const scopedLoggers = {\n  renderer: log.scope('renderer'),\n  error: log.scope('error'),\n  component: log.scope('component'),\n  api: log.scope('api')\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/main.tsx",
    "content": "import './assets/main.css'\nimport './assets/global.css'\nimport 'flag-icons/css/flag-icons.min.css'\n\nimport { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\nimport './i18n'\nimport { logger } from './lib/logger'\n\n// Setup global error handlers\nsetupGlobalErrorHandlers()\n\n// Get app version asynchronously\nlet appVersion: string | undefined\nif (window?.api && window.electron?.ipcRenderer) {\n  import('./lib/ipc')\n    .then(({ ipcServices }) => ipcServices.app.getVersion())\n    .then((version) => {\n      appVersion = version\n    })\n    .catch((err) => {\n      logger.warn('Failed to get app version for error reporting:', err)\n    })\n}\n\nfunction setupGlobalErrorHandlers(): void {\n  // Handle uncaught JavaScript errors\n  window.addEventListener('error', (event) => {\n    logger.error('Uncaught error:', event.error)\n\n    if (window?.api) {\n      try {\n        window.api.send('error:renderer', {\n          error: {\n            name: event.error?.name || 'Error',\n            message: event.error?.message || event.message || 'Unknown error',\n            stack: event.error?.stack || event.filename\n          },\n          timestamp: Date.now(),\n          context: {\n            url: window.location.href,\n            userAgent: navigator.userAgent,\n            platform: navigator.platform,\n            version: appVersion,\n            filename: event.filename,\n            lineno: event.lineno,\n            colno: event.colno\n          }\n        })\n      } catch (err) {\n        logger.error('Failed to send error to main process:', err)\n      }\n    }\n  })\n\n  // Handle unhandled promise rejections\n  window.addEventListener('unhandledrejection', (event) => {\n    logger.error('Unhandled promise rejection:', event.reason)\n\n    if (window?.api) {\n      try {\n        const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))\n\n        window.api.send('error:renderer', {\n          error: {\n            name: error.name || 'UnhandledPromiseRejection',\n            message: error.message || String(event.reason),\n            stack: error.stack\n          },\n          timestamp: Date.now(),\n          context: {\n            url: window.location.href,\n            userAgent: navigator.userAgent,\n            platform: navigator.platform,\n            version: appVersion\n          }\n        })\n      } catch (err) {\n        logger.error('Failed to send error to main process:', err)\n      }\n    }\n  })\n}\n\nconst rootElement = document.getElementById('root')\nif (!rootElement) {\n  throw new Error('Root element not found')\n}\ncreateRoot(rootElement).render(\n  <StrictMode>\n    <App />\n  </StrictMode>\n)\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/pages/About.tsx",
    "content": "import { useAppInfo } from '@renderer/components/feedback/FeedbackLinks'\nimport { Badge } from '@renderer/components/ui/badge'\nimport { Button } from '@renderer/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'\nimport { Progress } from '@renderer/components/ui/progress'\nimport { Switch } from '@renderer/components/ui/switch'\nimport { FeedbackLinkButtons } from '@vidbee/ui/components/ui/feedback-link-buttons'\nimport { useAtom, useSetAtom } from 'jotai'\nimport type { LucideIcon } from 'lucide-react'\nimport {\n  Download,\n  Facebook,\n  FileText,\n  Github,\n  Link as LinkIcon,\n  MessageSquare,\n  RefreshCw,\n  Twitter\n} from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { ipcEvents, ipcServices } from '../lib/ipc'\nimport { saveSettingAtom, settingsAtom } from '../store/settings'\nimport { updateAvailableAtom, updateReadyAtom } from '../store/update'\n\ninterface AboutResource {\n  icon: LucideIcon\n  label: string\n  description?: string\n  actionLabel: string\n  href?: string\n  onClick?: () => void\n}\n\ntype LatestVersionState =\n  | { status: 'available'; version: string }\n  | { status: 'uptodate'; version: string }\n  | { status: 'error'; error?: string }\n  | null\n\nexport function About() {\n  const { t, i18n } = useTranslation()\n  const [settings, _setSettings] = useAtom(settingsAtom)\n  const [updateReady] = useAtom(updateReadyAtom)\n  const [updateAvailableState] = useAtom(updateAvailableAtom)\n  const setUpdateAvailable = useSetAtom(updateAvailableAtom)\n  const { appVersion, osVersion } = useAppInfo()\n  const appVersionLabel = appVersion || '—'\n  const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)\n  const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)\n  const saveSetting = useSetAtom(saveSettingAtom)\n  const shareTargetUrl = 'https://vidbee.org'\n\n  useEffect(() => {\n    if (!updateAvailableState.available) {\n      return\n    }\n\n    setLatestVersionState({\n      status: 'available',\n      version: updateAvailableState.version ?? ''\n    })\n  }, [updateAvailableState.available, updateAvailableState.version])\n\n  // Listen for update events only in About page\n  useEffect(() => {\n    if (!window?.api) {\n      return\n    }\n\n    const handleUpdateAvailable = (rawInfo: unknown) => {\n      const info = (rawInfo ?? {}) as { version?: string }\n      const versionLabel = info.version ?? ''\n\n      // Update will be downloaded automatically because autoDownload is enabled in main process\n      toast.success(i18n.t('about.notifications.updateAvailable', { version: versionLabel }))\n      setLatestVersionState({\n        status: 'available',\n        version: versionLabel\n      })\n      setUpdateAvailable({\n        available: true,\n        version: versionLabel\n      })\n      // Reset download progress when new update is available\n      setUpdateDownloadProgress(0)\n    }\n\n    const handleUpdateDownloadProgress = (rawProgress: unknown) => {\n      const progress = (rawProgress ?? {}) as { percent?: number }\n      if (typeof progress?.percent === 'number') {\n        setUpdateDownloadProgress(progress.percent)\n      }\n    }\n\n    const handleUpdateDownloaded = () => {\n      // Clear progress when download is complete\n      setUpdateDownloadProgress(null)\n    }\n\n    ipcEvents.on('update:available', handleUpdateAvailable)\n    ipcEvents.on('update:download-progress', handleUpdateDownloadProgress)\n    ipcEvents.on('update:downloaded', handleUpdateDownloaded)\n\n    return () => {\n      ipcEvents.removeListener('update:available', handleUpdateAvailable)\n      ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)\n      ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)\n    }\n  }, [i18n, setUpdateAvailable])\n\n  const handleSettingChange = async (\n    key: keyof typeof settings,\n    value: (typeof settings)[keyof typeof settings]\n  ) => {\n    await saveSetting({ key, value })\n    toast.success(t('notifications.settingsSaved'))\n\n    // If auto-update is enabled, check for updates immediately\n    if (key === 'autoUpdate' && value === true) {\n      try {\n        toast.info(t('about.notifications.checkingUpdates'))\n        const result = await ipcServices.update.checkForUpdates()\n\n        if (result.available) {\n          // The update will be downloaded automatically because autoDownload is enabled\n          toast.success(t('about.notifications.updateAvailable', { version: result.version }))\n          setLatestVersionState({\n            status: 'available',\n            version: result.version ?? ''\n          })\n          setUpdateAvailable({\n            available: true,\n            version: result.version\n          })\n        } else if (result.error) {\n          toast.error(t('about.notifications.updateError', { error: result.error }))\n          setLatestVersionState({\n            status: 'error',\n            error: result.error\n          })\n        } else {\n          toast.success(t('about.notifications.noUpdatesAvailable'))\n          setLatestVersionState({\n            status: 'uptodate',\n            version: result.version ?? appVersionLabel\n          })\n          setUpdateAvailable({\n            available: false,\n            version: undefined\n          })\n        }\n      } catch (error) {\n        console.error('Failed to check for updates:', error)\n        toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))\n      }\n    }\n  }\n\n  const handleGoToDownload = () => {\n    openShareUrl('https://vidbee.org/download/')\n  }\n\n  const handleRestartToUpdate = () => {\n    void ipcServices.update.quitAndInstall()\n  }\n\n  const handleCheckForUpdates = async () => {\n    try {\n      toast.info(t('about.notifications.checkingUpdates'))\n      const result = await ipcServices.update.checkForUpdates()\n\n      if (result.available) {\n        toast.success(t('about.notifications.updateAvailable', { version: result.version }))\n        setLatestVersionState({\n          status: 'available',\n          version: result.version ?? ''\n        })\n        setUpdateAvailable({\n          available: true,\n          version: result.version\n        })\n      } else if (result.error) {\n        toast.error(t('about.notifications.updateError', { error: result.error }))\n        setLatestVersionState({\n          status: 'error',\n          error: result.error\n        })\n      } else {\n        toast.success(t('about.notifications.noUpdatesAvailable'))\n        setLatestVersionState({\n          status: 'uptodate',\n          version: result.version ?? appVersionLabel\n        })\n        setUpdateAvailable({\n          available: false,\n          version: undefined\n        })\n      }\n    } catch (error) {\n      console.error('Failed to check for updates:', error)\n      toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))\n      setLatestVersionState({\n        status: 'error'\n      })\n    }\n  }\n\n  const shareLinks = useMemo(() => {\n    const encodedUrl = encodeURIComponent(shareTargetUrl)\n    const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)\n\n    return {\n      facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,\n      twitter: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`\n    }\n  }, [t])\n\n  const openShareUrl = useCallback((url: string) => {\n    if (typeof window === 'undefined') {\n      return\n    }\n\n    window.open(url, '_blank', 'noopener,noreferrer')\n  }, [])\n\n  const handleShareTwitter = () => {\n    openShareUrl(shareLinks.twitter)\n  }\n\n  const handleShareFacebook = () => {\n    openShareUrl(shareLinks.facebook)\n  }\n\n  const handleCopyShareLink = async () => {\n    try {\n      await navigator.clipboard.writeText(shareTargetUrl)\n      toast.success(t('notifications.urlCopied'))\n    } catch (error) {\n      console.error('Failed to copy share link:', error)\n      toast.error(t('notifications.copyFailed'))\n    }\n  }\n\n  const latestVersionBadgeText =\n    latestVersionState && latestVersionState.status !== 'error' && latestVersionState.version\n      ? t('about.latestVersionBadge', { version: latestVersionState.version })\n      : null\n  const latestVersionStatusKey = latestVersionState\n    ? `about.latestVersionStatus.${latestVersionState.status}`\n    : null\n  const latestVersionStatusClass =\n    latestVersionState?.status === 'available'\n      ? 'text-primary'\n      : latestVersionState?.status === 'error'\n        ? 'text-destructive'\n        : 'text-muted-foreground'\n  const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null\n  const shouldShowCheckUpdates =\n    !updateAvailableState.available && latestVersionState?.status !== 'available'\n\n  const aboutResources = useMemo<AboutResource[]>(\n    () => [\n      {\n        icon: LinkIcon,\n        label: t('about.resources.website'),\n        description: t('about.resources.websiteDescription'),\n        actionLabel: t('about.actions.visit'),\n        href: 'https://vidbee.org/'\n      },\n      {\n        icon: FileText,\n        label: t('about.resources.changelog'),\n        description: t('about.resources.changelogDescription'),\n        actionLabel: t('about.actions.view'),\n        href: 'https://github.com/nexmoe/VidBee/releases'\n      }\n    ],\n    [t]\n  )\n\n  return (\n    <div className=\"h-full bg-background\">\n      <div className=\"container mx-auto max-w-5xl space-y-6 p-6\">\n        <Card>\n          <CardContent className=\"pt-6\">\n            <div className=\"flex flex-col gap-4\">\n              <div className=\"flex items-center gap-4\">\n                <img alt=\"VidBee\" className=\"h-18 w-18 rounded-2xl\" src=\"./app-icon.png\" />\n                <div className=\"flex-1 space-y-2\">\n                  <div className=\"flex items-center justify-between gap-4\">\n                    <div className=\"flex items-center gap-3\">\n                      <h2 className=\"font-semibold text-2xl leading-tight\">{t('about.appName')}</h2>\n                      <Badge variant=\"secondary\">\n                        {t('about.versionLabel', { version: appVersionLabel })}\n                      </Badge>\n                      {latestVersionState ? (\n                        <div className=\"flex flex-wrap items-center gap-2\">\n                          {latestVersionBadgeText ? (\n                            <Badge variant=\"outline\">{latestVersionBadgeText}</Badge>\n                          ) : null}\n                          {latestVersionStatusText ? (\n                            <span className={`text-sm ${latestVersionStatusClass}`}>\n                              {latestVersionStatusText}\n                            </span>\n                          ) : null}\n                        </div>\n                      ) : null}\n                    </div>\n                    <div className=\"flex flex-wrap items-center gap-2\">\n                      <Button asChild size=\"sm\" variant=\"outline\">\n                        <a\n                          aria-label={t('about.actions.openRepo')}\n                          href=\"https://github.com/nexmoe/vidbee\"\n                          rel=\"noreferrer\"\n                          target=\"_blank\"\n                        >\n                          <Github className=\"h-3.5 w-3.5\" />\n                        </a>\n                      </Button>\n                      {updateReady.ready ? (\n                        <Button\n                          className=\"gap-2\"\n                          onClick={handleRestartToUpdate}\n                          size=\"sm\"\n                          variant=\"default\"\n                        >\n                          <RefreshCw className=\"h-3.5 w-3.5\" />\n                          {t('about.notifications.restartNowAction')}\n                        </Button>\n                      ) : null}\n                      {latestVersionState?.status === 'available' ? (\n                        <Button\n                          className=\"gap-2\"\n                          onClick={handleGoToDownload}\n                          size=\"sm\"\n                          variant=\"default\"\n                        >\n                          <Download className=\"h-3.5 w-3.5\" />\n                          {t('about.actions.goToDownload')}\n                        </Button>\n                      ) : null}\n                      {shouldShowCheckUpdates ? (\n                        <Button className=\"gap-2\" onClick={handleCheckForUpdates} size=\"sm\">\n                          <RefreshCw className=\"h-3.5 w-3.5\" />\n                          {t('about.actions.checkUpdates')}\n                        </Button>\n                      ) : null}\n                    </div>\n                  </div>\n                  <p className=\"text-muted-foreground text-sm\">{t('about.description')}</p>\n                </div>\n              </div>\n            </div>\n            {updateDownloadProgress !== null && (\n              <div className=\"flex flex-col gap-3 pt-4\">\n                <div className=\"w-full space-y-2\">\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <span className=\"text-muted-foreground text-sm\">\n                      {t('about.downloadingUpdate')}\n                    </span>\n                    <span className=\"font-medium text-sm\">\n                      {updateDownloadProgress.toFixed(1)}%\n                    </span>\n                  </div>\n                  <Progress className=\"h-2\" value={updateDownloadProgress} />\n                </div>\n              </div>\n            )}\n            <div className=\"flex items-center justify-between gap-4 pt-6\">\n              <div className=\"space-y-1\">\n                <p className=\"font-medium leading-none\">{t('about.autoUpdateTitle')}</p>\n                <p className=\"text-muted-foreground text-sm\">{t('about.autoUpdateDescription')}</p>\n              </div>\n              <Switch\n                checked={settings.autoUpdate}\n                onCheckedChange={(value) => handleSettingChange('autoUpdate', value)}\n              />\n            </div>\n          </CardContent>\n        </Card>\n\n        <Card>\n          <CardHeader>\n            <CardTitle>{t('about.shareTitle')}</CardTitle>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n            <div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n              <p className=\"text-muted-foreground text-sm md:max-w-md\">{t('about.shareSupport')}</p>\n              <div className=\"flex flex-wrap gap-2\">\n                <Button className=\"gap-2\" onClick={handleShareTwitter} size=\"sm\" variant=\"outline\">\n                  <Twitter className=\"h-4 w-4\" />\n                  {t('about.shareActions.twitter')}\n                </Button>\n                <Button className=\"gap-2\" onClick={handleShareFacebook} size=\"sm\" variant=\"outline\">\n                  <Facebook className=\"h-4 w-4\" />\n                  {t('about.shareActions.facebook')}\n                </Button>\n                <Button\n                  className=\"gap-2\"\n                  onClick={handleCopyShareLink}\n                  size=\"sm\"\n                  variant=\"secondary\"\n                >\n                  <LinkIcon className=\"h-4 w-4\" />\n                  {t('about.shareActions.copy')}\n                </Button>\n              </div>\n            </div>\n            <div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n              <p className=\"text-muted-foreground text-sm md:max-w-md\">\n                {t('about.followAuthorSupport')}\n              </p>\n              <div className=\"flex flex-wrap gap-2\">\n                <Button\n                  className=\"gap-2\"\n                  onClick={() => openShareUrl('https://x.com/nexmoex')}\n                  size=\"sm\"\n                  variant=\"outline\"\n                >\n                  <Twitter className=\"h-4 w-4\" />\n                  {t('about.followAuthorActions.follow')}\n                </Button>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        <Card>\n          <CardContent className=\"p-0\">\n            <div className=\"flex flex-col divide-y\">\n              {/* Feedback section - merged into one row */}\n              <div className=\"flex items-center justify-between gap-4 px-6 py-4\">\n                <div className=\"flex items-center gap-4\">\n                  <div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-muted/60\">\n                    <MessageSquare className=\"h-5 w-5 text-muted-foreground\" />\n                  </div>\n                  <div className=\"space-y-1\">\n                    <p className=\"font-medium leading-none\">{t('about.resources.feedback')}</p>\n                    <p className=\"text-muted-foreground text-sm\">\n                      {t('about.resources.feedbackDescription')}\n                    </p>\n                  </div>\n                </div>\n                <div className=\"flex flex-wrap gap-2\">\n                  <FeedbackLinkButtons\n                    appInfo={{ appVersion, osVersion }}\n                    buttonClassName=\"gap-2\"\n                    iconClassName=\"h-4 w-4\"\n                    useSimpleGithubUrl={true}\n                  />\n                </div>\n              </div>\n              {/* Other resources */}\n              {aboutResources.map((resource) => {\n                const Icon = resource.icon\n                return (\n                  <div\n                    className=\"flex items-center justify-between gap-4 px-6 py-4\"\n                    key={resource.label}\n                  >\n                    <div className=\"flex items-center gap-4\">\n                      <div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-muted/60\">\n                        <Icon className=\"h-5 w-5 text-muted-foreground\" />\n                      </div>\n                      <div className=\"space-y-1\">\n                        <p className=\"font-medium leading-none\">{resource.label}</p>\n                        {resource.description ? (\n                          <p className=\"text-muted-foreground text-sm\">{resource.description}</p>\n                        ) : null}\n                      </div>\n                    </div>\n                    {resource.href ? (\n                      <Button asChild size=\"sm\" variant=\"outline\">\n                        <a href={resource.href} rel=\"noreferrer\" target=\"_blank\">\n                          {resource.actionLabel}\n                        </a>\n                      </Button>\n                    ) : (\n                      <Button onClick={resource.onClick} size=\"sm\" variant=\"outline\">\n                        {resource.actionLabel}\n                      </Button>\n                    )}\n                  </div>\n                )\n              })}\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/pages/Home.tsx",
    "content": "import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'\n\ninterface HomeProps {\n  onOpenSupportedSites?: () => void\n  onOpenSettings?: () => void\n  onOpenCookiesSettings?: () => void\n}\n\nexport function Home({ onOpenSupportedSites, onOpenSettings, onOpenCookiesSettings }: HomeProps) {\n  return (\n    <UnifiedDownloadHistory\n      onOpenCookiesSettings={onOpenCookiesSettings}\n      onOpenSettings={onOpenSettings}\n      onOpenSupportedSites={onOpenSupportedSites}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/pages/Settings.tsx",
    "content": "import { Button } from '@renderer/components/ui/button'\nimport { Input } from '@renderer/components/ui/input'\nimport {\n  Item,\n  ItemActions,\n  ItemContent,\n  ItemDescription,\n  ItemGroup,\n  ItemSeparator,\n  ItemTitle\n} from '@renderer/components/ui/item'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@renderer/components/ui/select'\nimport { Switch } from '@renderer/components/ui/switch'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'\nimport type { OneClickQualityPreset } from '@shared/types'\nimport {\n  buildBrowserCookiesSetting,\n  parseBrowserCookiesSetting\n} from '@vidbee/downloader-core/browser-cookies-setting'\nimport { type LanguageCode, languageList, normalizeLanguageCode } from '@vidbee/i18n/languages'\nimport { useAtom, useSetAtom } from 'jotai'\nimport { AlertTriangle } from 'lucide-react'\nimport { useTheme } from 'next-themes'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { useLocation } from 'react-router'\nimport { toast } from 'sonner'\nimport { ipcServices } from '../lib/ipc'\nimport { logger } from '../lib/logger'\nimport { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'\n\nexport function Settings() {\n  const { t, i18n: i18nInstance } = useTranslation()\n  const { theme, setTheme } = useTheme()\n  const location = useLocation()\n  const [settings, _setSettings] = useAtom(settingsAtom)\n  const loadSettings = useSetAtom(loadSettingsAtom)\n  const saveSetting = useSetAtom(saveSettingAtom)\n  const [platform, setPlatform] = useState<string>('')\n  const [activeTab, setActiveTab] = useState<string>('general')\n  const [browserProfileValidation, setBrowserProfileValidation] = useState<{\n    valid: boolean\n    reason?: string\n  }>({ valid: false })\n  const lastAutoDetectBrowser = useRef<string | null>(null)\n\n  useEffect(() => {\n    try {\n      loadSettings()\n    } catch (error) {\n      logger.error('[Settings] Failed to load settings:', error)\n    }\n  }, [loadSettings])\n\n  useEffect(() => {\n    const fetchPlatform = async () => {\n      try {\n        const platformInfo = await ipcServices.app.getPlatform()\n        setPlatform(platformInfo)\n      } catch (error) {\n        logger.error('Failed to get platform info:', error)\n      }\n    }\n\n    fetchPlatform()\n  }, [])\n\n  const autoLaunchSupported = platform === 'darwin' || platform === 'win32'\n\n  const handleSettingChange = useCallback(\n    async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => {\n      try {\n        await saveSetting({ key, value })\n      } catch (error) {\n        logger.error('[Settings] Failed to change setting', { key, value, error })\n        toast.error(t('settings.saveError') || 'Failed to save setting')\n      }\n    },\n    [saveSetting, t]\n  )\n\n  const handleSelectPath = async () => {\n    try {\n      const path = await ipcServices.fs.selectDirectory()\n      if (path) {\n        await handleSettingChange('downloadPath', path)\n      }\n    } catch (error) {\n      logger.error('Failed to select directory:', error)\n      toast.error(t('settings.directorySelectError'))\n    }\n  }\n\n  const handleSelectConfigFile = async () => {\n    try {\n      const path = await ipcServices.fs.selectFile()\n      if (path) {\n        await handleSettingChange('configPath', path)\n      }\n    } catch (error) {\n      logger.error('Failed to select file:', error)\n      toast.error(t('settings.fileSelectError'))\n    }\n  }\n\n  const handleSelectCookiesFile = async () => {\n    try {\n      const path = await ipcServices.fs.selectFile()\n      if (path) {\n        await handleSettingChange('cookiesPath', path)\n      }\n    } catch (error) {\n      logger.error('Failed to select cookies file:', error)\n      toast.error(t('settings.fileSelectError'))\n    }\n  }\n\n  const handleOpenCookiesGuide = async () => {\n    try {\n      await ipcServices.fs.openExternal('https://docs.vidbee.org/cookies')\n    } catch (error) {\n      logger.error('Failed to open cookies guide:', error)\n      toast.error(t('settings.openLinkError'))\n    }\n  }\n\n  const handleThemeChange = async (value: 'light' | 'dark' | 'system') => {\n    const currentTheme = (theme ?? settings.theme ?? 'system') as 'light' | 'dark' | 'system'\n    if (currentTheme === value) {\n      return\n    }\n\n    setTheme(value)\n    await handleSettingChange('theme', value)\n  }\n\n  const languageOptions = languageList\n  const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)\n  const currentLanguage =\n    languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]\n  const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies)\n  const browserForCookiesValue = parsedBrowserCookies.browser\n  const browserCookiesProfileValue = parsedBrowserCookies.profile\n  const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(\n    browserForCookiesValue,\n    browserCookiesProfileValue\n  )\n  const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0\n  const showBrowserProfileWarning =\n    hasBrowserProfileValue &&\n    !browserProfileValidation.valid &&\n    browserProfileValidation.reason !== 'empty'\n  const getBrowserProfileWarningMessage = (reason?: string) => {\n    switch (reason) {\n      case 'pathNotFound':\n        return t('settings.browserForCookiesProfileInvalidPath')\n      case 'profileNotFound':\n        return t('settings.browserForCookiesProfileInvalidProfile')\n      case 'browserUnsupported':\n        return t('settings.browserForCookiesProfileInvalidUnsupported')\n      case 'empty':\n        return t('settings.browserForCookiesProfileInvalidEmpty')\n      default:\n        return t('settings.browserForCookiesProfileInvalid')\n    }\n  }\n\n  useEffect(() => {\n    if (settings.browserForCookies !== normalizedBrowserCookiesSetting) {\n      void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting)\n    }\n  }, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies])\n\n  useEffect(() => {\n    const searchParams = new URLSearchParams(location.search)\n    const tab = searchParams.get('tab')\n    if (tab === 'general' || tab === 'advanced' || tab === 'cookies') {\n      setActiveTab(tab)\n    }\n  }, [location.search])\n\n  useEffect(() => {\n    const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue\n    const shouldAutoDetect =\n      browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue)\n\n    if (!shouldAutoDetect) {\n      lastAutoDetectBrowser.current = browserForCookiesValue\n      return\n    }\n\n    const detectProfilePath = async () => {\n      try {\n        const detectedPath =\n          await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue)\n        const nextProfileValue = detectedPath || ''\n        if (nextProfileValue !== browserCookiesProfileValue) {\n          const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue)\n          await handleSettingChange('browserForCookies', nextValue)\n        }\n      } catch (error) {\n        logger.error('[Settings] Failed to detect browser profile path:', error)\n      } finally {\n        lastAutoDetectBrowser.current = browserForCookiesValue\n      }\n    }\n\n    void detectProfilePath()\n  }, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange])\n\n  useEffect(() => {\n    if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) {\n      setBrowserProfileValidation({ valid: false, reason: 'empty' })\n      return\n    }\n\n    let isActive = true\n\n    const validateProfilePath = async () => {\n      try {\n        const result = await ipcServices.browserCookies.validateBrowserProfilePath(\n          browserForCookiesValue,\n          browserCookiesProfileValue\n        )\n        if (isActive) {\n          setBrowserProfileValidation(result)\n        }\n      } catch (error) {\n        if (isActive) {\n          setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' })\n        }\n        logger.error('[Settings] Failed to validate browser profile path:', error)\n      }\n    }\n\n    void validateProfilePath()\n\n    return () => {\n      isActive = false\n    }\n  }, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue])\n\n  const handleLanguageChange = async (value: LanguageCode) => {\n    if (activeLanguageCode === value) {\n      return\n    }\n\n    await saveSetting({ key: 'language', value })\n    await i18nInstance.changeLanguage(value)\n  }\n\n  return (\n    <div className=\"h-full bg-background\">\n      <div className=\"container mx-auto max-w-4xl space-y-6 p-6\">\n        <div className=\"space-y-2\">\n          <h1 className=\"font-bold text-3xl tracking-tight\">{t('settings.title')}</h1>\n          <p className=\"text-muted-foreground\">{t('settings.description')}</p>\n        </div>\n\n        <Tabs\n          onValueChange={(value) => {\n            setActiveTab(value)\n          }}\n          value={activeTab}\n        >\n          <TabsList className=\"grid w-full grid-cols-3\">\n            <TabsTrigger value=\"general\">{t('settings.general')}</TabsTrigger>\n            <TabsTrigger value=\"cookies\">{t('settings.cookiesTab')}</TabsTrigger>\n            <TabsTrigger value=\"advanced\">{t('settings.advanced')}</TabsTrigger>\n          </TabsList>\n\n          <TabsContent className=\"mt-2 space-y-4\" value=\"general\">\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.downloadPath')}</ItemTitle>\n                  <ItemDescription>{t('settings.downloadPathDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <div className=\"flex w-full max-w-md gap-2\">\n                    <Input className=\"flex-1\" readOnly value={settings.downloadPath} />\n                    <Button onClick={handleSelectPath}>{t('settings.selectPath')}</Button>\n                  </div>\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.theme')}</ItemTitle>\n                  <ItemDescription>{t('settings.themeDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Select\n                    onValueChange={(value) =>\n                      void handleThemeChange(value as 'light' | 'dark' | 'system')\n                    }\n                    value={theme ?? settings.theme ?? 'system'}\n                  >\n                    <SelectTrigger className=\"w-32\">\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"light\">{t('settings.light')}</SelectItem>\n                      <SelectItem value=\"dark\">{t('settings.dark')}</SelectItem>\n                      <SelectItem value=\"system\">{t('settings.system')}</SelectItem>\n                    </SelectContent>\n                  </Select>\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.language')}</ItemTitle>\n                  <ItemDescription>{t('settings.languageDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Select\n                    onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}\n                    value={currentLanguage.value}\n                  >\n                    <SelectTrigger className=\"w-48\">\n                      <SelectValue placeholder={currentLanguage.name}>\n                        <div className=\"flex items-center gap-2\">\n                          <span\n                            aria-hidden=\"true\"\n                            className={`${currentLanguage.flag} rounded-xs text-base`}\n                          />\n                          <span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>\n                        </div>\n                      </SelectValue>\n                    </SelectTrigger>\n                    <SelectContent>\n                      {languageOptions.map((option) => {\n                        const isActive = option.value === currentLanguage.value\n                        return (\n                          <SelectItem\n                            className={isActive ? 'bg-muted font-semibold' : undefined}\n                            key={option.value}\n                            value={option.value}\n                          >\n                            <div className=\"flex items-center gap-2\">\n                              <span\n                                aria-hidden=\"true\"\n                                className={`${option.flag} rounded-xs text-base`}\n                              />\n                              <span lang={option.hreflang}>{option.name}</span>\n                            </div>\n                          </SelectItem>\n                        )\n                      })}\n                    </SelectContent>\n                  </Select>\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.oneClickDownload')}</ItemTitle>\n                  <ItemDescription>{t('settings.oneClickDownloadDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.oneClickDownload}\n                    onCheckedChange={(value) => handleSettingChange('oneClickDownload', value)}\n                  />\n                </ItemActions>\n              </Item>\n\n              {settings.oneClickDownload && (\n                <>\n                  <ItemSeparator />\n                  <Item variant=\"muted\">\n                    <ItemContent>\n                      <ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>\n                      <ItemDescription>\n                        {t('settings.oneClickDownloadTypeDescription')}\n                      </ItemDescription>\n                    </ItemContent>\n                    <ItemActions>\n                      <Select\n                        onValueChange={(value) =>\n                          handleSettingChange('oneClickDownloadType', value)\n                        }\n                        value={settings.oneClickDownloadType}\n                      >\n                        <SelectTrigger className=\"w-32\">\n                          <SelectValue />\n                        </SelectTrigger>\n                        <SelectContent>\n                          <SelectItem value=\"video\">{t('download.video')}</SelectItem>\n                          <SelectItem value=\"audio\">{t('download.audio')}</SelectItem>\n                        </SelectContent>\n                      </Select>\n                    </ItemActions>\n                  </Item>\n                  <ItemSeparator />\n                  <Item variant=\"muted\">\n                    <ItemContent>\n                      <ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>\n                      <ItemDescription>{t('settings.oneClickQualityDescription')}</ItemDescription>\n                    </ItemContent>\n                    <ItemActions>\n                      <Select\n                        onValueChange={(value) =>\n                          handleSettingChange('oneClickQuality', value as OneClickQualityPreset)\n                        }\n                        value={settings.oneClickQuality}\n                      >\n                        <SelectTrigger className=\"w-40\">\n                          <SelectValue />\n                        </SelectTrigger>\n                        <SelectContent>\n                          <SelectItem value=\"best\">\n                            {t('settings.oneClickQualityOptions.best')}\n                          </SelectItem>\n                          <SelectItem value=\"good\">\n                            {t('settings.oneClickQualityOptions.good')}\n                          </SelectItem>\n                          <SelectItem value=\"normal\">\n                            {t('settings.oneClickQualityOptions.normal')}\n                          </SelectItem>\n                          <SelectItem value=\"bad\">\n                            {t('settings.oneClickQualityOptions.bad')}\n                          </SelectItem>\n                          <SelectItem value=\"worst\">\n                            {t('settings.oneClickQualityOptions.worst')}\n                          </SelectItem>\n                        </SelectContent>\n                      </Select>\n                    </ItemActions>\n                  </Item>\n                </>\n              )}\n            </ItemGroup>\n\n            <ItemGroup>\n              {platform === 'darwin' && (\n                <>\n                  <Item variant=\"muted\">\n                    <ItemContent>\n                      <ItemTitle>{t('settings.hideDockIcon')}</ItemTitle>\n                      <ItemDescription>{t('settings.hideDockIconDescription')}</ItemDescription>\n                    </ItemContent>\n                    <ItemActions>\n                      <Switch\n                        checked={settings.hideDockIcon}\n                        onCheckedChange={(value) => handleSettingChange('hideDockIcon', value)}\n                      />\n                    </ItemActions>\n                  </Item>\n                  <ItemSeparator />\n                </>\n              )}\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.launchAtLogin')}</ItemTitle>\n                  <ItemDescription>\n                    {autoLaunchSupported\n                      ? t('settings.launchAtLoginDescription')\n                      : t('settings.launchAtLoginUnsupported')}\n                  </ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.launchAtLogin}\n                    disabled={!autoLaunchSupported}\n                    onCheckedChange={(value) => handleSettingChange('launchAtLogin', value)}\n                  />\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n          </TabsContent>\n\n          <TabsContent className=\"mt-2 space-y-4\" value=\"advanced\">\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.embedSubs')}</ItemTitle>\n                  <ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.embedSubs ?? false}\n                    onCheckedChange={(value) => {\n                      try {\n                        handleSettingChange('embedSubs', value)\n                      } catch (error) {\n                        logger.error('[Settings] Error toggling embedSubs:', error)\n                      }\n                    }}\n                  />\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>\n                  <ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.embedThumbnail ?? false}\n                    onCheckedChange={(value) => {\n                      try {\n                        handleSettingChange('embedThumbnail', value)\n                      } catch (error) {\n                        logger.error('[Settings] Error toggling embedThumbnail:', error)\n                      }\n                    }}\n                  />\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.embedMetadata')}</ItemTitle>\n                  <ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.embedMetadata ?? false}\n                    onCheckedChange={(value) => {\n                      try {\n                        handleSettingChange('embedMetadata', value)\n                      } catch (error) {\n                        logger.error('[Settings] Error toggling embedMetadata:', error)\n                      }\n                    }}\n                  />\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.embedChapters')}</ItemTitle>\n                  <ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.embedChapters ?? true}\n                    onCheckedChange={(value) => {\n                      try {\n                        handleSettingChange('embedChapters', value)\n                      } catch (error) {\n                        logger.error('[Settings] Error toggling embedChapters:', error)\n                      }\n                    }}\n                  />\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.shareWatermark')}</ItemTitle>\n                  <ItemDescription>{t('settings.shareWatermarkDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Switch\n                    checked={settings.shareWatermark ?? false}\n                    onCheckedChange={(value) => {\n                      try {\n                        handleSettingChange('shareWatermark', value)\n                      } catch (error) {\n                        logger.error('[Settings] Error toggling shareWatermark:', error)\n                      }\n                    }}\n                  />\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>\n                  <ItemDescription>\n                    {t('settings.maxConcurrentDownloadsDescription')}\n                  </ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      const maxConcurrent = settings.maxConcurrentDownloads ?? 5\n                      const maxConcurrentStr = maxConcurrent.toString()\n                      return (\n                        <Select\n                          onValueChange={(value) => {\n                            try {\n                              const numValue = Number(value)\n                              handleSettingChange('maxConcurrentDownloads', numValue)\n                            } catch (error) {\n                              logger.error(\n                                '[Settings] Error changing max concurrent downloads:',\n                                error\n                              )\n                            }\n                          }}\n                          value={maxConcurrentStr}\n                        >\n                          <SelectTrigger className=\"w-20\">\n                            <SelectValue />\n                          </SelectTrigger>\n                          <SelectContent>\n                            {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (\n                              <SelectItem key={num} value={num.toString()}>\n                                {num}\n                              </SelectItem>\n                            ))}\n                          </SelectContent>\n                        </Select>\n                      )\n                    } catch (error) {\n                      logger.error(\n                        '[Settings] Error rendering max concurrent downloads select:',\n                        error\n                      )\n                      return <div>Error loading max concurrent downloads setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.proxy')}</ItemTitle>\n                  <ItemDescription>{t('settings.proxyDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      const proxyValue = settings.proxy ?? ''\n                      return (\n                        <Input\n                          className=\"w-64\"\n                          onChange={(e) => {\n                            try {\n                              handleSettingChange('proxy', e.target.value)\n                            } catch (error) {\n                              logger.error('[Settings] Error changing proxy:', error)\n                            }\n                          }}\n                          placeholder={t('settings.proxyPlaceholder')}\n                          value={proxyValue}\n                        />\n                      )\n                    } catch (error) {\n                      logger.error('[Settings] Error rendering proxy input:', error)\n                      return <div>Error loading proxy setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.configFile')}</ItemTitle>\n                  <ItemDescription>{t('settings.configFileDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      const configPathValue = settings.configPath ?? ''\n                      return (\n                        <div className=\"flex w-full max-w-md gap-2\">\n                          <Input className=\"flex-1\" readOnly value={configPathValue} />\n                          <Button onClick={handleSelectConfigFile}>\n                            {t('settings.selectPath')}\n                          </Button>\n                          <Button\n                            disabled={!configPathValue}\n                            onClick={() => {\n                              try {\n                                void handleSettingChange('configPath', '')\n                              } catch (error) {\n                                logger.error('[Settings] Error clearing config path:', error)\n                              }\n                            }}\n                            variant=\"secondary\"\n                          >\n                            {t('settings.clearConfigFile')}\n                          </Button>\n                        </div>\n                      )\n                    } catch (error) {\n                      logger.error('[Settings] Error rendering config file input:', error)\n                      return <div>Error loading config file setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>\n                  <ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      const analyticsValue = settings.enableAnalytics ?? true\n                      return (\n                        <Switch\n                          checked={analyticsValue}\n                          onCheckedChange={(value) => {\n                            try {\n                              handleSettingChange('enableAnalytics', value)\n                            } catch (error) {\n                              logger.error('[Settings] Error changing enable analytics:', error)\n                            }\n                          }}\n                        />\n                      )\n                    } catch (error) {\n                      logger.error('[Settings] Error rendering enable analytics switch:', error)\n                      return <div>Error loading enable analytics setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n          </TabsContent>\n\n          <TabsContent className=\"mt-2 space-y-4\" value=\"cookies\">\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.browserForCookies')}</ItemTitle>\n                  <ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>\n                  {platform === 'win32' && (\n                    <ItemDescription className=\"text-red-500\">\n                      {t('settings.browserForCookiesWindowsNote')}\n                    </ItemDescription>\n                  )}\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      return (\n                        <Select\n                          onValueChange={(value) => {\n                            try {\n                              const nextValue = buildBrowserCookiesSetting(value, '')\n                              handleSettingChange('browserForCookies', nextValue)\n                            } catch (error) {\n                              logger.error('[Settings] Error changing browser for cookies:', error)\n                            }\n                          }}\n                          value={browserForCookiesValue}\n                        >\n                          <SelectTrigger className=\"w-32\">\n                            <SelectValue />\n                          </SelectTrigger>\n                          <SelectContent>\n                            <SelectItem value=\"none\">{t('settings.none')}</SelectItem>\n                            <SelectItem value=\"chrome\">\n                              {t('settings.browserOptions.chrome')}\n                            </SelectItem>\n                            <SelectItem value=\"chromium\">\n                              {t('settings.browserOptions.chromium')}\n                            </SelectItem>\n                            <SelectItem value=\"firefox\">\n                              {t('settings.browserOptions.firefox')}\n                            </SelectItem>\n                            <SelectItem value=\"edge\">\n                              {t('settings.browserOptions.edge')}\n                            </SelectItem>\n                            <SelectItem value=\"safari\">\n                              {t('settings.browserOptions.safari')}\n                            </SelectItem>\n                            <SelectItem value=\"brave\">\n                              {t('settings.browserOptions.brave')}\n                            </SelectItem>\n                            <SelectItem value=\"opera\">\n                              {t('settings.browserOptions.opera')}\n                            </SelectItem>\n                            <SelectItem value=\"vivaldi\">\n                              {t('settings.browserOptions.vivaldi')}\n                            </SelectItem>\n                            <SelectItem value=\"whale\">\n                              {t('settings.browserOptions.whale')}\n                            </SelectItem>\n                          </SelectContent>\n                        </Select>\n                      )\n                    } catch (error) {\n                      logger.error('[Settings] Error rendering browser for cookies select:', error)\n                      return <div>Error loading browser for cookies setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent className=\"basis-full\">\n                  <ItemTitle>{t('settings.browserForCookiesProfile')}</ItemTitle>\n                  <ItemDescription>\n                    {t('settings.browserForCookiesProfileDescription')}\n                  </ItemDescription>\n                </ItemContent>\n                <ItemActions className=\"basis-full\">\n                  {(() => {\n                    try {\n                      return (\n                        <div className=\"relative w-full\">\n                          <Input\n                            className=\"w-full pr-10\"\n                            disabled={browserForCookiesValue === 'none'}\n                            onChange={(event) => {\n                              try {\n                                const newProfileValue = event.target.value\n                                const nextValue = buildBrowserCookiesSetting(\n                                  browserForCookiesValue,\n                                  newProfileValue\n                                )\n                                handleSettingChange('browserForCookies', nextValue)\n                              } catch (error) {\n                                logger.error(\n                                  '[Settings] Error changing browser cookies profile:',\n                                  error\n                                )\n                              }\n                            }}\n                            placeholder={t('settings.browserForCookiesProfilePlaceholder')}\n                            value={browserCookiesProfileValue}\n                          />\n                          {showBrowserProfileWarning ? (\n                            <Tooltip>\n                              <TooltipTrigger asChild>\n                                <span className=\"absolute top-1/2 right-3 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500\">\n                                  <AlertTriangle aria-hidden className=\"h-4 w-4\" />\n                                </span>\n                              </TooltipTrigger>\n                              <TooltipContent>\n                                {getBrowserProfileWarningMessage(browserProfileValidation.reason)}\n                              </TooltipContent>\n                            </Tooltip>\n                          ) : null}\n                        </div>\n                      )\n                    } catch (error) {\n                      logger.error(\n                        '[Settings] Error rendering browser cookies profile input:',\n                        error\n                      )\n                      return <div>Error loading browser cookies profile setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.cookiesFile')}</ItemTitle>\n                  <ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  {(() => {\n                    try {\n                      const cookiesPathValue = settings.cookiesPath ?? ''\n                      return (\n                        <div className=\"flex w-full max-w-md gap-2\">\n                          <Input className=\"flex-1\" readOnly value={cookiesPathValue} />\n                          <Button onClick={handleSelectCookiesFile}>\n                            {t('settings.selectPath')}\n                          </Button>\n                          <Button\n                            disabled={!cookiesPathValue}\n                            onClick={() => {\n                              try {\n                                void handleSettingChange('cookiesPath', '')\n                              } catch (error) {\n                                logger.error('[Settings] Error clearing cookies path:', error)\n                              }\n                            }}\n                            variant=\"secondary\"\n                          >\n                            {t('settings.clearCookiesFile')}\n                          </Button>\n                        </div>\n                      )\n                    } catch (error) {\n                      logger.error('[Settings] Error rendering cookies file input:', error)\n                      return <div>Error loading cookies file setting</div>\n                    }\n                  })()}\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n\n            <ItemGroup>\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>\n                  <ul className=\"list-inside list-disc space-y-1 text-muted-foreground text-sm leading-normal\">\n                    <li>{t('settings.cookiesHelpBrowser')}</li>\n                    <li>{t('settings.cookiesHelpFile')}</li>\n                  </ul>\n                </ItemContent>\n              </Item>\n\n              <ItemSeparator />\n\n              <Item variant=\"muted\">\n                <ItemContent>\n                  <ItemTitle>{t('settings.cookiesGuideTitle')}</ItemTitle>\n                  <ItemDescription>{t('settings.cookiesGuideDescription')}</ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Button className=\"px-0\" onClick={handleOpenCookiesGuide} variant=\"link\">\n                    {t('settings.cookiesGuideLink')}\n                  </Button>\n                </ItemActions>\n              </Item>\n            </ItemGroup>\n          </TabsContent>\n        </Tabs>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/pages/Subscriptions.tsx",
    "content": "import {\n  type SubscriptionFormData,\n  SubscriptionFormDialog\n} from '@renderer/components/subscription/SubscriptionFormDialog'\nimport { Badge } from '@renderer/components/ui/badge'\nimport { Button } from '@renderer/components/ui/button'\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle\n} from '@renderer/components/ui/card'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger\n} from '@renderer/components/ui/context-menu'\nimport { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'\nimport { RemoteImage } from '@renderer/components/ui/remote-image'\nimport { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'\nimport { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'\nimport { ipcServices } from '@renderer/lib/ipc'\nimport { cn } from '@renderer/lib/utils'\nimport { type DownloadRecord, downloadsArrayAtom } from '@renderer/store/downloads'\nimport {\n  createSubscriptionAtom,\n  refreshSubscriptionAtom,\n  removeSubscriptionAtom,\n  resolveFeedAtom,\n  subscriptionsAtom,\n  updateSubscriptionAtom\n} from '@renderer/store/subscriptions'\nimport type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'\nimport { SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '@shared/types'\nimport dayjs from 'dayjs'\nimport { useAtom, useAtomValue, useSetAtom } from 'jotai'\nimport { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\n\nconst statusStyles: Record<\n  SubscriptionRule['status'],\n  { dotClass: string; textClass: string; label: string }\n> = {\n  'up-to-date': {\n    dotClass: 'bg-emerald-500',\n    textClass: 'text-emerald-600',\n    label: 'subscriptions.status.up-to-date'\n  },\n  checking: {\n    dotClass: 'bg-sky-500',\n    textClass: 'text-sky-600',\n    label: 'subscriptions.status.checking'\n  },\n  failed: {\n    dotClass: 'bg-red-500',\n    textClass: 'text-red-600',\n    label: 'subscriptions.status.failed'\n  },\n  idle: {\n    dotClass: 'bg-muted-foreground',\n    textClass: 'text-muted-foreground',\n    label: 'subscriptions.status.idle'\n  }\n}\n\nconst disabledStatusStyle = {\n  dotClass: 'bg-zinc-400',\n  textClass: 'text-muted-foreground',\n  label: 'subscriptions.fields.disabled'\n}\n\ntype SubscriptionItemStatus = DownloadStatus | 'queued' | 'notQueued'\n\nconst subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {\n  notQueued: 'subscriptions.items.status.notQueued',\n  queued: 'subscriptions.items.status.queued',\n  pending: 'subscriptions.items.status.pending',\n  downloading: 'subscriptions.items.status.downloading',\n  processing: 'subscriptions.items.status.processing',\n  completed: 'subscriptions.items.status.completed',\n  error: 'subscriptions.items.status.error',\n  cancelled: 'subscriptions.items.status.cancelled'\n}\n\nconst getErrorMessage = (error: unknown): string | undefined => {\n  if (!error) {\n    return undefined\n  }\n  if (typeof error === 'string') {\n    return error\n  }\n  if (typeof error === 'object') {\n    if ('message' in error && typeof error.message === 'string') {\n      return error.message\n    }\n    if ('code' in error && typeof error.code === 'string') {\n      return error.code\n    }\n    if ('error' in error) {\n      const nested = (error as { error?: unknown }).error\n      if (typeof nested === 'string') {\n        return nested\n      }\n      if (nested && typeof nested === 'object' && 'message' in nested) {\n        const nestedMessage = (nested as { message?: unknown }).message\n        if (typeof nestedMessage === 'string') {\n          return nestedMessage\n        }\n      }\n    }\n  }\n  return undefined\n}\n\nconst isDuplicateFeedError = (error: unknown) => {\n  const message = getErrorMessage(error)\n  return Boolean(message?.includes(SUBSCRIPTION_DUPLICATE_FEED_ERROR))\n}\n\nfunction SubscriptionTab({\n  subscription,\n  onRefresh,\n  onRemove,\n  onUpdate,\n  isActive\n}: SubscriptionTabProps) {\n  const { t } = useTranslation()\n  const [editOpen, setEditOpen] = useState(false)\n  const isDisabled = !subscription.enabled\n  const statusMeta = isDisabled ? disabledStatusStyle : statusStyles[subscription.status]\n  const statusDescription =\n    subscription.status === 'failed' && subscription.lastError\n      ? subscription.lastError\n      : t(statusStyles[subscription.status].label)\n  const lastUpdatedTimestamp =\n    subscription.lastCheckedAt ?? subscription.updatedAt ?? subscription.createdAt ?? null\n  const lastUpdatedLabel = lastUpdatedTimestamp\n    ? dayjs(lastUpdatedTimestamp).format('YYYY-MM-DD HH:mm')\n    : t('subscriptions.never')\n\n  const handleToggleEnabled = async (checked: boolean) => {\n    await onUpdate({ enabled: checked })\n  }\n\n  const handleRefresh = async () => {\n    await onRefresh()\n    toast.success(t('subscriptions.notifications.refreshStarted'))\n  }\n\n  const handleRemove = async () => {\n    await onRemove()\n    toast.success(t('subscriptions.notifications.removed'))\n  }\n\n  const handleEdit = () => {\n    setEditOpen(true)\n  }\n\n  return (\n    <>\n      <ContextMenu>\n        <HoverCard closeDelay={0} openDelay={0}>\n          <ContextMenuTrigger asChild>\n            <HoverCardTrigger asChild>\n              <TabsTrigger\n                className={cn(\n                  'flex h-auto w-20 shrink-0 grow-0 flex-col items-center gap-1 rounded-2xl px-2 py-2 transition-all hover:opacity-80',\n                  isActive && 'bg-muted/45'\n                )}\n                value={subscription.id}\n              >\n                <div className=\"relative h-12 w-12 shrink-0 overflow-hidden transition-colors\">\n                  <RemoteImage\n                    alt={subscription.title || t('subscriptions.labels.unknown')}\n                    className=\"h-full w-full overflow-hidden rounded-full object-cover\"\n                    src={subscription.coverUrl}\n                  />\n                  <span\n                    className={cn(\n                      'absolute -right-0.5 -bottom-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',\n                      statusMeta.dotClass\n                    )}\n                  />\n                </div>\n                <div className=\"flex w-full flex-col items-center text-center\">\n                  <span className=\"w-full truncate font-medium text-xs\">\n                    {subscription.title || t('subscriptions.labels.unknown')}\n                  </span>\n                </div>\n              </TabsTrigger>\n            </HoverCardTrigger>\n          </ContextMenuTrigger>\n          <HoverCardContent className=\"max-w-xs space-y-1\">\n            <p className=\"font-semibold text-sm\">\n              {subscription.title || t('subscriptions.labels.unknown')}\n            </p>\n            <p className=\"text-xs\">{statusDescription}</p>\n            <p className=\"text-xs\">\n              {t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}\n            </p>\n          </HoverCardContent>\n        </HoverCard>\n        <ContextMenuContent>\n          <ContextMenuItem onClick={handleRefresh}>\n            <RefreshCw className=\"h-4 w-4\" />\n            {t('subscriptions.actions.refresh')}\n          </ContextMenuItem>\n          <ContextMenuItem onClick={handleEdit}>\n            <Edit className=\"h-4 w-4\" />\n            {t('subscriptions.actions.edit')}\n          </ContextMenuItem>\n          <ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>\n            <Power className=\"h-4 w-4\" />\n            {subscription.enabled\n              ? t('subscriptions.actions.disable')\n              : t('subscriptions.actions.enable')}\n          </ContextMenuItem>\n          <ContextMenuSeparator />\n          <ContextMenuItem onClick={() => void handleRemove()} variant=\"destructive\">\n            <Trash2 className=\"h-4 w-4\" />\n            {t('subscriptions.actions.remove')}\n          </ContextMenuItem>\n        </ContextMenuContent>\n      </ContextMenu>\n\n      <SubscriptionFormDialog\n        mode=\"edit\"\n        onClose={() => setEditOpen(false)}\n        onSave={async (data) => {\n          await onUpdate(data)\n          toast.success(t('subscriptions.notifications.updated'))\n          setEditOpen(false)\n        }}\n        open={editOpen}\n        subscription={subscription}\n      />\n    </>\n  )\n}\n\nexport function Subscriptions() {\n  const { t } = useTranslation()\n  const [subscriptions] = useAtom(subscriptionsAtom)\n  const updateSubscription = useSetAtom(updateSubscriptionAtom)\n  const removeSubscription = useSetAtom(removeSubscriptionAtom)\n  const refreshSubscription = useSetAtom(refreshSubscriptionAtom)\n\n  const [addDialogOpen, setAddDialogOpen] = useState(false)\n  const [selectedTab, setSelectedTab] = useState<string>('')\n\n  const sortedSubscriptions = useMemo(\n    () =>\n      [...subscriptions].sort(\n        (a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)\n      ),\n    [subscriptions]\n  )\n\n  const resolveFeed = useSetAtom(resolveFeedAtom)\n  const handleUpdateSubscription = useCallback(\n    async (id: string, data: SubscriptionRuleUpdateForm) => {\n      const updatePayload: Parameters<typeof updateSubscription>[0]['data'] = {\n        keywords: data.keywords,\n        tags: data.tags,\n        onlyDownloadLatest: data.onlyDownloadLatest,\n        downloadDirectory: data.downloadDirectory,\n        namingTemplate: data.namingTemplate,\n        enabled: data.enabled\n      }\n\n      // If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform\n      if (data.url) {\n        try {\n          const resolved = await resolveFeed(data.url)\n          updatePayload.sourceUrl = resolved.sourceUrl\n          updatePayload.feedUrl = resolved.feedUrl\n          updatePayload.platform = resolved.platform\n        } catch (error) {\n          console.error('Failed to resolve feed URL:', error)\n          toast.error(t('subscriptions.notifications.resolveError'))\n          return\n        }\n      }\n\n      try {\n        await updateSubscription({ id, data: updatePayload })\n        await refreshSubscription(id)\n      } catch (error) {\n        console.error('Failed to update subscription:', error)\n        toast.error(\n          isDuplicateFeedError(error)\n            ? t('subscriptions.notifications.duplicateUrl')\n            : t('subscriptions.notifications.createError')\n        )\n      }\n    },\n    [refreshSubscription, updateSubscription, resolveFeed, t]\n  )\n\n  const createSubscription = useSetAtom(createSubscriptionAtom)\n\n  const handleCreateSubscription = useCallback(\n    async (data: SubscriptionFormData) => {\n      if (!data.url) {\n        toast.error(t('subscriptions.notifications.missingUrl'))\n        return\n      }\n\n      try {\n        await createSubscription({\n          url: data.url,\n          keywords: data.keywords?.join(', '),\n          tags: data.tags?.join(', '),\n          onlyDownloadLatest: data.onlyDownloadLatest,\n          downloadDirectory: data.downloadDirectory,\n          namingTemplate: data.namingTemplate,\n          enabled: data.enabled\n        })\n        toast.success(t('subscriptions.notifications.created'))\n        setAddDialogOpen(false)\n      } catch (error) {\n        console.error('Failed to create subscription:', error)\n        toast.error(\n          isDuplicateFeedError(error)\n            ? t('subscriptions.notifications.duplicateUrl')\n            : t('subscriptions.notifications.createError')\n        )\n      }\n    },\n    [createSubscription, t]\n  )\n\n  const handleOpenRSSHubDocs = useCallback(async () => {\n    try {\n      await ipcServices.fs.openExternal('https://docs.vidbee.org/rss')\n    } catch (error) {\n      console.error('Failed to open RSS documentation:', error)\n      toast.error(t('subscriptions.notifications.openLinkError'))\n    }\n  }, [t])\n\n  // Filter subscriptions based on selected tab\n  const displayedSubscriptions = useMemo(() => {\n    if (!selectedTab) {\n      return []\n    }\n    return sortedSubscriptions.filter((sub) => sub.id === selectedTab)\n  }, [selectedTab, sortedSubscriptions])\n\n  // Set default tab to first subscription if available\n  useEffect(() => {\n    if (!selectedTab && sortedSubscriptions.length > 0) {\n      // Set to first subscription if no tab is selected\n      setSelectedTab(sortedSubscriptions[0].id)\n    } else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {\n      // If selected subscription no longer exists, switch to first available\n      if (sortedSubscriptions.length > 0) {\n        setSelectedTab(sortedSubscriptions[0].id)\n      } else {\n        setSelectedTab('')\n      }\n    }\n  }, [selectedTab, sortedSubscriptions])\n\n  return (\n    <div className=\"relative flex h-full w-full flex-col\">\n      {/* Channel Tabs Header */}\n      <div className=\"flex flex-row pr-6 pb-6 pl-6\">\n        <ScrollArea className=\"w-auto overflow-y-auto\">\n          <Tabs className=\"w-auto\" onValueChange={setSelectedTab} value={selectedTab}>\n            <TabsList className=\"h-auto w-auto justify-start rounded-none border-none bg-transparent p-0\">\n              {/* Subscription Channel Tabs */}\n              {sortedSubscriptions.map((subscription) => (\n                <SubscriptionTab\n                  isActive={subscription.id === selectedTab}\n                  key={subscription.id}\n                  onRefresh={() => refreshSubscription(subscription.id)}\n                  onRemove={() => removeSubscription(subscription.id)}\n                  onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}\n                  subscription={subscription}\n                />\n              ))}\n            </TabsList>\n          </Tabs>\n          <ScrollBar orientation=\"horizontal\" />\n        </ScrollArea>\n\n        {/* Add RSS Button */}\n        <Button\n          className=\"flex h-auto w-20 shrink-0 grow-0 flex-col items-center gap-1 rounded-2xl bg-transparent px-2 py-2 transition-all hover:bg-neutral-100 hover:opacity-80\"\n          onClick={() => setAddDialogOpen(true)}\n          variant=\"ghost\"\n        >\n          <div className=\"flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-muted-foreground/40 border-dashed transition-colors\">\n            <Plus className=\"h-5 w-5 text-muted-foreground\" />\n          </div>\n          <div className=\"flex w-full flex-col items-center text-center\">\n            <span className=\"w-full truncate font-medium text-xs\">\n              {t('subscriptions.add.title')}\n            </span>\n          </div>\n        </Button>\n      </div>\n\n      <ScrollArea className=\"overflow-y-auto\">\n        {/* Content Area */}\n        <div className=\"relative space-y-8 p-6 pt-0\">\n          <section className=\"space-y-4\">\n            {sortedSubscriptions.length === 0 ? (\n              <div className=\"py-12 text-center text-muted-foreground text-sm\">\n                {t('subscriptions.empty')}\n              </div>\n            ) : selectedTab ? (\n              <div className=\"space-y-3\">\n                {displayedSubscriptions.map((subscription) => (\n                  <SubscriptionCard key={subscription.id} subscription={subscription} />\n                ))}\n              </div>\n            ) : (\n              <div className=\"py-12 text-center text-muted-foreground text-sm\">\n                {t('subscriptions.empty')}\n              </div>\n            )}\n          </section>\n\n          {/* RSSHub Info Card */}\n          <Card className=\"border-primary/20 bg-primary/5\">\n            <CardHeader>\n              <CardTitle className=\"flex items-center gap-2\">\n                {t('subscriptions.rssHub.title')}\n              </CardTitle>\n              <CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>\n            </CardHeader>\n            <CardContent>\n              <Button\n                className=\"gap-2\"\n                onClick={() => void handleOpenRSSHubDocs()}\n                size=\"sm\"\n                variant=\"secondary\"\n              >\n                {t('subscriptions.rssHub.openDocs')}\n              </Button>\n            </CardContent>\n          </Card>\n        </div>\n      </ScrollArea>\n\n      <SubscriptionFormDialog\n        mode=\"add\"\n        onClose={() => setAddDialogOpen(false)}\n        onSave={handleCreateSubscription}\n        open={addDialogOpen}\n      />\n    </div>\n  )\n}\n\ninterface SubscriptionTabProps {\n  subscription: SubscriptionRule\n  isActive: boolean\n  onRefresh: () => Promise<void>\n  onRemove: () => Promise<void>\n  onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>\n}\n\ntype SubscriptionRuleUpdateForm = SubscriptionFormData\n\nfunction SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {\n  const { t } = useTranslation()\n  const feedItems: SubscriptionFeedItem[] = subscription.items ?? []\n  const downloads = useAtomValue(downloadsArrayAtom)\n  const [historyStatusMap, setHistoryStatusMap] = useState<Record<string, DownloadStatus | null>>(\n    {}\n  )\n  const downloadLookup = useMemo(() => {\n    const map = new Map<string, DownloadRecord>()\n    downloads.forEach((record) => {\n      map.set(record.id, record)\n    })\n    return map\n  }, [downloads])\n\n  useEffect(() => {\n    const queuedDownloadIds = Array.from(\n      new Set(\n        feedItems\n          .filter((item) => item.addedToQueue && item.downloadId)\n          .map((item) => item.downloadId as string)\n      )\n    )\n\n    const missingIds = queuedDownloadIds.filter(\n      (downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined\n    )\n\n    if (missingIds.length === 0) {\n      return\n    }\n\n    let cancelled = false\n\n    const fetchHistoryStatuses = async () => {\n      try {\n        const results = await Promise.all(\n          missingIds.map(async (downloadId) => {\n            try {\n              const historyItem = await ipcServices.history.getHistoryById(downloadId)\n              return { downloadId, status: historyItem?.status ?? null }\n            } catch (error) {\n              console.error('Failed to fetch download history entry:', error)\n              return { downloadId, status: null }\n            }\n          })\n        )\n\n        if (cancelled) {\n          return\n        }\n\n        setHistoryStatusMap((prev) => {\n          let changed = false\n          const next = { ...prev }\n\n          for (const { downloadId, status } of results) {\n            if (next[downloadId] === status) {\n              continue\n            }\n            next[downloadId] = status\n            changed = true\n          }\n\n          return changed ? next : prev\n        })\n      } catch (error) {\n        console.error('Failed to resolve download history statuses:', error)\n      }\n    }\n\n    void fetchHistoryStatuses()\n\n    return () => {\n      cancelled = true\n    }\n  }, [feedItems, downloadLookup, historyStatusMap])\n\n  const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {\n    if (!item.addedToQueue) {\n      return 'notQueued'\n    }\n    if (!item.downloadId) {\n      return 'queued'\n    }\n    const matchedDownload = downloadLookup.get(item.downloadId)\n    if (!matchedDownload) {\n      const cachedHistoryStatus = historyStatusMap[item.downloadId]\n      if (cachedHistoryStatus) {\n        return cachedHistoryStatus\n      }\n      return 'queued'\n    }\n    return matchedDownload.status\n  }\n\n  const handleOpenItem = async (url: string) => {\n    try {\n      await ipcServices.fs.openExternal(url)\n    } catch (error) {\n      console.error('Failed to open subscription item link:', error)\n      toast.error(t('subscriptions.notifications.openLinkError'))\n    }\n  }\n\n  const handleQueueItem = useCallback(\n    async (item: SubscriptionFeedItem) => {\n      if (item.addedToQueue) {\n        toast.info(t('subscriptions.notifications.itemAlreadyQueued'))\n        return\n      }\n      try {\n        const queued = await ipcServices.subscriptions.queueItem(subscription.id, item.id)\n        if (queued) {\n          toast.success(t('subscriptions.notifications.itemQueued'))\n          return\n        }\n        toast.info(t('subscriptions.notifications.itemAlreadyQueued'))\n      } catch (error) {\n        console.error('Failed to queue subscription item:', error)\n        toast.error(t('subscriptions.notifications.queueError'))\n      }\n    },\n    [subscription.id, t]\n  )\n\n  if (feedItems.length === 0) {\n    return (\n      <div className=\"py-12 text-center text-muted-foreground text-sm\">\n        {t('subscriptions.items.empty')}\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\">\n      {feedItems.map((item) => {\n        const itemStatus = resolveItemStatus(item)\n        const hasResolvedDownloadStatus =\n          item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued'\n        const badgeLabel = item.addedToQueue\n          ? t('subscriptions.items.status.queued')\n          : t('subscriptions.items.status.notQueued')\n        const tooltipLabel = item.addedToQueue\n          ? hasResolvedDownloadStatus\n            ? t('subscriptions.items.tooltip.downloadStatus', {\n                status: t(subscriptionItemStatusLabels[itemStatus])\n              })\n            : t('subscriptions.items.tooltip.downloadPending')\n          : t('subscriptions.items.tooltip.notQueued')\n        const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'\n        return (\n          <ContextMenu key={`${subscription.id}-${item.id}`}>\n            <ContextMenuTrigger asChild>\n              <article className=\"group transition-all\">\n                <div className=\"relative aspect-video w-full overflow-hidden rounded-2xl bg-muted\">\n                  {item.thumbnail ? (\n                    <RemoteImage\n                      alt={item.title}\n                      className=\"absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105\"\n                      src={item.thumbnail}\n                    />\n                  ) : (\n                    <div className=\"absolute inset-0 flex items-center justify-center text-muted-foreground text-sm\">\n                      {t('subscriptions.labels.noThumbnail')}\n                    </div>\n                  )}\n                  <div className=\"pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-black/5 to-transparent\" />\n                  <div className=\"absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 py-1 pr-3 pl-1 font-medium text-white text-xs backdrop-blur\">\n                    {subscription.coverUrl ? (\n                      <div className=\"h-6 w-6 overflow-hidden rounded-full border border-white/40\">\n                        <RemoteImage\n                          alt={subscription.title || t('subscriptions.labels.unknown')}\n                          className=\"h-full w-full object-cover\"\n                          src={subscription.coverUrl}\n                        />\n                      </div>\n                    ) : (\n                      <div className=\"flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 font-semibold text-[10px] text-white uppercase\">\n                        {(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}\n                      </div>\n                    )}\n                    <span className=\"max-w-40 truncate text-xs\">\n                      {subscription.title || t('subscriptions.labels.unknown')}\n                    </span>\n                  </div>\n                  <div className=\"absolute bottom-3 left-3 font-medium text-white text-xs\">\n                    {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}\n                  </div>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <Badge\n                        className={cn(\n                          'absolute right-3 bottom-3 rounded-full text-white text-xs backdrop-blur',\n                          badgeClass\n                        )}\n                        variant=\"secondary\"\n                      >\n                        {badgeLabel}\n                      </Badge>\n                    </TooltipTrigger>\n                    <TooltipContent>{tooltipLabel}</TooltipContent>\n                  </Tooltip>\n                </div>\n                <div className=\"flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between\">\n                  <div className=\"space-y-1\">\n                    <p\n                      className=\"font-semibold text-base text-card-foreground leading-snug\"\n                      title={item.title}\n                    >\n                      {item.title}\n                    </p>\n                  </div>\n                  <div className=\"flex items-center gap-2\">\n                    <Button\n                      className=\"rounded-full px-4\"\n                      onClick={() => void handleOpenItem(item.url)}\n                      size=\"sm\"\n                      title={t('subscriptions.items.actions.open')}\n                      variant=\"secondary\"\n                    >\n                      <ExternalLink className=\"h-4 w-4\" />\n                    </Button>\n                  </div>\n                </div>\n              </article>\n            </ContextMenuTrigger>\n            <ContextMenuContent>\n              <ContextMenuItem\n                disabled={item.addedToQueue}\n                onClick={() => void handleQueueItem(item)}\n              >\n                <Download className=\"h-4 w-4\" />\n                {t('subscriptions.items.actions.queue')}\n              </ContextMenuItem>\n              <ContextMenuItem onClick={() => void handleOpenItem(item.url)}>\n                <ExternalLink className=\"h-4 w-4\" />\n                {t('subscriptions.items.actions.open')}\n              </ContextMenuItem>\n            </ContextMenuContent>\n          </ContextMenu>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/store/downloads.ts",
    "content": "import { atom } from 'jotai'\nimport type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'\n\nexport type DownloadRecord = DownloadItem & {\n  entryType: 'active' | 'history'\n  downloadedAt?: number\n  downloadPath?: string\n  savedFileName?: string\n}\n\nconst isFinalStatus = (status: DownloadHistoryItem['status']): boolean =>\n  status === 'completed' || status === 'error' || status === 'cancelled'\n\nconst recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`\n\nconst toActiveRecord = (item: DownloadItem): DownloadRecord => ({\n  ...item,\n  entryType: 'active'\n})\n\nconst toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({\n  id: item.id,\n  url: item.url,\n  title: item.title,\n  thumbnail: item.thumbnail,\n  type: item.type,\n  status: item.status,\n  progress: undefined,\n  error: item.error,\n  ytDlpCommand: item.ytDlpCommand,\n  ytDlpLog: item.ytDlpLog,\n  downloadPath: item.downloadPath,\n  speed: undefined,\n  duration: item.duration,\n  fileSize: item.fileSize,\n  createdAt: item.downloadedAt,\n  startedAt: item.downloadedAt,\n  completedAt: item.completedAt ?? item.downloadedAt,\n  description: item.description,\n  channel: item.channel,\n  uploader: item.uploader,\n  viewCount: item.viewCount,\n  tags: item.tags,\n  selectedFormat: item.selectedFormat,\n  playlistId: item.playlistId,\n  playlistTitle: item.playlistTitle,\n  playlistIndex: item.playlistIndex,\n  playlistSize: item.playlistSize,\n  savedFileName: item.savedFileName,\n  entryType: 'history',\n  downloadedAt: item.downloadedAt\n})\n\nexport const downloadRecordsAtom = atom<Map<string, DownloadRecord>>(new Map())\n\nexport const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  downloads.delete(recordKey('history', item.id))\n  downloads.set(recordKey('active', item.id), toActiveRecord(item))\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const updateDownloadAtom = atom(\n  null,\n  (get, set, update: { id: string; changes: Partial<DownloadItem> }) => {\n    const downloads = new Map(get(downloadRecordsAtom))\n    const key = recordKey('active', update.id)\n    const existing = downloads.get(key)\n    if (!existing) {\n      return\n    }\n    downloads.set(key, { ...existing, ...update.changes })\n    set(downloadRecordsAtom, downloads)\n  }\n)\n\nexport const removeDownloadAtom = atom(null, (get, set, id: string) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  downloads.delete(recordKey('active', id))\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const clearCompletedAtom = atom(null, (get, set) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  for (const [key, item] of downloads.entries()) {\n    if (item.entryType === 'active' && item.status === 'completed') {\n      downloads.delete(key)\n    }\n  }\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  const activeKey = recordKey('active', item.id)\n  if (downloads.has(activeKey)) {\n    if (!isFinalStatus(item.status)) {\n      set(downloadRecordsAtom, downloads)\n      return\n    }\n    downloads.delete(activeKey)\n  }\n  downloads.set(recordKey('history', item.id), toHistoryRecord(item))\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  downloads.delete(recordKey('history', id))\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const removeHistoryRecordsAtom = atom(null, (get, set, ids: string[]) => {\n  if (!ids || ids.length === 0) {\n    return\n  }\n  const downloads = new Map(get(downloadRecordsAtom))\n  const uniqueIds = Array.from(new Set(ids))\n  uniqueIds.forEach((id) => {\n    downloads.delete(recordKey('history', id))\n  })\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const removeHistoryRecordsByPlaylistAtom = atom(null, (get, set, playlistId: string) => {\n  if (!playlistId) {\n    return\n  }\n  const downloads = new Map(get(downloadRecordsAtom))\n  for (const [key, item] of downloads.entries()) {\n    if (item.entryType === 'history' && item.playlistId === playlistId) {\n      downloads.delete(key)\n    }\n  }\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const clearHistoryRecordsAtom = atom(null, (get, set) => {\n  const downloads = new Map(get(downloadRecordsAtom))\n  for (const [key, item] of downloads.entries()) {\n    if (item.entryType === 'history') {\n      downloads.delete(key)\n    }\n  }\n  set(downloadRecordsAtom, downloads)\n})\n\nexport const clearHistoryRecordsByStatusAtom = atom(\n  null,\n  (get, set, status: DownloadHistoryItem['status']) => {\n    const downloads = new Map(get(downloadRecordsAtom))\n    for (const [key, item] of downloads.entries()) {\n      if (item.entryType === 'history' && item.status === status) {\n        downloads.delete(key)\n      }\n    }\n    set(downloadRecordsAtom, downloads)\n  }\n)\n\nexport const downloadsArrayAtom = atom((get) => {\n  const downloads = get(downloadRecordsAtom)\n  return Array.from(downloads.values()).sort((a, b) => b.createdAt - a.createdAt)\n})\n\nexport const activeDownloadsArrayAtom = atom((get) =>\n  get(downloadsArrayAtom).filter((item) => item.entryType === 'active')\n)\n\nexport const historyDownloadsArrayAtom = atom((get) =>\n  get(downloadsArrayAtom).filter((item) => item.entryType === 'history')\n)\n\nexport const historyStatsAtom = atom((get) => {\n  const history = get(historyDownloadsArrayAtom)\n  return history.reduce(\n    (acc, item) => {\n      acc.total += 1\n      if (item.status === 'completed') {\n        acc.completed += 1\n      }\n      if (item.status === 'error') {\n        acc.error += 1\n      }\n      if (item.status === 'cancelled') {\n        acc.cancelled += 1\n      }\n      return acc\n    },\n    { total: 0, completed: 0, error: 0, cancelled: 0 }\n  )\n})\n\nexport const downloadStatsAtom = atom((get) => {\n  const downloads = get(downloadsArrayAtom)\n  return downloads.reduce(\n    (acc, item) => {\n      acc.total += 1\n      if (\n        (item.entryType === 'active' && item.status === 'downloading') ||\n        item.status === 'processing' ||\n        item.status === 'pending'\n      ) {\n        acc.active += 1\n      }\n      if (item.status === 'completed') {\n        acc.completed += 1\n      }\n      if (item.status === 'error') {\n        acc.error += 1\n      }\n      if (item.status === 'cancelled') {\n        acc.cancelled += 1\n      }\n      return acc\n    },\n    { total: 0, active: 0, completed: 0, error: 0, cancelled: 0 }\n  )\n})\n\nexport const activeDownloadsCountAtom = atom((get) => {\n  const downloads = get(downloadRecordsAtom)\n  let count = 0\n  for (const item of downloads.values()) {\n    if (\n      (item.entryType === 'active' && item.status === 'downloading') ||\n      item.status === 'processing'\n    ) {\n      count++\n    }\n  }\n  return count\n})\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/store/settings.ts",
    "content": "import { normalizeLanguageCode } from '@vidbee/i18n/languages'\nimport { atom } from 'jotai'\nimport type { AppSettings } from '../../../shared/types'\nimport { defaultSettings } from '../../../shared/types'\nimport i18n from '../i18n'\nimport { ipcServices } from '../lib/ipc'\n\n// Settings atom\nexport const settingsAtom = atom<AppSettings>(defaultSettings)\n\n// Load settings from main process\nexport const loadSettingsAtom = atom(null, async (_get, set) => {\n  try {\n    const settings = await ipcServices.settings.getAll()\n    const savedLanguage = normalizeLanguageCode(settings.language)\n    const currentLanguage = normalizeLanguageCode(i18n.language)\n\n    if (currentLanguage !== savedLanguage) {\n      try {\n        await i18n.changeLanguage(savedLanguage)\n      } catch (error) {\n        console.error('Failed to apply saved language:', error)\n      }\n    }\n\n    set(settingsAtom, { ...settings, language: savedLanguage })\n  } catch (error) {\n    console.error('Failed to load settings:', error)\n  }\n})\n\n// Save a specific setting\nexport const saveSettingAtom = atom(\n  null,\n  async (get, set, update: { key: keyof AppSettings; value: AppSettings[keyof AppSettings] }) => {\n    const previousSettings = get(settingsAtom)\n    const nextSettings = { ...previousSettings, [update.key]: update.value }\n    set(settingsAtom, nextSettings)\n\n    try {\n      await ipcServices.settings.set(update.key, update.value)\n    } catch (error) {\n      set(settingsAtom, previousSettings)\n      console.error('Failed to save setting:', error)\n    }\n  }\n)\n\n// Save all settings\nexport const saveAllSettingsAtom = atom(\n  null,\n  async (get, set, newSettings: Partial<AppSettings>) => {\n    try {\n      await ipcServices.settings.setAll(newSettings)\n      const settings = get(settingsAtom)\n      set(settingsAtom, { ...settings, ...newSettings })\n    } catch (error) {\n      console.error('Failed to save settings:', error)\n    }\n  }\n)\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/store/subscriptions.ts",
    "content": "import type {\n  SubscriptionResolvedFeed,\n  SubscriptionRule,\n  SubscriptionUpdatePayload\n} from '@shared/types'\nimport { atom } from 'jotai'\nimport { ipcServices } from '../lib/ipc'\n\nconst normalizeCommaList = (value?: string): string[] => {\n  if (!value) {\n    return []\n  }\n  return value\n    .split(',')\n    .map((entry) => entry.trim())\n    .filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)\n}\n\nexport const subscriptionsAtom = atom<SubscriptionRule[]>([])\n\nexport const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => {\n  set(subscriptionsAtom, subscriptions)\n})\n\nexport const loadSubscriptionsAtom = atom(null, async (_get, set) => {\n  try {\n    const subscriptions = await ipcServices.subscriptions.list()\n    set(subscriptionsAtom, subscriptions)\n  } catch (error) {\n    console.error('Failed to load subscriptions:', error)\n  }\n})\n\nexport interface CreateSubscriptionForm {\n  url: string\n  keywords?: string\n  tags?: string\n  onlyDownloadLatest?: boolean\n  downloadDirectory?: string\n  namingTemplate?: string\n  enabled?: boolean\n}\n\nexport const createSubscriptionAtom = atom(\n  null,\n  async (_get, _set, payload: CreateSubscriptionForm) => {\n    await ipcServices.subscriptions.create({\n      url: payload.url,\n      keywords: normalizeCommaList(payload.keywords),\n      tags: normalizeCommaList(payload.tags),\n      onlyDownloadLatest: payload.onlyDownloadLatest,\n      downloadDirectory: payload.downloadDirectory,\n      namingTemplate: payload.namingTemplate,\n      enabled: payload.enabled\n    })\n  }\n)\n\nexport const updateSubscriptionAtom = atom(\n  null,\n  async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => {\n    await ipcServices.subscriptions.update(update.id, update.data)\n  }\n)\n\nexport const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => {\n  await ipcServices.subscriptions.remove(id)\n})\n\nexport const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => {\n  await ipcServices.subscriptions.refresh(id)\n})\n\nexport const resolveFeedAtom = atom(\n  null,\n  async (_get, _set, url: string): Promise<SubscriptionResolvedFeed> => {\n    return ipcServices.subscriptions.resolve(url)\n  }\n)\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/store/update.ts",
    "content": "import { atom } from 'jotai'\n\ninterface UpdateReadyState {\n  ready: boolean\n  version?: string\n}\n\ninterface UpdateAvailableState {\n  available: boolean\n  version?: string\n}\n\nexport const updateReadyAtom = atom<UpdateReadyState>({\n  ready: false,\n  version: undefined\n})\n\nexport const updateAvailableAtom = atom<UpdateAvailableState>({\n  available: false,\n  version: undefined\n})\n"
  },
  {
    "path": "apps/desktop/src/renderer/src/store/video.ts",
    "content": "import { atom } from 'jotai'\nimport type { VideoInfo } from '../../../shared/types'\nimport { ipcServices } from '../lib/ipc'\n\n// Current video info being prepared for download\nexport const currentVideoInfoAtom = atom<VideoInfo | null>(null)\n\n// Loading state for video info\nexport const videoInfoLoadingAtom = atom<boolean>(false)\n\n// Error state for video info\nexport const videoInfoErrorAtom = atom<string | null>(null)\n\n// Last yt-dlp command used for video info\nexport const videoInfoCommandAtom = atom<string | null>(null)\n\n// Fetch video info\nexport const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {\n  set(videoInfoLoadingAtom, true)\n  set(videoInfoErrorAtom, null)\n  set(videoInfoCommandAtom, null)\n  set(currentVideoInfoAtom, null)\n\n  try {\n    const result = await ipcServices.download.getVideoInfoWithCommand(url)\n    set(videoInfoCommandAtom, result.ytDlpCommand)\n    if (result.info) {\n      set(currentVideoInfoAtom, result.info)\n      return\n    }\n    set(videoInfoErrorAtom, result.error || 'Failed to fetch video info')\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'\n    set(videoInfoErrorAtom, errorMessage)\n  } finally {\n    set(videoInfoLoadingAtom, false)\n  }\n})\n\n// Clear video info\nexport const clearVideoInfoAtom = atom(null, (_get, set) => {\n  set(currentVideoInfoAtom, null)\n  set(videoInfoErrorAtom, null)\n  set(videoInfoCommandAtom, null)\n})\n"
  },
  {
    "path": "apps/desktop/src/shared/constants.ts",
    "content": "export const APP_PROTOCOL = 'vidbee'\nexport const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`\n"
  },
  {
    "path": "apps/desktop/src/shared/types/index.ts",
    "content": "import { defaultLanguageCode, type LanguageCode } from '@vidbee/i18n/languages'\n\n// Download related types\nexport interface VideoFormat {\n  format_id: string\n  ext: string\n  height?: number\n  width?: number\n  fps?: number\n  vcodec?: string\n  acodec?: string\n  filesize?: number\n  filesize_approx?: number\n  format_note?: string\n  video_ext?: string\n  audio_ext?: string\n  tbr?: number\n  quality?: number\n  protocol?: string // http, https, m3u8, m3u8_native, etc.\n  language?: string\n}\n\nexport interface VideoInfo {\n  id: string\n  title: string\n  thumbnail?: string\n  duration?: number\n  formats: VideoFormat[]\n  extractor_key?: string\n  webpage_url?: string\n  description?: string\n  view_count?: number\n  uploader?: string\n}\n\nexport interface VideoInfoCommandResult {\n  info?: VideoInfo\n  ytDlpCommand: string\n  error?: string\n}\n\nexport interface DownloadProgress {\n  percent: number\n  currentSpeed?: string\n  eta?: string\n  downloaded?: string\n  total?: string\n}\n\nexport type DownloadStatus =\n  | 'pending'\n  | 'downloading'\n  | 'processing'\n  | 'completed'\n  | 'error'\n  | 'cancelled'\n\nexport interface DownloadItem {\n  id: string\n  url: string\n  title: string\n  thumbnail?: string\n  type: 'video' | 'audio'\n  status: DownloadStatus\n  progress?: DownloadProgress\n  error?: string\n  speed?: string\n  ytDlpCommand?: string\n  ytDlpLog?: string\n  // Enhanced video information\n  duration?: number\n  fileSize?: number\n  savedFileName?: string\n  // Timestamps\n  createdAt: number\n  startedAt?: number\n  completedAt?: number\n  // Additional metadata\n  description?: string\n  channel?: string\n  uploader?: string\n  viewCount?: number\n  tags?: string[]\n  origin?: 'manual' | 'subscription'\n  subscriptionId?: string\n  // Download-specific format info\n  selectedFormat?: VideoFormat\n  // Playlist context (optional)\n  playlistId?: string\n  playlistTitle?: string\n  playlistIndex?: number\n  playlistSize?: number\n}\n\nexport interface SubscriptionFeedItem {\n  id: string\n  url: string\n  title: string\n  publishedAt: number\n  thumbnail?: string\n  addedToQueue: boolean\n  downloadId?: string\n}\n\nexport interface DownloadHistoryItem {\n  id: string\n  url: string\n  title: string\n  thumbnail?: string\n  type: 'video' | 'audio'\n  status: DownloadStatus\n  downloadPath?: string\n  savedFileName?: string\n  fileSize?: number\n  duration?: number\n  downloadedAt: number\n  completedAt?: number\n  error?: string\n  ytDlpCommand?: string\n  ytDlpLog?: string\n  // Additional metadata\n  description?: string\n  channel?: string\n  uploader?: string\n  viewCount?: number\n  tags?: string[]\n  origin?: 'manual' | 'subscription'\n  subscriptionId?: string\n  // Download-specific format info\n  selectedFormat?: VideoFormat\n  // Playlist context (optional)\n  playlistId?: string\n  playlistTitle?: string\n  playlistIndex?: number\n  playlistSize?: number\n}\n\nexport interface DownloadOptions {\n  url: string\n  type: 'video' | 'audio'\n  format?: string\n  audioFormat?: string\n  audioFormatIds?: string[]\n  startTime?: string\n  endTime?: string\n  downloadSubs?: boolean\n  customDownloadPath?: string\n  customFilenameTemplate?: string\n  tags?: string[]\n  origin?: 'manual' | 'subscription'\n  subscriptionId?: string\n}\n\nexport interface PlaylistEntry {\n  id: string\n  title: string\n  url: string\n  index: number\n  thumbnail?: string\n}\n\nexport interface PlaylistInfo {\n  id: string\n  title: string\n  entries: PlaylistEntry[]\n  entryCount: number\n}\n\nexport interface PlaylistDownloadOptions {\n  url: string\n  type: 'video' | 'audio'\n  format?: string\n  entryIds?: string[]\n  startIndex?: number\n  endIndex?: number\n  filenameFormat?: string\n  folderFormat?: string\n  customDownloadPath?: string\n}\n\nexport interface PlaylistDownloadEntry {\n  downloadId: string\n  entryId: string\n  title: string\n  url: string\n  index: number\n}\n\nexport interface PlaylistDownloadResult {\n  groupId: string\n  playlistId: string\n  playlistTitle: string\n  type: 'video' | 'audio'\n  totalCount: number\n  startIndex: number\n  endIndex: number\n  entries: PlaylistDownloadEntry[]\n}\n\n// Subscription types\nexport type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'\n\nexport type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'\n\nexport const SUBSCRIPTION_DUPLICATE_FEED_ERROR = 'SUBSCRIPTION_DUPLICATE_FEED_URL'\n\nexport interface SubscriptionRule {\n  id: string\n  title: string\n  sourceUrl: string\n  feedUrl: string\n  platform: SubscriptionPlatform\n  keywords: string[]\n  tags: string[]\n  onlyDownloadLatest: boolean\n  enabled: boolean\n  coverUrl?: string\n  latestVideoTitle?: string\n  latestVideoPublishedAt?: number\n  lastCheckedAt?: number\n  lastSuccessAt?: number\n  status: SubscriptionStatus\n  lastError?: string\n  createdAt: number\n  updatedAt: number\n  downloadDirectory?: string\n  namingTemplate?: string\n  items: SubscriptionFeedItem[]\n}\n\nexport interface SubscriptionResolvedFeed {\n  sourceUrl: string\n  feedUrl: string\n  platform: SubscriptionPlatform\n}\n\nexport interface SubscriptionCreatePayload {\n  sourceUrl: string\n  feedUrl: string\n  platform: SubscriptionPlatform\n  keywords?: string[]\n  tags?: string[]\n  onlyDownloadLatest?: boolean\n  downloadDirectory?: string\n  namingTemplate?: string\n  enabled?: boolean\n}\n\nexport interface SubscriptionUpdatePayload {\n  title?: string\n  sourceUrl?: string\n  feedUrl?: string\n  platform?: SubscriptionPlatform\n  keywords?: string[]\n  tags?: string[]\n  onlyDownloadLatest?: boolean\n  enabled?: boolean\n  downloadDirectory?: string\n  namingTemplate?: string\n  items?: SubscriptionFeedItem[]\n}\n\n// Settings types\nexport type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'\n\nexport interface AppSettings {\n  downloadPath: string\n  maxConcurrentDownloads: number\n  browserForCookies: string\n  cookiesPath: string\n  proxy: string\n  configPath: string\n  betaProgram: boolean\n  language: LanguageCode\n  theme: string\n  oneClickDownload: boolean\n  oneClickDownloadType: 'video' | 'audio'\n  oneClickQuality: OneClickQualityPreset\n  closeToTray: boolean\n  hideDockIcon: boolean\n  launchAtLogin: boolean\n  autoUpdate: boolean\n  subscriptionOnlyLatestDefault: boolean\n  enableAnalytics: boolean\n  embedSubs: boolean\n  embedThumbnail: boolean\n  embedMetadata: boolean\n  embedChapters: boolean\n  shareWatermark: boolean\n}\n\nexport const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'\n\nexport const defaultSettings: AppSettings = {\n  downloadPath: '',\n  maxConcurrentDownloads: 5,\n  browserForCookies: 'none',\n  cookiesPath: '',\n  proxy: '',\n  configPath: '',\n  betaProgram: false,\n  language: defaultLanguageCode,\n  theme: 'system',\n  oneClickDownload: false,\n  oneClickDownloadType: 'video',\n  oneClickQuality: 'best',\n  closeToTray: true,\n  hideDockIcon: false,\n  launchAtLogin: false,\n  autoUpdate: true,\n  subscriptionOnlyLatestDefault: true,\n  enableAnalytics: true,\n  embedSubs: true,\n  embedThumbnail: false,\n  embedMetadata: true,\n  embedChapters: true,\n  shareWatermark: false\n}\n"
  },
  {
    "path": "apps/desktop/src/shared/types/ipc.ts",
    "content": "// Re-export the IpcServices type from main process\nexport type { IpcServices } from '../../main/ipc'\n"
  },
  {
    "path": "apps/desktop/src/shared/utils/download-file.ts",
    "content": "export const normalizeSavedFileName = (fileName?: string): string | undefined => {\n  if (!fileName) {\n    return undefined\n  }\n  const trimmed = fileName.trim()\n  if (!trimmed) {\n    return undefined\n  }\n  // Remove yt-dlp format identifiers like:\n  // - .f123\n  // - .fhls-audio-128000-Audio\n  // - .fwebm-video-only\n  // Pattern: .f followed by alphanumeric/dash, ending before the final extension\n  return trimmed.replace(/\\.f[a-z0-9-]+(?=\\.[^.]+$)/gi, '')\n}\n\nexport const buildFileNameCandidates = (\n  title: string,\n  format: string,\n  savedFileName?: string\n): string[] => {\n  const safeTitle = title.trim() || 'Unknown'\n\n  const savedNameCandidates: string[] = []\n  const trimmedSavedFileName = savedFileName?.trim()\n  if (trimmedSavedFileName) {\n    const normalized = normalizeSavedFileName(trimmedSavedFileName)\n    if (normalized) {\n      savedNameCandidates.push(normalized)\n    }\n    if (!normalized || normalized !== trimmedSavedFileName) {\n      savedNameCandidates.push(trimmedSavedFileName)\n    }\n  }\n\n  return savedNameCandidates.length > 0\n    ? savedNameCandidates\n    : [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]\n}\n\nexport const buildFilePathCandidates = (\n  downloadPath: string,\n  title: string,\n  format: string,\n  savedFileName?: string\n): string[] => {\n  const normalizedDownloadPath = downloadPath.replace(/\\\\/g, '/')\n  const candidateFileNames = buildFileNameCandidates(title, format, savedFileName)\n  return Array.from(\n    new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))\n  )\n}\n\nexport const normalizeFilenameKey = (value: string): string => {\n  return value\n    .normalize('NFKC')\n    .toLowerCase()\n    .replace(/via\\s*vidbee/gi, '')\n    .replace(/[^a-z0-9\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af]+/g, '')\n}\n\nexport const buildFilenameKey = (value?: string): string => {\n  if (!value) {\n    return ''\n  }\n  return normalizeFilenameKey(value)\n}\n"
  },
  {
    "path": "apps/desktop/src/shared/utils/format-preferences.ts",
    "content": "import {\n  buildAudioFormatPreference as buildSharedAudioFormatPreference,\n  buildVideoFormatPreference as buildSharedVideoFormatPreference\n} from '@vidbee/downloader-core/format-preferences'\nimport type { AppSettings } from '../types'\n\nexport const buildVideoFormatPreference = (settings: AppSettings): string =>\n  buildSharedVideoFormatPreference({ oneClickQuality: settings.oneClickQuality })\n\nexport const buildAudioFormatPreference = (settings: AppSettings): string =>\n  buildSharedAudioFormatPreference({ oneClickQuality: settings.oneClickQuality })\n"
  },
  {
    "path": "apps/desktop/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.node.json\" },\n    { \"path\": \"./tsconfig.web.json\" }\n  ],\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@renderer/*\": [\"src/renderer/src/*\"],\n      \"@shared/*\": [\"src/shared/*\"]\n    },\n    \"strictNullChecks\": true\n  }\n}\n"
  },
  {
    "path": "apps/desktop/tsconfig.node.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.node.json\",\n  \"include\": [\n    \"electron.vite.config.*\",\n    \"src/main/**/*\",\n    \"src/preload/**/*\",\n    \"src/shared/**/*\"\n  ],\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"types\": [\"electron-vite/node\"],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@main/*\": [\"src/main/*\"],\n      \"@shared/*\": [\"src/shared/*\"]\n    },\n    \"strictNullChecks\": true\n  }\n}\n"
  },
  {
    "path": "apps/desktop/tsconfig.web.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.web.json\",\n  \"include\": [\n    \"src/renderer/src/env.d.ts\",\n    \"src/renderer/src/**/*\",\n    \"src/renderer/src/**/*.tsx\",\n    \"src/preload/*.d.ts\",\n    \"src/shared/**/*\",\n    \"src/main/**/*\"\n  ],\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"types\": [\"unplugin-icons/types/react\"],\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"paths\": {\n      \"@renderer/*\": [\"src/renderer/src/*\"],\n      \"@shared/*\": [\"src/shared/*\"]\n    },\n    \"strictNullChecks\": true\n  }\n}\n"
  },
  {
    "path": "apps/docs/.gitignore",
    "content": "# deps\n/node_modules\n\n# generated content\n.source\n\n# test & build\n/coverage\n/.next/\n/out/\n/build\n*.tsbuildinfo\n\n# misc\n.DS_Store\n*.pem\n/.pnp\n.pnp.js\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# others\n.env*.local\n.vercel\nnext-env.d.ts"
  },
  {
    "path": "apps/docs/README.md",
    "content": "# docs\n\nThis is a Next.js application generated with\n[Create Fumadocs](https://github.com/fuma-nama/fumadocs).\n\nRun development server:\n\n```bash\nnpm run dev\n# or\npnpm dev\n# or\nyarn dev\n```\n\nOpen http://localhost:3000 with your browser to see the result.\n\n## Explore\n\nIn the project, you can see:\n\n- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.\n- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.\n\n| Route                     | Description                                            |\n| ------------------------- | ------------------------------------------------------ |\n| `app/(home)`              | The route group for your landing page and other pages. |\n| `app/docs`                | The documentation layout and pages.                    |\n| `app/api/search/route.ts` | The Route Handler for search.                          |\n\n### Fumadocs MDX\n\nA `source.config.ts` config file has been included, you can customise different options like frontmatter schema.\n\nRead the [Introduction](https://fumadocs.dev/docs/mdx) for further details.\n\n## Learn More\n\nTo learn more about Next.js and Fumadocs, take a look at the following\nresources:\n\n- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js\n  features and API.\n- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.\n- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs\n"
  },
  {
    "path": "apps/docs/biome.config.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.2.0/schema.json\",\n  \"vcs\": {\n    \"enabled\": true,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"files\": {\n    \"ignoreUnknown\": true,\n    \"includes\": [\n      \"**\",\n      \"!node_modules\",\n      \"!.next\",\n      \"!dist\",\n      \"!build\",\n      \"!.source\"\n    ]\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true\n    },\n    \"domains\": {\n      \"next\": \"recommended\",\n      \"react\": \"recommended\"\n    }\n  },\n  \"assist\": {\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": \"on\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/docs/content/cookies.mdx",
    "content": "---\ntitle: Cookies\ndescription: Signed-in downloads, restricted content, and cookie setup\n---\n\nCookies reuse your browser's signed-in session so VidBee can download content that requires login or verification, such as subscriber-only items, age-gated pages, or private links.\n\nIf you see a message like **\"Sign in to confirm you're not a bot\"** or \"login required,\" you need cookies.\n\n![Error message prompting to sign in](/cookies/cookies-error-sign-in.png)\n\n## When you need cookies\n\n- Content that requires an account to view or download.\n- Age-gated or region-locked pages.\n- Private links or lists visible only to signed-in users.\n- “Sign in to confirm you’re not a bot” or similar verification prompts.\n\n## Two ways to use cookies in VidBee\n\n### Option 1: Read cookies from your browser\n\nIn **Settings**, select your browser. VidBee will try to detect the browser profile path automatically. You can also enter the profile path manually.\n\n![Select browser for cookies](/cookies/cookies-settings-select-browser.png)\n\n**Supported browsers (platform dependent):**\n\n- **Windows: Firefox only. Other browsers cannot be used for cookie reading.**\n- macOS: All browsers supported.\n- Linux: All browsers supported.\n\n**Steps:**\n\n1. Sign in to the site in your browser first.\n2. Open VidBee → Settings → Cookies.\n3. Select a browser and confirm the profile path.\n4. Go back and start the download again.\n\nIf detection fails or the path is invalid, manually choose the actual browser profile directory.\nOn Windows, if you are not using Firefox, switch to the cookies file method.\n\n### Option 2: Import a cookies file\n\nYou can import a cookies file. This is useful when reading the browser profile is not possible, such as using non-Firefox browsers on Windows.\n\n![Cookies file settings](/cookies-file.png)\n\n**Steps:**\n\n1. **Export cookies using a browser extension**\n\n   Choose one of these recommended extensions:\n   - **Chrome/Edge:** [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)\n   - **Firefox:** [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)\n\n   > **Security note:** Only install the extensions recommended above. If you previously installed \"Get cookies.txt\" (not \"LOCALLY\"), uninstall it immediately. That version has been reported as malware.\n\n2. **Export your cookies**\n\n   Click the export button in the extension and choose where to save the file. The extension will automatically export the cookies in **Netscape format**.\n\n3. **Import to VidBee**\n\n   - Open VidBee → Settings → Cookies file\n   - Click Select file and choose the exported cookies file\n   - Refresh the download page when done\n\n4. **Remove the cookies file**\n\n   Click Clear to disable it anytime.\n\n## Recommendations\n\n- Prefer browser-based cookies when possible, because it's easier to maintain.\n- If browser reading fails, switch to a cookies file.\n- Cookies files expire. Re-export when your account state changes.\n\n## Troubleshooting\n\n**Browser-based method:**\n\n- **Profile path is invalid**: Ensure the folder exists and points to the real browser profile directory.\n- **Browser read failed**: Close the browser and retry, or switch to a cookies file method.\n\n**Cookies file method:**\n\n- **Still asks for login**: Verify your browser is signed into the account. If signed in, ensure the exported cookies file is complete and properly formatted.\n- **Export extension not working**: Make sure you installed the recommended extension version. Some outdated or unauthorized versions may not work properly.\n- **Cookies file expired**: If unused for a long time, your login session may have expired. Sign in again in your browser and export a new cookies file.\n- **HTTP Error 400**: This usually indicates a file format issue. Ensure the file is in standard Netscape format with correct encoding.\n\n## Learn more\n\n- [yt-dlp cookies FAQ](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp)\n\n## Privacy and security\n\nCookies are equivalent to your login session. Keep them private and never share or upload them. If you suspect leakage, sign out and change your password.\n"
  },
  {
    "path": "apps/docs/content/faq.mdx",
    "content": "---\ntitle: FAQ\ndescription: Common questions and troubleshooting for VidBee\n---\n\n## What if a download fails or shows an error?\n\n- Confirm the link is valid and opens in your browser.\n- Update VidBee to the latest version and retry.\n- If the content requires login or age verification, [configure cookies](/cookies).\n\n![Error message prompting to sign in](/cookies/cookies-error-sign-in.png)\n\n## Why does the same link sometimes work and sometimes fail?\n\nSome sites frequently change page structure or limit request rates. Suggested steps:\n\n- Make sure VidBee is up to date.\n- Reduce the number of concurrent downloads.\n- [Use cookies to reuse your signed-in session when needed.](/cookies)\n\n## Which sites are supported?\n\nVidBee uses the yt-dlp extractor system and supports many sites. Try the download first; if it fails, submit feedback with the link and error details.\n\n## Why is the download speed slow?\n\n- Check your network and proxy settings.\n- Avoid starting too many tasks at once.\n- Some sites limit bandwidth on their side.\n\n## macOS says “file is damaged”\n\nRemove the quarantine attribute and retry:\n\n```bash\nxattr -rd com.apple.quarantine /Applications/VidBee.app/\n```\n"
  },
  {
    "path": "apps/docs/content/fr/cookies.mdx",
    "content": "---\ntitle: Cookies\ndescription: Téléchargements avec connexion, contenu restreint et configuration des cookies\n---\n\nLes cookies réutilisent la session connectée de votre navigateur pour que VidBee puisse télécharger du contenu qui nécessite une connexion ou une vérification, comme des éléments réservés aux abonnés, des pages avec limite d’âge ou des liens privés.\n\nSi vous voyez un message comme **\"Connectez-vous pour confirmer que vous n’êtes pas un robot\"** ou \"connexion requise\", vous avez besoin de cookies.\n\n![Message d’erreur demandant la connexion](/cookies/cookies-error-sign-in.png)\n\n## Quand vous avez besoin des cookies\n\n- Contenu nécessitant un compte pour être consulté ou téléchargé.\n- Pages avec limite d’âge ou blocage régional.\n- Liens privés ou listes visibles uniquement par les utilisateurs connectés.\n- “Connectez-vous pour confirmer que vous n’êtes pas un robot” ou messages similaires.\n\n## Deux façons d’utiliser les cookies dans VidBee\n\n### Option 1 : Lire les cookies depuis votre navigateur\n\nDans **Réglages**, sélectionnez votre navigateur. VidBee essaiera de détecter automatiquement le chemin du profil. Vous pouvez aussi saisir le chemin du profil manuellement.\n\n![Sélection du navigateur pour les cookies](/cookies/cookies-settings-select-browser.png)\n\n**Navigateurs pris en charge (selon la plateforme) :**\n\n- **Windows : Firefox uniquement. Les autres navigateurs ne peuvent pas être utilisés pour la lecture des cookies.**\n- macOS : tous les navigateurs sont pris en charge.\n- Linux : tous les navigateurs sont pris en charge.\n\n**Étapes :**\n\n1. Connectez-vous d’abord au site dans votre navigateur.\n2. Ouvrez VidBee → Réglages → Cookies.\n3. Sélectionnez un navigateur et confirmez le chemin du profil.\n4. Revenez et relancez le téléchargement.\n\nSi la détection échoue ou si le chemin est invalide, choisissez manuellement le vrai dossier de profil du navigateur.\nSous Windows, si vous n’utilisez pas Firefox, passez à la méthode du fichier cookies.\n\n### Option 2 : Importer un fichier de cookies\n\nVous pouvez importer un fichier de cookies. C'est utile quand la lecture du profil navigateur n'est pas possible, par exemple pour les navigateurs autres que Firefox sous Windows.\n\n![Réglages du fichier cookies](/cookies-file.png)\n\n**Étapes :**\n\n1. **Exportez les cookies avec une extension navigateur**\n\n   Choisissez l'une de ces extensions recommandées :\n   - **Chrome/Edge :** [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)\n   - **Firefox :** [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)\n\n   > **Note de sécurité :** installez uniquement les extensions recommandées ci-dessus. Si vous aviez installé \"Get cookies.txt\" (sans \"LOCALLY\"), désinstallez-la immédiatement. Cette version a été signalée comme malveillante.\n\n2. **Exportez vos cookies**\n\n   Cliquez sur le bouton d'export dans l'extension et choisissez où enregistrer le fichier. L'extension exportera automatiquement les cookies au format **Netscape**.\n\n3. **Importez dans VidBee**\n\n   - Ouvrez VidBee → Réglages → Fichier cookies\n   - Cliquez sur Sélectionner fichier et choisissez le fichier de cookies exporté\n   - Actualisez la page de téléchargement une fois terminé\n\n4. **Supprimez le fichier de cookies**\n\n   Cliquez sur Effacer pour le désactiver.\n\n## Recommandations\n\n- Préférez les cookies du navigateur quand c’est possible, c’est plus simple à maintenir.\n- Si la lecture du navigateur échoue, utilisez un fichier cookies.\n- Les fichiers cookies expirent. Réexportez-les lorsque l’état de votre compte change.\n\n## Dépannage\n\n**Méthode basée sur le navigateur :**\n\n- **Chemin du profil invalide** : assurez-vous que le dossier existe et pointe vers le vrai profil navigateur.\n- **Lecture navigateur échouée** : fermez le navigateur et réessayez, ou utilisez la méthode fichier cookies.\n\n**Méthode fichier de cookies :**\n\n- **Toujours demande la connexion** : vérifiez que votre navigateur est bien connecté au compte. Si connecté, assurez-vous que le fichier de cookies exporté est complet et au bon format.\n- **L'extension d'export ne fonctionne pas** : vérifiez que vous avez installé la version recommandée de l'extension. Certaines versions obsolètes ou non autorisées peuvent ne pas fonctionner correctement.\n- **Fichier de cookies expiré** : si longtemps sans utilisation, votre session de connexion peut avoir expiré. Reconnectez-vous dans votre navigateur et exportez un nouveau fichier de cookies.\n- **Erreur HTTP 400** : cela indique généralement un problème de format. Assurez-vous que le fichier est au format Netscape standard avec un encodage correct.\n\n## En savoir plus\n\n- [FAQ cookies de yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp)\n\n## Confidentialité et sécurité\n\nLes cookies sont équivalents à votre session de connexion. Gardez-les privés et ne les partagez jamais. Si vous soupçonnez une fuite, déconnectez-vous et changez votre mot de passe.\n"
  },
  {
    "path": "apps/docs/content/fr/faq.mdx",
    "content": "---\ntitle: FAQ\ndescription: Questions courantes et dépannage pour VidBee\n---\n\n## Que faire si un téléchargement échoue ou affiche une erreur ?\n\n- Vérifiez que le lien est valide et s’ouvre dans votre navigateur.\n- Mettez VidBee à jour vers la dernière version et réessayez.\n- Si le contenu nécessite une connexion ou une vérification d’âge, [configurez les cookies](/cookies).\n\n![Message d’erreur demandant la connexion](/cookies/cookies-error-sign-in.png)\n\n## Pourquoi le même lien fonctionne-t-il parfois et échoue-t-il parfois ?\n\nCertains sites modifient souvent leur structure ou limitent le débit. Étapes suggérées :\n\n- Assurez-vous que VidBee est à jour.\n- Réduisez le nombre de téléchargements simultanés.\n- [Utilisez les cookies pour réutiliser votre session connectée si nécessaire.](/cookies)\n\n## Quels sites sont pris en charge ?\n\nVidBee utilise le système d’extracteurs yt-dlp et prend en charge de nombreux sites. Essayez le téléchargement ; s’il échoue, envoyez un feedback avec le lien et les détails de l’erreur.\n\n## Pourquoi la vitesse de téléchargement est-elle lente ?\n\n- Vérifiez votre réseau et vos réglages de proxy.\n- Évitez de lancer trop de tâches en même temps.\n- Certains sites limitent la bande passante de leur côté.\n\n## macOS indique “fichier endommagé”\n\nSupprimez l’attribut de quarantaine et réessayez :\n\n```bash\nxattr -rd com.apple.quarantine /Applications/VidBee.app/\n```\n"
  },
  {
    "path": "apps/docs/content/fr/index.mdx",
    "content": "---\ntitle: Introduction\ndescription: Documentation et FAQ du téléchargeur VidBee\n---\n\nVidBee est un téléchargeur de bureau construit avec Electron et alimenté par yt-dlp. Il propose une interface claire et une gestion de file d’attente pour le téléchargement de vidéo et d’audio.\n\nCette documentation se concentre sur l’usage réel et les réglages, en particulier les téléchargements avec connexion et la configuration des cookies.\n\n## Commencer ici\n\n- [Protocole vidbee://](./protocol.mdx) : Téléchargement rapide via un protocole d’URL.\n- [Cookies](./cookies.mdx) : Configurer les sessions connectées et le contenu restreint.\n- [RSS](./rss.mdx) : S’abonner aux flux RSS et ajouter automatiquement les téléchargements.\n- [FAQ](./faq.mdx) : Questions courantes et dépannage.\n\n## Liens rapides\n\n- [Site VidBee](https://vidbee.org/)\n- [Sites pris en charge](https://vidbee.org/supported-sites/)\n- [Fonctionnalités](https://vidbee.org/features/)\n"
  },
  {
    "path": "apps/docs/content/fr/meta.json",
    "content": "{\n  \"title\": \"Documentation VidBee\",\n  \"pages\": [\n    \"index\",\n    \"---Téléchargement & Accès---\",\n    \"protocol\",\n    \"cookies\",\n    \"---Automatisation---\",\n    \"rss\",\n    \"---Aide---\",\n    \"faq\"\n  ]\n}\n"
  },
  {
    "path": "apps/docs/content/fr/protocol.mdx",
    "content": "---\ntitle: Protocole vidbee://\ndescription: Téléchargement rapide via le protocole d’URL vidbee://\n---\n\nVidBee enregistre un protocole d’URL personnalisé (`vidbee://`) qui permet de déclencher des téléchargements directement depuis les navigateurs, extensions ou userscripts.\n\n## Utilisation de base\n\nLe protocole `vidbee://` peut ouvrir VidBee et démarrer automatiquement le téléchargement de vidéos.\n\n### Format du protocole\n\n```\nvidbee://download?url=<url-video-encodee>\n```\n\n**Paramètres :**\n- `url` (obligatoire) : l’URL de la vidéo à télécharger, encodée en URL\n\n### Exemple\n\nPour télécharger une vidéo YouTube :\n\n```html\n<a href=\"vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ\">\n  Télécharger avec VidBee\n</a>\n```\n\nOu en JavaScript :\n\n```javascript\nconst videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n## Ouvrir VidBee\n\nPour ouvrir VidBee sans lancer de téléchargement :\n\n```\nvidbee://\n```\n\n## Cas d’usage\n\n### Extension de navigateur\n\nL’extension VidBee utilise ce protocole pour envoyer l’URL de l’onglet actuel à l’application :\n\n```javascript\nconst currentUrl = window.location.href\nconst deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`\nwindow.location.href = deepLink\n```\n\n### Intégration userscript\n\nLe userscript VidBee ajoute des boutons de téléchargement rapide sur les sites pris en charge :\n\n```javascript\n// Un clic déclenche le téléchargement via le protocole\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n### Pages web\n\nVous pouvez ajouter des liens de téléchargement direct à vos pages web :\n\n```html\n<!-- Lien simple -->\n<a href=\"vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo\">\n  Télécharger avec VidBee\n</a>\n\n<!-- Bouton avec JavaScript -->\n<button onclick=\"openInVidBee('https://example.com/video')\">\n  Téléchargement rapide\n</button>\n\n<script>\nfunction openInVidBee(url) {\n  const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`\n  window.location.href = vidbeeUrl\n}\n</script>\n```\n\n## Prise en charge des playlists\n\nPour télécharger une playlist entière :\n\n```\nvidbee://download?url=<url-playlist-encodee>&type=playlist\n```\n\n**Paramètres :**\n- `url` (obligatoire) : l’URL de la playlist, encodée en URL\n- `type` : définir `playlist` pour télécharger toutes les vidéos de la playlist\n\n### Exemple\n\n```javascript\nconst playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`\nwindow.location.href = vidbeeUrl\n```\n\n## Comment ça marche\n\n1. **Enregistrement du protocole** : VidBee s’enregistre comme gestionnaire du protocole `vidbee://` lors de l’installation\n2. **Analyse de l’URL** : quand un lien `vidbee://download?url=...` est cliqué, le système lance VidBee\n3. **Traitement de la file** : VidBee extrait l’URL vidéo et l’ajoute à la file de téléchargement\n4. **Démarrage automatique** : le téléchargement commence automatiquement si l’application est configurée pour cela\n\n## Compatibilité des navigateurs\n\nLe protocole `vidbee://` fonctionne sur tous les navigateurs majeurs :\n- Chrome/Edge/Brave\n- Firefox\n- Safari\n\n## Notes de sécurité\n\n- Seules les URL commençant par `vidbee://` déclenchent l’application\n- L’application valide le format d’URL avant traitement\n- Les URL mal formées sont ignorées avec un avertissement dans les logs\n\n## Dépannage\n\n### Le protocole ne fonctionne pas\n\nSi cliquer sur des liens `vidbee://` n’ouvre pas VidBee :\n\n1. **Vérifier l’installation** : assurez-vous que VidBee est bien installé\n2. **Réinstaller** : essayez de réinstaller VidBee pour ré-enregistrer le protocole\n3. **Permissions OS** : sur macOS, vérifiez Réglages Système > Confidentialité et sécurité pour d’éventuels blocages\n4. **Réglages du navigateur** : certains navigateurs demandent une autorisation au premier usage\n\n### L’application s’ouvre mais ne télécharge pas\n\nSi VidBee s’ouvre mais que le téléchargement ne démarre pas :\n\n1. **Vérifier l’encodage** : assurez-vous que l’URL est bien encodée avec `encodeURIComponent()`\n2. **Vérifier les logs** : ouvrez l’application et consultez la console développeur pour les erreurs\n3. **Sites pris en charge** : vérifiez que l’URL provient d’un [site pris en charge](https://vidbee.org/supported-sites/)\n"
  },
  {
    "path": "apps/docs/content/fr/rss.mdx",
    "content": "---\ntitle: RSS\ndescription: S’abonner aux flux RSS et ajouter automatiquement les téléchargements\n---\n\nVidBee peut surveiller des flux RSS et mettre automatiquement en file d’attente les nouveaux éléments à télécharger. C’est parfait pour les chaînes, playlists ou créateurs qui publient régulièrement.\n\n## Obtenir une URL de flux RSS\n\nSi vous avez déjà un lien RSS, utilisez-le directement. Sinon, générez-en un avec **RSSHub** :\n\n1. Ouvrez la [documentation des routes RSSHub](https://docs.rsshub.app/routes/) pour parcourir les routes de flux disponibles.\n2. Trouvez la plateforme que vous voulez suivre (YouTube, Twitter/X, etc.).\n3. Renseignez les paramètres requis et copiez l'URL du flux généré.\n\n### Instances RSSHub\n\nDe nombreux flux RSSHub utilisent le domaine `rsshub.app`, mais l'instance officielle est protégée par Cloudflare. VidBee ne peut pas accéder directement à l'instance officielle. Vous devrez utiliser l'une de ces alternatives :\n\n- **RSSHub auto-hébergé** : Déployez votre propre instance pour un accès fiable\n- **Instances publiques** : Consultez le [guide des instances RSSHub](https://docs.rsshub.app/guide/instances) pour voir la liste des instances publiques disponibles\n- **Instances communautaires** : D'autres utilisateurs peuvent avoir configuré des instances publiques accessibles\n\nChoisissez une instance fiable et accessible depuis votre réseau.\n\n## Ajouter un abonnement RSS\n\n1. Ouvrez **VidBee → RSS**.\n2. Cliquez sur **Ajouter RSS**.\n\n![Aperçu des abonnements RSS](/rss/rss-overview.png)\n\n3. Collez l’URL du flux RSS. VidBee détectera le flux automatiquement.\n4. (Optionnel) Choisissez un **Répertoire personnalisé** pour les téléchargements.\n5. (Optionnel) Activez **Télécharger uniquement la dernière vidéo** si vous voulez seulement l’élément le plus récent.\n6. (Optionnel) Ouvrez **Options avancées** pour :\n   - **Filtre par mots-clés** (séparés par des virgules)\n   - **Étiquettes automatiques** (séparées par des virgules)\n   - **Modèle de nom de fichier personnalisé** (placeholders de type yt-dlp)\n7. Cliquez sur **Ajouter** pour enregistrer.\n\n![Boîte de dialogue Ajouter RSS](/rss/rss-add-dialog.png)\n\n## Et ensuite\n\n- VidBee vérifie le flux périodiquement et met automatiquement en file d’attente les nouveaux éléments.\n- Les éléments apparaissent dans la liste RSS avec leur statut.\n- Vous pouvez modifier, actualiser, désactiver ou supprimer des abonnements à tout moment.\n\n## Dépannage\n\n- **URL du flux invalide** : vérifiez que l’URL s’ouvre dans un navigateur et renvoie du RSS/Atom XML.\n- **Flux en double** : chaque URL RSS ne peut être abonnée qu’une seule fois.\n- **Aucun nouveau téléchargement** : confirmez que l’abonnement est activé et que le flux contient de nouveaux éléments.\n"
  },
  {
    "path": "apps/docs/content/index.mdx",
    "content": "---\ntitle: Introduction\ndescription: VidBee desktop downloader documentation and FAQ\n---\n\nVidBee is a desktop downloader built with Electron and powered by yt-dlp. It provides a clean interface and queue management for downloading video and audio.\n\nThese docs focus on real-world usage and settings, especially signed-in downloads and cookie configuration.\n\n## Start here\n\n- [vidbee:// Protocol](./protocol.mdx): Quick download using URL protocol.\n- [Cookies](./cookies.mdx): Configure signed-in sessions and restricted content.\n- [RSS](./rss.mdx): Subscribe to RSS feeds and auto-queue downloads.\n- [FAQ](./faq.mdx): Common questions and troubleshooting.\n\n## Quick links\n\n- [VidBee website](https://vidbee.org/)\n- [Supported sites](https://vidbee.org/supported-sites/)\n- [Features](https://vidbee.org/features/)\n"
  },
  {
    "path": "apps/docs/content/meta.json",
    "content": "{\n  \"title\": \"VidBee Docs\",\n  \"pages\": [\n    \"index\",\n    \"---Download & Access---\",\n    \"protocol\",\n    \"cookies\",\n    \"---Automation---\",\n    \"rss\",\n    \"---Help---\",\n    \"faq\"\n  ]\n}\n"
  },
  {
    "path": "apps/docs/content/protocol.mdx",
    "content": "---\ntitle: vidbee:// Protocol\ndescription: Quick download using vidbee:// URL protocol\n---\n\nVidBee registers a custom URL protocol (`vidbee://`) that allows you to trigger downloads directly from web browsers, browser extensions, or userscripts.\n\n## Basic Usage\n\nThe `vidbee://` protocol can be used to open VidBee and automatically start downloading videos.\n\n### Protocol Format\n\n```\nvidbee://download?url=<encoded-video-url>\n```\n\n**Parameters:**\n- `url` (required): The video URL to download, must be URL-encoded\n\n### Example\n\nTo download a YouTube video:\n\n```html\n<a href=\"vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ\">\n  Download with VidBee\n</a>\n```\n\nOr in JavaScript:\n\n```javascript\nconst videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n## Opening VidBee\n\nTo simply open the VidBee app without starting a download:\n\n```\nvidbee://\n```\n\n## Use Cases\n\n### Browser Extension\n\nThe VidBee browser extension uses this protocol to send the current tab's URL to the desktop app:\n\n```javascript\nconst currentUrl = window.location.href\nconst deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`\nwindow.location.href = deepLink\n```\n\n### Userscript Integration\n\nThe VidBee userscript adds quick download buttons to supported video sites:\n\n```javascript\n// Single click triggers download via protocol\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n### Web Pages\n\nYou can add direct download links to your web pages:\n\n```html\n<!-- Simple link -->\n<a href=\"vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo\">\n  Download with VidBee\n</a>\n\n<!-- Button with JavaScript -->\n<button onclick=\"openInVidBee('https://example.com/video')\">\n  Quick Download\n</button>\n\n<script>\nfunction openInVidBee(url) {\n  const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`\n  window.location.href = vidbeeUrl\n}\n</script>\n```\n\n## Playlist Support\n\nTo download an entire playlist:\n\n```\nvidbee://download?url=<encoded-playlist-url>&type=playlist\n```\n\n**Parameters:**\n- `url` (required): The playlist URL, must be URL-encoded\n- `type`: Set to `playlist` to download all videos in the playlist\n\n### Example\n\n```javascript\nconst playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`\nwindow.location.href = vidbeeUrl\n```\n\n## How It Works\n\n1. **Protocol Registration**: VidBee registers as the handler for the `vidbee://` protocol during installation\n2. **URL Parsing**: When a `vidbee://download?url=...` link is clicked, the OS launches VidBee\n3. **Queue Processing**: VidBee extracts the video URL and adds it to the download queue\n4. **Auto-start**: The download begins automatically if the app is configured for auto-download\n\n## Browser Compatibility\n\nThe `vidbee://` protocol works across all major browsers:\n- Chrome/Edge/Brave\n- Firefox\n- Safari\n\n## Security Notes\n\n- Only URLs starting with `vidbee://` will trigger the app\n- The app validates the URL format before processing\n- Malformed URLs are ignored with a warning in the logs\n\n## Troubleshooting\n\n### Protocol Not Working\n\nIf clicking `vidbee://` links doesn't open VidBee:\n\n1. **Check installation**: Ensure VidBee is properly installed\n2. **Reinstall**: Try reinstalling VidBee to re-register the protocol\n3. **OS permissions**: On macOS, check System Settings > Privacy & Security for any blocks\n4. **Browser settings**: Some browsers may require you to allow the protocol on first use\n\n### App Opens But Doesn't Download\n\nIf VidBee opens but the download doesn't start:\n\n1. **Check URL encoding**: Ensure the video URL is properly encoded with `encodeURIComponent()`\n2. **Check logs**: Open the app and check the developer console for errors\n3. **Supported sites**: Verify the URL is from a [supported site](https://vidbee.org/supported-sites/)\n"
  },
  {
    "path": "apps/docs/content/rss.mdx",
    "content": "---\ntitle: RSS\ndescription: Subscribe to RSS feeds and auto-queue downloads\n---\n\nVidBee can watch RSS feeds and automatically queue new items for download. This is perfect for channels, playlists, or creators that publish regularly.\n\n## Get an RSS feed URL\n\nIf you already have an RSS link, use it directly. If you do not have one, generate it with **RSSHub**:\n\n1. Open the [RSSHub routes documentation](https://docs.rsshub.app/routes/) to browse available feed routes.\n2. Find the platform you want to follow (YouTube, Twitter/X, etc.).\n3. Fill in the required parameters and copy the generated feed URL.\n\n### RSSHub Instances\n\nMany RSSHub feeds use the `rsshub.app` domain, but the official instance is protected by Cloudflare. VidBee cannot directly access the official instance, so you'll need to use one of these alternatives:\n\n- **Self-hosted RSSHub**: Deploy your own instance for reliable access\n- **Public instances**: Check the [RSSHub instances guide](https://docs.rsshub.app/guide/instances) for a list of available public instances\n- **Community instances**: Other users may have set up public instances you can access\n\nChoose an instance that is reliable and accessible from your network.\n\n## Add an RSS subscription\n\n1. Open **VidBee → RSS**.\n2. Click **Add RSS**.\n\n![RSS subscriptions overview](/rss/rss-overview.png)\n\n3. Paste the RSS feed URL. VidBee will detect the feed automatically.\n4. (Optional) Choose a **Custom directory** for downloads.\n5. (Optional) Enable **Download only the latest video** if you only want the newest item.\n6. (Optional) Open **Advanced Options** for:\n   - **Keyword filter** (comma separated)\n   - **Auto tags** (comma separated)\n   - **Custom filename template** (yt-dlp style placeholders)\n7. Click **Add** to save.\n\n![Add RSS dialog](/rss/rss-add-dialog.png)\n\n## What happens next\n\n- VidBee checks the feed periodically and queues new items automatically.\n- Items appear in the RSS list with their queue status.\n- You can edit, refresh, disable, or remove subscriptions at any time.\n\n## Troubleshooting\n\n- **Feed URL invalid**: Verify the URL opens in a browser and returns RSS/Atom XML.\n- **Duplicate feed**: Each RSS URL can only be subscribed once.\n- **No new downloads**: Confirm the subscription is enabled and the feed has new items.\n"
  },
  {
    "path": "apps/docs/content/ru/cookies.mdx",
    "content": "---\ntitle: Cookies\ndescription: Загрузки с авторизацией, ограниченный контент и настройка cookies\n---\n\nCookies используют вашу авторизованную сессию браузера, чтобы VidBee мог скачивать контент, который требует входа или проверки, например материалы для подписчиков, страницы с возрастными ограничениями или приватные ссылки.\n\nЕсли вы видите сообщение **\"Войдите, чтобы подтвердить, что вы не бот\"** или \"требуется вход\", вам нужны cookies.\n\n![Сообщение об ошибке с запросом входа](/cookies/cookies-error-sign-in.png)\n\n## Когда нужны cookies\n\n- Контент, который требует аккаунт для просмотра или загрузки.\n- Страницы с возрастными или региональными ограничениями.\n- Приватные ссылки или списки, видимые только авторизованным пользователям.\n- “Войдите, чтобы подтвердить, что вы не бот” или похожие сообщения.\n\n## Два способа использовать cookies в VidBee\n\n### Вариант 1: Читать cookies из браузера\n\nВ **Настройках** выберите браузер. VidBee попробует автоматически определить путь к профилю. Вы также можете ввести путь вручную.\n\n![Выбор браузера для cookies](/cookies/cookies-settings-select-browser.png)\n\n**Поддерживаемые браузеры (зависит от платформы):**\n\n- **Windows: только Firefox. Другие браузеры не поддерживаются для чтения cookies.**\n- macOS: поддерживаются все браузеры.\n- Linux: поддерживаются все браузеры.\n\n**Шаги:**\n\n1. Сначала войдите на сайт в браузере.\n2. Откройте VidBee → Настройки → Cookies.\n3. Выберите браузер и подтвердите путь к профилю.\n4. Вернитесь назад и снова запустите загрузку.\n\nЕсли автоопределение не сработало или путь неверный, выберите реальную папку профиля вручную.\nНа Windows, если вы не используете Firefox, переключитесь на метод с файлом cookies.\n\n### Вариант 2: Импорт файла cookies\n\nМожно импортировать файл cookies. Это полезно, когда чтение профиля браузера невозможно, например при использовании браузеров, отличных от Firefox в Windows.\n\n![Настройки файла cookies](/cookies-file.png)\n\n**Шаги:**\n\n1. **Экспортируйте cookies с помощью расширения браузера**\n\n   Выберите одно из рекомендуемых расширений:\n   - **Chrome/Edge:** [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)\n   - **Firefox:** [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)\n\n   > **Предупреждение о безопасности:** устанавливайте только рекомендуемые выше расширения. Если вы ранее установили \"Get cookies.txt\" (без \"LOCALLY\"), немедленно удалите его. Эта версия была признана вредоносным ПО.\n\n2. **Экспортируйте ваши cookies**\n\n   Нажмите кнопку экспорта в расширении и выберите место сохранения файла. Расширение автоматически экспортирует cookies в формате **Netscape**.\n\n3. **Импортируйте в VidBee**\n\n   - Откройте VidBee → Настройки → Файл cookies\n   - Нажмите Выбрать файл и выберите экспортированный файл cookies\n   - Обновите страницу загрузки по завершении\n\n4. **Удалите файл cookies**\n\n   Нажмите «Очистить» для отключения.\n\n## Рекомендации\n\n- По возможности используйте cookies из браузера, это проще в обслуживании.\n- Если чтение браузера не работает, используйте файл cookies.\n- Файлы cookies истекают. Переэкспортируйте их, когда состояние аккаунта меняется.\n\n## Устранение проблем\n\n**Метод на основе браузера:**\n\n- **Путь профиля неверный**: убедитесь, что папка существует и указывает на реальный профиль браузера.\n- **Чтение браузера не удалось**: закройте браузер и повторите, или используйте метод с файлом cookies.\n\n**Метод с файлом cookies:**\n\n- **Все еще просит вход**: убедитесь, что вы вошли в браузер с нужным аккаунтом. Если вы вошли, проверьте, что экспортированный файл cookies полный и имеет правильный формат.\n- **Расширение экспорта не работает**: убедитесь, что установлена рекомендуемая версия расширения. Некоторые устаревшие или неавторизованные версии могут работать неправильно.\n- **Файл cookies истек**: если долго не использовать, ваша сессия может истечь. Войдите снова в браузер и экспортируйте новый файл cookies.\n- **Ошибка HTTP 400**: обычно указывает на проблему с форматом файла. Убедитесь, что файл в стандартном формате Netscape с правильной кодировкой.\n\n## Узнать больше\n\n- [FAQ по cookies для yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp)\n\n## Конфиденциальность и безопасность\n\nCookies эквивалентны вашей сессии входа. Держите их в секрете и никогда не делитесь ими. Если вы подозреваете утечку, выйдите и смените пароль.\n"
  },
  {
    "path": "apps/docs/content/ru/faq.mdx",
    "content": "---\ntitle: FAQ\ndescription: Частые вопросы и устранение проблем в VidBee\n---\n\n## Что делать, если загрузка не удалась или появилась ошибка?\n\n- Убедитесь, что ссылка корректная и открывается в браузере.\n- Обновите VidBee до последней версии и попробуйте снова.\n- Если контент требует входа или проверки возраста, [настройте cookies](/cookies).\n\n![Сообщение об ошибке с запросом входа](/cookies/cookies-error-sign-in.png)\n\n## Почему одна и та же ссылка иногда работает, а иногда нет?\n\nНекоторые сайты часто меняют структуру страниц или ограничивают частоту запросов. Рекомендуемые шаги:\n\n- Убедитесь, что VidBee обновлен.\n- Уменьшите число одновременных загрузок.\n- [Используйте cookies, чтобы при необходимости повторно использовать авторизованную сессию.](/cookies)\n\n## Какие сайты поддерживаются?\n\nVidBee использует систему экстракторов yt-dlp и поддерживает множество сайтов. Попробуйте скачать; если не получается, отправьте отзыв со ссылкой и деталями ошибки.\n\n## Почему скорость загрузки низкая?\n\n- Проверьте сеть и настройки прокси.\n- Не запускайте слишком много задач одновременно.\n- Некоторые сайты ограничивают скорость на своей стороне.\n\n## macOS сообщает «файл поврежден»\n\nУдалите атрибут карантина и попробуйте снова:\n\n```bash\nxattr -rd com.apple.quarantine /Applications/VidBee.app/\n```\n"
  },
  {
    "path": "apps/docs/content/ru/index.mdx",
    "content": "---\ntitle: Введение\ndescription: Документация и FAQ для загрузчика VidBee\n---\n\nVidBee — это настольный загрузчик на Electron с движком yt-dlp. Он предлагает аккуратный интерфейс и управление очередью для загрузки видео и аудио.\n\nЭта документация сосредоточена на реальном использовании и настройках, особенно на загрузках с авторизацией и конфигурации cookies.\n\n## Начните здесь\n\n- [Протокол vidbee://](./protocol.mdx): Быстрая загрузка через URL-протокол.\n- [Cookies](./cookies.mdx): Настройка авторизованных сессий и ограниченного контента.\n- [RSS](./rss.mdx): Подписка на RSS-ленты и авто-очередь загрузок.\n- [FAQ](./faq.mdx): Частые вопросы и устранение проблем.\n\n## Быстрые ссылки\n\n- [Сайт VidBee](https://vidbee.org/)\n- [Поддерживаемые сайты](https://vidbee.org/supported-sites/)\n- [Функции](https://vidbee.org/features/)\n"
  },
  {
    "path": "apps/docs/content/ru/meta.json",
    "content": "{\n  \"title\": \"Документация VidBee\",\n  \"pages\": [\n    \"index\",\n    \"---Загрузка и доступ---\",\n    \"protocol\",\n    \"cookies\",\n    \"---Автоматизация---\",\n    \"rss\",\n    \"---Помощь---\",\n    \"faq\"\n  ]\n}\n"
  },
  {
    "path": "apps/docs/content/ru/protocol.mdx",
    "content": "---\ntitle: Протокол vidbee://\ndescription: Быстрая загрузка через URL-протокол vidbee://\n---\n\nVidBee регистрирует собственный URL-протокол (`vidbee://`), который позволяет запускать загрузки прямо из браузеров, расширений или userscript-ов.\n\n## Базовое использование\n\nПротокол `vidbee://` может открыть VidBee и автоматически начать загрузку видео.\n\n### Формат протокола\n\n```\nvidbee://download?url=<zakodirovannyi-url-video>\n```\n\n**Параметры:**\n- `url` (обязательный): URL видео для загрузки, должен быть URL-кодирован\n\n### Пример\n\nЧтобы скачать видео с YouTube:\n\n```html\n<a href=\"vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ\">\n  Скачать через VidBee\n</a>\n```\n\nИли в JavaScript:\n\n```javascript\nconst videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n## Открыть VidBee\n\nЧтобы просто открыть приложение без начала загрузки:\n\n```\nvidbee://\n```\n\n## Сценарии использования\n\n### Расширение браузера\n\nРасширение VidBee использует этот протокол, чтобы отправить URL текущей вкладки в настольное приложение:\n\n```javascript\nconst currentUrl = window.location.href\nconst deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`\nwindow.location.href = deepLink\n```\n\n### Интеграция userscript\n\nUserscript VidBee добавляет кнопки быстрой загрузки на поддерживаемые сайты:\n\n```javascript\n// Один клик запускает загрузку через протокол\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n### Веб-страницы\n\nМожно добавить прямые ссылки для загрузки на свои страницы:\n\n```html\n<!-- Простая ссылка -->\n<a href=\"vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo\">\n  Скачать через VidBee\n</a>\n\n<!-- Кнопка с JavaScript -->\n<button onclick=\"openInVidBee('https://example.com/video')\">\n  Быстрая загрузка\n</button>\n\n<script>\nfunction openInVidBee(url) {\n  const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`\n  window.location.href = vidbeeUrl\n}\n</script>\n```\n\n## Поддержка плейлистов\n\nЧтобы скачать весь плейлист:\n\n```\nvidbee://download?url=<zakodirovannyi-url-playlista>&type=playlist\n```\n\n**Параметры:**\n- `url` (обязательный): URL плейлиста, должен быть URL-кодирован\n- `type`: установите `playlist`, чтобы скачать все видео в плейлисте\n\n### Пример\n\n```javascript\nconst playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`\nwindow.location.href = vidbeeUrl\n```\n\n## Как это работает\n\n1. **Регистрация протокола**: VidBee регистрируется как обработчик `vidbee://` при установке\n2. **Разбор URL**: когда нажимают ссылку `vidbee://download?url=...`, ОС запускает VidBee\n3. **Очередь**: VidBee извлекает URL видео и добавляет его в очередь загрузок\n4. **Авто-старт**: загрузка начинается автоматически, если это настроено в приложении\n\n## Совместимость с браузерами\n\nПротокол `vidbee://` работает во всех основных браузерах:\n- Chrome/Edge/Brave\n- Firefox\n- Safari\n\n## Заметки по безопасности\n\n- Только URL, начинающиеся с `vidbee://`, запускают приложение\n- Приложение проверяет формат URL перед обработкой\n- Неверные URL игнорируются с предупреждением в логах\n\n## Устранение проблем\n\n### Протокол не работает\n\nЕсли ссылки `vidbee://` не открывают VidBee:\n\n1. **Проверьте установку**: убедитесь, что VidBee установлен\n2. **Переустановите**: попробуйте переустановить VidBee, чтобы заново зарегистрировать протокол\n3. **Разрешения ОС**: на macOS проверьте Системные настройки > Конфиденциальность и безопасность\n4. **Настройки браузера**: некоторые браузеры требуют подтверждение при первом использовании\n\n### Приложение открывается, но загрузка не начинается\n\nЕсли VidBee открывается, но загрузка не стартует:\n\n1. **Проверьте кодирование**: убедитесь, что URL корректно закодирован через `encodeURIComponent()`\n2. **Проверьте логи**: откройте приложение и посмотрите консоль разработчика\n3. **Поддерживаемые сайты**: убедитесь, что URL ведет на [поддерживаемый сайт](https://vidbee.org/supported-sites/)\n"
  },
  {
    "path": "apps/docs/content/ru/rss.mdx",
    "content": "---\ntitle: RSS\ndescription: Подписка на RSS-ленты и авто-очередь загрузок\n---\n\nVidBee может отслеживать RSS-ленты и автоматически ставить новые элементы в очередь на загрузку. Это отлично подходит для каналов, плейлистов или авторов, которые публикуют регулярно.\n\n## Получить URL RSS-ленты\n\nЕсли у вас уже есть RSS-ссылка, используйте ее напрямую. Если нет, сгенерируйте ее через **RSSHub**:\n\n1. Откройте [документацию маршрутов RSSHub](https://docs.rsshub.app/routes/) для просмотра доступных маршрутов ленты.\n2. Найдите платформу, которую хотите отслеживать (YouTube, Twitter/X и т.д.).\n3. Заполните обязательные параметры и скопируйте сгенерированный URL ленты.\n\n### Инстансы RSSHub\n\nМногие RSSHub-ленты используют домен `rsshub.app`, но официальный инстанс защищен Cloudflare. VidBee не может напрямую обратиться к официальному инстансу. Вам нужно использовать одну из этих альтернатив:\n\n- **Самохостимый RSSHub**: разверните свой собственный инстанс для надежного доступа\n- **Публичные инстансы**: обратитесь к [руководству по инстансам RSSHub](https://docs.rsshub.app/guide/instances) для просмотра списка доступных публичных инстансов\n- **Инстансы сообщества**: другие пользователи могли настроить доступные публичные инстансы\n\nВыберите надежный инстанс, доступный из вашей сети.\n\n## Добавить RSS-подписку\n\n1. Откройте **VidBee → RSS**.\n2. Нажмите **Добавить RSS**.\n\n![Обзор RSS-подписок](/rss/rss-overview.png)\n\n3. Вставьте URL RSS-ленты. VidBee определит ленту автоматически.\n4. (Опционально) Выберите **Пользовательскую папку** для загрузок.\n5. (Опционально) Включите **Скачивать только последнее видео**, если нужен только самый новый элемент.\n6. (Опционально) Откройте **Дополнительные параметры** для:\n   - **Фильтра по ключевым словам** (через запятую)\n   - **Автотегов** (через запятую)\n   - **Пользовательского шаблона имени файла** (плейсхолдеры yt-dlp)\n7. Нажмите **Добавить**, чтобы сохранить.\n\n![Диалог добавления RSS](/rss/rss-add-dialog.png)\n\n## Что дальше\n\n- VidBee периодически проверяет ленту и автоматически ставит новые элементы в очередь.\n- Элементы появляются в списке RSS со своим статусом.\n- Вы можете редактировать, обновлять, отключать или удалять подписки в любое время.\n\n## Устранение проблем\n\n- **URL ленты неверный**: убедитесь, что URL открывается в браузере и возвращает RSS/Atom XML.\n- **Дублирующаяся лента**: на один URL можно подписаться только один раз.\n- **Нет новых загрузок**: проверьте, что подписка активна и в ленте есть новые элементы.\n"
  },
  {
    "path": "apps/docs/content/zh/cookies.mdx",
    "content": "---\ntitle: Cookie 使用说明\ndescription: 登录下载、受限内容与 Cookie 配置\n---\n\nCookie 用于复用浏览器的登录态，帮助 VidBee 下载需要登录或验证的内容，例如订阅内容、年龄限制或私密链接。\n\n如果出现 **\"Sign in to confirm you're not a bot\"** 或\"需要登录/验证\"的提示，说明需要配置 Cookie。\n\n![错误提示需要登录](/cookies/cookies-error-sign-in.png)\n\n## 适用场景\n\n- 需要账号登录才能观看或下载的内容。\n- 年龄限制或地区限制页面。\n- 仅对登录用户可见的私密链接或列表。\n- 出现“Sign in to confirm you’re not a bot”等验证提示。\n\n## VidBee 支持的两种方式\n\n### 方式一：读取浏览器 Cookie\n\n在 **设置** 中选择你的浏览器，VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。\n\n![选择浏览器读取 Cookie](/cookies/cookies-settings-select-browser.png)\n\n**支持的浏览器（按平台差异显示）：**\n\n- **Windows：仅支持 Firefox。其他浏览器无法读取 Cookie。**\n- macOS：全部支持。\n- Linux：全部支持。\n\n**使用步骤：**\n\n1. 先在浏览器中登录对应网站。\n2. 打开 VidBee → 设置 → Cookie。\n3. 选择浏览器并确认配置文件路径。\n4. 返回下载页面，重新开始下载任务。\n\n如果识别失败或路径无效，请手动选择实际的浏览器配置文件目录。\n在 Windows 上，如果不是 Firefox，请改用 cookies 文件方式。\n\n### 方式二：导入 Cookies 文件\n\n你也可以导入 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用，比如在 Windows 上使用非 Firefox 浏览器。\n\n![Cookies 文件设置](/cookies-file.png)\n\n**使用步骤：**\n\n1. **使用浏览器扩展导出 Cookies 文件**\n\n   选择以下任一扩展导出 cookies：\n   - **Chrome/Edge：** [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)\n   - **Firefox：** [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)\n\n   > **安全提示：** 只安装上面推荐的扩展。如果你之前安装过\"Get cookies.txt\"（注意不是\"LOCALLY\"），请立即卸载。那个版本已被报告为恶意软件。\n\n2. **导出你的 Cookies**\n\n   在浏览器扩展页面点击导出按钮，选择保存位置。扩展会自动导出 **Netscape 格式** 的 cookies 文件。\n\n3. **导入到 VidBee**\n\n   - 打开 VidBee → 设置 → Cookies 文件\n   - 点击选择文件，找到刚导出的 cookies 文件\n   - 完成后刷新下载页面\n\n4. **停用 Cookies 文件**\n\n   如需移除，点击\"清除\"按钮即可。\n\n## 使用建议\n\n- 优先使用浏览器读取方式，维护成本更低。\n- 如果浏览器读取失败，再切换到 cookies 文件方式。\n- cookies 文件会过期，账号状态变化时需要重新导出。\n\n## 常见问题\n\n**浏览器读取方式：**\n\n- **提示配置文件路径无效**：请确认路径存在，并指向实际的浏览器配置文件目录。\n- **浏览器读取失败**：关闭浏览器后重试，或改用 cookies 文件方式。\n\n**Cookies 文件方式：**\n\n- **下载仍提示需要登录**：检查你的浏览器是否已登录账号。如果浏览器中已登录，请确认导出的 cookies 文件完整。\n- **导出扩展无法使用**：确保安装的是推荐的扩展版本。某些旧版或未授权的扩展可能无法正常工作。\n- **Cookies 文件过期**：如果长时间未使用，账号登录状态可能已过期。需要重新在浏览器登录并导出新的 cookies 文件。\n- **提示 HTTP Error 400**：这通常是文件格式问题。确保文件是标准的 Netscape 格式，且文件编码正确。\n\n## 了解更多\n\n- [yt-dlp cookies 常见问题解答](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp)\n\n## 隐私与安全\n\nCookie 等同于登录态，请妥善保管，不要共享或上传。若怀疑泄露，请及时在网站上退出登录并更新密码。\n"
  },
  {
    "path": "apps/docs/content/zh/faq.mdx",
    "content": "---\ntitle: 常见问题 (FAQ)\ndescription: VidBee 使用过程中的常见问题与建议\n---\n\n## 下载失败或报错怎么办？\n\n- 先确认链接是否有效，并尝试在浏览器中打开。\n- 升级 VidBee 到最新版本后重试。\n- 如果是登录或年龄限制内容，请[配置 Cookie](/zh/cookies)。\n\n![错误提示需要登录](/cookies/cookies-error-sign-in.png)\n\n## 为什么同一个链接有时可以、有时不行？\n\n部分站点会频繁更新页面结构或限制请求频率。建议：\n\n- 确保 VidBee 为最新版本。\n- 适当降低同时下载任务数量。\n- 必要时[使用 Cookie 复用登录态](/zh/cookies)。\n\n## 支持哪些网站？\n\nVidBee 基于 yt-dlp 解析器体系，覆盖大量站点。可以先尝试下载；若失败，请提交反馈并附上链接与错误提示。\n\n## 下载速度慢怎么办？\n\n- 检查本地网络与代理设置。\n- 避免同时发起过多任务。\n- 某些站点本身带宽较低，速度受限。\n\n## macOS 提示“文件已损坏”\n\n请执行以下命令移除隔离标记后重试：\n\n```bash\nxattr -rd com.apple.quarantine /Applications/VidBee.app/\n```\n"
  },
  {
    "path": "apps/docs/content/zh/index.mdx",
    "content": "---\ntitle: 简介\ndescription: VidBee 桌面下载器使用说明与常见问题\n---\n\nVidBee 是一款基于 Electron 的桌面下载器，内置 yt-dlp 引擎，提供清爽的界面与队列管理能力，用于下载视频与音频内容。\n\n这份文档聚焦 VidBee 的实际使用场景与设置说明，尤其是登录下载与 Cookie 相关配置。\n\n## 从这里开始\n\n- [vidbee:// 协议](./protocol.mdx)：使用 URL 协议快速下载。\n- [Cookie 使用](./cookies.mdx)：登录态与限制内容的下载配置。\n- [RSS 订阅](./rss.mdx)：订阅 RSS 源并自动下载。\n- [常见问题](./faq.mdx)：常见问题与排查思路。\n\n## 快捷链接\n\n- [VidBee 官网](https://vidbee.org/)\n- [支持站点](https://vidbee.org/supported-sites/)\n- [功能介绍](https://vidbee.org/features/)\n"
  },
  {
    "path": "apps/docs/content/zh/meta.json",
    "content": "{\n  \"title\": \"VidBee 文档\",\n  \"pages\": [\n    \"index\",\n    \"---下载与访问---\",\n    \"protocol\",\n    \"cookies\",\n    \"---自动化---\",\n    \"rss\",\n    \"---帮助---\",\n    \"faq\"\n  ]\n}\n"
  },
  {
    "path": "apps/docs/content/zh/protocol.mdx",
    "content": "---\ntitle: vidbee:// 协议\ndescription: 使用 vidbee:// URL 协议快速下载\n---\n\nVidBee 注册了自定义 URL 协议（`vidbee://`），允许您直接从网页浏览器、浏览器扩展或用户脚本触发下载。\n\n## 基本用法\n\n`vidbee://` 协议可用于打开 VidBee 并自动开始下载视频。\n\n### 协议格式\n\n```\nvidbee://download?url=<编码后的视频URL>\n```\n\n**参数：**\n- `url`（必需）：要下载的视频 URL，必须经过 URL 编码\n\n### 示例\n\n下载 YouTube 视频：\n\n```html\n<a href=\"vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ\">\n  使用 VidBee 下载\n</a>\n```\n\n或使用 JavaScript：\n\n```javascript\nconst videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n## 打开 VidBee\n\n仅打开 VidBee 应用而不开始下载：\n\n```\nvidbee://\n```\n\n## 使用场景\n\n### 浏览器扩展\n\nVidBee 浏览器扩展使用此协议将当前标签页的 URL 发送到桌面应用：\n\n```javascript\nconst currentUrl = window.location.href\nconst deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`\nwindow.location.href = deepLink\n```\n\n### 用户脚本集成\n\nVidBee 用户脚本在支持的视频网站上添加快速下载按钮：\n\n```javascript\n// 单击通过协议触发下载\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`\nwindow.location.href = vidbeeUrl\n```\n\n### 网页集成\n\n您可以在网页中添加直接下载链接：\n\n```html\n<!-- 简单链接 -->\n<a href=\"vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo\">\n  使用 VidBee 下载\n</a>\n\n<!-- 带 JavaScript 的按钮 -->\n<button onclick=\"openInVidBee('https://example.com/video')\">\n  快速下载\n</button>\n\n<script>\nfunction openInVidBee(url) {\n  const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`\n  window.location.href = vidbeeUrl\n}\n</script>\n```\n\n## 播放列表支持\n\n下载整个播放列表：\n\n```\nvidbee://download?url=<编码后的播放列表URL>&type=playlist\n```\n\n**参数：**\n- `url`（必需）：播放列表 URL，必须经过 URL 编码\n- `type`：设置为 `playlist` 以下载播放列表中的所有视频\n\n### 示例\n\n```javascript\nconst playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'\nconst vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`\nwindow.location.href = vidbeeUrl\n```\n\n## 工作原理\n\n1. **协议注册**：VidBee 在安装过程中注册为 `vidbee://` 协议的处理程序\n2. **URL 解析**：当点击 `vidbee://download?url=...` 链接时，操作系统会启动 VidBee\n3. **队列处理**：VidBee 提取视频 URL 并将其添加到下载队列\n4. **自动开始**：如果应用配置为自动下载，下载会自动开始\n\n## 浏览器兼容性\n\n`vidbee://` 协议适用于所有主流浏览器：\n- Chrome/Edge/Brave\n- Firefox\n- Safari\n\n## 安全说明\n\n- 仅以 `vidbee://` 开头的 URL 会触发应用\n- 应用在处理前会验证 URL 格式\n- 格式错误的 URL 将被忽略，并在日志中显示警告\n\n## 故障排除\n\n### 协议不工作\n\n如果点击 `vidbee://` 链接无法打开 VidBee：\n\n1. **检查安装**：确保 VidBee 已正确安装\n2. **重新安装**：尝试重新安装 VidBee 以重新注册协议\n3. **操作系统权限**：在 macOS 上，检查系统设置 > 隐私与安全性 是否有任何阻止\n4. **浏览器设置**：某些浏览器可能需要您在首次使用时允许该协议\n\n### 应用打开但未下载\n\n如果 VidBee 打开但下载未开始：\n\n1. **检查 URL 编码**：确保视频 URL 使用 `encodeURIComponent()` 正确编码\n2. **检查日志**：打开应用并检查开发者控制台是否有错误\n3. **支持的网站**：验证 URL 是否来自[支持的网站](https://vidbee.org/supported-sites/)\n"
  },
  {
    "path": "apps/docs/content/zh/rss.mdx",
    "content": "---\ntitle: RSS 订阅\ndescription: 通过 RSS 自动订阅并下载最新内容\n---\n\nVidBee 支持订阅 RSS 源并自动把最新内容加入下载队列，适合持续更新的频道、列表或作者。\n\n## 获取 RSS 源链接\n\n如果你已经有 RSS 链接，可以直接使用。没有的话，可以通过 **RSSHub** 生成：\n\n1. 打开 [RSSHub 路由文档](https://docs.rsshub.app/routes/) 浏览可用的源路由。\n2. 找到对应平台（YouTube、Twitter/X 等）。\n3. 按说明填写参数并复制生成的 RSS 链接。\n\n### RSSHub 实例\n\n很多 RSSHub 链接会使用 `rsshub.app` 域名，但官方实例启用了 Cloudflare 安全防护，VidBee 无法直接访问。你需要使用以下替代方案：\n\n- **自建 RSSHub**：部署自己的实例以确保可靠访问\n- **公共实例**：查看 [RSSHub 实例指南](https://docs.rsshub.app/guide/instances) 获取可用公共实例列表\n- **社区实例**：其他用户可能已设置可访问的公共实例\n\n选择一个可靠且从你的网络中可以访问的实例。\n\n## 添加 RSS 订阅\n\n1. 打开 **VidBee → RSS**。\n2. 点击 **Add RSS**。\n\n![RSS 订阅概览](/rss/rss-overview.png)\n\n3. 粘贴 RSS 源链接，VidBee 会自动识别。\n4. （可选）设置 **Custom directory** 作为下载目录。\n5. （可选）打开 **Download only the latest video** 只下载最新一条。\n6. （可选）打开 **Advanced Options** 进行高级设置：\n   - **Keyword filter**（逗号分隔）\n   - **Auto tags**（逗号分隔）\n   - **Custom filename template**（yt-dlp 风格占位符）\n7. 点击 **Add** 保存。\n\n![添加 RSS 弹窗](/rss/rss-add-dialog.png)\n\n## 后续流程\n\n- VidBee 会定期检查 RSS 源并自动排队新内容。\n- 条目会出现在 RSS 列表中，并显示队列状态。\n- 随时可编辑、刷新、禁用或删除订阅。\n\n## 故障排查\n\n- **链接无效**：确认该 URL 在浏览器中能返回 RSS/Atom XML。\n- **重复订阅**：同一个 RSS 链接只能订阅一次。\n- **没有新下载**：确认订阅已启用且源里有新内容。\n"
  },
  {
    "path": "apps/docs/next.config.mjs",
    "content": "import { createMDX } from 'fumadocs-mdx/next';\n\nconst withMDX = createMDX();\n\n/** @type {import('next').NextConfig} */\nconst config = {\n  // Only use static export for production builds, not dev mode\n  output: process.env.NODE_ENV === 'production' ? 'export' : undefined,\n  reactStrictMode: true,\n  // Use trailing slashes to avoid conflicts with route handlers that have file extensions\n  trailingSlash: true,\n  images: {\n    // Required for Next.js static export to avoid broken images.\n    unoptimized: true,\n  },\n  // Note: rewrites are not supported with static export\n  // The /llms.mdx route will be pre-rendered as static files\n};\n\nexport default withMDX(config);\n"
  },
  {
    "path": "apps/docs/package.json",
    "content": "{\n  \"name\": \"docs\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"next build && node scripts/post-export.js\",\n    \"dev\": \"next dev\",\n    \"start\": \"next start\",\n    \"types:check\": \"fumadocs-mdx && next typegen && tsc --noEmit\",\n    \"postinstall\": \"fumadocs-mdx\",\n    \"lint\": \"biome check --config-path biome.config.json\",\n    \"format\": \"biome format --write --config-path biome.config.json\"\n  },\n  \"dependencies\": {\n    \"@next/third-parties\": \"^16.1.4\",\n    \"fumadocs-core\": \"16.4.7\",\n    \"fumadocs-mdx\": \"14.2.5\",\n    \"fumadocs-ui\": \"16.4.7\",\n    \"lucide-react\": \"^0.562.0\",\n    \"next\": \"16.1.1\",\n    \"react\": \"^19.2.3\",\n    \"react-dom\": \"^19.2.3\",\n    \"tailwind-merge\": \"^3.4.0\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^2.3.11\",\n    \"@tailwindcss/postcss\": \"^4.1.18\",\n    \"@types/mdx\": \"^2.0.13\",\n    \"@types/node\": \"^25.0.5\",\n    \"@types/react\": \"^19.2.8\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "apps/docs/postcss.config.mjs",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n};\n"
  },
  {
    "path": "apps/docs/public/ICONS.md",
    "content": "# VidBee Documentation Icons\n\nThis directory contains the VidBee logo in various sizes for use in the documentation site.\n\n## Source\n\nAll icons are generated from the original VidBee application icon located at:\n`apps/desktop/build/icon.png`\n\n## Available Sizes\n\n| Filename | Size | Use Case |\n|----------|------|----------|\n| `icon.png` | 512×512 | Default/Original icon |\n| `icon-16.png` | 16×16 | Browser favicon (small) |\n| `icon-32.png` | 32×32 | Browser favicon (standard) |\n| `icon-48.png` | 48×48 | Browser favicon (large) |\n| `icon-64.png` | 64×64 | Small UI elements |\n| `icon-128.png` | 128×128 | Medium UI elements |\n| `icon-192.png` | 192×192 | PWA icon (Android) |\n| `icon-256.png` | 256×256 | Large UI elements |\n| `icon-512.png` | 512×512 | PWA splash screen, high-res displays |\n| `apple-touch-icon.png` | 180×180 | iOS/macOS home screen icon |\n| `favicon.png` | 32×32 | Standard favicon |\n\n## Usage in Next.js\n\n### In `app/layout.tsx` or `app/favicon.ico`:\n\n```tsx\nimport type { Metadata } from 'next'\n\nexport const metadata: Metadata = {\n  title: 'VidBee Documentation',\n  description: 'Official VidBee documentation',\n  icons: {\n    icon: [\n      { url: '/icon-16.png', sizes: '16x16', type: 'image/png' },\n      { url: '/icon-32.png', sizes: '32x32', type: 'image/png' },\n      { url: '/icon-48.png', sizes: '48x48', type: 'image/png' },\n    ],\n    apple: [\n      { url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },\n    ],\n  },\n}\n```\n\n### For PWA (Progressive Web App):\n\nAdd to your `manifest.json` or `site.webmanifest`:\n\n```json\n{\n  \"icons\": [\n    {\n      \"src\": \"/icon-192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/icon-512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n```\n\n## Regeneration\n\nIf you need to regenerate these icons from the source:\n\n```bash\ncd apps/docs/public\nSOURCE=\"../../desktop/build/icon.png\"\n\n# Copy original\ncp $SOURCE icon-original.png\n\n# Generate sizes\nsips -z 16 16 icon-original.png --out icon-16.png\nsips -z 32 32 icon-original.png --out icon-32.png\nsips -z 48 48 icon-original.png --out icon-48.png\nsips -z 64 64 icon-original.png --out icon-64.png\nsips -z 128 128 icon-original.png --out icon-128.png\nsips -z 192 192 icon-original.png --out icon-192.png\nsips -z 256 256 icon-original.png --out icon-256.png\nsips -z 180 180 icon-original.png --out apple-touch-icon.png\n\n# Create standard copies\ncp icon-original.png icon-512.png\ncp icon-original.png icon.png\ncp icon-32.png favicon.png\n```\n\n## Notes\n\n- All icons maintain the VidBee bee/honeycomb theme\n- Icons use PNG format with transparency (RGBA)\n- Generated using macOS `sips` tool for quality consistency\n"
  },
  {
    "path": "apps/docs/scripts/post-export.js",
    "content": "#!/usr/bin/env node\n\n/**\n * Post-export script to copy English content to root directory\n * This allows the default language (en) to be accessible without language prefix\n */\n\nimport { cpSync, existsSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = fileURLToPath(new URL('.', import.meta.url));\nconst outDir = join(__dirname, '../out');\nconst enDir = join(outDir, 'en');\n\nconsole.log('Copying English content to root directory...');\n\nif (!existsSync(enDir)) {\n  console.error('Error: /en directory not found in output');\n  process.exit(1);\n}\n\n// Get all items in /en directory\nconst fs = await import('node:fs/promises');\nconst items = await fs.readdir(enDir);\n\n// Copy each item to root, excluding already existing root items\nfor (const item of items) {\n  const source = join(enDir, item);\n  const dest = join(outDir, item);\n\n  // Skip if item already exists at root (like _next, api, etc.)\n  if (existsSync(dest)) {\n    console.log(`Skipping ${item} (already exists at root)`);\n    continue;\n  }\n\n  try {\n    cpSync(source, dest, { recursive: true });\n    console.log(`Copied ${item}`);\n  } catch (error) {\n    console.error(`Error copying ${item}:`, error.message);\n  }\n}\n\nconsole.log('English content copied to root directory successfully!');\n"
  },
  {
    "path": "apps/docs/source.config.ts",
    "content": "import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config';\n\n// You can customise Zod schemas for frontmatter and `meta.json` here\n// see https://fumadocs.dev/docs/mdx/collections\nexport const docs = defineDocs({\n  dir: 'content',\n  docs: {\n    schema: frontmatterSchema,\n    postprocess: {\n      includeProcessedMarkdown: true,\n    },\n  },\n  meta: {\n    schema: metaSchema,\n  },\n});\n\nexport default defineConfig({\n  mdxOptions: {\n    // MDX options\n  },\n});\n"
  },
  {
    "path": "apps/docs/src/app/(docs)/[[...slug]]/page.tsx",
    "content": "import { getPageImage, source } from '@/lib/source';\nimport { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';\nimport { notFound } from 'next/navigation';\nimport { getMDXComponents } from '@/mdx-components';\nimport type { Metadata } from 'next';\nimport { createRelativeLink } from 'fumadocs-ui/mdx';\nimport { GitHubEditButton, LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';\nimport { i18n } from '@/lib/i18n';\n\nexport default async function Page(props: PageProps<'/[[...slug]]'>) {\n  const params = await props.params;\n  const page = source.getPage(params.slug, i18n.defaultLanguage);\n  if (!page) notFound();\n\n  const MDX = page.data.body;\n  const gitConfig = {\n    user: 'nexmoe',\n    repo: 'VidBee',\n    branch: 'main',\n  };\n\n  const githubFilePath = `apps/docs/content/${page.path}`;\n  const githubBlobUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${githubFilePath}`;\n  const githubEditUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/edit/${gitConfig.branch}/${githubFilePath}`;\n\n  return (\n    <DocsPage toc={page.data.toc} full={page.data.full}>\n      <DocsTitle>{page.data.title}</DocsTitle>\n      <DocsDescription className=\"mb-0\">{page.data.description}</DocsDescription>\n      <div className=\"flex flex-row gap-2 items-center border-b pb-6\">\n        <GitHubEditButton href={githubEditUrl} />\n        <LLMCopyButton markdownUrl={`${page.url}.mdx`} />\n        <ViewOptions\n          markdownUrl={`${page.url}.mdx`}\n          githubUrl={githubBlobUrl}\n        />\n      </div>\n      <DocsBody>\n        <MDX\n          components={getMDXComponents({\n            // this allows you to link to other pages with relative file paths\n            a: createRelativeLink(source, page),\n          })}\n        />\n      </DocsBody>\n    </DocsPage>\n  );\n}\n\nexport async function generateStaticParams() {\n  return source.generateParams().flatMap((params) => {\n    if (!('lang' in params)) return [params];\n    if (!params.lang || params.lang === i18n.defaultLanguage) {\n      return [{ slug: params.slug }];\n    }\n    return [];\n  });\n}\n\nexport async function generateMetadata(\n  props: PageProps<'/[[...slug]]'>,\n): Promise<Metadata> {\n  const params = await props.params;\n  const page = source.getPage(params.slug, i18n.defaultLanguage);\n  if (!page) notFound();\n\n  const baseUrl = 'https://docs.vidbee.org';\n  const canonicalUrl = `${baseUrl}${page.url}`;\n\n  return {\n    title: `${page.data.title} | VidBee Docs`,\n    description: page.data.description,\n    openGraph: {\n      images: getPageImage(page).url,\n    },\n    alternates: {\n      canonical: canonicalUrl,\n    },\n  };\n}\n"
  },
  {
    "path": "apps/docs/src/app/(docs)/layout.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { source } from '@/lib/source';\nimport { DocsLayout } from 'fumadocs-ui/layouts/docs';\nimport { baseOptions } from '@/lib/layout.shared';\nimport { i18n } from '@/lib/i18n';\nimport { RootProvider } from 'fumadocs-ui/provider/next';\nimport { defineI18nUI } from 'fumadocs-ui/i18n';\n\nconst { provider } = defineI18nUI(i18n, {\n  translations: {\n    en: {\n      displayName: 'English',\n    },\n    zh: {\n      displayName: '中文',\n    },\n  },\n});\n\nexport default async function Layout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const locale = i18n.defaultLanguage;\n  return (\n    <RootProvider\n      i18n={provider(locale)}\n      search={{\n        options: {\n          type: 'static',\n        },\n      }}\n    >\n      <DocsLayout tree={source.getPageTree(locale)} {...baseOptions(locale)}>\n        {children}\n      </DocsLayout>\n    </RootProvider>\n  );\n}\n"
  },
  {
    "path": "apps/docs/src/app/[lang]/(docs)/[[...slug]]/page.tsx",
    "content": "import { getPageImage, source } from '@/lib/source';\nimport { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';\nimport { notFound } from 'next/navigation';\nimport { getMDXComponents } from '@/mdx-components';\nimport type { Metadata } from 'next';\nimport { createRelativeLink } from 'fumadocs-ui/mdx';\nimport { GitHubEditButton, LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';\n\nexport default async function Page(props: PageProps<'/[lang]/[[...slug]]'>) {\n  const params = await props.params;\n  const page = source.getPage(params.slug, params.lang);\n  if (!page) notFound();\n\n  const MDX = page.data.body;\n  const gitConfig = {\n    user: 'nexmoe',\n    repo: 'VidBee',\n    branch: 'main',\n  };\n\n  const githubFilePath = `apps/docs/content/${page.path}`;\n  const githubBlobUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${githubFilePath}`;\n  const githubEditUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/edit/${gitConfig.branch}/${githubFilePath}`;\n\n  return (\n    <DocsPage toc={page.data.toc} full={page.data.full}>\n      <DocsTitle>{page.data.title}</DocsTitle>\n      <DocsDescription className=\"mb-0\">{page.data.description}</DocsDescription>\n      <div className=\"flex flex-row gap-2 items-center border-b pb-6\">\n        <GitHubEditButton href={githubEditUrl} />\n        <LLMCopyButton markdownUrl={`${page.url}.mdx`} />\n        <ViewOptions\n          markdownUrl={`${page.url}.mdx`}\n          githubUrl={githubBlobUrl}\n        />\n      </div>\n      <DocsBody>\n        <MDX\n          components={getMDXComponents({\n            // this allows you to link to other pages with relative file paths\n            a: createRelativeLink(source, page),\n          })}\n        />\n      </DocsBody>\n    </DocsPage>\n  );\n}\n\nexport async function generateStaticParams() {\n  return source.generateParams();\n}\n\nexport async function generateMetadata(\n  props: PageProps<'/[lang]/[[...slug]]'>,\n): Promise<Metadata> {\n  const params = await props.params;\n  const page = source.getPage(params.slug, params.lang);\n  if (!page) notFound();\n\n  const baseUrl = 'https://docs.vidbee.org';\n  const canonicalUrl = `${baseUrl}${page.url}`;\n\n  return {\n    title: `${page.data.title} | VidBee Docs`,\n    description: page.data.description,\n    openGraph: {\n      images: getPageImage(page).url,\n    },\n    alternates: {\n      canonical: canonicalUrl,\n    },\n  };\n}\n"
  },
  {
    "path": "apps/docs/src/app/[lang]/(docs)/layout.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { source } from '@/lib/source';\nimport { DocsLayout } from 'fumadocs-ui/layouts/docs';\nimport { baseOptions } from '@/lib/layout.shared';\n\nexport default async function Layout({\n  children,\n  params,\n}: {\n  children: ReactNode;\n  params: Promise<{ lang: string; slug?: string[] }>;\n}) {\n  const resolvedParams = await params;\n  const locale = resolvedParams.lang;\n  return (\n    <DocsLayout tree={source.getPageTree(locale)} {...baseOptions(locale)}>\n      {children}\n    </DocsLayout>\n  );\n}\n"
  },
  {
    "path": "apps/docs/src/app/[lang]/layout.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { RootProvider } from 'fumadocs-ui/provider/next';\nimport { defineI18nUI } from 'fumadocs-ui/i18n';\nimport { i18n, isLocale } from '@/lib/i18n';\n\nconst { provider } = defineI18nUI(i18n, {\n  translations: {\n    en: {\n      displayName: 'English',\n    },\n    zh: {\n      displayName: '中文',\n    },\n  },\n});\n\nexport default async function Layout({\n  children,\n  params,\n}: {\n  children: ReactNode;\n  params: Promise<{ lang: string }>;\n}) {\n  const { lang } = await params;\n  const locale = isLocale(lang) ? lang : i18n.defaultLanguage;\n\n  return (\n    <RootProvider\n      i18n={provider(locale)}\n      search={{\n        options: {\n          type: 'static',\n        },\n      }}\n    >\n      {children}\n    </RootProvider>\n  );\n}\n"
  },
  {
    "path": "apps/docs/src/app/api/search/route.ts",
    "content": "import { source } from '@/lib/source';\nimport { createFromSource } from 'fumadocs-core/search/server';\n\n// statically cached for static export\nexport const revalidate = false;\n\n// Configure language support for both English and Chinese\nexport const { staticGET: GET } = createFromSource(source, {\n  localeMap: {\n    en: { language: 'english' },\n    // Chinese is not natively supported by Orama, use English tokenizer for zh\n    zh: { language: 'english' },\n  },\n});\n"
  },
  {
    "path": "apps/docs/src/app/global.css",
    "content": "@import 'tailwindcss';\n@import 'fumadocs-ui/css/neutral.css';\n@import 'fumadocs-ui/css/preset.css';\n"
  },
  {
    "path": "apps/docs/src/app/layout.tsx",
    "content": "import './global.css';\nimport { Inter } from 'next/font/google';\nimport { GoogleAnalytics } from '@next/third-parties/google';\nimport type { ReactNode } from 'react';\nimport type { Metadata } from 'next';\nimport { i18n, isLocale } from '@/lib/i18n';\n\nconst inter = Inter({\n  subsets: ['latin'],\n});\n\nexport const metadata: Metadata = {\n  icons: {\n    icon: [\n      { url: '/favicon.png', sizes: '32x32', type: 'image/png' },\n      { url: '/icon-16.png', sizes: '16x16', type: 'image/png' },\n      { url: '/icon-32.png', sizes: '32x32', type: 'image/png' },\n      { url: '/icon-192.png', sizes: '192x192', type: 'image/png' },\n    ],\n    apple: '/apple-touch-icon.png',\n  },\n};\n\nexport default async function Layout({\n  children,\n  params,\n}: {\n  children: ReactNode;\n  params?: Promise<{ lang?: string }>;\n}) {\n  const resolvedParams = params ? await params : undefined;\n  const lang = isLocale(resolvedParams?.lang) ? resolvedParams.lang : i18n.defaultLanguage;\n\n  return (\n    <html lang={lang} className={inter.className} suppressHydrationWarning>\n      <body className=\"flex flex-col min-h-screen\">\n        {children}\n        <GoogleAnalytics gaId=\"G-2F11GJP6G9\" />\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "apps/docs/src/app/sitemap.ts",
    "content": "import type { MetadataRoute } from 'next';\nimport { i18n } from '@/lib/i18n';\nimport { source } from '@/lib/source';\n\nexport const dynamic = 'force-static';\n\nconst baseUrl = 'https://docs.vidbee.org';\n\nfunction buildPath(segments: string[]): string {\n  if (segments.length === 0) {\n    return '/';\n  }\n  return `/${segments.join('/')}/`;\n}\n\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n  const params = source.generateParams();\n\n  return params.map(({ lang, slug }) => {\n    const segments: string[] = [];\n\n    if (lang && lang !== i18n.defaultLanguage) {\n      segments.push(lang);\n    }\n\n    if (slug && slug.length > 0) {\n      segments.push(...slug);\n    }\n\n    return {\n      url: `${baseUrl}${buildPath(segments)}`,\n    };\n  });\n}\n"
  },
  {
    "path": "apps/docs/src/components/ai/page-actions.tsx",
    "content": "'use client';\nimport { useMemo, useState } from 'react';\nimport { Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react';\nimport { cn } from '@/lib/cn';\nimport { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';\nimport { buttonVariants } from 'fumadocs-ui/components/ui/button';\nimport { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';\n\nconst cache = new Map<string, string>();\n\nexport function LLMCopyButton({\n  /**\n   * A URL to fetch the raw Markdown/MDX content of page\n   */\n  markdownUrl,\n}: {\n  markdownUrl: string;\n}) {\n  const [isLoading, setLoading] = useState(false);\n  const [checked, onClick] = useCopyButton(async () => {\n    const cached = cache.get(markdownUrl);\n    if (cached) return navigator.clipboard.writeText(cached);\n\n    setLoading(true);\n\n    try {\n      await navigator.clipboard.write([\n        new ClipboardItem({\n          'text/plain': fetch(markdownUrl).then(async (res) => {\n            const content = await res.text();\n            cache.set(markdownUrl, content);\n\n            return content;\n          }),\n        }),\n      ]);\n    } finally {\n      setLoading(false);\n    }\n  });\n\n  return (\n    <button\n      disabled={isLoading}\n      className={cn(\n        buttonVariants({\n          color: 'secondary',\n          size: 'sm',\n          className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',\n        }),\n      )}\n      onClick={onClick}\n    >\n      {checked ? <Check /> : <Copy />}\n      Copy Markdown\n    </button>\n  );\n}\n\nexport function ViewOptions({\n  markdownUrl,\n  githubUrl,\n}: {\n  /**\n   * A URL to the raw Markdown/MDX content of page\n   */\n  markdownUrl: string;\n\n  /**\n   * Source file URL on GitHub\n   */\n  githubUrl: string;\n}) {\n  const items = useMemo(() => {\n    const fullMarkdownUrl =\n      typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';\n    const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;\n\n    return [\n      {\n        title: 'Open in GitHub',\n        href: githubUrl,\n        icon: (\n          <svg fill=\"currentColor\" role=\"img\" viewBox=\"0 0 24 24\">\n            <title>GitHub</title>\n            <path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" />\n          </svg>\n        ),\n      },\n      {\n        title: 'Open in Scira AI',\n        href: `https://scira.ai/?${new URLSearchParams({\n          q,\n        })}`,\n        icon: (\n          <svg\n            width=\"910\"\n            height=\"934\"\n            viewBox=\"0 0 910 934\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <title>Scira AI</title>\n            <path\n              d=\"M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z\"\n              fill=\"currentColor\"\n              stroke=\"currentColor\"\n              strokeWidth=\"8\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z\"\n              fill=\"currentColor\"\n              stroke=\"currentColor\"\n              strokeWidth=\"8\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z\"\n              stroke=\"currentColor\"\n              strokeWidth=\"20\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z\"\n              stroke=\"currentColor\"\n              strokeWidth=\"20\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z\"\n              fill=\"currentColor\"\n              stroke=\"currentColor\"\n              strokeWidth=\"8\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z\"\n              fill=\"currentColor\"\n              stroke=\"currentColor\"\n              strokeWidth=\"8\"\n              strokeLinejoin=\"round\"\n            />\n            <path\n              d=\"M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442\"\n              stroke=\"currentColor\"\n              strokeWidth=\"30\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        ),\n      },\n      {\n        title: 'Open in ChatGPT',\n        href: `https://chatgpt.com/?${new URLSearchParams({\n          hints: 'search',\n          q,\n        })}`,\n        icon: (\n          <svg\n            role=\"img\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <title>OpenAI</title>\n            <path d=\"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z\" />\n          </svg>\n        ),\n      },\n      {\n        title: 'Open in Claude',\n        href: `https://claude.ai/new?${new URLSearchParams({\n          q,\n        })}`,\n        icon: (\n          <svg\n            fill=\"currentColor\"\n            role=\"img\"\n            viewBox=\"0 0 24 24\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <title>Anthropic</title>\n            <path d=\"M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z\" />\n          </svg>\n        ),\n      },\n      {\n        title: 'Open in T3 Chat',\n        href: `https://t3.chat/new?${new URLSearchParams({\n          q,\n        })}`,\n        icon: <MessageCircleIcon />,\n      },\n    ];\n  }, [githubUrl, markdownUrl]);\n\n  return (\n    <Popover>\n      <PopoverTrigger\n        className={cn(\n          buttonVariants({\n            color: 'secondary',\n            size: 'sm',\n            className: 'gap-2',\n          }),\n        )}\n      >\n        Open\n        <ChevronDown className=\"size-3.5 text-fd-muted-foreground\" />\n      </PopoverTrigger>\n      <PopoverContent className=\"flex flex-col\">\n        {items.map((item) => (\n          <a\n            key={item.href}\n            href={item.href}\n            rel=\"noreferrer noopener\"\n            target=\"_blank\"\n            className=\"text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4\"\n          >\n            {item.icon}\n            {item.title}\n            <ExternalLinkIcon className=\"text-fd-muted-foreground size-3.5 ms-auto\" />\n          </a>\n        ))}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport function GitHubEditButton({ href }: { href: string }) {\n  return (\n    <a\n      href={href}\n      rel=\"noreferrer noopener\"\n      target=\"_blank\"\n      className={cn(\n        buttonVariants({\n          color: 'secondary',\n          size: 'sm',\n          className: 'gap-2',\n        })\n      )}\n    >\n      <ExternalLinkIcon className=\"size-3.5 text-fd-muted-foreground\" />\n      Edit on GitHub\n    </a>\n  )\n}\n"
  },
  {
    "path": "apps/docs/src/lib/cn.ts",
    "content": "export { twMerge as cn } from 'tailwind-merge';\n"
  },
  {
    "path": "apps/docs/src/lib/i18n.ts",
    "content": "import { defineI18n } from 'fumadocs-core/i18n';\n\nexport const i18n = defineI18n({\n  languages: ['en', 'zh', 'fr', 'ru'],\n  defaultLanguage: 'en',\n  // Hide locale prefix for default language (en) so English content appears at root\n  hideLocale: 'default-locale',\n  parser: 'dir'\n});\n\nexport type Locale = (typeof i18n.languages)[number];\n\nconst localeSet = new Set(i18n.languages);\n\nexport function isLocale(value?: string): value is Locale {\n  return Boolean(value && localeSet.has(value as Locale));\n}\n\nexport function resolveLocaleFromSlug(slug?: string[]): Locale {\n  if (slug && slug.length > 0 && isLocale(slug[0])) {\n    return slug[0];\n  }\n  return i18n.defaultLanguage;\n}\n\nexport function stripLocaleFromSlug(slug?: string[]): string[] {\n  if (!slug || slug.length === 0) {\n    return [];\n  }\n  if (isLocale(slug[0])) {\n    return slug.slice(1);\n  }\n  return slug;\n}\n"
  },
  {
    "path": "apps/docs/src/lib/layout.shared.tsx",
    "content": "import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';\nimport { i18n } from '@/lib/i18n';\n\nexport function baseOptions(_locale: string): BaseLayoutProps {\n  return {\n    nav: {\n      title: 'VidBee',\n    },\n    i18n,\n  };\n}\n"
  },
  {
    "path": "apps/docs/src/lib/source.ts",
    "content": "import { docs } from 'fumadocs-mdx:collections/server';\nimport { type InferPageType, loader } from 'fumadocs-core/source';\nimport { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';\nimport { i18n } from '@/lib/i18n';\n\n// See https://fumadocs.dev/docs/headless/source-api for more info\nexport const source = loader({\n  baseUrl: '/',\n  i18n,\n  source: docs.toFumadocsSource(),\n  plugins: [lucideIconsPlugin()],\n});\n\nexport function getPageImage(page: InferPageType<typeof source>) {\n  const localePrefix =\n    page.locale && page.locale !== i18n.defaultLanguage ? [page.locale] : [];\n  const segments = [...localePrefix, ...page.slugs, 'image.png'];\n\n  return {\n    segments,\n    url: `/og/docs/${segments.join('/')}`,\n  };\n}\n\nexport async function getLLMText(page: InferPageType<typeof source>) {\n  const processed = await page.data.getText('processed');\n\n  return `# ${page.data.title}\n\n${processed}`;\n}\n"
  },
  {
    "path": "apps/docs/src/mdx-components.tsx",
    "content": "import defaultMdxComponents from 'fumadocs-ui/mdx';\nimport type { MDXComponents } from 'mdx/types';\nimport type React from 'react';\nimport Image from 'next/image';\n\nconst basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';\n\nconst withBasePath = (src?: string) => {\n  if (!src || !src.startsWith('/')) return src;\n  return `${basePath}${src}`;\n};\n\nconst resolveImageSrc = (src: React.ComponentProps<typeof Image>['src']) => {\n  if (typeof src === 'string') return withBasePath(src) ?? src;\n  return src;\n};\n\nconst resolveImageSize = (\n  props: React.ComponentProps<typeof Image>,\n  src: React.ComponentProps<typeof Image>['src'],\n) => {\n  if (props.fill) return { fill: true as const };\n  if (typeof src === 'string') {\n    return {\n      width: props.width ?? 1200,\n      height: props.height ?? 675,\n    };\n  }\n  return {\n    width: props.width,\n    height: props.height,\n  };\n};\n\nexport function getMDXComponents(components?: MDXComponents): MDXComponents {\n  return {\n    ...defaultMdxComponents,\n    img: (props) => {\n      const src = resolveImageSrc(props.src);\n      const sizeProps = resolveImageSize(props, src);\n      return <Image {...props} {...sizeProps} src={src} />;\n    },\n    ...components,\n  };\n}\n"
  },
  {
    "path": "apps/docs/src/middleware.ts",
    "content": "import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';\nimport { i18n } from '@/lib/i18n';\n\nexport default createI18nMiddleware(i18n);\n\nexport const config = {\n  // Note: Middleware doesn't run in static export, but kept for development\n  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],\n};\n"
  },
  {
    "path": "apps/docs/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"target\": \"ESNext\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ],\n      \"fumadocs-mdx:collections/*\": [\n        \".source/*\"\n      ]\n    },\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ]\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}"
  },
  {
    "path": "apps/extension/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.output\nstats.html\nstats-*.json\n.wxt\nweb-ext.config.ts\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n.env\n.env.*\n"
  },
  {
    "path": "apps/extension/README.md",
    "content": "# VidBee Video Downloader Extension\n\nVidBee Video Downloader is a lightweight browser companion for the VidBee desktop app. It detects the video on your current tab, shows the available formats, and hands the URL to VidBee so the download happens in the desktop app instead of your browser.\n\n## Why install it?\n\n- **One-click handoff:** Send the current video page to VidBee without copy-pasting links.\n- **See formats before downloading:** Preview resolutions, file sizes, and audio-only options in the popup.\n- **More reliable downloads:** VidBee handles large files and multi-format downloads better than most browsers.\n- **Works on 1,000+ sites:** The extension uses the same site support as the VidBee app.\n\n## What it does\n\n1. Reads the active tab URL when you open the popup.\n2. Asks the VidBee desktop app (running locally) to analyze the video.\n3. Displays the formats it finds and lets you open VidBee to download.\n\n## Requirements\n\n- VidBee desktop app installed.\n- VidBee running while you use the extension.\n\n## How to use\n\n1. Open a supported video page.\n2. Click the VidBee extension icon.\n3. Review available formats.\n4. Click **Download with VidBee** to start the download in the desktop app.\n\n## Development\n\n```bash\npnpm install\npnpm dev\n```\n\nBuild or package:\n\n```bash\npnpm build\npnpm zip\n```\n\nFirefox builds:\n\n```bash\npnpm dev:firefox\npnpm build:firefox\npnpm zip:firefox\n```\n\n## Notes on privacy\n\nThe extension only sends the current tab URL to the local VidBee app on `127.0.0.1` and stores temporary results in browser storage for faster reloads.\n"
  },
  {
    "path": "apps/extension/assets/content.css",
    "content": ".vidbee-download-container {\n  position: fixed;\n  bottom: 16px;\n  right: 16px;\n  z-index: 9999;\n  transition: all 0.2s ease;\n  overflow: visible;\n}\n\n.vidbee-download-container.vidbee-hidden {\n  opacity: 0;\n  pointer-events: none;\n  transform: scale(0);\n}\n\n.vidbee-download-button {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 36px;\n  height: 36px;\n  padding: 0;\n  background: rgba(0, 0, 0, 0.5);\n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n  color: rgba(255, 255, 255, 0.8);\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  border-radius: 50%;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);\n  cursor: pointer;\n  font-size: 0;\n  transition: all 0.2s ease;\n  opacity: 0.6;\n  overflow: visible;\n}\n\n.vidbee-download-button:hover {\n  opacity: 1;\n  background: rgba(0, 0, 0, 0.7);\n  border-color: rgba(255, 255, 255, 0.2);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n  transform: scale(1.1);\n}\n\n.vidbee-download-button:active {\n  transform: scale(0.95);\n}\n\n.vidbee-download-button svg {\n  flex-shrink: 0;\n  stroke: currentColor;\n  width: 16px;\n  height: 16px;\n}\n\n.vidbee-tooltip {\n  position: absolute;\n  right: calc(100% + 8px);\n  top: 50%;\n  padding: 6px 10px;\n  background: rgba(0, 0, 0, 0.9);\n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n  color: white;\n  font-size: 12px;\n  font-weight: 500;\n  white-space: nowrap;\n  border-radius: 4px;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);\n  opacity: 0;\n  pointer-events: none;\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n  transform: translateY(-50%) translateX(4px);\n  z-index: 10000;\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n    Arial, sans-serif;\n  line-height: 1;\n}\n\n.vidbee-tooltip::after {\n  content: \"\";\n  position: absolute;\n  left: 100%;\n  top: 50%;\n  transform: translateY(-50%);\n  border: 4px solid transparent;\n  border-left-color: rgba(0, 0, 0, 0.9);\n}\n\n.vidbee-download-button:hover .vidbee-tooltip {\n  opacity: 1;\n  transform: translateY(-50%) translateX(0);\n}\n\n.vidbee-close-button {\n  position: absolute;\n  top: -6px;\n  right: -6px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 18px;\n  height: 18px;\n  padding: 0;\n  background: rgba(255, 77, 77, 0.9);\n  backdrop-filter: blur(4px);\n  -webkit-backdrop-filter: blur(4px);\n  color: white;\n  border: 1px solid rgba(255, 255, 255, 0.2);\n  border-radius: 50%;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n  cursor: pointer;\n  font-size: 0;\n  transition: all 0.2s ease;\n  z-index: 1;\n  opacity: 0;\n  pointer-events: none;\n  overflow: visible;\n}\n\n.vidbee-download-container:hover .vidbee-close-button {\n  opacity: 1;\n  pointer-events: auto;\n}\n\n.vidbee-close-button:hover {\n  background: rgba(255, 77, 77, 1);\n  transform: scale(1.15);\n  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);\n}\n\n.vidbee-close-button:active {\n  transform: scale(0.9);\n}\n\n.vidbee-close-button svg {\n  flex-shrink: 0;\n  stroke: currentColor;\n  width: 10px;\n  height: 10px;\n}\n\n.vidbee-close-button .vidbee-tooltip {\n  right: calc(100% + 6px);\n  top: 50%;\n  left: auto;\n  transform: translateY(-50%) translateX(4px);\n}\n\n.vidbee-close-button .vidbee-tooltip::after {\n  left: 100%;\n  top: 50%;\n  right: auto;\n  transform: translateY(-50%);\n  border-left-color: rgba(0, 0, 0, 0.9);\n  border-top-color: transparent;\n}\n\n.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {\n  opacity: 1;\n  transform: translateY(-50%) translateX(0);\n}\n"
  },
  {
    "path": "apps/extension/entrypoints/background.ts",
    "content": "interface VideoFormat {\n  format_id?: string\n  ext?: string\n  format_note?: string\n  resolution?: string\n  width?: number\n  height?: number\n  fps?: number\n  vcodec?: string\n  acodec?: string\n  filesize?: number\n  filesize_approx?: number\n  tbr?: number\n}\n\ninterface VideoInfo {\n  title?: string\n  thumbnail?: string\n  duration?: number\n  formats?: VideoFormat[]\n}\n\ninterface VideoInfoCacheEntry {\n  url: string\n  status: 'pending' | 'ready' | 'error'\n  fetchedAt: number\n  info?: VideoInfo\n  error?: string\n}\n\nconst PORT_RANGE_START = 27_100\nconst PORT_RANGE_END = 27_120\nconst STATUS_TIMEOUT_MS = 800\nconst INFO_TIMEOUT_MS = 60_000\nconst CACHE_TTL_MS = 5 * 60 * 1000\n\nconst pendingRequests = new Map<string, Promise<void>>()\nconst defaultIconPaths = {\n  16: 'icon/16.png',\n  32: 'icon/32.png',\n  48: 'icon/48.png',\n  128: 'icon/128.png'\n}\nconst loadingIconPaths = {\n  16: 'icon/icon-loading-16.png',\n  32: 'icon/icon-loading-32.png',\n  48: 'icon/icon-loading-48.png',\n  128: 'icon/icon-loading-128.png'\n}\nconst successIconPaths = {\n  16: 'icon/icon-success-16.png',\n  32: 'icon/icon-success-32.png',\n  48: 'icon/icon-success-48.png',\n  128: 'icon/icon-success-128.png'\n}\n\nconst setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {\n  const paths =\n    status === 'loading'\n      ? loadingIconPaths\n      : status === 'success'\n        ? successIconPaths\n        : defaultIconPaths\n  const options = tabId ? { path: paths, tabId } : { path: paths }\n  void browser.action.setIcon(options)\n}\n\nconst fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {\n  const controller = new AbortController()\n  const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)\n\n  try {\n    const response = await fetch(url, { signal: controller.signal })\n    const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null\n    if (!response.ok) {\n      const message = data && typeof data === 'object' && 'error' in data ? data.error : null\n      const details = data && typeof data === 'object' && 'details' in data ? data.details : null\n      const combined = [message, details].filter(Boolean).join('\\n\\n')\n      throw new Error(combined || `Request failed: ${response.status}`)\n    }\n    return data as T\n  } catch (error) {\n    if (error instanceof DOMException && error.name === 'AbortError') {\n      throw new Error('Request timed out.')\n    }\n    if (error instanceof Error && error.message.includes('signal is aborted')) {\n      throw new Error('Request timed out.')\n    }\n    if (error instanceof Error && error.message.includes('Failed to fetch')) {\n      throw new Error('VidBee app not responding on this port.')\n    }\n    throw error\n  } finally {\n    clearTimeout(timeoutId)\n  }\n}\n\nconst findAvailablePort = async (): Promise<number | null> => {\n  for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {\n    const baseUrl = `http://127.0.0.1:${port}`\n    try {\n      await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)\n      return port\n    } catch {\n      // Keep scanning.\n    }\n  }\n  return null\n}\n\nconst requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {\n  const port = await findAvailablePort()\n  if (!port) {\n    throw new Error('VidBee app not found on localhost.')\n  }\n\n  const baseUrl = `http://127.0.0.1:${port}`\n  const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)\n  if (!tokenResponse.token) {\n    throw new Error('Failed to acquire token from VidBee.')\n  }\n\n  return fetchJson<VideoInfo>(\n    `${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(\n      tokenResponse.token\n    )}`,\n    INFO_TIMEOUT_MS\n  )\n}\n\nconst getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {\n  const data = await browser.storage.local.get('videoInfoCacheByUrl')\n  const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined\n  if (!map) {\n    return {}\n  }\n  return map\n}\n\nconst pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {\n  const now = Date.now()\n  for (const [key, entry] of Object.entries(map)) {\n    if (now - entry.fetchedAt > CACHE_TTL_MS) {\n      delete map[key]\n    }\n  }\n}\n\nconst loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {\n  const map = await getCacheMap()\n  pruneCache(map)\n  const cache = map[url]\n  if (!cache) {\n    return null\n  }\n  return cache\n}\n\nconst saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {\n  const map = await getCacheMap()\n  pruneCache(map)\n  map[cache.url] = cache\n  await browser.storage.local.set({ videoInfoCacheByUrl: map })\n}\n\nconst fetchAndCache = async (url: string, tabId?: number): Promise<void> => {\n  if (pendingRequests.has(url)) {\n    return pendingRequests.get(url) as Promise<void>\n  }\n\n  const task = (async () => {\n    const existing = await loadCache(url)\n    if (existing?.status === 'ready') {\n      setActionIcon('success', tabId)\n      return\n    }\n\n    setActionIcon('loading', tabId)\n    await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })\n\n    try {\n      const info = await requestVideoInfo(url)\n      await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })\n      setActionIcon('success', tabId)\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Failed to fetch video info.'\n      await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })\n      setActionIcon('default', tabId)\n    }\n  })()\n\n  pendingRequests.set(url, task)\n  try {\n    await task\n  } finally {\n    pendingRequests.delete(url)\n  }\n}\n\nexport default defineBackground(() => {\n  browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {\n    if (message.type !== 'video-info:fetch' || !message.url) {\n      return\n    }\n    void fetchAndCache(message.url, sender.tab?.id)\n  })\n})\n"
  },
  {
    "path": "apps/extension/entrypoints/popup/App.css",
    "content": ":root {\n  --bg: #ffffff;\n  --fg: #111111;\n  --fg-secondary: #757575;\n  --border: #f0f0f0;\n  --accent: #000000;\n  --error: #e00000;\n  --success: #00c853;\n  --warning: #ffd600;\n}\n\nbody {\n  margin: 0;\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial,\n    sans-serif;\n  -webkit-font-smoothing: antialiased;\n  background: var(--bg);\n  color: var(--fg);\n}\n\n#root {\n  width: 360px;\n  min-height: 200px;\n  padding: 24px;\n  box-sizing: border-box;\n}\n\n.app {\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\nheader {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\nh1 {\n  font-size: 13px;\n  font-weight: 600;\n  margin: 0;\n  letter-spacing: -0.01em;\n  color: var(--fg);\n}\n\n.status-indicator {\n  font-size: 11px;\n  font-weight: 500;\n  color: var(--fg-secondary);\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.status-dot {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background-color: var(--border);\n}\n.status-dot.loading {\n  background-color: var(--warning);\n  box-shadow: 0 0 4px var(--warning);\n}\n.status-dot.ok {\n  background-color: var(--success);\n}\n.status-dot.error {\n  background-color: var(--error);\n}\n\n.video-info {\n  display: grid;\n  grid-template-columns: 1fr 90px;\n  gap: 20px;\n  align-items: start;\n}\n\n.video-details h2 {\n  font-size: 15px;\n  font-weight: 500;\n  line-height: 1.4;\n  margin: 0 0 8px 0;\n  color: var(--fg);\n}\n\n.meta-row {\n  font-size: 12px;\n  color: var(--fg-secondary);\n  margin: 0 0 4px 0;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.thumbnail {\n  width: 90px;\n  height: 50px;\n  background: var(--border);\n  object-fit: cover;\n  border-radius: 6px;\n  display: block;\n}\n\n.formats-section {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n.format-group {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n/* Sticky headers for long lists */\n.sticky-title {\n  position: sticky;\n  top: 0;\n  background: var(--bg);\n  padding: 4px 0;\n  z-index: 10;\n}\n\n.group-title {\n  font-size: 11px;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  color: var(--fg-secondary);\n  font-weight: 600;\n  border-bottom: 2px solid var(--border);\n  padding-bottom: 4px;\n}\n\n.format-table {\n  width: 100%;\n  border-collapse: collapse;\n  font-size: 12px;\n  table-layout: fixed;\n}\n\n.format-table th {\n  text-align: left;\n  font-weight: 400;\n  color: var(--fg-secondary);\n  padding-bottom: 6px;\n  border-bottom: 1px solid var(--border);\n  font-size: 10px;\n  text-transform: uppercase;\n  padding-top: 6px;\n}\n\n.format-table td {\n  padding: 6px 0;\n  border-bottom: 1px solid var(--border);\n  color: var(--fg);\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.format-table tr:last-child td {\n  border-bottom: none;\n}\n\n.col-id {\n  width: 50px;\n  color: var(--fg-secondary);\n}\n.col-ext {\n  width: 50px;\n}\n.col-size {\n  width: 60px;\n  text-align: right;\n}\n.format-table th.col-size {\n  text-align: right;\n}\n\n.empty-state {\n  font-size: 12px;\n  color: var(--fg-secondary);\n  padding: 12px 0;\n  font-style: italic;\n  text-align: center;\n}\n\n.error-banner {\n  font-size: 12px;\n  color: var(--error);\n  line-height: 1.5;\n  background: rgba(224, 0, 0, 0.05);\n  padding: 12px;\n  border-radius: 6px;\n}\n\n.primary-button {\n  background: var(--fg);\n  color: var(--bg);\n  border: none;\n  border-radius: 8px;\n  padding: 12px;\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n  width: 100%;\n  transition: opacity 0.2s;\n}\n\n.primary-button:hover {\n  opacity: 0.9;\n}\n\n.primary-button:active {\n  transform: scale(0.98);\n}\n\n.error-container {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.error-header {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.error-title {\n  font-size: 16px;\n  font-weight: 600;\n  margin: 0;\n  color: var(--fg);\n}\n\n.error-description {\n  font-size: 12px;\n  color: var(--fg-secondary);\n  margin: 0;\n  line-height: 1.5;\n}\n\n.action-grid {\n  display: grid;\n  gap: 12px;\n}\n\n.action-card {\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  padding: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  background: #fafafa;\n}\n\n.action-title {\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--fg-secondary);\n  margin: 0;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n.action-text {\n  font-size: 13px;\n  color: var(--fg);\n  margin: 0;\n  line-height: 1.5;\n}\n\n.secondary-button {\n  border: 1px solid var(--fg);\n  border-radius: 8px;\n  padding: 8px 10px;\n  font-size: 12px;\n  font-weight: 500;\n  cursor: pointer;\n  background: transparent;\n  color: var(--fg);\n  text-decoration: none;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: fit-content;\n}\n\n.secondary-button:hover {\n  background: var(--border);\n}\n\n.loading-container {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  min-height: 200px;\n  gap: 16px;\n  flex: 1;\n  width: 100%;\n}\n\n.spinner {\n  width: 24px;\n  height: 24px;\n  border: 2.5px solid var(--border);\n  border-top-color: var(--fg);\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n.loading-text {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--fg-secondary);\n  animation: pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.6;\n  }\n}\n"
  },
  {
    "path": "apps/extension/entrypoints/popup/App.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react'\nimport './App.css'\n\ninterface VideoFormat {\n  format_id?: string\n  ext?: string\n  format_note?: string\n  resolution?: string\n  width?: number\n  height?: number\n  fps?: number\n  vcodec?: string\n  acodec?: string\n  filesize?: number\n  filesize_approx?: number\n  tbr?: number\n}\n\ninterface VideoInfo {\n  title?: string\n  thumbnail?: string\n  duration?: number\n  formats?: VideoFormat[]\n}\n\nconst CACHE_TTL_MS = 60 * 60 * 1000\n\nconst isValidHttpUrl = (value?: string): boolean => {\n  if (!value) {\n    return false\n  }\n  return value.startsWith('http://') || value.startsWith('https://')\n}\n\nconst formatDuration = (value?: number): string => {\n  if (!value || value <= 0) {\n    return 'Unknown'\n  }\n  const totalSeconds = Math.round(value)\n  const hours = Math.floor(totalSeconds / 3600)\n  const minutes = Math.floor((totalSeconds % 3600) / 60)\n  const seconds = totalSeconds % 60\n  const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)\n  const paddedSeconds = String(seconds).padStart(2, '0')\n  return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`\n}\n\nconst formatBytes = (value?: number): string => {\n  if (!value || value <= 0) {\n    return '-'\n  }\n  const units = ['B', 'KB', 'MB', 'GB']\n  let size = value\n  let unitIndex = 0\n  while (size >= 1024 && unitIndex < units.length - 1) {\n    size /= 1024\n    unitIndex += 1\n  }\n  return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`\n}\n\nconst isVideoFormat = (format: VideoFormat): boolean => {\n  if (format.vcodec && format.vcodec !== 'none') {\n    return true\n  }\n  return Boolean(format.resolution || format.width || format.height)\n}\n\nconst isAudioFormat = (format: VideoFormat): boolean => {\n  return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))\n}\n\ninterface VideoInfoCacheEntry {\n  url: string\n  status: 'pending' | 'ready' | 'error'\n  fetchedAt: number\n  info?: VideoInfo\n  error?: string\n}\n\ninterface VideoGroup {\n  label: string\n  height: number\n  formats: VideoFormat[]\n}\n\nconst loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {\n  const data = await browser.storage.local.get('videoInfoCacheByUrl')\n  const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined\n  if (!map) {\n    return null\n  }\n  const cached = map[url]\n  if (!cached) {\n    return null\n  }\n  if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {\n    return null\n  }\n  return cached\n}\n\nconst sanitizeError = (error: string): string => {\n  const message = error.toLowerCase()\n  if (\n    message.includes('localhost') ||\n    message.includes('fetch') ||\n    message.includes('network') ||\n    message.includes('connect') ||\n    message.includes('failed to request')\n  ) {\n    return 'Client connection failed'\n  }\n  return error\n}\n\nfunction App() {\n  const [info, setInfo] = useState<VideoInfo | null>(null)\n  const [error, setError] = useState<string | null>(null)\n  const [loading, setLoading] = useState(false)\n  const [currentUrl, setCurrentUrl] = useState<string>('')\n  const [retryTrigger, setRetryTrigger] = useState(0)\n\n  useEffect(() => {\n    let active = true\n    const targetState = { url: '' }\n\n    const handleStorageChange = (\n      changes: Record<string, { newValue?: unknown }>,\n      areaName: string\n    ) => {\n      if (!active || areaName !== 'local') {\n        return\n      }\n      const change = changes.videoInfoCacheByUrl\n      if (!change?.newValue) {\n        return\n      }\n\n      const map = change.newValue as Record<string, VideoInfoCacheEntry>\n      const next = map[targetState.url]\n      if (!next) {\n        return\n      }\n\n      if (next.status === 'ready' && next.info) {\n        setInfo(next.info)\n        setError(null)\n        setLoading(false)\n      } else if (next.status === 'error' && next.error) {\n        setError(sanitizeError(next.error))\n        setInfo(null)\n        setLoading(false)\n      } else if (next.status === 'pending') {\n        setLoading(true)\n      }\n    }\n\n    browser.storage.onChanged.addListener(handleStorageChange)\n\n    const loadInfo = async () => {\n      setLoading(true)\n      setError(null)\n      setInfo(null)\n\n      const [tab] = await browser.tabs.query({ active: true, currentWindow: true })\n      if (!isValidHttpUrl(tab?.url)) {\n        setError('Please open a valid video page first.')\n        setLoading(false)\n        return\n      }\n\n      const targetUrl = tab.url as string\n      targetState.url = targetUrl\n      setCurrentUrl(targetUrl)\n\n      const cached = await loadCachedInfo(targetUrl)\n      const shouldBypassCache = retryTrigger > 0\n      if (cached && !shouldBypassCache) {\n        if (cached.status === 'ready' && cached.info) {\n          setInfo(cached.info)\n          setLoading(false)\n          return\n        }\n        if (cached.status === 'error' && cached.error) {\n          setError(sanitizeError(cached.error))\n          setLoading(false)\n          return\n        }\n      }\n\n      try {\n        await browser.runtime.sendMessage({\n          type: 'video-info:fetch',\n          url: targetUrl\n        })\n      } catch (err) {\n        const message = err instanceof Error ? err.message : 'Failed to request video info.'\n        setError(sanitizeError(message))\n        setLoading(false)\n      }\n\n      const latest = await loadCachedInfo(targetUrl)\n      if (latest && latest.status === 'ready' && latest.info) {\n        setInfo(latest.info)\n        setError(null)\n        setLoading(false)\n      } else if (latest && latest.status === 'error' && latest.error) {\n        setError(sanitizeError(latest.error))\n        setInfo(null)\n        setLoading(false)\n      }\n    }\n\n    void loadInfo()\n\n    return () => {\n      active = false\n      browser.storage.onChanged.removeListener(handleStorageChange)\n    }\n  }, [retryTrigger])\n\n  const formats = useMemo(() => info?.formats ?? [], [info])\n  const groupedFormats = useMemo(() => {\n    const video: VideoFormat[] = []\n    const audio: VideoFormat[] = []\n    const other: VideoFormat[] = []\n\n    for (const format of formats) {\n      if (isVideoFormat(format)) {\n        video.push(format)\n      } else if (isAudioFormat(format)) {\n        audio.push(format)\n      } else {\n        other.push(format)\n      }\n    }\n\n    return { video, audio, other }\n  }, [formats])\n\n  const groupedVideoFormats = useMemo(() => {\n    const raw = groupedFormats.video\n    if (!raw.length) {\n      return []\n    }\n\n    const groups: Record<number, VideoFormat[]> = {}\n    const noHeight: VideoFormat[] = []\n\n    for (const f of raw) {\n      const h = f.height || f.resolution?.match(/x(\\d+)/)?.[1]\n      const heightVal = h ? Number(h) : 0\n\n      if (heightVal > 0) {\n        if (!groups[heightVal]) {\n          groups[heightVal] = []\n        }\n        groups[heightVal].push(f)\n      } else {\n        noHeight.push(f)\n      }\n    }\n\n    const sortedLabels = Object.keys(groups)\n      .map(Number)\n      .sort((a, b) => b - a)\n\n    const result: VideoGroup[] = sortedLabels.map((h) => ({\n      label: `${h}p`,\n      height: h,\n      formats: groups[h].sort((a, b) => {\n        const sa = a.filesize || a.filesize_approx || 0\n        const sb = b.filesize || b.filesize_approx || 0\n        return sb - sa\n      })\n    }))\n\n    if (noHeight.length > 0) {\n      result.push({\n        label: 'Other',\n        height: 0,\n        formats: noHeight\n      })\n    }\n\n    return result\n  }, [groupedFormats.video])\n\n  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n  const clientLaunchDelayMs = 2000\n\n  const openClientApp = async () => {\n    window.location.href = 'vidbee://'\n    await wait(clientLaunchDelayMs)\n  }\n\n  const handleOpenClient = () => {\n    if (!currentUrl) {\n      return\n    }\n    const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`\n    window.location.href = deepLink\n  }\n\n  const handleOpenClientAndRetry = async () => {\n    await openClientApp()\n    setRetryTrigger((count) => count + 1)\n  }\n\n  const handleRetry = () => {\n    setRetryTrigger((count) => count + 1)\n  }\n\n  const isInvalidPageError = error === 'Please open a valid video page first.'\n  const isClientConnectionError = Boolean(error?.includes('Client connection failed'))\n  const errorTitle = isInvalidPageError\n    ? 'Open a video page'\n    : isClientConnectionError\n      ? 'Connect the VidBee app'\n      : 'Something went wrong'\n  const errorDescription = isInvalidPageError\n    ? 'Navigate to a supported video page, then try again.'\n    : isClientConnectionError\n      ? 'The extension needs the VidBee desktop app to be running.'\n      : 'Try again in a moment.'\n\n  const renderStatus = () => {\n    if (loading) {\n      return (\n        <span className=\"status-indicator\">\n          <div className=\"status-dot loading\" /> Working\n        </span>\n      )\n    }\n    if (error) {\n      return (\n        <span className=\"status-indicator\">\n          <div className=\"status-dot error\" /> Error\n        </span>\n      )\n    }\n    if (info) {\n      return (\n        <span className=\"status-indicator\">\n          <div className=\"status-dot ok\" /> Ready\n        </span>\n      )\n    }\n    return (\n      <span className=\"status-indicator\">\n        <div className=\"status-dot\" /> Idle\n      </span>\n    )\n  }\n\n  return (\n    <div className=\"app\">\n      <header>\n        <h1>VidBee</h1>\n        {renderStatus()}\n      </header>\n      {loading && (\n        <div className=\"loading-container\">\n          <div className=\"spinner\" />\n          <div className=\"loading-text\">Analyzing video...</div>\n        </div>\n      )}\n\n      {!loading && error && (\n        <div className=\"error-container\">\n          <div className=\"error-header\">\n            <h2 className=\"error-title\">{errorTitle}</h2>\n            <p className=\"error-description\">{errorDescription}</p>\n          </div>\n          <div className=\"error-banner\">{error}</div>\n          {isClientConnectionError ? (\n            <div className=\"action-grid\">\n              <div className=\"action-card\">\n                <p className=\"action-title\">Client installed</p>\n                <p className=\"action-text\">\n                  Start VidBee and keep it running, then we will retry automatically.\n                </p>\n                <button\n                  className=\"secondary-button\"\n                  onClick={handleOpenClientAndRetry}\n                  type=\"button\"\n                >\n                  Open Client\n                </button>\n              </div>\n              <div className=\"action-card\">\n                <p className=\"action-title\">Need the app?</p>\n                <p className=\"action-text\">\n                  Download VidBee once, install it, then come back here to try again.\n                </p>\n                <a\n                  className=\"secondary-button\"\n                  href=\"https://vidbee.app\"\n                  rel=\"noopener noreferrer\"\n                  target=\"_blank\"\n                >\n                  Download VidBee\n                </a>\n              </div>\n            </div>\n          ) : (\n            <div className=\"action-card\">\n              <p className=\"action-title\">Try again</p>\n              <p className=\"action-text\">\n                {isInvalidPageError\n                  ? 'Open a supported video page, then retry.'\n                  : 'Retry after a moment.'}\n              </p>\n              <button className=\"secondary-button\" onClick={handleRetry} type=\"button\">\n                Retry\n              </button>\n            </div>\n          )}\n        </div>\n      )}\n\n      {!(loading || error) && info && (\n        <>\n          <section className=\"video-info\">\n            <div className=\"video-details\">\n              <h2>{info.title || 'Untitled video'}</h2>\n              <div className=\"meta-row\">\n                <span>{formatDuration(info.duration)}</span>\n                <span>•</span>\n                <span>{formats.length} formats</span>\n              </div>\n            </div>\n            {info.thumbnail && <img alt=\"\" className=\"thumbnail\" src={info.thumbnail} />}\n          </section>\n\n          <button className=\"primary-button\" onClick={handleOpenClient} type=\"button\">\n            Download with VidBee\n          </button>\n\n          <section className=\"formats-section\">\n            {groupedVideoFormats.map((group) => (\n              <div className=\"format-group\" key={group.label}>\n                <div className=\"group-title sticky-title\">{group.label}</div>\n                <table className=\"format-table\">\n                  <thead>\n                    <tr>\n                      <th className=\"col-id\">ID</th>\n                      <th className=\"col-ext\">Ext</th>\n                      <th className=\"col-size\">Size</th>\n                    </tr>\n                  </thead>\n                  <tbody>\n                    {group.formats.map((f) => (\n                      <tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>\n                        <td className=\"col-id\">{f.format_id || '-'}</td>\n                        <td className=\"col-ext\">{f.ext || '-'}</td>\n                        <td className=\"col-size\">{formatBytes(f.filesize || f.filesize_approx)}</td>\n                      </tr>\n                    ))}\n                  </tbody>\n                </table>\n              </div>\n            ))}\n\n            {groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (\n              <div className=\"empty-state\">No compatible formats.</div>\n            )}\n\n            {groupedFormats.audio.length > 0 && (\n              <div className=\"format-group\">\n                <div className=\"group-title\">Audio Only</div>\n                <table className=\"format-table\">\n                  <thead>\n                    <tr>\n                      <th className=\"col-id\">ID</th>\n                      <th className=\"col-ext\">Ext</th>\n                      <th className=\"col-size\">Size</th>\n                    </tr>\n                  </thead>\n                  <tbody>\n                    {groupedFormats.audio.map((f) => (\n                      <tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>\n                        <td className=\"col-id\">{f.format_id || '-'}</td>\n                        <td className=\"col-ext\">{f.ext || '-'}</td>\n                        <td className=\"col-size\">{formatBytes(f.filesize || f.filesize_approx)}</td>\n                      </tr>\n                    ))}\n                  </tbody>\n                </table>\n              </div>\n            )}\n          </section>\n        </>\n      )}\n    </div>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "apps/extension/entrypoints/popup/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Default Popup Title</title>\n    <meta name=\"manifest.type\" content=\"browser_action\">\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"./main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/extension/entrypoints/popup/main.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App.tsx'\n\nconst root = document.getElementById('root')\n\nif (root) {\n  ReactDOM.createRoot(root).render(\n    <React.StrictMode>\n      <App />\n    </React.StrictMode>\n  )\n}\n"
  },
  {
    "path": "apps/extension/package.json",
    "content": "{\n  \"name\": \"vidbee-extension\",\n  \"description\": \"manifest.json description\",\n  \"private\": true,\n  \"version\": \"0.0.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"wxt\",\n    \"dev:firefox\": \"wxt -b firefox\",\n    \"build\": \"wxt build\",\n    \"build:firefox\": \"wxt build -b firefox\",\n    \"zip\": \"wxt zip\",\n    \"zip:firefox\": \"wxt zip -b firefox\",\n    \"compile\": \"tsc --noEmit\",\n    \"postinstall\": \"wxt prepare\"\n  },\n  \"dependencies\": {\n    \"react\": \"^19.2.3\",\n    \"react-dom\": \"^19.2.3\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.2.7\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@wxt-dev/module-react\": \"^1.1.5\",\n    \"typescript\": \"^5.9.3\",\n    \"wxt\": \"^0.20.6\"\n  }\n}\n"
  },
  {
    "path": "apps/extension/public/_locales/en/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"VidBee Video Downloader\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"Download videos from over 1,000 websites with VidBee.\"\n  },\n  \"downloadWithVidBee\": {\n    \"message\": \"Download with VidBee\"\n  },\n  \"hideButton\": {\n    \"message\": \"Hide\"\n  }\n}\n"
  },
  {
    "path": "apps/extension/tsconfig.json",
    "content": "{\n  \"extends\": \"./.wxt/tsconfig.json\",\n  \"compilerOptions\": {\n    \"allowImportingTsExtensions\": true,\n    \"jsx\": \"react-jsx\",\n    \"strictNullChecks\": true\n  }\n}\n"
  },
  {
    "path": "apps/extension/wxt.config.ts",
    "content": "import { defineConfig } from 'wxt'\n\n// See https://wxt.dev/api/config.html\nexport default defineConfig({\n  modules: ['@wxt-dev/module-react'],\n  manifest: {\n    name: '__MSG_extensionName__',\n    description: '__MSG_extensionDescription__',\n    default_locale: 'en',\n    host_permissions: ['http://127.0.0.1/*'],\n    permissions: ['activeTab', 'storage']\n  }\n})\n"
  },
  {
    "path": "apps/web/Dockerfile",
    "content": "FROM node:22-alpine\n\nENV PNPM_HOME=/pnpm\nENV PATH=${PNPM_HOME}:${PATH}\n\nRUN corepack enable\n\nWORKDIR /app\n\nARG VITE_API_URL=http://localhost:3100\nENV VITE_API_URL=${VITE_API_URL}\n\nCOPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./\nCOPY apps/web/package.json apps/web/package.json\nCOPY packages/downloader-core/package.json packages/downloader-core/package.json\nCOPY packages/i18n/package.json packages/i18n/package.json\nCOPY packages/ui/package.json packages/ui/package.json\n\nRUN pnpm install --filter \"{./apps/web}...\" --frozen-lockfile --ignore-scripts\n\nCOPY apps/web apps/web\nCOPY packages/downloader-core packages/downloader-core\nCOPY packages/i18n packages/i18n\nCOPY packages/ui packages/ui\n\nEXPOSE 3000\n\nCMD [\"pnpm\", \"--filter\", \"./apps/web\", \"exec\", \"vite\", \"dev\", \"--host\", \"0.0.0.0\", \"--port\", \"3000\"]\n"
  },
  {
    "path": "apps/web/README.md",
    "content": "# VidBee Web\n\nTanStack Start client for the VidBee API.\n\n## Development\n\n```bash\npnpm run start:web\n```\n\n`start:web` is a root workspace script that starts both `apps/web` and `apps/api`.\n\n## Build\n\n```bash\npnpm --filter ./apps/web run build\n```\n"
  },
  {
    "path": "apps/web/biome.json",
    "content": "{\n\t\"$schema\": \"https://biomejs.dev/schemas/2.2.4/schema.json\",\n\t\"vcs\": {\n\t\t\"enabled\": false,\n\t\t\"clientKind\": \"git\",\n\t\t\"useIgnoreFile\": false\n\t},\n\t\"files\": {\n\t\t\"ignoreUnknown\": false,\n\t\t\"includes\": [\n\t\t\t\"**/src/**/*\",\n\t\t\t\"**/.vscode/**/*\",\n\t\t\t\"**/index.html\",\n\t\t\t\"**/vite.config.ts\",\n\t\t\t\"!**/src/routeTree.gen.ts\",\n\t\t\t\"!**/src/styles.css\"\n\t\t]\n\t},\n\t\"formatter\": {\n\t\t\"enabled\": true,\n\t\t\"indentStyle\": \"tab\"\n\t},\n\t\"assist\": { \"actions\": { \"source\": { \"organizeImports\": \"on\" } } },\n\t\"linter\": {\n\t\t\"enabled\": true,\n\t\t\"rules\": {\n\t\t\t\"recommended\": true\n\t\t}\n\t},\n\t\"javascript\": {\n\t\t\"formatter\": {\n\t\t\t\"quoteStyle\": \"double\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "apps/web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"imports\": {\n    \"#/*\": \"./src/*\"\n  },\n  \"scripts\": {\n    \"dev\": \"vite dev --port 3000\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest run\",\n    \"format\": \"biome format\",\n    \"lint\": \"biome lint\",\n    \"check\": \"biome check\"\n  },\n  \"dependencies\": {\n    \"@orpc/client\": \"^1.13.5\",\n    \"@orpc/contract\": \"^1.13.5\",\n    \"@tailwindcss/vite\": \"^4.1.18\",\n    \"@tanstack/react-devtools\": \"^0.7.0\",\n    \"@tanstack/react-router\": \"^1.132.0\",\n    \"@tanstack/react-router-devtools\": \"^1.132.0\",\n    \"@tanstack/react-router-ssr-query\": \"^1.131.7\",\n    \"@tanstack/react-start\": \"^1.132.0\",\n    \"@tanstack/router-plugin\": \"^1.132.0\",\n    \"@vidbee/downloader-core\": \"workspace:*\",\n    \"@vidbee/i18n\": \"workspace:*\",\n    \"@vidbee/ui\": \"workspace:*\",\n    \"flag-icons\": \"^7.5.0\",\n    \"i18next\": \"^25.5.3\",\n    \"lucide-react\": \"^0.545.0\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-i18next\": \"^16.0.0\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwindcss\": \"^4.1.18\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.2.4\",\n    \"@iconify/json\": \"^2.2.394\",\n    \"@svgr/plugin-jsx\": \"^8.1.0\",\n    \"@tanstack/devtools-vite\": \"^0.3.11\",\n    \"@testing-library/dom\": \"^10.4.0\",\n    \"@testing-library/react\": \"^16.2.0\",\n    \"@types/node\": \"^22.10.2\",\n    \"@types/react\": \"^19.2.0\",\n    \"@types/react-dom\": \"^19.2.0\",\n    \"@vitejs/plugin-react\": \"^5.0.4\",\n    \"jsdom\": \"^27.0.0\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"^5.7.2\",\n    \"unplugin-icons\": \"^22.4.2\",\n    \"vite\": \"^7.1.7\",\n    \"vite-tsconfig-paths\": \"^5.1.4\",\n    \"vitest\": \"^3.0.5\"\n  }\n}\n"
  },
  {
    "path": "apps/web/public/manifest.json",
    "content": "{\n  \"short_name\": \"TanStack App\",\n  \"name\": \"Create TanStack App Sample\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      \"type\": \"image/x-icon\"\n    },\n    {\n      \"src\": \"logo192.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\"\n    },\n    {\n      \"src\": \"logo512.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#000000\",\n  \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "apps/web/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "apps/web/src/components/download/download-dialog.tsx",
    "content": "import type {\n\tPlaylistInfo,\n\tVideoFormat,\n\tVideoInfo,\n} from \"@vidbee/downloader-core\";\nimport { AddUrlPopover } from \"@vidbee/ui/components/ui/add-url-popover\";\nimport { Button } from \"@vidbee/ui/components/ui/button\";\nimport { Checkbox } from \"@vidbee/ui/components/ui/checkbox\";\nimport { DownloadDialogLayout } from \"@vidbee/ui/components/ui/download-dialog-layout\";\nimport { Input } from \"@vidbee/ui/components/ui/input\";\nimport { Label } from \"@vidbee/ui/components/ui/label\";\nimport { useAddUrlInteraction } from \"@vidbee/ui/lib/use-add-url-interaction\";\nimport { FolderOpen, Loader2 } from \"lucide-react\";\nimport { useCallback, useEffect, useId, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { useWebDownloadSettings } from \"../../hooks/use-web-download-settings\";\nimport {\n\tbuildAudioFormatPreference,\n\tbuildVideoFormatPreference,\n} from \"../../lib/download-format-preferences\";\nimport { orpcClient } from \"../../lib/orpc-client\";\nimport { readOrpcDownloadSettings } from \"../../lib/orpc-download-settings\";\nimport { PlaylistDownload } from \"./playlist-download\";\nimport {\n\tSingleVideoDownload,\n\ttype SingleVideoState,\n} from \"./single-video-download\";\n\nconst isMuxedVideoFormat = (format: VideoFormat | undefined): boolean =>\n\tBoolean(\n\t\tformat?.vcodec &&\n\t\t\tformat.vcodec !== \"none\" &&\n\t\t\tformat.acodec &&\n\t\t\tformat.acodec !== \"none\",\n\t);\n\nconst resolvePreferredAudioExt = (\n\tvideoExt: string | undefined,\n): string | undefined => {\n\tif (!videoExt) {\n\t\treturn undefined;\n\t}\n\n\tconst normalizedExt = videoExt.toLowerCase();\n\tif (normalizedExt === \"mp4\") {\n\t\treturn \"m4a\";\n\t}\n\tif (normalizedExt === \"webm\") {\n\t\treturn \"webm\";\n\t}\n\treturn undefined;\n};\n\nconst buildSingleVideoFormatSelector = (\n\tformatId: string,\n\tformat: VideoFormat | undefined,\n): string => {\n\tif (!format || isMuxedVideoFormat(format)) {\n\t\treturn formatId;\n\t}\n\n\tconst preferredAudioExt = resolvePreferredAudioExt(format.ext);\n\tif (!preferredAudioExt) {\n\t\treturn `${formatId}+bestaudio`;\n\t}\n\n\t// Prefer same-container audio and keep a fallback when not available.\n\treturn `${formatId}+bestaudio[ext=${preferredAudioExt}]/${formatId}+bestaudio`;\n};\n\ninterface DownloadDialogProps {\n\tonDownloadsChanged?: () => Promise<void> | void;\n}\n\nexport function DownloadDialog({ onDownloadsChanged }: DownloadDialogProps) {\n\tconst { t } = useTranslation();\n\tconst [open, setOpen] = useState(false);\n\tconst [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);\n\tconst [loading, setLoading] = useState(false);\n\tconst [error, setError] = useState<string | null>(null);\n\tconst { settings, updateSettings } = useWebDownloadSettings();\n\n\tconst [url, setUrl] = useState(\"\");\n\tconst [activeTab, setActiveTab] = useState<\"single\" | \"playlist\">(\"single\");\n\n\tconst [singleVideoState, setSingleVideoState] = useState<SingleVideoState>({\n\t\ttitle: \"\",\n\t\tactiveTab: \"video\",\n\t\tselectedVideoFormat: \"\",\n\t\tselectedAudioFormat: \"\",\n\t\tselectedContainer: undefined,\n\t\tselectedCodec: undefined,\n\t\tselectedFps: undefined,\n\t});\n\n\tconst downloadTypeId = useId();\n\tconst advancedOptionsId = useId();\n\tconst [playlistUrl, setPlaylistUrl] = useState(\"\");\n\tconst [downloadType, setDownloadType] = useState<\"video\" | \"audio\">(\"video\");\n\tconst [startIndex, setStartIndex] = useState(\"1\");\n\tconst [endIndex, setEndIndex] = useState(\"\");\n\tconst [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null);\n\tconst [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false);\n\tconst [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false);\n\tconst [playlistPreviewError, setPlaylistPreviewError] = useState<\n\t\tstring | null\n\t>(null);\n\tconst playlistBusy = playlistPreviewLoading || playlistDownloadLoading;\n\tconst [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false);\n\tconst [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(\n\t\tnew Set(),\n\t);\n\tconst lockDialogHeight =\n\t\tactiveTab === \"playlist\" &&\n\t\t(playlistPreviewLoading || playlistInfo !== null);\n\n\tconst notifyDownloadsChanged = useCallback(async () => {\n\t\tif (!onDownloadsChanged) {\n\t\t\treturn;\n\t\t}\n\t\tawait onDownloadsChanged();\n\t}, [onDownloadsChanged]);\n\n\tconst computePlaylistRange = useCallback(\n\t\t(info: PlaylistInfo) => {\n\t\t\tconst parsedStart = Math.max(Number.parseInt(startIndex, 10) || 1, 1);\n\t\t\tconst rawEnd = endIndex\n\t\t\t\t? Math.max(Number.parseInt(endIndex, 10), parsedStart)\n\t\t\t\t: undefined;\n\t\t\tconst start =\n\t\t\t\tinfo.entryCount > 0\n\t\t\t\t\t? Math.min(parsedStart, info.entryCount)\n\t\t\t\t\t: parsedStart;\n\t\t\tconst endValue =\n\t\t\t\trawEnd !== undefined\n\t\t\t\t\t? info.entryCount > 0\n\t\t\t\t\t\t? Math.min(rawEnd, info.entryCount)\n\t\t\t\t\t\t: rawEnd\n\t\t\t\t\t: undefined;\n\t\t\treturn { start, end: endValue };\n\t\t},\n\t\t[startIndex, endIndex],\n\t);\n\n\tconst selectedPlaylistEntries = useMemo(() => {\n\t\tif (!playlistInfo) {\n\t\t\treturn [];\n\t\t}\n\t\tif (selectedEntryIds.size > 0) {\n\t\t\treturn playlistInfo.entries.filter((entry) =>\n\t\t\t\tselectedEntryIds.has(entry.id),\n\t\t\t);\n\t\t}\n\t\tconst range = computePlaylistRange(playlistInfo);\n\t\tconst previewEnd = range.end ?? playlistInfo.entryCount;\n\t\treturn playlistInfo.entries.filter(\n\t\t\t(entry) => entry.index >= range.start && entry.index <= previewEnd,\n\t\t);\n\t}, [playlistInfo, computePlaylistRange, selectedEntryIds]);\n\n\tconst fetchVideoInfo = useCallback(\n\t\tasync (targetUrl: string) => {\n\t\t\tconst trimmedUrl = targetUrl.trim();\n\t\t\tif (!trimmedUrl) {\n\t\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetLoading(true);\n\t\t\tsetError(null);\n\t\t\tsetVideoInfo(null);\n\n\t\t\ttry {\n\t\t\t\tconst result = await orpcClient.videoInfo({\n\t\t\t\t\turl: trimmedUrl,\n\t\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t\t});\n\t\t\t\tsetVideoInfo(result.video);\n\t\t\t} catch (fetchError) {\n\t\t\t\tconst message =\n\t\t\t\t\tfetchError instanceof Error && fetchError.message\n\t\t\t\t\t\t? fetchError.message\n\t\t\t\t\t\t: t(\"errors.fetchInfoFailed\");\n\t\t\t\tsetError(message);\n\t\t\t} finally {\n\t\t\t\tsetLoading(false);\n\t\t\t}\n\t\t},\n\t\t[t],\n\t);\n\n\tconst startOneClickDownload = useCallback(\n\t\tasync (\n\t\t\ttargetUrl: string,\n\t\t\toptions?: { clearInput?: boolean; setInputValue?: boolean },\n\t\t) => {\n\t\t\tconst trimmedUrl = targetUrl.trim();\n\t\t\tif (!trimmedUrl) {\n\t\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (options?.setInputValue) {\n\t\t\t\tsetUrl(trimmedUrl);\n\t\t\t}\n\n\t\t\tconst format =\n\t\t\t\tsettings.oneClickDownloadType === \"video\"\n\t\t\t\t\t? buildVideoFormatPreference(settings)\n\t\t\t\t\t: buildAudioFormatPreference(settings);\n\n\t\t\ttry {\n\t\t\t\tawait orpcClient.downloads.create({\n\t\t\t\t\turl: trimmedUrl,\n\t\t\t\t\ttype: settings.oneClickDownloadType,\n\t\t\t\t\tformat,\n\t\t\t\t\taudioFormat:\n\t\t\t\t\t\tsettings.oneClickDownloadType === \"audio\" ? \"mp3\" : undefined,\n\t\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t\t});\n\n\t\t\t\ttoast.success(t(\"download.oneClickDownloadStarted\"));\n\t\t\t\tawait notifyDownloadsChanged();\n\t\t\t\tif (options?.clearInput) {\n\t\t\t\t\tsetUrl(\"\");\n\t\t\t\t}\n\t\t\t} catch (startError) {\n\t\t\t\tconsole.error(\"Failed to start one-click download:\", startError);\n\t\t\t\ttoast.error(t(\"notifications.downloadFailed\"));\n\t\t\t}\n\t\t},\n\t\t[notifyDownloadsChanged, settings, t],\n\t);\n\n\tconst handleFetchVideo = useCallback(async () => {\n\t\tif (!url.trim()) {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\treturn;\n\t\t}\n\t\tsetSingleVideoState((prev) => ({\n\t\t\t...prev,\n\t\t\tselectedVideoFormat: \"\",\n\t\t\tselectedAudioFormat: \"\",\n\t\t\tselectedContainer: undefined,\n\t\t\tselectedCodec: undefined,\n\t\t\tselectedFps: undefined,\n\t\t}));\n\t\tawait fetchVideoInfo(url.trim());\n\t}, [url, fetchVideoInfo, t]);\n\n\tconst handleParsePlaylistUrl = useCallback(\n\t\tasync (trimmedUrl: string) => {\n\t\t\tsetOpen(true);\n\t\t\tsetPlaylistUrl(trimmedUrl);\n\t\t\tsetPlaylistInfo(null);\n\t\t\tsetPlaylistPreviewError(null);\n\t\t\tsetSelectedEntryIds(new Set());\n\n\t\t\tsetPlaylistPreviewError(null);\n\t\t\tsetPlaylistPreviewLoading(true);\n\t\t\ttry {\n\t\t\t\tconst info = await orpcClient.playlist.info({\n\t\t\t\t\turl: trimmedUrl,\n\t\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t\t});\n\t\t\t\tsetPlaylistInfo(info.playlist);\n\t\t\t\tif (info.playlist.entryCount === 0) {\n\t\t\t\t\ttoast.error(t(\"playlist.noEntries\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttoast.success(\n\t\t\t\t\tt(\"playlist.foundVideos\", { count: info.playlist.entryCount }),\n\t\t\t\t);\n\t\t\t} catch (fetchError) {\n\t\t\t\tconsole.error(\"Failed to fetch playlist info:\", fetchError);\n\t\t\t\tconst message =\n\t\t\t\t\tfetchError instanceof Error && fetchError.message\n\t\t\t\t\t\t? fetchError.message\n\t\t\t\t\t\t: t(\"playlist.previewFailed\");\n\t\t\t\tsetPlaylistPreviewError(message);\n\t\t\t\tsetPlaylistInfo(null);\n\t\t\t\ttoast.error(t(\"playlist.previewFailed\"));\n\t\t\t} finally {\n\t\t\t\tsetPlaylistPreviewLoading(false);\n\t\t\t}\n\t\t},\n\t\t[t],\n\t);\n\n\tconst handleParseSingleUrl = useCallback(\n\t\tasync (trimmedUrl: string) => {\n\t\t\tsetOpen(true);\n\t\t\tsetUrl(trimmedUrl);\n\t\t\tsetSingleVideoState((prev) => ({\n\t\t\t\t...prev,\n\t\t\t\tselectedVideoFormat: \"\",\n\t\t\t\tselectedAudioFormat: \"\",\n\t\t\t\tselectedContainer: undefined,\n\t\t\t\tselectedCodec: undefined,\n\t\t\t\tselectedFps: undefined,\n\t\t\t}));\n\t\t\tawait fetchVideoInfo(trimmedUrl);\n\t\t},\n\t\t[fetchVideoInfo],\n\t);\n\n\tconst handleOneClickFromAddUrl = useCallback(\n\t\tasync (trimmedUrl: string) => {\n\t\t\tawait startOneClickDownload(trimmedUrl, {\n\t\t\t\tsetInputValue: false,\n\t\t\t\tclearInput: false,\n\t\t\t});\n\t\t},\n\t\t[startOneClickDownload],\n\t);\n\n\tconst {\n\t\taddUrlPopoverOpen,\n\t\taddUrlValue,\n\t\tcanConfirmAddUrl,\n\t\thandleConfirmAddUrl,\n\t\thandleOpenAddUrlPopover,\n\t\thasAddUrlValue,\n\t\tsetAddUrlPopoverOpen,\n\t\tsetAddUrlValue,\n\t} = useAddUrlInteraction({\n\t\tactiveTab,\n\t\tisOneClickDownloadEnabled: settings.oneClickDownload,\n\t\tisPlaylistBusy: playlistBusy,\n\t\tonEmptyUrl: () => {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t},\n\t\tonInvalidUrl: () => {\n\t\t\ttoast.error(t(\"errors.invalidUrl\"));\n\t\t},\n\t\tonOneClickDownload: handleOneClickFromAddUrl,\n\t\tonParsePlaylist: handleParsePlaylistUrl,\n\t\tonParseSingle: handleParseSingleUrl,\n\t});\n\n\tconst handleOneClickDownload = useCallback(async () => {\n\t\tawait startOneClickDownload(url, { clearInput: true });\n\t\tsetOpen(false);\n\t}, [startOneClickDownload, url]);\n\n\tconst handlePreviewPlaylist = useCallback(async () => {\n\t\tif (!playlistUrl.trim()) {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\treturn;\n\t\t}\n\t\tsetPlaylistPreviewError(null);\n\t\tsetPlaylistPreviewLoading(true);\n\t\ttry {\n\t\t\tconst trimmedUrl = playlistUrl.trim();\n\t\t\tconst info = await orpcClient.playlist.info({\n\t\t\t\turl: trimmedUrl,\n\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t});\n\t\t\tsetPlaylistInfo(info.playlist);\n\t\t\tsetSelectedEntryIds(new Set());\n\t\t\tif (info.playlist.entryCount === 0) {\n\t\t\t\ttoast.error(t(\"playlist.noEntries\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttoast.success(\n\t\t\t\tt(\"playlist.foundVideos\", { count: info.playlist.entryCount }),\n\t\t\t);\n\t\t} catch (fetchError) {\n\t\t\tconsole.error(\"Failed to fetch playlist info:\", fetchError);\n\t\t\tconst message =\n\t\t\t\tfetchError instanceof Error && fetchError.message\n\t\t\t\t\t? fetchError.message\n\t\t\t\t\t: t(\"playlist.previewFailed\");\n\t\t\tsetPlaylistPreviewError(message);\n\t\t\tsetPlaylistInfo(null);\n\t\t\ttoast.error(t(\"playlist.previewFailed\"));\n\t\t} finally {\n\t\t\tsetPlaylistPreviewLoading(false);\n\t\t}\n\t}, [playlistUrl, t]);\n\n\tconst handleDownloadPlaylist = useCallback(async () => {\n\t\tconst trimmedUrl = playlistUrl.trim();\n\t\tif (!trimmedUrl) {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\treturn;\n\t\t}\n\n\t\tif (!playlistInfo) {\n\t\t\ttoast.error(t(\"playlist.previewRequired\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetPlaylistPreviewError(null);\n\t\tsetPlaylistDownloadLoading(true);\n\t\ttry {\n\t\t\tlet start: number | undefined;\n\t\t\tlet end: number | undefined;\n\t\t\tlet entryIds: string[] | undefined;\n\n\t\t\tif (selectedEntryIds.size > 0) {\n\t\t\t\tconst selectedEntries = playlistInfo.entries\n\t\t\t\t\t.filter((entry) => selectedEntryIds.has(entry.id))\n\t\t\t\t\t.sort((a, b) => a.index - b.index);\n\t\t\t\tconst selectedIndices = selectedEntries\n\t\t\t\t\t.map((entry) => entry.index)\n\t\t\t\t\t.sort((a, b) => a - b);\n\n\t\t\t\tif (selectedEntries.length === 0) {\n\t\t\t\t\ttoast.error(t(\"playlist.noEntriesSelected\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tentryIds = selectedEntries.map((entry) => entry.id);\n\t\t\t\tstart = selectedIndices[0];\n\t\t\t\tend = selectedIndices.at(-1);\n\t\t\t} else {\n\t\t\t\tconst range = computePlaylistRange(playlistInfo);\n\t\t\t\tconst previewEnd = range.end ?? playlistInfo.entryCount;\n\n\t\t\t\tif (previewEnd < range.start || previewEnd === 0) {\n\t\t\t\t\ttoast.error(t(\"playlist.noEntriesInRange\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tstart = range.start;\n\t\t\t\tend = range.end;\n\t\t\t}\n\n\t\t\tconst format =\n\t\t\t\tdownloadType === \"video\"\n\t\t\t\t\t? buildVideoFormatPreference(settings)\n\t\t\t\t\t: buildAudioFormatPreference(settings);\n\n\t\t\tconst result = await orpcClient.playlist.download({\n\t\t\t\turl: trimmedUrl,\n\t\t\t\ttype: downloadType,\n\t\t\t\tformat,\n\t\t\t\taudioFormat: downloadType === \"audio\" ? \"mp3\" : undefined,\n\t\t\t\tstartIndex: start,\n\t\t\t\tendIndex: end,\n\t\t\t\tentryIds,\n\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t});\n\n\t\t\tif (result.result.totalCount === 0) {\n\t\t\t\ttoast.error(t(\"playlist.noEntriesInRange\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tawait notifyDownloadsChanged();\n\t\t\tsetOpen(false);\n\t\t} catch (startError) {\n\t\t\tconsole.error(\"Failed to start playlist download:\", startError);\n\t\t\ttoast.error(t(\"playlist.downloadFailed\"));\n\t\t} finally {\n\t\t\tsetPlaylistDownloadLoading(false);\n\t\t}\n\t}, [\n\t\tplaylistUrl,\n\t\tplaylistInfo,\n\t\tselectedEntryIds,\n\t\tcomputePlaylistRange,\n\t\tdownloadType,\n\t\tsettings,\n\t\tnotifyDownloadsChanged,\n\t\tt,\n\t]);\n\n\tuseEffect(() => {\n\t\tif (videoInfo) {\n\t\t\tsetSingleVideoState((prev) => ({\n\t\t\t\t...prev,\n\t\t\t\ttitle: videoInfo.title || prev.title,\n\t\t\t}));\n\t\t}\n\t}, [videoInfo]);\n\n\tconst handleSingleVideoDownload = useCallback(async () => {\n\t\tif (!videoInfo) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst type = singleVideoState.activeTab;\n\t\tconst selectedFormat =\n\t\t\ttype === \"video\"\n\t\t\t\t? singleVideoState.selectedVideoFormat\n\t\t\t\t: singleVideoState.selectedAudioFormat;\n\t\tif (!selectedFormat) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst selectedVideoFormat =\n\t\t\ttype === \"video\"\n\t\t\t\t? (videoInfo.formats || []).find(\n\t\t\t\t\t\t(format) => format.formatId === selectedFormat,\n\t\t\t\t\t)\n\t\t\t\t: undefined;\n\t\tconst resolvedFormat =\n\t\t\ttype === \"video\"\n\t\t\t\t? buildSingleVideoFormatSelector(selectedFormat, selectedVideoFormat)\n\t\t\t\t: selectedFormat;\n\n\t\tconst targetUrl = videoInfo.webpageUrl || url.trim();\n\t\tif (!targetUrl) {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait orpcClient.downloads.create({\n\t\t\t\turl: targetUrl,\n\t\t\t\ttype,\n\t\t\t\ttitle: singleVideoState.title || videoInfo.title,\n\t\t\t\tthumbnail: videoInfo.thumbnail,\n\t\t\t\tduration: videoInfo.duration,\n\t\t\t\tdescription: videoInfo.description,\n\t\t\t\tuploader: videoInfo.uploader,\n\t\t\t\tviewCount: videoInfo.viewCount,\n\t\t\t\ttags: videoInfo.tags,\n\t\t\t\tselectedFormat: selectedVideoFormat,\n\t\t\t\tformat: resolvedFormat || undefined,\n\t\t\t\taudioFormat: type === \"audio\" ? \"mp3\" : undefined,\n\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t});\n\n\t\t\tawait notifyDownloadsChanged();\n\t\t\tsetOpen(false);\n\t\t} catch (startError) {\n\t\t\tconsole.error(\"Failed to start download:\", startError);\n\t\t\ttoast.error(t(\"notifications.downloadFailed\"));\n\t\t}\n\t}, [notifyDownloadsChanged, singleVideoState, t, url, videoInfo]);\n\n\tuseEffect(() => {\n\t\tif (!open) {\n\t\t\tsetUrl(\"\");\n\t\t\tsetError(null);\n\t\t\tsetLoading(false);\n\t\t\tsetVideoInfo(null);\n\t\t\tsetActiveTab(\"single\");\n\t\t\tsetSingleVideoState({\n\t\t\t\ttitle: \"\",\n\t\t\t\tactiveTab: \"video\",\n\t\t\t\tselectedVideoFormat: \"\",\n\t\t\t\tselectedAudioFormat: \"\",\n\t\t\t\tselectedContainer: undefined,\n\t\t\t\tselectedCodec: undefined,\n\t\t\t\tselectedFps: undefined,\n\t\t\t});\n\n\t\t\tsetPlaylistUrl(\"\");\n\t\t\tsetPlaylistInfo(null);\n\t\t\tsetPlaylistPreviewError(null);\n\t\t\tsetStartIndex(\"1\");\n\t\t\tsetEndIndex(\"\");\n\t\t\tsetSelectedEntryIds(new Set());\n\t\t}\n\t}, [open]);\n\n\tconst handleSingleVideoStateChange = useCallback(\n\t\t(updates: Partial<SingleVideoState>) => {\n\t\t\tsetSingleVideoState((prev) => ({ ...prev, ...updates }));\n\t\t},\n\t\t[],\n\t);\n\n\tconst selectedSingleFormat =\n\t\tsingleVideoState.activeTab === \"video\"\n\t\t\t? singleVideoState.selectedVideoFormat\n\t\t\t: singleVideoState.selectedAudioFormat;\n\n\treturn (\n\t\t<DownloadDialogLayout\n\t\t\tactiveTab={activeTab}\n\t\t\taddUrlPopover={\n\t\t\t\t<AddUrlPopover\n\t\t\t\t\tcancelLabel={t(\"download.cancel\")}\n\t\t\t\t\tconfirmDisabled={!canConfirmAddUrl}\n\t\t\t\t\tconfirmLabel={t(\"download.fetch\")}\n\t\t\t\t\tinvalidMessage={\n\t\t\t\t\t\thasAddUrlValue && !canConfirmAddUrl\n\t\t\t\t\t\t\t? t(\"errors.invalidUrl\")\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tonCancel={() => {\n\t\t\t\t\t\tsetAddUrlPopoverOpen(false);\n\t\t\t\t\t}}\n\t\t\t\t\tonConfirm={() => {\n\t\t\t\t\t\tvoid handleConfirmAddUrl();\n\t\t\t\t\t}}\n\t\t\t\t\tonOpenChange={setAddUrlPopoverOpen}\n\t\t\t\t\tonTriggerClick={() => {\n\t\t\t\t\t\tvoid handleOpenAddUrlPopover();\n\t\t\t\t\t}}\n\t\t\t\t\tonValueChange={setAddUrlValue}\n\t\t\t\t\topen={addUrlPopoverOpen}\n\t\t\t\t\tplaceholder={t(\"download.urlPlaceholder\")}\n\t\t\t\t\ttitle={t(\"download.enterUrl\")}\n\t\t\t\t\ttriggerLabel={t(\"download.pasteUrlButton\")}\n\t\t\t\t\tvalue={addUrlValue}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tfooter={\n\t\t\t\t<div className=\"flex w-full items-center justify-between gap-3\">\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t{activeTab === \"playlist\" &&\n\t\t\t\t\t\t\t!playlistInfo &&\n\t\t\t\t\t\t\t!playlistPreviewLoading && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\tchecked={advancedOptionsOpen}\n\t\t\t\t\t\t\t\t\t\tid={advancedOptionsId}\n\t\t\t\t\t\t\t\t\t\tonCheckedChange={(checked) => {\n\t\t\t\t\t\t\t\t\t\t\tsetAdvancedOptionsOpen(checked === true);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<Label\n\t\t\t\t\t\t\t\t\t\tclassName=\"cursor-pointer text-xs\"\n\t\t\t\t\t\t\t\t\t\thtmlFor={advancedOptionsId}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t(\"advancedOptions.title\")}\n\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{activeTab === \"single\" && !videoInfo && !loading && (\n\t\t\t\t\t\t\t<div className=\"relative w-[320px]\">\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tclassName=\"h-8 pr-8 text-xs\"\n\t\t\t\t\t\t\t\t\tonChange={(event) => setUrl(event.target.value)}\n\t\t\t\t\t\t\t\t\tplaceholder={t(\"download.urlPlaceholder\")}\n\t\t\t\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div className=\"absolute top-1/2 right-1 -translate-y-1/2\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName=\"h-6 w-6\"\n\t\t\t\t\t\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\t\t\t\t\t\tif (!navigator.clipboard?.readText) {\n\t\t\t\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\t\t\tconst clipboardText =\n\t\t\t\t\t\t\t\t\t\t\t\t\tawait navigator.clipboard.readText();\n\t\t\t\t\t\t\t\t\t\t\t\tif (clipboardText.trim()) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetUrl(clipboardText.trim());\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t\t\t\t\t// ignore\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<FolderOpen className=\"h-3 w-3 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"ml-auto flex gap-2\">\n\t\t\t\t\t\t{activeTab === \"single\" ? (\n\t\t\t\t\t\t\tvideoInfo || loading ? (\n\t\t\t\t\t\t\t\t!loading && videoInfo ? (\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tdisabled={loading || !selectedSingleFormat}\n\t\t\t\t\t\t\t\t\t\tonClick={handleSingleVideoDownload}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{singleVideoState.activeTab === \"video\"\n\t\t\t\t\t\t\t\t\t\t\t? t(\"download.downloadVideo\")\n\t\t\t\t\t\t\t\t\t\t\t: t(\"download.downloadAudio\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t) : null\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tdisabled={loading || !url.trim()}\n\t\t\t\t\t\t\t\t\tonClick={\n\t\t\t\t\t\t\t\t\t\tsettings.oneClickDownload\n\t\t\t\t\t\t\t\t\t\t\t? handleOneClickDownload\n\t\t\t\t\t\t\t\t\t\t\t: handleFetchVideo\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{settings.oneClickDownload\n\t\t\t\t\t\t\t\t\t\t? t(\"download.oneClickDownloadNow\")\n\t\t\t\t\t\t\t\t\t\t: t(\"download.startDownload\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : playlistInfo && !playlistPreviewLoading ? (\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tdisabled={\n\t\t\t\t\t\t\t\t\tplaylistDownloadLoading ||\n\t\t\t\t\t\t\t\t\tselectedPlaylistEntries.length === 0\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonClick={handleDownloadPlaylist}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{playlistDownloadLoading ? (\n\t\t\t\t\t\t\t\t\t<Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\tt(\"playlist.downloadCurrentRange\")\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t) : playlistPreviewLoading ? null : (\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tdisabled={playlistBusy || !playlistUrl.trim()}\n\t\t\t\t\t\t\t\tonClick={handlePreviewPlaylist}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{playlistPreviewLoading ? (\n\t\t\t\t\t\t\t\t\t<Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\tt(\"download.startDownload\")\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t\tlockDialogHeight={lockDialogHeight}\n\t\t\toneClickDownloadEnabled={settings.oneClickDownload}\n\t\t\toneClickTooltip={t(\"download.oneClickDownloadTooltip\")}\n\t\t\tonActiveTabChange={setActiveTab}\n\t\t\tonOpenChange={setOpen}\n\t\t\tonToggleOneClickDownload={() => {\n\t\t\t\tupdateSettings({\n\t\t\t\t\toneClickDownload: !settings.oneClickDownload,\n\t\t\t\t});\n\t\t\t}}\n\t\t\topen={open}\n\t\t\tplaylistTabContent={\n\t\t\t\t<PlaylistDownload\n\t\t\t\t\tadvancedOptionsOpen={advancedOptionsOpen}\n\t\t\t\t\tdownloadType={downloadType}\n\t\t\t\t\tdownloadTypeId={downloadTypeId}\n\t\t\t\t\tendIndex={endIndex}\n\t\t\t\t\tplaylistBusy={playlistBusy}\n\t\t\t\t\tplaylistInfo={playlistInfo}\n\t\t\t\t\tplaylistPreviewError={playlistPreviewError}\n\t\t\t\t\tplaylistPreviewLoading={playlistPreviewLoading}\n\t\t\t\t\tselectedEntryIds={selectedEntryIds}\n\t\t\t\t\tselectedPlaylistEntries={selectedPlaylistEntries}\n\t\t\t\t\tsetDownloadType={setDownloadType}\n\t\t\t\t\tsetEndIndex={setEndIndex}\n\t\t\t\t\tsetSelectedEntryIds={setSelectedEntryIds}\n\t\t\t\t\tsetStartIndex={setStartIndex}\n\t\t\t\t\tstartIndex={startIndex}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tplaylistTabLabel={t(\"download.metadata.playlist\")}\n\t\t\tsingleTabContent={\n\t\t\t\t<SingleVideoDownload\n\t\t\t\t\terror={error}\n\t\t\t\t\tfeedbackSourceUrl={url}\n\t\t\t\t\tloading={loading}\n\t\t\t\t\tonStateChange={handleSingleVideoStateChange}\n\t\t\t\t\toneClickQuality={settings.oneClickQuality}\n\t\t\t\t\tstate={singleVideoState}\n\t\t\t\t\tvideoInfo={videoInfo}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tsingleTabLabel={t(\"download.singleVideo\")}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "apps/web/src/components/download/download-item.tsx",
    "content": "import {\n\tbuildFilePathCandidates,\n\tnormalizeSavedFileName,\n} from \"@vidbee/downloader-core/download-file\";\nimport { Badge } from \"@vidbee/ui/components/ui/badge\";\nimport { Button } from \"@vidbee/ui/components/ui/button\";\nimport { Checkbox } from \"@vidbee/ui/components/ui/checkbox\";\nimport {\n\tContextMenu,\n\tContextMenuContent,\n\tContextMenuItem,\n\tContextMenuSeparator,\n\tContextMenuTrigger,\n} from \"@vidbee/ui/components/ui/context-menu\";\nimport {\n\tDOWNLOAD_FEEDBACK_ISSUE_TITLE,\n\tFeedbackLinkButtons,\n} from \"@vidbee/ui/components/ui/feedback-link-buttons\";\nimport { Progress } from \"@vidbee/ui/components/ui/progress\";\nimport { RemoteImage } from \"@vidbee/ui/components/ui/remote-image\";\nimport {\n\tSheet,\n\tSheetContent,\n\tSheetDescription,\n\tSheetHeader,\n\tSheetTitle,\n} from \"@vidbee/ui/components/ui/sheet\";\nimport {\n\tTabs,\n\tTabsContent,\n\tTabsList,\n\tTabsTrigger,\n} from \"@vidbee/ui/components/ui/tabs\";\nimport {\n\tTooltip,\n\tTooltipContent,\n\tTooltipTrigger,\n} from \"@vidbee/ui/components/ui/tooltip\";\nimport {\n\tAlertCircle,\n\tCheckCircle2,\n\tCopy,\n\tFile,\n\tFolderOpen,\n\tLoader2,\n\tRotateCw,\n\tTrash2,\n\tX,\n} from \"lucide-react\";\nimport {\n\ttype KeyboardEvent,\n\ttype ReactNode,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { orpcClient } from \"../../lib/orpc-client\";\nimport { resolveImageProxyUrl } from \"../../lib/remote-image-proxy\";\nimport { readWebSettings } from \"../../lib/web-settings\";\nimport type { DownloadRecord } from \"./types\";\n\ninterface DownloadItemProps {\n\tdownload: DownloadRecord;\n\tisSelected?: boolean;\n\tonToggleSelect?: (id: string) => void;\n\tonCancel?: (id: string) => void;\n\tonRetry?: (download: DownloadRecord) => void;\n\tonRemove?: (id: string) => void;\n\tonCopyUrl?: (url: string) => void;\n}\n\ninterface MetadataDetail {\n\tlabel: string;\n\tvalue: ReactNode;\n}\n\nconst formatFileSize = (bytes?: number) => {\n\tif (!bytes) {\n\t\treturn \"\";\n\t}\n\tconst sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n\tconst order = Math.min(\n\t\tMath.floor(Math.log(bytes) / Math.log(1024)),\n\t\tsizes.length - 1,\n\t);\n\treturn `${(bytes / 1024 ** order).toFixed(1)} ${sizes[order]}`;\n};\n\nconst formatDuration = (seconds?: number) => {\n\tif (!seconds) {\n\t\treturn \"\";\n\t}\n\tconst h = Math.floor(seconds / 3600);\n\tconst m = Math.floor((seconds % 3600) / 60);\n\tconst s = Math.floor(seconds % 60);\n\n\tif (h > 0) {\n\t\treturn `${h}:${m.toString().padStart(2, \"0\")}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n};\n\nconst formatDate = (timestamp?: number) => {\n\tif (!timestamp) {\n\t\treturn \"\";\n\t}\n\treturn new Date(timestamp).toLocaleString();\n};\n\nconst formatDateShort = (timestamp?: number) => {\n\tif (!timestamp) {\n\t\treturn \"\";\n\t}\n\tconst date = new Date(timestamp);\n\treturn date.toLocaleString(undefined, {\n\t\tmonth: \"numeric\",\n\t\tday: \"numeric\",\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t});\n};\n\nconst getQualityLabel = (download: DownloadRecord): string | undefined => {\n\tconst format = download.selectedFormat;\n\tif (!format) {\n\t\treturn undefined;\n\t}\n\tif (format.height) {\n\t\treturn `${format.height}p${format.fps === 60 ? \"60\" : \"\"}`;\n\t}\n\tif (format.formatNote) {\n\t\treturn format.formatNote;\n\t}\n\tif (typeof format.quality === \"number\") {\n\t\treturn format.quality.toString();\n\t}\n\treturn undefined;\n};\n\nconst getFormatLabel = (download: DownloadRecord): string | undefined => {\n\tif (download.selectedFormat?.ext) {\n\t\treturn download.selectedFormat.ext.toUpperCase();\n\t}\n\tconst savedExt = normalizeSavedFileName(download.savedFileName)\n\t\t?.split(\".\")\n\t\t.pop()\n\t\t?.toUpperCase();\n\treturn savedExt;\n};\n\nconst sanitizeCodec = (codec?: string | null): string | undefined => {\n\tif (!codec || codec === \"none\") {\n\t\treturn undefined;\n\t}\n\treturn codec;\n};\n\nconst getCodecLabel = (download: DownloadRecord): string | undefined => {\n\tconst format = download.selectedFormat;\n\tif (!format) {\n\t\treturn undefined;\n\t}\n\tif (download.type === \"audio\") {\n\t\treturn sanitizeCodec(format.acodec);\n\t}\n\treturn sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec);\n};\n\nconst getStatusText = (\n\tstatus: DownloadRecord[\"status\"],\n\tt: (key: string) => string,\n): string => {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn t(\"download.completed\");\n\t\tcase \"error\":\n\t\t\treturn t(\"download.error\");\n\t\tcase \"downloading\":\n\t\t\treturn t(\"download.downloading\");\n\t\tcase \"processing\":\n\t\t\treturn t(\"download.processing\");\n\t\tcase \"pending\":\n\t\t\treturn t(\"download.downloadPending\");\n\t\tcase \"cancelled\":\n\t\t\treturn t(\"download.cancelled\");\n\t\tdefault:\n\t\t\treturn \"\";\n\t}\n};\n\nconst getStatusIcon = (status: DownloadRecord[\"status\"]) => {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn <CheckCircle2 className=\"h-4 w-4 text-green-500\" />;\n\t\tcase \"error\":\n\t\t\treturn <AlertCircle className=\"h-4 w-4 text-destructive\" />;\n\t\tcase \"downloading\":\n\t\tcase \"processing\":\n\t\t\treturn <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />;\n\t\tcase \"pending\":\n\t\t\treturn <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />;\n\t\tcase \"cancelled\":\n\t\t\treturn <X className=\"h-4 w-4 text-muted-foreground\" />;\n\t\tdefault:\n\t\t\treturn null;\n\t}\n};\n\nconst isActiveStatus = (status: DownloadRecord[\"status\"]): boolean =>\n\tstatus === \"downloading\" || status === \"processing\" || status === \"pending\";\n\nconst resolveDownloadExtension = (download: DownloadRecord): string => {\n\tconst savedExt = normalizeSavedFileName(download.savedFileName)\n\t\t?.split(\".\")\n\t\t.pop();\n\tif (savedExt) {\n\t\treturn savedExt.toLowerCase();\n\t}\n\tconst selectedExt = download.selectedFormat?.ext?.toLowerCase();\n\tif (selectedExt) {\n\t\treturn selectedExt;\n\t}\n\treturn download.type === \"audio\" ? \"mp3\" : \"mp4\";\n};\n\nexport function DownloadItem({\n\tdownload,\n\tisSelected = false,\n\tonToggleSelect,\n\tonCancel,\n\tonRetry,\n\tonRemove,\n\tonCopyUrl,\n}: DownloadItemProps) {\n\tconst { t } = useTranslation();\n\tconst isHistory = download.entryType === \"history\";\n\tconst timestamp =\n\t\tdownload.completedAt ?? download.startedAt ?? download.createdAt;\n\tconst qualityLabel = getQualityLabel(download);\n\tconst statusIcon = getStatusIcon(download.status);\n\tconst statusText = getStatusText(download.status, t);\n\tconst resolvedExtension = resolveDownloadExtension(download);\n\n\tconst [fileExists, setFileExists] = useState(false);\n\tconst [resolvedFilePath, setResolvedFilePath] = useState<string | null>(null);\n\tconst [sheetOpen, setSheetOpen] = useState(false);\n\tconst [activeTab, setActiveTab] = useState<\"details\" | \"logs\">(\"details\");\n\tconst [pendingTab, setPendingTab] = useState<\"details\" | \"logs\" | null>(null);\n\tconst [logAutoScroll, setLogAutoScroll] = useState(true);\n\tconst [isContextMenuOpen, setIsContextMenuOpen] = useState(false);\n\n\tconst logContainerRef = useRef<HTMLDivElement | null>(null);\n\tconst lastSheetOpenRef = useRef(false);\n\n\tconst getEffectiveDownloadPath = (): string => {\n\t\tconst rowPath = download.downloadPath?.trim();\n\t\tif (rowPath) {\n\t\t\treturn rowPath;\n\t\t}\n\t\treturn readWebSettings().downloadPath.trim();\n\t};\n\n\tconst getFilePathCandidates = (): string[] => {\n\t\tconst downloadPath = getEffectiveDownloadPath();\n\t\tif (!downloadPath) {\n\t\t\treturn [];\n\t\t}\n\t\treturn buildFilePathCandidates(\n\t\t\tdownloadPath,\n\t\t\tdownload.title ?? \"\",\n\t\t\tresolvedExtension,\n\t\t\tdownload.savedFileName,\n\t\t);\n\t};\n\n\tconst findExistingFilePath = async (): Promise<string | null> => {\n\t\tconst candidates = getFilePathCandidates();\n\t\tfor (const filePath of candidates) {\n\t\t\ttry {\n\t\t\t\tconst result = await orpcClient.files.exists({ path: filePath });\n\t\t\t\tif (result.exists) {\n\t\t\t\t\treturn filePath;\n\t\t\t\t}\n\t\t\t} catch {}\n\t\t}\n\t\treturn null;\n\t};\n\n\tconst tryFileOperation = async (\n\t\toperation: (filePath: string) => Promise<boolean>,\n\t): Promise<{ filePath?: string; success: boolean }> => {\n\t\tconst seen = new Set<string>();\n\t\tconst orderedCandidates = [\n\t\t\tresolvedFilePath,\n\t\t\t...getFilePathCandidates(),\n\t\t].filter((filePath): filePath is string => {\n\t\t\tif (!filePath) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (seen.has(filePath)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tseen.add(filePath);\n\t\t\treturn true;\n\t\t});\n\n\t\tfor (const filePath of orderedCandidates) {\n\t\t\ttry {\n\t\t\t\tconst success = await operation(filePath);\n\t\t\t\tif (success) {\n\t\t\t\t\treturn { success: true, filePath };\n\t\t\t\t}\n\t\t\t} catch {}\n\t\t}\n\n\t\treturn { success: false };\n\t};\n\n\tuseEffect(() => {\n\t\tlet isMounted = true;\n\n\t\tconst checkFileExists = async () => {\n\t\t\tconst downloadPath =\n\t\t\t\tdownload.downloadPath?.trim() || readWebSettings().downloadPath.trim();\n\t\t\tif (!downloadPath) {\n\t\t\t\tif (!isMounted) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetFileExists(false);\n\t\t\t\tsetResolvedFilePath(null);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst candidates = buildFilePathCandidates(\n\t\t\t\tdownloadPath,\n\t\t\t\tdownload.title ?? \"\",\n\t\t\t\tresolvedExtension,\n\t\t\t\tdownload.savedFileName,\n\t\t\t);\n\n\t\t\tlet existingFilePath: string | null = null;\n\t\t\tfor (const filePath of candidates) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await orpcClient.files.exists({ path: filePath });\n\t\t\t\t\tif (result.exists) {\n\t\t\t\t\t\texistingFilePath = filePath;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t} catch {}\n\t\t\t}\n\n\t\t\tif (!isMounted) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetFileExists(Boolean(existingFilePath));\n\t\t\tsetResolvedFilePath(existingFilePath);\n\t\t};\n\n\t\tvoid checkFileExists();\n\n\t\treturn () => {\n\t\t\tisMounted = false;\n\t\t};\n\t}, [\n\t\tdownload.title,\n\t\tdownload.downloadPath,\n\t\tdownload.savedFileName,\n\t\tresolvedExtension,\n\t]);\n\n\tconst handleCancel = () => {\n\t\tonCancel?.(download.id);\n\t};\n\n\tconst handleRetryDownload = () => {\n\t\tonRetry?.(download);\n\t};\n\n\tconst handleOpenFolder = async () => {\n\t\tconst result = await tryFileOperation(async (filePath) => {\n\t\t\tconst response = await orpcClient.files.openFileLocation({\n\t\t\t\tpath: filePath,\n\t\t\t});\n\t\t\treturn response.success;\n\t\t});\n\n\t\tif (!result.success) {\n\t\t\ttoast.error(t(\"notifications.openFolderFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetResolvedFilePath(result.filePath ?? null);\n\t\tsetFileExists(true);\n\t};\n\n\tconst handleOpenFile = async () => {\n\t\tconst result = await tryFileOperation(async (filePath) => {\n\t\t\tconst response = await orpcClient.files.openFile({ path: filePath });\n\t\t\treturn response.success;\n\t\t});\n\n\t\tif (!result.success) {\n\t\t\ttoast.error(t(\"notifications.openFileFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetResolvedFilePath(result.filePath ?? null);\n\t\tsetFileExists(true);\n\t};\n\n\tconst handleCopyLink = async () => {\n\t\tif (!download.url?.trim()) {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\tif (onCopyUrl) {\n\t\t\tonCopyUrl(download.url);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!navigator.clipboard?.writeText) {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(download.url);\n\t\t\ttoast.success(t(\"notifications.urlCopied\"));\n\t\t} catch {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t}\n\t};\n\n\tconst handleCopyToClipboard = async () => {\n\t\tconst currentPath = resolvedFilePath ?? (await findExistingFilePath());\n\t\tif (!currentPath) {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst response = await orpcClient.files.copyFileToClipboard({\n\t\t\t\tpath: currentPath,\n\t\t\t});\n\t\t\tif (!response.success) {\n\t\t\t\tif (!navigator.clipboard?.writeText) {\n\t\t\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tawait navigator.clipboard.writeText(currentPath);\n\t\t\t}\n\t\t\tsetResolvedFilePath(currentPath);\n\t\t\tsetFileExists(true);\n\t\t\ttoast.success(t(\"notifications.videoCopied\"));\n\t\t} catch {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t}\n\t};\n\n\tconst handleDeleteFile = async () => {\n\t\tconst result = await tryFileOperation(async (filePath) => {\n\t\t\tconst response = await orpcClient.files.deleteFile({ path: filePath });\n\t\t\treturn response.success;\n\t\t});\n\n\t\tif (!result.success) {\n\t\t\ttoast.error(t(\"notifications.removeFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetFileExists(false);\n\t\tsetResolvedFilePath(null);\n\t\tonRemove?.(download.id);\n\t};\n\n\tconst handleDeleteRecord = () => {\n\t\tonRemove?.(download.id);\n\t};\n\n\tconst selectedFormatSize =\n\t\tdownload.selectedFormat?.filesize ??\n\t\tdownload.selectedFormat?.filesizeApprox;\n\tconst inlineFileSize = selectedFormatSize\n\t\t? formatFileSize(selectedFormatSize)\n\t\t: undefined;\n\n\tconst isInProgressStatus = isActiveStatus(download.status);\n\tconst isCompletedStatus = download.status === \"completed\";\n\tconst canRetry = download.status === \"error\";\n\tconst showCopyAction = isCompletedStatus && fileExists;\n\tconst showOpenFolderAction = Boolean(\n\t\tdownload.title && getEffectiveDownloadPath().trim(),\n\t);\n\tconst canCopyLink = Boolean(download.url);\n\tconst canOpenFile = isCompletedStatus && fileExists;\n\tconst canDeleteFile = isCompletedStatus && fileExists;\n\tconst canDeleteRecord = Boolean(onRemove);\n\tconst isSelectedHistory = isHistory && Boolean(onToggleSelect) && isSelected;\n\n\tconst sourceDisplay =\n\t\tdownload.uploader &&\n\t\tdownload.channel &&\n\t\tdownload.uploader !== download.channel\n\t\t\t? `${download.uploader} • ${download.channel}`\n\t\t\t: download.uploader || download.channel || \"\";\n\n\tconst metadataDetails: MetadataDetail[] = [];\n\tif (timestamp) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"history.date\"),\n\t\t\tvalue: formatDate(timestamp),\n\t\t});\n\t}\n\tif (sourceDisplay) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.source\"),\n\t\t\tvalue: sourceDisplay,\n\t\t});\n\t}\n\tif (download.playlistId) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.playlist\"),\n\t\t\tvalue: (\n\t\t\t\t<span>\n\t\t\t\t\t{download.playlistTitle || t(\"playlist.untitled\")}\n\t\t\t\t\t{download.playlistIndex !== undefined &&\n\t\t\t\t\tdownload.playlistSize !== undefined ? (\n\t\t\t\t\t\t<span className=\"text-muted-foreground/80\">\n\t\t\t\t\t\t\t{` ${t(\"playlist.positionLabel\", {\n\t\t\t\t\t\t\t\tindex: download.playlistIndex,\n\t\t\t\t\t\t\t\ttotal: download.playlistSize,\n\t\t\t\t\t\t\t})}`}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t) : null}\n\t\t\t\t</span>\n\t\t\t),\n\t\t});\n\t}\n\tif (download.duration) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"history.duration\"),\n\t\t\tvalue: formatDuration(download.duration),\n\t\t});\n\t}\n\n\tconst formatLabelValue = getFormatLabel(download);\n\tif (formatLabelValue) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.format\"),\n\t\t\tvalue: formatLabelValue,\n\t\t});\n\t}\n\tif (qualityLabel) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.quality\"),\n\t\t\tvalue: qualityLabel,\n\t\t});\n\t}\n\tif (inlineFileSize) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"history.fileSize\"),\n\t\t\tvalue: inlineFileSize,\n\t\t});\n\t}\n\tconst codecValue = getCodecLabel(download);\n\tif (codecValue) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.codec\"),\n\t\t\tvalue: codecValue,\n\t\t});\n\t}\n\tconst normalizedSavedFileName = normalizeSavedFileName(\n\t\tdownload.savedFileName,\n\t);\n\tif (normalizedSavedFileName || download.savedFileName) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.savedFile\"),\n\t\t\tvalue: normalizedSavedFileName ?? download.savedFileName ?? \"\",\n\t\t});\n\t}\n\tif (download.url) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.url\"),\n\t\t\tvalue: (\n\t\t\t\t<a\n\t\t\t\t\tclassName=\"break-words text-primary hover:underline\"\n\t\t\t\t\thref={download.url}\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t>\n\t\t\t\t\t{download.url}\n\t\t\t\t</a>\n\t\t\t),\n\t\t});\n\t}\n\tif (download.description) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.description\"),\n\t\t\tvalue: <span className=\"break-words\">{download.description}</span>,\n\t\t});\n\t}\n\tif (download.viewCount !== undefined && download.viewCount !== null) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.views\"),\n\t\t\tvalue: download.viewCount.toLocaleString(),\n\t\t});\n\t}\n\tif (download.tags && download.tags.length > 0) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.tags\"),\n\t\t\tvalue: (\n\t\t\t\t<div className=\"flex flex-wrap gap-1\">\n\t\t\t\t\t{download.tags.map((tag) => (\n\t\t\t\t\t\t<Badge\n\t\t\t\t\t\t\tclassName=\"px-1.5 py-0.5 text-[10px]\"\n\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t</Badge>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t),\n\t\t});\n\t}\n\tif (download.downloadPath) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.downloadPath\"),\n\t\t\tvalue: (\n\t\t\t\t<span className=\"break-words font-mono text-xs\">\n\t\t\t\t\t{download.downloadPath}\n\t\t\t\t</span>\n\t\t\t),\n\t\t});\n\t}\n\tif (download.createdAt && download.createdAt !== timestamp) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.createdAt\"),\n\t\t\tvalue: formatDate(download.createdAt),\n\t\t});\n\t}\n\tif (download.startedAt) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.startedAt\"),\n\t\t\tvalue: formatDate(download.startedAt),\n\t\t});\n\t}\n\tif (download.completedAt && download.completedAt !== timestamp) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.completedAt\"),\n\t\t\tvalue: formatDate(download.completedAt),\n\t\t});\n\t}\n\tif (download.speed) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.speed\"),\n\t\t\tvalue: download.speed,\n\t\t});\n\t}\n\tif (download.fileSize && download.fileSize !== selectedFormatSize) {\n\t\tmetadataDetails.push({\n\t\t\tlabel: t(\"download.metadata.fileSize\"),\n\t\t\tvalue: formatFileSize(download.fileSize),\n\t\t});\n\t}\n\tif (download.selectedFormat) {\n\t\tif (download.selectedFormat.width) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.width\"),\n\t\t\t\tvalue: `${download.selectedFormat.width}px`,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.height && !qualityLabel) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.height\"),\n\t\t\t\tvalue: `${download.selectedFormat.height}px`,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.fps) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.fps\"),\n\t\t\t\tvalue: `${download.selectedFormat.fps}`,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.vcodec) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.videoCodec\"),\n\t\t\t\tvalue: download.selectedFormat.vcodec,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.acodec) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.audioCodec\"),\n\t\t\t\tvalue: download.selectedFormat.acodec,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.formatNote) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.formatNote\"),\n\t\t\t\tvalue: download.selectedFormat.formatNote,\n\t\t\t});\n\t\t}\n\t\tif (download.selectedFormat.protocol) {\n\t\t\tmetadataDetails.push({\n\t\t\t\tlabel: t(\"download.metadata.protocol\"),\n\t\t\t\tvalue: download.selectedFormat.protocol.toUpperCase(),\n\t\t\t});\n\t\t}\n\t}\n\n\tconst hasMetadataDetails = metadataDetails.length > 0;\n\tconst logContent = download.ytDlpLog ?? \"\";\n\tconst hasLogContent = logContent.trim().length > 0;\n\tconst ytDlpCommand = download.ytDlpCommand?.trim();\n\tconst hasYtDlpCommand = Boolean(ytDlpCommand);\n\tconst canShowSheet =\n\t\thasMetadataDetails ||\n\t\tisInProgressStatus ||\n\t\thasLogContent ||\n\t\thasYtDlpCommand;\n\n\tuseEffect(() => {\n\t\tconst wasOpen = lastSheetOpenRef.current;\n\t\tlastSheetOpenRef.current = sheetOpen;\n\t\tif (!sheetOpen || wasOpen) {\n\t\t\treturn;\n\t\t}\n\t\tconst defaultTab = hasMetadataDetails ? \"details\" : \"logs\";\n\t\tsetActiveTab(pendingTab ?? defaultTab);\n\t\tsetPendingTab(null);\n\t\tsetLogAutoScroll(true);\n\t}, [hasMetadataDetails, pendingTab, sheetOpen]);\n\n\tuseEffect(() => {\n\t\tif (!(sheetOpen && logAutoScroll && logContent)) {\n\t\t\treturn;\n\t\t}\n\t\tconst container = logContainerRef.current;\n\t\tif (container) {\n\t\t\tcontainer.scrollTop = container.scrollHeight;\n\t\t}\n\t}, [logAutoScroll, logContent, sheetOpen]);\n\n\tconst handleLogScroll = () => {\n\t\tconst container = logContainerRef.current;\n\t\tif (!container) {\n\t\t\treturn;\n\t\t}\n\t\tconst { scrollTop, scrollHeight, clientHeight } = container;\n\t\tconst isNearBottom = scrollHeight - scrollTop - clientHeight < 24;\n\t\tsetLogAutoScroll(isNearBottom);\n\t};\n\n\tconst openLogsSheet = () => {\n\t\tif (!canShowSheet) {\n\t\t\treturn;\n\t\t}\n\t\tsetPendingTab(sheetOpen ? null : \"logs\");\n\t\tsetActiveTab(\"logs\");\n\t\tsetLogAutoScroll(true);\n\t\tsetSheetOpen(true);\n\t};\n\n\tconst handleSelectKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n\t\tif (!onToggleSelect) {\n\t\t\treturn;\n\t\t}\n\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\tevent.preventDefault();\n\t\t\tonToggleSelect(download.id);\n\t\t}\n\t};\n\n\treturn (\n\t\t<ContextMenu onOpenChange={setIsContextMenuOpen}>\n\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t<div\n\t\t\t\t\tclassName={`group relative w-full max-w-full overflow-hidden px-6 py-2 transition-colors ${\n\t\t\t\t\t\tisSelectedHistory || isContextMenuOpen ? \"bg-primary/10\" : \"\"\n\t\t\t\t\t}`}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={`flex w-full flex-col gap-2 sm:flex-row sm:gap-3 ${\n\t\t\t\t\t\t\tisHistory && onToggleSelect ? \"cursor-pointer\" : \"\"\n\t\t\t\t\t\t}`}\n\t\t\t\t\t\t{...(isHistory && onToggleSelect\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tonClick: () => onToggleSelect(download.id),\n\t\t\t\t\t\t\t\t\tonKeyDown: handleSelectKeyDown,\n\t\t\t\t\t\t\t\t\trole: \"button\",\n\t\t\t\t\t\t\t\t\ttabIndex: 0,\n\t\t\t\t\t\t\t\t\t\"aria-label\": t(\"history.selectItem\"),\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {})}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"relative z-20 flex h-14 w-24 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background/60\">\n\t\t\t\t\t\t\t{isHistory && onToggleSelect && (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName={`absolute top-1 left-1 z-30 rounded-md transition ${\n\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t? \"opacity-100\"\n\t\t\t\t\t\t\t\t\t\t\t: \"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100\"\n\t\t\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\taria-label={t(\"history.selectItem\")}\n\t\t\t\t\t\t\t\t\t\tchecked={Boolean(isSelected)}\n\t\t\t\t\t\t\t\t\t\tonCheckedChange={() => onToggleSelect(download.id)}\n\t\t\t\t\t\t\t\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<RemoteImage\n\t\t\t\t\t\t\t\talt={download.title || download.id}\n\t\t\t\t\t\t\t\tcacheResolver={resolveImageProxyUrl}\n\t\t\t\t\t\t\t\tclassName=\"h-full w-full object-cover\"\n\t\t\t\t\t\t\t\tsrc={download.thumbnail}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"min-w-0 max-w-full flex-1 overflow-hidden\">\n\t\t\t\t\t\t\t<div className=\"flex h-14 w-full flex-col justify-center gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-2\">\n\t\t\t\t\t\t\t\t<div className=\"min-w-0 max-w-full flex-1 space-y-1.5 overflow-hidden\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex w-full min-w-0 flex-wrap items-center gap-1.5 overflow-hidden\">\n\t\t\t\t\t\t\t\t\t\t<p className=\"line-clamp-1 flex-1 font-medium text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t{download.title || download.url}\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t{download.type === \"audio\" && (\n\t\t\t\t\t\t\t\t\t\t\t<Badge\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0 px-1.5 py-0.5 text-[10px]\"\n\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.audio\")}\n\t\t\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{statusIcon && (\n\t\t\t\t\t\t\t\t\t\t\t<Tooltip>\n\t\t\t\t\t\t\t\t\t\t\t\t<TooltipTrigger asChild>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex shrink-0 items-center\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{statusIcon}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</TooltipTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t<TooltipContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<p>{statusText}</p>\n\t\t\t\t\t\t\t\t\t\t\t\t</TooltipContent>\n\t\t\t\t\t\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{isInProgressStatus && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex min-w-0 items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{(download.progress?.percent ?? 0).toFixed(1)}%\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{download.progress?.downloaded &&\n\t\t\t\t\t\t\t\t\t\t\t\t\tdownload.progress?.total && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"max-w-[120px] truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{download.progress.downloaded} /{\" \"}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{download.progress.total}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{download.progress?.currentSpeed && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"max-w-[80px] truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{download.progress.currentSpeed}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{download.progress?.eta && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"max-w-[80px] truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tETA: {download.progress.eta}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{timestamp && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t{formatDateShort(timestamp)}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{qualityLabel && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 text-muted-foreground/60\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0\">{qualityLabel}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{inlineFileSize && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 text-muted-foreground/60\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0\">{inlineFileSize}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100\">\n\t\t\t\t\t\t\t\t\t{canRetry && (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 w-8 shrink-0 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\thandleRetryDownload();\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<RotateCw className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{showCopyAction && (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 w-8 shrink-0 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\tvoid handleCopyToClipboard();\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Copy className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{showOpenFolderAction && (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 w-8 shrink-0 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\tvoid handleOpenFolder();\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<FolderOpen className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{isInProgressStatus && (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 w-8 shrink-0 rounded-full\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\thandleCancel();\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{download.progress &&\n\t\t\t\t\t\t\t\t![\"completed\", \"error\"].includes(download.status) && (\n\t\t\t\t\t\t\t\t\t<div className=\"w-full overflow-hidden bg-background/60\">\n\t\t\t\t\t\t\t\t\t\t<Progress\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-1 w-full\"\n\t\t\t\t\t\t\t\t\t\t\tvalue={download.progress.percent}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{download.status === \"error\" && download.error && (\n\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-1.5\">\n\t\t\t\t\t\t\t\t\t<p className=\"line-clamp-2 w-full overflow-hidden text-destructive text-xs\">\n\t\t\t\t\t\t\t\t\t\t{download.error}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div className=\"pointer-events-auto flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 font-medium text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"download.feedback.title\")}:\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{canShowSheet && (\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-6 px-1.5 text-[10px]\"\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\topenLogsSheet();\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.viewLogs\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<FeedbackLinkButtons\n\t\t\t\t\t\t\t\t\t\t\tbuttonClassName=\"h-6 gap-1 px-1.5 text-[10px]\"\n\t\t\t\t\t\t\t\t\t\t\tbuttonSize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\tbuttonVariant=\"outline\"\n\t\t\t\t\t\t\t\t\t\t\terror={download.error}\n\t\t\t\t\t\t\t\t\t\t\ticonClassName=\"h-3 w-3\"\n\t\t\t\t\t\t\t\t\t\t\tissueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}\n\t\t\t\t\t\t\t\t\t\t\tonLinkClick={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\tshowGroupSeparator={canShowSheet}\n\t\t\t\t\t\t\t\t\t\t\tsourceUrl={download.url}\n\t\t\t\t\t\t\t\t\t\t\twrapperClassName=\"flex flex-wrap items-center gap-1.5\"\n\t\t\t\t\t\t\t\t\t\t\tytDlpCommand={download.ytDlpCommand}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{canShowSheet && (\n\t\t\t\t\t\t<Sheet onOpenChange={setSheetOpen} open={sheetOpen}>\n\t\t\t\t\t\t\t<SheetContent\n\t\t\t\t\t\t\t\tclassName=\"flex w-full flex-col p-0 sm:max-w-lg\"\n\t\t\t\t\t\t\t\tside=\"right\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex h-full flex-col overflow-hidden\">\n\t\t\t\t\t\t\t\t\t<SheetHeader className=\"shrink-0 border-b px-6 pt-6 pb-4\">\n\t\t\t\t\t\t\t\t\t\t<SheetTitle className=\"line-clamp-2\">\n\t\t\t\t\t\t\t\t\t\t\t{download.title}\n\t\t\t\t\t\t\t\t\t\t</SheetTitle>\n\t\t\t\t\t\t\t\t\t\t<SheetDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"download.videoInfo\")}\n\t\t\t\t\t\t\t\t\t\t</SheetDescription>\n\t\t\t\t\t\t\t\t\t</SheetHeader>\n\t\t\t\t\t\t\t\t\t<Tabs\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 overflow-hidden\"\n\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\tsetActiveTab(value as \"details\" | \"logs\")\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvalue={activeTab}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div className=\"px-6 pt-4\">\n\t\t\t\t\t\t\t\t\t\t\t<TabsList>\n\t\t\t\t\t\t\t\t\t\t\t\t<TabsTrigger\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={!hasMetadataDetails}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue=\"details\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.detailsTab\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</TabsTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t<TabsTrigger value=\"logs\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.logsTab\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</TabsTrigger>\n\t\t\t\t\t\t\t\t\t\t\t</TabsList>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<TabsContent\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1 overflow-y-auto px-6 py-4\"\n\t\t\t\t\t\t\t\t\t\t\tvalue=\"details\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t\t\t\t\t\t\t{metadataDetails.map((item, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex flex-col gap-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={`${item.label}-${index}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-medium text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"break-words text-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{item.value}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</TabsContent>\n\t\t\t\t\t\t\t\t\t\t<TabsContent\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex flex-1 flex-col gap-3 overflow-hidden px-6 py-4\"\n\t\t\t\t\t\t\t\t\t\t\tvalue=\"logs\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{isInProgressStatus\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? t(\"download.logs.live\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: t(\"download.logs.history\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{logAutoScroll ? null : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground/70\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.logs.scrollPaused\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t{hasYtDlpCommand && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"rounded-md border border-border/60 bg-muted/20 p-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"font-medium text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.logs.command\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-1 whitespace-pre-wrap break-words font-mono text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{ytDlpCommand}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"min-h-0 flex-1 rounded-md border border-border/60 bg-muted/30\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-full overflow-y-auto whitespace-pre-wrap break-words p-3 font-mono text-xs leading-relaxed\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonScroll={handleLogScroll}\n\t\t\t\t\t\t\t\t\t\t\t\t\tref={logContainerRef}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{hasLogContent\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? logContent\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: t(\"download.logs.empty\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</TabsContent>\n\t\t\t\t\t\t\t\t\t</Tabs>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</SheetContent>\n\t\t\t\t\t\t</Sheet>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</ContextMenuTrigger>\n\n\t\t\t<ContextMenuContent>\n\t\t\t\t{isInProgressStatus ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{canRetry && (\n\t\t\t\t\t\t\t<ContextMenuItem onClick={handleRetryDownload}>\n\t\t\t\t\t\t\t\t<RotateCw className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{t(\"download.retry\")}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!showOpenFolderAction}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tvoid handleOpenFolder();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<FolderOpen className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"history.openFileLocation\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canCopyLink}\n\t\t\t\t\t\t\tonClick={() => void handleCopyLink()}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n\t\t\t\t\t\t\t{t(\"history.copyUrl\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t{canShowSheet && (\n\t\t\t\t\t\t\t<ContextMenuItem onClick={() => setSheetOpen(true)}>\n\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n\t\t\t\t\t\t\t\t{t(\"download.showDetails\")}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t\t<ContextMenuItem onClick={handleCancel}>\n\t\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"download.cancel\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{isCompletedStatus && (\n\t\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\t\tdisabled={!showCopyAction}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tvoid handleCopyToClipboard();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Copy className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{t(\"history.copyToClipboard\")}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{canRetry && (\n\t\t\t\t\t\t\t<ContextMenuItem onClick={handleRetryDownload}>\n\t\t\t\t\t\t\t\t<RotateCw className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{t(\"download.retry\")}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canOpenFile}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tvoid handleOpenFile();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<File className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"history.openFile\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!showOpenFolderAction}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tvoid handleOpenFolder();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<FolderOpen className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"history.openFileLocation\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canCopyLink}\n\t\t\t\t\t\t\tonClick={() => void handleCopyLink()}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n\t\t\t\t\t\t\t{t(\"history.copyUrl\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t{canShowSheet && (\n\t\t\t\t\t\t\t<ContextMenuItem onClick={() => setSheetOpen(true)}>\n\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n\t\t\t\t\t\t\t\t{t(\"download.showDetails\")}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canDeleteFile}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tvoid handleDeleteFile();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Trash2 className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"history.deleteFile\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canDeleteRecord}\n\t\t\t\t\t\t\tonClick={handleDeleteRecord}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n\t\t\t\t\t\t\t{t(\"history.deleteRecord\")}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</ContextMenuContent>\n\t\t</ContextMenu>\n\t);\n}\n"
  },
  {
    "path": "apps/web/src/components/download/playlist-download-group.tsx",
    "content": "import { Button } from \"@vidbee/ui/components/ui/button\";\nimport { Progress } from \"@vidbee/ui/components/ui/progress\";\nimport { ChevronDown, ChevronRight, Trash2 } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { DownloadItem } from \"./download-item\";\nimport type { DownloadRecord } from \"./types\";\n\ninterface PlaylistDownloadGroupProps {\n\tgroupId: string;\n\ttitle: string;\n\trecords: DownloadRecord[];\n\ttotalCount: number;\n\tselectedIds?: Set<string>;\n\tonToggleSelect?: (id: string) => void;\n\tonDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void;\n\tonCancel?: (id: string) => void;\n\tonRetry?: (download: DownloadRecord) => void;\n\tonRemove?: (id: string) => void;\n\tonCopyUrl?: (url: string) => void;\n}\n\nconst STORAGE_KEY_PREFIX = \"playlist_expanded_\";\n\nconst getStorageKey = (groupId: string): string => {\n\treturn `${STORAGE_KEY_PREFIX}${groupId}`;\n};\n\nconst loadExpandedState = (groupId: string): boolean => {\n\ttry {\n\t\tconst stored = localStorage.getItem(getStorageKey(groupId));\n\t\treturn stored === \"true\";\n\t} catch (error) {\n\t\tconsole.error(\"Failed to load playlist expanded state:\", error);\n\t\treturn false;\n\t}\n};\n\nconst saveExpandedState = (groupId: string, isExpanded: boolean): void => {\n\ttry {\n\t\tlocalStorage.setItem(getStorageKey(groupId), String(isExpanded));\n\t} catch (error) {\n\t\tconsole.error(\"Failed to save playlist expanded state:\", error);\n\t}\n};\n\nexport function PlaylistDownloadGroup({\n\tgroupId,\n\ttitle,\n\trecords,\n\ttotalCount,\n\tselectedIds,\n\tonToggleSelect,\n\tonDeletePlaylist,\n\tonCancel,\n\tonRetry,\n\tonRemove,\n\tonCopyUrl,\n}: PlaylistDownloadGroupProps) {\n\tconst { t } = useTranslation();\n\tconst [isExpanded, setIsExpanded] = useState(() =>\n\t\tloadExpandedState(groupId),\n\t);\n\n\tuseEffect(() => {\n\t\tsaveExpandedState(groupId, isExpanded);\n\t}, [groupId, isExpanded]);\n\n\tconst completedCount = records.filter(\n\t\t(record) => record.status === \"completed\",\n\t).length;\n\tconst errorCount = records.filter(\n\t\t(record) => record.status === \"error\",\n\t).length;\n\tconst activeCount = records.filter((record) =>\n\t\t[\"downloading\", \"processing\", \"pending\"].includes(record.status),\n\t).length;\n\n\tconst displayTitle = title || t(\"playlist.untitled\");\n\tconst historyRecords = records.filter(\n\t\t(record) => record.entryType === \"history\",\n\t);\n\tconst canDeletePlaylist =\n\t\thistoryRecords.length > 0 && Boolean(onDeletePlaylist);\n\tconst toggleLabel = isExpanded\n\t\t? t(\"playlist.groupCollapse\")\n\t\t: t(\"playlist.groupExpand\");\n\tconst totalProgress = records.reduce((acc, record) => {\n\t\tif (record.status === \"completed\") {\n\t\t\treturn acc + 1;\n\t\t}\n\t\tif (record.progress?.percent && record.progress.percent > 0) {\n\t\t\treturn acc + Math.min(record.progress.percent, 100) / 100;\n\t\t}\n\t\treturn acc;\n\t}, 0);\n\tconst aggregatePercent =\n\t\ttotalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0;\n\n\treturn (\n\t\t<div className=\"mx-6 rounded-md bg-muted/30\">\n\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t<button\n\t\t\t\t\taria-expanded={isExpanded}\n\t\t\t\t\taria-label={toggleLabel}\n\t\t\t\t\tclassName=\"flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 px-3 transition-colors hover:bg-muted/40 active:bg-muted/60\"\n\t\t\t\t\tonClick={() => setIsExpanded((prev) => !prev)}\n\t\t\t\t\ttitle={toggleLabel}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex shrink-0 items-center justify-center text-muted-foreground\">\n\t\t\t\t\t\t{isExpanded ? (\n\t\t\t\t\t\t\t<ChevronDown className=\"h-5 w-5\" />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronRight className=\"h-5 w-5\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"min-w-0 flex-1 text-left\">\n\t\t\t\t\t\t<p className=\"truncate font-medium text-foreground text-sm\">\n\t\t\t\t\t\t\t{displayTitle}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{t(\"playlist.collapsedProgress\", {\n\t\t\t\t\t\t\t\t\tcompleted: completedCount,\n\t\t\t\t\t\t\t\t\ttotal: totalCount,\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{activeCount > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground/50\">•</span>\n\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t{t(\"playlist.groupActive\", { count: activeCount })}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{errorCount > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground/50\">•</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-destructive\">\n\t\t\t\t\t\t\t\t\t\t{t(\"playlist.groupErrors\", { count: errorCount })}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</button>\n\t\t\t\t<div className=\"flex shrink-0 items-center gap-1 pr-3\">\n\t\t\t\t\t{canDeletePlaylist && (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\taria-label={t(\"history.deletePlaylist\")}\n\t\t\t\t\t\t\tclassName=\"h-6 w-6 rounded-full\"\n\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\tonDeletePlaylist?.(\n\t\t\t\t\t\t\t\t\tgroupId,\n\t\t\t\t\t\t\t\t\tdisplayTitle,\n\t\t\t\t\t\t\t\t\thistoryRecords.map((record) => record.id),\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\ttitle={t(\"history.deletePlaylist\")}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Trash2 className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{!isExpanded && totalCount > 0 && (\n\t\t\t\t<Progress className=\"h-0.5 w-full\" value={aggregatePercent} />\n\t\t\t)}\n\n\t\t\t<div\n\t\t\t\tclassName=\"grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out\"\n\t\t\t\tstyle={{\n\t\t\t\t\tgridTemplateRows: isExpanded ? \"1fr\" : \"0fr\",\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div className=\"min-h-0\">\n\t\t\t\t\t{records.map((record) => (\n\t\t\t\t\t\t<div key={`${groupId}:${record.entryType}:${record.id}`}>\n\t\t\t\t\t\t\t<DownloadItem\n\t\t\t\t\t\t\t\tdownload={record}\n\t\t\t\t\t\t\t\tisSelected={selectedIds?.has(record.id) ?? false}\n\t\t\t\t\t\t\t\tonCancel={onCancel}\n\t\t\t\t\t\t\t\tonCopyUrl={onCopyUrl}\n\t\t\t\t\t\t\t\tonRemove={onRemove}\n\t\t\t\t\t\t\t\tonRetry={onRetry}\n\t\t\t\t\t\t\t\tonToggleSelect={onToggleSelect}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "apps/web/src/components/download/playlist-download.tsx",
    "content": "import type { PlaylistInfo } from \"@vidbee/downloader-core\";\nimport { Checkbox } from \"@vidbee/ui/components/ui/checkbox\";\nimport { Input } from \"@vidbee/ui/components/ui/input\";\nimport { Label } from \"@vidbee/ui/components/ui/label\";\nimport { ScrollArea } from \"@vidbee/ui/components/ui/scroll-area\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@vidbee/ui/components/ui/select\";\nimport { cn } from \"@vidbee/ui/lib/cn\";\nimport { AlertCircle, List, Loader2 } from \"lucide-react\";\nimport type { Dispatch, SetStateAction } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface PlaylistDownloadProps {\n\tplaylistPreviewLoading: boolean;\n\tplaylistPreviewError: string | null;\n\tplaylistInfo: PlaylistInfo | null;\n\tplaylistBusy: boolean;\n\tselectedPlaylistEntries: PlaylistInfo[\"entries\"];\n\tselectedEntryIds: Set<string>;\n\tdownloadType: \"video\" | \"audio\";\n\tdownloadTypeId: string;\n\tstartIndex: string;\n\tendIndex: string;\n\tadvancedOptionsOpen: boolean;\n\tsetSelectedEntryIds: Dispatch<SetStateAction<Set<string>>>;\n\tsetStartIndex: Dispatch<SetStateAction<string>>;\n\tsetEndIndex: Dispatch<SetStateAction<string>>;\n\tsetDownloadType: Dispatch<SetStateAction<\"video\" | \"audio\">>;\n}\n\nexport function PlaylistDownload({\n\tplaylistPreviewLoading,\n\tplaylistPreviewError,\n\tplaylistInfo,\n\tplaylistBusy,\n\tselectedPlaylistEntries,\n\tselectedEntryIds,\n\tdownloadType,\n\tdownloadTypeId,\n\tstartIndex,\n\tendIndex,\n\tadvancedOptionsOpen,\n\tsetSelectedEntryIds,\n\tsetStartIndex,\n\tsetEndIndex,\n\tsetDownloadType,\n}: PlaylistDownloadProps) {\n\tconst { t } = useTranslation();\n\n\treturn (\n\t\t<>\n\t\t\t{playlistPreviewLoading && !playlistPreviewError && (\n\t\t\t\t<div className=\"flex min-h-[200px] flex-1 flex-col items-center justify-center gap-3\">\n\t\t\t\t\t<Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t{t(\"playlist.fetchingInfo\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{playlistPreviewError && (\n\t\t\t\t<div className=\"mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-3\">\n\t\t\t\t\t<div className=\"flex items-start gap-2\">\n\t\t\t\t\t\t<AlertCircle className=\"mt-0.5 h-4 w-4 shrink-0 text-destructive\" />\n\t\t\t\t\t\t<div className=\"flex-1 space-y-1\">\n\t\t\t\t\t\t\t<p className=\"font-medium text-destructive text-sm\">\n\t\t\t\t\t\t\t\t{t(\"playlist.previewFailed\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p className=\"text-muted-foreground/80 text-xs\">\n\t\t\t\t\t\t\t\t{playlistPreviewError}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{playlistInfo && !playlistPreviewLoading && (\n\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-3\">\n\t\t\t\t\t<div className=\"shrink-0 space-y-0.5\">\n\t\t\t\t\t\t<h3 className=\"line-clamp-1 font-bold text-sm leading-tight\">\n\t\t\t\t\t\t\t{playlistInfo.title}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t<List className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{t(\"playlist.foundVideos\", { count: playlistInfo.entryCount })}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{selectedPlaylistEntries.length !== playlistInfo.entryCount && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t\t\t<span className=\"font-medium text-primary\">\n\t\t\t\t\t\t\t\t\t\t{t(\"playlist.selectedVideos\", {\n\t\t\t\t\t\t\t\t\t\t\tcount: selectedPlaylistEntries.length,\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<ScrollArea className=\"min-h-0 w-full flex-1 rounded-md border\">\n\t\t\t\t\t\t<div className=\"p-1\">\n\t\t\t\t\t\t\t{playlistInfo.entries.map((entry) => {\n\t\t\t\t\t\t\t\tconst isSelected = selectedEntryIds.has(entry.id);\n\t\t\t\t\t\t\t\tconst isInRange =\n\t\t\t\t\t\t\t\t\tselectedEntryIds.size === 0 &&\n\t\t\t\t\t\t\t\t\tselectedPlaylistEntries.some(\n\t\t\t\t\t\t\t\t\t\t(playlistEntry) => playlistEntry.id === entry.id,\n\t\t\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\t\tconst handleToggle = () => {\n\t\t\t\t\t\t\t\t\tsetSelectedEntryIds((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tif (next.has(entry.id)) {\n\t\t\t\t\t\t\t\t\t\t\tnext.delete(entry.id);\n\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\tnext.add(entry.id);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\tif (selectedEntryIds.size === 0) {\n\t\t\t\t\t\t\t\t\t\tsetStartIndex(\"1\");\n\t\t\t\t\t\t\t\t\t\tsetEndIndex(\"\");\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\taria-label={t(\"playlist.selectEntry\", {\n\t\t\t\t\t\t\t\t\t\t\tindex: entry.index,\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex w-full cursor-pointer items-center gap-3 rounded px-2.5 py-1.5 text-left transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\tisSelected || isInRange\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-primary/10\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"hover:bg-muted/50\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\tkey={entry.id}\n\t\t\t\t\t\t\t\t\t\tonClick={handleToggle}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\t\thandleToggle();\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\t\tchecked={isSelected || isInRange}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(checked) => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSelectedEntryIds((prev) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\t\t\t\tif (checked) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tnext.add(entry.id);\n\t\t\t\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tnext.delete(entry.id);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t\t\tif (selectedEntryIds.size === 0) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetStartIndex(\"1\");\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetEndIndex(\"\");\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<div className=\"w-8 shrink-0 font-medium text-muted-foreground/70 text-xs tabular-nums\">\n\t\t\t\t\t\t\t\t\t\t\t#{entry.index}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"line-clamp-1 font-medium text-xs leading-tight\">\n\t\t\t\t\t\t\t\t\t\t\t\t{entry.title || t(\"download.fetchingVideoInfo\")}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ScrollArea>\n\n\t\t\t\t\t<div\n\t\t\t\t\t\taria-hidden={!advancedOptionsOpen}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"grid shrink-0 overflow-hidden transition-all duration-300 ease-out\",\n\t\t\t\t\t\t\tadvancedOptionsOpen\n\t\t\t\t\t\t\t\t? \"grid-rows-[1fr] py-3 opacity-100\"\n\t\t\t\t\t\t\t\t: \"grid-rows-[0fr] opacity-0\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tdata-state={advancedOptionsOpen ? \"open\" : \"closed\"}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"min-h-0\",\n\t\t\t\t\t\t\t\t!advancedOptionsOpen && \"pointer-events-none\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"w-full border-t pt-3\">\n\t\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t\t<div className=\"grid grid-cols-2 gap-3\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t<Label\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"font-medium text-muted-foreground text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\thtmlFor={downloadTypeId}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"playlist.downloadType\")}\n\t\t\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={playlistBusy}\n\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetDownloadType(value as \"video\" | \"audio\")\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={downloadType}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tid={downloadTypeId}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem className=\"text-xs\" value=\"video\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.video\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem className=\"text-xs\" value=\"audio\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.audio\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t<div className=\"space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t<Label className=\"font-medium text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"playlist.range\")}\n\t\t\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 text-center text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={playlistBusy}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetStartIndex(event.target.value);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (selectedEntryIds.size > 0) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetSelectedEntryIds(new Set());\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={startIndex}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground text-xs\">-</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-8 text-center text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={playlistBusy}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetEndIndex(event.target.value);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (selectedEntryIds.size > 0) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetSelectedEntryIds(new Set());\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tplaylistInfo?.entryCount.toString() || \"End\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={endIndex}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "apps/web/src/components/download/single-video-download.tsx",
    "content": "import type { VideoFormat, VideoInfo } from \"@vidbee/downloader-core\";\nimport { Button } from \"@vidbee/ui/components/ui/button\";\nimport {\n\tDOWNLOAD_FEEDBACK_ISSUE_TITLE,\n\tFeedbackLinkButtons,\n} from \"@vidbee/ui/components/ui/feedback-link-buttons\";\nimport { Label } from \"@vidbee/ui/components/ui/label\";\nimport {\n\tRadioGroup,\n\tRadioGroupItem,\n} from \"@vidbee/ui/components/ui/radio-group\";\nimport { RemoteImage } from \"@vidbee/ui/components/ui/remote-image\";\nimport { ScrollArea } from \"@vidbee/ui/components/ui/scroll-area\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@vidbee/ui/components/ui/select\";\nimport { Separator } from \"@vidbee/ui/components/ui/separator\";\nimport { cn } from \"@vidbee/ui/lib/cn\";\nimport { AlertCircle, ExternalLink, Loader2, Settings2 } from \"lucide-react\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport type { OneClickQualityPreset } from \"../../lib/download-format-preferences\";\nimport { resolveImageProxyUrl } from \"../../lib/remote-image-proxy\";\n\nexport interface SingleVideoState {\n\ttitle: string;\n\tactiveTab: \"video\" | \"audio\";\n\tselectedVideoFormat: string;\n\tselectedAudioFormat: string;\n\tselectedContainer?: string;\n\tselectedCodec?: string;\n\tselectedFps?: string;\n}\n\ninterface SingleVideoDownloadProps {\n\tloading: boolean;\n\terror: string | null;\n\tvideoInfo: VideoInfo | null;\n\tstate: SingleVideoState;\n\toneClickQuality: OneClickQualityPreset;\n\tfeedbackSourceUrl?: string | null;\n\tonStateChange: (state: Partial<SingleVideoState>) => void;\n}\n\nconst qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =\n\t{\n\t\tbest: null,\n\t\tgood: 1080,\n\t\tnormal: 720,\n\t\tbad: 480,\n\t\tworst: 360,\n\t};\n\nconst formatDuration = (seconds?: number): string => {\n\tif (!seconds) {\n\t\treturn \"00:00\";\n\t}\n\tconst hours = Math.floor(seconds / 3600);\n\tconst minutes = Math.floor((seconds % 3600) / 60);\n\tconst remainingSeconds = Math.floor(seconds % 60);\n\tif (hours > 0) {\n\t\treturn `${hours}:${minutes.toString().padStart(2, \"0\")}:${remainingSeconds\n\t\t\t.toString()\n\t\t\t.padStart(2, \"0\")}`;\n\t}\n\treturn `${minutes}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n};\n\nconst getCodecShortName = (codec?: string): string => {\n\tif (!codec || codec === \"none\") {\n\t\treturn \"Unknown\";\n\t}\n\treturn codec.split(\".\")[0].toUpperCase();\n};\n\nconst isHlsFormat = (format: VideoFormat): boolean =>\n\tformat.protocol === \"m3u8\" || format.protocol === \"m3u8_native\";\n\nconst isHttpProtocol = (format: VideoFormat): boolean =>\n\tBoolean(format.protocol?.startsWith(\"http\"));\n\nconst filterFormatsByType = (\n\tformats: VideoInfo[\"formats\"],\n\tactiveTab: \"video\" | \"audio\",\n): VideoInfo[\"formats\"] => {\n\tif (!formats) {\n\t\treturn [];\n\t}\n\n\treturn formats.filter((format) => {\n\t\tif (activeTab === \"video\") {\n\t\t\treturn format.vcodec && format.vcodec !== \"none\";\n\t\t}\n\n\t\treturn (\n\t\t\tformat.acodec &&\n\t\t\tformat.acodec !== \"none\" &&\n\t\t\t(format.videoExt === \"none\" ||\n\t\t\t\t!format.videoExt ||\n\t\t\t\t!format.vcodec ||\n\t\t\t\tformat.vcodec === \"none\")\n\t\t);\n\t});\n};\n\ninterface FormatListProps {\n\tformats: VideoFormat[];\n\ttype: \"video\" | \"audio\";\n\tcodec?: string;\n\tselectedFormat: string;\n\tonFormatChange: (formatId: string) => void;\n\toneClickQuality: OneClickQualityPreset;\n}\n\nconst FormatList = ({\n\tformats,\n\ttype,\n\tcodec,\n\tselectedFormat,\n\tonFormatChange,\n\toneClickQuality,\n}: FormatListProps) => {\n\tconst { t } = useTranslation();\n\tconst [videoFormats, setVideoFormats] = useState<VideoFormat[]>([]);\n\tconst [audioFormats, setAudioFormats] = useState<VideoFormat[]>([]);\n\n\tconst getFileSize = useCallback((format: VideoFormat): number => {\n\t\treturn format.filesize ?? format.filesizeApprox ?? 0;\n\t}, []);\n\n\tconst sortVideoFormatsByQuality = useCallback(\n\t\t(a: VideoFormat, b: VideoFormat) => {\n\t\t\tconst aHeight = a.height ?? 0;\n\t\t\tconst bHeight = b.height ?? 0;\n\t\t\tif (aHeight !== bHeight) {\n\t\t\t\treturn bHeight - aHeight;\n\t\t\t}\n\t\t\tconst aFps = a.fps ?? 0;\n\t\t\tconst bFps = b.fps ?? 0;\n\t\t\tif (aFps !== bFps) {\n\t\t\t\treturn bFps - aFps;\n\t\t\t}\n\t\t\tconst aHasSize = !!(a.filesize || a.filesizeApprox);\n\t\t\tconst bHasSize = !!(b.filesize || b.filesizeApprox);\n\t\t\tif (aHasSize !== bHasSize) {\n\t\t\t\treturn bHasSize ? 1 : -1;\n\t\t\t}\n\t\t\treturn getFileSize(b) - getFileSize(a);\n\t\t},\n\t\t[getFileSize],\n\t);\n\n\tconst sortAudioFormatsByQuality = useCallback(\n\t\t(a: VideoFormat, b: VideoFormat) => {\n\t\t\tconst aQuality = a.tbr ?? a.quality ?? 0;\n\t\t\tconst bQuality = b.tbr ?? b.quality ?? 0;\n\t\t\tif (aQuality !== bQuality) {\n\t\t\t\treturn bQuality - aQuality;\n\t\t\t}\n\t\t\tconst aHasSize = !!(a.filesize || a.filesizeApprox);\n\t\t\tconst bHasSize = !!(b.filesize || b.filesizeApprox);\n\t\t\tif (aHasSize !== bHasSize) {\n\t\t\t\treturn bHasSize ? 1 : -1;\n\t\t\t}\n\t\t\treturn getFileSize(b) - getFileSize(a);\n\t\t},\n\t\t[getFileSize],\n\t);\n\n\tconst pickVideoFormatForPreset = useCallback(\n\t\t(\n\t\t\tpresetFormats: VideoFormat[],\n\t\t\tpreset: OneClickQualityPreset,\n\t\t): VideoFormat | null => {\n\t\t\tif (presetFormats.length === 0) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tconst heightLimit = qualityPresetToVideoHeight[preset];\n\t\t\tconst sorted = [...presetFormats].sort(sortVideoFormatsByQuality);\n\n\t\t\tif (preset === \"worst\") {\n\t\t\t\treturn sorted.at(-1) ?? sorted[0];\n\t\t\t}\n\n\t\t\tif (!heightLimit) {\n\t\t\t\treturn sorted[0];\n\t\t\t}\n\n\t\t\tconst matchingLimit = sorted.find((format) => {\n\t\t\t\tif (!format.height) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\treturn format.height <= heightLimit;\n\t\t\t});\n\n\t\t\treturn matchingLimit ?? sorted[0];\n\t\t},\n\t\t[sortVideoFormatsByQuality],\n\t);\n\n\tuseEffect(() => {\n\t\tconst isVideoFormat = (format: VideoFormat) =>\n\t\t\tformat.videoExt !== \"none\" && format.vcodec && format.vcodec !== \"none\";\n\t\tconst isAudioFormat = (format: VideoFormat) =>\n\t\t\tformat.acodec &&\n\t\t\tformat.acodec !== \"none\" &&\n\t\t\t(format.videoExt === \"none\" ||\n\t\t\t\t!format.videoExt ||\n\t\t\t\t!format.vcodec ||\n\t\t\t\tformat.vcodec === \"none\");\n\n\t\tconst videos = formats.filter(isVideoFormat);\n\t\tconst audios = formats.filter(isAudioFormat);\n\n\t\tconst groupedByHeight = new Map<number, VideoFormat[]>();\n\t\tvideos.forEach((format) => {\n\t\t\tconst height = format.height ?? 0;\n\t\t\tconst existing = groupedByHeight.get(height) || [];\n\t\t\texisting.push(format);\n\t\t\tgroupedByHeight.set(height, existing);\n\t\t});\n\n\t\tconst finalVideos = Array.from(groupedByHeight.values()).map((group) => {\n\t\t\treturn group.sort((a, b) => getFileSize(b) - getFileSize(a))[0];\n\t\t});\n\n\t\tlet finalAudios = audios;\n\n\t\tif (codec === \"auto\" && type === \"audio\") {\n\t\t\tconst groupedByQuality = new Map<string, VideoFormat[]>();\n\t\t\taudios.forEach((format) => {\n\t\t\t\tconst qualityKey = format.tbr\n\t\t\t\t\t? `tbr_${format.tbr}`\n\t\t\t\t\t: format.quality\n\t\t\t\t\t\t? `quality_${format.quality}`\n\t\t\t\t\t\t: \"unknown\";\n\t\t\t\tconst existing = groupedByQuality.get(qualityKey) || [];\n\t\t\t\texisting.push(format);\n\t\t\t\tgroupedByQuality.set(qualityKey, existing);\n\t\t\t});\n\n\t\t\tfinalAudios = Array.from(groupedByQuality.values()).map((group) => {\n\t\t\t\treturn group.sort((a, b) => getFileSize(b) - getFileSize(a))[0];\n\t\t\t});\n\t\t}\n\n\t\tfinalVideos.sort(sortVideoFormatsByQuality);\n\t\tfinalAudios.sort(sortAudioFormatsByQuality);\n\n\t\tsetVideoFormats(finalVideos);\n\t\tsetAudioFormats(finalAudios);\n\n\t\tif (type === \"video\") {\n\t\t\tconst videosWithAudio = finalVideos.filter(\n\t\t\t\t(format) => format.acodec && format.acodec !== \"none\",\n\t\t\t);\n\t\t\tconst autoVideos =\n\t\t\t\tfinalAudios.length > 0\n\t\t\t\t\t? finalVideos\n\t\t\t\t\t: videosWithAudio.length > 0\n\t\t\t\t\t\t? videosWithAudio\n\t\t\t\t\t\t: finalVideos;\n\n\t\t\tconst hasSelectedVideo = finalVideos.some(\n\t\t\t\t(format) => format.formatId === selectedFormat,\n\t\t\t);\n\t\t\tif (autoVideos.length > 0 && !(selectedFormat && hasSelectedVideo)) {\n\t\t\t\tconst preferred = pickVideoFormatForPreset(autoVideos, oneClickQuality);\n\t\t\t\tif (preferred) {\n\t\t\t\t\tonFormatChange(preferred.formatId);\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tconst hasSelectedAudio = finalAudios.some(\n\t\t\t\t(format) => format.formatId === selectedFormat,\n\t\t\t);\n\t\t\tif (finalAudios.length > 0 && !(selectedFormat && hasSelectedAudio)) {\n\t\t\t\tconst best = finalAudios[0];\n\t\t\t\tonFormatChange(best.formatId);\n\t\t\t}\n\t\t}\n\t}, [\n\t\tformats,\n\t\toneClickQuality,\n\t\ttype,\n\t\tselectedFormat,\n\t\tonFormatChange,\n\t\tpickVideoFormatForPreset,\n\t\tcodec,\n\t\tgetFileSize,\n\t\tsortVideoFormatsByQuality,\n\t\tsortAudioFormatsByQuality,\n\t]);\n\n\tconst formatSize = (bytes?: number) => {\n\t\tif (!bytes) {\n\t\t\treturn t(\"download.unknownSize\");\n\t\t}\n\t\tconst mb = bytes / 1_000_000;\n\t\treturn `${mb.toFixed(2)} MB`;\n\t};\n\n\tconst formatMetaLabel = (format: VideoFormat) => {\n\t\tconst parts: string[] = [];\n\t\tconst pushPart = (label: string, value?: string) => {\n\t\t\tif (!value) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tparts.push(`${label}:${value}`);\n\t\t};\n\t\tpushPart(\"proto\", format.protocol);\n\t\tpushPart(\"lang\", format.language?.trim());\n\t\tif (format.tbr) {\n\t\t\tpushPart(\"tbr\", `${Math.round(format.tbr)}k`);\n\t\t}\n\t\tif (typeof format.quality === \"number\") {\n\t\t\tpushPart(\"q\", String(format.quality));\n\t\t}\n\t\tif (format.vcodec && format.vcodec !== \"none\") {\n\t\t\tpushPart(\"vcodec\", format.vcodec);\n\t\t}\n\t\tif (format.acodec && format.acodec !== \"none\") {\n\t\t\tpushPart(\"acodec\", format.acodec);\n\t\t}\n\n\t\treturn parts.join(\" • \");\n\t};\n\n\tconst formatVideoQuality = (format: VideoFormat) => {\n\t\tif (format.height) {\n\t\t\treturn `${format.height}p${format.fps === 60 ? \"60\" : \"\"}`;\n\t\t}\n\t\tif (format.formatNote) {\n\t\t\treturn format.formatNote;\n\t\t}\n\t\tif (typeof format.quality === \"number\") {\n\t\t\treturn format.quality.toString();\n\t\t}\n\t\treturn t(\"download.unknownQuality\");\n\t};\n\n\tconst formatAudioQuality = (format: VideoFormat) => {\n\t\tif (format.tbr) {\n\t\t\treturn `${Math.round(format.tbr)} kbps`;\n\t\t}\n\t\tif (format.formatNote) {\n\t\t\treturn format.formatNote;\n\t\t}\n\t\tif (typeof format.quality === \"number\") {\n\t\t\treturn format.quality.toString();\n\t\t}\n\t\treturn t(\"download.unknownQuality\");\n\t};\n\n\tconst formatVideoDetail = (format: VideoFormat) => {\n\t\tconst parts: string[] = [];\n\t\tparts.push(format.ext.toUpperCase());\n\t\tif (format.vcodec) {\n\t\t\tparts.push(format.vcodec.split(\".\")[0].toUpperCase());\n\t\t}\n\t\tif (format.acodec && format.acodec !== \"none\") {\n\t\t\tparts.push(format.acodec.split(\".\")[0].toUpperCase());\n\t\t}\n\t\treturn parts.join(\" • \");\n\t};\n\n\tconst formatAudioDetail = (format: VideoFormat) => {\n\t\tconst parts: string[] = [];\n\t\tconst ext = format.ext === \"webm\" ? \"opus\" : format.ext;\n\t\tparts.push(ext.toUpperCase());\n\t\tif (format.acodec) {\n\t\t\tparts.push(format.acodec.split(\".\")[0].toUpperCase());\n\t\t}\n\t\treturn parts.join(\" • \");\n\t};\n\n\tconst list = type === \"video\" ? videoFormats : audioFormats;\n\n\tif (list.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<RadioGroup\n\t\t\tclassName=\"w-full gap-1\"\n\t\t\tonValueChange={onFormatChange}\n\t\t\tvalue={selectedFormat}\n\t\t>\n\t\t\t{list.map((format) => {\n\t\t\t\tconst qualityLabel =\n\t\t\t\t\ttype === \"video\"\n\t\t\t\t\t\t? formatVideoQuality(format)\n\t\t\t\t\t\t: formatAudioQuality(format);\n\t\t\t\tconst detailLabel =\n\t\t\t\t\ttype === \"video\"\n\t\t\t\t\t\t? formatVideoDetail(format)\n\t\t\t\t\t\t: formatAudioDetail(format);\n\t\t\t\tconst thirdColumnLabel =\n\t\t\t\t\ttype === \"video\"\n\t\t\t\t\t\t? format.fps\n\t\t\t\t\t\t\t? `${format.fps}fps`\n\t\t\t\t\t\t\t: \"\"\n\t\t\t\t\t\t: format.acodec\n\t\t\t\t\t\t\t? format.acodec.split(\".\")[0].toUpperCase()\n\t\t\t\t\t\t\t: \"\";\n\t\t\t\tconst sizeLabel = formatSize(format.filesize || format.filesizeApprox);\n\t\t\t\tconst metaLabel = formatMetaLabel(format);\n\t\t\t\tconst isSelected = selectedFormat === format.formatId;\n\n\t\t\t\treturn (\n\t\t\t\t\t<label\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 transition-colors\",\n\t\t\t\t\t\t\tisSelected ? \"bg-primary/10\" : \"hover:bg-muted\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\thtmlFor={`${type}-${format.formatId}`}\n\t\t\t\t\t\tkey={format.formatId}\n\t\t\t\t\t>\n\t\t\t\t\t\t<RadioGroupItem\n\t\t\t\t\t\t\tclassName=\"hidden shrink-0\"\n\t\t\t\t\t\t\tid={`${type}-${format.formatId}`}\n\t\t\t\t\t\t\tvalue={format.formatId}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<div className=\"flex min-w-0 flex-1 items-center gap-4\">\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"w-16 shrink-0 font-medium text-sm\",\n\t\t\t\t\t\t\t\t\tisSelected && \"text-primary\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{qualityLabel}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t<div className=\"flex min-w-0 items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<span className=\"truncate text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t{detailLabel}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t{thirdColumnLabel && thirdColumnLabel !== \"-\" && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 rounded bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{thirdColumnLabel}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{metaLabel && (\n\t\t\t\t\t\t\t\t\t<div className=\"mt-0.5 break-words text-[10px] text-muted-foreground/70 leading-snug\">\n\t\t\t\t\t\t\t\t\t\t{metaLabel}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<span className=\"w-20 shrink-0 text-right text-muted-foreground text-xs tabular-nums\">\n\t\t\t\t\t\t\t\t{sizeLabel}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</label>\n\t\t\t\t);\n\t\t\t})}\n\t\t</RadioGroup>\n\t);\n};\n\nexport function SingleVideoDownload({\n\tloading,\n\terror,\n\tvideoInfo,\n\tstate,\n\toneClickQuality,\n\tfeedbackSourceUrl,\n\tonStateChange,\n}: SingleVideoDownloadProps) {\n\tconst { t } = useTranslation();\n\tconst [showAdvanced, setShowAdvanced] = useState(false);\n\n\tconst { title, activeTab, selectedContainer, selectedCodec, selectedFps } =\n\t\tstate;\n\tconst displayTitle =\n\t\ttitle || videoInfo?.title || t(\"download.fetchingVideoInfo\");\n\n\tconst relevantFormats = useMemo(() => {\n\t\tif (!videoInfo?.formats) {\n\t\t\treturn [];\n\t\t}\n\t\tconst baseFormats = filterFormatsByType(videoInfo.formats, activeTab);\n\t\tif (baseFormats.length === 0) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst hasHttpFormats = baseFormats.some(isHttpProtocol);\n\t\tif (!hasHttpFormats) {\n\t\t\treturn baseFormats;\n\t\t}\n\n\t\tconst nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format));\n\t\treturn nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats;\n\t}, [videoInfo?.formats, activeTab]);\n\n\tconst containers = useMemo(() => {\n\t\tif (relevantFormats.length === 0) {\n\t\t\treturn [];\n\t\t}\n\t\tconst exts = new Set(relevantFormats.map((format) => format.ext));\n\t\treturn Array.from(exts).sort();\n\t}, [relevantFormats]);\n\n\tuseEffect(() => {\n\t\tif (containers.length === 0) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tif (selectedContainer && !containers.includes(selectedContainer)) {\n\t\t\tlet defaultContainer: string;\n\t\t\tif (activeTab === \"video\") {\n\t\t\t\tdefaultContainer = containers.includes(\"mp4\") ? \"mp4\" : containers[0];\n\t\t\t} else {\n\t\t\t\tdefaultContainer = containers.includes(\"m4a\")\n\t\t\t\t\t? \"m4a\"\n\t\t\t\t\t: containers.includes(\"mp3\")\n\t\t\t\t\t\t? \"mp3\"\n\t\t\t\t\t\t: containers[0];\n\t\t\t}\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tonStateChange({\n\t\t\t\t\tselectedContainer: defaultContainer,\n\t\t\t\t\tselectedCodec: \"auto\",\n\t\t\t\t});\n\t\t\t}, 0);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\n\t\tif (!selectedContainer) {\n\t\t\tlet defaultContainer: string;\n\t\t\tif (activeTab === \"video\") {\n\t\t\t\tdefaultContainer = containers.includes(\"mp4\") ? \"mp4\" : containers[0];\n\t\t\t} else {\n\t\t\t\tdefaultContainer = containers.includes(\"m4a\")\n\t\t\t\t\t? \"m4a\"\n\t\t\t\t\t: containers.includes(\"mp3\")\n\t\t\t\t\t\t? \"mp3\"\n\t\t\t\t\t\t: containers[0];\n\t\t\t}\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tonStateChange({ selectedContainer: defaultContainer });\n\t\t\t}, 0);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\n\t\treturn undefined;\n\t}, [containers, selectedContainer, activeTab, onStateChange]);\n\n\tconst formatsByContainer = useMemo(() => {\n\t\tif (relevantFormats.length === 0) {\n\t\t\treturn [];\n\t\t}\n\n\t\tif (!selectedContainer) {\n\t\t\treturn relevantFormats;\n\t\t}\n\n\t\treturn relevantFormats.filter((format) => format.ext === selectedContainer);\n\t}, [relevantFormats, selectedContainer]);\n\n\tconst codecs = useMemo(() => {\n\t\tif (formatsByContainer.length === 0) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst setVals = new Set<string>();\n\t\tformatsByContainer.forEach((format) => {\n\t\t\tif (activeTab === \"video\") {\n\t\t\t\tconst codec = format.vcodec;\n\t\t\t\tif (codec && codec !== \"none\") {\n\t\t\t\t\tsetVals.add(getCodecShortName(codec));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst codec = format.acodec;\n\t\t\t\tif (codec && codec !== \"none\") {\n\t\t\t\t\tsetVals.add(getCodecShortName(codec));\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\treturn Array.from(setVals).sort();\n\t}, [formatsByContainer, activeTab]);\n\n\tuseEffect(() => {\n\t\tif (codecs.length === 0) {\n\t\t\treturn undefined;\n\t\t}\n\t\tif (\n\t\t\tselectedCodec &&\n\t\t\tselectedCodec !== \"auto\" &&\n\t\t\t!codecs.includes(selectedCodec)\n\t\t) {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tonStateChange({ selectedCodec: \"auto\" });\n\t\t\t}, 0);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\t\treturn undefined;\n\t}, [codecs, selectedCodec, onStateChange]);\n\n\tconst formatsByCodec = useMemo(() => {\n\t\tif (!selectedCodec || selectedCodec === \"auto\") {\n\t\t\treturn formatsByContainer;\n\t\t}\n\t\treturn formatsByContainer.filter((format) => {\n\t\t\tif (activeTab === \"video\") {\n\t\t\t\tconst codec = format.vcodec;\n\t\t\t\treturn (\n\t\t\t\t\tcodec &&\n\t\t\t\t\tcodec !== \"none\" &&\n\t\t\t\t\tgetCodecShortName(codec) === selectedCodec\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst codec = format.acodec;\n\t\t\treturn (\n\t\t\t\tcodec && codec !== \"none\" && getCodecShortName(codec) === selectedCodec\n\t\t\t);\n\t\t});\n\t}, [formatsByContainer, selectedCodec, activeTab]);\n\n\tconst framerates = useMemo(() => {\n\t\tif (activeTab !== \"video\") {\n\t\t\treturn [];\n\t\t}\n\t\tconst setVals = new Set<number>();\n\t\tformatsByCodec.forEach((format) => {\n\t\t\tif (format.fps) {\n\t\t\t\tsetVals.add(format.fps);\n\t\t\t}\n\t\t});\n\t\treturn Array.from(setVals).sort((a, b) => b - a);\n\t}, [formatsByCodec, activeTab]);\n\n\tconst filteredFormats = useMemo(() => {\n\t\tlet result = formatsByCodec;\n\t\tif (activeTab === \"video\" && selectedFps && selectedFps !== \"highest\") {\n\t\t\tresult = result.filter((format) => format.fps === Number(selectedFps));\n\t\t}\n\t\treturn result;\n\t}, [formatsByCodec, selectedFps, activeTab]);\n\n\treturn (\n\t\t<div className=\"flex min-h-0 flex-1 flex-col\">\n\t\t\t{loading && !error && (\n\t\t\t\t<div className=\"flex min-h-[200px] flex-1 flex-col items-center justify-center gap-3\">\n\t\t\t\t\t<Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t{t(\"download.fetchingVideoInfo\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{error && (\n\t\t\t\t<div className=\"mb-3 shrink-0 rounded-md border border-destructive/30 bg-destructive/5 p-3\">\n\t\t\t\t\t<div className=\"flex items-start gap-2\">\n\t\t\t\t\t\t<AlertCircle className=\"mt-0.5 h-4 w-4 shrink-0 text-destructive\" />\n\t\t\t\t\t\t<div className=\"min-w-0 flex-1 space-y-1\">\n\t\t\t\t\t\t\t<p className=\"font-medium text-destructive text-sm\">\n\t\t\t\t\t\t\t\t{t(\"errors.fetchInfoFailed\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p className=\"break-words text-muted-foreground/80 text-xs\">\n\t\t\t\t\t\t\t\t{error}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"mt-2.5 flex flex-wrap items-center gap-1.5\">\n\t\t\t\t\t\t<span className=\"font-medium text-[10px] text-muted-foreground/70\">\n\t\t\t\t\t\t\t{t(\"download.feedback.title\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className=\"flex flex-wrap gap-1.5\">\n\t\t\t\t\t\t\t<FeedbackLinkButtons\n\t\t\t\t\t\t\t\tbuttonClassName=\"h-5 gap-1 px-1.5 text-[10px]\"\n\t\t\t\t\t\t\t\tbuttonSize=\"sm\"\n\t\t\t\t\t\t\t\tbuttonVariant=\"outline\"\n\t\t\t\t\t\t\t\terror={error}\n\t\t\t\t\t\t\t\ticonClassName=\"h-2.5 w-2.5\"\n\t\t\t\t\t\t\t\tissueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}\n\t\t\t\t\t\t\t\tsourceUrl={feedbackSourceUrl}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{!loading && videoInfo && (\n\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col\">\n\t\t\t\t\t<div className=\"flex shrink-0 gap-4 py-4\">\n\t\t\t\t\t\t<div className=\"relative w-32 shrink-0 overflow-hidden rounded-md bg-muted\">\n\t\t\t\t\t\t\t<RemoteImage\n\t\t\t\t\t\t\t\talt={displayTitle}\n\t\t\t\t\t\t\t\tcacheResolver={resolveImageProxyUrl}\n\t\t\t\t\t\t\t\tclassName=\"aspect-video h-full w-full object-cover\"\n\t\t\t\t\t\t\t\tsrc={videoInfo.thumbnail}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"absolute right-1 bottom-1 rounded bg-black/80 px-1 text-[10px] text-white\">\n\t\t\t\t\t\t\t\t{formatDuration(videoInfo.duration)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex min-w-0 flex-1 flex-col justify-between py-0.5\">\n\t\t\t\t\t\t\t<div className=\"space-y-0.5\">\n\t\t\t\t\t\t\t\t<h3 className=\"line-clamp-2 font-bold text-[13px] leading-tight\">\n\t\t\t\t\t\t\t\t\t{displayTitle}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t{videoInfo.uploader && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"max-w-[140px] truncate font-semibold uppercase tracking-wider opacity-70\">\n\t\t\t\t\t\t\t\t\t\t\t{videoInfo.uploader}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{videoInfo.webpageUrl && (\n\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"transition-colors hover:text-primary\"\n\t\t\t\t\t\t\t\t\t\t\thref={videoInfo.webpageUrl}\n\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<ExternalLink className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t\t<div className=\"flex gap-0.5 rounded-md bg-muted p-0.5\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"h-5 rounded-sm px-2 text-[11px]\",\n\t\t\t\t\t\t\t\t\t\t\tactiveTab === \"video\"\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-background text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground/60\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\tonClick={() => onStateChange({ activeTab: \"video\" })}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant={activeTab === \"video\" ? \"secondary\" : \"ghost\"}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t(\"download.video\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"h-5 rounded-sm px-2 text-[11px]\",\n\t\t\t\t\t\t\t\t\t\t\tactiveTab === \"audio\"\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-background text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground/60\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\tonClick={() => onStateChange({ activeTab: \"audio\" })}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant={activeTab === \"audio\" ? \"secondary\" : \"ghost\"}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t(\"download.audio\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"h-6 w-6 rounded-full p-0 font-normal text-muted-foreground transition-colors hover:bg-muted\",\n\t\t\t\t\t\t\t\t\t\tshowAdvanced && \"bg-muted text-foreground\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonClick={() => setShowAdvanced(!showAdvanced)}\n\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Settings2 className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<Separator />\n\n\t\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"grid transition-all duration-300 ease-in-out\",\n\t\t\t\t\t\t\t\tshowAdvanced\n\t\t\t\t\t\t\t\t\t? \"grid-rows-[1fr] border-b py-3\"\n\t\t\t\t\t\t\t\t\t: \"grid-rows-[0fr]\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"min-h-0 overflow-hidden\">\n\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-end gap-3\">\n\t\t\t\t\t\t\t\t\t<div className=\"min-w-[120px] flex-1 space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t<Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"download.metadata.format\")}\n\t\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tdisabled={containers.length <= 1}\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tonStateChange({ selectedContainer: value })\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={selectedContainer || \"\"}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"h-8 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue placeholder=\"Container\" />\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t{containers.map((ext) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem className=\"text-xs\" key={ext} value={ext}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{ext.toUpperCase()}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div className=\"min-w-[120px] flex-1 space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t<Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\tCodec\n\t\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tdisabled={codecs.length <= 1}\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tonStateChange({ selectedCodec: value })\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={selectedCodec || \"auto\"}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"h-8 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue placeholder=\"Auto\" />\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem className=\"text-xs\" value=\"auto\">\n\t\t\t\t\t\t\t\t\t\t\t\t\tAuto\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t{codecs.map((codecName) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={codecName}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={codecName}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{codecName}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t{activeTab === \"video\" && (\n\t\t\t\t\t\t\t\t\t\t<div className=\"min-w-[120px] flex-1 space-y-1.5\">\n\t\t\t\t\t\t\t\t\t\t\t<Label className=\"px-0.5 font-medium text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\tFrame Rate\n\t\t\t\t\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={framerates.length === 0}\n\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tonStateChange({ selectedFps: value })\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={selectedFps || \"highest\"}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"h-8 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue placeholder=\"Highest\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem className=\"text-xs\" value=\"highest\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tHighest\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{framerates.map((fps) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={fps}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={String(fps)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{fps} fps\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<ScrollArea className=\"my-3 max-h-72 flex-1 overflow-y-auto\">\n\t\t\t\t\t\t\t<FormatList\n\t\t\t\t\t\t\t\tcodec={selectedCodec}\n\t\t\t\t\t\t\t\tformats={filteredFormats}\n\t\t\t\t\t\t\t\tonFormatChange={(formatId) =>\n\t\t\t\t\t\t\t\t\tonStateChange(\n\t\t\t\t\t\t\t\t\t\tactiveTab === \"video\"\n\t\t\t\t\t\t\t\t\t\t\t? { selectedVideoFormat: formatId }\n\t\t\t\t\t\t\t\t\t\t\t: { selectedAudioFormat: formatId },\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\toneClickQuality={oneClickQuality}\n\t\t\t\t\t\t\t\tselectedFormat={\n\t\t\t\t\t\t\t\t\tactiveTab === \"video\"\n\t\t\t\t\t\t\t\t\t\t? state.selectedVideoFormat\n\t\t\t\t\t\t\t\t\t\t: state.selectedAudioFormat\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\ttype={activeTab}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</ScrollArea>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "apps/web/src/components/download/types.ts",
    "content": "import type { DownloadTask } from \"@vidbee/downloader-core\";\n\nexport type DownloadRecord = DownloadTask & {\n\tentryType: \"active\" | \"history\";\n};\n\nexport type StatusFilter = \"all\" | \"active\" | \"completed\" | \"error\";\n"
  },
  {
    "path": "apps/web/src/components/layout/app-shell.tsx",
    "content": "import { useNavigate } from \"@tanstack/react-router\";\nimport {\n\tAppSidebar,\n\ttype AppSidebarItem,\n} from \"@vidbee/ui/components/ui/app-sidebar\";\nimport { appSidebarIcons } from \"@vidbee/ui/components/ui/app-sidebar-icons\";\nimport type { ReactNode } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\ntype AppPage = \"about\" | \"download\" | \"settings\";\n\ninterface AppShellProps {\n\tchildren: ReactNode;\n\tpage: AppPage;\n}\n\nexport const AppShell = ({ children, page }: AppShellProps) => {\n\tconst { t } = useTranslation();\n\tconst navigate = useNavigate();\n\n\tconst openSupportedSites = () => {\n\t\twindow.open(\n\t\t\t\"https://vidbee.org/supported-sites/\",\n\t\t\t\"_blank\",\n\t\t\t\"noopener,noreferrer\",\n\t\t);\n\t};\n\n\tconst items: AppSidebarItem[] = [\n\t\t{\n\t\t\tid: \"home\",\n\t\t\tactive: page === \"download\",\n\t\t\ticon: appSidebarIcons.home,\n\t\t\tlabel: t(\"menu.download\"),\n\t\t\tonClick: () => {\n\t\t\t\tvoid navigate({ to: \"/\" });\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"subscriptions\",\n\t\t\tdisabled: true,\n\t\t\ticon: appSidebarIcons.subscriptions,\n\t\t\tlabel: t(\"menu.rss\"),\n\t\t},\n\t\t{\n\t\t\tid: \"supported-sites\",\n\t\t\ticon: appSidebarIcons.supportedSites,\n\t\t\tlabel: t(\"menu.supportedSites\"),\n\t\t\tonClick: openSupportedSites,\n\t\t},\n\t];\n\n\tconst bottomItems: AppSidebarItem[] = [\n\t\t{\n\t\t\tid: \"settings\",\n\t\t\tactive: page === \"settings\",\n\t\t\ticon: appSidebarIcons.settings,\n\t\t\tlabel: t(\"menu.preferences\"),\n\t\t\tshowLabel: false,\n\t\t\tshowTooltip: true,\n\t\t\tonClick: () => {\n\t\t\t\tvoid navigate({ to: \"/settings\" });\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"about\",\n\t\t\tactive: page === \"about\",\n\t\t\ticon: appSidebarIcons.about,\n\t\t\tlabel: t(\"menu.about\"),\n\t\t\tonClick: () => {\n\t\t\t\tvoid navigate({ to: \"/about\" });\n\t\t\t},\n\t\t\tshowLabel: false,\n\t\t\tshowTooltip: true,\n\t\t},\n\t];\n\n\treturn (\n\t\t<div className=\"flex h-screen flex-row\">\n\t\t\t<AppSidebar\n\t\t\t\tappName=\"VidBee\"\n\t\t\t\tbottomItems={bottomItems}\n\t\t\t\titems={items}\n\t\t\t\tlogoAlt=\"VidBee\"\n\t\t\t\tlogoSrc=\"/app-icon.png\"\n\t\t\t/>\n\n\t\t\t<main className=\"flex min-h-0 flex-1 flex-col overflow-hidden bg-background\">\n\t\t\t\t<div className=\"h-full flex-1 overflow-y-auto overflow-x-hidden\">\n\t\t\t\t\t{children}\n\t\t\t\t</div>\n\t\t\t</main>\n\t\t</div>\n\t);\n};\n"
  },
  {
    "path": "apps/web/src/components/pages/about-page.tsx",
    "content": "import { Badge } from \"@vidbee/ui/components/ui/badge\";\nimport { Button } from \"@vidbee/ui/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@vidbee/ui/components/ui/card\";\nimport { Progress } from \"@vidbee/ui/components/ui/progress\";\nimport { Switch } from \"@vidbee/ui/components/ui/switch\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n\tDownload,\n\tFacebook,\n\tFileText,\n\tGithub,\n\tLink as LinkIcon,\n\tMessageSquare,\n\tRefreshCw,\n\tTwitter,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { orpcClient } from \"../../lib/orpc-client\";\nimport { AppShell } from \"../layout/app-shell\";\n\ninterface AboutResource {\n\ticon: LucideIcon;\n\tlabel: string;\n\tdescription?: string;\n\tactionLabel: string;\n\thref?: string;\n}\n\ntype LatestVersionState =\n\t| { status: \"available\"; version: string }\n\t| { status: \"uptodate\"; version: string }\n\t| { status: \"error\"; error?: string }\n\t| null;\n\nconst AUTO_UPDATE_KEY = \"vidbee.web.auto-update\";\nconst PREVIEW_CHANNEL_KEY = \"vidbee.web.preview-channel\";\nconst SHARE_TARGET_URL = \"https://vidbee.org\";\nconst APP_VERSION = __APP_VERSION__;\n\nconst readStoredBoolean = (key: string, fallbackValue: boolean): boolean => {\n\tif (typeof window === \"undefined\") {\n\t\treturn fallbackValue;\n\t}\n\n\tconst value = window.localStorage.getItem(key);\n\tif (value === \"true\") {\n\t\treturn true;\n\t}\n\tif (value === \"false\") {\n\t\treturn false;\n\t}\n\n\treturn fallbackValue;\n};\n\nconst writeStoredBoolean = (key: string, value: boolean): void => {\n\tif (typeof window === \"undefined\") {\n\t\treturn;\n\t}\n\n\twindow.localStorage.setItem(key, value ? \"true\" : \"false\");\n};\n\nexport const AboutPage = () => {\n\tconst { t } = useTranslation();\n\tconst [appVersion] = useState(APP_VERSION);\n\tconst [osVersion, setOsVersion] = useState(\"-\");\n\tconst [autoUpdate, setAutoUpdate] = useState(() =>\n\t\treadStoredBoolean(AUTO_UPDATE_KEY, true),\n\t);\n\tconst [previewChannel, setPreviewChannel] = useState(() =>\n\t\treadStoredBoolean(PREVIEW_CHANNEL_KEY, false),\n\t);\n\tconst [latestVersionState, setLatestVersionState] =\n\t\tuseState<LatestVersionState>(null);\n\tconst [updateDownloadProgress] = useState<number | null>(null);\n\n\tuseEffect(() => {\n\t\tconst loadStatus = async () => {\n\t\t\ttry {\n\t\t\t\tawait orpcClient.status();\n\t\t\t\tsetOsVersion(window.navigator.userAgent || \"-\");\n\t\t\t} catch {\n\t\t\t\tsetOsVersion(\"-\");\n\t\t\t}\n\t\t};\n\n\t\tvoid loadStatus();\n\t}, []);\n\n\tconst setAutoUpdateValue = (value: boolean) => {\n\t\tsetAutoUpdate(value);\n\t\twriteStoredBoolean(AUTO_UPDATE_KEY, value);\n\t};\n\n\tconst setPreviewChannelValue = (value: boolean) => {\n\t\tsetPreviewChannel(value);\n\t\twriteStoredBoolean(PREVIEW_CHANNEL_KEY, value);\n\t};\n\n\tconst openShareUrl = useCallback((url: string) => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\twindow.open(url, \"_blank\", \"noopener,noreferrer\");\n\t}, []);\n\n\tconst handleGoToDownload = () => {\n\t\topenShareUrl(\"https://vidbee.org/download/\");\n\t};\n\n\tconst handleCheckForUpdates = async () => {\n\t\ttry {\n\t\t\tconst result = await orpcClient.status();\n\t\t\tsetLatestVersionState({\n\t\t\t\tstatus: \"uptodate\",\n\t\t\t\tversion: result.version || appVersion,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tsetLatestVersionState({\n\t\t\t\tstatus: \"error\",\n\t\t\t\terror: error instanceof Error ? error.message : \"Unknown error\",\n\t\t\t});\n\t\t}\n\t};\n\n\tconst shareLinks = useMemo(() => {\n\t\tconst encodedUrl = encodeURIComponent(SHARE_TARGET_URL);\n\t\tconst encodedText = encodeURIComponent(\n\t\t\t`${t(\"about.description\")} @nexmoex`,\n\t\t);\n\n\t\treturn {\n\t\t\tfacebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,\n\t\t\ttwitter: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`,\n\t\t};\n\t}, [t]);\n\n\tconst handleShareTwitter = () => {\n\t\topenShareUrl(shareLinks.twitter);\n\t};\n\n\tconst handleShareFacebook = () => {\n\t\topenShareUrl(shareLinks.facebook);\n\t};\n\n\tconst handleCopyShareLink = async () => {\n\t\tif (typeof navigator === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(SHARE_TARGET_URL);\n\t\t} catch {\n\t\t\t// no-op\n\t\t}\n\t};\n\n\tconst latestVersionBadgeText =\n\t\tlatestVersionState &&\n\t\tlatestVersionState.status !== \"error\" &&\n\t\tlatestVersionState.version\n\t\t\t? t(\"about.latestVersionBadge\", { version: latestVersionState.version })\n\t\t\t: null;\n\tconst latestVersionStatusKey = latestVersionState\n\t\t? `about.latestVersionStatus.${latestVersionState.status}`\n\t\t: null;\n\tconst latestVersionStatusClass =\n\t\tlatestVersionState?.status === \"available\"\n\t\t\t? \"text-primary\"\n\t\t\t: latestVersionState?.status === \"error\"\n\t\t\t\t? \"text-destructive\"\n\t\t\t\t: \"text-muted-foreground\";\n\tconst latestVersionStatusText = latestVersionStatusKey\n\t\t? t(latestVersionStatusKey)\n\t\t: null;\n\n\tconst aboutResources = useMemo<AboutResource[]>(\n\t\t() => [\n\t\t\t{\n\t\t\t\ticon: LinkIcon,\n\t\t\t\tlabel: t(\"about.resources.website\"),\n\t\t\t\tdescription: t(\"about.resources.websiteDescription\"),\n\t\t\t\tactionLabel: t(\"about.actions.visit\"),\n\t\t\t\thref: \"https://vidbee.org/\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ticon: FileText,\n\t\t\t\tlabel: t(\"about.resources.changelog\"),\n\t\t\t\tdescription: t(\"about.resources.changelogDescription\"),\n\t\t\t\tactionLabel: t(\"about.actions.view\"),\n\t\t\t\thref: \"https://github.com/nexmoe/VidBee/releases\",\n\t\t\t},\n\t\t],\n\t\t[t],\n\t);\n\n\treturn (\n\t\t<AppShell page=\"about\">\n\t\t\t<div className=\"h-full bg-background\">\n\t\t\t\t<div className=\"container mx-auto max-w-5xl space-y-6 p-6\">\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CardContent className=\"pt-6\">\n\t\t\t\t\t\t\t<div className=\"flex flex-col gap-4\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\talt=\"VidBee\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"h-18 w-18 rounded-2xl\"\n\t\t\t\t\t\t\t\t\t\tsrc=\"/app-icon.png\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className=\"flex-1 space-y-2\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-4\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t\t\t\t<h2 className=\"font-semibold text-2xl leading-tight\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.appName\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t\t\t\t\t<Badge variant=\"secondary\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.versionLabel\", {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tversion: appVersion || \"-\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionState ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionBadgeText ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Badge variant=\"outline\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionBadgeText}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionStatusText ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`text-sm ${latestVersionStatusClass}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionStatusText}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Button asChild size=\"sm\" variant=\"outline\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"about.actions.openRepo\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thref=\"https://github.com/nexmoe/vidbee\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Github className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t{latestVersionState?.status === \"available\" ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={handleGoToDownload}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Download className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.actions.goToDownload\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={handleCheckForUpdates}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RefreshCw className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.actions.checkUpdates\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"about.description\")}\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{updateDownloadProgress !== null ? (\n\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-3 pt-4\">\n\t\t\t\t\t\t\t\t\t<div className=\"w-full space-y-2\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.downloadingUpdate\")}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"font-medium text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t{updateDownloadProgress.toFixed(1)}%\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<Progress className=\"h-2\" value={updateDownloadProgress} />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-4 pt-6\">\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t<p className=\"font-medium leading-none\">\n\t\t\t\t\t\t\t\t\t\t{t(\"about.autoUpdateTitle\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t{t(\"about.autoUpdateDescription\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\tchecked={autoUpdate}\n\t\t\t\t\t\t\t\t\tonCheckedChange={setAutoUpdateValue}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-4 pt-6\">\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t<p className=\"font-medium leading-none\">\n\t\t\t\t\t\t\t\t\t\t{t(\"about.betaProgramTitle\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t{t(\"about.betaProgramDescription\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\tchecked={previewChannel}\n\t\t\t\t\t\t\t\t\tonCheckedChange={setPreviewChannelValue}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-xs\">{osVersion}</p>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t</Card>\n\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>{t(\"about.shareTitle\")}</CardTitle>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardContent className=\"space-y-4\">\n\t\t\t\t\t\t\t<div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm md:max-w-md\">\n\t\t\t\t\t\t\t\t\t{t(\"about.shareSupport\")}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleShareTwitter}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Twitter className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t{t(\"about.shareActions.twitter\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleShareFacebook}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Facebook className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t{t(\"about.shareActions.facebook\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleCopyShareLink}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<LinkIcon className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t{t(\"about.shareActions.copy\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm md:max-w-md\">\n\t\t\t\t\t\t\t\t\t{t(\"about.followAuthorSupport\")}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => openShareUrl(\"https://x.com/nexmoex\")}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Twitter className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t{t(\"about.followAuthorActions.follow\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t</Card>\n\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CardContent className=\"p-0\">\n\t\t\t\t\t\t\t<div className=\"flex flex-col divide-y\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-4 px-6 py-4\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-muted/60\">\n\t\t\t\t\t\t\t\t\t\t\t<MessageSquare className=\"h-5 w-5 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"font-medium leading-none\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.resources.feedback\")}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.resources.feedbackDescription\")}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tasChild\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\thref=\"https://github.com/nexmoe/VidBee/issues/new/choose\"\n\t\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Github className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.resources.githubIssues\")}\n\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tasChild\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\thref=\"https://x.com/intent/tweet?text=%40nexmoex%20VidBee\"\n\t\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Twitter className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.resources.xFeedback\")}\n\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tasChild\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"gap-2\"\n\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\thref=\"https://discord.gg/uBqXV6QPdm\"\n\t\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<MessageSquare className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"about.resources.discord\")}\n\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t{aboutResources.map((resource) => {\n\t\t\t\t\t\t\t\t\tconst Icon = resource.icon;\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-between gap-4 px-6 py-4\"\n\t\t\t\t\t\t\t\t\t\t\tkey={resource.label}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-muted/60\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Icon className=\"h-5 w-5 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"font-medium leading-none\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{resource.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{resource.description ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{resource.description}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<Button asChild size=\"sm\" variant=\"outline\">\n\t\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\t\thref={resource.href}\n\t\t\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{resource.actionLabel}\n\t\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t</Card>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</AppShell>\n\t);\n};\n"
  },
  {
    "path": "apps/web/src/components/pages/download-page.tsx",
    "content": "import { Button } from \"@vidbee/ui/components/ui/button\";\nimport { CardContent, CardHeader } from \"@vidbee/ui/components/ui/card\";\nimport { Checkbox } from \"@vidbee/ui/components/ui/checkbox\";\nimport {\n\tbuildFilePathCandidates,\n\tnormalizeSavedFileName,\n} from \"@vidbee/downloader-core/download-file\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@vidbee/ui/components/ui/dialog\";\nimport { DownloadEmptyState } from \"@vidbee/ui/components/ui/download-empty-state\";\nimport {\n\tDownloadFilterBar,\n\ttype DownloadFilterItem,\n} from \"@vidbee/ui/components/ui/download-filter-bar\";\nimport { ScrollArea } from \"@vidbee/ui/components/ui/scroll-area\";\nimport { cn } from \"@vidbee/ui/lib/cn\";\nimport { useCallback, useEffect, useId, useMemo, useState } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { eventsUrl, orpcClient } from \"../../lib/orpc-client\";\nimport { readOrpcDownloadSettings } from \"../../lib/orpc-download-settings\";\nimport { readWebSettings } from \"../../lib/web-settings\";\nimport { DownloadDialog } from \"../download/download-dialog\";\nimport { DownloadItem } from \"../download/download-item\";\nimport { PlaylistDownloadGroup } from \"../download/playlist-download-group\";\nimport type { DownloadRecord, StatusFilter } from \"../download/types\";\nimport { AppShell } from \"../layout/app-shell\";\n\ntype ConfirmAction =\n\t| { type: \"delete-selected\"; ids: string[] }\n\t| {\n\t\t\ttype: \"delete-playlist\";\n\t\t\tplaylistId: string;\n\t\t\ttitle: string;\n\t\t\tids: string[];\n\t  };\n\nconst POLL_INTERVAL_MS = 2000;\n\nconst isEditableTarget = (target: EventTarget | null): boolean => {\n\tif (!(target && target instanceof HTMLElement)) {\n\t\treturn false;\n\t}\n\tif (target.isContentEditable) {\n\t\treturn true;\n\t}\n\tconst tagName = target.tagName;\n\treturn tagName === \"INPUT\" || tagName === \"TEXTAREA\" || tagName === \"SELECT\";\n};\n\nconst resolveDownloadExtension = (record: DownloadRecord): string => {\n\tconst savedExt = normalizeSavedFileName(record.savedFileName)\n\t\t? record.savedFileName?.split(\".\").at(-1)?.toLowerCase()\n\t\t: undefined;\n\tif (savedExt) {\n\t\treturn savedExt;\n\t}\n\treturn record.type === \"audio\" ? \"mp3\" : \"mp4\";\n};\n\nexport const DownloadPage = () => {\n\tconst { t } = useTranslation();\n\tconst [allRecords, setAllRecords] = useState<DownloadRecord[]>([]);\n\tconst [statusFilter, setStatusFilter] = useState<StatusFilter>(\"all\");\n\tconst [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n\tconst [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(\n\t\tnull,\n\t);\n\tconst [confirmBusy, setConfirmBusy] = useState(false);\n\tconst [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false);\n\tconst alsoDeleteFilesId = useId();\n\tconst [isApiReachable, setIsApiReachable] = useState(false);\n\tconst [apiConnectionMessage, setApiConnectionMessage] = useState(\"\");\n\n\tconst refreshData = useCallback(async () => {\n\t\ttry {\n\t\t\tconst [downloadsResult, historyResult] = await Promise.all([\n\t\t\t\torpcClient.downloads.list(),\n\t\t\t\torpcClient.history.list(),\n\t\t\t]);\n\n\t\t\tconst activeEntries: DownloadRecord[] = downloadsResult.downloads.map(\n\t\t\t\t(record) => ({\n\t\t\t\t\t...record,\n\t\t\t\t\tentryType: \"active\",\n\t\t\t\t}),\n\t\t\t);\n\t\t\tconst historyEntries: DownloadRecord[] = historyResult.history.map(\n\t\t\t\t(record) => ({\n\t\t\t\t\t...record,\n\t\t\t\t\tentryType: \"history\",\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst merged = [...activeEntries, ...historyEntries].sort(\n\t\t\t\t(left, right) => {\n\t\t\t\t\tconst leftTime = left.completedAt ?? left.createdAt;\n\t\t\t\t\tconst rightTime = right.completedAt ?? right.createdAt;\n\t\t\t\t\treturn rightTime - leftTime;\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tsetAllRecords(merged);\n\t\t\tsetIsApiReachable(true);\n\t\t\tsetApiConnectionMessage(\"\");\n\t\t} catch (error) {\n\t\t\tsetIsApiReachable(false);\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : t(\"errors.networkError\");\n\t\t\tsetApiConnectionMessage(message);\n\t\t}\n\t}, [t]);\n\n\tuseEffect(() => {\n\t\tvoid refreshData();\n\t\tconst timer = window.setInterval(() => {\n\t\t\tvoid refreshData();\n\t\t}, POLL_INTERVAL_MS);\n\n\t\treturn () => {\n\t\t\twindow.clearInterval(timer);\n\t\t};\n\t}, [refreshData]);\n\n\tuseEffect(() => {\n\t\tif (!isApiReachable) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst source = new EventSource(eventsUrl);\n\t\tconst onChanged = () => {\n\t\t\tvoid refreshData();\n\t\t};\n\t\tconst onError = () => {\n\t\t\tsetIsApiReachable(false);\n\t\t\tsource.close();\n\t\t};\n\n\t\tsource.addEventListener(\"task-updated\", onChanged);\n\t\tsource.addEventListener(\"queue-updated\", onChanged);\n\t\tsource.addEventListener(\"error\", onError);\n\n\t\treturn () => {\n\t\t\tsource.removeEventListener(\"task-updated\", onChanged);\n\t\t\tsource.removeEventListener(\"queue-updated\", onChanged);\n\t\t\tsource.removeEventListener(\"error\", onError);\n\t\t\tsource.close();\n\t\t};\n\t}, [isApiReachable, refreshData]);\n\n\tconst historyRecords = useMemo(\n\t\t() => allRecords.filter((record) => record.entryType === \"history\"),\n\t\t[allRecords],\n\t);\n\n\tconst downloadStats = useMemo(() => {\n\t\treturn allRecords.reduce(\n\t\t\t(acc, item) => {\n\t\t\t\tacc.total += 1;\n\t\t\t\tif (\n\t\t\t\t\t(item.entryType === \"active\" && item.status === \"downloading\") ||\n\t\t\t\t\titem.status === \"processing\" ||\n\t\t\t\t\titem.status === \"pending\"\n\t\t\t\t) {\n\t\t\t\t\tacc.active += 1;\n\t\t\t\t}\n\t\t\t\tif (item.status === \"completed\") {\n\t\t\t\t\tacc.completed += 1;\n\t\t\t\t}\n\t\t\t\tif (item.status === \"error\") {\n\t\t\t\t\tacc.error += 1;\n\t\t\t\t}\n\t\t\t\treturn acc;\n\t\t\t},\n\t\t\t{ total: 0, active: 0, completed: 0, error: 0 },\n\t\t);\n\t}, [allRecords]);\n\n\tconst filteredRecords = useMemo(() => {\n\t\treturn allRecords.filter((record) => {\n\t\t\tswitch (statusFilter) {\n\t\t\t\tcase \"all\":\n\t\t\t\t\treturn true;\n\t\t\t\tcase \"active\":\n\t\t\t\t\treturn (\n\t\t\t\t\t\trecord.status === \"downloading\" ||\n\t\t\t\t\t\trecord.status === \"processing\" ||\n\t\t\t\t\t\trecord.status === \"pending\"\n\t\t\t\t\t);\n\t\t\t\tcase \"completed\":\n\t\t\t\tcase \"error\":\n\t\t\t\t\treturn record.status === statusFilter;\n\t\t\t\tdefault:\n\t\t\t\t\treturn true;\n\t\t\t}\n\t\t});\n\t}, [allRecords, statusFilter]);\n\n\tconst visibleHistoryIds = useMemo(\n\t\t() =>\n\t\t\tfilteredRecords\n\t\t\t\t.filter((record) => record.entryType === \"history\")\n\t\t\t\t.map((record) => record.id),\n\t\t[filteredRecords],\n\t);\n\n\tconst filters: Array<DownloadFilterItem<StatusFilter>> = [\n\t\t{ key: \"all\", label: t(\"download.all\"), count: downloadStats.total },\n\t\t{ key: \"active\", label: t(\"download.active\"), count: downloadStats.active },\n\t\t{\n\t\t\tkey: \"completed\",\n\t\t\tlabel: t(\"download.completed\"),\n\t\t\tcount: downloadStats.completed,\n\t\t},\n\t\t{ key: \"error\", label: t(\"download.error\"), count: downloadStats.error },\n\t];\n\n\tconst selectableIds = useMemo(() => {\n\t\tif (visibleHistoryIds.length === 0) {\n\t\t\treturn [];\n\t\t}\n\t\tconst ids = new Set(visibleHistoryIds);\n\t\tconst playlistIds = new Set(\n\t\t\tfilteredRecords\n\t\t\t\t.filter((record) => record.entryType === \"history\" && record.playlistId)\n\t\t\t\t.map((record) => record.playlistId as string),\n\t\t);\n\t\tif (playlistIds.size === 0) {\n\t\t\treturn Array.from(ids);\n\t\t}\n\t\tfor (const record of historyRecords) {\n\t\t\tif (record.playlistId && playlistIds.has(record.playlistId)) {\n\t\t\t\tids.add(record.id);\n\t\t\t}\n\t\t}\n\t\treturn Array.from(ids);\n\t}, [filteredRecords, historyRecords, visibleHistoryIds]);\n\n\tconst selectableCount = selectableIds.length;\n\tconst selectedCount = selectedIds.size;\n\tconst visibleSelectableCount = visibleHistoryIds.length;\n\tconst selectionSummary =\n\t\tselectableCount === 0\n\t\t\t? t(\"history.selectedCount\", { count: selectedCount })\n\t\t\t: selectableCount > visibleSelectableCount\n\t\t\t\t? t(\"history.selectedCount\", { count: selectedCount })\n\t\t\t\t: t(\"history.selectionSummary\", {\n\t\t\t\t\t\tselected: selectedCount,\n\t\t\t\t\t\ttotal: selectableCount,\n\t\t\t\t\t});\n\n\tuseEffect(() => {\n\t\tif (selectedIds.size === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst historyIdSet = new Set(historyRecords.map((record) => record.id));\n\t\tsetSelectedIds((prev) => {\n\t\t\tlet changed = false;\n\t\t\tconst next = new Set<string>();\n\t\t\tfor (const id of prev) {\n\t\t\t\tif (historyIdSet.has(id)) {\n\t\t\t\t\tnext.add(id);\n\t\t\t\t} else {\n\t\t\t\t\tchanged = true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn changed ? next : prev;\n\t\t});\n\t}, [historyRecords, selectedIds.size]);\n\n\tconst handleToggleSelect = (id: string) => {\n\t\tsetSelectedIds((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tif (next.has(id)) {\n\t\t\t\tnext.delete(id);\n\t\t\t} else {\n\t\t\t\tnext.add(id);\n\t\t\t}\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst handleClearSelection = () => {\n\t\tsetSelectedIds(new Set());\n\t};\n\n\tconst handleRequestDeleteSelected = () => {\n\t\tif (selectedIds.size === 0) {\n\t\t\treturn;\n\t\t}\n\t\tsetConfirmAction({ type: \"delete-selected\", ids: Array.from(selectedIds) });\n\t};\n\n\tconst handleRequestDeletePlaylist = (\n\t\tplaylistId: string,\n\t\ttitle: string,\n\t\tids: string[],\n\t) => {\n\t\tif (ids.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tsetConfirmAction({ type: \"delete-playlist\", playlistId, title, ids });\n\t};\n\n\tconst pruneSelectedIds = (ids: string[]) => {\n\t\tif (ids.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tsetSelectedIds((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tlet changed = false;\n\t\t\tids.forEach((id) => {\n\t\t\t\tif (next.delete(id)) {\n\t\t\t\t\tchanged = true;\n\t\t\t\t}\n\t\t\t});\n\t\t\treturn changed ? next : prev;\n\t\t});\n\t};\n\n\tconst confirmContent = useMemo(() => {\n\t\tif (!confirmAction) {\n\t\t\treturn null;\n\t\t}\n\t\tswitch (confirmAction.type) {\n\t\t\tcase \"delete-selected\": {\n\t\t\t\treturn {\n\t\t\t\t\ttitle: t(\"history.confirmDeleteSelectedTitle\"),\n\t\t\t\t\tdescription: t(\"history.confirmDeleteSelectedDescription\", {\n\t\t\t\t\t\tcount: confirmAction.ids.length,\n\t\t\t\t\t}),\n\t\t\t\t\tactionLabel: t(\"history.removeAction\"),\n\t\t\t\t};\n\t\t\t}\n\t\t\tcase \"delete-playlist\": {\n\t\t\t\treturn {\n\t\t\t\t\ttitle: t(\"history.confirmDeletePlaylistTitle\"),\n\t\t\t\t\tdescription: t(\"history.confirmDeletePlaylistDescription\", {\n\t\t\t\t\t\tcount: confirmAction.ids.length,\n\t\t\t\t\t\ttitle: confirmAction.title,\n\t\t\t\t\t}),\n\t\t\t\t\tactionLabel: t(\"history.removeAction\"),\n\t\t\t\t};\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn null;\n\t\t}\n\t}, [confirmAction, t]);\n\n\tconst handleConfirmAction = async () => {\n\t\tif (!confirmAction) {\n\t\t\treturn;\n\t\t}\n\t\tsetConfirmBusy(true);\n\t\ttry {\n\t\t\tif (confirmAction.type === \"delete-selected\") {\n\t\t\t\tconst selectedHistoryRecords = allRecords.filter(\n\t\t\t\t\t(record) =>\n\t\t\t\t\t\tconfirmAction.ids.includes(record.id) && record.entryType === \"history\",\n\t\t\t\t);\n\n\t\t\t\tif (alsoDeleteFiles) {\n\t\t\t\t\tconst fallbackPath = readWebSettings().downloadPath.trim();\n\t\t\t\t\tconst candidatePaths = new Set<string>();\n\n\t\t\t\t\tfor (const record of selectedHistoryRecords) {\n\t\t\t\t\t\tconst downloadPath = record.downloadPath?.trim() || fallbackPath;\n\t\t\t\t\t\tif (!(downloadPath && record.title)) {\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst extension = resolveDownloadExtension(record);\n\t\t\t\t\t\tconst candidates = buildFilePathCandidates(\n\t\t\t\t\t\t\tdownloadPath,\n\t\t\t\t\t\t\trecord.title,\n\t\t\t\t\t\t\textension,\n\t\t\t\t\t\t\trecord.savedFileName,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tfor (const candidate of candidates) {\n\t\t\t\t\t\t\tcandidatePaths.add(candidate);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tawait Promise.allSettled(\n\t\t\t\t\t\tArray.from(candidatePaths).map(async (candidate) => {\n\t\t\t\t\t\t\tawait orpcClient.files.deleteFile({ path: candidate });\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst result = await orpcClient.history.removeItems({\n\t\t\t\t\tids: confirmAction.ids,\n\t\t\t\t});\n\t\t\t\tpruneSelectedIds(confirmAction.ids);\n\t\t\t\tawait refreshData();\n\t\t\t\ttoast.success(\n\t\t\t\t\tt(\"notifications.itemsRemoved\", { count: result.removed }),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (confirmAction.type === \"delete-playlist\") {\n\t\t\t\tconst result = await orpcClient.history.removeByPlaylist({\n\t\t\t\t\tplaylistId: confirmAction.playlistId,\n\t\t\t\t});\n\t\t\t\tpruneSelectedIds(confirmAction.ids);\n\t\t\t\tawait refreshData();\n\t\t\t\ttoast.success(\n\t\t\t\t\tt(\"notifications.playlistHistoryRemoved\", { count: result.removed }),\n\t\t\t\t);\n\t\t\t}\n\t\t\tsetConfirmAction(null);\n\t\t\tsetAlsoDeleteFiles(false);\n\t\t} catch (error) {\n\t\t\tif (confirmAction.type === \"delete-selected\") {\n\t\t\t\tconsole.error(\"Failed to remove selected history items:\", error);\n\t\t\t\ttoast.error(t(\"notifications.itemsRemoveFailed\"));\n\t\t\t}\n\t\t\tif (confirmAction.type === \"delete-playlist\") {\n\t\t\t\tconsole.error(\"Failed to remove playlist history:\", error);\n\t\t\t\ttoast.error(t(\"notifications.playlistHistoryRemoveFailed\"));\n\t\t\t}\n\t\t} finally {\n\t\t\tsetConfirmBusy(false);\n\t\t}\n\t};\n\n\tconst groupedView = useMemo(() => {\n\t\tconst groups = new Map<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t\ttotalCount: number;\n\t\t\t\trecords: DownloadRecord[];\n\t\t\t}\n\t\t>();\n\t\tconst order: Array<\n\t\t\t{ type: \"group\"; id: string } | { type: \"single\"; record: DownloadRecord }\n\t\t> = [];\n\n\t\tfor (const record of filteredRecords) {\n\t\t\tif (record.playlistId) {\n\t\t\t\tlet group = groups.get(record.playlistId);\n\t\t\t\tif (!group) {\n\t\t\t\t\tgroup = {\n\t\t\t\t\t\tid: record.playlistId,\n\t\t\t\t\t\ttitle:\n\t\t\t\t\t\t\trecord.playlistTitle || record.title || t(\"playlist.untitled\"),\n\t\t\t\t\t\ttotalCount: record.playlistSize || 0,\n\t\t\t\t\t\trecords: [],\n\t\t\t\t\t};\n\t\t\t\t\tgroups.set(record.playlistId, group);\n\t\t\t\t\torder.push({ type: \"group\", id: record.playlistId });\n\t\t\t\t}\n\t\t\t\tgroup.records.push(record);\n\t\t\t\tif (!group.title && record.playlistTitle) {\n\t\t\t\t\tgroup.title = record.playlistTitle;\n\t\t\t\t}\n\t\t\t\tif (!group.totalCount && record.playlistSize) {\n\t\t\t\t\tgroup.totalCount = record.playlistSize;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\torder.push({ type: \"single\", record });\n\t\t\t}\n\t\t}\n\n\t\tfor (const group of groups.values()) {\n\t\t\tgroup.records.sort((a, b) => {\n\t\t\t\tconst aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER;\n\t\t\t\tconst bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER;\n\t\t\t\tif (aIndex !== bIndex) {\n\t\t\t\t\treturn aIndex - bIndex;\n\t\t\t\t}\n\t\t\t\treturn b.createdAt - a.createdAt;\n\t\t\t});\n\t\t\tif (!group.totalCount) {\n\t\t\t\tgroup.totalCount = group.records.length;\n\t\t\t}\n\t\t}\n\n\t\treturn { order, groups };\n\t}, [filteredRecords, t]);\n\n\tuseEffect(() => {\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.defaultPrevented) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (isEditableTarget(event.target)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tif (confirmAction) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (selectedIds.size === 0) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetSelectedIds(new Set());\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!(event.metaKey || event.ctrlKey)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.key.toLowerCase() !== \"a\") {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (selectableIds.length === 0) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tevent.preventDefault();\n\t\t\tsetSelectedIds(new Set(selectableIds));\n\t\t};\n\n\t\twindow.addEventListener(\"keydown\", handleKeyDown);\n\t\treturn () => window.removeEventListener(\"keydown\", handleKeyDown);\n\t}, [confirmAction, selectableIds, selectedIds]);\n\n\tconst handleCancelDownload = async (id: string) => {\n\t\ttry {\n\t\t\tawait orpcClient.downloads.cancel({ id });\n\t\t\tawait refreshData();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to cancel download:\", error);\n\t\t\ttoast.error(t(\"notifications.downloadFailed\"));\n\t\t}\n\t};\n\n\tconst handleRetryDownload = async (download: DownloadRecord) => {\n\t\tif (!download.url) {\n\t\t\ttoast.error(t(\"errors.emptyUrl\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait orpcClient.downloads.create({\n\t\t\t\turl: download.url,\n\t\t\t\ttype: download.type,\n\t\t\t\ttitle: download.title,\n\t\t\t\tthumbnail: download.thumbnail,\n\t\t\t\tduration: download.duration,\n\t\t\t\tdescription: download.description,\n\t\t\t\tchannel: download.channel,\n\t\t\t\tuploader: download.uploader,\n\t\t\t\tviewCount: download.viewCount,\n\t\t\t\ttags: download.tags,\n\t\t\t\tselectedFormat: download.selectedFormat,\n\t\t\t\tplaylistId: download.playlistId,\n\t\t\t\tplaylistTitle: download.playlistTitle,\n\t\t\t\tplaylistIndex: download.playlistIndex,\n\t\t\t\tplaylistSize: download.playlistSize,\n\t\t\t\tformat: download.selectedFormat?.formatId,\n\t\t\t\taudioFormat: download.type === \"audio\" ? \"mp3\" : undefined,\n\t\t\t\tsettings: readOrpcDownloadSettings(),\n\t\t\t});\n\t\t\tawait refreshData();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to retry download:\", error);\n\t\t\ttoast.error(t(\"notifications.downloadFailed\"));\n\t\t}\n\t};\n\n\tconst handleRemoveHistoryRecord = async (id: string) => {\n\t\ttry {\n\t\t\tawait orpcClient.history.removeItems({ ids: [id] });\n\t\t\tpruneSelectedIds([id]);\n\t\t\tawait refreshData();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to remove history record:\", error);\n\t\t\ttoast.error(t(\"notifications.removeFailed\"));\n\t\t}\n\t};\n\n\tconst handleCopyUrl = async (url: string) => {\n\t\tif (!navigator.clipboard?.writeText) {\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(url);\n\t\t\ttoast.success(t(\"notifications.urlCopied\"));\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to copy url:\", error);\n\t\t\ttoast.error(t(\"notifications.copyFailed\"));\n\t\t}\n\t};\n\n\treturn (\n\t\t<AppShell page=\"download\">\n\t\t\t<div className={cn(\"flex h-full flex-col\")}>\n\t\t\t\t<CardHeader className=\"z-50 gap-4 bg-background p-0 px-6 py-4 backdrop-blur\">\n\t\t\t\t\t<DownloadFilterBar\n\t\t\t\t\t\tactions={<DownloadDialog onDownloadsChanged={refreshData} />}\n\t\t\t\t\t\tactiveFilter={statusFilter}\n\t\t\t\t\t\tfilters={filters}\n\t\t\t\t\t\tonFilterChange={setStatusFilter}\n\t\t\t\t\t/>\n\t\t\t\t\t{!isApiReachable && apiConnectionMessage ? (\n\t\t\t\t\t\t<p className=\"font-medium text-destructive text-sm\">\n\t\t\t\t\t\t\t{apiConnectionMessage}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t) : null}\n\t\t\t\t</CardHeader>\n\n\t\t\t\t<ScrollArea className=\"flex-1 overflow-y-auto\">\n\t\t\t\t\t<CardContent className=\"w-full space-y-3 overflow-x-hidden p-0\">\n\t\t\t\t\t\t{filteredRecords.length === 0 ? (\n\t\t\t\t\t\t\t<DownloadEmptyState\n\t\t\t\t\t\t\t\tclassName=\"mx-6 mb-4\"\n\t\t\t\t\t\t\t\tmessage={t(\"download.noItems\")}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"w-full pb-4\">\n\t\t\t\t\t\t\t\t{groupedView.order.map((item) => {\n\t\t\t\t\t\t\t\t\tif (item.type === \"single\") {\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<DownloadItem\n\t\t\t\t\t\t\t\t\t\t\t\tdownload={item.record}\n\t\t\t\t\t\t\t\t\t\t\t\tisSelected={selectedIds.has(item.record.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tkey={`${item.record.entryType}:${item.record.id}`}\n\t\t\t\t\t\t\t\t\t\t\t\tonCancel={handleCancelDownload}\n\t\t\t\t\t\t\t\t\t\t\t\tonCopyUrl={handleCopyUrl}\n\t\t\t\t\t\t\t\t\t\t\t\tonRemove={handleRemoveHistoryRecord}\n\t\t\t\t\t\t\t\t\t\t\t\tonRetry={handleRetryDownload}\n\t\t\t\t\t\t\t\t\t\t\t\tonToggleSelect={handleToggleSelect}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\tconst group = groupedView.groups.get(item.id);\n\t\t\t\t\t\t\t\t\tif (!group) {\n\t\t\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<PlaylistDownloadGroup\n\t\t\t\t\t\t\t\t\t\t\tgroupId={group.id}\n\t\t\t\t\t\t\t\t\t\t\tkey={`group:${group.id}`}\n\t\t\t\t\t\t\t\t\t\t\tonCancel={handleCancelDownload}\n\t\t\t\t\t\t\t\t\t\t\tonCopyUrl={handleCopyUrl}\n\t\t\t\t\t\t\t\t\t\t\tonDeletePlaylist={handleRequestDeletePlaylist}\n\t\t\t\t\t\t\t\t\t\t\tonRemove={handleRemoveHistoryRecord}\n\t\t\t\t\t\t\t\t\t\t\tonRetry={handleRetryDownload}\n\t\t\t\t\t\t\t\t\t\t\tonToggleSelect={handleToggleSelect}\n\t\t\t\t\t\t\t\t\t\t\trecords={group.records}\n\t\t\t\t\t\t\t\t\t\t\tselectedIds={selectedIds}\n\t\t\t\t\t\t\t\t\t\t\ttitle={group.title}\n\t\t\t\t\t\t\t\t\t\t\ttotalCount={group.totalCount}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</CardContent>\n\t\t\t\t</ScrollArea>\n\n\t\t\t\t{selectedCount > 0 && (\n\t\t\t\t\t<div className=\"fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:w-auto sm:translate-x-0\">\n\t\t\t\t\t\t<div className=\"flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 py-2 pr-2 pl-5 shadow-lg backdrop-blur\">\n\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground text-xs\">\n\t\t\t\t\t\t\t\t\t{selectionSummary}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tclassName=\"h-8 rounded-full px-3\"\n\t\t\t\t\t\t\t\t\tonClick={handleClearSelection}\n\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t(\"history.clearSelection\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tclassName=\"h-8 rounded-full px-3\"\n\t\t\t\t\t\t\t\t\tonClick={handleRequestDeleteSelected}\n\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\tvariant=\"destructive\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t(\"history.deleteSelected\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t<Dialog\n\t\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\t\tif (!(open || confirmBusy)) {\n\t\t\t\t\t\t\tsetConfirmAction(null);\n\t\t\t\t\t\t\tsetAlsoDeleteFiles(false);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\topen={Boolean(confirmAction)}\n\t\t\t\t>\n\t\t\t\t\t{confirmContent && (\n\t\t\t\t\t\t<DialogContent>\n\t\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t\t<DialogTitle>{confirmContent.title}</DialogTitle>\n\t\t\t\t\t\t\t\t<DialogDescription>\n\t\t\t\t\t\t\t\t\t{confirmContent.description}\n\t\t\t\t\t\t\t\t</DialogDescription>\n\t\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t\t{confirmAction?.type === \"delete-selected\" && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center space-x-2\">\n\t\t\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\t\t\tchecked={alsoDeleteFiles}\n\t\t\t\t\t\t\t\t\t\tid={alsoDeleteFilesId}\n\t\t\t\t\t\t\t\t\t\tonCheckedChange={(checked) =>\n\t\t\t\t\t\t\t\t\t\t\tsetAlsoDeleteFiles(checked === true)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\tclassName=\"cursor-pointer font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n\t\t\t\t\t\t\t\t\t\thtmlFor={alsoDeleteFilesId}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Trans i18nKey=\"history.alsoDeleteFiles\" />\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tdisabled={confirmBusy}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetConfirmAction(null);\n\t\t\t\t\t\t\t\t\t\tsetAlsoDeleteFiles(false);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t(\"download.cancel\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tdisabled={confirmBusy}\n\t\t\t\t\t\t\t\t\tonClick={handleConfirmAction}\n\t\t\t\t\t\t\t\t\tvariant=\"destructive\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{confirmContent.actionLabel}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t</DialogContent>\n\t\t\t\t\t)}\n\t\t\t\t</Dialog>\n\t\t\t</div>\n\t\t</AppShell>\n\t);\n};\n"
  },
  {
    "path": "apps/web/src/components/pages/settings-page.tsx",
    "content": "import {\n\tbuildBrowserCookiesSetting,\n\tparseBrowserCookiesSetting,\n} from \"@vidbee/downloader-core/browser-cookies-setting\";\nimport {\n\ttype LanguageCode,\n\tlanguageList,\n\tnormalizeLanguageCode,\n} from \"@vidbee/i18n/languages\";\nimport { Button } from \"@vidbee/ui/components/ui/button\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@vidbee/ui/components/ui/dialog\";\nimport { Input } from \"@vidbee/ui/components/ui/input\";\nimport {\n\tItem,\n\tItemActions,\n\tItemContent,\n\tItemDescription,\n\tItemGroup,\n\tItemSeparator,\n\tItemTitle,\n} from \"@vidbee/ui/components/ui/item\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@vidbee/ui/components/ui/select\";\nimport { Switch } from \"@vidbee/ui/components/ui/switch\";\nimport {\n\tTabs,\n\tTabsContent,\n\tTabsList,\n\tTabsTrigger,\n} from \"@vidbee/ui/components/ui/tabs\";\nimport {\n\tTooltip,\n\tTooltipContent,\n\tTooltipTrigger,\n} from \"@vidbee/ui/components/ui/tooltip\";\nimport { AlertTriangle, Folder, RefreshCw } from \"lucide-react\";\nimport { type ChangeEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { useWebSettings } from \"../../hooks/use-web-settings\";\nimport type { OneClickQualityPreset } from \"../../lib/download-format-preferences\";\nimport { orpcClient } from \"../../lib/orpc-client\";\nimport type { ThemeValue, WebAppSettings } from \"../../lib/web-settings\";\nimport { AppShell } from \"../layout/app-shell\";\n\ntype SettingsTab = \"advanced\" | \"cookies\" | \"general\";\n\ntype BrowserProfileValidationReason =\n\t| \"browserUnsupported\"\n\t| \"empty\"\n\t| \"pathNotFound\"\n\t| \"profileNotFound\";\n\ninterface BrowserProfileValidation {\n\tvalid: boolean;\n\treason?: BrowserProfileValidationReason;\n}\n\ninterface ServerDirectoryEntry {\n\tname: string;\n\tpath: string;\n}\n\nconst WINDOWS_PLATFORM = \"win32\";\nconst MAC_PLATFORM = \"darwin\";\nconst MAX_SETTINGS_UPLOAD_BYTES = 500_000;\n\nconst parsePlatform = (userAgent: string): string => {\n\tconst normalizedUserAgent = userAgent.toLowerCase();\n\tif (normalizedUserAgent.includes(\"mac os\")) {\n\t\treturn MAC_PLATFORM;\n\t}\n\tif (normalizedUserAgent.includes(\"windows\")) {\n\t\treturn WINDOWS_PLATFORM;\n\t}\n\tif (normalizedUserAgent.includes(\"linux\")) {\n\t\treturn \"linux\";\n\t}\n\treturn \"web\";\n};\n\nconst ABSOLUTE_WINDOWS_PATH_REGEX = /^[A-Za-z]:\\\\/;\n\nconst validateBrowserProfile = (\n\tbrowser: string,\n\tprofile: string,\n\tplatform: string,\n): BrowserProfileValidation => {\n\tconst trimmedProfile = profile.trim();\n\tif (!trimmedProfile) {\n\t\treturn { valid: false, reason: \"empty\" };\n\t}\n\n\tif (browser === \"safari\" && platform !== MAC_PLATFORM) {\n\t\treturn { valid: false, reason: \"browserUnsupported\" };\n\t}\n\n\tconst hasPathSeparator =\n\t\ttrimmedProfile.includes(\"/\") || trimmedProfile.includes(\"\\\\\");\n\tif (hasPathSeparator) {\n\t\tconst isAbsolutePath =\n\t\t\ttrimmedProfile.startsWith(\"/\") ||\n\t\t\tABSOLUTE_WINDOWS_PATH_REGEX.test(trimmedProfile);\n\t\tif (!isAbsolutePath) {\n\t\t\treturn { valid: false, reason: \"pathNotFound\" };\n\t\t}\n\t}\n\n\tif (trimmedProfile.length < 2) {\n\t\treturn { valid: false, reason: \"profileNotFound\" };\n\t}\n\n\treturn { valid: true };\n};\n\nconst toSelectString = (value: number): string => value.toString();\n\nconst getBrowserProfileWarningMessage = (\n\treason: BrowserProfileValidationReason | undefined,\n\tt: (key: string) => string,\n): string => {\n\tswitch (reason) {\n\t\tcase \"pathNotFound\":\n\t\t\treturn t(\"settings.browserForCookiesProfileInvalidPath\");\n\t\tcase \"profileNotFound\":\n\t\t\treturn t(\"settings.browserForCookiesProfileInvalidProfile\");\n\t\tcase \"browserUnsupported\":\n\t\t\treturn t(\"settings.browserForCookiesProfileInvalidUnsupported\");\n\t\tcase \"empty\":\n\t\t\treturn t(\"settings.browserForCookiesProfileInvalidEmpty\");\n\t\tdefault:\n\t\t\treturn t(\"settings.browserForCookiesProfileInvalid\");\n\t}\n};\n\nconst updateSingleSetting = <K extends keyof WebAppSettings>(\n\tkey: K,\n\tvalue: WebAppSettings[K],\n\tupdateSettings: (updates: Partial<WebAppSettings>) => void,\n) => {\n\tupdateSettings({ [key]: value } as Pick<WebAppSettings, K>);\n};\n\nexport const SettingsPage = () => {\n\tconst { t } = useTranslation();\n\tconst { settings, updateSettings } = useWebSettings();\n\tconst [platform, setPlatform] = useState<string>(\"web\");\n\tconst [activeTab, setActiveTab] = useState<SettingsTab>(\"general\");\n\tconst [downloadPathDialogOpen, setDownloadPathDialogOpen] = useState(false);\n\tconst [serverPathLoading, setServerPathLoading] = useState(false);\n\tconst [serverPathError, setServerPathError] = useState<string | null>(null);\n\tconst [serverCurrentPath, setServerCurrentPath] = useState(\"\");\n\tconst [serverPathInput, setServerPathInput] = useState(\"\");\n\tconst [serverParentPath, setServerParentPath] = useState<string | null>(null);\n\tconst [serverDirectories, setServerDirectories] = useState<\n\t\tServerDirectoryEntry[]\n\t>([]);\n\tconst configFileInputRef = useRef<HTMLInputElement>(null);\n\tconst cookiesFileInputRef = useRef<HTMLInputElement>(null);\n\tconst [configFileUploading, setConfigFileUploading] = useState(false);\n\tconst [cookiesFileUploading, setCookiesFileUploading] = useState(false);\n\n\tconst parsedBrowserCookies = parseBrowserCookiesSetting(\n\t\tsettings.browserForCookies,\n\t);\n\tconst browserForCookiesValue = parsedBrowserCookies.browser;\n\tconst browserCookiesProfileValue = parsedBrowserCookies.profile;\n\n\tconst normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(\n\t\tbrowserForCookiesValue,\n\t\tbrowserCookiesProfileValue,\n\t);\n\n\tconst browserProfileValidation = useMemo(\n\t\t() =>\n\t\t\tvalidateBrowserProfile(\n\t\t\t\tbrowserForCookiesValue,\n\t\t\t\tbrowserCookiesProfileValue,\n\t\t\t\tplatform,\n\t\t\t),\n\t\t[browserForCookiesValue, browserCookiesProfileValue, platform],\n\t);\n\n\tconst hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0;\n\tconst showBrowserProfileWarning =\n\t\thasBrowserProfileValue &&\n\t\t!browserProfileValidation.valid &&\n\t\tbrowserProfileValidation.reason !== \"empty\";\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tsetPlatform(parsePlatform(window.navigator.userAgent));\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tconst searchParams = new URLSearchParams(window.location.search);\n\t\tconst tab = searchParams.get(\"tab\");\n\t\tif (tab === \"general\" || tab === \"advanced\" || tab === \"cookies\") {\n\t\t\tsetActiveTab(tab);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (settings.browserForCookies === normalizedBrowserCookiesSetting) {\n\t\t\treturn;\n\t\t}\n\n\t\tupdateSingleSetting(\n\t\t\t\"browserForCookies\",\n\t\t\tnormalizedBrowserCookiesSetting,\n\t\t\tupdateSettings,\n\t\t);\n\t}, [\n\t\tnormalizedBrowserCookiesSetting,\n\t\tsettings.browserForCookies,\n\t\tupdateSettings,\n\t]);\n\n\tconst languageOptions = languageList;\n\tconst activeLanguageCode = normalizeLanguageCode(settings.language);\n\tconst currentLanguage =\n\t\tlanguageOptions.find((option) => option.value === activeLanguageCode) ??\n\t\tlanguageOptions[0];\n\n\tconst handleThemeChange = (value: ThemeValue) => {\n\t\tif (settings.theme === value) {\n\t\t\treturn;\n\t\t}\n\t\tupdateSingleSetting(\"theme\", value, updateSettings);\n\t};\n\n\tconst handleLanguageChange = (value: LanguageCode) => {\n\t\tif (settings.language === value) {\n\t\t\treturn;\n\t\t}\n\t\tupdateSingleSetting(\"language\", value, updateSettings);\n\t};\n\n\tconst loadServerDirectories = async (targetPath?: string) => {\n\t\tsetServerPathLoading(true);\n\t\tsetServerPathError(null);\n\n\t\ttry {\n\t\t\tconst response = await orpcClient.files.listDirectories({\n\t\t\t\tpath: targetPath?.trim() || undefined,\n\t\t\t});\n\t\t\tsetServerCurrentPath(response.currentPath);\n\t\t\tsetServerPathInput(response.currentPath);\n\t\t\tsetServerParentPath(response.parentPath);\n\t\t\tsetServerDirectories(response.directories);\n\t\t} catch {\n\t\t\tsetServerPathError(t(\"errors.networkError\"));\n\t\t} finally {\n\t\t\tsetServerPathLoading(false);\n\t\t}\n\t};\n\n\tconst handleOpenDownloadPathDialog = () => {\n\t\tsetDownloadPathDialogOpen(true);\n\t\tsetServerPathInput(settings.downloadPath);\n\t\tvoid loadServerDirectories(settings.downloadPath);\n\t};\n\n\tconst handleNavigateServerDirectory = (targetPath: string) => {\n\t\tvoid loadServerDirectories(targetPath);\n\t};\n\n\tconst handleSubmitServerPathInput = () => {\n\t\tconst targetPath = serverPathInput.trim();\n\t\tif (!targetPath) {\n\t\t\treturn;\n\t\t}\n\n\t\thandleNavigateServerDirectory(targetPath);\n\t};\n\n\tconst handleSelectCurrentServerPath = () => {\n\t\tconst selectedPath = serverPathInput.trim() || serverCurrentPath.trim();\n\t\tif (!selectedPath) {\n\t\t\treturn;\n\t\t}\n\n\t\tupdateSingleSetting(\"downloadPath\", selectedPath, updateSettings);\n\t\tsetDownloadPathDialogOpen(false);\n\t};\n\n\tconst uploadSelectedSettingsFile = async (\n\t\tkind: \"config\" | \"cookies\",\n\t\tfile: File,\n\t): Promise<string> => {\n\t\tif (file.size > MAX_SETTINGS_UPLOAD_BYTES) {\n\t\t\tthrow new Error(t(\"settings.fileSelectError\"));\n\t\t}\n\n\t\tconst content = await file.text();\n\t\tconst response = await orpcClient.files.uploadSettingsFile({\n\t\t\tkind,\n\t\t\tfileName: file.name,\n\t\t\tcontent,\n\t\t});\n\t\treturn response.path;\n\t};\n\n\tconst handleSelectConfigFile = () => {\n\t\tconfigFileInputRef.current?.click();\n\t};\n\n\tconst handleSelectCookiesFile = () => {\n\t\tcookiesFileInputRef.current?.click();\n\t};\n\n\tconst handleConfigFileInputChange = (\n\t\tevent: ChangeEvent<HTMLInputElement>,\n\t) => {\n\t\tconst selectedFile = event.target.files?.[0];\n\t\tevent.target.value = \"\";\n\t\tif (!selectedFile) {\n\t\t\treturn;\n\t\t}\n\n\t\tsetConfigFileUploading(true);\n\t\tvoid uploadSelectedSettingsFile(\"config\", selectedFile)\n\t\t\t.then((serverPath) => {\n\t\t\t\tupdateSingleSetting(\"configPath\", serverPath, updateSettings);\n\t\t\t})\n\t\t\t.catch((error: unknown) => {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error && error.message.trim().length > 0\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: t(\"settings.fileSelectError\");\n\t\t\t\ttoast.error(message);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetConfigFileUploading(false);\n\t\t\t});\n\t};\n\n\tconst handleCookiesFileInputChange = (\n\t\tevent: ChangeEvent<HTMLInputElement>,\n\t) => {\n\t\tconst selectedFile = event.target.files?.[0];\n\t\tevent.target.value = \"\";\n\t\tif (!selectedFile) {\n\t\t\treturn;\n\t\t}\n\n\t\tsetCookiesFileUploading(true);\n\t\tvoid uploadSelectedSettingsFile(\"cookies\", selectedFile)\n\t\t\t.then((serverPath) => {\n\t\t\t\tupdateSingleSetting(\"cookiesPath\", serverPath, updateSettings);\n\t\t\t})\n\t\t\t.catch((error: unknown) => {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error && error.message.trim().length > 0\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: t(\"settings.fileSelectError\");\n\t\t\t\ttoast.error(message);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetCookiesFileUploading(false);\n\t\t\t});\n\t};\n\n\tconst handleOpenCookiesGuide = () => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\t\twindow.open(\n\t\t\t\"https://docs.vidbee.org/cookies\",\n\t\t\t\"_blank\",\n\t\t\t\"noopener,noreferrer\",\n\t\t);\n\t};\n\n\treturn (\n\t\t<AppShell page=\"settings\">\n\t\t\t<div className=\"h-full bg-background\">\n\t\t\t\t<div className=\"container mx-auto max-w-4xl space-y-6 p-6\">\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<h1 className=\"font-bold text-3xl tracking-tight\">\n\t\t\t\t\t\t\t{t(\"settings.title\")}\n\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t<p className=\"text-muted-foreground\">{t(\"settings.description\")}</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<Tabs\n\t\t\t\t\t\tonValueChange={(value) => setActiveTab(value as SettingsTab)}\n\t\t\t\t\t\tvalue={activeTab}\n\t\t\t\t\t>\n\t\t\t\t\t\t<TabsList className=\"grid w-full grid-cols-3\">\n\t\t\t\t\t\t\t<TabsTrigger value=\"general\">{t(\"settings.general\")}</TabsTrigger>\n\t\t\t\t\t\t\t<TabsTrigger value=\"cookies\">\n\t\t\t\t\t\t\t\t{t(\"settings.cookiesTab\")}\n\t\t\t\t\t\t\t</TabsTrigger>\n\t\t\t\t\t\t\t<TabsTrigger value=\"advanced\">\n\t\t\t\t\t\t\t\t{t(\"settings.advanced\")}\n\t\t\t\t\t\t\t</TabsTrigger>\n\t\t\t\t\t\t</TabsList>\n\n\t\t\t\t\t\t<TabsContent className=\"mt-2 space-y-4\" value=\"general\">\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.downloadPath\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.downloadPathDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex w-full max-w-md gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={settings.downloadPath}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Button onClick={handleOpenDownloadPathDialog}>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.selectPath\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.theme\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.themeDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\thandleThemeChange(value as ThemeValue)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={settings.theme}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-32\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"light\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.light\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"dark\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.dark\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"system\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.system\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.language\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.languageDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\thandleLanguageChange(value as LanguageCode)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={currentLanguage.value}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-52\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue placeholder={currentLanguage.name}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`${currentLanguage.flag} rounded-xs text-base`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span lang={currentLanguage.hreflang}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{currentLanguage.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectValue>\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t{languageOptions.map((option) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toption.value === currentLanguage.value\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-muted font-semibold\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={option.value}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`${option.flag} rounded-xs text-base`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span lang={option.hreflang}>{option.name}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.oneClickDownload\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickDownloadDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.oneClickDownload}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"oneClickDownload\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t{settings.oneClickDownload && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<ItemSeparator />\n\t\t\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t\t\t<ItemTitle>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickDownloadType\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickDownloadTypeDescription\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"oneClickDownloadType\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue as \"audio\" | \"video\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={settings.oneClickDownloadType}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-32\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"video\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.video\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"audio\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"download.audio\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t\t\t<ItemSeparator />\n\t\t\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.oneClickQuality\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityDescription\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"oneClickQuality\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue as OneClickQualityPreset,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={settings.oneClickQuality}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-40\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"best\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityOptions.best\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"good\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityOptions.good\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"normal\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityOptions.normal\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"bad\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityOptions.bad\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"worst\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.oneClickQualityOptions.worst\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</ItemGroup>\n\t\t\t\t\t\t</TabsContent>\n\n\t\t\t\t\t\t<TabsContent className=\"mt-2 space-y-4\" value=\"advanced\">\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.embedSubs\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.embedSubsDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.embedSubs}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\"embedSubs\", value, updateSettings)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.embedThumbnail\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.embedThumbnailDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.embedThumbnail}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"embedThumbnail\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.embedMetadata\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.embedMetadataDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.embedMetadata}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"embedMetadata\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.embedChapters\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.embedChaptersDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.embedChapters}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"embedChapters\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.shareWatermark\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.shareWatermarkDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.shareWatermark}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"shareWatermark\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.maxConcurrentDownloads\")}\n\t\t\t\t\t\t\t\t\t\t</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.maxConcurrentDownloadsDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"maxConcurrentDownloads\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tNumber(value),\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={toSelectString(settings.maxConcurrentDownloads)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-20\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem key={num} value={num.toString()}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{num}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.proxy\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.proxyDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-64\"\n\t\t\t\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"proxy\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tevent.target.value,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tplaceholder={t(\"settings.proxyPlaceholder\")}\n\t\t\t\t\t\t\t\t\t\t\tvalue={settings.proxy}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.configFile\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.configFileDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex w-full max-w-md gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={settings.configPath}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={configFileUploading}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={handleSelectConfigFile}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{configFileUploading\n\t\t\t\t\t\t\t\t\t\t\t\t\t? t(\"download.loading\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t: t(\"settings.selectPath\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={configFileUploading || !settings.configPath}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\"configPath\", \"\", updateSettings)\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.clearConfigFile\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.enableAnalytics\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.enableAnalyticsDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={settings.enableAnalytics}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"enableAnalytics\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue,\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\t\t\t\t\t\t</TabsContent>\n\n\t\t\t\t\t\t<TabsContent className=\"mt-2 space-y-4\" value=\"cookies\">\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.browserForCookies\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserForCookiesDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t\t{platform === WINDOWS_PLATFORM && (\n\t\t\t\t\t\t\t\t\t\t\t<ItemDescription className=\"text-red-500\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserForCookiesWindowsNote\")}\n\t\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"browserForCookies\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tbuildBrowserCookiesSetting(value, \"\"),\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tvalue={browserForCookiesValue}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"w-32\">\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"none\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.none\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"chrome\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.chrome\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"chromium\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.chromium\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"firefox\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.firefox\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"edge\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.edge\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"safari\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.safari\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"brave\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.brave\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"opera\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.opera\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"vivaldi\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.vivaldi\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem value=\"whale\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserOptions.whale\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent className=\"basis-full\">\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserForCookiesProfile\")}\n\t\t\t\t\t\t\t\t\t\t</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.browserForCookiesProfileDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions className=\"basis-full\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"relative w-full\">\n\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"w-full pr-10\"\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={browserForCookiesValue === \"none\"}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"browserForCookies\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbuildBrowserCookiesSetting(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbrowserForCookiesValue,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tevent.target.value,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSettings,\n\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"settings.browserForCookiesProfilePlaceholder\",\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={browserCookiesProfileValue}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t{showBrowserProfileWarning ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<Tooltip>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TooltipTrigger asChild>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"absolute top-1/2 right-3 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<AlertTriangle aria-hidden className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</TooltipTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TooltipContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getBrowserProfileWarningMessage(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbrowserProfileValidation.reason,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</TooltipContent>\n\t\t\t\t\t\t\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.cookiesFile\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.cookiesFileDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex w-full max-w-md gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={settings.cookiesPath}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={cookiesFileUploading}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={handleSelectCookiesFile}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{cookiesFileUploading\n\t\t\t\t\t\t\t\t\t\t\t\t\t? t(\"download.loading\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t: t(\"settings.selectPath\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={cookiesFileUploading || !settings.cookiesPath}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tupdateSingleSetting(\"cookiesPath\", \"\", updateSettings)\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.clearCookiesFile\")}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\n\t\t\t\t\t\t\t<ItemGroup>\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.cookiesHelpTitle\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ul className=\"list-inside list-disc space-y-1 text-muted-foreground text-sm leading-normal\">\n\t\t\t\t\t\t\t\t\t\t\t<li>{t(\"settings.cookiesHelpBrowser\")}</li>\n\t\t\t\t\t\t\t\t\t\t\t<li>{t(\"settings.cookiesHelpFile\")}</li>\n\t\t\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t</Item>\n\n\t\t\t\t\t\t\t\t<ItemSeparator />\n\n\t\t\t\t\t\t\t\t<Item variant=\"muted\">\n\t\t\t\t\t\t\t\t\t<ItemContent>\n\t\t\t\t\t\t\t\t\t\t<ItemTitle>{t(\"settings.cookiesGuideTitle\")}</ItemTitle>\n\t\t\t\t\t\t\t\t\t\t<ItemDescription>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.cookiesGuideDescription\")}\n\t\t\t\t\t\t\t\t\t\t</ItemDescription>\n\t\t\t\t\t\t\t\t\t</ItemContent>\n\t\t\t\t\t\t\t\t\t<ItemActions>\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-0\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={handleOpenCookiesGuide}\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"link\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{t(\"settings.cookiesGuideLink\")}\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</ItemActions>\n\t\t\t\t\t\t\t\t</Item>\n\t\t\t\t\t\t\t</ItemGroup>\n\t\t\t\t\t\t</TabsContent>\n\t\t\t\t\t</Tabs>\n\n\t\t\t\t\t<input\n\t\t\t\t\t\tclassName=\"sr-only\"\n\t\t\t\t\t\tonChange={handleConfigFileInputChange}\n\t\t\t\t\t\tref={configFileInputRef}\n\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t/>\n\t\t\t\t\t<input\n\t\t\t\t\t\taccept=\".txt\"\n\t\t\t\t\t\tclassName=\"sr-only\"\n\t\t\t\t\t\tonChange={handleCookiesFileInputChange}\n\t\t\t\t\t\tref={cookiesFileInputRef}\n\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t/>\n\n\t\t\t\t\t<Dialog\n\t\t\t\t\t\tonOpenChange={setDownloadPathDialogOpen}\n\t\t\t\t\t\topen={downloadPathDialogOpen}\n\t\t\t\t\t>\n\t\t\t\t\t\t<DialogContent className=\"sm:max-w-2xl\">\n\t\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t\t<DialogTitle>{t(\"settings.downloadPath\")}</DialogTitle>\n\t\t\t\t\t\t\t\t<DialogDescription>\n\t\t\t\t\t\t\t\t\t{t(\"settings.downloadPathDescription\")}\n\t\t\t\t\t\t\t\t</DialogDescription>\n\t\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tonChange={(event) => setServerPathInput(event.target.value)}\n\t\t\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\t\t\tif (event.key !== \"Enter\") {\n\t\t\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\t\thandleSubmitServerPathInput();\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tvalue={serverPathInput}\n\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tdisabled={serverPathLoading || !serverParentPath}\n\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\tserverParentPath\n\t\t\t\t\t\t\t\t\t\t\t\t? handleNavigateServerDirectory(serverParentPath)\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t(\"download.back\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tdisabled={serverPathLoading || !serverPathInput.trim()}\n\t\t\t\t\t\t\t\t\t\tonClick={handleSubmitServerPathInput}\n\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<RefreshCw className=\"mr-1 h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t{t(\"download.fetch\")}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<div className=\"max-h-64 overflow-auto rounded-md border\">\n\t\t\t\t\t\t\t\t\t{serverPathLoading ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"p-3 text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"download.loading\")}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t{serverPathError ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"p-3 text-destructive text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t{serverPathError}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t{!serverPathLoading &&\n\t\t\t\t\t\t\t\t\t!serverPathError &&\n\t\t\t\t\t\t\t\t\tserverDirectories.length === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"p-3 text-muted-foreground text-sm\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"download.noItems\")}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t{!serverPathLoading && !serverPathError ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"divide-y\">\n\t\t\t\t\t\t\t\t\t\t\t{serverDirectories.map((directory) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={directory.path}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleNavigateServerDirectory(directory.path)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Folder className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"truncate\">{directory.name}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tonClick={() => setDownloadPathDialogOpen(false)}\n\t\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t(\"download.cancel\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tdisabled={serverPathLoading || !serverCurrentPath}\n\t\t\t\t\t\t\t\t\tonClick={handleSelectCurrentServerPath}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t(\"settings.selectPath\")}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t</DialogContent>\n\t\t\t\t\t</Dialog>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</AppShell>\n\t);\n};\n"
  },
  {
    "path": "apps/web/src/env.d.ts",
    "content": "declare const __APP_VERSION__: string;\n"
  },
  {
    "path": "apps/web/src/hooks/use-web-download-settings.ts",
    "content": "import {\n\tDEFAULT_WEB_DOWNLOAD_SETTINGS,\n\ttype WebDownloadSettings,\n} from \"../lib/download-format-preferences\";\nimport { useWebSettings } from \"./use-web-settings\";\n\nexport const useWebDownloadSettings = () => {\n\tconst { settings: webSettings, updateSettings: updateWebSettings } =\n\t\tuseWebSettings();\n\n\tconst settings: WebDownloadSettings = {\n\t\toneClickDownload:\n\t\t\twebSettings.oneClickDownload ??\n\t\t\tDEFAULT_WEB_DOWNLOAD_SETTINGS.oneClickDownload,\n\t\toneClickDownloadType:\n\t\t\twebSettings.oneClickDownloadType ??\n\t\t\tDEFAULT_WEB_DOWNLOAD_SETTINGS.oneClickDownloadType,\n\t\toneClickQuality:\n\t\t\twebSettings.oneClickQuality ??\n\t\t\tDEFAULT_WEB_DOWNLOAD_SETTINGS.oneClickQuality,\n\t};\n\n\tconst updateSettings = (updates: Partial<WebDownloadSettings>) => {\n\t\tupdateWebSettings(updates);\n\t};\n\n\treturn {\n\t\tsettings,\n\t\tupdateSettings,\n\t};\n};\n"
  },
  {
    "path": "apps/web/src/hooks/use-web-settings.ts",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { i18n } from \"../lib/i18n\";\nimport { orpcClient } from \"../lib/orpc-client\";\nimport {\n\tapplyThemeToDocument,\n\tdefaultWebSettings,\n\treadWebSettings,\n\tWEB_SETTINGS_STORAGE_KEY,\n\ttype WebAppSettings,\n\twriteWebSettings,\n} from \"../lib/web-settings\";\n\nexport const useWebSettings = () => {\n\tconst [settings, setSettings] = useState<WebAppSettings>(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn defaultWebSettings;\n\t\t}\n\t\treturn readWebSettings();\n\t});\n\tconst [remoteReady, setRemoteReady] = useState(false);\n\n\tuseEffect(() => {\n\t\twriteWebSettings(settings);\n\t}, [settings]);\n\n\tuseEffect(() => {\n\t\tlet disposed = false;\n\n\t\tconst loadRemoteSettings = async () => {\n\t\t\ttry {\n\t\t\t\tconst result = await orpcClient.settings.get();\n\t\t\t\tif (disposed) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetSettings(result.settings);\n\t\t\t} catch {\n\t\t\t\t// Keep local settings as fallback when API is unavailable.\n\t\t\t} finally {\n\t\t\t\tif (!disposed) {\n\t\t\t\t\tsetRemoteReady(true);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tvoid loadRemoteSettings();\n\n\t\treturn () => {\n\t\t\tdisposed = true;\n\t\t};\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (!remoteReady) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst timeoutId = window.setTimeout(() => {\n\t\t\tvoid orpcClient.settings.set({ settings }).catch(() => {\n\t\t\t\t// Keep local settings as fallback when API save fails.\n\t\t\t});\n\t\t}, 200);\n\n\t\treturn () => {\n\t\t\twindow.clearTimeout(timeoutId);\n\t\t};\n\t}, [remoteReady, settings]);\n\n\tuseEffect(() => {\n\t\tapplyThemeToDocument(settings.theme);\n\t}, [settings.theme]);\n\n\tuseEffect(() => {\n\t\tvoid i18n.changeLanguage(settings.language);\n\t}, [settings.language]);\n\n\tuseEffect(() => {\n\t\tconst onStorage = (event: StorageEvent) => {\n\t\t\tif (event.storageArea !== window.localStorage) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.key !== WEB_SETTINGS_STORAGE_KEY) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetSettings(readWebSettings());\n\t\t};\n\n\t\twindow.addEventListener(\"storage\", onStorage);\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"storage\", onStorage);\n\t\t};\n\t}, []);\n\n\tconst updateSettings = useCallback((updates: Partial<WebAppSettings>) => {\n\t\tsetSettings((prev) => ({ ...prev, ...updates }));\n\t}, []);\n\n\tconst replaceSettings = useCallback((nextSettings: WebAppSettings) => {\n\t\tsetSettings(nextSettings);\n\t}, []);\n\n\treturn useMemo(\n\t\t() => ({\n\t\t\tsettings,\n\t\t\tupdateSettings,\n\t\t\treplaceSettings,\n\t\t}),\n\t\t[settings, updateSettings, replaceSettings],\n\t);\n};\n"
  },
  {
    "path": "apps/web/src/lib/download-format-preferences.ts",
    "content": "import type { DownloadType } from \"@vidbee/downloader-core\";\nimport {\n\tbuildAudioFormatPreference as buildSharedAudioFormatPreference,\n\tbuildVideoFormatPreference as buildSharedVideoFormatPreference,\n\ttype OneClickQualityPreset,\n} from \"@vidbee/downloader-core/format-preferences\";\n\nexport type { OneClickQualityPreset };\n\nexport interface WebDownloadSettings {\n\toneClickDownload: boolean;\n\toneClickDownloadType: DownloadType;\n\toneClickQuality: OneClickQualityPreset;\n}\n\nexport const DEFAULT_WEB_DOWNLOAD_SETTINGS: WebDownloadSettings = {\n\toneClickDownload: false,\n\toneClickDownloadType: \"video\",\n\toneClickQuality: \"best\",\n};\n\nexport const buildVideoFormatPreference = (\n\tsettings: WebDownloadSettings,\n): string =>\n\tbuildSharedVideoFormatPreference({\n\t\toneClickQuality: settings.oneClickQuality,\n\t});\n\nexport const buildAudioFormatPreference = (\n\tsettings: WebDownloadSettings,\n): string =>\n\tbuildSharedAudioFormatPreference({\n\t\toneClickQuality: settings.oneClickQuality,\n\t});\n"
  },
  {
    "path": "apps/web/src/lib/i18n.ts",
    "content": "import { initSharedI18n } from \"@vidbee/i18n\";\nimport i18n from \"i18next\";\n\nvoid initSharedI18n(i18n);\n\nexport { i18n };\n"
  },
  {
    "path": "apps/web/src/lib/orpc-client.ts",
    "content": "import { createORPCClient } from \"@orpc/client\";\nimport { RPCLink } from \"@orpc/client/fetch\";\nimport type { ContractRouterClient } from \"@orpc/contract\";\nimport type { downloaderContract } from \"@vidbee/downloader-core\";\n\nconst configuredApiUrl = import.meta.env.VITE_API_URL?.trim();\nconst normalizedApiUrl = configuredApiUrl\n\t? configuredApiUrl.replace(/\\/+$/, \"\")\n\t: \"\";\nconst defaultOrigin =\n\ttypeof window === \"undefined\"\n\t\t? \"http://localhost:3000\"\n\t\t: window.location.origin;\nexport const apiUrl = normalizedApiUrl || defaultOrigin;\n\nexport const eventsUrl = `${apiUrl}/events`;\nconst rpcUrl = `${apiUrl}/rpc`;\n\nexport const orpcClient: ContractRouterClient<typeof downloaderContract> =\n\tcreateORPCClient(\n\t\tnew RPCLink({\n\t\t\turl: rpcUrl,\n\t\t}),\n\t);\n"
  },
  {
    "path": "apps/web/src/lib/orpc-download-settings.ts",
    "content": "import type { DownloadRuntimeSettings } from \"@vidbee/downloader-core\";\nimport { readWebSettings } from \"./web-settings\";\n\nexport const readOrpcDownloadSettings = (): DownloadRuntimeSettings => {\n\tconst settings = readWebSettings();\n\treturn {\n\t\tdownloadPath: settings.downloadPath,\n\t\tbrowserForCookies: settings.browserForCookies,\n\t\tcookiesPath: settings.cookiesPath,\n\t\tproxy: settings.proxy,\n\t\tconfigPath: settings.configPath,\n\t\tembedSubs: settings.embedSubs,\n\t\tembedThumbnail: settings.embedThumbnail,\n\t\tembedMetadata: settings.embedMetadata,\n\t\tembedChapters: settings.embedChapters,\n\t};\n};\n"
  },
  {
    "path": "apps/web/src/lib/remote-image-proxy.ts",
    "content": "import { apiUrl } from \"./orpc-client\";\n\nconst IMAGE_PROXY_PATH = \"images/proxy\";\n\nexport const buildImageProxyUrl = (sourceUrl: string): string => {\n\tconst proxyUrl = new URL(`${apiUrl}/${IMAGE_PROXY_PATH}`);\n\tproxyUrl.searchParams.set(\"url\", sourceUrl);\n\treturn proxyUrl.toString();\n};\n\nexport const resolveImageProxyUrl = async (\n\tsourceUrl: string,\n): Promise<string> => {\n\treturn buildImageProxyUrl(sourceUrl);\n};\n"
  },
  {
    "path": "apps/web/src/lib/web-settings.ts",
    "content": "import type { DownloadType } from \"@vidbee/downloader-core\";\nimport {\n\tdefaultLanguageCode,\n\ttype LanguageCode,\n\tnormalizeLanguageCode,\n} from \"@vidbee/i18n/languages\";\n\nexport type OneClickQualityPreset =\n\t| \"best\"\n\t| \"good\"\n\t| \"normal\"\n\t| \"bad\"\n\t| \"worst\";\n\nexport type ThemeValue = \"light\" | \"dark\" | \"system\";\n\nexport interface WebAppSettings {\n\tdownloadPath: string;\n\tmaxConcurrentDownloads: number;\n\tbrowserForCookies: string;\n\tcookiesPath: string;\n\tproxy: string;\n\tconfigPath: string;\n\tbetaProgram: boolean;\n\tlanguage: LanguageCode;\n\ttheme: ThemeValue;\n\toneClickDownload: boolean;\n\toneClickDownloadType: DownloadType;\n\toneClickQuality: OneClickQualityPreset;\n\tcloseToTray: boolean;\n\tautoUpdate: boolean;\n\tsubscriptionOnlyLatestDefault: boolean;\n\tenableAnalytics: boolean;\n\tembedSubs: boolean;\n\tembedThumbnail: boolean;\n\tembedMetadata: boolean;\n\tembedChapters: boolean;\n\tshareWatermark: boolean;\n}\n\nexport const WEB_SETTINGS_STORAGE_KEY = \"vidbee.web.settings\";\n\nexport const defaultWebSettings: WebAppSettings = {\n\tdownloadPath: \"\",\n\tmaxConcurrentDownloads: 5,\n\tbrowserForCookies: \"none\",\n\tcookiesPath: \"\",\n\tproxy: \"\",\n\tconfigPath: \"\",\n\tbetaProgram: false,\n\tlanguage: defaultLanguageCode,\n\ttheme: \"system\",\n\toneClickDownload: false,\n\toneClickDownloadType: \"video\",\n\toneClickQuality: \"best\",\n\tcloseToTray: true,\n\tautoUpdate: true,\n\tsubscriptionOnlyLatestDefault: true,\n\tenableAnalytics: true,\n\tembedSubs: true,\n\tembedThumbnail: false,\n\tembedMetadata: true,\n\tembedChapters: true,\n\tshareWatermark: false,\n};\n\nconst toThemeValue = (value: unknown): ThemeValue => {\n\tif (value === \"dark\" || value === \"light\" || value === \"system\") {\n\t\treturn value;\n\t}\n\treturn defaultWebSettings.theme;\n};\n\nconst toOneClickQuality = (value: unknown): OneClickQualityPreset => {\n\tif (\n\t\tvalue === \"best\" ||\n\t\tvalue === \"good\" ||\n\t\tvalue === \"normal\" ||\n\t\tvalue === \"bad\" ||\n\t\tvalue === \"worst\"\n\t) {\n\t\treturn value;\n\t}\n\treturn defaultWebSettings.oneClickQuality;\n};\n\nconst toDownloadType = (value: unknown): DownloadType => {\n\tif (value === \"audio\" || value === \"video\") {\n\t\treturn value;\n\t}\n\treturn defaultWebSettings.oneClickDownloadType;\n};\n\nconst toBoolean = (value: unknown, fallback: boolean): boolean =>\n\ttypeof value === \"boolean\" ? value : fallback;\n\nconst toNumber = (value: unknown, fallback: number): number =>\n\ttypeof value === \"number\" && Number.isFinite(value) ? value : fallback;\n\nconst toStringValue = (value: unknown, fallback = \"\"): string =>\n\ttypeof value === \"string\" ? value : fallback;\n\nconst parseSettings = (raw: string | null): WebAppSettings => {\n\tif (!raw) {\n\t\treturn defaultWebSettings;\n\t}\n\n\ttry {\n\t\tconst parsed = JSON.parse(raw) as Partial<WebAppSettings>;\n\t\treturn {\n\t\t\t...defaultWebSettings,\n\t\t\tdownloadPath: toStringValue(parsed.downloadPath),\n\t\t\tmaxConcurrentDownloads: toNumber(\n\t\t\t\tparsed.maxConcurrentDownloads,\n\t\t\t\tdefaultWebSettings.maxConcurrentDownloads,\n\t\t\t),\n\t\t\tbrowserForCookies: toStringValue(\n\t\t\t\tparsed.browserForCookies,\n\t\t\t\tdefaultWebSettings.browserForCookies,\n\t\t\t),\n\t\t\tcookiesPath: toStringValue(parsed.cookiesPath),\n\t\t\tproxy: toStringValue(parsed.proxy),\n\t\t\tconfigPath: toStringValue(parsed.configPath),\n\t\t\tbetaProgram: toBoolean(\n\t\t\t\tparsed.betaProgram,\n\t\t\t\tdefaultWebSettings.betaProgram,\n\t\t\t),\n\t\t\tlanguage: normalizeLanguageCode(parsed.language),\n\t\t\ttheme: toThemeValue(parsed.theme),\n\t\t\toneClickDownload: toBoolean(\n\t\t\t\tparsed.oneClickDownload,\n\t\t\t\tdefaultWebSettings.oneClickDownload,\n\t\t\t),\n\t\t\toneClickDownloadType: toDownloadType(parsed.oneClickDownloadType),\n\t\t\toneClickQuality: toOneClickQuality(parsed.oneClickQuality),\n\t\t\tcloseToTray: toBoolean(\n\t\t\t\tparsed.closeToTray,\n\t\t\t\tdefaultWebSettings.closeToTray,\n\t\t\t),\n\t\t\tautoUpdate: toBoolean(parsed.autoUpdate, defaultWebSettings.autoUpdate),\n\t\t\tsubscriptionOnlyLatestDefault: toBoolean(\n\t\t\t\tparsed.subscriptionOnlyLatestDefault,\n\t\t\t\tdefaultWebSettings.subscriptionOnlyLatestDefault,\n\t\t\t),\n\t\t\tenableAnalytics: toBoolean(\n\t\t\t\tparsed.enableAnalytics,\n\t\t\t\tdefaultWebSettings.enableAnalytics,\n\t\t\t),\n\t\t\tembedSubs: toBoolean(parsed.embedSubs, defaultWebSettings.embedSubs),\n\t\t\tembedThumbnail: toBoolean(\n\t\t\t\tparsed.embedThumbnail,\n\t\t\t\tdefaultWebSettings.embedThumbnail,\n\t\t\t),\n\t\t\tembedMetadata: toBoolean(\n\t\t\t\tparsed.embedMetadata,\n\t\t\t\tdefaultWebSettings.embedMetadata,\n\t\t\t),\n\t\t\tembedChapters: toBoolean(\n\t\t\t\tparsed.embedChapters,\n\t\t\t\tdefaultWebSettings.embedChapters,\n\t\t\t),\n\t\t\tshareWatermark: toBoolean(\n\t\t\t\tparsed.shareWatermark,\n\t\t\t\tdefaultWebSettings.shareWatermark,\n\t\t\t),\n\t\t};\n\t} catch {\n\t\treturn defaultWebSettings;\n\t}\n};\n\nexport const readWebSettings = (): WebAppSettings => {\n\tif (typeof window === \"undefined\") {\n\t\treturn defaultWebSettings;\n\t}\n\n\treturn parseSettings(window.localStorage.getItem(WEB_SETTINGS_STORAGE_KEY));\n};\n\nexport const writeWebSettings = (settings: WebAppSettings): void => {\n\tif (typeof window === \"undefined\") {\n\t\treturn;\n\t}\n\n\twindow.localStorage.setItem(\n\t\tWEB_SETTINGS_STORAGE_KEY,\n\t\tJSON.stringify(settings),\n\t);\n};\n\nexport const applyThemeToDocument = (theme: ThemeValue): void => {\n\tif (typeof window === \"undefined\") {\n\t\treturn;\n\t}\n\n\tconst root = window.document.documentElement;\n\tconst shouldUseDark =\n\t\ttheme === \"dark\" ||\n\t\t(theme === \"system\" &&\n\t\t\twindow.matchMedia &&\n\t\t\twindow.matchMedia(\"(prefers-color-scheme: dark)\").matches);\n\n\troot.classList.toggle(\"dark\", shouldUseDark);\n};\n"
  },
  {
    "path": "apps/web/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as SettingsRouteImport } from './routes/settings'\nimport { Route as AboutRouteImport } from './routes/about'\nimport { Route as IndexRouteImport } from './routes/index'\n\nconst SettingsRoute = SettingsRouteImport.update({\n  id: '/settings',\n  path: '/settings',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AboutRoute = AboutRouteImport.update({\n  id: '/about',\n  path: '/about',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/about': typeof AboutRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/about': typeof AboutRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/about': typeof AboutRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths: '/' | '/about' | '/settings'\n  fileRoutesByTo: FileRoutesByTo\n  to: '/' | '/about' | '/settings'\n  id: '__root__' | '/' | '/about' | '/settings'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  AboutRoute: typeof AboutRoute\n  SettingsRoute: typeof SettingsRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/settings': {\n      id: '/settings'\n      path: '/settings'\n      fullPath: '/settings'\n      preLoaderRoute: typeof SettingsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/about': {\n      id: '/about'\n      path: '/about'\n      fullPath: '/about'\n      preLoaderRoute: typeof AboutRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n  }\n}\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  AboutRoute: AboutRoute,\n  SettingsRoute: SettingsRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n\nimport type { getRouter } from './router.tsx'\nimport type { createStart } from '@tanstack/react-start'\ndeclare module '@tanstack/react-start' {\n  interface Register {\n    ssr: true\n    router: Awaited<ReturnType<typeof getRouter>>\n  }\n}\n"
  },
  {
    "path": "apps/web/src/router.tsx",
    "content": "import { createRouter as createTanStackRouter } from \"@tanstack/react-router\";\nimport { routeTree } from \"./routeTree.gen\";\n\nexport function getRouter() {\n\tconst router = createTanStackRouter({\n\t\trouteTree,\n\n\t\tscrollRestoration: true,\n\t\tdefaultPreload: \"intent\",\n\t\tdefaultPreloadStaleTime: 0,\n\t});\n\n\treturn router;\n}\n\ndeclare module \"@tanstack/react-router\" {\n\tinterface Register {\n\t\trouter: ReturnType<typeof getRouter>;\n\t}\n}\n"
  },
  {
    "path": "apps/web/src/routes/__root.tsx",
    "content": "import { TanStackDevtools } from \"@tanstack/react-devtools\";\nimport { createRootRoute, HeadContent, Scripts } from \"@tanstack/react-router\";\nimport { TanStackRouterDevtoolsPanel } from \"@tanstack/react-router-devtools\";\nimport { useEffect } from \"react\";\nimport { Toaster } from \"sonner\";\nimport { i18n } from \"../lib/i18n\";\nimport { applyThemeToDocument, readWebSettings } from \"../lib/web-settings\";\n\nimport appCss from \"../styles.css?url\";\n\nexport const Route = createRootRoute({\n\thead: () => ({\n\t\tmeta: [\n\t\t\t{\n\t\t\t\tcharSet: \"utf-8\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: \"viewport\",\n\t\t\t\tcontent: \"width=device-width, initial-scale=1\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"VidBee Web\",\n\t\t\t},\n\t\t],\n\t\tlinks: [\n\t\t\t{\n\t\t\t\trel: \"stylesheet\",\n\t\t\t\thref: appCss,\n\t\t\t},\n\t\t],\n\t}),\n\tshellComponent: RootDocument,\n});\n\nfunction RootDocument({ children }: { children: React.ReactNode }) {\n\treturn (\n\t\t<html lang=\"en\">\n\t\t\t<head>\n\t\t\t\t<HeadContent />\n\t\t\t</head>\n\t\t\t<body className=\"bg-background text-foreground\" suppressHydrationWarning>\n\t\t\t\t<RootHydrationEffects />\n\t\t\t\t{children}\n\t\t\t\t<Toaster richColors={true} />\n\t\t\t\t<TanStackDevtools\n\t\t\t\t\tconfig={{\n\t\t\t\t\t\tposition: \"bottom-right\",\n\t\t\t\t\t}}\n\t\t\t\t\tplugins={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: \"Tanstack Router\",\n\t\t\t\t\t\t\trender: <TanStackRouterDevtoolsPanel />,\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t/>\n\t\t\t\t<Scripts />\n\t\t\t</body>\n\t\t</html>\n\t);\n}\n\nfunction RootHydrationEffects() {\n\tuseEffect(() => {\n\t\tconst settings = readWebSettings();\n\t\tapplyThemeToDocument(settings.theme);\n\t\tvoid i18n.changeLanguage(settings.language);\n\t}, []);\n\n\treturn null;\n}\n"
  },
  {
    "path": "apps/web/src/routes/about.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { AboutPage } from \"../components/pages/about-page\";\n\nexport const Route = createFileRoute(\"/about\")({\n\tcomponent: AboutPage,\n});\n"
  },
  {
    "path": "apps/web/src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { DownloadPage } from \"../components/pages/download-page\";\n\nexport const Route = createFileRoute(\"/\")({\n\tcomponent: DownloadPage,\n});\n"
  },
  {
    "path": "apps/web/src/routes/settings.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { SettingsPage } from \"../components/pages/settings-page\";\n\nexport const Route = createFileRoute(\"/settings\")({\n\tcomponent: SettingsPage,\n});\n"
  },
  {
    "path": "apps/web/src/styles.css",
    "content": "@import \"tailwindcss\";\n@import \"@vidbee/ui/theme.css\";\n@import \"tw-animate-css\";\n@import \"@vidbee/ui/base.css\";\n@import \"flag-icons/css/flag-icons.min.css\";\n"
  },
  {
    "path": "apps/web/tsconfig.json",
    "content": "{\n  \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ESNext\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"types\": [\"vite/client\", \"unplugin-icons/types/react\"],\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"noEmit\": true,\n    \"resolveJsonModule\": true,\n\n    /* Linting */\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  }\n}\n"
  },
  {
    "path": "apps/web/vite.config.ts",
    "content": "import tailwindcss from \"@tailwindcss/vite\";\nimport { devtools } from \"@tanstack/devtools-vite\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport viteReact from \"@vitejs/plugin-react\";\nimport Icons from \"unplugin-icons/vite\";\nimport { defineConfig } from \"vite\";\nimport tsconfigPaths from \"vite-tsconfig-paths\";\nimport packageJson from \"./package.json\";\n\nconst config = defineConfig({\n\tdefine: {\n\t\t__APP_VERSION__: JSON.stringify(packageJson.version),\n\t},\n\tplugins: [\n\t\tdevtools({\n\t\t\teventBusConfig: {\n\t\t\t\tenabled: false,\n\t\t\t},\n\t\t}),\n\t\ttsconfigPaths({ projects: [\"./tsconfig.json\"] }),\n\t\tIcons({\n\t\t\tcompiler: \"jsx\",\n\t\t\tjsx: \"react\",\n\t\t}),\n\t\ttailwindcss(),\n\t\ttanstackStart(),\n\t\tviteReact(),\n\t],\n\tserver: {\n\t\tproxy: {\n\t\t\t\"/events\": {\n\t\t\t\ttarget: \"http://localhost:3100\",\n\t\t\t\tchangeOrigin: true,\n\t\t\t},\n\t\t\t\"/rpc\": {\n\t\t\t\ttarget: \"http://localhost:3100\",\n\t\t\t\tchangeOrigin: true,\n\t\t\t},\n\t\t\t\"/images\": {\n\t\t\t\ttarget: \"http://localhost:3100\",\n\t\t\t\tchangeOrigin: true,\n\t\t\t},\n\t\t},\n\t},\n\tssr: {\n\t\tnoExternal: [\"@vidbee/i18n\"],\n\t},\n});\n\nexport default config;\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true,\n      \"correctness\": {\n        \"useExhaustiveDependencies\": \"warn\",\n        \"useImageSize\": \"off\"\n      },\n      \"suspicious\": {\n        \"noExplicitAny\": \"warn\",\n        \"useAwait\": \"off\",\n        \"noEmptyBlockStatements\": \"off\"\n      },\n      \"style\": {\n        \"useConst\": \"error\",\n        \"noNestedTernary\": \"off\",\n        \"useFilenamingConvention\": \"off\",\n        \"noExportedImports\": \"off\",\n        \"useCollapsedElseIf\": \"off\"\n      },\n      \"complexity\": {\n        \"noForEach\": \"off\",\n        \"noVoid\": \"off\",\n        \"noExcessiveCognitiveComplexity\": \"off\"\n      },\n      \"performance\": {\n        \"useTopLevelRegex\": \"off\",\n        \"noNamespaceImport\": \"off\"\n      },\n      \"a11y\": {\n        \"noNoninteractiveElementInteractions\": \"off\"\n      }\n    }\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"formatWithErrors\": false,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 100,\n    \"lineEnding\": \"lf\"\n  },\n  \"javascript\": {\n    \"globals\": [\"browser\", \"defineBackground\"],\n    \"formatter\": {\n      \"quoteStyle\": \"single\",\n      \"semicolons\": \"asNeeded\",\n      \"trailingCommas\": \"none\",\n      \"bracketSpacing\": true,\n      \"bracketSameLine\": false,\n      \"arrowParentheses\": \"always\",\n      \"quoteProperties\": \"asNeeded\"\n    }\n  },\n  \"files\": {\n    \"ignoreUnknown\": false,\n    \"includes\": [\n      \"**/*.ts\",\n      \"**/*.tsx\",\n      \"**/*.js\",\n      \"**/*.jsx\",\n      \"**/*.json\",\n      \"!apps/docs\",\n      \"!apps/desktop/build\",\n      \"!apps/desktop/dist\",\n      \"!apps/desktop/out\",\n      \"!dist\",\n      \"!out\",\n      \"!build\"\n    ]\n  },\n  \"vcs\": {\n    \"enabled\": true,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"extends\": [\"ultracite/biome/core\", \"ultracite/biome/react\"]\n}\n"
  },
  {
    "path": "conductor.json",
    "content": "{\n    \"scripts\": {\n        \"setup\": \"rm -rf apps/desktop/resources && cp -r $CONDUCTOR_ROOT_PATH/apps/desktop/resources apps/desktop/resources && pnpm install\",\n        \"run\": \"pnpm run dev\"\n    }\n}"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  api:\n    build:\n      context: .\n      dockerfile: apps/api/Dockerfile\n    environment:\n      VIDBEE_API_HOST: 0.0.0.0\n      VIDBEE_API_PORT: ${VIDBEE_API_PORT:-3100}\n      VIDBEE_DOWNLOAD_DIR: /data/downloads\n      VIDBEE_HISTORY_STORE_PATH: /data/vidbee/vidbee.db\n    ports:\n      - \"${VIDBEE_API_PORT:-3100}:${VIDBEE_API_PORT:-3100}\"\n    volumes:\n      - vidbee-downloads:/data/downloads\n      - vidbee-data:/data/vidbee\n    healthcheck:\n      test: [\"CMD-SHELL\", \"wget -qO- http://127.0.0.1:${VIDBEE_API_PORT:-3100}/health > /dev/null || exit 1\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n      start_period: 10s\n    restart: unless-stopped\n\n  web:\n    build:\n      context: .\n      dockerfile: apps/web/Dockerfile\n      args:\n        VITE_API_URL: ${VITE_API_URL:-http://localhost:${VIDBEE_API_PORT:-3100}}\n    depends_on:\n      api:\n        condition: service_healthy\n    ports:\n      - \"${VIDBEE_WEB_PORT:-3000}:3000\"\n    restart: unless-stopped\n\nvolumes:\n  vidbee-downloads:\n  vidbee-data:\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"vidbee-workspace\",\n  \"private\": true,\n  \"scripts\": {\n    \"check\": \"pnpm --filter ./apps/desktop run check\",\n    \"check:i18n\": \"pnpm --filter ./apps/desktop run check:i18n\",\n    \"typecheck\": \"pnpm --filter ./apps/desktop run typecheck\",\n    \"dev\": \"pnpm --filter ./apps/desktop run dev\",\n    \"start\": \"pnpm --filter ./apps/desktop run start\",\n    \"build\": \"pnpm --filter ./apps/desktop run build\",\n    \"setup\": \"pnpm --filter ./apps/desktop run setup\",\n    \"build:unpack\": \"pnpm --filter ./apps/desktop run build:unpack\",\n    \"build:win\": \"pnpm --filter ./apps/desktop run build:win\",\n    \"build:mac\": \"pnpm --filter ./apps/desktop run build:mac\",\n    \"build:linux\": \"pnpm --filter ./apps/desktop run build:linux\",\n    \"release\": \"pnpm --filter ./apps/desktop run release\",\n    \"db:generate\": \"pnpm --filter ./apps/desktop run db:generate\",\n    \"db:migrate\": \"pnpm --filter ./apps/desktop run db:migrate\",\n    \"fix\": \"pnpm --filter ./apps/desktop run fix\",\n    \"dev:desktop\": \"pnpm --filter ./apps/desktop run dev\",\n    \"build:desktop\": \"pnpm --filter ./apps/desktop run build\",\n    \"dev:api\": \"pnpm --filter ./apps/api run dev\",\n    \"start:api\": \"pnpm --filter ./apps/api run start\",\n    \"check:api\": \"pnpm --filter ./apps/api run check\",\n    \"dev:web\": \"pnpm --filter ./apps/web run dev\",\n    \"start:web\": \"pnpm -r --parallel --filter ./apps/api --filter ./apps/web run dev\",\n    \"build:web\": \"pnpm --filter ./apps/web run build\",\n    \"check:web\": \"pnpm --filter ./apps/web run check\",\n    \"dev:docs\": \"pnpm --filter ./apps/docs dev\",\n    \"build:docs\": \"pnpm --filter ./apps/docs build\",\n    \"dev:extension\": \"pnpm --filter ./apps/extension dev\",\n    \"build:extension\": \"pnpm --filter ./apps/extension build\",\n    \"zip:extension\": \"pnpm --filter ./apps/extension zip\",\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"@svgr/core\": \"^8.1.0\",\n    \"@svgr/plugin-jsx\": \"^8.1.0\",\n    \"electron\": \"^38.3.0\",\n    \"husky\": \"^9.1.7\"\n  },\n  \"pnpm\": {\n    \"onlyBuiltDependencies\": [\n      \"better-sqlite3\",\n      \"electron\",\n      \"esbuild\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/db/package.json",
    "content": "{\n  \"name\": \"@vidbee/db\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./history\": \"./src/history.ts\"\n  },\n  \"types\": \"./src/history.ts\",\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"drizzle-orm\": \"^0.44.7\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.9.2\"\n  }\n}\n"
  },
  {
    "path": "packages/db/src/history.ts",
    "content": "import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n\nexport const downloadHistoryTable = sqliteTable('download_history', {\n  id: text('id').primaryKey(),\n  url: text('url').notNull(),\n  title: text('title').notNull(),\n  thumbnail: text('thumbnail'),\n  type: text('type').notNull(),\n  status: text('status').notNull(),\n  downloadPath: text('download_path'),\n  savedFileName: text('saved_file_name'),\n  fileSize: integer('file_size', { mode: 'number' }),\n  duration: integer('duration', { mode: 'number' }),\n  downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),\n  completedAt: integer('completed_at', { mode: 'number' }),\n  sortKey: integer('sort_key', { mode: 'number' }).notNull(),\n  error: text('error'),\n  ytDlpCommand: text('yt_dlp_command'),\n  ytDlpLog: text('yt_dlp_log'),\n  description: text('description'),\n  channel: text('channel'),\n  uploader: text('uploader'),\n  viewCount: integer('view_count', { mode: 'number' }),\n  tags: text('tags'),\n  origin: text('origin'),\n  subscriptionId: text('subscription_id'),\n  selectedFormat: text('selected_format'),\n  playlistId: text('playlist_id'),\n  playlistTitle: text('playlist_title'),\n  playlistIndex: integer('playlist_index', { mode: 'number' }),\n  playlistSize: integer('playlist_size', { mode: 'number' })\n})\n\nexport type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect\nexport type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert\n"
  },
  {
    "path": "packages/db/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/downloader-core/package.json",
    "content": "{\n  \"name\": \"@vidbee/downloader-core\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./browser-cookies-setting\": \"./src/browser-cookies-setting.ts\",\n    \"./download-file\": \"./src/download-file.ts\",\n    \"./format-preferences\": \"./src/format-preferences.ts\",\n    \"./yt-dlp-args\": \"./src/yt-dlp-args.ts\"\n  },\n  \"types\": \"./src/index.ts\",\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@orpc/contract\": \"^1.13.5\",\n    \"yt-dlp-wrap-plus\": \"^2.3.20\",\n    \"zod\": \"^4.1.11\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^22.18.6\",\n    \"typescript\": \"^5.9.2\"\n  }\n}\n"
  },
  {
    "path": "packages/downloader-core/src/browser-cookies-setting.ts",
    "content": "export interface BrowserCookiesSetting {\n  browser: string\n  profile: string\n}\n\nconst normalizeProfileInput = (value: string): string => value.trim().replace(/^['\"]|['\"]$/g, '')\n\nexport const parseBrowserCookiesSetting = (value: string | undefined): BrowserCookiesSetting => {\n  if (!value || value === 'none') {\n    return { browser: 'none', profile: '' }\n  }\n\n  const separatorIndex = value.indexOf(':')\n  if (separatorIndex === -1) {\n    return { browser: value, profile: '' }\n  }\n\n  const browser = value.slice(0, separatorIndex).trim()\n  const profile = normalizeProfileInput(value.slice(separatorIndex + 1))\n  return { browser: browser || 'none', profile }\n}\n\nexport const buildBrowserCookiesSetting = (browser: string, profile: string): string => {\n  const trimmedBrowser = browser.trim()\n  if (!trimmedBrowser || trimmedBrowser === 'none') {\n    return 'none'\n  }\n\n  const trimmedProfile = normalizeProfileInput(profile)\n  return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser\n}\n"
  },
  {
    "path": "packages/downloader-core/src/contract.ts",
    "content": "import { oc } from '@orpc/contract'\nimport {\n  CancelDownloadInputSchema,\n  CancelDownloadOutputSchema,\n  DirectoryListInputSchema,\n  CreateDownloadInputSchema,\n  CreateDownloadOutputSchema,\n  FileExistsOutputSchema,\n  FileOperationOutputSchema,\n  FilePathInputSchema,\n  ListDirectoriesOutputSchema,\n  ListDownloadsOutputSchema,\n  ListHistoryOutputSchema,\n  PlaylistDownloadInputSchema,\n  PlaylistDownloadOutputSchema,\n  PlaylistInfoInputSchema,\n  PlaylistInfoOutputSchema,\n  RemoveHistoryByPlaylistInputSchema,\n  RemoveHistoryItemsInputSchema,\n  RemoveHistoryOutputSchema,\n  SetWebSettingsInputSchema,\n  StatusOutputSchema,\n  GetWebSettingsOutputSchema,\n  UploadSettingsFileInputSchema,\n  UploadSettingsFileOutputSchema,\n  VideoInfoInputSchema,\n  VideoInfoOutputSchema\n} from './schemas'\n\nexport const downloaderContract = {\n  status: oc.output(StatusOutputSchema),\n  videoInfo: oc.input(VideoInfoInputSchema).output(VideoInfoOutputSchema),\n  playlist: {\n    info: oc.input(PlaylistInfoInputSchema).output(PlaylistInfoOutputSchema),\n    download: oc.input(PlaylistDownloadInputSchema).output(PlaylistDownloadOutputSchema)\n  },\n  downloads: {\n    create: oc.input(CreateDownloadInputSchema).output(CreateDownloadOutputSchema),\n    list: oc.output(ListDownloadsOutputSchema),\n    cancel: oc.input(CancelDownloadInputSchema).output(CancelDownloadOutputSchema)\n  },\n  history: {\n    list: oc.output(ListHistoryOutputSchema),\n    removeItems: oc.input(RemoveHistoryItemsInputSchema).output(RemoveHistoryOutputSchema),\n    removeByPlaylist: oc\n      .input(RemoveHistoryByPlaylistInputSchema)\n      .output(RemoveHistoryOutputSchema)\n  },\n  files: {\n    exists: oc.input(FilePathInputSchema).output(FileExistsOutputSchema),\n    listDirectories: oc\n      .input(DirectoryListInputSchema)\n      .output(ListDirectoriesOutputSchema),\n    openFile: oc.input(FilePathInputSchema).output(FileOperationOutputSchema),\n    openFileLocation: oc.input(FilePathInputSchema).output(FileOperationOutputSchema),\n    copyFileToClipboard: oc\n      .input(FilePathInputSchema)\n      .output(FileOperationOutputSchema),\n    deleteFile: oc.input(FilePathInputSchema).output(FileOperationOutputSchema),\n    uploadSettingsFile: oc\n      .input(UploadSettingsFileInputSchema)\n      .output(UploadSettingsFileOutputSchema)\n  },\n  settings: {\n    get: oc.output(GetWebSettingsOutputSchema),\n    set: oc.input(SetWebSettingsInputSchema).output(GetWebSettingsOutputSchema)\n  }\n}\n"
  },
  {
    "path": "packages/downloader-core/src/download-file.ts",
    "content": "export const normalizeSavedFileName = (fileName?: string): string | undefined => {\n  if (!fileName) {\n    return undefined\n  }\n\n  const trimmed = fileName.trim()\n  if (!trimmed) {\n    return undefined\n  }\n\n  // Remove yt-dlp format identifiers before the final extension, for example:\n  // - .f123\n  // - .fhls-audio-128000-Audio\n  // - .fwebm-video-only\n  return trimmed.replace(/\\.f[a-z0-9-]+(?=\\.[^.]+$)/gi, '')\n}\n\nexport const buildFileNameCandidates = (\n  title: string,\n  format: string,\n  savedFileName?: string\n): string[] => {\n  const safeTitle = title.trim() || 'Unknown'\n\n  const savedNameCandidates: string[] = []\n  const trimmedSavedFileName = savedFileName?.trim()\n  if (trimmedSavedFileName) {\n    const normalized = normalizeSavedFileName(trimmedSavedFileName)\n    if (normalized) {\n      savedNameCandidates.push(normalized)\n    }\n    if (!normalized || normalized !== trimmedSavedFileName) {\n      savedNameCandidates.push(trimmedSavedFileName)\n    }\n  }\n\n  return savedNameCandidates.length > 0\n    ? savedNameCandidates\n    : [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]\n}\n\nexport const buildFilePathCandidates = (\n  downloadPath: string,\n  title: string,\n  format: string,\n  savedFileName?: string\n): string[] => {\n  const normalizedDownloadPath = downloadPath.replace(/\\\\/g, '/')\n  const candidateFileNames = buildFileNameCandidates(title, format, savedFileName)\n\n  return Array.from(\n    new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))\n  )\n}\n"
  },
  {
    "path": "packages/downloader-core/src/downloader-core.ts",
    "content": "import { execSync } from 'node:child_process'\nimport { randomUUID } from 'node:crypto'\nimport { EventEmitter } from 'node:events'\nimport fs from 'node:fs'\nimport { createRequire } from 'node:module'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type {\n  CreateDownloadInput,\n  DownloadRuntimeSettings,\n  DownloadTask,\n  PlaylistDownloadInput,\n  PlaylistDownloadResult,\n  PlaylistInfo,\n  VideoFormat,\n  VideoInfo\n} from './types'\nimport {\n  buildDownloadArgs,\n  buildPlaylistInfoArgs,\n  buildVideoInfoArgs,\n  formatYtDlpCommand\n} from './yt-dlp-args'\n\nconst require = createRequire(import.meta.url)\nconst YTDlpWrapModule = require('yt-dlp-wrap-plus')\n\ninterface YtDlpExecProcess {\n  ytDlpProcess?: {\n    stdout?: NodeJS.ReadableStream\n    stderr?: NodeJS.ReadableStream\n  }\n  on(event: 'progress', listener: (payload: ProgressPayload) => void): this\n  on(event: 'close', listener: (code: number | null) => void): this\n  on(event: 'error', listener: (error: Error) => void): this\n  once(event: 'close', listener: (code: number | null) => void): this\n  once(event: 'error', listener: (error: Error) => void): this\n}\n\ninterface YtDlpWrapInstance {\n  exec(args: string[], options?: { signal?: AbortSignal }): YtDlpExecProcess\n}\n\ntype YtDlpWrapConstructor = new (binaryPath: string) => YtDlpWrapInstance\nconst YTDlpWrapCtor = (YTDlpWrapModule.default ?? YTDlpWrapModule) as YtDlpWrapConstructor\n\ninterface ActiveTask {\n  controller: AbortController\n  process: YtDlpExecProcess\n}\n\ninterface RawVideoInfo {\n  id?: string\n  title?: string\n  thumbnail?: string | null\n  duration?: number | null\n  extractor_key?: string | null\n  webpage_url?: string | null\n  description?: string | null\n  view_count?: number | null\n  uploader?: string | null\n  tags?: unknown\n  formats?: Array<{\n    format_id?: string | null\n    ext?: string | null\n    width?: number | null\n    height?: number | null\n    fps?: number | null\n    vcodec?: string | null\n    acodec?: string | null\n    filesize?: number | null\n    filesize_approx?: number | null\n    format_note?: string | null\n    tbr?: number | null\n    quality?: number | null\n    protocol?: string | null\n    language?: string | null\n    video_ext?: string | null\n    audio_ext?: string | null\n  }>\n}\n\ninterface RawPlaylistEntry {\n  id?: string | null\n  title?: string | null\n  url?: string | null\n  webpage_url?: string | null\n  original_url?: string | null\n  ie_key?: string | null\n  thumbnail?: string | null\n}\n\ninterface RawPlaylistInfo {\n  id?: string | null\n  title?: string | null\n  entries?: RawPlaylistEntry[]\n}\n\ninterface ProgressPayload {\n  percent?: number\n  currentSpeed?: string\n  eta?: string\n  downloaded?: string\n  total?: string\n}\n\nexport interface DownloaderCoreOptions {\n  downloadDir?: string\n  maxConcurrent?: number\n  runtimeSettings?: DownloadRuntimeSettings\n}\n\nconst DEFAULT_DOWNLOAD_DIR = path.join(os.homedir(), 'Downloads', 'VidBee')\nconst DEFAULT_MAX_CONCURRENT = 3\nconst MAX_TASK_LOG_LENGTH = 80_000\nconst FFMPEG_NOT_FOUND_ERROR =\n  'ffmpeg/ffprobe not found. Use Desktop resources/ffmpeg, install in PATH, or set FFMPEG_PATH.'\nconst MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))\nconst REPO_ROOT_FROM_MODULE = path.resolve(MODULE_DIR, '../../..')\n\nconst getDesktopResourcesDirs = (): string[] => {\n  const dirs: string[] = []\n  const cwd = process.cwd()\n\n  dirs.push(path.join(cwd, 'resources'))\n  dirs.push(path.join(cwd, 'apps', 'desktop', 'resources'))\n  dirs.push(path.resolve(cwd, '..', 'desktop', 'resources'))\n\n  dirs.push(path.join(REPO_ROOT_FROM_MODULE, 'resources'))\n  dirs.push(path.join(REPO_ROOT_FROM_MODULE, 'apps', 'desktop', 'resources'))\n\n  if (process.env.NODE_ENV === 'development') {\n    dirs.push(path.join(REPO_ROOT_FROM_MODULE, 'apps', 'desktop', 'resources'))\n  }\n\n  const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath\n  if (resourcesPath) {\n    dirs.push(path.join(resourcesPath, 'app.asar.unpacked', 'resources'))\n    dirs.push(path.join(resourcesPath, 'resources'))\n  }\n\n  return Array.from(new Set(dirs))\n}\n\nconst ensureExecutable = (targetPath: string): void => {\n  if (process.platform === 'win32') {\n    return\n  }\n  try {\n    fs.chmodSync(targetPath, 0o755)\n  } catch {\n    // Ignore permission errors and let process execution decide.\n  }\n}\n\nconst resolveBundledYtDlpPath = (): string | undefined => {\n  const binaryName =\n    process.platform === 'win32'\n      ? 'yt-dlp.exe'\n      : process.platform === 'darwin'\n        ? 'yt-dlp_macos'\n        : 'yt-dlp_linux'\n\n  for (const resourcesDir of getDesktopResourcesDirs()) {\n    const candidate = path.join(resourcesDir, binaryName)\n    if (!fs.existsSync(candidate)) {\n      continue\n    }\n    ensureExecutable(candidate)\n    return candidate\n  }\n\n  return undefined\n}\n\nconst resolveBundledFfmpegLocation = (): string | undefined => {\n  const ffmpegBinaryName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'\n  const ffprobeBinaryName = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'\n\n  for (const resourcesDir of getDesktopResourcesDirs()) {\n    const candidateDir = path.join(resourcesDir, 'ffmpeg')\n    const ffmpegPath = path.join(candidateDir, ffmpegBinaryName)\n    const ffprobePath = path.join(candidateDir, ffprobeBinaryName)\n    if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {\n      continue\n    }\n    ensureExecutable(ffmpegPath)\n    ensureExecutable(ffprobePath)\n    return candidateDir\n  }\n\n  return undefined\n}\n\nconst tryCommandPath = (command: string): string | null => {\n  const commandName = process.platform === 'win32' ? `where ${command}` : `which ${command}`\n  try {\n    const output = execSync(commandName, { stdio: ['ignore', 'pipe', 'ignore'] })\n      .toString()\n      .split(/\\r?\\n/)\n      .map((value) => value.trim())\n      .find((value) => value.length > 0)\n    return output ?? null\n  } catch {\n    return null\n  }\n}\n\nconst resolveYtDlpPath = (): string => {\n  const envPath = process.env.YTDLP_PATH?.trim()\n  if (envPath && fs.existsSync(envPath)) {\n    return envPath\n  }\n  const bundledPath = resolveBundledYtDlpPath()\n  if (bundledPath) {\n    return bundledPath\n  }\n  const commandPath = tryCommandPath('yt-dlp')\n  if (commandPath) {\n    return commandPath\n  }\n  throw new Error('yt-dlp binary not found. Set YTDLP_PATH or install yt-dlp in PATH.')\n}\n\nconst resolveFfmpegLocation = (ytDlpPath?: string): string | undefined => {\n  const ffmpegBinaryName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'\n  const ffprobeBinaryName = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'\n  const resolveFromDirectory = (directory: string): string | undefined => {\n    const ffmpegPath = path.join(directory, ffmpegBinaryName)\n    const ffprobePath = path.join(directory, ffprobeBinaryName)\n    if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {\n      return undefined\n    }\n    ensureExecutable(ffmpegPath)\n    ensureExecutable(ffprobePath)\n    return directory\n  }\n  const envPath = process.env.FFMPEG_PATH?.trim()\n  if (envPath && fs.existsSync(envPath)) {\n    const stats = fs.statSync(envPath)\n    if (stats.isDirectory()) {\n      return resolveFromDirectory(envPath)\n    }\n    const candidateDir = path.dirname(envPath)\n    return resolveFromDirectory(candidateDir)\n  }\n\n  if (ytDlpPath) {\n    const ytDlpDir = path.dirname(ytDlpPath)\n    const sameDirResolved = resolveFromDirectory(ytDlpDir)\n    if (sameDirResolved) {\n      return sameDirResolved\n    }\n\n    // Align with Desktop resource layout: resources/yt-dlp_* + resources/ffmpeg/{ffmpeg,ffprobe}\n    const siblingDirResolved = resolveFromDirectory(path.join(ytDlpDir, 'ffmpeg'))\n    if (siblingDirResolved) {\n      return siblingDirResolved\n    }\n  }\n\n  const bundledLocation = resolveBundledFfmpegLocation()\n  if (bundledLocation) {\n    return bundledLocation\n  }\n\n  const commandPath = tryCommandPath('ffmpeg')\n  if (commandPath) {\n    const resolved = resolveFromDirectory(path.dirname(commandPath))\n    if (resolved) {\n      return resolved\n    }\n  }\n\n  if (process.platform === 'darwin') {\n    const macCommonDirs = ['/opt/homebrew/bin', '/usr/local/bin']\n    for (const dirPath of macCommonDirs) {\n      const resolved = resolveFromDirectory(dirPath)\n      if (resolved) {\n        return resolved\n      }\n    }\n  }\n\n  return undefined\n}\n\nconst resolveJsRuntimePath = (runtime: string): string | undefined => {\n  const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()\n  if (envPath && fs.existsSync(envPath)) {\n    return envPath\n  }\n\n  const runtimeCandidates: string[] = []\n  if (runtime === 'deno') {\n    runtimeCandidates.push(process.platform === 'win32' ? 'deno.exe' : 'deno')\n  } else if (runtime === 'node') {\n    runtimeCandidates.push(process.platform === 'win32' ? 'node.exe' : 'node')\n  } else if (runtime === 'bun') {\n    runtimeCandidates.push(process.platform === 'win32' ? 'bun.exe' : 'bun')\n  } else if (runtime === 'quickjs') {\n    runtimeCandidates.push(process.platform === 'win32' ? 'qjs.exe' : 'qjs')\n  } else {\n    runtimeCandidates.push(runtime)\n    if (process.platform === 'win32' && !runtime.endsWith('.exe')) {\n      runtimeCandidates.push(`${runtime}.exe`)\n    }\n  }\n\n  for (const resourcesDir of getDesktopResourcesDirs()) {\n    for (const candidateName of runtimeCandidates) {\n      const candidatePath = path.join(resourcesDir, candidateName)\n      if (!fs.existsSync(candidatePath)) {\n        continue\n      }\n      ensureExecutable(candidatePath)\n      return candidatePath\n    }\n  }\n\n  const commandPath = tryCommandPath(runtime)\n  return commandPath ?? undefined\n}\n\nconst resolveJsRuntimeArgs = (): string[] => {\n  const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()\n  if (!runtime || runtime === 'none') {\n    return []\n  }\n\n  const runtimePath = resolveJsRuntimePath(runtime)\n  if (runtimePath) {\n    return ['--js-runtimes', `${runtime}:${runtimePath}`]\n  }\n\n  return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []\n}\n\nconst clampPercent = (value: unknown): number => {\n  if (typeof value !== 'number' || Number.isNaN(value)) {\n    return 0\n  }\n  if (value < 0) {\n    return 0\n  }\n  if (value > 100) {\n    return 100\n  }\n  return value\n}\n\nconst toOptionalNumber = (value: unknown): number | undefined => {\n  if (typeof value !== 'number' || Number.isNaN(value)) {\n    return undefined\n  }\n\n  return value\n}\n\nconst toOptionalString = (value: unknown): string | undefined => {\n  if (typeof value !== 'string') {\n    return undefined\n  }\n\n  const trimmed = value.trim()\n  if (!trimmed) {\n    return undefined\n  }\n\n  return trimmed\n}\n\nconst toOptionalStringArray = (value: unknown): string[] | undefined => {\n  if (!Array.isArray(value)) {\n    return undefined\n  }\n\n  const list = value\n    .filter((entry): entry is string => typeof entry === 'string')\n    .map((entry) => entry.trim())\n    .filter((entry) => entry.length > 0)\n\n  return list.length > 0 ? list : undefined\n}\n\nconst toTerminal = (task: DownloadTask): boolean =>\n  task.status === 'completed' || task.status === 'error' || task.status === 'cancelled'\n\nconst isHttpUrl = (value?: string | null): boolean => {\n  if (!value) {\n    return false\n  }\n\n  try {\n    const parsed = new URL(value)\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:'\n  } catch {\n    return false\n  }\n}\n\nconst resolvePlaylistEntryUrl = (entry: RawPlaylistEntry): string | undefined => {\n  if (isHttpUrl(entry.url)) {\n    return toOptionalString(entry.url)\n  }\n\n  if (isHttpUrl(entry.webpage_url)) {\n    return toOptionalString(entry.webpage_url)\n  }\n\n  if (isHttpUrl(entry.original_url)) {\n    return toOptionalString(entry.original_url)\n  }\n\n  if (entry.url) {\n    const extractedId = entry.url.trim()\n    const extractor = entry.ie_key?.toLowerCase() ?? ''\n    if (extractor.includes('youtube')) {\n      return `https://www.youtube.com/watch?v=${extractedId}`\n    }\n    if (extractor.includes('youtubemusic')) {\n      return `https://music.youtube.com/watch?v=${extractedId}`\n    }\n  }\n\n  return undefined\n}\n\nconst trimTaskLog = (value: string): string => {\n  if (value.length <= MAX_TASK_LOG_LENGTH) {\n    return value\n  }\n\n  return value.slice(value.length - MAX_TASK_LOG_LENGTH)\n}\n\nconst extractSavedFilePath = (rawLog: string): string | undefined => {\n  const log = rawLog.trim()\n  if (!log) {\n    return undefined\n  }\n\n  const quotedPatterns = [\n    /Merging formats into \"([^\"]+)\"/g,\n    /Destination:\\s+\"([^\"]+)\"/g,\n    /Destination:\\s+'([^']+)'/g,\n    /\\[download\\]\\s+([^\\r\\n]+?)\\s+has already been downloaded/g\n  ]\n\n  for (const pattern of quotedPatterns) {\n    const matches = Array.from(log.matchAll(pattern))\n    const lastMatch = matches.at(-1)\n    const candidate = lastMatch?.[1]?.trim()\n    if (candidate) {\n      return candidate\n    }\n  }\n\n  const lines = log.split(/\\r?\\n/).reverse()\n  for (const line of lines) {\n    const destinationIndex = line.indexOf('Destination:')\n    if (destinationIndex >= 0) {\n      const candidate = line.slice(destinationIndex + 'Destination:'.length).trim()\n      if (candidate) {\n        return candidate\n      }\n    }\n  }\n\n  return undefined\n}\n\nconst cloneVideoFormat = (format?: VideoFormat): VideoFormat | undefined => {\n  if (!format) {\n    return undefined\n  }\n\n  return { ...format }\n}\n\nconst cloneTask = (task: DownloadTask): DownloadTask => ({\n  ...task,\n  progress: task.progress ? { ...task.progress } : undefined,\n  tags: task.tags ? [...task.tags] : undefined,\n  selectedFormat: cloneVideoFormat(task.selectedFormat)\n})\n\nexport class DownloaderCore extends EventEmitter {\n  private readonly maxConcurrent: number\n  private readonly downloadDir: string\n  private readonly defaultRuntimeSettings: DownloadRuntimeSettings\n  private readonly jsRuntimeArgs: string[]\n  private readonly tasks = new Map<string, DownloadTask>()\n  private readonly taskInputs = new Map<string, CreateDownloadInput>()\n  private readonly active = new Map<string, ActiveTask>()\n  private readonly pending: string[] = []\n  private readonly history = new Map<string, DownloadTask>()\n  private readonly cancelled = new Set<string>()\n  private ytdlp: YtDlpWrapInstance | null = null\n  private ffmpegLocation: string | undefined\n\n  constructor(options: DownloaderCoreOptions = {}) {\n    super()\n    this.maxConcurrent = Math.max(options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT, 1)\n    this.downloadDir = options.downloadDir?.trim() || DEFAULT_DOWNLOAD_DIR\n    this.defaultRuntimeSettings = { ...(options.runtimeSettings ?? {}) }\n    this.jsRuntimeArgs = resolveJsRuntimeArgs()\n  }\n\n  async initialize(): Promise<void> {\n    if (this.ytdlp) {\n      return\n    }\n    fs.mkdirSync(this.downloadDir, { recursive: true })\n    const ytDlpPath = resolveYtDlpPath()\n    this.ffmpegLocation = resolveFfmpegLocation(ytDlpPath)\n    if (this.ffmpegLocation) {\n      process.env.FFMPEG_PATH = this.ffmpegLocation\n    }\n    this.ytdlp = new YTDlpWrapCtor(ytDlpPath)\n  }\n\n  private getYtDlp(): YtDlpWrapInstance {\n    if (!this.ytdlp) {\n      throw new Error('DownloaderCore is not initialized.')\n    }\n    return this.ytdlp\n  }\n\n  private publishHistory(): void {\n    this.emit('history-updated', this.listHistory())\n  }\n\n  private resolveRuntimeSettings(\n    taskSettings?: DownloadRuntimeSettings | undefined\n  ): DownloadRuntimeSettings {\n    const merged: DownloadRuntimeSettings = {\n      ...this.defaultRuntimeSettings,\n      ...(taskSettings ?? {})\n    }\n    const downloadPath =\n      taskSettings?.downloadPath?.trim() ||\n      this.defaultRuntimeSettings.downloadPath?.trim() ||\n      this.downloadDir\n\n    return {\n      ...merged,\n      downloadPath\n    }\n  }\n\n  private updateTask(id: string, patch: Partial<DownloadTask>): DownloadTask | null {\n    const existing = this.tasks.get(id)\n    if (!existing) {\n      return null\n    }\n    const next: DownloadTask = { ...existing, ...patch }\n    this.tasks.set(id, next)\n\n    if (toTerminal(next)) {\n      this.history.set(id, next)\n      this.taskInputs.delete(id)\n      this.publishHistory()\n    }\n\n    const snapshot = cloneTask(next)\n    this.emit('task-updated', snapshot)\n    this.emit('queue-updated', this.listDownloads())\n    return snapshot\n  }\n\n  private async runJsonCommand<T>(args: string[]): Promise<T> {\n    const process = this.getYtDlp().exec(args)\n    let stdout = ''\n    let stderr = ''\n\n    process.ytDlpProcess?.stdout?.on('data', (chunk: Buffer) => {\n      stdout += chunk.toString()\n    })\n    process.ytDlpProcess?.stderr?.on('data', (chunk: Buffer) => {\n      stderr += chunk.toString()\n    })\n\n    const code = await new Promise<number | null>((resolve, reject) => {\n      process.once('close', (exitCode: number | null) => resolve(exitCode))\n      process.once('error', reject)\n    })\n\n    if (code !== 0 || !stdout.trim()) {\n      throw new Error(stderr.trim() || `yt-dlp exited with code ${code ?? -1}`)\n    }\n\n    return JSON.parse(stdout) as T\n  }\n\n  async getVideoInfo(url: string, runtimeSettings?: DownloadRuntimeSettings): Promise<VideoInfo> {\n    await this.initialize()\n    const target = url.trim()\n    if (!target) {\n      throw new Error('URL is required.')\n    }\n\n    const raw = await this.runJsonCommand<RawVideoInfo>(\n      buildVideoInfoArgs(target, this.resolveRuntimeSettings(runtimeSettings), this.jsRuntimeArgs)\n    )\n    const formats: VideoFormat[] = (raw.formats ?? []).map((format) => ({\n      formatId: format.format_id ?? 'unknown',\n      ext: format.ext ?? 'unknown',\n      width: toOptionalNumber(format.width),\n      height: toOptionalNumber(format.height),\n      fps: toOptionalNumber(format.fps),\n      vcodec: toOptionalString(format.vcodec),\n      acodec: toOptionalString(format.acodec),\n      filesize: toOptionalNumber(format.filesize),\n      filesizeApprox: toOptionalNumber(format.filesize_approx),\n      formatNote: toOptionalString(format.format_note),\n      tbr: toOptionalNumber(format.tbr),\n      quality: toOptionalNumber(format.quality),\n      protocol: toOptionalString(format.protocol),\n      language: toOptionalString(format.language),\n      videoExt: toOptionalString(format.video_ext),\n      audioExt: toOptionalString(format.audio_ext)\n    }))\n\n    return {\n      id: raw.id ?? target,\n      title: raw.title ?? target,\n      thumbnail: toOptionalString(raw.thumbnail),\n      duration: toOptionalNumber(raw.duration),\n      extractorKey: toOptionalString(raw.extractor_key),\n      webpageUrl: toOptionalString(raw.webpage_url),\n      description: toOptionalString(raw.description),\n      viewCount: toOptionalNumber(raw.view_count),\n      uploader: toOptionalString(raw.uploader),\n      tags: toOptionalStringArray(raw.tags),\n      formats\n    }\n  }\n\n  async getPlaylistInfo(\n    url: string,\n    runtimeSettings?: DownloadRuntimeSettings\n  ): Promise<PlaylistInfo> {\n    await this.initialize()\n    const target = url.trim()\n    if (!target) {\n      throw new Error('URL is required.')\n    }\n\n    const raw = await this.runJsonCommand<RawPlaylistInfo>(\n      buildPlaylistInfoArgs(target, this.resolveRuntimeSettings(runtimeSettings), this.jsRuntimeArgs)\n    )\n\n    const rawEntries = Array.isArray(raw.entries) ? raw.entries : []\n    const entries = rawEntries\n      .map((entry, index) => {\n        const resolvedUrl = resolvePlaylistEntryUrl(entry)\n        if (!resolvedUrl || !isHttpUrl(resolvedUrl)) {\n          return null\n        }\n\n        return {\n          id: toOptionalString(entry.id) ?? `${index + 1}`,\n          title: toOptionalString(entry.title) ?? `Entry ${index + 1}`,\n          url: resolvedUrl,\n          index: index + 1,\n          thumbnail: toOptionalString(entry.thumbnail)\n        }\n      })\n      .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))\n\n    return {\n      id: toOptionalString(raw.id) ?? target,\n      title: toOptionalString(raw.title) ?? 'Playlist',\n      entries,\n      entryCount: entries.length\n    }\n  }\n\n  async startPlaylistDownload(input: PlaylistDownloadInput): Promise<PlaylistDownloadResult> {\n    const playlist = await this.getPlaylistInfo(input.url, input.settings)\n    const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`\n\n    if (playlist.entryCount === 0) {\n      return {\n        groupId,\n        playlistId: playlist.id,\n        playlistTitle: playlist.title,\n        type: input.type,\n        totalCount: 0,\n        startIndex: 0,\n        endIndex: 0,\n        entries: []\n      }\n    }\n\n    let selectedEntries: PlaylistInfo['entries'] = []\n\n    if (input.entryIds && input.entryIds.length > 0) {\n      const selectedIdSet = new Set(input.entryIds)\n      selectedEntries = playlist.entries.filter((entry) => selectedIdSet.has(entry.id))\n    } else {\n      const requestedStart = Math.max((input.startIndex ?? 1) - 1, 0)\n      const requestedEnd = input.endIndex\n        ? Math.min(input.endIndex - 1, playlist.entryCount - 1)\n        : playlist.entryCount - 1\n      const rangeStart = Math.min(requestedStart, requestedEnd)\n      const rangeEnd = Math.max(requestedStart, requestedEnd)\n      selectedEntries = playlist.entries.slice(rangeStart, rangeEnd + 1)\n    }\n\n    const createdEntries: PlaylistDownloadResult['entries'] = []\n\n    for (const entry of selectedEntries) {\n      const download = await this.createDownload({\n        url: entry.url,\n        type: input.type,\n        title: entry.title,\n        thumbnail: entry.thumbnail,\n        playlistId: groupId,\n        playlistTitle: playlist.title,\n        playlistIndex: entry.index,\n        playlistSize: selectedEntries.length,\n        format: input.format,\n        audioFormat: input.audioFormat,\n        audioFormatIds: input.audioFormatIds,\n        customDownloadPath: input.customDownloadPath,\n        customFilenameTemplate: input.customFilenameTemplate,\n        settings: input.settings\n      })\n\n      createdEntries.push({\n        downloadId: download.id,\n        entryId: entry.id,\n        title: entry.title,\n        url: entry.url,\n        index: entry.index\n      })\n    }\n\n    return {\n      groupId,\n      playlistId: playlist.id,\n      playlistTitle: playlist.title,\n      type: input.type,\n      totalCount: selectedEntries.length,\n      startIndex: selectedEntries[0]?.index ?? 0,\n      endIndex: selectedEntries.at(-1)?.index ?? 0,\n      entries: createdEntries\n    }\n  }\n\n  async createDownload(input: CreateDownloadInput): Promise<DownloadTask> {\n    await this.initialize()\n    const id = randomUUID()\n    const now = Date.now()\n    const runtimeSettings = this.resolveRuntimeSettings(input.settings)\n    const resolvedDownloadPath =\n      input.customDownloadPath?.trim() || runtimeSettings.downloadPath?.trim() || this.downloadDir\n    const task: DownloadTask = {\n      id,\n      url: input.url,\n      title: input.title,\n      thumbnail: input.thumbnail,\n      type: input.type,\n      status: 'pending',\n      createdAt: now,\n      duration: input.duration,\n      description: input.description,\n      channel: input.channel,\n      uploader: input.uploader,\n      viewCount: input.viewCount,\n      tags: input.tags ? [...input.tags] : undefined,\n      selectedFormat: cloneVideoFormat(input.selectedFormat),\n      playlistId: input.playlistId,\n      playlistTitle: input.playlistTitle,\n      playlistIndex: input.playlistIndex,\n      playlistSize: input.playlistSize,\n      downloadPath: resolvedDownloadPath\n    }\n\n    this.tasks.set(id, task)\n    this.taskInputs.set(id, {\n      ...input,\n      selectedFormat: cloneVideoFormat(input.selectedFormat),\n      customDownloadPath: input.customDownloadPath?.trim() || undefined,\n      customFilenameTemplate: input.customFilenameTemplate?.trim() || undefined,\n      settings: runtimeSettings\n    })\n    this.pending.push(id)\n    this.emit('queue-updated', this.listDownloads())\n    this.processQueue()\n\n    return cloneTask(task)\n  }\n\n  private processQueue(): void {\n    if (this.active.size >= this.maxConcurrent) {\n      return\n    }\n\n    const nextId = this.pending.shift()\n    if (!nextId) {\n      return\n    }\n\n    const task = this.tasks.get(nextId)\n    if (!task) {\n      this.processQueue()\n      return\n    }\n    const input = this.taskInputs.get(nextId)\n    if (!input) {\n      this.updateTask(nextId, {\n        status: 'error',\n        completedAt: Date.now(),\n        error: 'Missing download input'\n      })\n      this.processQueue()\n      return\n    }\n\n    const runtimeSettings = this.resolveRuntimeSettings(input.settings)\n    const resolvedDownloadPath =\n      input.customDownloadPath?.trim() || runtimeSettings.downloadPath?.trim() || this.downloadDir\n    const args = buildDownloadArgs(\n      {\n        url: task.url,\n        type: input.type,\n        format: input.format,\n        audioFormat: input.audioFormat,\n        audioFormatIds: input.audioFormatIds,\n        startTime: input.startTime,\n        endTime: input.endTime,\n        customDownloadPath: input.customDownloadPath,\n        customFilenameTemplate: input.customFilenameTemplate\n      },\n      this.downloadDir,\n      runtimeSettings,\n      this.jsRuntimeArgs\n    )\n\n    const urlArg = args.pop()\n    if (!urlArg) {\n      this.updateTask(nextId, {\n        status: 'error',\n        completedAt: Date.now(),\n        error: 'Download arguments missing URL'\n      })\n      this.processQueue()\n      return\n    }\n\n    if (!this.ffmpegLocation) {\n      this.updateTask(nextId, {\n        status: 'error',\n        completedAt: Date.now(),\n        error: FFMPEG_NOT_FOUND_ERROR\n      })\n      this.processQueue()\n      return\n    }\n\n    args.push('--ffmpeg-location', this.ffmpegLocation)\n    args.push(urlArg)\n\n    const controller = new AbortController()\n    const ytDlpCommand = formatYtDlpCommand(args)\n    const process = this.getYtDlp().exec(args, {\n      signal: controller.signal\n    })\n\n    this.active.set(nextId, { controller, process })\n\n    let taskLog = ''\n    const appendLogChunk = (chunk: Buffer | string): void => {\n      taskLog = trimTaskLog(`${taskLog}${chunk.toString()}`)\n    }\n\n    process.ytDlpProcess?.stdout?.on('data', appendLogChunk)\n    process.ytDlpProcess?.stderr?.on('data', appendLogChunk)\n\n    this.updateTask(nextId, {\n      status: 'downloading',\n      startedAt: Date.now(),\n      progress: { percent: 0 },\n      ytDlpCommand,\n      ytDlpLog: ''\n    })\n\n    process.on('progress', (payload: ProgressPayload) => {\n      this.updateTask(nextId, {\n        progress: {\n          percent: clampPercent(payload.percent),\n          currentSpeed: payload.currentSpeed,\n          eta: payload.eta,\n          downloaded: payload.downloaded,\n          total: payload.total\n        },\n        speed: payload.currentSpeed\n      })\n    })\n\n    let settled = false\n    const isCancelled = (): boolean => controller.signal.aborted || this.cancelled.has(nextId)\n    const finalizeTask = (patch: Pick<DownloadTask, 'status'> & Partial<DownloadTask>): void => {\n      if (settled) {\n        return\n      }\n      settled = true\n      this.active.delete(nextId)\n      this.cancelled.delete(nextId)\n\n      const finalPatch: Partial<DownloadTask> = {\n        ...patch,\n        completedAt: patch.completedAt ?? Date.now(),\n        ytDlpLog: taskLog\n      }\n\n      const filePath = extractSavedFilePath(taskLog)\n      if (filePath) {\n        finalPatch.savedFileName = path.basename(filePath)\n        finalPatch.downloadPath = path.dirname(filePath)\n      } else {\n        finalPatch.downloadPath = resolvedDownloadPath\n      }\n\n      this.updateTask(nextId, finalPatch)\n      this.processQueue()\n    }\n\n    process.on('close', (code: number | null) => {\n      if (settled) {\n        return\n      }\n\n      if (isCancelled()) {\n        finalizeTask({\n          status: 'cancelled',\n          progress: { percent: 0 }\n        })\n        return\n      }\n\n      if (code === 0) {\n        finalizeTask({\n          status: 'completed',\n          progress: { percent: 100 }\n        })\n        return\n      }\n\n      finalizeTask({\n        status: 'error',\n        error: `yt-dlp exited with code ${code ?? -1}`\n      })\n    })\n\n    process.on('error', (error: Error) => {\n      if (settled) {\n        return\n      }\n\n      if (isCancelled()) {\n        finalizeTask({\n          status: 'cancelled',\n          progress: { percent: 0 }\n        })\n        return\n      }\n\n      finalizeTask({\n        status: 'error',\n        error: error.message\n      })\n    })\n\n    this.processQueue()\n  }\n\n  async cancelDownload(id: string): Promise<boolean> {\n    const active = this.active.get(id)\n    if (active) {\n      this.cancelled.add(id)\n      active.controller.abort()\n      return true\n    }\n\n    const pendingIndex = this.pending.findIndex((value) => value === id)\n    if (pendingIndex >= 0) {\n      this.pending.splice(pendingIndex, 1)\n      this.updateTask(id, {\n        status: 'cancelled',\n        completedAt: Date.now()\n      })\n      return true\n    }\n\n    return false\n  }\n\n  removeHistoryItems(ids: string[]): number {\n    let removed = 0\n\n    for (const rawId of ids) {\n      const id = rawId.trim()\n      if (!id) {\n        continue\n      }\n\n      if (!this.history.delete(id)) {\n        continue\n      }\n\n      removed += 1\n      const task = this.tasks.get(id)\n      if (task && toTerminal(task)) {\n        this.tasks.delete(id)\n      }\n    }\n\n    if (removed > 0) {\n      this.publishHistory()\n    }\n\n    return removed\n  }\n\n  removeHistoryByPlaylist(playlistId: string): number {\n    const target = playlistId.trim()\n    if (!target) {\n      return 0\n    }\n\n    const idsToDelete: string[] = []\n    for (const [id, task] of this.history.entries()) {\n      if (task.playlistId === target) {\n        idsToDelete.push(id)\n      }\n    }\n\n    return this.removeHistoryItems(idsToDelete)\n  }\n\n  listDownloads(): DownloadTask[] {\n    return Array.from(this.tasks.values())\n      .filter((task) => !toTerminal(task))\n      .sort((a, b) => b.createdAt - a.createdAt)\n      .map(cloneTask)\n  }\n\n  listHistory(): DownloadTask[] {\n    return Array.from(this.history.values())\n      .sort((a, b) => (b.completedAt ?? b.createdAt) - (a.completedAt ?? a.createdAt))\n      .map(cloneTask)\n  }\n\n  getStatus(): { active: number; pending: number } {\n    return { active: this.active.size, pending: this.pending.length }\n  }\n}\n"
  },
  {
    "path": "packages/downloader-core/src/format-preferences.ts",
    "content": "export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'\n\nexport interface OneClickFormatSettings {\n  oneClickQuality?: OneClickQualityPreset\n}\n\nconst qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {\n  best: null,\n  good: 1080,\n  normal: 720,\n  bad: 480,\n  worst: 360\n}\n\nconst qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {\n  best: 320,\n  good: 256,\n  normal: 192,\n  bad: 128,\n  worst: 96\n}\n\nconst dedupe = (candidates: Array<string | undefined>): string[] => {\n  const seen = new Set<string>()\n  const result: string[] = []\n  for (const candidate of candidates) {\n    if (!candidate) {\n      continue\n    }\n    if (seen.has(candidate)) {\n      continue\n    }\n    seen.add(candidate)\n    result.push(candidate)\n  }\n  return result\n}\n\nconst getQualityPreset = (settings: OneClickFormatSettings): OneClickQualityPreset =>\n  settings.oneClickQuality ?? 'best'\n\nconst buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {\n  if (preset === 'worst') {\n    return dedupe(['worstaudio', 'bestaudio'])\n  }\n\n  const abrLimit = qualityPresetToAudioAbr[preset]\n  return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])\n}\n\nexport const buildVideoFormatPreference = (settings: OneClickFormatSettings): string => {\n  const preset = getQualityPreset(settings)\n\n  if (preset === 'worst') {\n    return 'worstvideo+worstaudio/worst/best'\n  }\n\n  const maxHeight = qualityPresetToVideoHeight[preset]\n  const videoCandidates = dedupe([\n    maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,\n    'bestvideo'\n  ])\n\n  const audioSelectors = buildAudioSelectors(preset)\n  const combinations: string[] = []\n\n  for (const video of videoCandidates) {\n    for (const audio of audioSelectors) {\n      combinations.push(`${video}+${audio}`)\n    }\n  }\n\n  combinations.push('bestvideo+bestaudio')\n  combinations.push('best')\n\n  return dedupe(combinations).join('/')\n}\n\nexport const buildAudioFormatPreference = (settings: OneClickFormatSettings): string => {\n  const selectors = buildAudioSelectors(getQualityPreset(settings))\n  return dedupe([...selectors, 'best']).join('/')\n}\n"
  },
  {
    "path": "packages/downloader-core/src/index.ts",
    "content": "export type { BrowserCookiesSetting } from './browser-cookies-setting'\nexport {\n  buildBrowserCookiesSetting,\n  parseBrowserCookiesSetting\n} from './browser-cookies-setting'\nexport { downloaderContract } from './contract'\nexport { DownloaderCore } from './downloader-core'\nexport { WebAppSettingsSchema } from './schemas'\nexport type {\n  OneClickFormatSettings,\n  OneClickQualityPreset\n} from './format-preferences'\nexport {\n  buildAudioFormatPreference,\n  buildVideoFormatPreference\n} from './format-preferences'\nexport {\n  appendYouTubeSafeExtractorArgs,\n  buildDownloadArgs,\n  buildPlaylistInfoArgs,\n  buildVideoInfoArgs,\n  formatYtDlpCommand,\n  resolveAudioFormatSelector,\n  resolveFfmpegLocationFromPath,\n  resolvePathWithHome,\n  resolveVideoFormatSelector,\n  sanitizeFilenameTemplate\n} from './yt-dlp-args'\nexport type {\n  CreateDownloadInput,\n  DownloadRuntimeSettings,\n  DownloadProgress,\n  DownloadStatus,\n  DownloadTask,\n  DownloadType,\n  DirectoryEntry,\n  DirectoryListInput,\n  FileExistsOutput,\n  FileOperationOutput,\n  FilePathInput,\n  ListDirectoriesOutput,\n  PlaylistDownloadEntry,\n  PlaylistDownloadInput,\n  PlaylistDownloadResult,\n  PlaylistEntry,\n  PlaylistInfoInput,\n  PlaylistInfo,\n  UploadSettingsFileInput,\n  UploadSettingsFileKind,\n  UploadSettingsFileOutput,\n  VideoFormat,\n  VideoInfoInput,\n  VideoInfo\n} from './types'\n"
  },
  {
    "path": "packages/downloader-core/src/schemas.ts",
    "content": "import { z } from 'zod'\n\nexport const DownloadTypeSchema = z.enum(['video', 'audio'])\nexport const DownloadStatusSchema = z.enum([\n  'pending',\n  'downloading',\n  'processing',\n  'completed',\n  'error',\n  'cancelled'\n])\n\nexport const DownloadProgressSchema = z.object({\n  percent: z.number(),\n  currentSpeed: z.string().optional(),\n  eta: z.string().optional(),\n  downloaded: z.string().optional(),\n  total: z.string().optional()\n})\n\nexport const DownloadTaskSchema = z.object({\n  id: z.string(),\n  url: z.url(),\n  title: z.string().optional(),\n  thumbnail: z.string().optional(),\n  type: DownloadTypeSchema,\n  status: DownloadStatusSchema,\n  createdAt: z.number(),\n  startedAt: z.number().optional(),\n  completedAt: z.number().optional(),\n  duration: z.number().optional(),\n  fileSize: z.number().optional(),\n  speed: z.string().optional(),\n  ytDlpCommand: z.string().optional(),\n  ytDlpLog: z.string().optional(),\n  downloadPath: z.string().optional(),\n  savedFileName: z.string().optional(),\n  description: z.string().optional(),\n  channel: z.string().optional(),\n  uploader: z.string().optional(),\n  viewCount: z.number().optional(),\n  tags: z.array(z.string()).optional(),\n  selectedFormat: z.lazy(() => VideoFormatSchema).optional(),\n  playlistId: z.string().optional(),\n  playlistTitle: z.string().optional(),\n  playlistIndex: z.number().optional(),\n  playlistSize: z.number().optional(),\n  progress: DownloadProgressSchema.optional(),\n  error: z.string().optional()\n})\n\nexport const DownloadRuntimeSettingsSchema = z.object({\n  downloadPath: z.string().optional(),\n  browserForCookies: z.string().optional(),\n  cookiesPath: z.string().optional(),\n  proxy: z.string().optional(),\n  configPath: z.string().optional(),\n  embedSubs: z.boolean().optional(),\n  embedThumbnail: z.boolean().optional(),\n  embedMetadata: z.boolean().optional(),\n  embedChapters: z.boolean().optional()\n})\n\nexport const OneClickQualityPresetSchema = z.enum([\n  'best',\n  'good',\n  'normal',\n  'bad',\n  'worst'\n])\n\nexport const ThemeValueSchema = z.enum(['light', 'dark', 'system'])\n\nexport const WebAppSettingsSchema = z.object({\n  downloadPath: z.string(),\n  maxConcurrentDownloads: z.number().int().min(1).max(10),\n  browserForCookies: z.string(),\n  cookiesPath: z.string(),\n  proxy: z.string(),\n  configPath: z.string(),\n  betaProgram: z.boolean(),\n  language: z.string(),\n  theme: ThemeValueSchema,\n  oneClickDownload: z.boolean(),\n  oneClickDownloadType: DownloadTypeSchema,\n  oneClickQuality: OneClickQualityPresetSchema,\n  closeToTray: z.boolean(),\n  autoUpdate: z.boolean(),\n  subscriptionOnlyLatestDefault: z.boolean(),\n  enableAnalytics: z.boolean(),\n  embedSubs: z.boolean(),\n  embedThumbnail: z.boolean(),\n  embedMetadata: z.boolean(),\n  embedChapters: z.boolean(),\n  shareWatermark: z.boolean()\n})\n\nexport const CreateDownloadInputSchema = z.object({\n  url: z.url(),\n  type: DownloadTypeSchema,\n  title: z.string().optional(),\n  thumbnail: z.string().optional(),\n  duration: z.number().optional(),\n  description: z.string().optional(),\n  channel: z.string().optional(),\n  uploader: z.string().optional(),\n  viewCount: z.number().optional(),\n  tags: z.array(z.string()).optional(),\n  selectedFormat: z.lazy(() => VideoFormatSchema).optional(),\n  playlistId: z.string().optional(),\n  playlistTitle: z.string().optional(),\n  playlistIndex: z.number().optional(),\n  playlistSize: z.number().optional(),\n  format: z.string().optional(),\n  audioFormat: z.string().optional(),\n  audioFormatIds: z.array(z.string()).optional(),\n  startTime: z.string().optional(),\n  endTime: z.string().optional(),\n  customDownloadPath: z.string().optional(),\n  customFilenameTemplate: z.string().optional(),\n  settings: DownloadRuntimeSettingsSchema.optional()\n})\n\nexport const VideoInfoInputSchema = z.object({\n  url: z.url(),\n  settings: DownloadRuntimeSettingsSchema.optional()\n})\n\nexport const PlaylistInfoInputSchema = z.object({\n  url: z.url(),\n  settings: DownloadRuntimeSettingsSchema.optional()\n})\n\nexport const VideoFormatSchema = z.object({\n  formatId: z.string(),\n  ext: z.string(),\n  width: z.number().optional(),\n  height: z.number().optional(),\n  fps: z.number().optional(),\n  vcodec: z.string().optional(),\n  acodec: z.string().optional(),\n  filesize: z.number().optional(),\n  filesizeApprox: z.number().optional(),\n  formatNote: z.string().optional(),\n  tbr: z.number().optional(),\n  quality: z.number().optional(),\n  protocol: z.string().optional(),\n  language: z.string().optional(),\n  videoExt: z.string().optional(),\n  audioExt: z.string().optional()\n})\n\nexport const VideoInfoSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  thumbnail: z.string().optional(),\n  duration: z.number().optional(),\n  extractorKey: z.string().optional(),\n  webpageUrl: z.string().optional(),\n  description: z.string().optional(),\n  viewCount: z.number().optional(),\n  uploader: z.string().optional(),\n  tags: z.array(z.string()).optional(),\n  formats: z.array(VideoFormatSchema)\n})\n\nexport const PlaylistEntrySchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  url: z.url(),\n  index: z.number(),\n  thumbnail: z.string().optional()\n})\n\nexport const PlaylistInfoSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  entries: z.array(PlaylistEntrySchema),\n  entryCount: z.number()\n})\n\nexport const PlaylistDownloadInputSchema = z.object({\n  url: z.url(),\n  type: DownloadTypeSchema,\n  format: z.string().optional(),\n  audioFormat: z.string().optional(),\n  audioFormatIds: z.array(z.string()).optional(),\n  customDownloadPath: z.string().optional(),\n  customFilenameTemplate: z.string().optional(),\n  entryIds: z.array(z.string()).optional(),\n  startIndex: z.number().int().positive().optional(),\n  endIndex: z.number().int().positive().optional(),\n  settings: DownloadRuntimeSettingsSchema.optional()\n})\n\nexport const PlaylistDownloadEntrySchema = z.object({\n  downloadId: z.string(),\n  entryId: z.string(),\n  title: z.string(),\n  url: z.url(),\n  index: z.number()\n})\n\nexport const PlaylistDownloadResultSchema = z.object({\n  groupId: z.string(),\n  playlistId: z.string(),\n  playlistTitle: z.string(),\n  type: DownloadTypeSchema,\n  totalCount: z.number(),\n  startIndex: z.number(),\n  endIndex: z.number(),\n  entries: z.array(PlaylistDownloadEntrySchema)\n})\n\nexport const StatusOutputSchema = z.object({\n  ok: z.boolean(),\n  version: z.string(),\n  active: z.number(),\n  pending: z.number()\n})\n\nexport const CreateDownloadOutputSchema = z.object({\n  download: DownloadTaskSchema\n})\n\nexport const ListDownloadsOutputSchema = z.object({\n  downloads: z.array(DownloadTaskSchema)\n})\n\nexport const CancelDownloadInputSchema = z.object({\n  id: z.string()\n})\n\nexport const CancelDownloadOutputSchema = z.object({\n  cancelled: z.boolean()\n})\n\nexport const ListHistoryOutputSchema = z.object({\n  history: z.array(DownloadTaskSchema)\n})\n\nexport const VideoInfoOutputSchema = z.object({\n  video: VideoInfoSchema\n})\n\nexport const PlaylistInfoOutputSchema = z.object({\n  playlist: PlaylistInfoSchema\n})\n\nexport const PlaylistDownloadOutputSchema = z.object({\n  result: PlaylistDownloadResultSchema\n})\n\nexport const RemoveHistoryItemsInputSchema = z.object({\n  ids: z.array(z.string()).min(1)\n})\n\nexport const RemoveHistoryByPlaylistInputSchema = z.object({\n  playlistId: z.string().min(1)\n})\n\nexport const RemoveHistoryOutputSchema = z.object({\n  removed: z.number().int().nonnegative()\n})\n\nexport const FilePathInputSchema = z.object({\n  path: z.string().min(1)\n})\n\nexport const DirectoryListInputSchema = z.object({\n  path: z.string().optional()\n})\n\nexport const UploadSettingsFileKindSchema = z.enum(['cookies', 'config'])\n\nexport const UploadSettingsFileInputSchema = z.object({\n  kind: UploadSettingsFileKindSchema,\n  fileName: z.string().trim().min(1).max(255),\n  content: z.string().min(1).max(1_000_000)\n})\n\nexport const FileExistsOutputSchema = z.object({\n  exists: z.boolean()\n})\n\nexport const FileOperationOutputSchema = z.object({\n  success: z.boolean()\n})\n\nexport const DirectoryEntrySchema = z.object({\n  name: z.string(),\n  path: z.string()\n})\n\nexport const ListDirectoriesOutputSchema = z.object({\n  currentPath: z.string(),\n  parentPath: z.string().nullable(),\n  directories: z.array(DirectoryEntrySchema)\n})\n\nexport const UploadSettingsFileOutputSchema = z.object({\n  path: z.string()\n})\n\nexport const GetWebSettingsOutputSchema = z.object({\n  settings: WebAppSettingsSchema\n})\n\nexport const SetWebSettingsInputSchema = z.object({\n  settings: WebAppSettingsSchema\n})\n"
  },
  {
    "path": "packages/downloader-core/src/types.ts",
    "content": "export type DownloadType = 'video' | 'audio'\n\nexport type DownloadStatus =\n  | 'pending'\n  | 'downloading'\n  | 'processing'\n  | 'completed'\n  | 'error'\n  | 'cancelled'\n\nexport interface DownloadProgress {\n  percent: number\n  currentSpeed?: string\n  eta?: string\n  downloaded?: string\n  total?: string\n}\n\nexport interface DownloadTask {\n  id: string\n  url: string\n  title?: string\n  thumbnail?: string\n  type: DownloadType\n  status: DownloadStatus\n  createdAt: number\n  startedAt?: number\n  completedAt?: number\n  duration?: number\n  fileSize?: number\n  speed?: string\n  ytDlpCommand?: string\n  ytDlpLog?: string\n  downloadPath?: string\n  savedFileName?: string\n  description?: string\n  channel?: string\n  uploader?: string\n  viewCount?: number\n  tags?: string[]\n  selectedFormat?: VideoFormat\n  playlistId?: string\n  playlistTitle?: string\n  playlistIndex?: number\n  playlistSize?: number\n  progress?: DownloadProgress\n  error?: string\n}\n\nexport interface DownloadRuntimeSettings {\n  downloadPath?: string\n  browserForCookies?: string\n  cookiesPath?: string\n  proxy?: string\n  configPath?: string\n  embedSubs?: boolean\n  embedThumbnail?: boolean\n  embedMetadata?: boolean\n  embedChapters?: boolean\n}\n\nexport interface VideoInfoInput {\n  url: string\n  settings?: DownloadRuntimeSettings\n}\n\nexport interface PlaylistInfoInput {\n  url: string\n  settings?: DownloadRuntimeSettings\n}\n\nexport interface CreateDownloadInput {\n  url: string\n  type: DownloadType\n  title?: string\n  thumbnail?: string\n  duration?: number\n  description?: string\n  channel?: string\n  uploader?: string\n  viewCount?: number\n  tags?: string[]\n  selectedFormat?: VideoFormat\n  playlistId?: string\n  playlistTitle?: string\n  playlistIndex?: number\n  playlistSize?: number\n  format?: string\n  audioFormat?: string\n  audioFormatIds?: string[]\n  startTime?: string\n  endTime?: string\n  customDownloadPath?: string\n  customFilenameTemplate?: string\n  settings?: DownloadRuntimeSettings\n}\n\nexport interface VideoFormat {\n  formatId: string\n  ext: string\n  width?: number\n  height?: number\n  fps?: number\n  vcodec?: string\n  acodec?: string\n  filesize?: number\n  filesizeApprox?: number\n  formatNote?: string\n  tbr?: number\n  quality?: number\n  protocol?: string\n  language?: string\n  videoExt?: string\n  audioExt?: string\n}\n\nexport interface VideoInfo {\n  id: string\n  title: string\n  thumbnail?: string\n  duration?: number\n  extractorKey?: string\n  webpageUrl?: string\n  description?: string\n  viewCount?: number\n  uploader?: string\n  tags?: string[]\n  formats: VideoFormat[]\n}\n\nexport interface PlaylistEntry {\n  id: string\n  title: string\n  url: string\n  index: number\n  thumbnail?: string\n}\n\nexport interface PlaylistInfo {\n  id: string\n  title: string\n  entries: PlaylistEntry[]\n  entryCount: number\n}\n\nexport interface PlaylistDownloadInput {\n  url: string\n  type: DownloadType\n  format?: string\n  audioFormat?: string\n  audioFormatIds?: string[]\n  customDownloadPath?: string\n  customFilenameTemplate?: string\n  entryIds?: string[]\n  startIndex?: number\n  endIndex?: number\n  settings?: DownloadRuntimeSettings\n}\n\nexport interface PlaylistDownloadEntry {\n  downloadId: string\n  entryId: string\n  title: string\n  url: string\n  index: number\n}\n\nexport interface PlaylistDownloadResult {\n  groupId: string\n  playlistId: string\n  playlistTitle: string\n  type: DownloadType\n  totalCount: number\n  startIndex: number\n  endIndex: number\n  entries: PlaylistDownloadEntry[]\n}\n\nexport interface FilePathInput {\n  path: string\n}\n\nexport interface DirectoryListInput {\n  path?: string\n}\n\nexport type UploadSettingsFileKind = 'cookies' | 'config'\n\nexport interface UploadSettingsFileInput {\n  kind: UploadSettingsFileKind\n  fileName: string\n  content: string\n}\n\nexport interface DirectoryEntry {\n  name: string\n  path: string\n}\n\nexport interface FileExistsOutput {\n  exists: boolean\n}\n\nexport interface FileOperationOutput {\n  success: boolean\n}\n\nexport interface ListDirectoriesOutput {\n  currentPath: string\n  parentPath: string | null\n  directories: DirectoryEntry[]\n}\n\nexport interface UploadSettingsFileOutput {\n  path: string\n}\n"
  },
  {
    "path": "packages/downloader-core/src/yt-dlp-args.ts",
    "content": "import os from 'node:os'\nimport path from 'node:path'\n\nexport interface YtDlpDownloadSettings {\n  downloadPath?: string\n  browserForCookies?: string\n  cookiesPath?: string\n  proxy?: string\n  configPath?: string\n  embedSubs?: boolean\n  embedThumbnail?: boolean\n  embedMetadata?: boolean\n  embedChapters?: boolean\n}\n\nexport interface YtDlpDownloadOptions {\n  url: string\n  type: 'video' | 'audio'\n  format?: string\n  audioFormat?: string\n  audioFormatIds?: string[]\n  startTime?: string\n  endTime?: string\n  customDownloadPath?: string\n  customFilenameTemplate?: string\n}\n\nconst YOUTUBE_HOST_SUFFIXES = ['youtube.com', 'youtu.be', 'youtube-nocookie.com'] as const\nconst YOUTUBE_SAFE_PLAYER_CLIENTS = 'default,-web,-web_safari'\nconst DEFAULT_FILENAME_TEMPLATE = '%(title)s via VidBee.%(ext)s'\n\nconst hasYouTubeHost = (host: string): boolean =>\n  YOUTUBE_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))\n\nconst trim = (value?: string | null): string => value?.trim() ?? ''\n\nconst isBilibiliUrl = (url: string): boolean => {\n  try {\n    const host = new URL(url).hostname.toLowerCase()\n    return host.includes('bilibili.com') || host.includes('b23.tv') || host.includes('bili.tv')\n  } catch {\n    return false\n  }\n}\n\nexport const resolvePathWithHome = (rawPath?: string | null): string | undefined => {\n  const trimmed = trim(rawPath)\n  if (!trimmed) {\n    return undefined\n  }\n\n  if (trimmed === '~') {\n    return os.homedir()\n  }\n\n  if (trimmed.startsWith('~/') || trimmed.startsWith('~\\\\')) {\n    return path.join(os.homedir(), trimmed.slice(2))\n  }\n\n  return trimmed\n}\n\nexport const sanitizeFilenameTemplate = (template: string): string => {\n  const trimmed = template.trim()\n  if (!trimmed) {\n    return DEFAULT_FILENAME_TEMPLATE\n  }\n  const normalized = trimmed.replace(/\\\\/g, '/')\n  const safeParts = normalized\n    .split('/')\n    .map((part) => part.trim())\n    .filter((part) => part !== '' && part !== '.' && part !== '..')\n    .map((part) => part.replace(/[<>:\"|?*]/g, '-').replace(/[. ]+$/g, ''))\n    .filter((part) => part !== '')\n  return safeParts.length === 0 ? DEFAULT_FILENAME_TEMPLATE : safeParts.join('/')\n}\n\nexport const isYouTubeUrl = (url: string): boolean => {\n  try {\n    const host = new URL(url).hostname.toLowerCase()\n    return hasYouTubeHost(host)\n  } catch {\n    return false\n  }\n}\n\nexport const appendYouTubeSafeExtractorArgs = (args: string[], url: string): void => {\n  if (!isYouTubeUrl(url)) {\n    return\n  }\n  args.push('--extractor-args', `youtube:player_client=${YOUTUBE_SAFE_PLAYER_CLIENTS}`)\n}\n\nexport const formatYtDlpCommand = (args: string[]): string => {\n  const quoted = args.map((arg) => {\n    if (arg === '') {\n      return '\"\"'\n    }\n    if (/[\\s\"'\\\\]/.test(arg)) {\n      return `\"${arg.replace(/([\"\\\\])/g, '\\\\$1')}\"`\n    }\n    return arg\n  })\n  return `yt-dlp ${quoted.join(' ')}`\n}\n\nexport const resolveFfmpegLocationFromPath = (ffmpegPath: string): string => path.dirname(ffmpegPath)\n\nexport const resolveVideoFormatSelector = (options: YtDlpDownloadOptions): string => {\n  const format = options.format\n  const audioFormat = options.audioFormat\n  const audioFormatIds = (options.audioFormatIds ?? []).filter((id) => id.trim() !== '')\n\n  if (format && audioFormat === '') {\n    return format\n  }\n\n  if (format && (format.includes('/') || format.includes('+') || format.includes('['))) {\n    return format\n  }\n\n  if (audioFormatIds.length > 0) {\n    const baseVideo = format && format !== 'best' ? format : 'bestvideo*'\n    return `${baseVideo}+${audioFormatIds.join('+')}`\n  }\n\n  if (!format || format === 'best') {\n    if (audioFormat === 'none') {\n      return 'bestvideo+none'\n    }\n    if (!audioFormat || audioFormat === 'best') {\n      return 'bestvideo+bestaudio/best'\n    }\n    return `bestvideo+${audioFormat}`\n  }\n\n  if (audioFormat === 'none') {\n    return `${format}+none`\n  }\n\n  if (!audioFormat || audioFormat === 'best') {\n    return `${format}+bestaudio/best`\n  }\n\n  return `${format}+${audioFormat}`\n}\n\nexport const resolveAudioFormatSelector = (options: YtDlpDownloadOptions): string => {\n  const format = options.format\n\n  if (!format) {\n    return 'bestaudio'\n  }\n\n  if (format.includes('/') || format.includes('+') || format.includes('[')) {\n    return format\n  }\n\n  return format\n}\n\nexport const buildDownloadArgs = (\n  options: YtDlpDownloadOptions,\n  fallbackDownloadPath: string,\n  settings: YtDlpDownloadSettings,\n  jsRuntimeArgs: string[] = []\n): string[] => {\n  const args: string[] = ['--no-playlist', '--no-mtime', '--encoding', 'utf-8']\n\n  if (options.type === 'video') {\n    const formatSelector = resolveVideoFormatSelector(options)\n    if (formatSelector) {\n      args.push('-f', formatSelector)\n    }\n    if ((options.audioFormatIds?.length ?? 0) > 0 || formatSelector.includes('mergeall')) {\n      args.push('--audio-multistreams')\n    }\n  } else if (options.type === 'audio') {\n    args.push('-f', resolveAudioFormatSelector(options))\n  }\n\n  if (options.startTime || options.endTime) {\n    const start = options.startTime || '0'\n    const end = options.endTime || ''\n    args.push('--download-sections', `*${start}-${end}`)\n  }\n\n  const embedSubs = settings.embedSubs ?? true\n  const embedThumbnail = settings.embedThumbnail ?? false\n  const embedMetadata = settings.embedMetadata ?? true\n  const embedChapters = settings.embedChapters ?? true\n  const browserForCookies = trim(settings.browserForCookies)\n  const cookiesPath = trim(settings.cookiesPath)\n  const hasSubtitleAuth = (browserForCookies && browserForCookies !== 'none') || Boolean(cookiesPath)\n  const shouldAttemptSubtitles = !isBilibiliUrl(options.url) || hasSubtitleAuth\n\n  if (shouldAttemptSubtitles) {\n    if (embedSubs) {\n      args.push('--sub-langs', 'all')\n    } else {\n      args.push('--write-subs')\n    }\n    args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')\n  } else {\n    args.push('--no-embed-subs')\n  }\n\n  args.push(embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')\n  args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')\n  args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')\n\n  const baseDownloadPath =\n    trim(options.customDownloadPath) || trim(settings.downloadPath) || fallbackDownloadPath\n  const filenameTemplate = sanitizeFilenameTemplate(\n    options.customFilenameTemplate ?? DEFAULT_FILENAME_TEMPLATE\n  )\n  const safeTemplate = filenameTemplate.replace(/^[\\\\/]+/, '')\n  args.push('-o', path.join(baseDownloadPath, safeTemplate))\n  args.push('--continue')\n  args.push('--no-playlist-reverse')\n\n  if (process.platform === 'win32') {\n    args.push('--windows-filenames')\n  }\n\n  if (browserForCookies && browserForCookies !== 'none') {\n    args.push('--cookies-from-browser', browserForCookies)\n  }\n\n  if (cookiesPath) {\n    args.push('--cookies', cookiesPath)\n  }\n\n  const proxy = trim(settings.proxy)\n  if (proxy) {\n    args.push('--proxy', proxy)\n  }\n\n  const configPath = resolvePathWithHome(settings.configPath)\n  if (configPath) {\n    args.push('--config-location', configPath)\n  } else {\n    appendYouTubeSafeExtractorArgs(args, options.url)\n  }\n\n  if (jsRuntimeArgs.length > 0) {\n    args.push(...jsRuntimeArgs)\n  }\n\n  args.push(options.url)\n  return args\n}\n\nexport const buildVideoInfoArgs = (\n  url: string,\n  settings: YtDlpDownloadSettings,\n  jsRuntimeArgs: string[] = []\n): string[] => {\n  const args = ['-j', '--no-playlist', '--no-warnings', '--encoding', 'utf-8']\n\n  const proxy = trim(settings.proxy)\n  if (proxy) {\n    args.push('--proxy', proxy)\n  }\n\n  const browserForCookies = trim(settings.browserForCookies)\n  if (browserForCookies && browserForCookies !== 'none') {\n    args.push('--cookies-from-browser', browserForCookies)\n  }\n\n  const cookiesPath = trim(settings.cookiesPath)\n  if (cookiesPath) {\n    args.push('--cookies', cookiesPath)\n  }\n\n  const configPath = resolvePathWithHome(settings.configPath)\n  if (configPath) {\n    args.push('--config-location', configPath)\n  } else {\n    appendYouTubeSafeExtractorArgs(args, url)\n  }\n\n  if (jsRuntimeArgs.length > 0) {\n    args.push(...jsRuntimeArgs)\n  }\n\n  args.push(url)\n  return args\n}\n\nexport const buildPlaylistInfoArgs = (\n  url: string,\n  settings: YtDlpDownloadSettings,\n  jsRuntimeArgs: string[] = []\n): string[] => {\n  const args = ['-J', '--flat-playlist', '--no-warnings', '--encoding', 'utf-8']\n\n  const proxy = trim(settings.proxy)\n  if (proxy) {\n    args.push('--proxy', proxy)\n  }\n\n  const browserForCookies = trim(settings.browserForCookies)\n  if (browserForCookies && browserForCookies !== 'none') {\n    args.push('--cookies-from-browser', browserForCookies)\n  }\n\n  const cookiesPath = trim(settings.cookiesPath)\n  if (cookiesPath) {\n    args.push('--cookies', cookiesPath)\n  }\n\n  const configPath = resolvePathWithHome(settings.configPath)\n  if (configPath) {\n    args.push('--config-location', configPath)\n  } else {\n    appendYouTubeSafeExtractorArgs(args, url)\n  }\n\n  if (jsRuntimeArgs.length > 0) {\n    args.push(...jsRuntimeArgs)\n  }\n\n  args.push(url)\n  return args\n}\n"
  },
  {
    "path": "packages/downloader-core/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/i18n/package.json",
    "content": "{\n  \"name\": \"@vidbee/i18n\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./languages\": \"./src/languages.ts\",\n    \"./locales/*\": \"./src/locales/*.json\"\n  },\n  \"types\": \"./src/index.ts\",\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"react-i18next\": \"^16.0.0\"\n  },\n  \"devDependencies\": {\n    \"i18next\": \"^25.5.3\",\n    \"typescript\": \"^5.9.2\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/index.ts",
    "content": "export {\n  defaultLanguageCode,\n  languageList,\n  languages,\n  normalizeLanguageCode,\n  supportedLanguageCodes\n} from './languages'\nexport type { LanguageCode, LanguageDefinition } from './languages'\nexport { createI18nOptions, initSharedI18n } from './init'\nexport { translationResources } from './resources'\nexport type { TranslationDictionary } from './resources'\n"
  },
  {
    "path": "packages/i18n/src/init.ts",
    "content": "import type { InitOptions, i18n as I18nInstance } from 'i18next'\nimport { initReactI18next } from 'react-i18next'\nimport { defaultLanguageCode, supportedLanguageCodes } from './languages'\nimport { translationResources } from './resources'\n\nexport const createI18nOptions = (): InitOptions => ({\n  resources: translationResources,\n  lng: defaultLanguageCode,\n  fallbackLng: defaultLanguageCode,\n  supportedLngs: supportedLanguageCodes,\n  interpolation: {\n    escapeValue: false\n  }\n})\n\nexport const initSharedI18n = async (instance: I18nInstance): Promise<I18nInstance> => {\n  if (instance.isInitialized) {\n    return instance\n  }\n\n  await instance.use(initReactI18next).init(createI18nOptions())\n  return instance\n}\n"
  },
  {
    "path": "packages/i18n/src/languages.ts",
    "content": "export interface LanguageDefinition {\n  flag: string\n  name: string\n  hreflang: string\n}\n\nexport const languages = {\n  en: {\n    flag: 'fi fi-us',\n    name: 'English',\n    hreflang: 'en'\n  },\n  es: {\n    flag: 'fi fi-es',\n    name: 'Español',\n    hreflang: 'es'\n  },\n  ar: {\n    flag: 'fi fi-sa',\n    name: 'العربية',\n    hreflang: 'ar'\n  },\n  id: {\n    flag: 'fi fi-id',\n    name: 'Bahasa Indonesia',\n    hreflang: 'id'\n  },\n  pt: {\n    flag: 'fi fi-br',\n    name: 'Português',\n    hreflang: 'pt-BR'\n  },\n  fr: {\n    flag: 'fi fi-fr',\n    name: 'Français',\n    hreflang: 'fr'\n  },\n  it: {\n    flag: 'fi fi-it',\n    name: 'Italiano',\n    hreflang: 'it'\n  },\n  zh: {\n    flag: 'fi fi-cn',\n    name: '中文',\n    hreflang: 'zh-CN'\n  },\n  'zh-TW': {\n    flag: 'fi fi-tw',\n    name: '繁體中文',\n    hreflang: 'zh-TW'\n  },\n  ko: {\n    flag: 'fi fi-kr',\n    name: '한국어',\n    hreflang: 'ko'\n  },\n  ja: {\n    flag: 'fi fi-jp',\n    name: '日本語',\n    hreflang: 'ja'\n  },\n  ru: {\n    flag: 'fi fi-ru',\n    name: 'Русский',\n    hreflang: 'ru'\n  },\n  tr: {\n    flag: 'fi fi-tr',\n    name: 'Türkçe',\n    hreflang: 'tr'\n  },\n  de: {\n    flag: 'fi fi-de',\n    name: 'Deutsch',\n    hreflang: 'de'\n  }\n} as const satisfies Record<string, LanguageDefinition>\n\nexport type LanguageCode = keyof typeof languages\n\nexport const defaultLanguageCode: LanguageCode = 'en'\n\nexport const languageList = Object.entries(languages).map(([code, definition]) => ({\n  value: code as LanguageCode,\n  ...definition\n}))\n\nexport const supportedLanguageCodes = languageList.map((language) => language.value)\n\nexport function normalizeLanguageCode(code: string | null | undefined): LanguageCode {\n  if (!code) {\n    return defaultLanguageCode\n  }\n\n  const normalizedInput = code.toLowerCase()\n  const directMatch = supportedLanguageCodes.find(\n    (languageCode) => languageCode.toLowerCase() === normalizedInput\n  )\n  if (directMatch) {\n    return directMatch\n  }\n\n  const base = normalizedInput.split('-')[0] ?? ''\n  const baseMatch = supportedLanguageCodes.find(\n    (languageCode) => languageCode.split('-')[0]?.toLowerCase() === base\n  )\n\n  return baseMatch ?? defaultLanguageCode\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/ar.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"التحقق من التحديثات\",\n      \"download\": \"تحميل\",\n      \"email\": \"البريد الإلكتروني\",\n      \"feedback\": \"ملاحظات\",\n      \"goToDownload\": \"الانتقال إلى صفحة التحميل\",\n      \"openRepo\": \"فتح مستودع GitHub\",\n      \"view\": \"عرض\",\n      \"visit\": \"زيارة\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"تنزيل وتثبيت الإصدارات الجديدة تلقائياً في الخلفية.\",\n    \"autoUpdateTitle\": \"التحديثات التلقائية\",\n    \"betaProgramDescription\": \"تلقي البناءات المبكرة والميزات القادمة قبل أي شخص آخر.\",\n    \"betaProgramTitle\": \"قناة المعاينة\",\n    \"description\": \"VidBee هو برنامج تنزيل مجاني ومفتوح المصدر مبني باستخدام Electron ويعمل بواسطة yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"متابعة @nexmoex\"\n    },\n    \"followAuthorDescription\": \"ابق على اطلاع بآخر أخبار وتحديثات VidBee.\",\n    \"followAuthorSupport\": \"تابع المطور على X (Twitter) للحصول على آخر التحديثات والأخبار حول VidBee.\",\n    \"followAuthorTitle\": \"متابعة المطور\",\n    \"here\": \"هنا\",\n    \"homepage\": \"الصفحة الرئيسية\",\n    \"notifications\": {\n      \"checkingUpdates\": \"البحث عن التحديثات...\",\n      \"downloadError\": \"فشل في تنزيل التحديث\",\n      \"downloadUpdate\": \"تنزيل وتثبيت التحديث {{version}}؟\",\n      \"manualDownloadAction\": \"تنزيل الآن\",\n      \"noUpdatesAvailable\": \"أنت تستخدم أحدث إصدار\",\n      \"restartToUpdate\": \"إعادة التشغيل الآن لتثبيت التحديث؟\",\n      \"restartNowAction\": \"إعادة التشغيل الآن\",\n      \"updateAvailable\": \"تحديث متاح: {{version}}\",\n      \"updateAvailableMessage\": \"إصدار جديد {{version}} متاح. يرجى تنزيله من الموقع الرسمي.\",\n      \"updateDownloaded\": \"تم تنزيل التحديث، أعد التشغيل للتثبيت\",\n      \"updateDownloadedVersion\": \"تم تنزيل التحديث {{version}}، أعد التشغيل للتثبيت\",\n      \"updateError\": \"فشل في التحقق من التحديثات: {{error}}\",\n      \"unknownErrorFallback\": \"خطأ غير معروف\"\n    },\n    \"preferencesDescription\": \"ضبط إعدادات التحديث دون مغادرة هذه الصفحة.\",\n    \"preferencesTitle\": \"التبديلات السريعة\",\n    \"resources\": {\n      \"changelog\": \"ملاحظات الإصدار\",\n      \"changelogDescription\": \"اطلع على ما تغير في كل إصدار.\",\n      \"contact\": \"دعم البريد الإلكتروني\",\n      \"contactDescription\": \"تواصل مباشرة للحصول على المساعدة أو التعاون.\",\n      \"documentation\": \"مركز المساعدة\",\n      \"documentationDescription\": \"أدلة، أسئلة شائعة، وسير عمل شائعة.\",\n      \"feedback\": \"ملاحظات ومشاكل\",\n      \"feedbackDescription\": \"شارك الأفكار أو أبلغ عن المشاكل على GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"الإبلاغ عن الأخطاء أو طلب الميزات على GitHub.\",\n      \"xFeedback\": \"تويتر\",\n      \"xFeedbackDescription\": \"شارك الملاحظات أو الاقتراحات على X بذكر @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"انضم إلى مجتمع Discord للنقاش والدعم.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"الأسئلة الشائعة وأدلة استكشاف الأخطاء وإصلاحها.\",\n      \"license\": \"الترخيص\",\n      \"licenseDescription\": \"راجع شروط ترخيص المصدر المفتوح.\",\n      \"website\": \"الموقع الرسمي\",\n      \"websiteDescription\": \"أبرز المنتجات، خارطة الطريق، وأخبار المجتمع.\"\n    },\n    \"resourcesDescription\": \"روابط مفيدة لمعرفة المزيد عن VidBee والبقاء على اتصال.\",\n    \"resourcesTitle\": \"الموارد\",\n    \"shareActions\": {\n      \"copy\": \"نسخ الرابط\",\n      \"facebook\": \"مشاركة على Facebook\",\n      \"twitter\": \"مشاركة على X (Twitter)\"\n    },\n    \"shareDescription\": \"شارك VidBee مع مجتمعك بنقرة واحدة.\",\n    \"shareSupport\": \"أوصِ VidBee لأصدقائك لدعم نمونا وتحديثاتنا.\",\n    \"shareTitle\": \"انشر الكلمة\",\n    \"sourceCode\": \"الكود المصدري متاح\",\n    \"title\": \"حول\",\n    \"version\": \"الإصدار\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"الأحدث: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"إصدار جديد متاح\",\n      \"uptodate\": \"أنت محدث\",\n      \"error\": \"تعذر جلب أحدث إصدار\"\n    },\n    \"downloadingUpdate\": \"تنزيل التحديث\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"إغلاق التطبيق عند انتهاء التحميل\",\n    \"currentLocation\": \"موقع التحميل الحالي - \",\n    \"downloadLocation\": \"موقع التحميل\",\n    \"downloadSubs\": \"تحميل الترجمات إن كانت متاحة\",\n    \"downloadSubsHint\": \"احفظ الترجمات كملفات منفصلة عند توفرها\",\n    \"end\": \"النهاية\",\n    \"endHint\": \"إذا تُرك فارغاً، سيتم التحميل حتى النهاية\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"اختر موقع التحميل\",\n    \"start\": \"البداية\",\n    \"startHint\": \"إذا تُرك فارغاً، سيبدأ من البداية\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"الترجمات\",\n    \"timeRange\": \"تحميل نطاق زمني محدد\",\n    \"title\": \"خيارات متقدمة\"\n  },\n  \"app\": {\n    \"description\": \"تحميل الفيديوهات والصوتيات من مئات المواقع\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"نشط\",\n    \"all\": \"الكل\",\n    \"audio\": \"صوت\",\n    \"back\": \"رجوع\",\n    \"cancel\": \"إلغاء\",\n    \"cancelled\": \"ملغي\",\n    \"clearCompleted\": \"مسح المكتملة\",\n    \"clearDownloads\": \"مسح التحميلات\",\n    \"completed\": \"مكتمل\",\n    \"downloadAudio\": \"تحميل الصوت\",\n    \"downloadBtn\": \"تحميل\",\n    \"downloadPending\": \"قيد الانتظار\",\n    \"downloadQueue\": \"قائمة انتظار التحميل\",\n    \"customDownloadFolder\": \"مجلد تنزيل مخصص\",\n    \"retry\": \"إعادة المحاولة\",\n    \"autoFolderPlaceholder\": \"مجلد تلقائي (استنادًا إلى البيانات الوصفية)\",\n    \"autoFolderHint\": \"يتم إنشاء المجلدات التلقائية من البيانات الوصفية.\",\n    \"useAutoFolder\": \"استخدام المجلد التلقائي\",\n    \"downloadVideo\": \"تحميل الفيديو\",\n    \"downloading\": \"جاري التحميل...\",\n    \"enterUrl\": \"أدخل رابط الفيديو\",\n    \"enterUrlDescription\": \"الصق أو اكتب رابط فيديو. \",\n    \"error\": \"خطأ\",\n    \"fetch\": \"جلب\",\n    \"fetchingVideoInfo\": \"جلب معلومات الفيديو...\",\n    \"feedback\": {\n      \"title\": \"أبلغ عن هذا الخطأ:\",\n      \"githubUrlTooLong\": \"رابط GitHub هذا طويل جدًا. إذا لم يفتح، فالرجاء فتح صفحة المشكلة ولصق السجلات يدويًا.\"\n    },\n    \"history\": \"السجل\",\n    \"imageLoadError\": \"فشل تحميل الصورة\",\n    \"imagePlaceholder\": \"لا توجد صورة متاحة\",\n    \"infoUnavailable\": \"تحميل بنقرة واحدة (المعلومات غير متاحة)\",\n    \"loading\": \"جاري التحميل\",\n    \"moreOptions\": \"خيارات إضافية\",\n    \"noActiveDownloads\": \"لا توجد تحميلات نشطة\",\n    \"noAudio\": \"لا يوجد صوت\",\n    \"noHistory\": \"لا يوجد سجل تحميل\",\n    \"noItems\": \"لم يتم العثور على عناصر\",\n    \"goToSettings\": \"انتقل إلى الإعدادات\",\n    \"oneClickDownload\": \"تحميل بنقرة واحدة\",\n    \"oneClickDownloadDescription\": \"تحميل مباشر بالإعدادات الافتراضية دون تأكيد\",\n    \"oneClickDownloadTooltip\": \"الصق ونزّل فوراً، بدون خطوات إضافية\",\n    \"oneClickDownloadNow\": \"تحميل الآن\",\n    \"oneClickDownloadStarted\": \"بدأ التحميل بالإعدادات الافتراضية\",\n    \"paste\": \"لصق\",\n    \"pastePlaylistUrl\": \"انقر للصق رابط قائمة التشغيل من الحافظة [Ctrl + V]\",\n    \"pasteUrl\": \"انقر للصق رابط أو معرف الفيديو [Ctrl + V]\",\n    \"pasteUrlButton\": \"لصق الرابط\",\n    \"preparing\": \"جاري التحضير...\",\n    \"processing\": \"جاري المعالجة\",\n    \"progress\": \"التقدم\",\n    \"showDetails\": \"إظهار التفاصيل\",\n    \"hideDetails\": \"إخفاء التفاصيل\",\n    \"viewLogs\": \"عرض السجلات\",\n    \"detailsTab\": \"التفاصيل\",\n    \"logsTab\": \"السجلات\",\n    \"logs\": {\n      \"live\": \"سجلات مباشرة\",\n      \"history\": \"سجلات محفوظة\",\n      \"command\": \"أمر yt-dlp\",\n      \"empty\": \"لا توجد سجلات بعد.\",\n      \"scrollPaused\": \"تم إيقاف التمرير\"\n    },\n    \"selectAudioFormat\": \"اختر تنسيق الصوت\",\n    \"selectDownloadType\": \"اختر نوع التنزيل\",\n    \"selectFormat\": \"اختر التنسيق\",\n    \"startDownload\": \"بدء التحميل\",\n    \"selectVideoFormat\": \"اختر تنسيق الفيديو\",\n    \"singleVideo\": \"فيديو واحد\",\n    \"speed\": \"السرعة\",\n    \"title\": \"العنوان\",\n    \"total\": \"الإجمالي\",\n    \"unknownQuality\": \"جودة غير معروفة\",\n    \"unknownSize\": \"حجم غير معروف\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"فيديو\",\n    \"videoInfo\": \"معلومات الفيديو\",\n    \"videoInfoUpdated\": \"تم تحديث معلومات الفيديو\",\n    \"metadata\": {\n      \"source\": \"المصدر\",\n      \"playlist\": \"قائمة التشغيل\",\n      \"format\": \"التنسيق\",\n      \"quality\": \"الجودة\",\n      \"codec\": \"الترميز\",\n      \"savedFile\": \"الملف المحفوظ\",\n      \"url\": \"رابط المصدر\",\n      \"description\": \"الوصف\",\n      \"views\": \"المشاهدات\",\n      \"tags\": \"العلامات\",\n      \"downloadPath\": \"مسار التحميل\",\n      \"createdAt\": \"تم الإنشاء في\",\n      \"startedAt\": \"بدأ في\",\n      \"completedAt\": \"اكتمل في\",\n      \"speed\": \"السرعة\",\n      \"fileSize\": \"حجم الملف\",\n      \"width\": \"العرض\",\n      \"height\": \"الارتفاع\",\n      \"fps\": \"معدل الإطارات\",\n      \"videoCodec\": \"ترميز الفيديو\",\n      \"audioCodec\": \"ترميز الصوت\",\n      \"formatNote\": \"ملاحظة التنسيق\",\n      \"protocol\": \"البروتوكول\",\n      \"subscription\": \"الاشتراك\"\n    }\n  },\n  \"error\": {\n    \"title\": \"حدث خطأ ما\",\n    \"description\": \"حدث خطأ غير متوقع. يرجى إعادة تحميل التطبيق أو الإبلاغ عن هذه المشكلة إذا استمرت.\",\n    \"message\": \"رسالة الخطأ\",\n    \"unknownError\": \"حدث خطأ غير معروف\",\n    \"goHome\": \"العودة إلى الصفحة الرئيسية\",\n    \"reload\": \"إعادة تحميل التطبيق\",\n    \"copyReport\": \"نسخ تقرير الخطأ\",\n    \"copied\": \"تم النسخ!\",\n    \"copySuccess\": \"تم نسخ تقرير الخطأ إلى الحافظة\",\n    \"copyFailed\": \"فشل نسخ تقرير الخطأ\",\n    \"showDetails\": \"إظهار التفاصيل\",\n    \"hideDetails\": \"إخفاء التفاصيل\",\n    \"stackTrace\": \"تتبع المكدس\",\n    \"componentStack\": \"مكدس المكوّن\",\n    \"noStackTrace\": \"لا يوجد تتبع مكدس متاح\",\n    \"fullReport\": \"تقرير الخطأ الكامل\",\n    \"helpText\": \"إذا استمر هذا الخطأ، يرجى نسخ تقرير الخطأ أعلاه ومشاركته مع فريق الدعم. يمكنك العثور على معلومات الاتصال في صفحة \\\"حول\\\".\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"انقر لنسخ التفاصيل\",\n    \"clipboardEmpty\": \"الحافظة فارغة\",\n    \"downloadFailed\": \"فشل التحميل\",\n    \"downloadNecessaryFilesFailed\": \"فشل في تحميل الملفات الضرورية. يرجى التحقق من شبكتك والمحاولة مرة أخرى\",\n    \"emptyUrl\": \"يرجى إدخال رابط\",\n    \"errorDetails\": \"تفاصيل الخطأ\",\n    \"fetchInfoFailed\": \"فشل في جلب معلومات الفيديو\",\n    \"invalidUrl\": \"محتوى الحافظة ليس رابطًا صالحًا\",\n    \"networkError\": \"حدث خطأ ما. تحقق من شبكتك واستخدم رابطاً صحيحاً\",\n    \"pasteFromClipboard\": \"فشل في اللصق من الحافظة\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"مسح الملغاة\",\n    \"clearCompleted\": \"مسح المكتملة\",\n    \"clearErrors\": \"مسح الأخطاء\",\n    \"clearAll\": \"مسح كل السجل\",\n    \"clearAllAction\": \"مسح السجل\",\n    \"clearSelection\": \"مسح التحديد\",\n    \"confirmClearAllTitle\": \"مسح كل السجل؟\",\n    \"confirmClearAllDescription\": \"إزالة {{count}} عنصرًا من السجل. تبقى الملفات على القرص.\",\n    \"confirmDeleteSelectedTitle\": \"إزالة العناصر المحددة؟\",\n    \"confirmDeleteSelectedDescription\": \"إزالة {{count}} عنصرًا من السجل. تبقى الملفات على القرص.\",\n    \"alsoDeleteFiles\": \"احذف الملفات أيضًا\",\n    \"confirmDeletePlaylistTitle\": \"إزالة سجل قائمة التشغيل؟\",\n    \"confirmDeletePlaylistDescription\": \"إزالة {{count}} عنصرًا من {{title}} وحذف ملفاتها.\",\n    \"copyToClipboard\": \"نسخ إلى الحافظة\",\n    \"copyUrl\": \"نسخ الرابط\",\n    \"date\": \"التاريخ\",\n    \"deletePlaylist\": \"إزالة قائمة التشغيل\",\n    \"deleteSelected\": \"إزالة المحدد\",\n    \"description\": \"عرض وإدارة سجل التحميل الخاص بك\",\n    \"doneSelecting\": \"تم\",\n    \"duration\": \"المدة\",\n    \"fileSize\": \"حجم الملف\",\n    \"filters\": {\n      \"all\": \"الكل\",\n      \"cancelled\": \"ملغي\",\n      \"completed\": \"مكتمل\",\n      \"errors\": \"أخطاء\"\n    },\n    \"noHistory\": \"لا يوجد سجل تحميل بعد\",\n    \"noHistoryDescription\": \"ستظهر تحميلاتك المكتملة هنا\",\n    \"cookiesTipTitle\": \"ارفع معدل نجاح التنزيل باستخدام ملفات تعريف الارتباط\",\n    \"cookiesTipDescription\": \"قم بإعداد ملفات تعريف الارتباط لرفع نسبة النجاح من <strong>70%</strong> إلى <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"إعداد ملفات تعريف الارتباط\",\n    \"openDownloadFolder\": \"فتح مجلد التحميل\",\n    \"openFile\": \"فتح الملف\",\n    \"openFileLocation\": \"فتح موقع الملف\",\n    \"openFolder\": \"فتح المجلد\",\n    \"openInBrowser\": \"انقر لفتح في المتصفح\",\n    \"removeAction\": \"إزالة\",\n    \"removeItem\": \"إزالة العنصر\",\n    \"deleteFile\": \"حذف الملف\",\n    \"deleteRecord\": \"إزالة من القائمة\",\n    \"select\": \"تحديد\",\n    \"selectAll\": \"تحديد الكل\",\n    \"selectVisible\": \"تحديد المرئي\",\n    \"selectItem\": \"تحديد العنصر\",\n    \"selectedCount\": \"تم تحديد {{count}}\",\n    \"selectionSummary\": \"تم تحديد {{selected}} من {{total}} المرئية\",\n    \"stats\": {\n      \"cancelled\": \"ملغي\",\n      \"completed\": \"مكتمل\",\n      \"errors\": \"أخطاء\",\n      \"total\": \"الإجمالي\"\n    },\n    \"status\": {\n      \"cancelled\": \"ملغي\",\n      \"completed\": \"مكتمل\",\n      \"error\": \"خطأ\"\n    },\n    \"title\": \"سجل التحميل\"\n  },\n  \"menu\": {\n    \"about\": \"حول\",\n    \"download\": \"تحميل\",\n    \"playlist\": \"تحميل قائمة التشغيل\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"الاشتراكات\",\n    \"preferences\": \"التفضيلات\",\n    \"supportedSites\": \"المواقع المدعومة\",\n    \"theme\": \"المظهر:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"فشل في النسخ إلى الحافظة\",\n    \"downloadCompleted\": \"اكتمل التحميل\",\n    \"downloadAlreadyQueued\": \"هذا التنزيل قيد التنفيذ بالفعل\",\n    \"downloadFailed\": \"فشل التحميل\",\n    \"historyCleared\": \"تم مسح السجل\",\n    \"historyClearFailed\": \"فشل مسح السجل\",\n    \"itemRemoved\": \"تم إزالة العنصر\",\n    \"itemsRemoved\": \"تمت إزالة {{count}} عنصرًا\",\n    \"itemsRemoveFailed\": \"فشل إزالة العناصر المحددة\",\n    \"openFileFailed\": \"فشل في فتح الملف\",\n    \"openFolderFailed\": \"فشل في فتح المجلد\",\n    \"playlistHistoryRemoved\": \"تمت إزالة قائمة التشغيل وحذف الملفات\",\n    \"playlistHistoryRemoveFailed\": \"فشل إزالة سجل قائمة التشغيل\",\n    \"removeFailed\": \"فشل في إزالة العنصر\",\n    \"settingsSaved\": \"تم حفظ الإعدادات\",\n    \"urlCopied\": \"تم نسخ الرابط إلى الحافظة\",\n    \"videoCopied\": \"تم نسخ الفيديو إلى الحافظة\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"قائمة التشغيل\",\n    \"clearPreview\": \"مسح المعاينة\",\n    \"collapsedProgress\": \"جارٍ تنزيل قائمة التشغيل: {{completed}} / {{total}} مكتملة\",\n    \"comingSoon\": \"ميزة تحميل قائمة التشغيل قريباً!\",\n    \"completed\": \"تم تحميل قائمة التشغيل\",\n    \"description\": \"تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube\",\n    \"downloadFailed\": \"فشل في بدء تحميل قائمة التشغيل\",\n    \"downloadPlaylist\": \"تحميل قائمة التشغيل\",\n    \"downloadType\": \"نوع التحميل\",\n    \"downloading\": \"جاري تحميل قائمة التشغيل:\",\n    \"endIndex\": \"النهاية\",\n    \"enterPlaylistUrl\": \"أدخل رابط قائمة التشغيل\",\n    \"fetchFailed\": \"فشل في جلب معلومات قائمة التشغيل\",\n    \"filenameFormat\": \"تنسيق اسم الملف لقوائم التشغيل\",\n    \"folderFormat\": \"تنسيق اسم المجلد لقوائم التشغيل\",\n    \"foundVideos\": \"تم العثور على {{count}} فيديو في قائمة التشغيل\",\n    \"groupActive\": \"{{count}} نشط\",\n    \"groupCollapse\": \"طي\",\n    \"groupErrors\": \"{{count}} فشل\",\n    \"groupExpand\": \"توسيع\",\n    \"groupSummary\": \"{{completed}} / {{total}} مكتمل\",\n    \"linkLabel\": \"رابط قائمة التشغيل\",\n    \"noEntries\": \"لم يتم العثور على فيديوهات في قائمة التشغيل هذه\",\n    \"noEntriesInRange\": \"لا توجد فيديوهات في النطاق المحدد\",\n    \"noRangeSelected\": \"لم يتم تعيين النهاية - تم تحديد قائمة التشغيل الكاملة\",\n    \"playlistUrlDescription\": \"تحميل جميع الفيديوهات من قائمة تشغيل بشكل مجمع\",\n    \"positionLabel\": \"العنصر {{index}} من {{total}}\",\n    \"previewButton\": \"معاينة قائمة التشغيل\",\n    \"previewFailed\": \"فشل في معاينة قائمة التشغيل\",\n    \"previewSummary\": \"معاينة عناصر قائمة التشغيل قبل التحميل.\",\n    \"previewRequired\": \"معاينة قائمة التشغيل قبل التحميل.\",\n    \"range\": \"النطاق (اختياري)\",\n    \"resetToDefault\": \"إعادة تعيين إلى الافتراضي\",\n    \"selectedRange\": \"النطاق: {{start}}-{{end}}\",\n    \"selectedVideos\": \"تم تحديد {{count}}\",\n    \"downloadCurrentRange\": \"تنزيل المحدد\",\n    \"showingCount\": \"عرض {{count}} فيديو\",\n    \"selectEntry\": \"تحديد الإدخال {{index}}\",\n    \"noEntriesSelected\": \"لا توجد إدخالات محددة\",\n    \"startIndex\": \"البداية (1)\",\n    \"title\": \"تحميل قائمة التشغيل\",\n    \"totalVideos\": \"إجمالي الفيديوهات: {{count}}\",\n    \"untitled\": \"قائمة تشغيل بدون عنوان\",\n    \"fetchingInfo\": \"جارٍ جلب معلومات قائمة التشغيل...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"حول\",\n    \"advanced\": \"متقدم\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"إعدادات التطبيق\",\n    \"audio\": \"تفضيلات الصوت\",\n    \"browserForCookies\": \"اختر المتصفح لاستخدام ملفات تعريف الارتباط منه\",\n    \"browserForCookiesDescription\": \"المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة\",\n    \"browserForCookiesWindowsNote\": \"في Windows، يتم دعم ملفات تعريف الارتباط من Firefox فقط. للمتصفحات الأخرى، يرجى إعداد ملف ملفات تعريف الارتباط يدويًا.\",\n    \"browserForCookiesProfile\": \"اسم الملف الشخصي أو المسار\",\n    \"browserForCookiesProfileDescription\": \"مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.\",\n    \"browserForCookiesProfilePlaceholder\": \"اسم الملف الشخصي أو المسار الكامل (اختياري)\",\n    \"browserForCookiesProfileInvalid\": \"مسار الملف الشخصي غير صالح. اختر مجلد الملف الشخصي للمتصفح المحدد.\",\n    \"browserForCookiesProfileInvalidPath\": \"هذا المجلد غير موجود. اختر مجلد ملف شخصي موجود.\",\n    \"browserForCookiesProfileInvalidProfile\": \"لم يتم العثور على اسم الملف الشخصي في موقع المتصفح الافتراضي.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"لا يوجد موقع ملف شخصي افتراضي معروف لهذا المتصفح على هذه المنصة.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"أدخل مسار ملف شخصي للمتصفح المحدد.\",\n    \"cookiesFile\": \"Cookies 文件\",\n    \"cookiesFileDescription\": \"ملف ملفات تعريف الارتباط بتنسيق Netscape للتحميل للمصادقة\",\n    \"clearCookiesFile\": \"مسح\",\n    \"cookiesHelpTitle\": \"استخدام Cookies\",\n    \"cookiesHelpBrowser\": \"اختر متصفحك أعلاه لإعادة استخدام جلسته الموقعة تلقائياً.\",\n    \"cookiesHelpFile\": \"قم بتصدير ملف ملفات تعريف الارتباط Netscape (راجع الأسئلة الشائعة لـ yt-dlp) واختره هنا عند الحاجة.\",\n    \"cookiesGuideTitle\": \"هل تحتاج إلى شرح؟\",\n    \"cookiesGuideDescription\": \"اطّلع على دليل خطوة بخطوة لاستخدام ملفات تعريف الارتباط في VidBee.\",\n    \"cookiesGuideLink\": \"فتح دليل Cookies\",\n    \"openLinkError\": \"فشل في فتح الرابط\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"استخدام ملف التكوين\",\n    \"configFileDescription\": \"ملف تكوين مخصص لـ yt-dlp\",\n    \"clearConfigFile\": \"مسح\",\n    \"dark\": \"داكن\",\n    \"description\": \"تكوين تفضيلات التحميل وإعدادات التطبيق\",\n    \"directorySelectError\": \"فشل في اختيار المجلد\",\n    \"downloadPath\": \"موقع التحميل\",\n    \"downloadPathDescription\": \"اختر مكان حفظ الملفات المحملة\",\n    \"fileSelectError\": \"فشل في اختيار الملف\",\n    \"general\": \"عام\",\n    \"language\": \"اللغة\",\n    \"languageDescription\": \"اختر لغتك المفضلة لواجهة التطبيق\",\n    \"light\": \"فاتح\",\n    \"hideDockIcon\": \"إخفاء أيقونة Dock\",\n    \"hideDockIconDescription\": \"إزالة VidBee من Dock في macOS. استخدم شريط القائمة أو أيقونة الدرج لإعادة فتح التطبيق.\",\n    \"launchAtLogin\": \"بدء التشغيل عند تسجيل الدخول\",\n    \"launchAtLoginDescription\": \"فتح VidBee تلقائياً بعد تسجيل الدخول إلى جهاز الكمبيوتر الخاص بك.\",\n    \"launchAtLoginUnsupported\": \"البدء التلقائي متاح فقط على macOS و Windows.\",\n    \"enableAnalytics\": \"مساعدة في تحسين VidBee\",\n    \"enableAnalyticsDescription\": \"مشاركة بيانات الاستخدام المجهولة لمساعدتنا في فهم كيفية استخدام التطبيق وأولويات التحسينات.\",\n    \"embedChapters\": \"تضمين الفصول\",\n    \"embedChaptersDescription\": \"إضافة علامات الفصول إلى الملف عند توفرها\",\n    \"embedMetadata\": \"تضمين البيانات الوصفية\",\n    \"embedMetadataDescription\": \"كتابة العنوان والفنان وبيانات وصفية أخرى عند توفرها\",\n    \"embedSubs\": \"تضمين الترجمات\",\n    \"embedSubsDescription\": \"تضمين الترجمات داخل ملف الفيديو (mp4، webm، mkv)\",\n    \"embedThumbnail\": \"تضمين الصورة المصغرة\",\n    \"embedThumbnailDescription\": \"إضافة الصورة المصغرة كغلاف\",\n    \"shareWatermark\": \"علامة مائية للمشاركة\",\n    \"shareWatermarkDescription\": \"أضف علامة مائية تتضمن العنوان الأصلي والمؤلف وعلامة VidBee التجارية\",\n    \"maxConcurrentDownloads\": \"العدد الأقصى للتحميلات النشطة\",\n    \"maxConcurrentDownloadsDescription\": \"العدد الأقصى للتحميلات المتزامنة\",\n    \"none\": \"لا شيء\",\n    \"oneClickDownload\": \"تحميل بنقرة واحدة\",\n    \"oneClickDownloadDescription\": \"تفعيل التحميل بنقرة واحدة بالإعدادات الافتراضية\",\n    \"oneClickDownloadType\": \"نوع التحميل الافتراضي\",\n    \"oneClickDownloadTypeDescription\": \"اختر نوع التحميل الافتراضي للتحميلات بنقرة واحدة. تستخدم الجودة الإعداد المسبق أدناه.\",\n    \"oneClickQuality\": \"الجودة المفضلة\",\n    \"oneClickQualityDescription\": \"اختر الإعداد المسبق للجودة المستخدم للتحميلات بنقرة واحدة\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"تلقائي\",\n      \"bad\": \"سيء\",\n      \"best\": \"أفضل\",\n      \"good\": \"جيد\",\n      \"normal\": \"عادي\",\n      \"worst\": \"أسوأ\"\n    },\n    \"proxy\": \"الوكيل\",\n    \"proxyDescription\": \"خادم الوكيل لطلبات الشبكة\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"اختر ملف التكوين\",\n    \"selectPath\": \"اختر\",\n    \"showMoreFormats\": \"إظهار المزيد من خيارات التنسيق\",\n    \"showMoreFormatsDescription\": \"عرض خيارات التنسيق الإضافية في الواجهة\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.\"\n    },\n    \"system\": \"النظام\",\n    \"theme\": \"المظهر\",\n    \"themeDescription\": \"اختر مظهراً فاتحاً أو داكناً أو نظامياً لـ VidBee\",\n    \"title\": \"الإعدادات\",\n    \"tray\": {\n      \"quit\": \"إنهاء\",\n      \"showHome\": \"إظهار الصفحة الرئيسية\"\n    },\n    \"video\": \"تفضيلات الفيديو\"\n  },\n  \"subscriptions\": {\n    \"title\": \"الاشتراكات\",\n    \"subtitle\": \"{{count}} اشتراك{{count, plural, one {} other {}}}\",\n    \"description\": \"مراقبة تغذيات RSS تلقائياً ووضع التحميلات الجديدة في قائمة الانتظار دون عمل يدوي.\",\n    \"defaults\": {\n      \"title\": \"الإعدادات الافتراضية للأتمتة\",\n      \"description\": \"التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.\",\n      \"downloadDirectory\": \"مجلد التحميل\",\n      \"filenameTemplate\": \"قالب اسم الملف (الملف فقط)\",\n      \"onlyLatest\": \"تحميل أحدث فيديو فقط\",\n      \"onlyLatestDescription\": \"عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل.\"\n    },\n    \"add\": {\n      \"title\": \"إضافة RSS\",\n      \"description\": \"الصق رابط تغذية RSS. سيكتشف VidBee التغذية تلقائياً.\"\n    },\n    \"fields\": {\n      \"url\": \"رابط التغذية\",\n      \"keywords\": \"مرشح الكلمات الرئيسية (مفصولة بفواصل)\",\n      \"tags\": \"علامات تلقائية\",\n      \"customDirectory\": \"مجلد مخصص\",\n      \"namingTemplate\": \"قالب اسم ملف مخصص (الملف فقط)\",\n      \"onlyLatest\": \"تحميل أحدث فيديو فقط\",\n      \"onlyLatestDescription\": \"تجاهل عناصر المتراكمة وجلب أحدث تحميل من هذه التغذية فقط.\",\n      \"enabled\": \"مفعل\",\n      \"disabled\": \"معطل\",\n      \"onlyLatestShort\": \"الأحدث فقط\"\n    },\n    \"actions\": {\n      \"add\": \"إضافة\",\n      \"refresh\": \"تحديث\",\n      \"edit\": \"تعديل\",\n      \"remove\": \"إزالة\",\n      \"save\": \"حفظ التغييرات\",\n      \"selectDirectory\": \"تصفح\",\n      \"enable\": \"تفعيل\",\n      \"disable\": \"تعطيل\"\n    },\n    \"items\": {\n      \"title\": \"آخر التحميلات ({{count}})\",\n      \"count\": \"{{count}} عنصر\",\n      \"empty\": \"لم يتم العثور على عناصر تغذية حديثة.\",\n      \"status\": {\n        \"queued\": \"في قائمة الانتظار\",\n        \"notQueued\": \"ليس في قائمة الانتظار\",\n        \"pending\": \"قيد الانتظار\",\n        \"downloading\": \"جاري التحميل\",\n        \"processing\": \"جاري المعالجة\",\n        \"completed\": \"مكتمل\",\n        \"error\": \"فشل\",\n        \"cancelled\": \"ملغي\"\n      },\n      \"fromChannel\": \"من {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"حالة التحميل: {{status}}\",\n        \"downloadPending\": \"في انتظار تفاصيل التحميل...\",\n        \"notQueued\": \"ليس في قائمة انتظار التحميل بعد\"\n      },\n      \"actions\": {\n        \"open\": \"فتح في المتصفح\",\n        \"queue\": \"إضافة إلى قائمة انتظار التحميل\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"الاشتراك\",\n      \"unknown\": \"اشتراك غير معروف\",\n      \"noThumbnail\": \"لا توجد صورة مصغرة\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"فشل في فتح منتقي المجلد.\",\n      \"missingUrl\": \"يرجى لصق رابط القناة أولاً.\",\n      \"created\": \"تمت إضافة الاشتراك\",\n      \"createError\": \"فشل في إضافة الاشتراك.\",\n      \"refreshStarted\": \"بدأ التحديث\",\n      \"removed\": \"تمت إزالة الاشتراك\",\n      \"updated\": \"تم تحديث الاشتراك\",\n      \"itemQueued\": \"تمت الإضافة إلى قائمة انتظار التحميل\",\n      \"itemAlreadyQueued\": \"هذا الفيديو موجود بالفعل في قائمة الانتظار\",\n      \"queueError\": \"فشل في الإضافة إلى قائمة انتظار التحميل.\",\n      \"openLinkError\": \"فشل في فتح رابط الفيديو.\",\n      \"resolveError\": \"فشل في حل رابط تغذية RSS.\",\n      \"duplicateUrl\": \"تم الاشتراك في موجز RSS هذا بالفعل.\"\n    },\n    \"detectedFeed\": \"تم اكتشاف تغذية {{platform}} -> {{feed}}\",\n    \"detecting\": \"جاري اكتشاف التغذية...\",\n    \"latestVideo\": \"أحدث فيديو: {{title}}\",\n    \"lastChecked\": \"آخر فحص: {{time}}\",\n    \"never\": \"أبداً\",\n    \"empty\": \"لا توجد اشتراكات بعد. أضف قنواتك المفضلة لبدء التحميل التلقائي.\",\n    \"edit\": {\n      \"title\": \"تعديل {{name}}\",\n      \"description\": \"ضبط المرشحات والعلامات والتجاوزات لهذه التغذية.\"\n    },\n    \"status\": {\n      \"title\": \"الحالة\",\n      \"up-to-date\": \"محدث\",\n      \"checking\": \"جاري الفحص\",\n      \"failed\": \"فشل\",\n      \"idle\": \"خامل\",\n      \"tooltip\": {\n        \"updatedAt\": \"محدث: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"الاشتراكات الآلية مع RSSHub\",\n      \"description\": \"اجمع VidBee مع RSSHub لتمكين الاشتراكات والتحميلات الآلية من منصات مختلفة. بمجرد الإعداد، يعمل VidBee في الخلفية ويحمل تلقائياً أحدث الفيديوهات والمحتوى.\",\n      \"learnMore\": \"تعرف على المزيد حول RSSHub\",\n      \"openDocs\": \"فتح وثائق RSSHub\",\n      \"hint\": \"ليس لديك رابط تغذية RSS؟ استخدم RSSHub لإنشاء تغذيات RSS لـ YouTube و Twitter والآلاف من المنصات الأخرى.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"يدعم {{sites}} والمزيد.\",\n    \"moreDescription\": \"يتم تحديث قائمة yt-dlp الكاملة باستمرار من قبل المجتمع.\",\n    \"moreTitle\": \"تحتاج موقعاً آخر؟\",\n    \"openFullList\": \"فتح قائمة المواقع المدعومة الكاملة\",\n    \"pageDescription\": \"يستخدم VidBee yt-dlp تحت الغطاء للوصول إلى مئات المصادر.\",\n    \"pageIntro\": \"إليك الخدمات الرئيسية التي يقوم الناس بتحميلها منها في أغلب الأحيان.\",\n    \"pageTitle\": \"المواقع المدعومة\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"ألبومات الفنانين المستقلين وإصدارات المجتمع.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"الأخبار العالمية والرياضة ومقاطع الترفيه.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"مقاطع فيديو Feed و Watch و Reels من الصفحات العامة.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"محتوى Feed و Stories و Reels و Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"البث المباشر للمبدعين وإعادة التشغيل على منصة Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"المحادثات المهنية والندوات عبر الويب ومقاطع الفيديو التعليمية.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"مزج DJ وبرامج الراديو والصوت طويل الشكل.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"أرشيف الرسوم المتحركة اليابانية والموسيقى والبث المباشر.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"دبابيس الأفكار ومقاطع كيفية القيام بذلك ومقاطع فيديو إلهام نمط الحياة.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"المقاطع المضمنة ومقاطع الفيديو المستضافة من المجتمعات.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"مقاطع الموسيقى وقوائم التشغيل ومجموعات DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"مقاطع فيديو قصيرة للهاتف المحمول والتأثيرات والبث المباشر.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"الوسائط الإبداعية قصيرة الشكل وتحريرات المعجبين.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"البث المباشر للألعاب والموسيقى و IRL و VODs.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"منشورات الجدول الزمني وتسجيلات Spaces والبث.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"استضافة فيديو عالية الجودة للمبدعين والأعمال.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"فيديو طويل الشكل والبث المباشر من المبدعين في جميع أنحاء العالم.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"مقاطع فيديو موسيقية رسمية وألبومات وعروض مباشرة.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"المنصات الرئيسية\",\n    \"viewAll\": \"عرض جميع المواقع المدعومة\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/de.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Updates prüfen\",\n      \"download\": \"Herunterladen\",\n      \"email\": \"E-Mail\",\n      \"feedback\": \"Feedback\",\n      \"goToDownload\": \"Zur Download-Seite gehen\",\n      \"openRepo\": \"GitHub-Repository öffnen\",\n      \"view\": \"Ansehen\",\n      \"visit\": \"Besuchen\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Neue Versionen automatisch im Hintergrund herunterladen und installieren.\",\n    \"autoUpdateTitle\": \"Automatische Updates\",\n    \"betaProgramDescription\": \"Erhalten Sie frühe Builds und kommende Funktionen vor allen anderen.\",\n    \"betaProgramTitle\": \"Vorschau-Kanal\",\n    \"description\": \"VidBee ist ein kostenloser, quelloffener Downloader, der mit Electron erstellt wurde und von yt-dlp angetrieben wird.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Folgen Sie @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Bleiben Sie auf dem Laufenden mit den neuesten VidBee-Nachrichten und Updates.\",\n    \"followAuthorSupport\": \"Folgen Sie dem Entwickler auf X (Twitter), um die neuesten Updates und Nachrichten über VidBee zu erhalten.\",\n    \"followAuthorTitle\": \"Dem Entwickler folgen\",\n    \"here\": \"hier\",\n    \"homepage\": \"Startseite\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Suche nach Updates...\",\n      \"downloadError\": \"Update konnte nicht heruntergeladen werden\",\n      \"downloadUpdate\": \"Update {{version}} herunterladen und installieren?\",\n      \"manualDownloadAction\": \"Jetzt herunterladen\",\n      \"noUpdatesAvailable\": \"Sie verwenden die neueste Version\",\n      \"restartToUpdate\": \"Jetzt neu starten, um Update zu installieren?\",\n      \"restartNowAction\": \"Jetzt neu starten\",\n      \"updateAvailable\": \"Update verfügbar: {{version}}\",\n      \"updateAvailableMessage\": \"Eine neue Version {{version}} ist verfügbar. Bitte laden Sie sie von der offiziellen Website herunter.\",\n      \"updateDownloaded\": \"Update heruntergeladen, neu starten zum Installieren\",\n      \"updateDownloadedVersion\": \"Update {{version}} heruntergeladen, neu starten zum Installieren\",\n      \"updateError\": \"Update-Prüfung fehlgeschlagen: {{error}}\",\n      \"unknownErrorFallback\": \"Unbekannter Fehler\"\n    },\n    \"preferencesDescription\": \"Update-Einstellungen anpassen, ohne diese Seite zu verlassen.\",\n    \"preferencesTitle\": \"Schnellumschalter\",\n    \"resources\": {\n      \"changelog\": \"Versionshinweise\",\n      \"changelogDescription\": \"Informieren Sie sich über die Änderungen in jeder Version.\",\n      \"contact\": \"E-Mail-Support\",\n      \"contactDescription\": \"Kontaktieren Sie uns direkt für Hilfe oder Zusammenarbeit.\",\n      \"documentation\": \"Hilfezentrum\",\n      \"documentationDescription\": \"Anleitungen, FAQs und gängige Arbeitsabläufe.\",\n      \"feedback\": \"Feedback & Probleme\",\n      \"feedbackDescription\": \"Teilen Sie Ideen oder melden Sie Probleme auf GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Fehler melden oder Funktionen auf GitHub anfordern.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Feedback oder Vorschläge auf X teilen, indem @nexmoex erwähnt wird.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Tritt unserer Discord-Community für Diskussionen und Support bei.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Häufig gestellte Fragen und Troubleshooting-Anleitungen.\",\n      \"license\": \"Lizenz\",\n      \"licenseDescription\": \"Überprüfen Sie die Bedingungen der Open-Source-Lizenz.\",\n      \"website\": \"Offizielle Website\",\n      \"websiteDescription\": \"Produkthighlights, Roadmap und Community-Nachrichten.\"\n    },\n    \"resourcesDescription\": \"Nützliche Links, um mehr über VidBee zu erfahren und in Verbindung zu bleiben.\",\n    \"resourcesTitle\": \"Ressourcen\",\n    \"shareActions\": {\n      \"copy\": \"Link kopieren\",\n      \"facebook\": \"Auf Facebook teilen\",\n      \"twitter\": \"Auf X (Twitter) teilen\"\n    },\n    \"shareDescription\": \"Teilen Sie VidBee mit Ihrer Community mit einem Klick.\",\n    \"shareSupport\": \"Empfehlen Sie VidBee Ihren Freunden, um unser Wachstum und unsere Updates zu unterstützen.\",\n    \"shareTitle\": \"Verbreiten Sie die Nachricht\",\n    \"sourceCode\": \"Quellcode ist verfügbar\",\n    \"title\": \"Über\",\n    \"version\": \"Version\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Neueste: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Neue Version verfügbar\",\n      \"uptodate\": \"Sie sind auf dem neuesten Stand\",\n      \"error\": \"Neueste Version konnte nicht abgerufen werden\"\n    },\n    \"downloadingUpdate\": \"Update wird heruntergeladen\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"App schließen, wenn Download abgeschlossen ist\",\n    \"currentLocation\": \"Aktueller Download-Speicherort - \",\n    \"downloadLocation\": \"Download-Speicherort\",\n    \"downloadSubs\": \"Untertitel herunterladen, falls verfügbar\",\n    \"downloadSubsHint\": \"Untertitel als separate Dateien speichern, wenn verfügbar\",\n    \"end\": \"Ende\",\n    \"endHint\": \"Wenn leer gelassen, wird bis zum Ende heruntergeladen\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Download-Speicherort auswählen\",\n    \"start\": \"Start\",\n    \"startHint\": \"Wenn leer gelassen, wird vom Anfang an gestartet\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Untertitel\",\n    \"timeRange\": \"Bestimmten Zeitbereich herunterladen\",\n    \"title\": \"Erweiterte Optionen\"\n  },\n  \"app\": {\n    \"description\": \"Videos und Audios von Hunderten von Websites herunterladen\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Aktiv\",\n    \"all\": \"Alle\",\n    \"audio\": \"Audio\",\n    \"back\": \"Zurück\",\n    \"cancel\": \"Abbrechen\",\n    \"cancelled\": \"Abgebrochen\",\n    \"clearCompleted\": \"Abgeschlossene löschen\",\n    \"clearDownloads\": \"Downloads löschen\",\n    \"completed\": \"Abgeschlossen\",\n    \"downloadAudio\": \"Audio herunterladen\",\n    \"downloadBtn\": \"Herunterladen\",\n    \"downloadPending\": \"Ausstehend\",\n    \"downloadQueue\": \"Download-Warteschlange\",\n    \"customDownloadFolder\": \"Benutzerdefinierter Download-Ordner\",\n    \"retry\": \"Download erneut versuchen\",\n    \"autoFolderPlaceholder\": \"Automatischer Ordner (basierend auf Metadaten)\",\n    \"autoFolderHint\": \"Automatische Ordner werden aus Metadaten erstellt.\",\n    \"useAutoFolder\": \"Automatischen Ordner verwenden\",\n    \"downloadVideo\": \"Video herunterladen\",\n    \"downloading\": \"Wird heruntergeladen...\",\n    \"enterUrl\": \"Video-URL eingeben\",\n    \"enterUrlDescription\": \"Fügen Sie eine Video-URL ein oder geben Sie sie ein. \",\n    \"error\": \"Fehler\",\n    \"fetch\": \"Abrufen\",\n    \"fetchingVideoInfo\": \"Video-Informationen werden abgerufen...\",\n    \"feedback\": {\n      \"title\": \"Fehler melden:\",\n      \"githubUrlTooLong\": \"Dieser GitHub-Link ist sehr lang. Wenn er sich nicht öffnet, öffnen Sie bitte die Issue-Seite und fügen Sie die Logs manuell ein.\"\n    },\n    \"history\": \"Verlauf\",\n    \"imageLoadError\": \"Bild konnte nicht geladen werden\",\n    \"imagePlaceholder\": \"Kein Bild verfügbar\",\n    \"infoUnavailable\": \"Ein-Klick-Download (Info nicht verfügbar)\",\n    \"loading\": \"Wird geladen\",\n    \"moreOptions\": \"Weitere Optionen\",\n    \"noActiveDownloads\": \"Keine aktiven Downloads\",\n    \"noAudio\": \"Kein Audio\",\n    \"noHistory\": \"Kein Download-Verlauf\",\n    \"noItems\": \"Keine Elemente gefunden\",\n    \"goToSettings\": \"Zu Einstellungen gehen\",\n    \"oneClickDownload\": \"Ein-Klick-Download\",\n    \"oneClickDownloadDescription\": \"Direkt mit Standardeinstellungen ohne Bestätigung herunterladen\",\n    \"oneClickDownloadTooltip\": \"Sofort einfügen & herunterladen, Schritte überspringen\",\n    \"oneClickDownloadNow\": \"Jetzt herunterladen\",\n    \"oneClickDownloadStarted\": \"Download mit Standardeinstellungen gestartet\",\n    \"paste\": \"Einfügen\",\n    \"pastePlaylistUrl\": \"Klicken, um Playlist-Link aus Zwischenablage einzufügen [Strg + V]\",\n    \"pasteUrl\": \"Klicken, um Video-URL oder ID einzufügen [Strg + V]\",\n    \"pasteUrlButton\": \"URL einfügen\",\n    \"preparing\": \"Wird vorbereitet...\",\n    \"processing\": \"Wird verarbeitet\",\n    \"progress\": \"Fortschritt\",\n    \"showDetails\": \"Details anzeigen\",\n    \"hideDetails\": \"Details ausblenden\",\n    \"viewLogs\": \"Logs anzeigen\",\n    \"detailsTab\": \"Details\",\n    \"logsTab\": \"Logs\",\n    \"logs\": {\n      \"live\": \"Live-Logs\",\n      \"history\": \"Gespeicherte Logs\",\n      \"command\": \"yt-dlp-Befehl\",\n      \"empty\": \"Noch keine Logs.\",\n      \"scrollPaused\": \"Scrollen pausiert\"\n    },\n    \"selectAudioFormat\": \"Audio-Format auswählen\",\n    \"selectDownloadType\": \"Download-Typ auswählen\",\n    \"selectFormat\": \"Format auswählen\",\n    \"startDownload\": \"Download starten\",\n    \"selectVideoFormat\": \"Video-Format auswählen\",\n    \"singleVideo\": \"Einzelnes Video\",\n    \"speed\": \"Geschwindigkeit\",\n    \"title\": \"Titel\",\n    \"total\": \"Gesamt\",\n    \"unknownQuality\": \"Unbekannte Qualität\",\n    \"unknownSize\": \"Unbekannte Größe\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Video-Informationen\",\n    \"videoInfoUpdated\": \"Video-Informationen aktualisiert\",\n    \"metadata\": {\n      \"source\": \"Quelle\",\n      \"playlist\": \"Playlist\",\n      \"format\": \"Format\",\n      \"quality\": \"Qualität\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"Gespeicherte Datei\",\n      \"url\": \"Quell-URL\",\n      \"description\": \"Beschreibung\",\n      \"views\": \"Aufrufe\",\n      \"tags\": \"Tags\",\n      \"downloadPath\": \"Download-Pfad\",\n      \"createdAt\": \"Erstellt am\",\n      \"startedAt\": \"Gestartet am\",\n      \"completedAt\": \"Abgeschlossen am\",\n      \"speed\": \"Geschwindigkeit\",\n      \"fileSize\": \"Dateigröße\",\n      \"width\": \"Breite\",\n      \"height\": \"Höhe\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Video-Codec\",\n      \"audioCodec\": \"Audio-Codec\",\n      \"formatNote\": \"Format-Hinweis\",\n      \"protocol\": \"Protokoll\",\n      \"subscription\": \"Abonnement\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Etwas ist schiefgelaufen\",\n    \"description\": \"Ein unerwarteter Fehler ist aufgetreten. Bitte lade die App neu oder melde das Problem, wenn es weiterhin besteht.\",\n    \"message\": \"Fehlermeldung\",\n    \"unknownError\": \"Unbekannter Fehler ist aufgetreten\",\n    \"goHome\": \"Zur Startseite\",\n    \"reload\": \"App neu laden\",\n    \"copyReport\": \"Fehlerbericht kopieren\",\n    \"copied\": \"Kopiert!\",\n    \"copySuccess\": \"Fehlerbericht in die Zwischenablage kopiert\",\n    \"copyFailed\": \"Fehlerbericht konnte nicht kopiert werden\",\n    \"showDetails\": \"Details anzeigen\",\n    \"hideDetails\": \"Details ausblenden\",\n    \"stackTrace\": \"Stacktrace\",\n    \"componentStack\": \"Komponenten-Stack\",\n    \"noStackTrace\": \"Kein Stacktrace verfügbar\",\n    \"fullReport\": \"Vollständiger Fehlerbericht\",\n    \"helpText\": \"Wenn dieser Fehler weiterhin auftritt, kopiere bitte den obigen Fehlerbericht und teile ihn mit dem Support-Team. Kontaktinformationen findest du auf der Seite \\\"Über\\\".\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Klicken, um Details zu kopieren\",\n    \"clipboardEmpty\": \"Zwischenablage ist leer\",\n    \"downloadFailed\": \"Download fehlgeschlagen\",\n    \"downloadNecessaryFilesFailed\": \"Herunterladen der erforderlichen Dateien fehlgeschlagen. Bitte überprüfen Sie Ihr Netzwerk und versuchen Sie es erneut\",\n    \"emptyUrl\": \"Bitte geben Sie eine URL ein\",\n    \"errorDetails\": \"Fehlerdetails\",\n    \"fetchInfoFailed\": \"Video-Informationen konnten nicht abgerufen werden\",\n    \"invalidUrl\": \"Der Inhalt der Zwischenablage ist keine gültige URL\",\n    \"networkError\": \"Ein Fehler ist aufgetreten. Überprüfen Sie Ihr Netzwerk und verwenden Sie die richtige URL\",\n    \"pasteFromClipboard\": \"Einfügen aus Zwischenablage fehlgeschlagen\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Abgebrochene löschen\",\n    \"clearCompleted\": \"Abgeschlossene löschen\",\n    \"clearErrors\": \"Fehler löschen\",\n    \"clearAll\": \"Gesamten Verlauf löschen\",\n    \"clearAllAction\": \"Verlauf löschen\",\n    \"clearSelection\": \"Auswahl löschen\",\n    \"confirmClearAllTitle\": \"Gesamten Verlauf löschen?\",\n    \"confirmClearAllDescription\": \"{{count}} Elemente aus deinem Verlauf entfernen. Dateien bleiben auf der Festplatte.\",\n    \"confirmDeleteSelectedTitle\": \"Ausgewählte Elemente entfernen?\",\n    \"confirmDeleteSelectedDescription\": \"{{count}} Elemente aus deinem Verlauf entfernen. Dateien bleiben auf der Festplatte.\",\n    \"alsoDeleteFiles\": \"Dateien ebenfalls löschen\",\n    \"confirmDeletePlaylistTitle\": \"Playlist-Verlauf entfernen?\",\n    \"confirmDeletePlaylistDescription\": \"{{count}} Elemente aus {{title}} entfernen und ihre Dateien löschen.\",\n    \"copyToClipboard\": \"In Zwischenablage kopieren\",\n    \"copyUrl\": \"URL kopieren\",\n    \"date\": \"Datum\",\n    \"deletePlaylist\": \"Playlist entfernen\",\n    \"deleteSelected\": \"Auswahl entfernen\",\n    \"description\": \"Ihren Download-Verlauf anzeigen und verwalten\",\n    \"doneSelecting\": \"Fertig\",\n    \"duration\": \"Dauer\",\n    \"fileSize\": \"Dateigröße\",\n    \"filters\": {\n      \"all\": \"Alle\",\n      \"cancelled\": \"Abgebrochen\",\n      \"completed\": \"Abgeschlossen\",\n      \"errors\": \"Fehler\"\n    },\n    \"noHistory\": \"Noch kein Download-Verlauf\",\n    \"noHistoryDescription\": \"Ihre abgeschlossenen Downloads werden hier angezeigt\",\n    \"cookiesTipTitle\": \"Steigere den Download-Erfolg mit Cookies\",\n    \"cookiesTipDescription\": \"Richte Cookies ein, um die Erfolgsrate von <strong>70%</strong> auf <strong>99%</strong> zu erhöhen.\",\n    \"cookiesTipCta\": \"Cookies einrichten\",\n    \"openDownloadFolder\": \"Download-Ordner öffnen\",\n    \"openFile\": \"Datei öffnen\",\n    \"openFileLocation\": \"Dateispeicherort öffnen\",\n    \"openFolder\": \"Ordner öffnen\",\n    \"openInBrowser\": \"Klicken, um im Browser zu öffnen\",\n    \"removeAction\": \"Entfernen\",\n    \"removeItem\": \"Element entfernen\",\n    \"deleteFile\": \"Datei löschen\",\n    \"deleteRecord\": \"Aus Liste entfernen\",\n    \"select\": \"Auswählen\",\n    \"selectAll\": \"Alle auswählen\",\n    \"selectVisible\": \"Sichtbare auswählen\",\n    \"selectItem\": \"Element auswählen\",\n    \"selectedCount\": \"{{count}} ausgewählt\",\n    \"selectionSummary\": \"{{selected}} von {{total}} sichtbaren ausgewählt\",\n    \"stats\": {\n      \"cancelled\": \"Abgebrochen\",\n      \"completed\": \"Abgeschlossen\",\n      \"errors\": \"Fehler\",\n      \"total\": \"Gesamt\"\n    },\n    \"status\": {\n      \"cancelled\": \"Abgebrochen\",\n      \"completed\": \"Abgeschlossen\",\n      \"error\": \"Fehler\"\n    },\n    \"title\": \"Download-Verlauf\"\n  },\n  \"menu\": {\n    \"about\": \"Über\",\n    \"download\": \"Herunterladen\",\n    \"playlist\": \"Playlist herunterladen\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Abonnements\",\n    \"preferences\": \"Einstellungen\",\n    \"supportedSites\": \"Unterstützte Websites\",\n    \"theme\": \"Design:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Kopieren in Zwischenablage fehlgeschlagen\",\n    \"downloadCompleted\": \"Download abgeschlossen\",\n    \"downloadAlreadyQueued\": \"Dieser Download läuft bereits\",\n    \"downloadFailed\": \"Download fehlgeschlagen\",\n    \"historyCleared\": \"Verlauf gelöscht\",\n    \"historyClearFailed\": \"Verlauf konnte nicht gelöscht werden\",\n    \"itemRemoved\": \"Element entfernt\",\n    \"itemsRemoved\": \"{{count}} Elemente entfernt\",\n    \"itemsRemoveFailed\": \"Ausgewählte Elemente konnten nicht entfernt werden\",\n    \"openFileFailed\": \"Datei konnte nicht geöffnet werden\",\n    \"openFolderFailed\": \"Ordner konnte nicht geöffnet werden\",\n    \"playlistHistoryRemoved\": \"Playlist entfernt und Dateien gelöscht\",\n    \"playlistHistoryRemoveFailed\": \"Playlist-Verlauf konnte nicht entfernt werden\",\n    \"removeFailed\": \"Element konnte nicht entfernt werden\",\n    \"settingsSaved\": \"Einstellungen gespeichert\",\n    \"urlCopied\": \"URL in Zwischenablage kopiert\",\n    \"videoCopied\": \"Video in Zwischenablage kopiert\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Playlist\",\n    \"clearPreview\": \"Vorschau löschen\",\n    \"collapsedProgress\": \"Playlist wird heruntergeladen: {{completed}} / {{total}} abgeschlossen\",\n    \"comingSoon\": \"Playlist-Download-Funktion kommt bald!\",\n    \"completed\": \"Playlist heruntergeladen\",\n    \"description\": \"Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen\",\n    \"downloadFailed\": \"Playlist-Download konnte nicht gestartet werden\",\n    \"downloadPlaylist\": \"Playlist herunterladen\",\n    \"downloadType\": \"Download-Typ\",\n    \"downloading\": \"Playlist wird heruntergeladen:\",\n    \"endIndex\": \"Ende\",\n    \"enterPlaylistUrl\": \"Playlist-URL eingeben\",\n    \"fetchFailed\": \"Playlist-Informationen konnten nicht abgerufen werden\",\n    \"filenameFormat\": \"Dateinamenformat für Playlists\",\n    \"folderFormat\": \"Ordnernamenformat für Playlists\",\n    \"foundVideos\": \"{{count}} Videos in Playlist gefunden\",\n    \"groupActive\": \"{{count}} aktiv\",\n    \"groupCollapse\": \"Einklappen\",\n    \"groupErrors\": \"{{count}} fehlgeschlagen\",\n    \"groupExpand\": \"Ausklappen\",\n    \"groupSummary\": \"{{completed}} / {{total}} abgeschlossen\",\n    \"linkLabel\": \"Playlist-URL\",\n    \"noEntries\": \"In dieser Playlist wurden keine Videos gefunden\",\n    \"noEntriesInRange\": \"Keine Videos im ausgewählten Bereich\",\n    \"noRangeSelected\": \"Kein Ende gesetzt - vollständige Playlist ausgewählt\",\n    \"playlistUrlDescription\": \"Alle Videos aus einer Playlist in großen Mengen herunterladen\",\n    \"positionLabel\": \"Element {{index}} von {{total}}\",\n    \"previewButton\": \"Playlist-Vorschau\",\n    \"previewFailed\": \"Playlist-Vorschau fehlgeschlagen\",\n    \"previewSummary\": \"Playlist-Elemente vor dem Download in der Vorschau anzeigen.\",\n    \"previewRequired\": \"Playlist vor dem Download in der Vorschau anzeigen.\",\n    \"range\": \"Bereich (Optional)\",\n    \"resetToDefault\": \"Auf Standard zurücksetzen\",\n    \"selectedRange\": \"Bereich: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} ausgewählt\",\n    \"downloadCurrentRange\": \"Auswahl herunterladen\",\n    \"showingCount\": \"{{count}} Videos werden angezeigt\",\n    \"selectEntry\": \"Eintrag {{index}} auswählen\",\n    \"noEntriesSelected\": \"Keine Einträge ausgewählt\",\n    \"startIndex\": \"Start (1)\",\n    \"title\": \"Playlist herunterladen\",\n    \"totalVideos\": \"Gesamt Videos: {{count}}\",\n    \"untitled\": \"Unbenannte Playlist\",\n    \"fetchingInfo\": \"Playlist-Informationen werden abgerufen...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Über\",\n    \"advanced\": \"Erweitert\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"App-Einstellungen\",\n    \"audio\": \"Audio-Einstellungen\",\n    \"browserForCookies\": \"Browser für Cookies auswählen\",\n    \"browserForCookiesDescription\": \"Browser zum Extrahieren von Cookies für die Authentifizierung\",\n    \"browserForCookiesWindowsNote\": \"Unter Windows werden nur Firefox-Cookies unterstützt. Für andere Browser richten Sie bitte manuell eine Cookie-Datei ein.\",\n    \"browserForCookiesProfile\": \"Profilname oder Pfad\",\n    \"browserForCookiesProfileDescription\": \"Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.\",\n    \"browserForCookiesProfilePlaceholder\": \"Profilname oder vollständiger Pfad (optional)\",\n    \"browserForCookiesProfileInvalid\": \"Profilpfad ist ungültig. Wähle den Profilordner für den ausgewählten Browser.\",\n    \"browserForCookiesProfileInvalidPath\": \"Dieser Ordner existiert nicht. Wähle einen vorhandenen Profilordner.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Profilname am Standard-Browserort nicht gefunden.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Für diesen Browser ist auf dieser Plattform kein Standard-Profilort bekannt.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Gib einen Profilpfad für den ausgewählten Browser ein.\",\n    \"cookiesFile\": \"Cookie-Datei\",\n    \"cookiesFileDescription\": \"Netscape-formatierte Cookie-Datei zum Laden für die Authentifizierung\",\n    \"clearCookiesFile\": \"Löschen\",\n    \"cookiesHelpTitle\": \"Cookies verwenden\",\n    \"cookiesHelpBrowser\": \"Wählen Sie oben Ihren Browser aus, um seine angemeldete Sitzung automatisch wiederzuverwenden.\",\n    \"cookiesHelpFile\": \"Exportieren Sie eine Netscape-Cookie-Datei (siehe yt-dlp FAQ) und wählen Sie sie hier bei Bedarf aus.\",\n    \"cookiesGuideTitle\": \"Brauchst du eine Anleitung?\",\n    \"cookiesGuideDescription\": \"Sieh dir eine Schritt-für-Schritt-Anleitung für Cookies in VidBee an.\",\n    \"cookiesGuideLink\": \"Cookies-Anleitung öffnen\",\n    \"openLinkError\": \"Link konnte nicht geöffnet werden\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Konfigurationsdatei verwenden\",\n    \"configFileDescription\": \"Benutzerdefinierte Konfigurationsdatei für yt-dlp\",\n    \"clearConfigFile\": \"Löschen\",\n    \"dark\": \"Dunkel\",\n    \"description\": \"Konfigurieren Sie Ihre Download-Einstellungen und Anwendungseinstellungen\",\n    \"directorySelectError\": \"Verzeichnis konnte nicht ausgewählt werden\",\n    \"downloadPath\": \"Download-Speicherort\",\n    \"downloadPathDescription\": \"Wählen Sie, wo heruntergeladene Dateien gespeichert werden sollen\",\n    \"fileSelectError\": \"Datei konnte nicht ausgewählt werden\",\n    \"general\": \"Allgemein\",\n    \"language\": \"Sprache\",\n    \"languageDescription\": \"Wähle deine bevorzugte Sprache für die App-Oberfläche\",\n    \"light\": \"Hell\",\n    \"hideDockIcon\": \"Dock-Symbol ausblenden\",\n    \"hideDockIconDescription\": \"VidBee aus dem macOS Dock entfernen. Verwenden Sie die Menüleiste oder das Tray-Symbol, um die App erneut zu öffnen.\",\n    \"launchAtLogin\": \"Beim Start starten\",\n    \"launchAtLoginDescription\": \"VidBee automatisch öffnen, nachdem Sie sich bei Ihrem Computer angemeldet haben.\",\n    \"launchAtLoginUnsupported\": \"Autostart ist nur unter macOS und Windows verfügbar.\",\n    \"enableAnalytics\": \"Helfen Sie, VidBee zu verbessern\",\n    \"enableAnalyticsDescription\": \"Teilen Sie anonyme Nutzungsdaten, damit wir verstehen können, wie die App verwendet wird, und Verbesserungen priorisieren können.\",\n    \"embedChapters\": \"Kapitel einbetten\",\n    \"embedChaptersDescription\": \"Kapitelmarken zur Datei hinzufügen, wenn verfügbar\",\n    \"embedMetadata\": \"Metadaten einbetten\",\n    \"embedMetadataDescription\": \"Titel, Künstler und andere Metadaten schreiben, wenn verfügbar\",\n    \"embedSubs\": \"Untertitel einbetten\",\n    \"embedSubsDescription\": \"Untertitel in die Videodatei einbetten (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Vorschaubild einbetten\",\n    \"embedThumbnailDescription\": \"Vorschaubild als Covergrafik hinzufügen\",\n    \"shareWatermark\": \"Wasserzeichen zum Teilen\",\n    \"shareWatermarkDescription\": \"Fügt ein Wasserzeichen mit Originaltitel, Autor und VidBee-Branding hinzu\",\n    \"maxConcurrentDownloads\": \"Maximale Anzahl aktiver Downloads\",\n    \"maxConcurrentDownloadsDescription\": \"Maximale Anzahl gleichzeitiger Downloads\",\n    \"none\": \"Keine\",\n    \"oneClickDownload\": \"Ein-Klick-Download\",\n    \"oneClickDownloadDescription\": \"Ein-Klick-Download mit Standardeinstellungen aktivieren\",\n    \"oneClickDownloadType\": \"Standard-Download-Typ\",\n    \"oneClickDownloadTypeDescription\": \"Wählen Sie den Standard-Download-Typ für Ein-Klick-Downloads. Die Qualität verwendet die unten stehende Voreinstellung.\",\n    \"oneClickQuality\": \"Bevorzugte Qualität\",\n    \"oneClickQualityDescription\": \"Wählen Sie die Qualitätsvoreinstellung für Ein-Klick-Downloads\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Automatisch\",\n      \"bad\": \"Schlecht\",\n      \"best\": \"Am besten\",\n      \"good\": \"Gut\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Am schlechtesten\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Proxy-Server für Netzwerkanfragen\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Konfigurationsdatei auswählen\",\n    \"selectPath\": \"Auswählen\",\n    \"showMoreFormats\": \"Mehr Formatoptionen anzeigen\",\n    \"showMoreFormatsDescription\": \"Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.\"\n    },\n    \"system\": \"System\",\n    \"theme\": \"Design\",\n    \"themeDescription\": \"Wählen Sie ein helles, dunkles oder System-Design für VidBee\",\n    \"title\": \"Einstellungen\",\n    \"tray\": {\n      \"quit\": \"Beenden\",\n      \"showHome\": \"Startseite anzeigen\"\n    },\n    \"video\": \"Video-Einstellungen\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Abonnements\",\n    \"subtitle\": \"{{count}} Abonnement{{count, plural, one {} other {e}}}\",\n    \"description\": \"Überwachen Sie RSS-Feeds automatisch und fügen Sie neue Downloads zur Warteschlange hinzu, ohne manuelle Arbeit.\",\n    \"defaults\": {\n      \"title\": \"Automatisierungs-Standardeinstellungen\",\n      \"description\": \"Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.\",\n      \"downloadDirectory\": \"Download-Verzeichnis\",\n      \"filenameTemplate\": \"Dateinamen-Vorlage (nur Datei)\",\n      \"onlyLatest\": \"Nur das neueste Video herunterladen\",\n      \"onlyLatestDescription\": \"Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload.\"\n    },\n    \"add\": {\n      \"title\": \"RSS hinzufügen\",\n      \"description\": \"Fügen Sie einen RSS-Feed-Link ein. VidBee erkennt den Feed automatisch.\"\n    },\n    \"fields\": {\n      \"url\": \"Feed-URL\",\n      \"keywords\": \"Schlüsselwortfilter (durch Komma getrennt)\",\n      \"tags\": \"Automatische Tags\",\n      \"customDirectory\": \"Benutzerdefiniertes Verzeichnis\",\n      \"namingTemplate\": \"Benutzerdefinierte Dateinamen-Vorlage (nur Datei)\",\n      \"onlyLatest\": \"Nur das neueste Video herunterladen\",\n      \"onlyLatestDescription\": \"Backlog-Elemente ignorieren und nur den neuesten Upload aus diesem Feed abrufen.\",\n      \"enabled\": \"Aktiviert\",\n      \"disabled\": \"Deaktiviert\",\n      \"onlyLatestShort\": \"Nur neueste\"\n    },\n    \"actions\": {\n      \"add\": \"Hinzufügen\",\n      \"refresh\": \"Aktualisieren\",\n      \"edit\": \"Bearbeiten\",\n      \"remove\": \"Entfernen\",\n      \"save\": \"Änderungen speichern\",\n      \"selectDirectory\": \"Durchsuchen\",\n      \"enable\": \"Aktivieren\",\n      \"disable\": \"Deaktivieren\"\n    },\n    \"items\": {\n      \"title\": \"Neueste Uploads ({{count}})\",\n      \"count\": \"{{count}} Elemente\",\n      \"empty\": \"Keine kürzlichen Feed-Elemente gefunden.\",\n      \"status\": {\n        \"queued\": \"In Warteschlange\",\n        \"notQueued\": \"Nicht in Warteschlange\",\n        \"pending\": \"Ausstehend\",\n        \"downloading\": \"Wird heruntergeladen\",\n        \"processing\": \"Wird verarbeitet\",\n        \"completed\": \"Abgeschlossen\",\n        \"error\": \"Fehlgeschlagen\",\n        \"cancelled\": \"Abgebrochen\"\n      },\n      \"fromChannel\": \"Von {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Download-Status: {{status}}\",\n        \"downloadPending\": \"Warten auf Download-Details...\",\n        \"notQueued\": \"Noch nicht in der Download-Warteschlange\"\n      },\n      \"actions\": {\n        \"open\": \"Im Browser öffnen\",\n        \"queue\": \"Zur Download-Warteschlange hinzufügen\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Abonnement\",\n      \"unknown\": \"Unbekanntes Abonnement\",\n      \"noThumbnail\": \"Kein Vorschaubild\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Verzeichnisauswahl konnte nicht geöffnet werden.\",\n      \"missingUrl\": \"Bitte fügen Sie zuerst einen Kanal-Link ein.\",\n      \"created\": \"Abonnement hinzugefügt\",\n      \"createError\": \"Abonnement konnte nicht hinzugefügt werden.\",\n      \"refreshStarted\": \"Aktualisierung gestartet\",\n      \"removed\": \"Abonnement entfernt\",\n      \"updated\": \"Abonnement aktualisiert\",\n      \"itemQueued\": \"Zur Download-Warteschlange hinzugefügt\",\n      \"itemAlreadyQueued\": \"Dieses Video ist bereits in der Warteschlange\",\n      \"queueError\": \"Hinzufügen zur Download-Warteschlange fehlgeschlagen.\",\n      \"openLinkError\": \"Video-Link konnte nicht geöffnet werden.\",\n      \"resolveError\": \"RSS-Feed-URL konnte nicht aufgelöst werden.\",\n      \"duplicateUrl\": \"Dieser RSS-Feed ist bereits abonniert.\"\n    },\n    \"detectedFeed\": \"{{platform}} Feed erkannt -> {{feed}}\",\n    \"detecting\": \"Feed wird erkannt...\",\n    \"latestVideo\": \"Neuestes Video: {{title}}\",\n    \"lastChecked\": \"Zuletzt geprüft: {{time}}\",\n    \"never\": \"Nie\",\n    \"empty\": \"Noch keine Abonnements. Fügen Sie Ihre Lieblingskanäle hinzu, um mit dem automatischen Download zu beginnen.\",\n    \"edit\": {\n      \"title\": \"{{name}} bearbeiten\",\n      \"description\": \"Passen Sie Filter, Tags und Überschreibungen für diesen Feed an.\"\n    },\n    \"status\": {\n      \"title\": \"Status\",\n      \"up-to-date\": \"Aktuell\",\n      \"checking\": \"Wird geprüft\",\n      \"failed\": \"Fehlgeschlagen\",\n      \"idle\": \"Leerlauf\",\n      \"tooltip\": {\n        \"updatedAt\": \"Aktualisiert: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Automatische Abonnements mit RSSHub\",\n      \"description\": \"Kombinieren Sie VidBee mit RSSHub, um automatische Abonnements und Downloads von verschiedenen Plattformen zu ermöglichen. Nach der Einrichtung läuft VidBee im Hintergrund und lädt automatisch die neuesten Videos und Inhalte herunter.\",\n      \"learnMore\": \"Mehr über RSSHub erfahren\",\n      \"openDocs\": \"RSSHub-Dokumentation öffnen\",\n      \"hint\": \"Keine RSS-Feed-URL? Verwenden Sie RSSHub, um RSS-Feeds für YouTube, Twitter und Tausende anderer Plattformen zu generieren.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Unterstützt {{sites}} und mehr.\",\n    \"moreDescription\": \"Die vollständige yt-dlp-Liste wird ständig von der Community aktualisiert.\",\n    \"moreTitle\": \"Benötigen Sie eine andere Website?\",\n    \"openFullList\": \"Vollständige Liste der unterstützten Websites öffnen\",\n    \"pageDescription\": \"VidBee verwendet yt-dlp unter der Haube, um Hunderte von Quellen zu erreichen.\",\n    \"pageIntro\": \"Hier sind die gängigen Dienste, von denen die meisten Menschen herunterladen.\",\n    \"pageTitle\": \"Unterstützte Websites\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Alben unabhängiger Künstler und Community-Veröffentlichungen.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Globale Nachrichten, Sport und Unterhaltungsclips.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Videos aus Feed, Watch und Reels von öffentlichen Seiten.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Inhalte aus Feed, Stories, Reels und Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Live-Streams und Wiederholungen von Creators auf der Kick-Plattform.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Professionelle Vorträge, Webinare und Lernvideos.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ-Mixe, Radiosendungen und lange Audioformate.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Japanische Animation, Musik und Live-Übertragungsarchiv.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Ideen-Pins, How-to-Reels und Lifestyle-Inspirationsvideos.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Eingebettete Clips und gehostete Videos aus Communities.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Musiktitel, Playlists und DJ-Sets.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Kurze mobile Videos, Effekte und Live-Streams.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Kreative kurze Medien und Fan-Bearbeitungen.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Gaming-, Musik- und IRL-Live-Streams und VODs.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Timeline-Posts, Spaces-Aufnahmen und Übertragungen.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Hochwertiges Video-Hosting für Creators und Unternehmen.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Lange Videos und Livestreams von Creators weltweit.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Offizielle Musikvideos, Alben und Live-Auftritte.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Hauptplattformen\",\n    \"viewAll\": \"Alle unterstützten Websites anzeigen\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/en.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Check updates\",\n      \"download\": \"Download\",\n      \"email\": \"Email\",\n      \"feedback\": \"Feedback\",\n      \"goToDownload\": \"Go to download page\",\n      \"openRepo\": \"Open GitHub repository\",\n      \"view\": \"View\",\n      \"visit\": \"Visit\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Download and install new releases automatically in the background.\",\n    \"autoUpdateTitle\": \"Auto updates\",\n    \"betaProgramDescription\": \"Receive early builds and upcoming features before everyone else.\",\n    \"betaProgramTitle\": \"Preview channel\",\n    \"description\": \"VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Follow @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Stay updated with the latest VidBee news and updates.\",\n    \"followAuthorSupport\": \"Follow the developer on X (Twitter) to get the latest updates and news about VidBee.\",\n    \"followAuthorTitle\": \"Follow the Developer\",\n    \"here\": \"here\",\n    \"homepage\": \"Homepage\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Looking for updates...\",\n      \"downloadError\": \"Failed to download update\",\n      \"downloadUpdate\": \"Download and install update {{version}}?\",\n      \"manualDownloadAction\": \"Download now\",\n      \"noUpdatesAvailable\": \"You're using the latest version\",\n      \"restartToUpdate\": \"Restart now to install update?\",\n      \"restartNowAction\": \"Restart now\",\n      \"updateAvailable\": \"Update available: {{version}}\",\n      \"updateAvailableMessage\": \"A new version {{version}} is available. Please download it from the official website.\",\n      \"updateDownloaded\": \"Update downloaded, restart to install\",\n      \"updateDownloadedVersion\": \"Update {{version}} downloaded, restart to install\",\n      \"updateError\": \"Failed to check for updates: {{error}}\",\n      \"unknownErrorFallback\": \"Unknown error\"\n    },\n    \"preferencesDescription\": \"Tune update settings without leaving this page.\",\n    \"preferencesTitle\": \"Quick Toggles\",\n    \"resources\": {\n      \"changelog\": \"Release notes\",\n      \"changelogDescription\": \"Catch up on what changed in each version.\",\n      \"contact\": \"Email support\",\n      \"contactDescription\": \"Reach out directly for help or collaboration.\",\n      \"documentation\": \"Help center\",\n      \"documentationDescription\": \"Guides, FAQs, and common workflows.\",\n      \"feedback\": \"Feedback & issues\",\n      \"feedbackDescription\": \"Share ideas, report issues, or provide feedback through multiple channels.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Report bugs or request features on GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Share feedback or suggestions on X by mentioning @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Join our Discord community for discussions and support.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Frequently asked questions and troubleshooting guides.\",\n      \"license\": \"License\",\n      \"licenseDescription\": \"Review the open-source license terms.\",\n      \"website\": \"Official website\",\n      \"websiteDescription\": \"Product highlights, roadmap, and community news.\"\n    },\n    \"resourcesDescription\": \"Useful links to learn more about VidBee and stay connected.\",\n    \"resourcesTitle\": \"Resources\",\n    \"shareActions\": {\n      \"copy\": \"Copy link\",\n      \"facebook\": \"Share on Facebook\",\n      \"twitter\": \"Share on X (Twitter)\"\n    },\n    \"shareDescription\": \"Share VidBee with your community in one click.\",\n    \"shareSupport\": \"Recommend VidBee to your friends to support our growth and updates.\",\n    \"shareTitle\": \"Spread the word\",\n    \"sourceCode\": \"Source Code is available\",\n    \"title\": \"About\",\n    \"version\": \"Version\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Latest: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"New version available\",\n      \"uptodate\": \"You're up to date\",\n      \"error\": \"Unable to fetch the latest version\"\n    },\n    \"downloadingUpdate\": \"Downloading update\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Close app when download finishes\",\n    \"currentLocation\": \"Current download location - \",\n    \"downloadLocation\": \"Download location\",\n    \"downloadSubs\": \"Download subtitles if available\",\n    \"downloadSubsHint\": \"Save subtitles as separate files when available\",\n    \"end\": \"End\",\n    \"endHint\": \"If kept empty, it will be downloaded to the end\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Select Download Location\",\n    \"start\": \"Start\",\n    \"startHint\": \"If kept empty, it will start from the beginning\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Subtitles\",\n    \"timeRange\": \"Download particular time-range\",\n    \"title\": \"Advanced Options\"\n  },\n  \"app\": {\n    \"description\": \"Download videos and audios from hundreds of sites\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Active\",\n    \"all\": \"All\",\n    \"audio\": \"Audio\",\n    \"back\": \"Back\",\n    \"cancel\": \"Cancel\",\n    \"cancelled\": \"Cancelled\",\n    \"clearCompleted\": \"Clear Completed\",\n    \"clearDownloads\": \"Clear Downloads\",\n    \"completed\": \"Completed\",\n    \"downloadAudio\": \"Download Audio\",\n    \"downloadBtn\": \"Download\",\n    \"downloadPending\": \"Pending\",\n    \"downloadQueue\": \"Download Queue\",\n    \"customDownloadFolder\": \"Custom download folder\",\n    \"retry\": \"Retry Download\",\n    \"autoFolderPlaceholder\": \"Automatic folder (based on metadata)\",\n    \"autoFolderHint\": \"Automatic folders are created from metadata.\",\n    \"useAutoFolder\": \"Use automatic folder\",\n    \"downloadVideo\": \"Download Video\",\n    \"downloading\": \"Downloading...\",\n    \"enterUrl\": \"Enter Video URL\",\n    \"enterUrlDescription\": \"Paste or type a video URL. \",\n    \"error\": \"Error\",\n    \"fetch\": \"Fetch\",\n    \"fetchingVideoInfo\": \"Fetching video info...\",\n    \"feedback\": {\n      \"title\": \"Report this error:\",\n      \"githubUrlTooLong\": \"This GitHub link is very long. If it fails to open, please open the issue page and paste the logs manually.\"\n    },\n    \"history\": \"History\",\n    \"imageLoadError\": \"Image failed to load\",\n    \"imagePlaceholder\": \"No image available\",\n    \"infoUnavailable\": \"One-Click Download (Info unavailable)\",\n    \"loading\": \"Loading\",\n    \"moreOptions\": \"More options\",\n    \"noActiveDownloads\": \"No active downloads\",\n    \"noAudio\": \"No Audio\",\n    \"noHistory\": \"No download history\",\n    \"noItems\": \"No items found\",\n    \"goToSettings\": \"Go to Settings\",\n    \"oneClickDownload\": \"One-Click Download\",\n    \"oneClickDownloadDescription\": \"Download directly with default settings without confirmation\",\n    \"oneClickDownloadTooltip\": \"Paste & download instantly, skip the steps\",\n    \"oneClickDownloadNow\": \"Download Now\",\n    \"oneClickDownloadStarted\": \"Download started with default settings\",\n    \"paste\": \"Paste\",\n    \"pastePlaylistUrl\": \"Click to paste playlist link from clipboard [Ctrl + V]\",\n    \"pasteUrl\": \"Click to paste video URL or ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Add URL\",\n    \"preparing\": \"Preparing...\",\n    \"processing\": \"Processing\",\n    \"progress\": \"Progress\",\n    \"showDetails\": \"Show details\",\n    \"hideDetails\": \"Hide details\",\n    \"viewLogs\": \"View logs\",\n    \"detailsTab\": \"Details\",\n    \"logsTab\": \"Logs\",\n    \"logs\": {\n      \"live\": \"Live logs\",\n      \"history\": \"Saved logs\",\n      \"command\": \"yt-dlp command\",\n      \"empty\": \"No logs yet.\",\n      \"scrollPaused\": \"Scroll paused\"\n    },\n    \"selectAudioFormat\": \"Select Audio Format\",\n    \"selectDownloadType\": \"Select download type\",\n    \"selectFormat\": \"Select Format\",\n    \"startDownload\": \"Start Download\",\n    \"selectVideoFormat\": \"Select Video Format\",\n    \"singleVideo\": \"Single Video\",\n    \"speed\": \"Speed\",\n    \"title\": \"Title\",\n    \"total\": \"Total\",\n    \"unknownQuality\": \"Unknown quality\",\n    \"unknownSize\": \"Unknown size\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Video Information\",\n    \"videoInfoUpdated\": \"Video information updated\",\n    \"metadata\": {\n      \"source\": \"Source\",\n      \"playlist\": \"Playlist\",\n      \"format\": \"Format\",\n      \"quality\": \"Quality\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"Saved file\",\n      \"url\": \"Source URL\",\n      \"description\": \"Description\",\n      \"views\": \"Views\",\n      \"tags\": \"Tags\",\n      \"downloadPath\": \"Download path\",\n      \"createdAt\": \"Created at\",\n      \"startedAt\": \"Started at\",\n      \"completedAt\": \"Completed at\",\n      \"speed\": \"Speed\",\n      \"fileSize\": \"File size\",\n      \"width\": \"Width\",\n      \"height\": \"Height\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Video codec\",\n      \"audioCodec\": \"Audio codec\",\n      \"formatNote\": \"Format note\",\n      \"protocol\": \"Protocol\",\n      \"subscription\": \"Subscription\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Something went wrong\",\n    \"description\": \"An unexpected error occurred. Please try reloading the application or report this issue if it persists.\",\n    \"message\": \"Error Message\",\n    \"unknownError\": \"Unknown error occurred\",\n    \"goHome\": \"Go Home\",\n    \"reload\": \"Reload App\",\n    \"copyReport\": \"Copy Error Report\",\n    \"copied\": \"Copied!\",\n    \"copySuccess\": \"Error report copied to clipboard\",\n    \"copyFailed\": \"Failed to copy error report\",\n    \"showDetails\": \"Show Details\",\n    \"hideDetails\": \"Hide Details\",\n    \"stackTrace\": \"Stack Trace\",\n    \"componentStack\": \"Component Stack\",\n    \"noStackTrace\": \"No stack trace available\",\n    \"fullReport\": \"Full Error Report\",\n    \"helpText\": \"If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Click to copy details\",\n    \"clipboardEmpty\": \"Clipboard is empty\",\n    \"downloadFailed\": \"Download failed\",\n    \"downloadNecessaryFilesFailed\": \"Failed to download necessary files. Please check your network and try again\",\n    \"emptyUrl\": \"Please enter a URL\",\n    \"errorDetails\": \"Error Details\",\n    \"fetchInfoFailed\": \"Failed to fetch video information\",\n    \"invalidUrl\": \"The clipboard content is not a valid URL\",\n    \"networkError\": \"Some error has occurred. Check your network and use correct URL\",\n    \"pasteFromClipboard\": \"Failed to paste from clipboard\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Clear Cancelled\",\n    \"clearCompleted\": \"Clear Completed\",\n    \"clearErrors\": \"Clear Errors\",\n    \"clearAll\": \"Clear All History\",\n    \"clearAllAction\": \"Clear History\",\n    \"clearSelection\": \"Clear Selection\",\n    \"confirmClearAllTitle\": \"Clear all history?\",\n    \"confirmClearAllDescription\": \"Remove {{count}} items from your history. Files stay on disk.\",\n    \"confirmDeleteSelectedTitle\": \"Remove selected items?\",\n    \"confirmDeleteSelectedDescription\": \"Remove {{count}} items from your history. Files stay on disk.\",\n    \"alsoDeleteFiles\": \"Also delete files\",\n    \"confirmDeletePlaylistTitle\": \"Remove playlist history?\",\n    \"confirmDeletePlaylistDescription\": \"Remove {{count}} items from {{title}} and delete their files.\",\n    \"copyToClipboard\": \"Copy to clipboard\",\n    \"copyUrl\": \"Copy URL\",\n    \"date\": \"Date\",\n    \"deletePlaylist\": \"Remove Playlist\",\n    \"deleteSelected\": \"Remove Selected\",\n    \"description\": \"View and manage your download history\",\n    \"doneSelecting\": \"Done\",\n    \"duration\": \"Duration\",\n    \"fileSize\": \"File Size\",\n    \"filters\": {\n      \"all\": \"All\",\n      \"cancelled\": \"Cancelled\",\n      \"completed\": \"Completed\",\n      \"errors\": \"Errors\"\n    },\n    \"noHistory\": \"No download history yet\",\n    \"noHistoryDescription\": \"Your completed downloads will appear here\",\n    \"cookiesTipTitle\": \"Boost download success with cookies\",\n    \"cookiesTipDescription\": \"Configure cookies to raise success rates from <strong>70%</strong> to <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Set up cookies\",\n    \"openDownloadFolder\": \"Open Download Folder\",\n    \"openFile\": \"Open File\",\n    \"openFileLocation\": \"Open File Location\",\n    \"openFolder\": \"Open Folder\",\n    \"openInBrowser\": \"Click to open in browser\",\n    \"removeAction\": \"Remove\",\n    \"removeItem\": \"Remove Item\",\n    \"deleteFile\": \"Delete File\",\n    \"deleteRecord\": \"Remove from List\",\n    \"select\": \"Select\",\n    \"selectAll\": \"Select All\",\n    \"selectVisible\": \"Select visible\",\n    \"selectItem\": \"Select item\",\n    \"selectedCount\": \"{{count}} selected\",\n    \"selectionSummary\": \"{{selected}} of {{total}} visible selected\",\n    \"stats\": {\n      \"cancelled\": \"Cancelled\",\n      \"completed\": \"Completed\",\n      \"errors\": \"Errors\",\n      \"total\": \"Total\"\n    },\n    \"status\": {\n      \"cancelled\": \"Cancelled\",\n      \"completed\": \"Completed\",\n      \"error\": \"Error\"\n    },\n    \"title\": \"Download History\"\n  },\n  \"menu\": {\n    \"about\": \"About\",\n    \"download\": \"Download\",\n    \"playlist\": \"Download Playlist\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Subscriptions\",\n    \"preferences\": \"Preferences\",\n    \"supportedSites\": \"Supported Sites\",\n    \"theme\": \"Theme:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Failed to copy to clipboard\",\n    \"downloadCompleted\": \"Download completed\",\n    \"downloadAlreadyQueued\": \"This download is already in progress\",\n    \"downloadFailed\": \"Download failed\",\n    \"historyCleared\": \"History cleared\",\n    \"historyClearFailed\": \"Failed to clear history\",\n    \"itemRemoved\": \"Item removed\",\n    \"itemsRemoved\": \"Removed {{count}} items\",\n    \"itemsRemoveFailed\": \"Failed to remove selected items\",\n    \"openFileFailed\": \"Failed to open file\",\n    \"openFolderFailed\": \"Failed to open folder\",\n    \"playlistHistoryRemoved\": \"Playlist removed and files deleted\",\n    \"playlistHistoryRemoveFailed\": \"Failed to remove playlist history\",\n    \"removeFailed\": \"Failed to remove item\",\n    \"settingsSaved\": \"Settings saved\",\n    \"urlCopied\": \"URL copied to clipboard\",\n    \"videoCopied\": \"Video copied to clipboard\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Playlist\",\n    \"clearPreview\": \"Clear preview\",\n    \"collapsedProgress\": \"Downloading playlist: {{completed}} / {{total}} completed\",\n    \"comingSoon\": \"Playlist download feature coming soon!\",\n    \"completed\": \"Playlist downloaded\",\n    \"description\": \"Download all videos from a YouTube playlist or channel\",\n    \"downloadFailed\": \"Failed to start playlist download\",\n    \"downloadPlaylist\": \"Download Playlist\",\n    \"downloadType\": \"Download Type\",\n    \"downloading\": \"Downloading playlist:\",\n    \"endIndex\": \"End\",\n    \"enterPlaylistUrl\": \"Enter Playlist URL\",\n    \"fetchFailed\": \"Failed to fetch playlist information\",\n    \"filenameFormat\": \"Filename format for playlists\",\n    \"folderFormat\": \"Folder name format for playlists\",\n    \"foundVideos\": \"Found {{count}} videos in playlist\",\n    \"groupActive\": \"{{count}} active\",\n    \"groupCollapse\": \"Collapse\",\n    \"groupErrors\": \"{{count}} failed\",\n    \"groupExpand\": \"Expand\",\n    \"groupSummary\": \"{{completed}} / {{total}} completed\",\n    \"linkLabel\": \"Playlist URL\",\n    \"noEntries\": \"No videos were found in this playlist\",\n    \"noEntriesInRange\": \"No videos in the selected range\",\n    \"noRangeSelected\": \"No end set - full playlist selected\",\n    \"playlistUrlDescription\": \"Download all videos from a playlist in bulk\",\n    \"positionLabel\": \"Item {{index}} of {{total}}\",\n    \"previewButton\": \"Preview playlist\",\n    \"previewFailed\": \"Failed to preview playlist\",\n    \"previewSummary\": \"Preview playlist items before downloading.\",\n    \"previewRequired\": \"Preview the playlist before downloading.\",\n    \"range\": \"Range (Optional)\",\n    \"resetToDefault\": \"Reset to default\",\n    \"selectedRange\": \"Range: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} selected\",\n    \"downloadCurrentRange\": \"Download Selected\",\n    \"showingCount\": \"Showing {{count}} videos\",\n    \"selectEntry\": \"Select entry {{index}}\",\n    \"noEntriesSelected\": \"No entries selected\",\n    \"startIndex\": \"Start (1)\",\n    \"title\": \"Download Playlist\",\n    \"totalVideos\": \"Total videos: {{count}}\",\n    \"untitled\": \"Untitled playlist\",\n    \"fetchingInfo\": \"Fetching playlist information...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"About\",\n    \"advanced\": \"Advanced\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"App Settings\",\n    \"audio\": \"Audio Preferences\",\n    \"browserForCookies\": \"Select browser to use cookies from\",\n    \"browserForCookiesDescription\": \"Browser to extract cookies from for authentication. We'll try to detect a profile automatically.\",\n    \"browserForCookiesWindowsNote\": \"Windows only supports Firefox cookies. For other browsers, please configure a cookies file manually.\",\n    \"browserForCookiesProfile\": \"Profile name or path\",\n    \"browserForCookiesProfileDescription\": \"Profile path for the browser selected above. Auto-filled when possible.\",\n    \"browserForCookiesProfilePlaceholder\": \"Profile name or full path (optional)\",\n    \"browserForCookiesProfileInvalid\": \"Profile path is not valid. Choose the profile folder for the selected browser.\",\n    \"browserForCookiesProfileInvalidPath\": \"That folder does not exist. Pick an existing profile folder.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Profile name not found in the default browser location.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"No default profile location is known for this browser on this platform.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Enter a profile path for the selected browser.\",\n    \"cookiesFile\": \"Cookies file\",\n    \"cookiesFileDescription\": \"Netscape formatted cookies file to load for authentication\",\n    \"clearCookiesFile\": \"Clear\",\n    \"cookiesHelpTitle\": \"Using cookies\",\n    \"cookiesHelpBrowser\": \"Pick your browser above to reuse its signed-in session automatically.\",\n    \"cookiesHelpFile\": \"Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.\",\n    \"cookiesGuideTitle\": \"Need a walkthrough?\",\n    \"cookiesGuideDescription\": \"See step-by-step guidance for using cookies in VidBee.\",\n    \"cookiesGuideLink\": \"Open cookies guide\",\n    \"openLinkError\": \"Failed to open link\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Use configuration file\",\n    \"configFileDescription\": \"Custom configuration file for yt-dlp\",\n    \"clearConfigFile\": \"Clear\",\n    \"dark\": \"Dark\",\n    \"description\": \"Configure your download preferences and application settings\",\n    \"directorySelectError\": \"Failed to select directory\",\n    \"downloadPath\": \"Download location\",\n    \"downloadPathDescription\": \"Choose where to save downloaded files\",\n    \"fileSelectError\": \"Failed to select file\",\n    \"general\": \"General\",\n    \"language\": \"Language\",\n    \"languageDescription\": \"Choose your preferred language for the application interface\",\n    \"light\": \"Light\",\n    \"hideDockIcon\": \"Hide Dock icon\",\n    \"hideDockIconDescription\": \"Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.\",\n    \"launchAtLogin\": \"Launch at startup\",\n    \"launchAtLoginDescription\": \"Open VidBee automatically after you sign in to your computer.\",\n    \"launchAtLoginUnsupported\": \"Auto launch is only available on macOS and Windows.\",\n    \"enableAnalytics\": \"Help improve VidBee\",\n    \"enableAnalyticsDescription\": \"Share anonymous usage data to help us understand how the app is used and prioritize improvements.\",\n    \"embedChapters\": \"Embed chapters\",\n    \"embedChaptersDescription\": \"Add chapter markers to the file when available\",\n    \"embedMetadata\": \"Embed metadata\",\n    \"embedMetadataDescription\": \"Write title, artist, and other metadata when available\",\n    \"embedSubs\": \"Embed subtitles\",\n    \"embedSubsDescription\": \"Embed subtitles into the video file (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Embed thumbnail\",\n    \"embedThumbnailDescription\": \"Add the thumbnail as cover art\",\n    \"shareWatermark\": \"Share watermark\",\n    \"shareWatermarkDescription\": \"Add a watermark with the original title, author, and VidBee branding\",\n    \"maxConcurrentDownloads\": \"Maximum number of active downloads\",\n    \"maxConcurrentDownloadsDescription\": \"Maximum number of simultaneous downloads\",\n    \"none\": \"None\",\n    \"oneClickDownload\": \"One-Click Download\",\n    \"oneClickDownloadDescription\": \"Enable one-click download with default settings\",\n    \"oneClickDownloadType\": \"Default download type\",\n    \"oneClickDownloadTypeDescription\": \"Choose the default download type for one-click downloads. Quality uses the preset below.\",\n    \"oneClickQuality\": \"Preferred quality\",\n    \"oneClickQualityDescription\": \"Select the quality preset used for one-click downloads\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Auto\",\n      \"bad\": \"Bad\",\n      \"best\": \"Best\",\n      \"good\": \"Good\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Worst\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Proxy server for network requests\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Select config file\",\n    \"selectPath\": \"Select\",\n    \"showMoreFormats\": \"Show more format options\",\n    \"showMoreFormatsDescription\": \"Display additional format options in the interface\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Pattern used when a subscription does not override its filename.\"\n    },\n    \"system\": \"System\",\n    \"theme\": \"Theme\",\n    \"themeDescription\": \"Choose a light, dark, or system theme for VidBee\",\n    \"title\": \"Settings\",\n    \"tray\": {\n      \"quit\": \"Quit\",\n      \"showHome\": \"Show Home\"\n    },\n    \"video\": \"Video Preferences\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Subscriptions\",\n    \"subtitle\": \"{{count}} subscription{{count, plural, one {} other {s}}}\",\n    \"description\": \"Automatically monitor RSS feeds and queue new downloads without manual work.\",\n    \"defaults\": {\n      \"title\": \"Automation defaults\",\n      \"description\": \"Control where subscription downloads are stored and how often VidBee checks for new videos.\",\n      \"downloadDirectory\": \"Download directory\",\n      \"filenameTemplate\": \"Filename template\",\n      \"onlyLatest\": \"Download only the latest video\",\n      \"onlyLatestDescription\": \"When enabled, VidBee skips older backlog items and only grabs the newest upload.\"\n    },\n    \"add\": {\n      \"title\": \"Add RSS\",\n      \"description\": \"Paste an RSS feed link. VidBee will detect the feed automatically.\"\n    },\n    \"fields\": {\n      \"url\": \"Feed URL\",\n      \"keywords\": \"Keyword filter (comma separated)\",\n      \"tags\": \"Auto tags\",\n      \"customDirectory\": \"Custom directory\",\n      \"namingTemplate\": \"Custom filename template\",\n      \"onlyLatest\": \"Download only the latest video\",\n      \"onlyLatestDescription\": \"Ignore backlog items and fetch just the newest upload from this feed.\",\n      \"enabled\": \"Enabled\",\n      \"disabled\": \"Disabled\",\n      \"onlyLatestShort\": \"Only latest\"\n    },\n    \"actions\": {\n      \"add\": \"Add\",\n      \"refresh\": \"Refresh\",\n      \"edit\": \"Edit\",\n      \"remove\": \"Remove\",\n      \"save\": \"Save changes\",\n      \"selectDirectory\": \"Browse\",\n      \"enable\": \"Enable\",\n      \"disable\": \"Disable\"\n    },\n    \"items\": {\n      \"title\": \"Latest uploads ({{count}})\",\n      \"count\": \"{{count}} items\",\n      \"empty\": \"No recent feed items found.\",\n      \"status\": {\n        \"queued\": \"Queued\",\n        \"notQueued\": \"Not queued\",\n        \"pending\": \"Pending\",\n        \"downloading\": \"Downloading\",\n        \"processing\": \"Processing\",\n        \"completed\": \"Completed\",\n        \"error\": \"Failed\",\n        \"cancelled\": \"Cancelled\"\n      },\n      \"fromChannel\": \"From {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Download status: {{status}}\",\n        \"downloadPending\": \"Waiting for download details...\",\n        \"notQueued\": \"Not in the download queue yet\"\n      },\n      \"actions\": {\n        \"open\": \"Open in browser\",\n        \"queue\": \"Add to download queue\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Subscription\",\n      \"unknown\": \"Unknown subscription\",\n      \"noThumbnail\": \"No thumbnail\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Failed to open the directory picker.\",\n      \"missingUrl\": \"Please paste a channel link first.\",\n      \"created\": \"Subscription added\",\n      \"createError\": \"Failed to add subscription.\",\n      \"duplicateUrl\": \"This RSS feed is already subscribed.\",\n      \"refreshStarted\": \"Refresh started\",\n      \"removed\": \"Subscription removed\",\n      \"updated\": \"Subscription updated\",\n      \"itemQueued\": \"Added to download queue\",\n      \"itemAlreadyQueued\": \"This video is already queued\",\n      \"queueError\": \"Failed to add to download queue.\",\n      \"openLinkError\": \"Failed to open the video link.\",\n      \"resolveError\": \"Failed to resolve RSS feed URL.\"\n    },\n    \"detectedFeed\": \"Detected {{platform}} feed -> {{feed}}\",\n    \"detecting\": \"Detecting feed...\",\n    \"latestVideo\": \"Latest video: {{title}}\",\n    \"lastChecked\": \"Last checked: {{time}}\",\n    \"never\": \"Never\",\n    \"empty\": \"No subscriptions yet. Add your favorite channels to start auto-downloading.\",\n    \"edit\": {\n      \"title\": \"Edit {{name}}\",\n      \"description\": \"Tweak filters, tags, and overrides for this feed.\"\n    },\n    \"status\": {\n      \"title\": \"Status\",\n      \"up-to-date\": \"Up to date\",\n      \"checking\": \"Checking\",\n      \"failed\": \"Failed\",\n      \"idle\": \"Idle\",\n      \"tooltip\": {\n        \"updatedAt\": \"Updated: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Automated Subscriptions with RSSHub\",\n      \"description\": \"Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.\",\n      \"learnMore\": \"Learn more about RSSHub\",\n      \"openDocs\": \"Open RSSHub Documentation\",\n      \"hint\": \"Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Supports {{sites}} and more.\",\n    \"moreDescription\": \"The complete yt-dlp list is updated constantly by the community.\",\n    \"moreTitle\": \"Need another site?\",\n    \"openFullList\": \"Open full supported sites list\",\n    \"pageDescription\": \"VidBee uses yt-dlp under the hood to reach hundreds of sources.\",\n    \"pageIntro\": \"Here are the mainstream services people download from most frequently.\",\n    \"pageTitle\": \"Supported Sites\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Independent artist albums and community releases.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Global news, sports, and entertainment clips.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Feed, Watch, and Reels videos from public pages.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Feed, Stories, Reels, and Highlights content.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Creator live streams and replays on the Kick platform.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Professional talks, webinars, and learning videos.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ mixes, radio shows, and long-form audio.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Japanese animation, music, and live broadcast archive.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Idea pins, how-to reels, and lifestyle inspiration videos.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Embedded clips and hosted videos from communities.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Music tracks, playlists, and DJ sets.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Short-form mobile videos, effects, and live streams.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Creative short-form media and fan edits.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Gaming, music, and IRL live streams and VODs.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Timeline posts, Spaces recordings, and broadcasts.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"High-quality creator and business video hosting.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Long-form and livestream video from creators worldwide.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Official music videos, albums, and live performances.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Main platforms\",\n    \"viewAll\": \"View all supported sites\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/es.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Verificar actualizaciones\",\n      \"download\": \"Descargar\",\n      \"email\": \"Correo electrónico\",\n      \"feedback\": \"Comentarios\",\n      \"goToDownload\": \"Ir a la página de descarga\",\n      \"openRepo\": \"Abrir repositorio de GitHub\",\n      \"view\": \"Ver\",\n      \"visit\": \"Visitar\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Descargar e instalar nuevas versiones automáticamente en segundo plano.\",\n    \"autoUpdateTitle\": \"Actualizaciones automáticas\",\n    \"betaProgramDescription\": \"Recibe compilaciones tempranas y próximas funciones antes que nadie.\",\n    \"betaProgramTitle\": \"Canal de vista previa\",\n    \"description\": \"VidBee es un descargador gratuito y de código abierto construido con Electron y potenciado por yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Seguir a @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Mantente actualizado con las últimas noticias y actualizaciones de VidBee.\",\n    \"followAuthorSupport\": \"Sigue al desarrollador en X (Twitter) para obtener las últimas actualizaciones y noticias sobre VidBee.\",\n    \"followAuthorTitle\": \"Seguir al Desarrollador\",\n    \"here\": \"aquí\",\n    \"homepage\": \"Página de inicio\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Buscando actualizaciones...\",\n      \"downloadError\": \"Error al descargar la actualización\",\n      \"downloadUpdate\": \"¿Descargar e instalar la actualización {{version}}?\",\n      \"manualDownloadAction\": \"Descargar ahora\",\n      \"noUpdatesAvailable\": \"Estás usando la última versión\",\n      \"restartToUpdate\": \"¿Reiniciar ahora para instalar la actualización?\",\n      \"restartNowAction\": \"Reiniciar ahora\",\n      \"updateAvailable\": \"Actualización disponible: {{version}}\",\n      \"updateAvailableMessage\": \"Una nueva versión {{version}} está disponible. Por favor, descárgala desde el sitio web oficial.\",\n      \"updateDownloaded\": \"Actualización descargada, reinicia para instalar\",\n      \"updateDownloadedVersion\": \"Actualización {{version}} descargada, reinicia para instalar\",\n      \"updateError\": \"Error al verificar actualizaciones: {{error}}\",\n      \"unknownErrorFallback\": \"Error desconocido\"\n    },\n    \"preferencesDescription\": \"Ajusta la configuración de actualizaciones sin salir de esta página.\",\n    \"preferencesTitle\": \"Cambios Rápidos\",\n    \"resources\": {\n      \"changelog\": \"Notas de la versión\",\n      \"changelogDescription\": \"Infórmate sobre lo que cambió en cada versión.\",\n      \"contact\": \"Soporte por correo\",\n      \"contactDescription\": \"Contacta directamente para ayuda o colaboración.\",\n      \"documentation\": \"Centro de ayuda\",\n      \"documentationDescription\": \"Guías, preguntas frecuentes y flujos de trabajo comunes.\",\n      \"feedback\": \"Comentarios e incidencias\",\n      \"feedbackDescription\": \"Comparte ideas o reporta problemas en GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Reportar errores o solicitar funciones en GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Comparte comentarios o sugerencias en X mencionando a @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Únete a nuestra comunidad de Discord para debates y soporte.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Preguntas frecuentes y guías de solución de problemas.\",\n      \"license\": \"Licencia\",\n      \"licenseDescription\": \"Revisa los términos de la licencia de código abierto.\",\n      \"website\": \"Sitio web oficial\",\n      \"websiteDescription\": \"Destacados del producto, hoja de ruta y noticias de la comunidad.\"\n    },\n    \"resourcesDescription\": \"Enlaces útiles para aprender más sobre VidBee y mantenerte conectado.\",\n    \"resourcesTitle\": \"Recursos\",\n    \"shareActions\": {\n      \"copy\": \"Copiar enlace\",\n      \"facebook\": \"Compartir en Facebook\",\n      \"twitter\": \"Compartir en X (Twitter)\"\n    },\n    \"shareDescription\": \"Comparte VidBee con tu comunidad con un clic.\",\n    \"shareSupport\": \"Recomienda VidBee a tus amigos para apoyar nuestro crecimiento y actualizaciones.\",\n    \"shareTitle\": \"Difunde la palabra\",\n    \"sourceCode\": \"El código fuente está disponible\",\n    \"title\": \"Acerca de\",\n    \"version\": \"Versión\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Última: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Nueva versión disponible\",\n      \"uptodate\": \"Estás actualizado\",\n      \"error\": \"No se pudo obtener la última versión\"\n    },\n    \"downloadingUpdate\": \"Descargando actualización\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Cerrar la aplicación cuando termine la descarga\",\n    \"currentLocation\": \"Ubicación de descarga actual - \",\n    \"downloadLocation\": \"Ubicación de descarga\",\n    \"downloadSubs\": \"Descargar subtítulos si están disponibles\",\n    \"downloadSubsHint\": \"Guardar subtítulos como archivos separados cuando estén disponibles\",\n    \"end\": \"Fin\",\n    \"endHint\": \"Si se deja vacío, se descargará hasta el final\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Seleccionar Ubicación de Descarga\",\n    \"start\": \"Inicio\",\n    \"startHint\": \"Si se deja vacío, comenzará desde el principio\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Subtítulos\",\n    \"timeRange\": \"Descargar rango de tiempo específico\",\n    \"title\": \"Opciones Avanzadas\"\n  },\n  \"app\": {\n    \"description\": \"Descarga videos y audios de cientos de sitios\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Activo\",\n    \"all\": \"Todo\",\n    \"audio\": \"Audio\",\n    \"back\": \"Atrás\",\n    \"cancel\": \"Cancelar\",\n    \"cancelled\": \"Cancelado\",\n    \"clearCompleted\": \"Limpiar Completados\",\n    \"clearDownloads\": \"Limpiar Descargas\",\n    \"completed\": \"Completado\",\n    \"downloadAudio\": \"Descargar Audio\",\n    \"downloadBtn\": \"Descargar\",\n    \"downloadPending\": \"Pendiente\",\n    \"downloadQueue\": \"Cola de Descarga\",\n    \"customDownloadFolder\": \"Carpeta de descarga personalizada\",\n    \"retry\": \"Reintentar descarga\",\n    \"autoFolderPlaceholder\": \"Carpeta automática (basada en metadatos)\",\n    \"autoFolderHint\": \"Las carpetas automáticas se crean a partir de metadatos.\",\n    \"useAutoFolder\": \"Usar carpeta automática\",\n    \"downloadVideo\": \"Descargar Video\",\n    \"downloading\": \"Descargando...\",\n    \"enterUrl\": \"Ingresar URL del Video\",\n    \"enterUrlDescription\": \"Pega o escribe una URL de video. \",\n    \"error\": \"Error\",\n    \"fetch\": \"Obtener\",\n    \"fetchingVideoInfo\": \"Obteniendo información del video...\",\n    \"feedback\": {\n      \"title\": \"Informe este error:\",\n      \"githubUrlTooLong\": \"Este enlace de GitHub es muy largo. Si no se abre, abre la página de la incidencia y pega los registros manualmente.\"\n    },\n    \"history\": \"Historial\",\n    \"imageLoadError\": \"Error al cargar la imagen\",\n    \"imagePlaceholder\": \"No hay imagen disponible\",\n    \"infoUnavailable\": \"Descarga con un clic (Información no disponible)\",\n    \"loading\": \"Cargando\",\n    \"moreOptions\": \"Más opciones\",\n    \"noActiveDownloads\": \"No hay descargas activas\",\n    \"noAudio\": \"Sin Audio\",\n    \"noHistory\": \"No hay historial de descargas\",\n    \"noItems\": \"No se encontraron elementos\",\n    \"goToSettings\": \"Ir a Configuración\",\n    \"oneClickDownload\": \"Descarga con un Clic\",\n    \"oneClickDownloadDescription\": \"Descargar directamente con la configuración predeterminada sin confirmación\",\n    \"oneClickDownloadTooltip\": \"Pega y descarga al instante, omite los pasos\",\n    \"oneClickDownloadNow\": \"Descargar Ahora\",\n    \"oneClickDownloadStarted\": \"Descarga iniciada con la configuración predeterminada\",\n    \"paste\": \"Pegar\",\n    \"pastePlaylistUrl\": \"Haz clic para pegar el enlace de la lista de reproducción desde el portapapeles [Ctrl + V]\",\n    \"pasteUrl\": \"Haz clic para pegar la URL o ID del video [Ctrl + V]\",\n    \"pasteUrlButton\": \"Pegar URL\",\n    \"preparing\": \"Preparando...\",\n    \"processing\": \"Procesando\",\n    \"progress\": \"Progreso\",\n    \"showDetails\": \"Mostrar detalles\",\n    \"hideDetails\": \"Ocultar detalles\",\n    \"viewLogs\": \"Ver registros\",\n    \"detailsTab\": \"Detalles\",\n    \"logsTab\": \"Registros\",\n    \"logs\": {\n      \"live\": \"Registros en vivo\",\n      \"history\": \"Registros guardados\",\n      \"command\": \"Comando de yt-dlp\",\n      \"empty\": \"Aún no hay registros.\",\n      \"scrollPaused\": \"Desplazamiento en pausa\"\n    },\n    \"selectAudioFormat\": \"Seleccionar Formato de Audio\",\n    \"selectDownloadType\": \"Seleccionar tipo de descarga\",\n    \"selectFormat\": \"Seleccionar Formato\",\n    \"startDownload\": \"Iniciar descarga\",\n    \"selectVideoFormat\": \"Seleccionar Formato de Video\",\n    \"singleVideo\": \"Video Individual\",\n    \"speed\": \"Velocidad\",\n    \"title\": \"Título\",\n    \"total\": \"Total\",\n    \"unknownQuality\": \"Calidad desconocida\",\n    \"unknownSize\": \"Tamaño desconocido\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Información del Video\",\n    \"videoInfoUpdated\": \"Información del video actualizada\",\n    \"metadata\": {\n      \"source\": \"Fuente\",\n      \"playlist\": \"Lista de reproducción\",\n      \"format\": \"Formato\",\n      \"quality\": \"Calidad\",\n      \"codec\": \"Códec\",\n      \"savedFile\": \"Archivo guardado\",\n      \"url\": \"URL de origen\",\n      \"description\": \"Descripción\",\n      \"views\": \"Visualizaciones\",\n      \"tags\": \"Etiquetas\",\n      \"downloadPath\": \"Ruta de descarga\",\n      \"createdAt\": \"Creado en\",\n      \"startedAt\": \"Iniciado en\",\n      \"completedAt\": \"Completado en\",\n      \"speed\": \"Velocidad\",\n      \"fileSize\": \"Tamaño del archivo\",\n      \"width\": \"Ancho\",\n      \"height\": \"Alto\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Códec de video\",\n      \"audioCodec\": \"Códec de audio\",\n      \"formatNote\": \"Nota de formato\",\n      \"protocol\": \"Protocolo\",\n      \"subscription\": \"Suscripción\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Algo salió mal\",\n    \"description\": \"Ocurrió un error inesperado. Intenta recargar la aplicación o informa de este problema si persiste.\",\n    \"message\": \"Mensaje de error\",\n    \"unknownError\": \"Ocurrió un error desconocido\",\n    \"goHome\": \"Ir a inicio\",\n    \"reload\": \"Recargar aplicación\",\n    \"copyReport\": \"Copiar informe de error\",\n    \"copied\": \"¡Copiado!\",\n    \"copySuccess\": \"Informe de error copiado al portapapeles\",\n    \"copyFailed\": \"No se pudo copiar el informe de error\",\n    \"showDetails\": \"Mostrar detalles\",\n    \"hideDetails\": \"Ocultar detalles\",\n    \"stackTrace\": \"Rastro de pila\",\n    \"componentStack\": \"Pila de componentes\",\n    \"noStackTrace\": \"No hay rastro de pila disponible\",\n    \"fullReport\": \"Informe de error completo\",\n    \"helpText\": \"Si este error persiste, copia el informe de error anterior y compártelo con el equipo de soporte. Puedes encontrar la información de contacto en la página Acerca de.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Haz clic para copiar los detalles\",\n    \"clipboardEmpty\": \"El portapapeles está vacío\",\n    \"downloadFailed\": \"Error en la descarga\",\n    \"downloadNecessaryFilesFailed\": \"Error al descargar archivos necesarios. Por favor, verifica tu red e intenta de nuevo\",\n    \"emptyUrl\": \"Por favor, ingresa una URL\",\n    \"errorDetails\": \"Detalles del Error\",\n    \"fetchInfoFailed\": \"Error al obtener información del video\",\n    \"invalidUrl\": \"El contenido del portapapeles no es una URL válida\",\n    \"networkError\": \"Ha ocurrido un error. Verifica tu red y usa una URL correcta\",\n    \"pasteFromClipboard\": \"Error al pegar desde el portapapeles\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Limpiar Cancelados\",\n    \"clearCompleted\": \"Limpiar Completados\",\n    \"clearErrors\": \"Limpiar Errores\",\n    \"clearAll\": \"Borrar todo el historial\",\n    \"clearAllAction\": \"Borrar historial\",\n    \"clearSelection\": \"Borrar selección\",\n    \"confirmClearAllTitle\": \"¿Borrar todo el historial?\",\n    \"confirmClearAllDescription\": \"Eliminar {{count}} elementos de tu historial. Los archivos permanecen en el disco.\",\n    \"confirmDeleteSelectedTitle\": \"¿Eliminar elementos seleccionados?\",\n    \"confirmDeleteSelectedDescription\": \"Eliminar {{count}} elementos de tu historial. Los archivos permanecen en el disco.\",\n    \"alsoDeleteFiles\": \"También eliminar archivos\",\n    \"confirmDeletePlaylistTitle\": \"¿Eliminar historial de la lista de reproducción?\",\n    \"confirmDeletePlaylistDescription\": \"Eliminar {{count}} elementos de {{title}} y borrar sus archivos.\",\n    \"copyToClipboard\": \"Copiar al portapapeles\",\n    \"copyUrl\": \"Copiar URL\",\n    \"date\": \"Fecha\",\n    \"deletePlaylist\": \"Eliminar lista de reproducción\",\n    \"deleteSelected\": \"Eliminar seleccionados\",\n    \"description\": \"Ver y gestionar tu historial de descargas\",\n    \"doneSelecting\": \"Listo\",\n    \"duration\": \"Duración\",\n    \"fileSize\": \"Tamaño del Archivo\",\n    \"filters\": {\n      \"all\": \"Todo\",\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Completado\",\n      \"errors\": \"Errores\"\n    },\n    \"noHistory\": \"Aún no hay historial de descargas\",\n    \"noHistoryDescription\": \"Tus descargas completadas aparecerán aquí\",\n    \"cookiesTipTitle\": \"Mejora el éxito de descarga con cookies\",\n    \"cookiesTipDescription\": \"Configura cookies para aumentar la tasa de éxito del <strong>70%</strong> al <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Configurar cookies\",\n    \"openDownloadFolder\": \"Abrir Carpeta de Descargas\",\n    \"openFile\": \"Abrir Archivo\",\n    \"openFileLocation\": \"Abrir Ubicación del Archivo\",\n    \"openFolder\": \"Abrir Carpeta\",\n    \"openInBrowser\": \"Haz clic para abrir en el navegador\",\n    \"removeAction\": \"Eliminar\",\n    \"removeItem\": \"Eliminar Elemento\",\n    \"deleteFile\": \"Eliminar Archivo\",\n    \"deleteRecord\": \"Eliminar de la Lista\",\n    \"select\": \"Seleccionar\",\n    \"selectAll\": \"Seleccionar todo\",\n    \"selectVisible\": \"Seleccionar visibles\",\n    \"selectItem\": \"Seleccionar elemento\",\n    \"selectedCount\": \"{{count}} seleccionados\",\n    \"selectionSummary\": \"{{selected}} de {{total}} visibles seleccionados\",\n    \"stats\": {\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Completado\",\n      \"errors\": \"Errores\",\n      \"total\": \"Total\"\n    },\n    \"status\": {\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Completado\",\n      \"error\": \"Error\"\n    },\n    \"title\": \"Historial de Descargas\"\n  },\n  \"menu\": {\n    \"about\": \"Acerca de\",\n    \"download\": \"Descargar\",\n    \"playlist\": \"Descargar Lista de Reproducción\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Suscripciones\",\n    \"preferences\": \"Preferencias\",\n    \"supportedSites\": \"Sitios Soportados\",\n    \"theme\": \"Tema:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Error al copiar al portapapeles\",\n    \"downloadCompleted\": \"Descarga completada\",\n    \"downloadAlreadyQueued\": \"Esta descarga ya está en curso\",\n    \"downloadFailed\": \"Error en la descarga\",\n    \"historyCleared\": \"Historial borrado\",\n    \"historyClearFailed\": \"No se pudo borrar el historial\",\n    \"itemRemoved\": \"Elemento eliminado\",\n    \"itemsRemoved\": \"Se eliminaron {{count}} elementos\",\n    \"itemsRemoveFailed\": \"No se pudieron eliminar los elementos seleccionados\",\n    \"openFileFailed\": \"Error al abrir el archivo\",\n    \"openFolderFailed\": \"Error al abrir la carpeta\",\n    \"playlistHistoryRemoved\": \"Lista de reproducción eliminada y archivos borrados\",\n    \"playlistHistoryRemoveFailed\": \"No se pudo eliminar el historial de la lista de reproducción\",\n    \"removeFailed\": \"Error al eliminar el elemento\",\n    \"settingsSaved\": \"Configuración guardada\",\n    \"urlCopied\": \"URL copiada al portapapeles\",\n    \"videoCopied\": \"Video copiado al portapapeles\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Lista de reproducción\",\n    \"clearPreview\": \"Limpiar vista previa\",\n    \"collapsedProgress\": \"Descargando lista de reproducción: {{completed}} / {{total}} completado\",\n    \"comingSoon\": \"¡La función de descarga de listas de reproducción llegará pronto!\",\n    \"completed\": \"Lista de reproducción descargada\",\n    \"description\": \"Descarga todos los videos de una lista de reproducción o canal de YouTube\",\n    \"downloadFailed\": \"Error al iniciar la descarga de la lista de reproducción\",\n    \"downloadPlaylist\": \"Descargar Lista de Reproducción\",\n    \"downloadType\": \"Tipo de Descarga\",\n    \"downloading\": \"Descargando lista de reproducción:\",\n    \"endIndex\": \"Fin\",\n    \"enterPlaylistUrl\": \"Ingresar URL de la Lista de Reproducción\",\n    \"fetchFailed\": \"Error al obtener información de la lista de reproducción\",\n    \"filenameFormat\": \"Formato de nombre de archivo para listas de reproducción\",\n    \"folderFormat\": \"Formato de nombre de carpeta para listas de reproducción\",\n    \"foundVideos\": \"Se encontraron {{count}} videos en la lista de reproducción\",\n    \"groupActive\": \"{{count}} activo\",\n    \"groupCollapse\": \"Contraer\",\n    \"groupErrors\": \"{{count}} fallido\",\n    \"groupExpand\": \"Expandir\",\n    \"groupSummary\": \"{{completed}} / {{total}} completado\",\n    \"linkLabel\": \"URL de la Lista de Reproducción\",\n    \"noEntries\": \"No se encontraron videos en esta lista de reproducción\",\n    \"noEntriesInRange\": \"No hay videos en el rango seleccionado\",\n    \"noRangeSelected\": \"No se estableció fin - lista de reproducción completa seleccionada\",\n    \"playlistUrlDescription\": \"Descarga todos los videos de una lista de reproducción en masa\",\n    \"positionLabel\": \"Elemento {{index}} de {{total}}\",\n    \"previewButton\": \"Vista previa de la lista de reproducción\",\n    \"previewFailed\": \"Error al obtener vista previa de la lista de reproducción\",\n    \"previewSummary\": \"Vista previa de los elementos de la lista de reproducción antes de descargar.\",\n    \"previewRequired\": \"Vista previa de la lista de reproducción antes de descargar.\",\n    \"range\": \"Rango (Opcional)\",\n    \"resetToDefault\": \"Restablecer a predeterminado\",\n    \"selectedRange\": \"Rango: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} seleccionados\",\n    \"downloadCurrentRange\": \"Descargar seleccionados\",\n    \"showingCount\": \"Mostrando {{count}} videos\",\n    \"selectEntry\": \"Seleccionar entrada {{index}}\",\n    \"noEntriesSelected\": \"No hay entradas seleccionadas\",\n    \"startIndex\": \"Inicio (1)\",\n    \"title\": \"Descargar Lista de Reproducción\",\n    \"totalVideos\": \"Total de videos: {{count}}\",\n    \"untitled\": \"Lista de reproducción sin título\",\n    \"fetchingInfo\": \"Obteniendo información de la lista de reproducción...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Acerca de\",\n    \"advanced\": \"Avanzado\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"Configuración de la Aplicación\",\n    \"audio\": \"Preferencias de Audio\",\n    \"browserForCookies\": \"Seleccionar navegador para usar cookies\",\n    \"browserForCookiesDescription\": \"Navegador del que extraer cookies para autenticación\",\n    \"browserForCookiesWindowsNote\": \"Windows solo admite cookies de Firefox. Para otros navegadores, configura manualmente un archivo de cookies.\",\n    \"browserForCookiesProfile\": \"Nombre de perfil o ruta\",\n    \"browserForCookiesProfileDescription\": \"Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.\",\n    \"browserForCookiesProfilePlaceholder\": \"Nombre de perfil o ruta completa (opcional)\",\n    \"browserForCookiesProfileInvalid\": \"La ruta del perfil no es válida. Elige la carpeta de perfil del navegador seleccionado.\",\n    \"browserForCookiesProfileInvalidPath\": \"Esa carpeta no existe. Elige una carpeta de perfil existente.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Nombre de perfil no encontrado en la ubicación predeterminada del navegador.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"No se conoce una ubicación de perfil predeterminada para este navegador en esta plataforma.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Introduce una ruta de perfil para el navegador seleccionado.\",\n    \"cookiesFile\": \"Archivo de cookies\",\n    \"cookiesFileDescription\": \"Archivo de cookies con formato Netscape para cargar para autenticación\",\n    \"clearCookiesFile\": \"Limpiar\",\n    \"cookiesHelpTitle\": \"Usar cookies\",\n    \"cookiesHelpBrowser\": \"Elige tu navegador arriba para reutilizar automáticamente su sesión iniciada.\",\n    \"cookiesHelpFile\": \"Exporta un archivo de cookies Netscape (consulta las preguntas frecuentes de yt-dlp) y selecciónalo aquí cuando sea necesario.\",\n    \"cookiesGuideTitle\": \"¿Necesitas una guía?\",\n    \"cookiesGuideDescription\": \"Consulta una guía paso a paso para usar cookies en VidBee.\",\n    \"cookiesGuideLink\": \"Abrir guía de cookies\",\n    \"openLinkError\": \"Error al abrir el enlace\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Usar archivo de configuración\",\n    \"configFileDescription\": \"Archivo de configuración personalizado para yt-dlp\",\n    \"clearConfigFile\": \"Limpiar\",\n    \"dark\": \"Oscuro\",\n    \"description\": \"Configura tus preferencias de descarga y configuración de la aplicación\",\n    \"directorySelectError\": \"Error al seleccionar directorio\",\n    \"downloadPath\": \"Ubicación de descarga\",\n    \"downloadPathDescription\": \"Elige dónde guardar los archivos descargados\",\n    \"fileSelectError\": \"Error al seleccionar archivo\",\n    \"general\": \"General\",\n    \"language\": \"Idioma\",\n    \"languageDescription\": \"Elige tu idioma preferido para la interfaz de la aplicación\",\n    \"light\": \"Claro\",\n    \"hideDockIcon\": \"Ocultar icono del Dock\",\n    \"hideDockIconDescription\": \"Eliminar VidBee del Dock de macOS. Usa la barra de menú o el icono de la bandeja para volver a abrir la aplicación.\",\n    \"launchAtLogin\": \"Iniciar al arrancar\",\n    \"launchAtLoginDescription\": \"Abrir VidBee automáticamente después de iniciar sesión en tu computadora.\",\n    \"launchAtLoginUnsupported\": \"El inicio automático solo está disponible en macOS y Windows.\",\n    \"enableAnalytics\": \"Ayuda a mejorar VidBee\",\n    \"enableAnalyticsDescription\": \"Comparte datos de uso anónimos para ayudarnos a entender cómo se usa la aplicación y priorizar mejoras.\",\n    \"embedChapters\": \"Incrustar capítulos\",\n    \"embedChaptersDescription\": \"Agregar marcadores de capítulos al archivo cuando estén disponibles\",\n    \"embedMetadata\": \"Incrustar metadatos\",\n    \"embedMetadataDescription\": \"Escribir título, artista y otros metadatos cuando estén disponibles\",\n    \"embedSubs\": \"Incrustar subtítulos\",\n    \"embedSubsDescription\": \"Incrustar subtítulos en el archivo de video (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Incrustar miniatura\",\n    \"embedThumbnailDescription\": \"Agregar la miniatura como portada\",\n    \"shareWatermark\": \"Marca de agua para compartir\",\n    \"shareWatermarkDescription\": \"Agrega una marca de agua con el título original, el autor y la marca VidBee\",\n    \"maxConcurrentDownloads\": \"Número máximo de descargas activas\",\n    \"maxConcurrentDownloadsDescription\": \"Número máximo de descargas simultáneas\",\n    \"none\": \"Ninguno\",\n    \"oneClickDownload\": \"Descarga con un Clic\",\n    \"oneClickDownloadDescription\": \"Habilitar descarga con un clic con configuración predeterminada\",\n    \"oneClickDownloadType\": \"Tipo de descarga predeterminado\",\n    \"oneClickDownloadTypeDescription\": \"Elige el tipo de descarga predeterminado para descargas con un clic. La calidad usa el ajuste preestablecido a continuación.\",\n    \"oneClickQuality\": \"Calidad preferida\",\n    \"oneClickQualityDescription\": \"Selecciona el ajuste preestablecido de calidad usado para descargas con un clic\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Automático\",\n      \"bad\": \"Malo\",\n      \"best\": \"Mejor\",\n      \"good\": \"Bueno\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Peor\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Servidor proxy para solicitudes de red\",\n    \"proxyPlaceholder\": \"http://proxy:puerto\",\n    \"selectConfigFile\": \"Seleccionar archivo de configuración\",\n    \"selectPath\": \"Seleccionar\",\n    \"showMoreFormats\": \"Mostrar más opciones de formato\",\n    \"showMoreFormatsDescription\": \"Mostrar opciones de formato adicionales en la interfaz\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.\"\n    },\n    \"system\": \"Sistema\",\n    \"theme\": \"Tema\",\n    \"themeDescription\": \"Elige un tema claro, oscuro o del sistema para VidBee\",\n    \"title\": \"Configuración\",\n    \"tray\": {\n      \"quit\": \"Salir\",\n      \"showHome\": \"Mostrar Inicio\"\n    },\n    \"video\": \"Preferencias de Video\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Suscripciones\",\n    \"subtitle\": \"{{count}} suscripción{{count, plural, one {} other {s}}}\",\n    \"description\": \"Monitorea automáticamente los feeds RSS y encola nuevas descargas sin trabajo manual.\",\n    \"defaults\": {\n      \"title\": \"Valores predeterminados de automatización\",\n      \"description\": \"Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.\",\n      \"downloadDirectory\": \"Directorio de descarga\",\n      \"filenameTemplate\": \"Plantilla de nombre de archivo (solo archivo)\",\n      \"onlyLatest\": \"Descargar solo el último video\",\n      \"onlyLatestDescription\": \"Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga.\"\n    },\n    \"add\": {\n      \"title\": \"Agregar RSS\",\n      \"description\": \"Pega un enlace de feed RSS. VidBee detectará el feed automáticamente.\"\n    },\n    \"fields\": {\n      \"url\": \"URL del Feed\",\n      \"keywords\": \"Filtro de palabras clave (separadas por comas)\",\n      \"tags\": \"Etiquetas automáticas\",\n      \"customDirectory\": \"Directorio personalizado\",\n      \"namingTemplate\": \"Plantilla de nombre de archivo personalizada (solo archivo)\",\n      \"onlyLatest\": \"Descargar solo el último video\",\n      \"onlyLatestDescription\": \"Ignorar elementos del backlog y obtener solo la última carga de este feed.\",\n      \"enabled\": \"Habilitado\",\n      \"disabled\": \"Deshabilitado\",\n      \"onlyLatestShort\": \"Solo último\"\n    },\n    \"actions\": {\n      \"add\": \"Agregar\",\n      \"refresh\": \"Actualizar\",\n      \"edit\": \"Editar\",\n      \"remove\": \"Eliminar\",\n      \"save\": \"Guardar cambios\",\n      \"selectDirectory\": \"Explorar\",\n      \"enable\": \"Habilitar\",\n      \"disable\": \"Deshabilitar\"\n    },\n    \"items\": {\n      \"title\": \"Últimas cargas ({{count}})\",\n      \"count\": \"{{count}} elementos\",\n      \"empty\": \"No se encontraron elementos recientes del feed.\",\n      \"status\": {\n        \"queued\": \"En cola\",\n        \"notQueued\": \"No en cola\",\n        \"pending\": \"Pendiente\",\n        \"downloading\": \"Descargando\",\n        \"processing\": \"Procesando\",\n        \"completed\": \"Completado\",\n        \"error\": \"Fallido\",\n        \"cancelled\": \"Cancelado\"\n      },\n      \"fromChannel\": \"De {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Estado de descarga: {{status}}\",\n        \"downloadPending\": \"Esperando detalles de descarga...\",\n        \"notQueued\": \"Aún no está en la cola de descarga\"\n      },\n      \"actions\": {\n        \"open\": \"Abrir en el navegador\",\n        \"queue\": \"Agregar a la cola de descarga\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Suscripción\",\n      \"unknown\": \"Suscripción desconocida\",\n      \"noThumbnail\": \"Sin miniatura\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Error al abrir el selector de directorio.\",\n      \"missingUrl\": \"Por favor, pega primero un enlace de canal.\",\n      \"created\": \"Suscripción agregada\",\n      \"createError\": \"Error al agregar suscripción.\",\n      \"refreshStarted\": \"Actualización iniciada\",\n      \"removed\": \"Suscripción eliminada\",\n      \"updated\": \"Suscripción actualizada\",\n      \"itemQueued\": \"Agregado a la cola de descarga\",\n      \"itemAlreadyQueued\": \"Este video ya está en cola\",\n      \"queueError\": \"Error al agregar a la cola de descarga.\",\n      \"openLinkError\": \"Error al abrir el enlace del video.\",\n      \"resolveError\": \"Error al resolver la URL del feed RSS.\",\n      \"duplicateUrl\": \"Este feed RSS ya está suscrito.\"\n    },\n    \"detectedFeed\": \"Feed {{platform}} detectado -> {{feed}}\",\n    \"detecting\": \"Detectando feed...\",\n    \"latestVideo\": \"Último video: {{title}}\",\n    \"lastChecked\": \"Última verificación: {{time}}\",\n    \"never\": \"Nunca\",\n    \"empty\": \"Aún no hay suscripciones. Agrega tus canales favoritos para comenzar a descargar automáticamente.\",\n    \"edit\": {\n      \"title\": \"Editar {{name}}\",\n      \"description\": \"Ajusta filtros, etiquetas y sobrescrituras para este feed.\"\n    },\n    \"status\": {\n      \"title\": \"Estado\",\n      \"up-to-date\": \"Actualizado\",\n      \"checking\": \"Verificando\",\n      \"failed\": \"Fallido\",\n      \"idle\": \"Inactivo\",\n      \"tooltip\": {\n        \"updatedAt\": \"Actualizado: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Suscripciones Automatizadas con RSSHub\",\n      \"description\": \"Combina VidBee con RSSHub para habilitar suscripciones y descargas automatizadas de varias plataformas. Una vez configurado, VidBee se ejecuta en segundo plano y descarga automáticamente los últimos videos y contenido.\",\n      \"learnMore\": \"Aprende más sobre RSSHub\",\n      \"openDocs\": \"Abrir Documentación de RSSHub\",\n      \"hint\": \"¿No tienes una URL de feed RSS? Usa RSSHub para generar feeds RSS para YouTube, Twitter y miles de otras plataformas.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Soporta {{sites}} y más.\",\n    \"moreDescription\": \"La lista completa de yt-dlp se actualiza constantemente por la comunidad.\",\n    \"moreTitle\": \"¿Necesitas otro sitio?\",\n    \"openFullList\": \"Abrir lista completa de sitios soportados\",\n    \"pageDescription\": \"VidBee usa yt-dlp bajo el capó para llegar a cientos de fuentes.\",\n    \"pageIntro\": \"Aquí están los servicios principales de los que la gente descarga con más frecuencia.\",\n    \"pageTitle\": \"Sitios Soportados\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Álbumes de artistas independientes y lanzamientos de la comunidad.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Noticias globales, deportes y clips de entretenimiento.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Videos de Feed, Watch y Reels de páginas públicas.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Contenido de Feed, Stories, Reels y Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Transmisiones en vivo y repeticiones de creadores en la plataforma Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Charlas profesionales, seminarios web y videos de aprendizaje.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"Mezclas de DJ, programas de radio y audio de larga duración.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Archivo de animación japonesa, música y transmisión en vivo.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Pines de ideas, reels de cómo hacer y videos de inspiración de estilo de vida.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Clips incrustados y videos alojados de comunidades.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Pistas de música, listas de reproducción y sets de DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Videos móviles de formato corto, efectos y transmisiones en vivo.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Medios de formato corto creativos y ediciones de fanáticos.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Transmisiones en vivo de juegos, música e IRL y VODs.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Publicaciones de línea de tiempo, grabaciones de Spaces y transmisiones.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Alojamiento de video de alta calidad para creadores y empresas.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Video de formato largo y transmisión en vivo de creadores de todo el mundo.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Videos musicales oficiales, álbumes y presentaciones en vivo.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Plataformas principales\",\n    \"viewAll\": \"Ver todos los sitios soportados\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/fr.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Vérifier les mises à jour\",\n      \"download\": \"Télécharger\",\n      \"email\": \"Email\",\n      \"feedback\": \"Commentaires\",\n      \"goToDownload\": \"Aller à la page de téléchargement\",\n      \"openRepo\": \"Ouvrir le dépôt GitHub\",\n      \"view\": \"Voir\",\n      \"visit\": \"Visiter\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Télécharger et installer automatiquement les nouvelles versions en arrière-plan.\",\n    \"autoUpdateTitle\": \"Mises à jour automatiques\",\n    \"betaProgramDescription\": \"Recevez les versions préliminaires et les prochaines fonctionnalités avant tout le monde.\",\n    \"betaProgramTitle\": \"Canal de prévisualisation\",\n    \"description\": \"VidBee est un téléchargeur gratuit et open-source construit avec Electron et alimenté par yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Suivre @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Restez à jour avec les dernières nouvelles et mises à jour de VidBee.\",\n    \"followAuthorSupport\": \"Suivez le développeur sur X (Twitter) pour obtenir les dernières mises à jour et nouvelles sur VidBee.\",\n    \"followAuthorTitle\": \"Suivre le Développeur\",\n    \"here\": \"ici\",\n    \"homepage\": \"Page d'accueil\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Recherche de mises à jour...\",\n      \"downloadError\": \"Échec du téléchargement de la mise à jour\",\n      \"downloadUpdate\": \"Télécharger et installer la mise à jour {{version}} ?\",\n      \"manualDownloadAction\": \"Téléchargez maintenant\",\n      \"noUpdatesAvailable\": \"Vous utilisez la dernière version\",\n      \"restartToUpdate\": \"Redémarrer maintenant pour installer la mise à jour ?\",\n      \"restartNowAction\": \"Redémarrer maintenant\",\n      \"updateAvailable\": \"Mise à jour disponible : {{version}}\",\n      \"updateAvailableMessage\": \"Une nouvelle version {{version}} est disponible. \\nVeuillez le télécharger sur le site officiel.\",\n      \"updateDownloaded\": \"Mise à jour téléchargée, redémarrez pour installer\",\n      \"updateDownloadedVersion\": \"Mise à jour {{version}} téléchargée, redémarrez pour installer\",\n      \"updateError\": \"Échec de la vérification des mises à jour : {{error}}\",\n      \"unknownErrorFallback\": \"Erreur inconnue\"\n    },\n    \"preferencesDescription\": \"Ajustez les paramètres de mise à jour sans quitter cette page.\",\n    \"preferencesTitle\": \"Basculements Rapides\",\n    \"resources\": {\n      \"changelog\": \"Notes de version\",\n      \"changelogDescription\": \"Suivez ce qui a changé dans chaque version.\",\n      \"contact\": \"Support par email\",\n      \"contactDescription\": \"Contactez directement pour de l'aide ou collaboration.\",\n      \"documentation\": \"Centre d'aide\",\n      \"documentationDescription\": \"Guides, FAQ et flux de travail courants.\",\n      \"feedback\": \"Commentaires et problèmes\",\n      \"feedbackDescription\": \"Partagez des idées ou signalez des problèmes sur GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Signaler des bugs ou demander des fonctionnalités sur GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Partagez des retours ou des suggestions sur X en mentionnant @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Rejoignez notre communauté Discord pour les discussions et l'assistance.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Questions fréquemment posées et guides de dépannage.\",\n      \"license\": \"Licence\",\n      \"licenseDescription\": \"Consultez les termes de la licence open-source.\",\n      \"website\": \"Site web officiel\",\n      \"websiteDescription\": \"Points forts du produit, feuille de route et actualités de la communauté.\"\n    },\n    \"resourcesDescription\": \"Liens utiles pour en savoir plus sur VidBee et rester connecté.\",\n    \"resourcesTitle\": \"Ressources\",\n    \"shareActions\": {\n      \"copy\": \"Copier le lien\",\n      \"facebook\": \"Partager sur Facebook\",\n      \"twitter\": \"Partager sur X (Twitter)\"\n    },\n    \"shareDescription\": \"Partagez VidBee avec votre communauté en un clic.\",\n    \"shareSupport\": \"Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.\",\n    \"shareTitle\": \"Faites passer le mot\",\n    \"sourceCode\": \"Le code source est disponible\",\n    \"title\": \"À propos\",\n    \"version\": \"Version\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Dernière : v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Nouvelle version disponible\",\n      \"uptodate\": \"Vous êtes à jour\",\n      \"error\": \"Impossible de récupérer la dernière version\"\n    },\n    \"downloadingUpdate\": \"Téléchargement de la mise à jour\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Fermer l'application quand le téléchargement se termine\",\n    \"currentLocation\": \"Emplacement de téléchargement actuel - \",\n    \"downloadLocation\": \"Emplacement de téléchargement\",\n    \"downloadSubs\": \"Télécharger les sous-titres si disponibles\",\n    \"downloadSubsHint\": \"Enregistrer les sous-titres en fichiers séparés lorsqu'ils sont disponibles\",\n    \"end\": \"Fin\",\n    \"endHint\": \"Si laissé vide, sera téléchargé jusqu'à la fin\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Sélectionner l'Emplacement de Téléchargement\",\n    \"start\": \"Début\",\n    \"startHint\": \"Si laissé vide, commencera du début\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Sous-titres\",\n    \"timeRange\": \"Télécharger une plage de temps spécifique\",\n    \"title\": \"Options Avancées\"\n  },\n  \"app\": {\n    \"description\": \"Télécharger des vidéos et audios depuis des centaines de sites\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Actif\",\n    \"all\": \"Tout\",\n    \"audio\": \"Audio\",\n    \"back\": \"Retour\",\n    \"cancel\": \"Annuler\",\n    \"cancelled\": \"Annulé\",\n    \"clearCompleted\": \"Effacer les Terminés\",\n    \"clearDownloads\": \"Effacer les Téléchargements\",\n    \"completed\": \"Terminé\",\n    \"downloadAudio\": \"Télécharger l'Audio\",\n    \"downloadBtn\": \"Télécharger\",\n    \"downloadPending\": \"En attente\",\n    \"downloadQueue\": \"File de Téléchargement\",\n    \"customDownloadFolder\": \"Dossier de téléchargement personnalisé\",\n    \"retry\": \"Relancer le téléchargement\",\n    \"autoFolderPlaceholder\": \"Dossier automatique (basé sur les métadonnées)\",\n    \"autoFolderHint\": \"Les dossiers automatiques sont créés à partir des métadonnées.\",\n    \"useAutoFolder\": \"Utiliser le dossier automatique\",\n    \"downloadVideo\": \"Télécharger la Vidéo\",\n    \"downloading\": \"Téléchargement en cours...\",\n    \"enterUrl\": \"Entrer l'URL de la Vidéo\",\n    \"enterUrlDescription\": \"Collez ou tapez une URL de vidéo. \",\n    \"error\": \"Erreur\",\n    \"fetch\": \"Récupérer\",\n    \"fetchingVideoInfo\": \"Récupération des informations vidéo...\",\n    \"feedback\": {\n      \"title\": \"Signalez cette erreur :\",\n      \"githubUrlTooLong\": \"Ce lien GitHub est très long. S'il ne s'ouvre pas, ouvrez la page de l'issue et collez les logs manuellement.\"\n    },\n    \"history\": \"Historique\",\n    \"imageLoadError\": \"Échec du chargement de l'image\",\n    \"imagePlaceholder\": \"Aucune image disponible\",\n    \"infoUnavailable\": \"Téléchargement en Un Clic (Info indisponible)\",\n    \"loading\": \"Chargement\",\n    \"moreOptions\": \"Plus d'options\",\n    \"noActiveDownloads\": \"Aucun téléchargement actif\",\n    \"noAudio\": \"Pas d'Audio\",\n    \"noHistory\": \"Aucun historique de téléchargement\",\n    \"noItems\": \"Aucun élément trouvé\",\n    \"goToSettings\": \"Allez dans Paramètres\",\n    \"oneClickDownload\": \"Téléchargement en Un Clic\",\n    \"oneClickDownloadDescription\": \"Télécharger directement avec les paramètres par défaut sans confirmation\",\n    \"oneClickDownloadTooltip\": \"Collez et téléchargez instantanément, sans étapes\",\n    \"oneClickDownloadNow\": \"Télécharger Maintenant\",\n    \"oneClickDownloadStarted\": \"Téléchargement démarré avec les paramètres par défaut\",\n    \"paste\": \"Coller\",\n    \"pastePlaylistUrl\": \"Cliquez pour coller le lien de la playlist depuis le presse-papiers [Ctrl + V]\",\n    \"pasteUrl\": \"Cliquez pour coller l'URL de la vidéo ou l'ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Coller l'URL\",\n    \"preparing\": \"Préparation...\",\n    \"processing\": \"Traitement\",\n    \"progress\": \"Progrès\",\n    \"showDetails\": \"Afficher les détails\",\n    \"hideDetails\": \"Masquer les détails\",\n    \"viewLogs\": \"Voir les journaux\",\n    \"detailsTab\": \"Détails\",\n    \"logsTab\": \"Journaux\",\n    \"logs\": {\n      \"live\": \"Journaux en direct\",\n      \"history\": \"Journaux enregistrés\",\n      \"command\": \"Commande yt-dlp\",\n      \"empty\": \"Aucun journal pour le moment.\",\n      \"scrollPaused\": \"Défilement en pause\"\n    },\n    \"selectAudioFormat\": \"Sélectionner le Format Audio\",\n    \"selectDownloadType\": \"Sélectionner le type de téléchargement\",\n    \"selectFormat\": \"Sélectionner le Format\",\n    \"startDownload\": \"Démarrer le téléchargement\",\n    \"selectVideoFormat\": \"Sélectionner le Format Vidéo\",\n    \"singleVideo\": \"Vidéo Unique\",\n    \"speed\": \"Vitesse\",\n    \"title\": \"Titre\",\n    \"total\": \"Total\",\n    \"unknownQuality\": \"Qualité inconnue\",\n    \"unknownSize\": \"Taille inconnue\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Vidéo\",\n    \"videoInfo\": \"Informations Vidéo\",\n    \"videoInfoUpdated\": \"Informations vidéo mises à jour\",\n    \"metadata\": {\n      \"source\": \"Source\",\n      \"playlist\": \"Liste de lecture\",\n      \"format\": \"Format\",\n      \"quality\": \"Qualité\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"Fichier enregistré\",\n      \"url\": \"URL source\",\n      \"description\": \"Description\",\n      \"views\": \"Vues\",\n      \"tags\": \"Balises\",\n      \"downloadPath\": \"Chemin de téléchargement\",\n      \"createdAt\": \"Créé à\",\n      \"startedAt\": \"Commencé à\",\n      \"completedAt\": \"Terminé à\",\n      \"speed\": \"Vitesse\",\n      \"fileSize\": \"Taille du fichier\",\n      \"width\": \"Largeur\",\n      \"height\": \"Hauteur\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Codec vidéo\",\n      \"audioCodec\": \"Codec audio\",\n      \"formatNote\": \"Remarque sur le format\",\n      \"protocol\": \"Protocole\",\n      \"subscription\": \"Abonnement\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Un problème est survenu\",\n    \"description\": \"Une erreur inattendue s'est produite. Veuillez recharger l'application ou signaler ce problème s'il persiste.\",\n    \"message\": \"Message d'erreur\",\n    \"unknownError\": \"Une erreur inconnue s'est produite\",\n    \"goHome\": \"Aller à l'accueil\",\n    \"reload\": \"Recharger l'application\",\n    \"copyReport\": \"Copier le rapport d'erreur\",\n    \"copied\": \"Copié !\",\n    \"copySuccess\": \"Rapport d'erreur copié dans le presse-papiers\",\n    \"copyFailed\": \"Échec de la copie du rapport d'erreur\",\n    \"showDetails\": \"Afficher les détails\",\n    \"hideDetails\": \"Masquer les détails\",\n    \"stackTrace\": \"Trace de pile\",\n    \"componentStack\": \"Pile de composants\",\n    \"noStackTrace\": \"Aucune trace de pile disponible\",\n    \"fullReport\": \"Rapport d'erreur complet\",\n    \"helpText\": \"Si cette erreur persiste, veuillez copier le rapport d'erreur ci-dessus et le partager avec l'équipe de support. Vous trouverez les coordonnées sur la page À propos.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Cliquez pour copier les détails\",\n    \"clipboardEmpty\": \"Le presse-papiers est vide\",\n    \"downloadFailed\": \"Échec du téléchargement\",\n    \"downloadNecessaryFilesFailed\": \"Échec du téléchargement des fichiers nécessaires. Veuillez vérifier votre réseau et réessayer\",\n    \"emptyUrl\": \"Veuillez entrer une URL\",\n    \"errorDetails\": \"Détails de l'Erreur\",\n    \"fetchInfoFailed\": \"Échec de la récupération des informations vidéo\",\n    \"invalidUrl\": \"Le contenu du presse-papiers n'est pas une URL valide\",\n    \"networkError\": \"Une erreur s'est produite. Vérifiez votre réseau et utilisez une URL correcte\",\n    \"pasteFromClipboard\": \"Échec du collage depuis le presse-papiers\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Effacer les Annulés\",\n    \"clearCompleted\": \"Effacer les Terminés\",\n    \"clearErrors\": \"Effacer les Erreurs\",\n    \"clearAll\": \"Effacer tout l'historique\",\n    \"clearAllAction\": \"Effacer l'historique\",\n    \"clearSelection\": \"Effacer la sélection\",\n    \"confirmClearAllTitle\": \"Effacer tout l'historique ?\",\n    \"confirmClearAllDescription\": \"Supprimer {{count}} éléments de votre historique. Les fichiers restent sur le disque.\",\n    \"confirmDeleteSelectedTitle\": \"Supprimer les éléments sélectionnés ?\",\n    \"confirmDeleteSelectedDescription\": \"Supprimer {{count}} éléments de votre historique. Les fichiers restent sur le disque.\",\n    \"alsoDeleteFiles\": \"Supprimer aussi les fichiers\",\n    \"confirmDeletePlaylistTitle\": \"Supprimer l'historique de la playlist ?\",\n    \"confirmDeletePlaylistDescription\": \"Supprimer {{count}} éléments de {{title}} et supprimer leurs fichiers.\",\n    \"copyToClipboard\": \"Copier dans le presse-papiers\",\n    \"copyUrl\": \"Copier l'URL\",\n    \"date\": \"Date\",\n    \"deletePlaylist\": \"Supprimer la playlist\",\n    \"deleteSelected\": \"Supprimer la sélection\",\n    \"description\": \"Voir et gérer votre historique de téléchargements\",\n    \"doneSelecting\": \"Terminé\",\n    \"duration\": \"Durée\",\n    \"fileSize\": \"Taille du Fichier\",\n    \"filters\": {\n      \"all\": \"Tout\",\n      \"cancelled\": \"Annulé\",\n      \"completed\": \"Terminé\",\n      \"errors\": \"Erreurs\"\n    },\n    \"noHistory\": \"Aucun historique de téléchargement encore\",\n    \"noHistoryDescription\": \"Vos téléchargements terminés apparaîtront ici\",\n    \"cookiesTipTitle\": \"Augmentez le taux de réussite avec les cookies\",\n    \"cookiesTipDescription\": \"Configurez les cookies pour faire passer le taux de réussite de <strong>70 %</strong> à <strong>99 %</strong>.\",\n    \"cookiesTipCta\": \"Configurer les cookies\",\n    \"openDownloadFolder\": \"Ouvrir le Dossier de Téléchargement\",\n    \"openFile\": \"Ouvrir le Fichier\",\n    \"openFileLocation\": \"Ouvrir l'Emplacement du Fichier\",\n    \"openFolder\": \"Ouvrir le Dossier\",\n    \"openInBrowser\": \"Cliquez pour ouvrir dans le navigateur\",\n    \"removeAction\": \"Supprimer\",\n    \"removeItem\": \"Supprimer l'Élément\",\n    \"deleteFile\": \"Supprimer le Fichier\",\n    \"deleteRecord\": \"Supprimer de la Liste\",\n    \"select\": \"Sélectionner\",\n    \"selectAll\": \"Tout sélectionner\",\n    \"selectVisible\": \"Sélectionner les visibles\",\n    \"selectItem\": \"Sélectionner l'élément\",\n    \"selectedCount\": \"{{count}} sélectionnés\",\n    \"selectionSummary\": \"{{selected}} sur {{total}} visibles sélectionnés\",\n    \"stats\": {\n      \"cancelled\": \"Annulé\",\n      \"completed\": \"Terminé\",\n      \"errors\": \"Erreurs\",\n      \"total\": \"Total\"\n    },\n    \"status\": {\n      \"cancelled\": \"Annulé\",\n      \"completed\": \"Terminé\",\n      \"error\": \"Erreur\"\n    },\n    \"title\": \"Historique de Téléchargement\"\n  },\n  \"menu\": {\n    \"about\": \"À propos\",\n    \"download\": \"Télécharger\",\n    \"playlist\": \"Télécharger la Playlist\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Abonnements\",\n    \"preferences\": \"Préférences\",\n    \"supportedSites\": \"Sites Supportés\",\n    \"theme\": \"Thème :\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Échec de la copie dans le presse-papiers\",\n    \"downloadCompleted\": \"Téléchargement terminé\",\n    \"downloadAlreadyQueued\": \"Ce téléchargement est déjà en cours\",\n    \"downloadFailed\": \"Échec du téléchargement\",\n    \"historyCleared\": \"Historique effacé\",\n    \"historyClearFailed\": \"Échec de l'effacement de l'historique\",\n    \"itemRemoved\": \"Élément supprimé\",\n    \"itemsRemoved\": \"{{count}} éléments supprimés\",\n    \"itemsRemoveFailed\": \"Échec de la suppression des éléments sélectionnés\",\n    \"openFileFailed\": \"Échec de l'ouverture du fichier\",\n    \"openFolderFailed\": \"Échec de l'ouverture du dossier\",\n    \"playlistHistoryRemoved\": \"Playlist supprimée et fichiers supprimés\",\n    \"playlistHistoryRemoveFailed\": \"Échec de la suppression de l'historique de la playlist\",\n    \"removeFailed\": \"Échec de la suppression de l'élément\",\n    \"settingsSaved\": \"Paramètres sauvegardés\",\n    \"urlCopied\": \"URL copiée dans le presse-papiers\",\n    \"videoCopied\": \"Vidéo copiée dans le presse-papiers\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Liste de lecture\",\n    \"clearPreview\": \"Effacer l'aperçu\",\n    \"collapsedProgress\": \"Téléchargement de la playlist : {{completed}} / {{total}} terminés\",\n    \"comingSoon\": \"La fonctionnalité de téléchargement de playlist arrive bientôt !\",\n    \"completed\": \"Playlist téléchargée\",\n    \"description\": \"Télécharger toutes les vidéos d'une playlist ou chaîne YouTube\",\n    \"downloadFailed\": \"Échec du démarrage du téléchargement de playlist\",\n    \"downloadPlaylist\": \"Télécharger la Playlist\",\n    \"downloadType\": \"Type de Téléchargement\",\n    \"downloading\": \"Téléchargement de la playlist :\",\n    \"endIndex\": \"Fin\",\n    \"enterPlaylistUrl\": \"Entrer l'URL de la Playlist\",\n    \"fetchFailed\": \"Échec de la récupération des informations de playlist\",\n    \"filenameFormat\": \"Format de nom de fichier pour les playlists\",\n    \"folderFormat\": \"Format de nom de dossier pour les playlists\",\n    \"foundVideos\": \"Trouvé {{count}} vidéos dans la playlist\",\n    \"groupActive\": \"{{count}} actifs\",\n    \"groupCollapse\": \"Réduire\",\n    \"groupErrors\": \"{{count}} a échoué\",\n    \"groupExpand\": \"Développer\",\n    \"groupSummary\": \"{{completed}} / {{total}} terminé\",\n    \"linkLabel\": \"URL de la Playlist\",\n    \"noEntries\": \"Aucune vidéo n'a été trouvée dans cette playlist\",\n    \"noEntriesInRange\": \"Aucune vidéo dans la plage sélectionnée\",\n    \"noRangeSelected\": \"Pas de fin - playlist complète sélectionnée\",\n    \"playlistUrlDescription\": \"Télécharger toutes les vidéos d'une playlist en lot\",\n    \"positionLabel\": \"Article {{index}} sur {{total}}\",\n    \"previewButton\": \"Aperçu de la liste de lecture\",\n    \"previewFailed\": \"Échec de la prévisualisation de la playlist\",\n    \"previewSummary\": \"Prévisualisez les éléments de la liste de lecture avant de les télécharger.\",\n    \"previewRequired\": \"Prévisualisez la playlist avant de la télécharger.\",\n    \"range\": \"Plage (Optionnel)\",\n    \"resetToDefault\": \"Réinitialiser par défaut\",\n    \"selectedRange\": \"Plage : {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} sélectionnés\",\n    \"downloadCurrentRange\": \"Télécharger la sélection\",\n    \"showingCount\": \"Affichage de {{count}} vidéos\",\n    \"selectEntry\": \"Sélectionner l'entrée {{index}}\",\n    \"noEntriesSelected\": \"Aucune entrée sélectionnée\",\n    \"startIndex\": \"Début (1)\",\n    \"title\": \"Télécharger la Playlist\",\n    \"totalVideos\": \"Nombre total de vidéos : {{count}}\",\n    \"untitled\": \"Liste de lecture sans titre\",\n    \"fetchingInfo\": \"Récupération des informations de la playlist...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"À propos\",\n    \"advanced\": \"Avancé\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"Paramètres de l'App\",\n    \"audio\": \"Préférences Audio\",\n    \"browserForCookies\": \"Sélectionner le navigateur pour utiliser les cookies\",\n    \"browserForCookiesDescription\": \"Navigateur pour extraire les cookies pour l'authentification\",\n    \"browserForCookiesWindowsNote\": \"Sous Windows, seuls les cookies de Firefox sont pris en charge. Pour les autres navigateurs, configurez un fichier de cookies manuellement.\",\n    \"browserForCookiesProfile\": \"Nom du profil ou chemin\",\n    \"browserForCookiesProfileDescription\": \"Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.\",\n    \"browserForCookiesProfilePlaceholder\": \"Nom du profil ou chemin complet (facultatif)\",\n    \"browserForCookiesProfileInvalid\": \"Le chemin du profil n'est pas valide. Choisissez le dossier de profil du navigateur sélectionné.\",\n    \"browserForCookiesProfileInvalidPath\": \"Ce dossier n'existe pas. Choisissez un dossier de profil existant.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Nom de profil introuvable à l'emplacement par défaut du navigateur.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Aucun emplacement de profil par défaut n'est connu pour ce navigateur sur cette plateforme.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Saisissez un chemin de profil pour le navigateur sélectionné.\",\n    \"cookiesFile\": \"Fichier de cookies\",\n    \"cookiesFileDescription\": \"Fichier de cookies au format Netscape à charger pour l'authentification\",\n    \"clearCookiesFile\": \"Clair\",\n    \"cookiesHelpTitle\": \"Utiliser des cookies\",\n    \"cookiesHelpBrowser\": \"Choisissez votre navigateur ci-dessus pour réutiliser automatiquement sa session de connexion.\",\n    \"cookiesHelpFile\": \"Exportez un fichier de cookies Netscape (voir la FAQ yt-dlp) et sélectionnez-le ici si nécessaire.\",\n    \"cookiesGuideTitle\": \"Besoin d’un guide ?\",\n    \"cookiesGuideDescription\": \"Consultez un guide étape par étape pour utiliser les cookies dans VidBee.\",\n    \"cookiesGuideLink\": \"Ouvrir le guide des cookies\",\n    \"openLinkError\": \"Échec de l'ouverture du lien\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Utiliser le fichier de configuration\",\n    \"configFileDescription\": \"Fichier de configuration personnalisé pour yt-dlp\",\n    \"clearConfigFile\": \"Clair\",\n    \"dark\": \"Sombre\",\n    \"description\": \"Configurez vos préférences de téléchargement et paramètres de l'application\",\n    \"directorySelectError\": \"Échec de la sélection du répertoire\",\n    \"downloadPath\": \"Emplacement de téléchargement\",\n    \"downloadPathDescription\": \"Choisissez où sauvegarder les fichiers téléchargés\",\n    \"fileSelectError\": \"Échec de la sélection du fichier\",\n    \"general\": \"Général\",\n    \"language\": \"Langue\",\n    \"languageDescription\": \"Choisissez votre langue préférée pour l'interface de l'application\",\n    \"light\": \"Clair\",\n    \"hideDockIcon\": \"Masquer l'icône du Dock\",\n    \"hideDockIconDescription\": \"Supprimez VidBee du Dock macOS. \\nUtilisez la barre de menu ou l'icône de la barre d'état pour rouvrir l'application.\",\n    \"launchAtLogin\": \"Lancer au démarrage\",\n    \"launchAtLoginDescription\": \"Ouvrez VidBee automatiquement après vous être connecté à votre ordinateur.\",\n    \"launchAtLoginUnsupported\": \"Le lancement automatique n'est disponible que sur macOS et Windows.\",\n    \"enableAnalytics\": \"Aidez-nous à améliorer VidBee\",\n    \"enableAnalyticsDescription\": \"Partagez des données d'utilisation anonymes pour nous aider à comprendre comment l'application est utilisée et prioriser les améliorations.\",\n    \"embedChapters\": \"Intégrer les chapitres\",\n    \"embedChaptersDescription\": \"Ajouter des marqueurs de chapitre au fichier lorsqu'ils sont disponibles\",\n    \"embedMetadata\": \"Intégrer les métadonnées\",\n    \"embedMetadataDescription\": \"Écrire le titre, l'artiste et d'autres métadonnées lorsqu'ils sont disponibles\",\n    \"embedSubs\": \"Intégrer les sous-titres\",\n    \"embedSubsDescription\": \"Intégrer les sous-titres dans le fichier vidéo (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Intégrer la miniature\",\n    \"embedThumbnailDescription\": \"Ajouter la miniature comme illustration de couverture\",\n    \"shareWatermark\": \"Filigrane de partage\",\n    \"shareWatermarkDescription\": \"Ajoute un filigrane avec le titre original, l'auteur et la marque VidBee\",\n    \"maxConcurrentDownloads\": \"Nombre maximum de téléchargements actifs\",\n    \"maxConcurrentDownloadsDescription\": \"Nombre maximum de téléchargements simultanés\",\n    \"none\": \"Aucun\",\n    \"oneClickDownload\": \"Téléchargement en Un Clic\",\n    \"oneClickDownloadDescription\": \"Activer le téléchargement en un clic avec les paramètres par défaut\",\n    \"oneClickDownloadType\": \"Type de téléchargement par défaut\",\n    \"oneClickDownloadTypeDescription\": \"Choisissez le type de téléchargement par défaut pour les téléchargements en un clic. La qualité utilise le préréglage ci-dessous.\",\n    \"oneClickQuality\": \"Qualité préférée\",\n    \"oneClickQualityDescription\": \"Sélectionnez le préréglage de qualité utilisé pour les téléchargements en un clic\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Auto\",\n      \"bad\": \"Mauvais\",\n      \"best\": \"Meilleur\",\n      \"good\": \"Bon\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Pire\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Serveur proxy pour les requêtes réseau\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Sélectionner le fichier de configuration\",\n    \"selectPath\": \"Sélectionner\",\n    \"showMoreFormats\": \"Afficher plus d'options de format\",\n    \"showMoreFormatsDescription\": \"Afficher des options de format supplémentaires dans l'interface\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Modèle utilisé lorsqu’un abonnement ne remplace pas son nom de fichier.\"\n    },\n    \"system\": \"Système\",\n    \"theme\": \"Thème\",\n    \"themeDescription\": \"Choisissez un thème clair, sombre ou système pour VidBee\",\n    \"title\": \"Paramètres\",\n    \"tray\": {\n      \"quit\": \"Quitter\",\n      \"showHome\": \"Afficher l'Accueil\"\n    },\n    \"video\": \"Préférences Vidéo\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Abonnements\",\n    \"subtitle\": \"{{count}} abonnement{{count, plural, one {} other {s}}}\",\n    \"description\": \"Surveillez automatiquement les flux RSS et mettez les nouveaux téléchargements en file d’attente sans travail manuel.\",\n    \"defaults\": {\n      \"title\": \"Paramètres par défaut de l'automatisation\",\n      \"description\": \"Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.\",\n      \"downloadDirectory\": \"Répertoire de téléchargement\",\n      \"filenameTemplate\": \"Modèle de nom de fichier (fichier uniquement)\",\n      \"onlyLatest\": \"Téléchargez uniquement la dernière vidéo\",\n      \"onlyLatestDescription\": \"Lorsqu'il est activé, VidBee ignore les anciens éléments du backlog et récupère uniquement le téléchargement le plus récent.\"\n    },\n    \"add\": {\n      \"title\": \"Ajouter un flux RSS\",\n      \"description\": \"Collez un lien de flux RSS. \\nVidBee détectera automatiquement le flux.\"\n    },\n    \"fields\": {\n      \"url\": \"URL du flux\",\n      \"keywords\": \"Filtre de mots clés (séparés par des virgules)\",\n      \"tags\": \"Balises automatiques\",\n      \"customDirectory\": \"Répertoire personnalisé\",\n      \"namingTemplate\": \"Modèle de nom de fichier personnalisé (fichier uniquement)\",\n      \"onlyLatest\": \"Téléchargez uniquement la dernière vidéo\",\n      \"onlyLatestDescription\": \"Ignorez les éléments du backlog et récupérez uniquement le téléchargement le plus récent à partir de ce flux.\",\n      \"enabled\": \"Activé\",\n      \"disabled\": \"Désactivé\",\n      \"onlyLatestShort\": \"Seulement le dernier\"\n    },\n    \"actions\": {\n      \"add\": \"Ajouter\",\n      \"refresh\": \"Rafraîchir\",\n      \"edit\": \"Modifier\",\n      \"remove\": \"Retirer\",\n      \"save\": \"Enregistrer les modifications\",\n      \"selectDirectory\": \"Parcourir\",\n      \"enable\": \"Activer\",\n      \"disable\": \"Désactiver\"\n    },\n    \"items\": {\n      \"title\": \"Derniers téléchargements ({{count}})\",\n      \"count\": \"{{count}} articles\",\n      \"empty\": \"Aucun élément de flux récent trouvé.\",\n      \"status\": {\n        \"queued\": \"En file d'attente\",\n        \"notQueued\": \"Pas en file d'attente\",\n        \"pending\": \"En attente\",\n        \"downloading\": \"Téléchargement\",\n        \"processing\": \"Traitement\",\n        \"completed\": \"Complété\",\n        \"error\": \"Échoué\",\n        \"cancelled\": \"Annulé\"\n      },\n      \"fromChannel\": \"De {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"État du téléchargement : {{status}}\",\n        \"downloadPending\": \"En attente des détails du téléchargement...\",\n        \"notQueued\": \"Pas encore dans la file d'attente de téléchargement\"\n      },\n      \"actions\": {\n        \"open\": \"Ouvrir dans le navigateur\",\n        \"queue\": \"Ajouter à la file d'attente de téléchargement\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Abonnement\",\n      \"unknown\": \"Abonnement inconnu\",\n      \"noThumbnail\": \"Aucune vignette\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Échec de l'ouverture du sélecteur de répertoire.\",\n      \"missingUrl\": \"Veuillez d'abord coller un lien de chaîne.\",\n      \"created\": \"Abonnement ajouté\",\n      \"createError\": \"Échec de l'ajout de l'abonnement.\",\n      \"refreshStarted\": \"Actualisation démarrée\",\n      \"removed\": \"Abonnement supprimé\",\n      \"updated\": \"Abonnement mis à jour\",\n      \"itemQueued\": \"Ajouté à la file d'attente de téléchargement\",\n      \"itemAlreadyQueued\": \"Cette vidéo est déjà en file d'attente\",\n      \"queueError\": \"Échec de l'ajout à la file d'attente de téléchargement.\",\n      \"openLinkError\": \"Échec de l'ouverture du lien vidéo.\",\n      \"resolveError\": \"Échec de la résolution de l'URL du flux RSS.\",\n      \"duplicateUrl\": \"Ce flux RSS est déjà abonné.\"\n    },\n    \"detectedFeed\": \"Flux {{platform}} détecté -> {{feed}}\",\n    \"detecting\": \"Détection du flux...\",\n    \"latestVideo\": \"Dernière vidéo : {{title}}\",\n    \"lastChecked\": \"Dernière vérification : {{time}}\",\n    \"never\": \"Jamais\",\n    \"empty\": \"Aucun abonnement pour l'instant. \\nAjoutez vos chaînes préférées pour lancer le téléchargement automatique.\",\n    \"edit\": {\n      \"title\": \"Modifier {{name}}\",\n      \"description\": \"Ajustez les filtres, les balises et les remplacements pour ce flux.\"\n    },\n    \"status\": {\n      \"title\": \"Statut\",\n      \"up-to-date\": \"À jour\",\n      \"checking\": \"Vérification\",\n      \"failed\": \"Échoué\",\n      \"idle\": \"Inactif\",\n      \"tooltip\": {\n        \"updatedAt\": \"Mise à jour : {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Abonnements automatisés avec RSSHub\",\n      \"description\": \"Combinez VidBee avec RSSHub pour activer les abonnements et les téléchargements automatisés à partir de diverses plateformes. \\nUne fois configuré, VidBee s'exécute en arrière-plan et télécharge automatiquement les dernières vidéos et contenus.\",\n      \"learnMore\": \"En savoir plus sur RSSHub\",\n      \"openDocs\": \"Ouvrir la documentation RSSHub\",\n      \"hint\": \"Vous n'avez pas d'URL de flux RSS ? \\nUtilisez RSSHub pour générer des flux RSS pour YouTube, Twitter et des milliers d'autres plateformes.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Supporte {{sites}} et plus.\",\n    \"moreDescription\": \"La liste complète yt-dlp est mise à jour constamment par la communauté.\",\n    \"moreTitle\": \"Besoin d'un autre site ?\",\n    \"openFullList\": \"Ouvrir la liste complète des sites supportés\",\n    \"pageDescription\": \"VidBee utilise yt-dlp en arrière-plan pour atteindre des centaines de sources.\",\n    \"pageIntro\": \"Voici les services principaux que les gens téléchargent le plus souvent.\",\n    \"pageTitle\": \"Sites Supportés\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Albums d'artistes indépendants et sorties communautaires.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Actualités mondiales, sports et clips de divertissement.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Vidéos de flux, Watch et Reels des pages publiques.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Contenu de flux, Stories, Reels et Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Streams en direct et replays de créateurs sur la plateforme Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Conférences professionnelles, webinaires et vidéos d'apprentissage.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"Mixes DJ, émissions radio et audio long format.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Animation japonaise, musique et archives de diffusion en direct.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Épingles d'idées, Reels de tutoriels et vidéos d'inspiration lifestyle.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Clips intégrés et vidéos hébergées des communautés.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Pistes musicales, playlists et sets DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Vidéos courtes mobiles, effets et streams en direct.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Médias courts créatifs et montages de fans.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Streams en direct de gaming, musique et IRL et VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Publications de timeline, enregistrements Spaces et diffusions.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Hébergement vidéo de haute qualité pour créateurs et entreprises.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Vidéo long format et livestream de créateurs du monde entier.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Vidéos musicales officielles, albums et performances live.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Plateformes principales\",\n    \"viewAll\": \"Voir tous les sites supportés\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/id.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Periksa pembaruan\",\n      \"download\": \"Unduh\",\n      \"email\": \"Email\",\n      \"feedback\": \"Masukan\",\n      \"goToDownload\": \"Buka halaman unduhan\",\n      \"openRepo\": \"Buka repositori GitHub\",\n      \"view\": \"Lihat\",\n      \"visit\": \"Kunjungi\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Unduh dan instal rilis baru secara otomatis di latar belakang.\",\n    \"autoUpdateTitle\": \"Pembaruan otomatis\",\n    \"betaProgramDescription\": \"Terima build awal dan fitur yang akan datang sebelum orang lain.\",\n    \"betaProgramTitle\": \"Saluran pratinjau\",\n    \"description\": \"VidBee adalah pengunduh gratis dan open-source yang dibangun dengan Electron dan didukung oleh yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Ikuti @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Tetap update dengan berita dan pembaruan VidBee terbaru.\",\n    \"followAuthorSupport\": \"Ikuti pengembang di X (Twitter) untuk mendapatkan pembaruan dan berita terbaru tentang VidBee.\",\n    \"followAuthorTitle\": \"Ikuti Pengembang\",\n    \"here\": \"di sini\",\n    \"homepage\": \"Beranda\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Mencari pembaruan...\",\n      \"downloadError\": \"Gagal mengunduh pembaruan\",\n      \"downloadUpdate\": \"Unduh dan instal pembaruan {{version}}?\",\n      \"manualDownloadAction\": \"Unduh sekarang\",\n      \"noUpdatesAvailable\": \"Anda menggunakan versi terbaru\",\n      \"restartToUpdate\": \"Mulai ulang sekarang untuk menginstal pembaruan?\",\n      \"restartNowAction\": \"Mulai ulang sekarang\",\n      \"updateAvailable\": \"Pembaruan tersedia: {{version}}\",\n      \"updateAvailableMessage\": \"Versi baru {{version}} tersedia. Silakan unduh dari situs web resmi.\",\n      \"updateDownloaded\": \"Pembaruan diunduh, mulai ulang untuk menginstal\",\n      \"updateDownloadedVersion\": \"Pembaruan {{version}} diunduh, mulai ulang untuk menginstal\",\n      \"updateError\": \"Gagal memeriksa pembaruan: {{error}}\",\n      \"unknownErrorFallback\": \"Kesalahan tidak diketahui\"\n    },\n    \"preferencesDescription\": \"Sesuaikan pengaturan pembaruan tanpa meninggalkan halaman ini.\",\n    \"preferencesTitle\": \"Toggle Cepat\",\n    \"resources\": {\n      \"changelog\": \"Catatan rilis\",\n      \"changelogDescription\": \"Ikuti apa yang berubah di setiap versi.\",\n      \"contact\": \"Dukungan email\",\n      \"contactDescription\": \"Hubungi langsung untuk bantuan atau kolaborasi.\",\n      \"documentation\": \"Pusat bantuan\",\n      \"documentationDescription\": \"Panduan, FAQ, dan alur kerja umum.\",\n      \"feedback\": \"Masukan & masalah\",\n      \"feedbackDescription\": \"Bagikan ide atau laporkan masalah di GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Laporkan bug atau minta fitur di GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Bagikan masukan atau saran di X dengan menyebut @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Bergabunglah dengan komunitas Discord kami untuk diskusi dan dukungan.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Pertanyaan yang sering diajukan dan panduan pemecahan masalah.\",\n      \"license\": \"Lisensi\",\n      \"licenseDescription\": \"Tinjau ketentuan lisensi open-source.\",\n      \"website\": \"Situs web resmi\",\n      \"websiteDescription\": \"Sorotan produk, roadmap, dan berita komunitas.\"\n    },\n    \"resourcesDescription\": \"Tautan berguna untuk mempelajari lebih lanjut tentang VidBee dan tetap terhubung.\",\n    \"resourcesTitle\": \"Sumber Daya\",\n    \"shareActions\": {\n      \"copy\": \"Salin tautan\",\n      \"facebook\": \"Bagikan di Facebook\",\n      \"twitter\": \"Bagikan di X (Twitter)\"\n    },\n    \"shareDescription\": \"Bagikan VidBee dengan komunitas Anda dalam satu klik.\",\n    \"shareSupport\": \"Rekomendasikan VidBee kepada teman-teman Anda untuk mendukung pertumbuhan dan pembaruan kami.\",\n    \"shareTitle\": \"Sebarkan berita\",\n    \"sourceCode\": \"Kode Sumber tersedia\",\n    \"title\": \"Tentang\",\n    \"version\": \"Versi\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Terbaru: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Versi baru tersedia\",\n      \"uptodate\": \"Anda sudah menggunakan versi terbaru\",\n      \"error\": \"Tidak dapat mengambil versi terbaru\"\n    },\n    \"downloadingUpdate\": \"Mengunduh pembaruan\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Tutup aplikasi saat unduhan selesai\",\n    \"currentLocation\": \"Lokasi unduhan saat ini - \",\n    \"downloadLocation\": \"Lokasi unduhan\",\n    \"downloadSubs\": \"Unduh subtitle jika tersedia\",\n    \"downloadSubsHint\": \"Simpan subtitle sebagai file terpisah jika tersedia\",\n    \"end\": \"Akhir\",\n    \"endHint\": \"Jika dibiarkan kosong, akan diunduh sampai akhir\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Pilih Lokasi Unduhan\",\n    \"start\": \"Mulai\",\n    \"startHint\": \"Jika dibiarkan kosong, akan dimulai dari awal\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Subtitle\",\n    \"timeRange\": \"Unduh rentang waktu tertentu\",\n    \"title\": \"Opsi Lanjutan\"\n  },\n  \"app\": {\n    \"description\": \"Unduh video dan audio dari ratusan situs\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Aktif\",\n    \"all\": \"Semua\",\n    \"audio\": \"Audio\",\n    \"back\": \"Kembali\",\n    \"cancel\": \"Batal\",\n    \"cancelled\": \"Dibatalkan\",\n    \"clearCompleted\": \"Hapus Selesai\",\n    \"clearDownloads\": \"Hapus Unduhan\",\n    \"completed\": \"Selesai\",\n    \"downloadAudio\": \"Unduh Audio\",\n    \"downloadBtn\": \"Unduh\",\n    \"downloadPending\": \"Menunggu\",\n    \"downloadQueue\": \"Antrian Unduhan\",\n    \"customDownloadFolder\": \"Folder unduhan khusus\",\n    \"retry\": \"Coba ulang unduhan\",\n    \"autoFolderPlaceholder\": \"Folder otomatis (berdasarkan metadata)\",\n    \"autoFolderHint\": \"Folder otomatis dibuat dari metadata.\",\n    \"useAutoFolder\": \"Gunakan folder otomatis\",\n    \"downloadVideo\": \"Unduh Video\",\n    \"downloading\": \"Mengunduh...\",\n    \"enterUrl\": \"Masukkan URL Video\",\n    \"enterUrlDescription\": \"Tempel atau ketik URL video. \",\n    \"error\": \"Kesalahan\",\n    \"fetch\": \"Ambil\",\n    \"fetchingVideoInfo\": \"Mengambil info video...\",\n    \"feedback\": {\n      \"title\": \"Laporkan kesalahan ini:\",\n      \"githubUrlTooLong\": \"Tautan GitHub ini sangat panjang. Jika tidak terbuka, buka halaman issue dan tempel log secara manual.\"\n    },\n    \"history\": \"Riwayat\",\n    \"imageLoadError\": \"Gagal memuat gambar\",\n    \"imagePlaceholder\": \"Tidak ada gambar tersedia\",\n    \"infoUnavailable\": \"Unduh Satu Klik (Info tidak tersedia)\",\n    \"loading\": \"Memuat\",\n    \"moreOptions\": \"Opsi lainnya\",\n    \"noActiveDownloads\": \"Tidak ada unduhan aktif\",\n    \"noAudio\": \"Tidak Ada Audio\",\n    \"noHistory\": \"Tidak ada riwayat unduhan\",\n    \"noItems\": \"Tidak ada item ditemukan\",\n    \"goToSettings\": \"Buka Pengaturan\",\n    \"oneClickDownload\": \"Unduh Satu Klik\",\n    \"oneClickDownloadDescription\": \"Unduh langsung dengan pengaturan default tanpa konfirmasi\",\n    \"oneClickDownloadTooltip\": \"Tempel & unduh instan, lewati langkah-langkah\",\n    \"oneClickDownloadNow\": \"Unduh Sekarang\",\n    \"oneClickDownloadStarted\": \"Unduhan dimulai dengan pengaturan default\",\n    \"paste\": \"Tempel\",\n    \"pastePlaylistUrl\": \"Klik untuk menempelkan tautan playlist dari clipboard [Ctrl + V]\",\n    \"pasteUrl\": \"Klik untuk menempelkan URL video atau ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Tempel URL\",\n    \"preparing\": \"Mempersiapkan...\",\n    \"processing\": \"Memproses\",\n    \"progress\": \"Kemajuan\",\n    \"showDetails\": \"Tampilkan detail\",\n    \"hideDetails\": \"Sembunyikan detail\",\n    \"viewLogs\": \"Lihat log\",\n    \"detailsTab\": \"Detail\",\n    \"logsTab\": \"Log\",\n    \"logs\": {\n      \"live\": \"Log langsung\",\n      \"history\": \"Log tersimpan\",\n      \"command\": \"Perintah yt-dlp\",\n      \"empty\": \"Belum ada log.\",\n      \"scrollPaused\": \"Pengguliran dijeda\"\n    },\n    \"selectAudioFormat\": \"Pilih Format Audio\",\n    \"selectDownloadType\": \"Pilih jenis unduhan\",\n    \"selectFormat\": \"Pilih Format\",\n    \"startDownload\": \"Mulai unduhan\",\n    \"selectVideoFormat\": \"Pilih Format Video\",\n    \"singleVideo\": \"Video Tunggal\",\n    \"speed\": \"Kecepatan\",\n    \"title\": \"Judul\",\n    \"total\": \"Total\",\n    \"unknownQuality\": \"Kualitas tidak diketahui\",\n    \"unknownSize\": \"Ukuran tidak diketahui\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Informasi Video\",\n    \"videoInfoUpdated\": \"Informasi video diperbarui\",\n    \"metadata\": {\n      \"source\": \"Sumber\",\n      \"playlist\": \"Playlist\",\n      \"format\": \"Format\",\n      \"quality\": \"Kualitas\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"File tersimpan\",\n      \"url\": \"URL Sumber\",\n      \"description\": \"Deskripsi\",\n      \"views\": \"Tayangan\",\n      \"tags\": \"Tag\",\n      \"downloadPath\": \"Jalur unduhan\",\n      \"createdAt\": \"Dibuat pada\",\n      \"startedAt\": \"Dimulai pada\",\n      \"completedAt\": \"Selesai pada\",\n      \"speed\": \"Kecepatan\",\n      \"fileSize\": \"Ukuran file\",\n      \"width\": \"Lebar\",\n      \"height\": \"Tinggi\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Codec video\",\n      \"audioCodec\": \"Codec audio\",\n      \"formatNote\": \"Catatan format\",\n      \"protocol\": \"Protokol\",\n      \"subscription\": \"Berlangganan\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Terjadi kesalahan\",\n    \"description\": \"Terjadi kesalahan tak terduga. Silakan muat ulang aplikasi atau laporkan masalah ini jika terus terjadi.\",\n    \"message\": \"Pesan kesalahan\",\n    \"unknownError\": \"Terjadi kesalahan yang tidak diketahui\",\n    \"goHome\": \"Ke beranda\",\n    \"reload\": \"Muat ulang aplikasi\",\n    \"copyReport\": \"Salin laporan kesalahan\",\n    \"copied\": \"Tersalin!\",\n    \"copySuccess\": \"Laporan kesalahan disalin ke papan klip\",\n    \"copyFailed\": \"Gagal menyalin laporan kesalahan\",\n    \"showDetails\": \"Tampilkan detail\",\n    \"hideDetails\": \"Sembunyikan detail\",\n    \"stackTrace\": \"Jejak tumpukan\",\n    \"componentStack\": \"Tumpukan komponen\",\n    \"noStackTrace\": \"Tidak ada jejak tumpukan yang tersedia\",\n    \"fullReport\": \"Laporan kesalahan lengkap\",\n    \"helpText\": \"Jika kesalahan ini terus terjadi, silakan salin laporan kesalahan di atas dan bagikan dengan tim dukungan. Informasi kontak dapat ditemukan di halaman Tentang.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Klik untuk menyalin detail\",\n    \"clipboardEmpty\": \"Clipboard kosong\",\n    \"downloadFailed\": \"Unduhan gagal\",\n    \"downloadNecessaryFilesFailed\": \"Gagal mengunduh file yang diperlukan. Silakan periksa jaringan Anda dan coba lagi\",\n    \"emptyUrl\": \"Silakan masukkan URL\",\n    \"errorDetails\": \"Detail Kesalahan\",\n    \"fetchInfoFailed\": \"Gagal mengambil informasi video\",\n    \"invalidUrl\": \"Konten papan klip bukan URL yang valid\",\n    \"networkError\": \"Terjadi kesalahan. Periksa jaringan Anda dan gunakan URL yang benar\",\n    \"pasteFromClipboard\": \"Gagal menempel dari clipboard\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Hapus Dibatalkan\",\n    \"clearCompleted\": \"Hapus Selesai\",\n    \"clearErrors\": \"Hapus Kesalahan\",\n    \"clearAll\": \"Hapus semua riwayat\",\n    \"clearAllAction\": \"Hapus riwayat\",\n    \"clearSelection\": \"Hapus pilihan\",\n    \"confirmClearAllTitle\": \"Hapus semua riwayat?\",\n    \"confirmClearAllDescription\": \"Hapus {{count}} item dari riwayat Anda. File tetap di disk.\",\n    \"confirmDeleteSelectedTitle\": \"Hapus item yang dipilih?\",\n    \"confirmDeleteSelectedDescription\": \"Hapus {{count}} item dari riwayat Anda. File tetap di disk.\",\n    \"alsoDeleteFiles\": \"Hapus juga file\",\n    \"confirmDeletePlaylistTitle\": \"Hapus riwayat playlist?\",\n    \"confirmDeletePlaylistDescription\": \"Hapus {{count}} item dari {{title}} dan hapus file-nya.\",\n    \"copyToClipboard\": \"Salin ke clipboard\",\n    \"copyUrl\": \"Salin URL\",\n    \"date\": \"Tanggal\",\n    \"deletePlaylist\": \"Hapus playlist\",\n    \"deleteSelected\": \"Hapus yang dipilih\",\n    \"description\": \"Lihat dan kelola riwayat unduhan Anda\",\n    \"doneSelecting\": \"Selesai\",\n    \"duration\": \"Durasi\",\n    \"fileSize\": \"Ukuran File\",\n    \"filters\": {\n      \"all\": \"Semua\",\n      \"cancelled\": \"Dibatalkan\",\n      \"completed\": \"Selesai\",\n      \"errors\": \"Kesalahan\"\n    },\n    \"noHistory\": \"Belum ada riwayat unduhan\",\n    \"noHistoryDescription\": \"Unduhan yang selesai akan muncul di sini\",\n    \"cookiesTipTitle\": \"Tingkatkan keberhasilan unduhan dengan cookie\",\n    \"cookiesTipDescription\": \"Atur cookie untuk menaikkan tingkat keberhasilan dari <strong>70%</strong> ke <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Atur cookie\",\n    \"openDownloadFolder\": \"Buka Folder Unduhan\",\n    \"openFile\": \"Buka File\",\n    \"openFileLocation\": \"Buka Lokasi File\",\n    \"openFolder\": \"Buka Folder\",\n    \"openInBrowser\": \"Klik untuk membuka di browser\",\n    \"removeAction\": \"Hapus\",\n    \"removeItem\": \"Hapus Item\",\n    \"deleteFile\": \"Hapus File\",\n    \"deleteRecord\": \"Hapus dari Daftar\",\n    \"select\": \"Pilih\",\n    \"selectAll\": \"Pilih semua\",\n    \"selectVisible\": \"Pilih yang terlihat\",\n    \"selectItem\": \"Pilih item\",\n    \"selectedCount\": \"{{count}} dipilih\",\n    \"selectionSummary\": \"{{selected}} dari {{total}} terlihat dipilih\",\n    \"stats\": {\n      \"cancelled\": \"Dibatalkan\",\n      \"completed\": \"Selesai\",\n      \"errors\": \"Kesalahan\",\n      \"total\": \"Total\"\n    },\n    \"status\": {\n      \"cancelled\": \"Dibatalkan\",\n      \"completed\": \"Selesai\",\n      \"error\": \"Kesalahan\"\n    },\n    \"title\": \"Riwayat Unduhan\"\n  },\n  \"menu\": {\n    \"about\": \"Tentang\",\n    \"download\": \"Unduh\",\n    \"playlist\": \"Unduh Playlist\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Berlangganan\",\n    \"preferences\": \"Preferensi\",\n    \"supportedSites\": \"Situs yang Didukung\",\n    \"theme\": \"Tema:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Gagal menyalin ke clipboard\",\n    \"downloadCompleted\": \"Unduhan selesai\",\n    \"downloadAlreadyQueued\": \"Unduhan ini sedang berlangsung\",\n    \"downloadFailed\": \"Unduhan gagal\",\n    \"historyCleared\": \"Riwayat dihapus\",\n    \"historyClearFailed\": \"Gagal menghapus riwayat\",\n    \"itemRemoved\": \"Item dihapus\",\n    \"itemsRemoved\": \"{{count}} item dihapus\",\n    \"itemsRemoveFailed\": \"Gagal menghapus item yang dipilih\",\n    \"openFileFailed\": \"Gagal membuka file\",\n    \"openFolderFailed\": \"Gagal membuka folder\",\n    \"playlistHistoryRemoved\": \"Playlist dihapus dan file dihapus\",\n    \"playlistHistoryRemoveFailed\": \"Gagal menghapus riwayat playlist\",\n    \"removeFailed\": \"Gagal menghapus item\",\n    \"settingsSaved\": \"Pengaturan disimpan\",\n    \"urlCopied\": \"URL disalin ke clipboard\",\n    \"videoCopied\": \"Video disalin ke clipboard\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Playlist\",\n    \"clearPreview\": \"Hapus pratinjau\",\n    \"collapsedProgress\": \"Mengunduh playlist: {{completed}} / {{total}} selesai\",\n    \"comingSoon\": \"Fitur unduh playlist segera hadir!\",\n    \"completed\": \"Playlist diunduh\",\n    \"description\": \"Unduh semua video dari playlist atau saluran YouTube\",\n    \"downloadFailed\": \"Gagal memulai unduhan playlist\",\n    \"downloadPlaylist\": \"Unduh Playlist\",\n    \"downloadType\": \"Jenis Unduhan\",\n    \"downloading\": \"Mengunduh playlist:\",\n    \"endIndex\": \"Akhir\",\n    \"enterPlaylistUrl\": \"Masukkan URL Playlist\",\n    \"fetchFailed\": \"Gagal mengambil informasi playlist\",\n    \"filenameFormat\": \"Format nama file untuk playlist\",\n    \"folderFormat\": \"Format nama folder untuk playlist\",\n    \"foundVideos\": \"Ditemukan {{count}} video dalam playlist\",\n    \"groupActive\": \"{{count}} aktif\",\n    \"groupCollapse\": \"Ciutkan\",\n    \"groupErrors\": \"{{count}} gagal\",\n    \"groupExpand\": \"Perluas\",\n    \"groupSummary\": \"{{completed}} / {{total}} selesai\",\n    \"linkLabel\": \"URL Playlist\",\n    \"noEntries\": \"Tidak ada video yang ditemukan dalam playlist ini\",\n    \"noEntriesInRange\": \"Tidak ada video dalam rentang yang dipilih\",\n    \"noRangeSelected\": \"Tidak ada akhir yang ditetapkan - playlist penuh dipilih\",\n    \"playlistUrlDescription\": \"Unduh semua video dari playlist secara massal\",\n    \"positionLabel\": \"Item {{index}} dari {{total}}\",\n    \"previewButton\": \"Pratinjau playlist\",\n    \"previewFailed\": \"Gagal mempratinjau playlist\",\n    \"previewSummary\": \"Pratinjau item playlist sebelum mengunduh.\",\n    \"previewRequired\": \"Pratinjau playlist sebelum mengunduh.\",\n    \"range\": \"Rentang (Opsional)\",\n    \"resetToDefault\": \"Reset ke default\",\n    \"selectedRange\": \"Rentang: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} dipilih\",\n    \"downloadCurrentRange\": \"Unduh yang dipilih\",\n    \"showingCount\": \"Menampilkan {{count}} video\",\n    \"selectEntry\": \"Pilih entri {{index}}\",\n    \"noEntriesSelected\": \"Tidak ada entri yang dipilih\",\n    \"startIndex\": \"Mulai (1)\",\n    \"title\": \"Unduh Playlist\",\n    \"totalVideos\": \"Total video: {{count}}\",\n    \"untitled\": \"Playlist tanpa judul\",\n    \"fetchingInfo\": \"Mengambil informasi playlist...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Tentang\",\n    \"advanced\": \"Lanjutan\",\n    \"cookiesTab\": \"Cookie\",\n    \"app\": \"Pengaturan Aplikasi\",\n    \"audio\": \"Preferensi Audio\",\n    \"browserForCookies\": \"Pilih browser untuk menggunakan cookie\",\n    \"browserForCookiesDescription\": \"Browser untuk mengekstrak cookie untuk autentikasi\",\n    \"browserForCookiesWindowsNote\": \"Di Windows, hanya cookie Firefox yang didukung. Untuk browser lain, silakan konfigurasi file cookie secara manual.\",\n    \"browserForCookiesProfile\": \"Nama profil atau path\",\n    \"browserForCookiesProfileDescription\": \"Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.\",\n    \"browserForCookiesProfilePlaceholder\": \"Nama profil atau path lengkap (opsional)\",\n    \"browserForCookiesProfileInvalid\": \"Path profil tidak valid. Pilih folder profil untuk browser yang dipilih.\",\n    \"browserForCookiesProfileInvalidPath\": \"Folder itu tidak ada. Pilih folder profil yang ada.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Nama profil tidak ditemukan di lokasi browser default.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Tidak ada lokasi profil default yang diketahui untuk browser ini di platform ini.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Masukkan path profil untuk browser yang dipilih.\",\n    \"cookiesFile\": \"File cookie\",\n    \"cookiesFileDescription\": \"File cookie format Netscape untuk dimuat untuk autentikasi\",\n    \"clearCookiesFile\": \"Hapus\",\n    \"cookiesHelpTitle\": \"Menggunakan cookie\",\n    \"cookiesHelpBrowser\": \"Pilih browser Anda di atas untuk menggunakan kembali sesi masuk secara otomatis.\",\n    \"cookiesHelpFile\": \"Ekspor file cookie Netscape (lihat FAQ yt-dlp) dan pilih di sini saat diperlukan.\",\n    \"cookiesGuideTitle\": \"Butuh panduan?\",\n    \"cookiesGuideDescription\": \"Lihat panduan langkah demi langkah untuk menggunakan cookie di VidBee.\",\n    \"cookiesGuideLink\": \"Buka panduan cookie\",\n    \"openLinkError\": \"Gagal membuka tautan\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Gunakan file konfigurasi\",\n    \"configFileDescription\": \"File konfigurasi khusus untuk yt-dlp\",\n    \"clearConfigFile\": \"Hapus\",\n    \"dark\": \"Gelap\",\n    \"description\": \"Konfigurasikan preferensi unduhan dan pengaturan aplikasi Anda\",\n    \"directorySelectError\": \"Gagal memilih direktori\",\n    \"downloadPath\": \"Lokasi unduhan\",\n    \"downloadPathDescription\": \"Pilih tempat menyimpan file yang diunduh\",\n    \"fileSelectError\": \"Gagal memilih file\",\n    \"general\": \"Umum\",\n    \"language\": \"Bahasa\",\n    \"languageDescription\": \"Pilih bahasa pilihan Anda untuk antarmuka aplikasi\",\n    \"light\": \"Terang\",\n    \"hideDockIcon\": \"Sembunyikan ikon Dock\",\n    \"hideDockIconDescription\": \"Hapus VidBee dari Dock macOS. Gunakan menu bar atau ikon tray untuk membuka kembali aplikasi.\",\n    \"launchAtLogin\": \"Luncurkan saat startup\",\n    \"launchAtLoginDescription\": \"Buka VidBee secara otomatis setelah Anda masuk ke komputer Anda.\",\n    \"launchAtLoginUnsupported\": \"Peluncuran otomatis hanya tersedia di macOS dan Windows.\",\n    \"enableAnalytics\": \"Bantu tingkatkan VidBee\",\n    \"enableAnalyticsDescription\": \"Bagikan data penggunaan anonim untuk membantu kami memahami bagaimana aplikasi digunakan dan memprioritaskan peningkatan.\",\n    \"embedChapters\": \"Sematkan bab\",\n    \"embedChaptersDescription\": \"Tambahkan penanda bab ke file saat tersedia\",\n    \"embedMetadata\": \"Sematkan metadata\",\n    \"embedMetadataDescription\": \"Tulis judul, artis, dan metadata lain saat tersedia\",\n    \"embedSubs\": \"Sematkan subtitle\",\n    \"embedSubsDescription\": \"Sematkan subtitle ke file video (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Sematkan thumbnail\",\n    \"embedThumbnailDescription\": \"Tambahkan thumbnail sebagai sampul\",\n    \"shareWatermark\": \"Tanda air berbagi\",\n    \"shareWatermarkDescription\": \"Tambahkan tanda air dengan judul asli, pembuat, dan merek VidBee\",\n    \"maxConcurrentDownloads\": \"Jumlah maksimum unduhan aktif\",\n    \"maxConcurrentDownloadsDescription\": \"Jumlah maksimum unduhan bersamaan\",\n    \"none\": \"Tidak ada\",\n    \"oneClickDownload\": \"Unduh Satu Klik\",\n    \"oneClickDownloadDescription\": \"Aktifkan unduh satu klik dengan pengaturan default\",\n    \"oneClickDownloadType\": \"Jenis unduhan default\",\n    \"oneClickDownloadTypeDescription\": \"Pilih jenis unduhan default untuk unduhan satu klik. Kualitas menggunakan preset di bawah ini.\",\n    \"oneClickQuality\": \"Kualitas yang disukai\",\n    \"oneClickQualityDescription\": \"Pilih preset kualitas yang digunakan untuk unduhan satu klik\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Otomatis\",\n      \"bad\": \"Buruk\",\n      \"best\": \"Terbaik\",\n      \"good\": \"Baik\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Terburuk\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Server proxy untuk permintaan jaringan\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Pilih file konfigurasi\",\n    \"selectPath\": \"Pilih\",\n    \"showMoreFormats\": \"Tampilkan lebih banyak opsi format\",\n    \"showMoreFormatsDescription\": \"Tampilkan opsi format tambahan di antarmuka\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.\"\n    },\n    \"system\": \"Sistem\",\n    \"theme\": \"Tema\",\n    \"themeDescription\": \"Pilih tema terang, gelap, atau sistem untuk VidBee\",\n    \"title\": \"Pengaturan\",\n    \"tray\": {\n      \"quit\": \"Keluar\",\n      \"showHome\": \"Tampilkan Beranda\"\n    },\n    \"video\": \"Preferensi Video\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Berlangganan\",\n    \"subtitle\": \"{{count}} berlangganan{{count, plural, one {} other {}}}\",\n    \"description\": \"Pantau feed RSS secara otomatis dan antre unduhan baru tanpa pekerjaan manual.\",\n    \"defaults\": {\n      \"title\": \"Default otomatisasi\",\n      \"description\": \"Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.\",\n      \"downloadDirectory\": \"Direktori unduhan\",\n      \"filenameTemplate\": \"Template nama file (hanya file)\",\n      \"onlyLatest\": \"Unduh hanya video terbaru\",\n      \"onlyLatestDescription\": \"Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru.\"\n    },\n    \"add\": {\n      \"title\": \"Tambah RSS\",\n      \"description\": \"Tempel tautan feed RSS. VidBee akan mendeteksi feed secara otomatis.\"\n    },\n    \"fields\": {\n      \"url\": \"URL Feed\",\n      \"keywords\": \"Filter kata kunci (dipisahkan koma)\",\n      \"tags\": \"Tag otomatis\",\n      \"customDirectory\": \"Direktori khusus\",\n      \"namingTemplate\": \"Template nama file khusus (hanya file)\",\n      \"onlyLatest\": \"Unduh hanya video terbaru\",\n      \"onlyLatestDescription\": \"Abaikan item backlog dan ambil hanya unggahan terbaru dari feed ini.\",\n      \"enabled\": \"Diaktifkan\",\n      \"disabled\": \"Dinonaktifkan\",\n      \"onlyLatestShort\": \"Hanya terbaru\"\n    },\n    \"actions\": {\n      \"add\": \"Tambah\",\n      \"refresh\": \"Segarkan\",\n      \"edit\": \"Edit\",\n      \"remove\": \"Hapus\",\n      \"save\": \"Simpan perubahan\",\n      \"selectDirectory\": \"Jelajahi\",\n      \"enable\": \"Aktifkan\",\n      \"disable\": \"Nonaktifkan\"\n    },\n    \"items\": {\n      \"title\": \"Unggahan terbaru ({{count}})\",\n      \"count\": \"{{count}} item\",\n      \"empty\": \"Tidak ada item feed terbaru ditemukan.\",\n      \"status\": {\n        \"queued\": \"Diantre\",\n        \"notQueued\": \"Tidak diantre\",\n        \"pending\": \"Menunggu\",\n        \"downloading\": \"Mengunduh\",\n        \"processing\": \"Memproses\",\n        \"completed\": \"Selesai\",\n        \"error\": \"Gagal\",\n        \"cancelled\": \"Dibatalkan\"\n      },\n      \"fromChannel\": \"Dari {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Status unduhan: {{status}}\",\n        \"downloadPending\": \"Menunggu detail unduhan...\",\n        \"notQueued\": \"Belum dalam antrian unduhan\"\n      },\n      \"actions\": {\n        \"open\": \"Buka di browser\",\n        \"queue\": \"Tambahkan ke antrian unduhan\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Berlangganan\",\n      \"unknown\": \"Berlangganan tidak diketahui\",\n      \"noThumbnail\": \"Tidak ada thumbnail\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Gagal membuka pemilih direktori.\",\n      \"missingUrl\": \"Silakan tempel tautan saluran terlebih dahulu.\",\n      \"created\": \"Berlangganan ditambahkan\",\n      \"createError\": \"Gagal menambahkan berlangganan.\",\n      \"refreshStarted\": \"Penyegaran dimulai\",\n      \"removed\": \"Berlangganan dihapus\",\n      \"updated\": \"Berlangganan diperbarui\",\n      \"itemQueued\": \"Ditambahkan ke antrian unduhan\",\n      \"itemAlreadyQueued\": \"Video ini sudah diantre\",\n      \"queueError\": \"Gagal menambahkan ke antrian unduhan.\",\n      \"openLinkError\": \"Gagal membuka tautan video.\",\n      \"resolveError\": \"Gagal menyelesaikan URL feed RSS.\",\n      \"duplicateUrl\": \"Feed RSS ini sudah berlangganan.\"\n    },\n    \"detectedFeed\": \"Feed {{platform}} terdeteksi -> {{feed}}\",\n    \"detecting\": \"Mendeteksi feed...\",\n    \"latestVideo\": \"Video terbaru: {{title}}\",\n    \"lastChecked\": \"Terakhir diperiksa: {{time}}\",\n    \"never\": \"Tidak pernah\",\n    \"empty\": \"Belum ada berlangganan. Tambahkan saluran favorit Anda untuk mulai mengunduh otomatis.\",\n    \"edit\": {\n      \"title\": \"Edit {{name}}\",\n      \"description\": \"Sesuaikan filter, tag, dan penggantian untuk feed ini.\"\n    },\n    \"status\": {\n      \"title\": \"Status\",\n      \"up-to-date\": \"Terbaru\",\n      \"checking\": \"Memeriksa\",\n      \"failed\": \"Gagal\",\n      \"idle\": \"Menganggur\",\n      \"tooltip\": {\n        \"updatedAt\": \"Diperbarui: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Berlangganan Otomatis dengan RSSHub\",\n      \"description\": \"Gabungkan VidBee dengan RSSHub untuk mengaktifkan berlangganan dan unduhan otomatis dari berbagai platform. Setelah disetel, VidBee berjalan di latar belakang dan secara otomatis mengunduh video dan konten terbaru.\",\n      \"learnMore\": \"Pelajari lebih lanjut tentang RSSHub\",\n      \"openDocs\": \"Buka Dokumentasi RSSHub\",\n      \"hint\": \"Tidak punya URL feed RSS? Gunakan RSSHub untuk menghasilkan feed RSS untuk YouTube, Twitter, dan ribuan platform lainnya.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Mendukung {{sites}} dan lainnya.\",\n    \"moreDescription\": \"Daftar lengkap yt-dlp terus diperbarui oleh komunitas.\",\n    \"moreTitle\": \"Perlu situs lain?\",\n    \"openFullList\": \"Buka daftar lengkap situs yang didukung\",\n    \"pageDescription\": \"VidBee menggunakan yt-dlp di balik layar untuk mencapai ratusan sumber.\",\n    \"pageIntro\": \"Berikut adalah layanan utama yang paling sering diunduh orang.\",\n    \"pageTitle\": \"Situs yang Didukung\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Album artis independen dan rilis komunitas.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Klip berita, olahraga, dan hiburan global.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Video Feed, Watch, dan Reels dari halaman publik.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Konten Feed, Stories, Reels, dan Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Streaming langsung dan replay kreator di platform Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Pembicaraan profesional, webinar, dan video pembelajaran.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"Mix DJ, acara radio, dan audio format panjang.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Arsip animasi, musik, dan siaran langsung Jepang.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Pin ide, reel cara-cara, dan video inspirasi gaya hidup.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Klip tersemat dan video yang dihosting dari komunitas.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Lagu musik, playlist, dan set DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Video format pendek seluler, efek, dan streaming langsung.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Media format pendek kreatif dan edit penggemar.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Streaming langsung gaming, musik, dan IRL serta VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Posting timeline, rekaman Spaces, dan siaran.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Hosting video kreator dan bisnis berkualitas tinggi.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Video format panjang dan streaming langsung dari kreator di seluruh dunia.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Video musik resmi, album, dan pertunjukan langsung.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Platform utama\",\n    \"viewAll\": \"Lihat semua situs yang didukung\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/it.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Controlla aggiornamenti\",\n      \"download\": \"Scaricamento\",\n      \"email\": \"Email\",\n      \"feedback\": \"Feedback\",\n      \"goToDownload\": \"Vai alla pagina di download\",\n      \"openRepo\": \"Apri repository GitHub\",\n      \"view\": \"Visualizza\",\n      \"visit\": \"Visita\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Scarica e installa automaticamente le nuove versioni in background.\",\n    \"autoUpdateTitle\": \"Aggiornamenti automatici\",\n    \"betaProgramDescription\": \"Ricevi build anticipate e prossime funzionalità prima di tutti.\",\n    \"betaProgramTitle\": \"Canale anteprima\",\n    \"description\": \"VidBee è un downloader gratuito e open-source costruito con Electron e alimentato da yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Segui @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Rimani aggiornato con le ultime notizie e aggiornamenti di VidBee.\",\n    \"followAuthorSupport\": \"Segui lo sviluppatore su X (Twitter) per ottenere gli ultimi aggiornamenti e notizie su VidBee.\",\n    \"followAuthorTitle\": \"Segui lo Sviluppatore\",\n    \"here\": \"qui\",\n    \"homepage\": \"Homepage\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Ricerca aggiornamenti...\",\n      \"downloadError\": \"Errore nel download dell'aggiornamento\",\n      \"downloadUpdate\": \"Scarica e installa l'aggiornamento {{version}}?\",\n      \"manualDownloadAction\": \"Scarica ora\",\n      \"noUpdatesAvailable\": \"Stai usando l'ultima versione\",\n      \"restartToUpdate\": \"Riavvia ora per installare l'aggiornamento?\",\n      \"restartNowAction\": \"Ricomincia adesso\",\n      \"updateAvailable\": \"Aggiornamento disponibile: {{version}}\",\n      \"updateAvailableMessage\": \"È disponibile una nuova versione {{version}}. \\nSi prega di scaricarlo dal sito ufficiale.\",\n      \"updateDownloaded\": \"Aggiornamento scaricato, riavvia per installare\",\n      \"updateDownloadedVersion\": \"Aggiornamento {{version}} scaricato, riavvia per installare\",\n      \"updateError\": \"Errore nel controllo degli aggiornamenti: {{error}}\",\n      \"unknownErrorFallback\": \"Errore sconosciuto\"\n    },\n    \"preferencesDescription\": \"Regola le impostazioni di aggiornamento senza lasciare questa pagina.\",\n    \"preferencesTitle\": \"Toggle Rapidi\",\n    \"resources\": {\n      \"changelog\": \"Note di rilascio\",\n      \"changelogDescription\": \"Tieni traccia di cosa è cambiato in ogni versione.\",\n      \"contact\": \"Supporto email\",\n      \"contactDescription\": \"Contatta direttamente per aiuto o collaborazione.\",\n      \"documentation\": \"Centro assistenza\",\n      \"documentationDescription\": \"Guide, FAQ e flussi di lavoro comuni.\",\n      \"feedback\": \"Feedback e problemi\",\n      \"feedbackDescription\": \"Condividi idee o segnala problemi su GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Segnala bug o richiedi funzionalità su GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Condividi feedback o suggerimenti su X menzionando @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Unisciti alla nostra community Discord per discussioni e supporto.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Domande frequenti e guide per la risoluzione dei problemi.\",\n      \"license\": \"Licenza\",\n      \"licenseDescription\": \"Rivedi i termini della licenza open-source.\",\n      \"website\": \"Sito web ufficiale\",\n      \"websiteDescription\": \"Punti salienti del prodotto, roadmap e notizie della comunità.\"\n    },\n    \"resourcesDescription\": \"Link utili per saperne di più su VidBee e rimanere connessi.\",\n    \"resourcesTitle\": \"Risorse\",\n    \"shareActions\": {\n      \"copy\": \"Copia link\",\n      \"facebook\": \"Condividi su Facebook\",\n      \"twitter\": \"Condividi su X (Twitter)\"\n    },\n    \"shareDescription\": \"Condividi VidBee con la tua comunità in un clic.\",\n    \"shareSupport\": \"Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.\",\n    \"shareTitle\": \"Passa parola\",\n    \"sourceCode\": \"Il codice sorgente è disponibile\",\n    \"title\": \"Informazioni\",\n    \"version\": \"Versione\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Ultima: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Nuova versione disponibile\",\n      \"uptodate\": \"Sei aggiornato\",\n      \"error\": \"Impossibile recuperare l'ultima versione\"\n    },\n    \"downloadingUpdate\": \"Download dell'aggiornamento\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Chiudi app quando il download finisce\",\n    \"currentLocation\": \"Posizione download attuale - \",\n    \"downloadLocation\": \"Posizione download\",\n    \"downloadSubs\": \"Scarica sottotitoli se disponibili\",\n    \"downloadSubsHint\": \"Salva i sottotitoli come file separati quando disponibili\",\n    \"end\": \"Fine\",\n    \"endHint\": \"Se lasciato vuoto, verrà scaricato fino alla fine\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Seleziona Posizione Download\",\n    \"start\": \"Inizio\",\n    \"startHint\": \"Se lasciato vuoto, inizierà dall'inizio\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Sottotitoli\",\n    \"timeRange\": \"Scarica intervallo di tempo specifico\",\n    \"title\": \"Opzioni Avanzate\"\n  },\n  \"app\": {\n    \"description\": \"Scarica video e audio da centinaia di siti\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Attivo\",\n    \"all\": \"Tutto\",\n    \"audio\": \"Audio\",\n    \"back\": \"Indietro\",\n    \"cancel\": \"Annulla\",\n    \"cancelled\": \"Annullato\",\n    \"clearCompleted\": \"Cancella Completati\",\n    \"clearDownloads\": \"Cancella Download\",\n    \"completed\": \"Completato\",\n    \"downloadAudio\": \"Scarica Audio\",\n    \"downloadBtn\": \"Scarica\",\n    \"downloadPending\": \"In attesa\",\n    \"downloadQueue\": \"Coda Download\",\n    \"customDownloadFolder\": \"Cartella di download personalizzata\",\n    \"retry\": \"Riprova download\",\n    \"autoFolderPlaceholder\": \"Cartella automatica (in base ai metadati)\",\n    \"autoFolderHint\": \"Le cartelle automatiche vengono create dai metadati.\",\n    \"useAutoFolder\": \"Usa cartella automatica\",\n    \"downloadVideo\": \"Scarica Video\",\n    \"downloading\": \"Scaricando...\",\n    \"enterUrl\": \"Inserisci URL Video\",\n    \"enterUrlDescription\": \"Incolla o digita un URL video. \",\n    \"error\": \"Errore\",\n    \"fetch\": \"Recupera\",\n    \"fetchingVideoInfo\": \"Recupero informazioni video...\",\n    \"feedback\": {\n      \"title\": \"Segnala questo errore:\",\n      \"githubUrlTooLong\": \"Questo link GitHub è molto lungo. Se non si apre, apri la pagina dell'issue e incolla i log manualmente.\"\n    },\n    \"history\": \"Cronologia\",\n    \"imageLoadError\": \"Errore nel caricamento dell'immagine\",\n    \"imagePlaceholder\": \"Nessuna immagine disponibile\",\n    \"infoUnavailable\": \"Download con Un Clic (Info non disponibile)\",\n    \"loading\": \"Caricamento\",\n    \"moreOptions\": \"Più opzioni\",\n    \"noActiveDownloads\": \"Nessun download attivo\",\n    \"noAudio\": \"Nessun Audio\",\n    \"noHistory\": \"Nessuna cronologia download\",\n    \"noItems\": \"Nessun elemento trovato\",\n    \"goToSettings\": \"Vai su Impostazioni\",\n    \"oneClickDownload\": \"Download con Un Clic\",\n    \"oneClickDownloadDescription\": \"Scarica direttamente con impostazioni predefinite senza conferma\",\n    \"oneClickDownloadTooltip\": \"Incolla e scarica subito, salta i passaggi\",\n    \"oneClickDownloadNow\": \"Scarica Ora\",\n    \"oneClickDownloadStarted\": \"Download iniziato con impostazioni predefinite\",\n    \"paste\": \"Incolla\",\n    \"pastePlaylistUrl\": \"Clicca per incollare link playlist dagli appunti [Ctrl + V]\",\n    \"pasteUrl\": \"Clicca per incollare URL video o ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Incolla URL\",\n    \"preparing\": \"Preparazione...\",\n    \"processing\": \"Elaborazione\",\n    \"progress\": \"Progresso\",\n    \"showDetails\": \"Mostra dettagli\",\n    \"hideDetails\": \"Nascondi dettagli\",\n    \"viewLogs\": \"Visualizza log\",\n    \"detailsTab\": \"Dettagli\",\n    \"logsTab\": \"Log\",\n    \"logs\": {\n      \"live\": \"Log in tempo reale\",\n      \"history\": \"Log salvati\",\n      \"command\": \"Comando yt-dlp\",\n      \"empty\": \"Nessun log ancora.\",\n      \"scrollPaused\": \"Scorrimento in pausa\"\n    },\n    \"selectAudioFormat\": \"Seleziona Formato Audio\",\n    \"selectDownloadType\": \"Seleziona tipo di download\",\n    \"selectFormat\": \"Seleziona Formato\",\n    \"startDownload\": \"Avvia download\",\n    \"selectVideoFormat\": \"Seleziona Formato Video\",\n    \"singleVideo\": \"Video Singolo\",\n    \"speed\": \"Velocità\",\n    \"title\": \"Titolo\",\n    \"total\": \"Totale\",\n    \"unknownQuality\": \"Qualità sconosciuta\",\n    \"unknownSize\": \"Dimensione sconosciuta\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Informazioni Video\",\n    \"videoInfoUpdated\": \"Informazioni video aggiornate\",\n    \"metadata\": {\n      \"source\": \"Fonte\",\n      \"playlist\": \"Playlist\",\n      \"format\": \"Formato\",\n      \"quality\": \"Qualità\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"File salvato\",\n      \"url\": \"URL di origine\",\n      \"description\": \"Descrizione\",\n      \"views\": \"Viste\",\n      \"tags\": \"Tag\",\n      \"downloadPath\": \"Scarica il percorso\",\n      \"createdAt\": \"Creato a\",\n      \"startedAt\": \"Iniziato alle\",\n      \"completedAt\": \"Completato a\",\n      \"speed\": \"Velocità\",\n      \"fileSize\": \"Dimensioni del file\",\n      \"width\": \"Larghezza\",\n      \"height\": \"Altezza\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Codec video\",\n      \"audioCodec\": \"Codec audio\",\n      \"formatNote\": \"Nota sul formato\",\n      \"protocol\": \"Protocollo\",\n      \"subscription\": \"Sottoscrizione\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Qualcosa è andato storto\",\n    \"description\": \"Si è verificato un errore imprevisto. Ricarica l'app o segnala il problema se persiste.\",\n    \"message\": \"Messaggio di errore\",\n    \"unknownError\": \"Si è verificato un errore sconosciuto\",\n    \"goHome\": \"Vai alla home\",\n    \"reload\": \"Ricarica app\",\n    \"copyReport\": \"Copia rapporto errore\",\n    \"copied\": \"Copiato!\",\n    \"copySuccess\": \"Rapporto di errore copiato negli appunti\",\n    \"copyFailed\": \"Impossibile copiare il rapporto di errore\",\n    \"showDetails\": \"Mostra dettagli\",\n    \"hideDetails\": \"Nascondi dettagli\",\n    \"stackTrace\": \"Traccia dello stack\",\n    \"componentStack\": \"Stack dei componenti\",\n    \"noStackTrace\": \"Nessuna traccia dello stack disponibile\",\n    \"fullReport\": \"Rapporto di errore completo\",\n    \"helpText\": \"Se l'errore persiste, copia il rapporto di errore sopra e condividilo con il team di supporto. Le informazioni di contatto sono nella pagina Informazioni.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Clicca per copiare i dettagli\",\n    \"clipboardEmpty\": \"Gli appunti sono vuoti\",\n    \"downloadFailed\": \"Download fallito\",\n    \"downloadNecessaryFilesFailed\": \"Errore nel download dei file necessari. Controlla la tua rete e riprova\",\n    \"emptyUrl\": \"Inserisci un URL\",\n    \"errorDetails\": \"Dettagli Errore\",\n    \"fetchInfoFailed\": \"Errore nel recupero delle informazioni video\",\n    \"invalidUrl\": \"Il contenuto degli appunti non è un URL valido\",\n    \"networkError\": \"Si è verificato un errore. Controlla la tua rete e usa un URL corretto\",\n    \"pasteFromClipboard\": \"Errore nell'incollare dagli appunti\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Cancella Annullati\",\n    \"clearCompleted\": \"Cancella Completati\",\n    \"clearErrors\": \"Cancella Errori\",\n    \"clearAll\": \"Cancella tutta la cronologia\",\n    \"clearAllAction\": \"Cancella cronologia\",\n    \"clearSelection\": \"Cancella selezione\",\n    \"confirmClearAllTitle\": \"Cancellare tutta la cronologia?\",\n    \"confirmClearAllDescription\": \"Rimuovi {{count}} elementi dalla cronologia. I file rimangono sul disco.\",\n    \"confirmDeleteSelectedTitle\": \"Rimuovere gli elementi selezionati?\",\n    \"confirmDeleteSelectedDescription\": \"Rimuovi {{count}} elementi dalla cronologia. I file rimangono sul disco.\",\n    \"alsoDeleteFiles\": \"Elimina anche i file\",\n    \"confirmDeletePlaylistTitle\": \"Rimuovere la cronologia della playlist?\",\n    \"confirmDeletePlaylistDescription\": \"Rimuovi {{count}} elementi da {{title}} ed elimina i loro file.\",\n    \"copyToClipboard\": \"Copia negli appunti\",\n    \"copyUrl\": \"Copia URL\",\n    \"date\": \"Data\",\n    \"deletePlaylist\": \"Rimuovi playlist\",\n    \"deleteSelected\": \"Rimuovi selezionati\",\n    \"description\": \"Visualizza e gestisci la tua cronologia download\",\n    \"doneSelecting\": \"Fatto\",\n    \"duration\": \"Durata\",\n    \"fileSize\": \"Dimensione File\",\n    \"filters\": {\n      \"all\": \"Tutto\",\n      \"cancelled\": \"Annullato\",\n      \"completed\": \"Completato\",\n      \"errors\": \"Errori\"\n    },\n    \"noHistory\": \"Nessuna cronologia download ancora\",\n    \"noHistoryDescription\": \"I tuoi download completati appariranno qui\",\n    \"cookiesTipTitle\": \"Aumenta il successo dei download con i cookie\",\n    \"cookiesTipDescription\": \"Configura i cookie per aumentare il tasso di successo dal <strong>70%</strong> al <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Configura i cookie\",\n    \"openDownloadFolder\": \"Apri Cartella Download\",\n    \"openFile\": \"Apri File\",\n    \"openFileLocation\": \"Apri Posizione File\",\n    \"openFolder\": \"Apri Cartella\",\n    \"openInBrowser\": \"Clicca per aprire nel browser\",\n    \"removeAction\": \"Rimuovi\",\n    \"removeItem\": \"Rimuovi Elemento\",\n    \"deleteFile\": \"Elimina File\",\n    \"deleteRecord\": \"Rimuovi dall'Elenco\",\n    \"select\": \"Seleziona\",\n    \"selectAll\": \"Seleziona tutto\",\n    \"selectVisible\": \"Seleziona visibili\",\n    \"selectItem\": \"Seleziona elemento\",\n    \"selectedCount\": \"{{count}} selezionati\",\n    \"selectionSummary\": \"{{selected}} di {{total}} visibili selezionati\",\n    \"stats\": {\n      \"cancelled\": \"Annullato\",\n      \"completed\": \"Completato\",\n      \"errors\": \"Errori\",\n      \"total\": \"Totale\"\n    },\n    \"status\": {\n      \"cancelled\": \"Annullato\",\n      \"completed\": \"Completato\",\n      \"error\": \"Errore\"\n    },\n    \"title\": \"Cronologia Download\"\n  },\n  \"menu\": {\n    \"about\": \"Informazioni\",\n    \"download\": \"Scarica\",\n    \"playlist\": \"Scarica Playlist\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Abbonamenti\",\n    \"preferences\": \"Preferenze\",\n    \"supportedSites\": \"Siti Supportati\",\n    \"theme\": \"Tema:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Errore nella copia negli appunti\",\n    \"downloadCompleted\": \"Download completato\",\n    \"downloadAlreadyQueued\": \"Questo download è già in corso\",\n    \"downloadFailed\": \"Download fallito\",\n    \"historyCleared\": \"Cronologia cancellata\",\n    \"historyClearFailed\": \"Impossibile cancellare la cronologia\",\n    \"itemRemoved\": \"Elemento rimosso\",\n    \"itemsRemoved\": \"{{count}} elementi rimossi\",\n    \"itemsRemoveFailed\": \"Impossibile rimuovere gli elementi selezionati\",\n    \"openFileFailed\": \"Errore nell'apertura del file\",\n    \"openFolderFailed\": \"Errore nell'apertura della cartella\",\n    \"playlistHistoryRemoved\": \"Playlist rimossa e file eliminati\",\n    \"playlistHistoryRemoveFailed\": \"Impossibile rimuovere la cronologia della playlist\",\n    \"removeFailed\": \"Errore nella rimozione dell'elemento\",\n    \"settingsSaved\": \"Impostazioni salvate\",\n    \"urlCopied\": \"URL copiato negli appunti\",\n    \"videoCopied\": \"Video copiato negli appunti\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Playlist\",\n    \"clearPreview\": \"Anteprima chiara\",\n    \"collapsedProgress\": \"Download playlist: {{completed}} / {{total}} completati\",\n    \"comingSoon\": \"La funzionalità di download playlist arriverà presto!\",\n    \"completed\": \"Playlist scaricata\",\n    \"description\": \"Scarica tutti i video da una playlist o canale YouTube\",\n    \"downloadFailed\": \"Errore nell'avvio del download playlist\",\n    \"downloadPlaylist\": \"Scarica Playlist\",\n    \"downloadType\": \"Tipo Download\",\n    \"downloading\": \"Scaricando playlist:\",\n    \"endIndex\": \"Fine\",\n    \"enterPlaylistUrl\": \"Inserisci URL Playlist\",\n    \"fetchFailed\": \"Errore nel recupero delle informazioni playlist\",\n    \"filenameFormat\": \"Formato nome file per playlist\",\n    \"folderFormat\": \"Formato nome cartella per playlist\",\n    \"foundVideos\": \"Trovati {{count}} video nella playlist\",\n    \"groupActive\": \"{{count}} attivi\",\n    \"groupCollapse\": \"Comprimi\",\n    \"groupErrors\": \"{{count}} non riuscito\",\n    \"groupExpand\": \"Espandi\",\n    \"groupSummary\": \"{{completed}} / {{total}} completati\",\n    \"linkLabel\": \"URL Playlist\",\n    \"noEntries\": \"Nessun video trovato in questa playlist\",\n    \"noEntriesInRange\": \"Nessun video nell'intervallo selezionato\",\n    \"noRangeSelected\": \"Nessun set finale: playlist completa selezionata\",\n    \"playlistUrlDescription\": \"Scarica tutti i video da una playlist in blocco\",\n    \"positionLabel\": \"Articolo {{index}} di {{total}}\",\n    \"previewButton\": \"Anteprima della playlist\",\n    \"previewFailed\": \"Impossibile visualizzare l'anteprima della playlist\",\n    \"previewSummary\": \"Anteprima degli elementi della playlist prima del download.\",\n    \"previewRequired\": \"Anteprima della playlist prima del download.\",\n    \"range\": \"Intervallo (Opzionale)\",\n    \"resetToDefault\": \"Ripristina predefinito\",\n    \"selectedRange\": \"Intervallo: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} selezionati\",\n    \"downloadCurrentRange\": \"Scarica selezionati\",\n    \"showingCount\": \"Visualizzazione di {{count}} video\",\n    \"selectEntry\": \"Seleziona voce {{index}}\",\n    \"noEntriesSelected\": \"Nessuna voce selezionata\",\n    \"startIndex\": \"Inizio (1)\",\n    \"title\": \"Scarica Playlist\",\n    \"totalVideos\": \"Video totali: {{count}}\",\n    \"untitled\": \"Playlist senza titolo\",\n    \"fetchingInfo\": \"Recupero informazioni playlist...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Informazioni\",\n    \"advanced\": \"Avanzato\",\n    \"cookiesTab\": \"Cookie\",\n    \"app\": \"Impostazioni App\",\n    \"audio\": \"Preferenze Audio\",\n    \"browserForCookies\": \"Seleziona browser per usare i cookie\",\n    \"browserForCookiesDescription\": \"Browser per estrarre i cookie per l'autenticazione\",\n    \"browserForCookiesWindowsNote\": \"Su Windows sono supportati solo i cookie di Firefox. Per altri browser, configura manualmente un file di cookie.\",\n    \"browserForCookiesProfile\": \"Nome profilo o percorso\",\n    \"browserForCookiesProfileDescription\": \"Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.\",\n    \"browserForCookiesProfilePlaceholder\": \"Nome profilo o percorso completo (opzionale)\",\n    \"browserForCookiesProfileInvalid\": \"Il percorso del profilo non è valido. Scegli la cartella del profilo del browser selezionato.\",\n    \"browserForCookiesProfileInvalidPath\": \"Quella cartella non esiste. Scegli una cartella del profilo esistente.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Nome profilo non trovato nella posizione predefinita del browser.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Nessuna posizione di profilo predefinita nota per questo browser su questa piattaforma.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Inserisci un percorso del profilo per il browser selezionato.\",\n    \"cookiesFile\": \"Archivio dei cookie\",\n    \"cookiesFileDescription\": \"File cookie formattato per Netscape da caricare per l'autenticazione\",\n    \"clearCookiesFile\": \"Chiaro\",\n    \"cookiesHelpTitle\": \"Utilizzo dei cookie\",\n    \"cookiesHelpBrowser\": \"Scegli il tuo browser qui sopra per riutilizzare automaticamente la sessione a cui hai effettuato l'accesso.\",\n    \"cookiesHelpFile\": \"Esporta un file cookie di Netscape (vedi le FAQ yt-dlp) e selezionalo qui quando necessario.\",\n    \"cookiesGuideTitle\": \"Serve una guida?\",\n    \"cookiesGuideDescription\": \"Consulta una guida passo passo per usare i cookie in VidBee.\",\n    \"cookiesGuideLink\": \"Apri la guida ai cookie\",\n    \"openLinkError\": \"Impossibile aprire il collegamento\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Usa file di configurazione\",\n    \"configFileDescription\": \"File di configurazione personalizzato per yt-dlp\",\n    \"clearConfigFile\": \"Chiaro\",\n    \"dark\": \"Scuro\",\n    \"description\": \"Configura le tue preferenze di download e impostazioni dell'app\",\n    \"directorySelectError\": \"Errore nella selezione della directory\",\n    \"downloadPath\": \"Posizione download\",\n    \"downloadPathDescription\": \"Scegli dove salvare i file scaricati\",\n    \"fileSelectError\": \"Errore nella selezione del file\",\n    \"general\": \"Generale\",\n    \"language\": \"Lingua\",\n    \"languageDescription\": \"Scegli la lingua preferita per l'interfaccia dell'applicazione\",\n    \"light\": \"Chiaro\",\n    \"hideDockIcon\": \"Nascondi l'icona del Dock\",\n    \"hideDockIconDescription\": \"Rimuovi VidBee dal Dock di macOS. \\nUtilizza la barra dei menu o l'icona nella barra delle applicazioni per riaprire l'app.\",\n    \"launchAtLogin\": \"Avvia all'avvio\",\n    \"launchAtLoginDescription\": \"Apri VidBee automaticamente dopo aver effettuato l'accesso al tuo computer.\",\n    \"launchAtLoginUnsupported\": \"L'avvio automatico è disponibile solo su macOS e Windows.\",\n    \"enableAnalytics\": \"Aiutaci a migliorare VidBee\",\n    \"enableAnalyticsDescription\": \"Condividi dati di utilizzo anonimi per aiutarci a capire come viene utilizzata l'app e dare priorità ai miglioramenti.\",\n    \"embedChapters\": \"Incorpora capitoli\",\n    \"embedChaptersDescription\": \"Aggiungi marcatori di capitolo al file quando disponibili\",\n    \"embedMetadata\": \"Incorpora metadati\",\n    \"embedMetadataDescription\": \"Scrivi titolo, artista e altri metadati quando disponibili\",\n    \"embedSubs\": \"Incorpora sottotitoli\",\n    \"embedSubsDescription\": \"Incorpora i sottotitoli nel file video (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Incorpora miniatura\",\n    \"embedThumbnailDescription\": \"Aggiungi la miniatura come copertina\",\n    \"shareWatermark\": \"Filigrana di condivisione\",\n    \"shareWatermarkDescription\": \"Aggiunge una filigrana con il titolo originale, l'autore e il marchio VidBee\",\n    \"maxConcurrentDownloads\": \"Numero massimo di download attivi\",\n    \"maxConcurrentDownloadsDescription\": \"Numero massimo di download simultanei\",\n    \"none\": \"Nessuno\",\n    \"oneClickDownload\": \"Download con Un Clic\",\n    \"oneClickDownloadDescription\": \"Abilita download con un clic con impostazioni predefinite\",\n    \"oneClickDownloadType\": \"Tipo download predefinito\",\n    \"oneClickDownloadTypeDescription\": \"Scegli il tipo di download predefinito per i download con un clic. La qualità usa il preset qui sotto.\",\n    \"oneClickQuality\": \"Qualità preferita\",\n    \"oneClickQualityDescription\": \"Seleziona il preset di qualità utilizzato per i download con un clic\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Auto\",\n      \"bad\": \"Cattivo\",\n      \"best\": \"Migliore\",\n      \"good\": \"Buono\",\n      \"normal\": \"Normale\",\n      \"worst\": \"Peggiore\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Server proxy per le richieste di rete\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Seleziona file di configurazione\",\n    \"selectPath\": \"Seleziona\",\n    \"showMoreFormats\": \"Mostra più opzioni formato\",\n    \"showMoreFormatsDescription\": \"Visualizza opzioni di formato aggiuntive nell'interfaccia\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.\"\n    },\n    \"system\": \"Sistema\",\n    \"theme\": \"Tema\",\n    \"themeDescription\": \"Scegli un tema chiaro, scuro o sistema per VidBee\",\n    \"title\": \"Impostazioni\",\n    \"tray\": {\n      \"quit\": \"Esci\",\n      \"showHome\": \"Mostra Home\"\n    },\n    \"video\": \"Preferenze Video\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Abbonamenti\",\n    \"subtitle\": \"{{count}} abbonamento{{count, plural, one {} other {s}}}\",\n    \"description\": \"Monitora automaticamente i feed RSS e accoda i nuovi download senza lavoro manuale.\",\n    \"defaults\": {\n      \"title\": \"Impostazioni predefinite dell'automazione\",\n      \"description\": \"Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.\",\n      \"downloadDirectory\": \"Scarica la directory\",\n      \"filenameTemplate\": \"Modello nome file (solo file)\",\n      \"onlyLatest\": \"Scarica solo il video più recente\",\n      \"onlyLatestDescription\": \"Se abilitato, VidBee salta gli elementi del backlog più vecchi e acquisisce solo il caricamento più recente.\"\n    },\n    \"add\": {\n      \"title\": \"Aggiungi RSS\",\n      \"description\": \"Incolla un collegamento al feed RSS. \\nVidBee rileverà automaticamente il feed.\"\n    },\n    \"fields\": {\n      \"url\": \"URL del feed\",\n      \"keywords\": \"Filtro parole chiave (separati da virgole)\",\n      \"tags\": \"Tag automatici\",\n      \"customDirectory\": \"Directory personalizzata\",\n      \"namingTemplate\": \"Modello nome file personalizzato (solo file)\",\n      \"onlyLatest\": \"Scarica solo il video più recente\",\n      \"onlyLatestDescription\": \"Ignora gli elementi del backlog e recupera solo il caricamento più recente da questo feed.\",\n      \"enabled\": \"Abilitato\",\n      \"disabled\": \"Disabilitato\",\n      \"onlyLatestShort\": \"Solo più recente\"\n    },\n    \"actions\": {\n      \"add\": \"Aggiungere\",\n      \"refresh\": \"Aggiorna\",\n      \"edit\": \"Modificare\",\n      \"remove\": \"Rimuovere\",\n      \"save\": \"Salva modifiche\",\n      \"selectDirectory\": \"Sfoglia\",\n      \"enable\": \"Abilitare\",\n      \"disable\": \"Disabilita\"\n    },\n    \"items\": {\n      \"title\": \"Ultimi caricamenti ({{count}})\",\n      \"count\": \"{{count}} articoli\",\n      \"empty\": \"Nessun elemento del feed recente trovato.\",\n      \"status\": {\n        \"queued\": \"In coda\",\n        \"notQueued\": \"Non in coda\",\n        \"pending\": \"In attesa di\",\n        \"downloading\": \"Download in corso\",\n        \"processing\": \"Elaborazione\",\n        \"completed\": \"Completato\",\n        \"error\": \"Fallito\",\n        \"cancelled\": \"Annullato\"\n      },\n      \"fromChannel\": \"Da {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Stato del download: {{status}}\",\n        \"downloadPending\": \"In attesa dei dettagli per il download...\",\n        \"notQueued\": \"Non ancora nella coda di download\"\n      },\n      \"actions\": {\n        \"open\": \"Apri nel browser\",\n        \"queue\": \"Aggiungi alla coda di download\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Sottoscrizione\",\n      \"unknown\": \"Abbonamento sconosciuto\",\n      \"noThumbnail\": \"Nessuna miniatura\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Impossibile aprire il selettore di directory.\",\n      \"missingUrl\": \"Incolla prima il collegamento al canale.\",\n      \"created\": \"Abbonamento aggiunto\",\n      \"createError\": \"Impossibile aggiungere l'abbonamento.\",\n      \"refreshStarted\": \"Aggiornamento avviato\",\n      \"removed\": \"Abbonamento rimosso\",\n      \"updated\": \"Abbonamento aggiornato\",\n      \"itemQueued\": \"Aggiunto alla coda di download\",\n      \"itemAlreadyQueued\": \"Questo video è già in coda\",\n      \"queueError\": \"Impossibile aggiungere alla coda di download.\",\n      \"openLinkError\": \"Impossibile aprire il collegamento video.\",\n      \"resolveError\": \"Impossibile risolvere l'URL del feed RSS.\",\n      \"duplicateUrl\": \"Questo feed RSS è già sottoscritto.\"\n    },\n    \"detectedFeed\": \"Feed {{platform}} rilevato -> {{feed}}\",\n    \"detecting\": \"Rilevamento alimentazione...\",\n    \"latestVideo\": \"Ultimo video: {{title}}\",\n    \"lastChecked\": \"Ultimo controllo: {{time}}\",\n    \"never\": \"Mai\",\n    \"empty\": \"Nessun abbonamento ancora. \\nAggiungi i tuoi canali preferiti per avviare il download automatico.\",\n    \"edit\": {\n      \"title\": \"Modifica {{name}}\",\n      \"description\": \"Modifica filtri, tag e sostituzioni per questo feed.\"\n    },\n    \"status\": {\n      \"title\": \"Stato\",\n      \"up-to-date\": \"Aggiornato\",\n      \"checking\": \"Controllo\",\n      \"failed\": \"Fallito\",\n      \"idle\": \"Oziare\",\n      \"tooltip\": {\n        \"updatedAt\": \"Aggiornato: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Abbonamenti automatizzati con RSSHub\",\n      \"description\": \"Combina VidBee con RSSHub per abilitare abbonamenti e download automatizzati da varie piattaforme. \\nUna volta configurato, VidBee viene eseguito in background e scarica automaticamente i video e i contenuti più recenti.\",\n      \"learnMore\": \"Ulteriori informazioni su RSSHub\",\n      \"openDocs\": \"Apri la documentazione RSSHub\",\n      \"hint\": \"Non hai l'URL del feed RSS? \\nUtilizza RSSHub per generare feed RSS per YouTube, Twitter e migliaia di altre piattaforme.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Supporta {{sites}} e altro.\",\n    \"moreDescription\": \"La lista completa yt-dlp viene aggiornata costantemente dalla comunità.\",\n    \"moreTitle\": \"Hai bisogno di un altro sito?\",\n    \"openFullList\": \"Apri lista completa siti supportati\",\n    \"pageDescription\": \"VidBee usa yt-dlp sotto il cofano per raggiungere centinaia di fonti.\",\n    \"pageIntro\": \"Ecco i servizi principali da cui le persone scaricano più spesso.\",\n    \"pageTitle\": \"Siti Supportati\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Album di artisti indipendenti e uscite della comunità.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Notizie globali, sport e clip di intrattenimento.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Video di feed, Watch e Reels da pagine pubbliche.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Contenuto di feed, Stories, Reels e Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Stream live e replay di creatori sulla piattaforma Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Talk professionali, webinar e video di apprendimento.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"Mix DJ, programmi radio e audio long-form.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Animazione giapponese, musica e archivio di trasmissioni live.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Pin di idee, Reels tutorial e video di ispirazione lifestyle.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Clip incorporati e video ospitati dalle comunità.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Traccia musicali, playlist e set DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Video brevi mobili, effetti e stream live.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Media brevi creativi e montaggi di fan.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Stream live di gaming, musica e IRL e VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Post di timeline, registrazioni Spaces e trasmissioni.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Hosting video di alta qualità per creatori e aziende.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Video long-form e livestream da creatori di tutto il mondo.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Video musicali ufficiali, album e performance live.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Piattaforme principali\",\n    \"viewAll\": \"Visualizza tutti i siti supportati\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/ja.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"アップデートを確認\",\n      \"download\": \"ダウンロード\",\n      \"email\": \"メール\",\n      \"feedback\": \"フィードバック\",\n      \"goToDownload\": \"ダウンロードページへ行く\",\n      \"openRepo\": \"GitHubリポジトリを開く\",\n      \"view\": \"表示\",\n      \"visit\": \"訪問\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"バックグラウンドで新しいリリースを自動的にダウンロードしてインストールします。\",\n    \"autoUpdateTitle\": \"自動アップデート\",\n    \"betaProgramDescription\": \"他の人より先に早期ビルドと今後の機能を受け取ります。\",\n    \"betaProgramTitle\": \"プレビューチャンネル\",\n    \"description\": \"VidBeeはElectronで構築され、yt-dlpによって動力を得る無料のオープンソースダウンローダーです。\",\n    \"followAuthorActions\": {\n      \"follow\": \"@nexmoexをフォロー\"\n    },\n    \"followAuthorDescription\": \"VidBeeの最新ニュースとアップデートを入手してください。\",\n    \"followAuthorSupport\": \"X (Twitter)で開発者をフォローして、VidBeeの最新アップデートとニュースを入手してください。\",\n    \"followAuthorTitle\": \"開発者をフォロー\",\n    \"here\": \"ここ\",\n    \"homepage\": \"ホームページ\",\n    \"notifications\": {\n      \"checkingUpdates\": \"アップデートを検索中...\",\n      \"downloadError\": \"アップデートのダウンロードに失敗\",\n      \"downloadUpdate\": \"アップデート{{version}}をダウンロードしてインストールしますか？\",\n      \"manualDownloadAction\": \"今すぐダウンロード\",\n      \"noUpdatesAvailable\": \"最新バージョンを使用しています\",\n      \"restartToUpdate\": \"今すぐ再起動してアップデートをインストールしますか？\",\n      \"restartNowAction\": \"今すぐ再起動してください\",\n      \"updateAvailable\": \"利用可能なアップデート：{{version}}\",\n      \"updateAvailableMessage\": \"新しいバージョン {{version}} が利用可能です。\\n公式サイトからダウンロードしてください。\",\n      \"updateDownloaded\": \"アップデートがダウンロードされました。再起動してインストールしてください\",\n      \"updateDownloadedVersion\": \"アップデート {{version}} をダウンロードしました。再起動してインストールしてください\",\n      \"updateError\": \"アップデートの確認に失敗：{{error}}\",\n      \"unknownErrorFallback\": \"不明なエラー\"\n    },\n    \"preferencesDescription\": \"このページを離れることなくアップデート設定を調整します。\",\n    \"preferencesTitle\": \"クイックトグル\",\n    \"resources\": {\n      \"changelog\": \"リリースノート\",\n      \"changelogDescription\": \"各バージョンで何が変更されたかを追跡します。\",\n      \"contact\": \"メールサポート\",\n      \"contactDescription\": \"ヘルプやコラボレーションのために直接連絡してください。\",\n      \"documentation\": \"ヘルプセンター\",\n      \"documentationDescription\": \"ガイド、FAQ、一般的なワークフロー。\",\n      \"feedback\": \"フィードバックと問題\",\n      \"feedbackDescription\": \"GitHubでアイデアを共有したり問題を報告したりしてください。\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"GitHub でバグ報告や機能要望を送ってください。\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"X で @nexmoex に言及してフィードバックや提案を共有してください。\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Discord コミュニティに参加して、議論やサポートを受けてください。\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"よくある質問とトラブルシューティングガイド。\",\n      \"license\": \"ライセンス\",\n      \"licenseDescription\": \"オープンソースライセンス条項を確認してください。\",\n      \"website\": \"公式ウェブサイト\",\n      \"websiteDescription\": \"製品のハイライト、ロードマップ、コミュニティニュース。\"\n    },\n    \"resourcesDescription\": \"VidBeeについてもっと学び、つながりを保つための有用なリンク。\",\n    \"resourcesTitle\": \"リソース\",\n    \"shareActions\": {\n      \"copy\": \"リンクをコピー\",\n      \"facebook\": \"Facebookで共有\",\n      \"twitter\": \"X (Twitter)で共有\"\n    },\n    \"shareDescription\": \"ワンクリックでコミュニティとVidBeeを共有してください。\",\n    \"shareSupport\": \"私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。\",\n    \"shareTitle\": \"口コミを広める\",\n    \"sourceCode\": \"ソースコードが利用可能\",\n    \"title\": \"について\",\n    \"version\": \"バージョン\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"最新: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"新しいバージョンが利用可能\",\n      \"uptodate\": \"最新バージョンを使用中\",\n      \"error\": \"最新バージョンを取得できません\"\n    },\n    \"downloadingUpdate\": \"アップデートをダウンロードしています\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"ダウンロード完了時にアプリを閉じる\",\n    \"currentLocation\": \"現在のダウンロード場所 - \",\n    \"downloadLocation\": \"ダウンロード場所\",\n    \"downloadSubs\": \"利用可能な場合は字幕をダウンロード\",\n    \"downloadSubsHint\": \"利用可能な場合は字幕を別ファイルとして保存\",\n    \"end\": \"終了\",\n    \"endHint\": \"空のままにすると最後までダウンロードされます\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"ダウンロード場所を選択\",\n    \"start\": \"開始\",\n    \"startHint\": \"空のままにすると最初から開始されます\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"字幕\",\n    \"timeRange\": \"特定の時間範囲をダウンロード\",\n    \"title\": \"高度なオプション\"\n  },\n  \"app\": {\n    \"description\": \"数百のサイトからビデオとオーディオをダウンロード\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"アクティブ\",\n    \"all\": \"すべて\",\n    \"audio\": \"オーディオ\",\n    \"back\": \"戻る\",\n    \"cancel\": \"キャンセル\",\n    \"cancelled\": \"キャンセル済み\",\n    \"clearCompleted\": \"完了をクリア\",\n    \"clearDownloads\": \"ダウンロードをクリア\",\n    \"completed\": \"完了\",\n    \"downloadAudio\": \"オーディオをダウンロード\",\n    \"downloadBtn\": \"ダウンロード\",\n    \"downloadPending\": \"保留中\",\n    \"downloadQueue\": \"ダウンロードキュー\",\n    \"customDownloadFolder\": \"カスタムダウンロードフォルダー\",\n    \"retry\": \"ダウンロードを再試行\",\n    \"autoFolderPlaceholder\": \"自動フォルダー（メタデータに基づく）\",\n    \"autoFolderHint\": \"自動フォルダーはメタデータから作成されます。\",\n    \"useAutoFolder\": \"自動フォルダーを使用\",\n    \"downloadVideo\": \"ビデオをダウンロード\",\n    \"downloading\": \"ダウンロード中...\",\n    \"enterUrl\": \"ビデオURLを入力\",\n    \"enterUrlDescription\": \"ビデオURLを貼り付けまたは入力してください。 \",\n    \"error\": \"エラー\",\n    \"fetch\": \"取得\",\n    \"fetchingVideoInfo\": \"ビデオ情報を取得中...\",\n    \"feedback\": {\n      \"title\": \"このエラーを報告:\",\n      \"githubUrlTooLong\": \"このGitHubリンクは非常に長いです。開けない場合は、issueページを開いてログを手動で貼り付けてください。\"\n    },\n    \"history\": \"履歴\",\n    \"imageLoadError\": \"画像の読み込みに失敗\",\n    \"imagePlaceholder\": \"利用可能な画像なし\",\n    \"infoUnavailable\": \"ワンクリックダウンロード（情報利用不可）\",\n    \"loading\": \"読み込み中\",\n    \"moreOptions\": \"その他のオプション\",\n    \"noActiveDownloads\": \"アクティブなダウンロードなし\",\n    \"noAudio\": \"オーディオなし\",\n    \"noHistory\": \"ダウンロード履歴なし\",\n    \"noItems\": \"アイテムが見つかりません\",\n    \"goToSettings\": \"設定に移動\",\n    \"oneClickDownload\": \"ワンクリックダウンロード\",\n    \"oneClickDownloadDescription\": \"確認なしでデフォルト設定で直接ダウンロード\",\n    \"oneClickDownloadTooltip\": \"貼り付けて即ダウンロード、手順を省略\",\n    \"oneClickDownloadNow\": \"今すぐダウンロード\",\n    \"oneClickDownloadStarted\": \"デフォルト設定でダウンロード開始\",\n    \"paste\": \"貼り付け\",\n    \"pastePlaylistUrl\": \"クリップボードからプレイリストリンクを貼り付け [Ctrl + V]\",\n    \"pasteUrl\": \"ビデオURLまたはIDを貼り付け [Ctrl + V]\",\n    \"pasteUrlButton\": \"URL を貼り付け\",\n    \"preparing\": \"準備中...\",\n    \"processing\": \"処理中\",\n    \"progress\": \"進行状況\",\n    \"showDetails\": \"詳細を表示\",\n    \"hideDetails\": \"詳細を隠す\",\n    \"viewLogs\": \"ログを表示\",\n    \"detailsTab\": \"詳細\",\n    \"logsTab\": \"ログ\",\n    \"logs\": {\n      \"live\": \"ライブログ\",\n      \"history\": \"保存済みログ\",\n      \"command\": \"yt-dlp コマンド\",\n      \"empty\": \"ログはまだありません。\",\n      \"scrollPaused\": \"スクロールを一時停止\"\n    },\n    \"selectAudioFormat\": \"オーディオフォーマットを選択\",\n    \"selectDownloadType\": \"ダウンロードの種類を選択\",\n    \"selectFormat\": \"フォーマットを選択\",\n    \"startDownload\": \"ダウンロードを開始\",\n    \"selectVideoFormat\": \"ビデオフォーマットを選択\",\n    \"singleVideo\": \"単一ビデオ\",\n    \"speed\": \"速度\",\n    \"title\": \"タイトル\",\n    \"total\": \"合計\",\n    \"unknownQuality\": \"不明な品質\",\n    \"unknownSize\": \"不明なサイズ\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"ビデオ\",\n    \"videoInfo\": \"ビデオ情報\",\n    \"videoInfoUpdated\": \"ビデオ情報が更新されました\",\n    \"metadata\": {\n      \"source\": \"ソース\",\n      \"playlist\": \"プレイリスト\",\n      \"format\": \"形式\",\n      \"quality\": \"品質\",\n      \"codec\": \"コーデック\",\n      \"savedFile\": \"保存されたファイル\",\n      \"url\": \"ソースURL\",\n      \"description\": \"説明\",\n      \"views\": \"ビュー\",\n      \"tags\": \"タグ\",\n      \"downloadPath\": \"ダウンロードパス\",\n      \"createdAt\": \"で作成されました\",\n      \"startedAt\": \"に開始\",\n      \"completedAt\": \"完了時刻\",\n      \"speed\": \"スピード\",\n      \"fileSize\": \"ファイルサイズ\",\n      \"width\": \"幅\",\n      \"height\": \"身長\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"ビデオコーデック\",\n      \"audioCodec\": \"オーディオコーデック\",\n      \"formatNote\": \"メモのフォーマット\",\n      \"protocol\": \"プロトコル\",\n      \"subscription\": \"サブスクリプション\"\n    }\n  },\n  \"error\": {\n    \"title\": \"問題が発生しました\",\n    \"description\": \"予期しないエラーが発生しました。アプリを再読み込みするか、問題が続く場合は報告してください。\",\n    \"message\": \"エラーメッセージ\",\n    \"unknownError\": \"不明なエラーが発生しました\",\n    \"goHome\": \"ホームへ\",\n    \"reload\": \"アプリを再読み込み\",\n    \"copyReport\": \"エラーレポートをコピー\",\n    \"copied\": \"コピーしました！\",\n    \"copySuccess\": \"エラーレポートをクリップボードにコピーしました\",\n    \"copyFailed\": \"エラーレポートのコピーに失敗しました\",\n    \"showDetails\": \"詳細を表示\",\n    \"hideDetails\": \"詳細を非表示\",\n    \"stackTrace\": \"スタックトレース\",\n    \"componentStack\": \"コンポーネントスタック\",\n    \"noStackTrace\": \"利用可能なスタックトレースはありません\",\n    \"fullReport\": \"完全なエラーレポート\",\n    \"helpText\": \"このエラーが続く場合は、上記のエラーレポートをコピーしてサポートチームに共有してください。連絡先は「概要」ページにあります。\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"詳細をコピーするにはクリック\",\n    \"clipboardEmpty\": \"クリップボードが空です\",\n    \"downloadFailed\": \"ダウンロードに失敗\",\n    \"downloadNecessaryFilesFailed\": \"必要なファイルのダウンロードに失敗しました。ネットワークを確認して再試行してください\",\n    \"emptyUrl\": \"URLを入力してください\",\n    \"errorDetails\": \"エラーの詳細\",\n    \"fetchInfoFailed\": \"ビデオ情報の取得に失敗\",\n    \"invalidUrl\": \"クリップボードの内容が有効なURLではありません\",\n    \"networkError\": \"エラーが発生しました。ネットワークを確認し、正しいURLを使用してください\",\n    \"pasteFromClipboard\": \"クリップボードからの貼り付けに失敗\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"キャンセル済みをクリア\",\n    \"clearCompleted\": \"完了をクリア\",\n    \"clearErrors\": \"エラーをクリア\",\n    \"clearAll\": \"履歴をすべてクリア\",\n    \"clearAllAction\": \"履歴をクリア\",\n    \"clearSelection\": \"選択をクリア\",\n    \"confirmClearAllTitle\": \"履歴をすべてクリアしますか？\",\n    \"confirmClearAllDescription\": \"履歴から {{count}} 件を削除します。ファイルはディスクに残ります。\",\n    \"confirmDeleteSelectedTitle\": \"選択した項目を削除しますか？\",\n    \"confirmDeleteSelectedDescription\": \"履歴から {{count}} 件を削除します。ファイルはディスクに残ります。\",\n    \"alsoDeleteFiles\": \"ファイルも削除\",\n    \"confirmDeletePlaylistTitle\": \"プレイリスト履歴を削除しますか？\",\n    \"confirmDeletePlaylistDescription\": \"{{title}} から {{count}} 件を削除し、ファイルを削除します。\",\n    \"copyToClipboard\": \"クリップボードにコピー\",\n    \"copyUrl\": \"URLをコピー\",\n    \"date\": \"日付\",\n    \"deletePlaylist\": \"プレイリストを削除\",\n    \"deleteSelected\": \"選択した項目を削除\",\n    \"description\": \"ダウンロード履歴を表示および管理\",\n    \"doneSelecting\": \"完了\",\n    \"duration\": \"期間\",\n    \"fileSize\": \"ファイルサイズ\",\n    \"filters\": {\n      \"all\": \"すべて\",\n      \"cancelled\": \"キャンセル済み\",\n      \"completed\": \"完了\",\n      \"errors\": \"エラー\"\n    },\n    \"noHistory\": \"ダウンロード履歴はまだありません\",\n    \"noHistoryDescription\": \"完了したダウンロードがここに表示されます\",\n    \"cookiesTipTitle\": \"Cookie を設定して成功率を向上\",\n    \"cookiesTipDescription\": \"Cookie を設定すると成功率が <strong>70%</strong> から <strong>99%</strong> に向上します。\",\n    \"cookiesTipCta\": \"Cookie を設定\",\n    \"openDownloadFolder\": \"ダウンロードフォルダを開く\",\n    \"openFile\": \"ファイルを開く\",\n    \"openFileLocation\": \"ファイルの場所を開く\",\n    \"openFolder\": \"フォルダを開く\",\n    \"openInBrowser\": \"ブラウザで開くにはクリック\",\n    \"removeAction\": \"削除\",\n    \"removeItem\": \"アイテムを削除\",\n    \"deleteFile\": \"ファイルを削除\",\n    \"deleteRecord\": \"リストから削除\",\n    \"select\": \"選択\",\n    \"selectAll\": \"すべて選択\",\n    \"selectVisible\": \"表示中を選択\",\n    \"selectItem\": \"項目を選択\",\n    \"selectedCount\": \"{{count}} 件選択\",\n    \"selectionSummary\": \"{{total}} 件中 {{selected}} 件を選択\",\n    \"stats\": {\n      \"cancelled\": \"キャンセル済み\",\n      \"completed\": \"完了\",\n      \"errors\": \"エラー\",\n      \"total\": \"合計\"\n    },\n    \"status\": {\n      \"cancelled\": \"キャンセル済み\",\n      \"completed\": \"完了\",\n      \"error\": \"エラー\"\n    },\n    \"title\": \"ダウンロード履歴\"\n  },\n  \"menu\": {\n    \"about\": \"について\",\n    \"download\": \"ダウンロード\",\n    \"playlist\": \"プレイリストをダウンロード\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"定期購入\",\n    \"preferences\": \"設定\",\n    \"supportedSites\": \"サポートされているサイト\",\n    \"theme\": \"テーマ：\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"クリップボードへのコピーに失敗\",\n    \"downloadCompleted\": \"ダウンロード完了\",\n    \"downloadAlreadyQueued\": \"このダウンロードはすでに進行中です\",\n    \"downloadFailed\": \"ダウンロードに失敗\",\n    \"historyCleared\": \"履歴をクリアしました\",\n    \"historyClearFailed\": \"履歴のクリアに失敗しました\",\n    \"itemRemoved\": \"アイテムが削除されました\",\n    \"itemsRemoved\": \"{{count}} 件を削除しました\",\n    \"itemsRemoveFailed\": \"選択した項目の削除に失敗しました\",\n    \"openFileFailed\": \"ファイルの開封に失敗\",\n    \"openFolderFailed\": \"フォルダの開封に失敗\",\n    \"playlistHistoryRemoved\": \"プレイリストを削除し、ファイルを削除しました\",\n    \"playlistHistoryRemoveFailed\": \"プレイリスト履歴の削除に失敗しました\",\n    \"removeFailed\": \"アイテムの削除に失敗\",\n    \"settingsSaved\": \"設定が保存されました\",\n    \"urlCopied\": \"URLがクリップボードにコピーされました\",\n    \"videoCopied\": \"ビデオがクリップボードにコピーされました\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"プレイリスト\",\n    \"clearPreview\": \"プレビューをクリアする\",\n    \"collapsedProgress\": \"プレイリストをダウンロード中: {{completed}} / {{total}} 完了\",\n    \"comingSoon\": \"プレイリストダウンロード機能がまもなく登場します！\",\n    \"completed\": \"プレイリストがダウンロードされました\",\n    \"description\": \"YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード\",\n    \"downloadFailed\": \"プレイリストダウンロードの開始に失敗\",\n    \"downloadPlaylist\": \"プレイリストをダウンロード\",\n    \"downloadType\": \"ダウンロードタイプ\",\n    \"downloading\": \"プレイリストをダウンロード中：\",\n    \"endIndex\": \"終了\",\n    \"enterPlaylistUrl\": \"プレイリストURLを入力\",\n    \"fetchFailed\": \"プレイリスト情報の取得に失敗\",\n    \"filenameFormat\": \"プレイリスト用ファイル名フォーマット\",\n    \"folderFormat\": \"プレイリスト用フォルダ名フォーマット\",\n    \"foundVideos\": \"プレイリストで{{count}}個のビデオを発見\",\n    \"groupActive\": \"{{count}} 個がアクティブです\",\n    \"groupCollapse\": \"折りたたむ\",\n    \"groupErrors\": \"{{count}} 回失敗しました\",\n    \"groupExpand\": \"展開\",\n    \"groupSummary\": \"{{completed}} / {{total}} 完了\",\n    \"linkLabel\": \"プレイリストURL\",\n    \"noEntries\": \"このプレイリストにはビデオが見つかりませんでした\",\n    \"noEntriesInRange\": \"選択した範囲にビデオがありません\",\n    \"noRangeSelected\": \"終了セットなし - 完全なプレイリストが選択されています\",\n    \"playlistUrlDescription\": \"プレイリストからすべてのビデオを一括ダウンロード\",\n    \"positionLabel\": \"{{total}} 中のアイテム {{index}}\",\n    \"previewButton\": \"プレイリストをプレビューする\",\n    \"previewFailed\": \"プレイリストのプレビューに失敗しました\",\n    \"previewSummary\": \"ダウンロードする前にプレイリスト項目をプレビューします。\",\n    \"previewRequired\": \"ダウンロードする前にプレイリストをプレビューします。\",\n    \"range\": \"範囲（オプション）\",\n    \"resetToDefault\": \"デフォルトにリセット\",\n    \"selectedRange\": \"範囲: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} 件選択\",\n    \"downloadCurrentRange\": \"選択項目をダウンロード\",\n    \"showingCount\": \"{{count}} 本の動画を表示しています\",\n    \"selectEntry\": \"項目 {{index}} を選択\",\n    \"noEntriesSelected\": \"選択された項目はありません\",\n    \"startIndex\": \"開始（1）\",\n    \"title\": \"プレイリストをダウンロード\",\n    \"totalVideos\": \"合計動画: {{count}}\",\n    \"untitled\": \"無題のプレイリスト\",\n    \"fetchingInfo\": \"プレイリスト情報を取得中...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"について\",\n    \"advanced\": \"高度\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"アプリ設定\",\n    \"audio\": \"オーディオ設定\",\n    \"browserForCookies\": \"Cookieに使用するブラウザを選択\",\n    \"browserForCookiesDescription\": \"認証用のCookieを抽出するブラウザ\",\n    \"browserForCookiesWindowsNote\": \"Windows では Firefox の Cookie のみ対応しています。他のブラウザは Cookie ファイルを手動で設定してください。\",\n    \"browserForCookiesProfile\": \"プロファイル名またはパス\",\n    \"browserForCookiesProfileDescription\": \"上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。\",\n    \"browserForCookiesProfilePlaceholder\": \"プロファイル名または完全なパス（任意）\",\n    \"browserForCookiesProfileInvalid\": \"プロファイルパスが無効です。選択したブラウザのプロファイルフォルダーを選んでください。\",\n    \"browserForCookiesProfileInvalidPath\": \"そのフォルダーは存在しません。既存のプロファイルフォルダーを選んでください。\",\n    \"browserForCookiesProfileInvalidProfile\": \"既定のブラウザ場所にプロファイル名が見つかりません。\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"このプラットフォームではこのブラウザの既定のプロファイル場所が不明です。\",\n    \"browserForCookiesProfileInvalidEmpty\": \"選択したブラウザのプロファイルパスを入力してください。\",\n    \"cookiesFile\": \"Cookies ファイル\",\n    \"cookiesFileDescription\": \"認証のためにロードする Netscape 形式の Cookie ファイル\",\n    \"clearCookiesFile\": \"クリア\",\n    \"cookiesHelpTitle\": \"Cookies の使用\",\n    \"cookiesHelpBrowser\": \"サインイン セッションを自動的に再利用するには、上記のブラウザーを選択してください。\",\n    \"cookiesHelpFile\": \"Netscape Cookie ファイルをエクスポートし (yt-dlp FAQ を参照)、必要に応じてここで選択します。\",\n    \"cookiesGuideTitle\": \"使い方の案内が必要ですか？\",\n    \"cookiesGuideDescription\": \"VidBeeでクッキーを使う手順を確認できます。\",\n    \"cookiesGuideLink\": \"Cookies ガイドを開く\",\n    \"openLinkError\": \"リンクを開けませんでした\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"設定ファイルを使用\",\n    \"configFileDescription\": \"yt-dlp用のカスタム設定ファイル\",\n    \"clearConfigFile\": \"クリア\",\n    \"dark\": \"ダーク\",\n    \"description\": \"ダウンロード設定とアプリ設定を構成\",\n    \"directorySelectError\": \"ディレクトリの選択に失敗\",\n    \"downloadPath\": \"ダウンロード場所\",\n    \"downloadPathDescription\": \"ダウンロードファイルの保存場所を選択\",\n    \"fileSelectError\": \"ファイルの選択に失敗\",\n    \"general\": \"一般\",\n    \"language\": \"言語\",\n    \"languageDescription\": \"アプリのインターフェースに使用する言語を選択してください\",\n    \"light\": \"ライト\",\n    \"hideDockIcon\": \"ドックアイコンを非表示にする\",\n    \"hideDockIconDescription\": \"VidBee を macOS Dock から削除します。\\nメニュー バーまたはトレイ アイコンを使用して、アプリを再度開きます。\",\n    \"launchAtLogin\": \"起動時に起動する\",\n    \"launchAtLoginDescription\": \"コンピューターにサインインした後、VidBee を自動的に開きます。\",\n    \"launchAtLoginUnsupported\": \"自動起動は macOS と Windows でのみ利用できます。\",\n    \"enableAnalytics\": \"VidBee の改善にご協力ください\",\n    \"enableAnalyticsDescription\": \"匿名の使用状況データを共有することで、アプリの使用状況を把握し、改善の優先順位を付けることができます。\",\n    \"embedChapters\": \"チャプターを埋め込む\",\n    \"embedChaptersDescription\": \"利用可能な場合はファイルにチャプターマーカーを追加\",\n    \"embedMetadata\": \"メタデータを埋め込む\",\n    \"embedMetadataDescription\": \"利用可能な場合はタイトルやアーティストなどのメタデータを書き込む\",\n    \"embedSubs\": \"字幕を埋め込む\",\n    \"embedSubsDescription\": \"字幕を動画ファイルに埋め込む（mp4、webm、mkv）\",\n    \"embedThumbnail\": \"サムネイルを埋め込む\",\n    \"embedThumbnailDescription\": \"サムネイルをカバーアートとして追加\",\n    \"shareWatermark\": \"共有用ウォーターマーク\",\n    \"shareWatermarkDescription\": \"元のタイトル、作者、VidBee のブランド名を含むウォーターマークを追加します\",\n    \"maxConcurrentDownloads\": \"最大アクティブダウンロード数\",\n    \"maxConcurrentDownloadsDescription\": \"最大同時ダウンロード数\",\n    \"none\": \"なし\",\n    \"oneClickDownload\": \"ワンクリックダウンロード\",\n    \"oneClickDownloadDescription\": \"デフォルト設定でワンクリックダウンロードを有効化\",\n    \"oneClickDownloadType\": \"デフォルトダウンロードタイプ\",\n    \"oneClickDownloadTypeDescription\": \"ワンクリックダウンロードのデフォルトダウンロードタイプを選択。品質は下のプリセットを使用。\",\n    \"oneClickQuality\": \"優先品質\",\n    \"oneClickQualityDescription\": \"ワンクリックダウンロードに使用される品質プリセットを選択\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"自動\",\n      \"bad\": \"悪い\",\n      \"best\": \"最高\",\n      \"good\": \"良い\",\n      \"normal\": \"通常\",\n      \"worst\": \"最悪\"\n    },\n    \"proxy\": \"プロキシ\",\n    \"proxyDescription\": \"ネットワークリクエスト用のプロキシサーバー\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"設定ファイルを選択\",\n    \"selectPath\": \"選択\",\n    \"showMoreFormats\": \"より多くのフォーマットオプションを表示\",\n    \"showMoreFormatsDescription\": \"インターフェースに追加のフォーマットオプションを表示\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。\"\n    },\n    \"system\": \"システム\",\n    \"theme\": \"テーマ\",\n    \"themeDescription\": \"VidBeeのライト、ダーク、またはシステムテーマを選択\",\n    \"title\": \"設定\",\n    \"tray\": {\n      \"quit\": \"終了\",\n      \"showHome\": \"ホームを表示\"\n    },\n    \"video\": \"ビデオ設定\"\n  },\n  \"subscriptions\": {\n    \"title\": \"定期購入\",\n    \"subtitle\": \"{{count}} 件のサブスクリプション{{count、複数、one {} other {s}}}\",\n    \"description\": \"RSS フィードを自動的に監視し、手動作業なしで新しいダウンロードをキューに追加します。\",\n    \"defaults\": {\n      \"title\": \"自動化のデフォルト\",\n      \"description\": \"サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。\",\n      \"downloadDirectory\": \"ダウンロードディレクトリ\",\n      \"filenameTemplate\": \"ファイル名のテンプレート (ファイルのみ)\",\n      \"onlyLatest\": \"最新のビデオのみをダウンロードする\",\n      \"onlyLatestDescription\": \"有効にすると、VidBee は古いバックログ項目をスキップし、最新のアップロードのみを取得します。\"\n    },\n    \"add\": {\n      \"title\": \"RSSを追加\",\n      \"description\": \"RSS フィードのリンクを貼り付けます。 \\nVidBee はフィードを自動的に検出します。\"\n    },\n    \"fields\": {\n      \"url\": \"フィード URL\",\n      \"keywords\": \"キーワードフィルター (カンマ区切り)\",\n      \"tags\": \"自動タグ\",\n      \"customDirectory\": \"カスタムディレクトリ\",\n      \"namingTemplate\": \"カスタム ファイル名テンプレート (ファイルのみ)\",\n      \"onlyLatest\": \"最新のビデオのみをダウンロードする\",\n      \"onlyLatestDescription\": \"バックログ項目を無視し、このフィードから最新のアップロードのみを取得します。\",\n      \"enabled\": \"有効\",\n      \"disabled\": \"無効\",\n      \"onlyLatestShort\": \"最新のもののみ\"\n    },\n    \"actions\": {\n      \"add\": \"追加\",\n      \"refresh\": \"リフレッシュ\",\n      \"edit\": \"編集\",\n      \"remove\": \"取り除く\",\n      \"save\": \"変更を保存する\",\n      \"selectDirectory\": \"ブラウズ\",\n      \"enable\": \"有効にする\",\n      \"disable\": \"無効にする\"\n    },\n    \"items\": {\n      \"title\": \"最新のアップロード ({{count}})\",\n      \"count\": \"{{count}} 個のアイテム\",\n      \"empty\": \"最近のフィード項目が見つかりませんでした。\",\n      \"status\": {\n        \"queued\": \"キューに入れられました\",\n        \"notQueued\": \"キューに登録されていません\",\n        \"pending\": \"保留中\",\n        \"downloading\": \"ダウンロード中\",\n        \"processing\": \"処理\",\n        \"completed\": \"完了\",\n        \"error\": \"失敗した\",\n        \"cancelled\": \"キャンセル\"\n      },\n      \"fromChannel\": \"{{channel}} から\",\n      \"tooltip\": {\n        \"downloadStatus\": \"ダウンロードステータス: {{status}}\",\n        \"downloadPending\": \"ダウンロードの詳細を待っています...\",\n        \"notQueued\": \"まだダウンロードキューにありません\"\n      },\n      \"actions\": {\n        \"open\": \"ブラウザで開く\",\n        \"queue\": \"ダウンロードキューに追加\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"サブスクリプション\",\n      \"unknown\": \"不明なサブスクリプション\",\n      \"noThumbnail\": \"サムネイルなし\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"ディレクトリピッカーを開けませんでした。\",\n      \"missingUrl\": \"まずチャンネルのリンクを貼り付けてください。\",\n      \"created\": \"サブスクリプションが追加されました\",\n      \"createError\": \"サブスクリプションの追加に失敗しました。\",\n      \"refreshStarted\": \"更新が開始されました\",\n      \"removed\": \"サブスクリプションが削除されました\",\n      \"updated\": \"サブスクリプションが更新されました\",\n      \"itemQueued\": \"ダウンロードキューに追加されました\",\n      \"itemAlreadyQueued\": \"このビデオはすでにキューに登録されています\",\n      \"queueError\": \"ダウンロードキューへの追加に失敗しました。\",\n      \"openLinkError\": \"ビデオリンクを開けませんでした。\",\n      \"resolveError\": \"RSS フィード URL を解決できませんでした。\",\n      \"duplicateUrl\": \"このRSSフィードはすでに登録されています。\"\n    },\n    \"detectedFeed\": \"{{platform}} フィードが検出されました -> {{feed}}\",\n    \"detecting\": \"フィードを検出中...\",\n    \"latestVideo\": \"最新の動画: {{title}}\",\n    \"lastChecked\": \"最終チェック日: {{time}}\",\n    \"never\": \"一度もない\",\n    \"empty\": \"まだ購読はありません。\\nお気に入りのチャンネルを追加して自動ダウンロードを開始します。\",\n    \"edit\": {\n      \"title\": \"{{name}}を編集\",\n      \"description\": \"このフィードのフィルター、タグ、オーバーライドを調整します。\"\n    },\n    \"status\": {\n      \"title\": \"状態\",\n      \"up-to-date\": \"最新の\",\n      \"checking\": \"チェック中\",\n      \"failed\": \"失敗した\",\n      \"idle\": \"アイドル状態\",\n      \"tooltip\": {\n        \"updatedAt\": \"更新日: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"RSSHub による自動サブスクリプション\",\n      \"description\": \"VidBee と RSSHub を組み合わせると、さまざまなプラットフォームからの自動サブスクリプションとダウンロードが可能になります。\\nセットアップが完了すると、VidBee がバックグラウンドで実行され、最新のビデオとコンテンツが自動的にダウンロードされます。\",\n      \"learnMore\": \"RSSHub について詳しく見る\",\n      \"openDocs\": \"RSSHub ドキュメントを開く\",\n      \"hint\": \"RSS フィード URL をお持ちでない場合は、 \\nRSSHub を使用して、YouTube、Twitter、その他数千のプラットフォーム用の RSS フィードを生成します。\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"{{sites}}およびその他のサイトをサポートしています。\",\n    \"moreDescription\": \"完全なyt-dlpリストはコミュニティによって継続的に更新されています。\",\n    \"moreTitle\": \"他のサイトが必要ですか？\",\n    \"openFullList\": \"サポートされているすべてのサイトリストを開く\",\n    \"pageDescription\": \"VidBeeは数百のソースに到達するためにyt-dlpをバックグラウンドで使用します。\",\n    \"pageIntro\": \"人々が最も頻繁にダウンロードする主要サービスです。\",\n    \"pageTitle\": \"サポートされているサイト\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"インディペンデントアーティストのアルバムとコミュニティリリース。\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"グローバルニュース、スポーツ、エンターテイメントクリップ。\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"パブリックページからのフィード、Watch、Reels動画。\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"フィード、Stories、Reels、ハイライトコンテンツ。\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Kickプラットフォームでのクリエイターライブストリームとリプレイ。\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"プロフェッショナルトーク、ウェビナー、学習動画。\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJミックス、ラジオ番組、ロングフォームオーディオ。\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"日本のアニメーション、音楽、ライブ放送アーカイブ。\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"アイデアピン、ハウツーReels、ライフスタイルインスピレーション動画。\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"コミュニティからの埋め込みクリップとホスト動画。\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"音楽トラック、プレイリスト、DJセット。\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"ショートフォームモバイル動画、エフェクト、ライブストリーム。\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"クリエイティブショートフォームメディアとファン編集。\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"ゲーミング、音楽、IRLライブストリームとVOD。\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"タイムラインポスト、Spaces録音、放送。\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"クリエイターとビジネス向け高品質動画ホスティング。\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"世界中のクリエイターからのロングフォームとライブストリーム動画。\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"公式ミュージックビデオ、アルバム、ライブパフォーマンス。\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"主要プラットフォーム\",\n    \"viewAll\": \"サポートされているすべてのサイトを表示\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/ko.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"업데이트 확인\",\n      \"download\": \"다운로드\",\n      \"email\": \"이메일\",\n      \"feedback\": \"피드백\",\n      \"goToDownload\": \"다운로드 페이지로 이동\",\n      \"openRepo\": \"GitHub 저장소 열기\",\n      \"view\": \"보기\",\n      \"visit\": \"방문\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"백그라운드에서 새 릴리스를 자동으로 다운로드하고 설치합니다.\",\n    \"autoUpdateTitle\": \"자동 업데이트\",\n    \"betaProgramDescription\": \"다른 사람들보다 먼저 초기 빌드와 다가오는 기능을 받으세요.\",\n    \"betaProgramTitle\": \"미리보기 채널\",\n    \"description\": \"VidBee는 Electron으로 구축되고 yt-dlp로 구동되는 무료 오픈소스 다운로더입니다.\",\n    \"followAuthorActions\": {\n      \"follow\": \"@nexmoex 팔로우\"\n    },\n    \"followAuthorDescription\": \"VidBee의 최신 뉴스와 업데이트를 받아보세요.\",\n    \"followAuthorSupport\": \"X (Twitter)에서 개발자를 팔로우하여 VidBee의 최신 업데이트와 뉴스를 받아보세요.\",\n    \"followAuthorTitle\": \"개발자 팔로우\",\n    \"here\": \"여기\",\n    \"homepage\": \"홈페이지\",\n    \"notifications\": {\n      \"checkingUpdates\": \"업데이트 검색 중...\",\n      \"downloadError\": \"업데이트 다운로드 실패\",\n      \"downloadUpdate\": \"업데이트 {{version}}을(를) 다운로드하고 설치하시겠습니까?\",\n      \"manualDownloadAction\": \"지금 다운로드\",\n      \"noUpdatesAvailable\": \"최신 버전을 사용 중입니다\",\n      \"restartToUpdate\": \"지금 재시작하여 업데이트를 설치하시겠습니까?\",\n      \"restartNowAction\": \"지금 다시 시작\",\n      \"updateAvailable\": \"사용 가능한 업데이트: {{version}}\",\n      \"updateAvailableMessage\": \"새 버전 {{version}}을(를) 사용할 수 있습니다. \\n공식 홈페이지에서 다운로드해주세요.\",\n      \"updateDownloaded\": \"업데이트가 다운로드되었습니다. 재시작하여 설치하세요\",\n      \"updateDownloadedVersion\": \"업데이트 {{version}}을(를) 다운로드했습니다. 설치하려면 다시 시작하세요.\",\n      \"updateError\": \"업데이트 확인 실패: {{error}}\",\n      \"unknownErrorFallback\": \"알 수 없는 오류\"\n    },\n    \"preferencesDescription\": \"이 페이지를 떠나지 않고 업데이트 설정을 조정하세요.\",\n    \"preferencesTitle\": \"빠른 토글\",\n    \"resources\": {\n      \"changelog\": \"릴리스 노트\",\n      \"changelogDescription\": \"각 버전에서 변경된 내용을 확인하세요.\",\n      \"contact\": \"이메일 지원\",\n      \"contactDescription\": \"도움이나 협업을 위해 직접 연락하세요.\",\n      \"documentation\": \"도움말 센터\",\n      \"documentationDescription\": \"가이드, FAQ 및 일반적인 워크플로우.\",\n      \"feedback\": \"피드백 및 문제\",\n      \"feedbackDescription\": \"GitHub에서 아이디어를 공유하거나 문제를 신고하세요.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"GitHub에서 버그를 보고하거나 기능을 요청하세요.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"X에서 @nexmoex를 멘션하여 피드백이나 제안을 공유하세요.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Discord 커뮤니티에 참여해 토론과 지원을 받으세요.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"자주 묻는 질문 및 문제 해결 가이드.\",\n      \"license\": \"라이선스\",\n      \"licenseDescription\": \"오픈소스 라이선스 조건을 검토하세요.\",\n      \"website\": \"공식 웹사이트\",\n      \"websiteDescription\": \"제품 하이라이트, 로드맵 및 커뮤니티 뉴스.\"\n    },\n    \"resourcesDescription\": \"VidBee에 대해 더 알아보고 연결을 유지하는 유용한 링크입니다.\",\n    \"resourcesTitle\": \"리소스\",\n    \"shareActions\": {\n      \"copy\": \"링크 복사\",\n      \"facebook\": \"Facebook에서 공유\",\n      \"twitter\": \"X (Twitter)에서 공유\"\n    },\n    \"shareDescription\": \"한 번의 클릭으로 커뮤니티와 VidBee를 공유하세요.\",\n    \"shareSupport\": \"우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.\",\n    \"shareTitle\": \"소문을 퍼뜨리세요\",\n    \"sourceCode\": \"소스 코드 사용 가능\",\n    \"title\": \"정보\",\n    \"version\": \"버전\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"최신: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"새 버전 사용 가능\",\n      \"uptodate\": \"최신 버전 사용 중\",\n      \"error\": \"최신 버전을 가져올 수 없음\"\n    },\n    \"downloadingUpdate\": \"업데이트 다운로드 중\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"다운로드 완료 시 앱 닫기\",\n    \"currentLocation\": \"현재 다운로드 위치 - \",\n    \"downloadLocation\": \"다운로드 위치\",\n    \"downloadSubs\": \"사용 가능한 경우 자막 다운로드\",\n    \"downloadSubsHint\": \"가능한 경우 자막을 별도 파일로 저장\",\n    \"end\": \"끝\",\n    \"endHint\": \"비워두면 끝까지 다운로드됩니다\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"다운로드 위치 선택\",\n    \"start\": \"시작\",\n    \"startHint\": \"비워두면 처음부터 시작됩니다\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"자막\",\n    \"timeRange\": \"특정 시간 범위 다운로드\",\n    \"title\": \"고급 옵션\"\n  },\n  \"app\": {\n    \"description\": \"수백 개의 사이트에서 비디오와 오디오 다운로드\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"활성\",\n    \"all\": \"모두\",\n    \"audio\": \"오디오\",\n    \"back\": \"뒤로\",\n    \"cancel\": \"취소\",\n    \"cancelled\": \"취소됨\",\n    \"clearCompleted\": \"완료된 항목 지우기\",\n    \"clearDownloads\": \"다운로드 지우기\",\n    \"completed\": \"완료\",\n    \"downloadAudio\": \"오디오 다운로드\",\n    \"downloadBtn\": \"다운로드\",\n    \"downloadPending\": \"대기 중\",\n    \"downloadQueue\": \"다운로드 큐\",\n    \"customDownloadFolder\": \"사용자 지정 다운로드 폴더\",\n    \"retry\": \"다운로드 재시도\",\n    \"autoFolderPlaceholder\": \"자동 폴더(메타데이터 기반)\",\n    \"autoFolderHint\": \"자동 폴더는 메타데이터에서 생성됩니다.\",\n    \"useAutoFolder\": \"자동 폴더 사용\",\n    \"downloadVideo\": \"비디오 다운로드\",\n    \"downloading\": \"다운로드 중...\",\n    \"enterUrl\": \"비디오 URL 입력\",\n    \"enterUrlDescription\": \"비디오 URL을 붙여넣거나 입력하세요. \",\n    \"error\": \"오류\",\n    \"fetch\": \"가져오기\",\n    \"fetchingVideoInfo\": \"비디오 정보 가져오는 중...\",\n    \"feedback\": {\n      \"title\": \"이 오류를 보고:\",\n      \"githubUrlTooLong\": \"이 GitHub 링크가 매우 깁니다. 열리지 않으면 이슈 페이지를 열고 로그를 수동으로 붙여 넣어 주세요.\"\n    },\n    \"history\": \"기록\",\n    \"imageLoadError\": \"이미지 로드 실패\",\n    \"imagePlaceholder\": \"사용 가능한 이미지 없음\",\n    \"infoUnavailable\": \"원클릭 다운로드 (정보 사용 불가)\",\n    \"loading\": \"로딩 중\",\n    \"moreOptions\": \"더 많은 옵션\",\n    \"noActiveDownloads\": \"활성 다운로드 없음\",\n    \"noAudio\": \"오디오 없음\",\n    \"noHistory\": \"다운로드 기록 없음\",\n    \"noItems\": \"항목을 찾을 수 없음\",\n    \"goToSettings\": \"설정으로 이동\",\n    \"oneClickDownload\": \"원클릭 다운로드\",\n    \"oneClickDownloadDescription\": \"확인 없이 기본 설정으로 직접 다운로드\",\n    \"oneClickDownloadTooltip\": \"붙여넣고 즉시 다운로드, 단계 생략\",\n    \"oneClickDownloadNow\": \"지금 다운로드\",\n    \"oneClickDownloadStarted\": \"기본 설정으로 다운로드 시작됨\",\n    \"paste\": \"붙여넣기\",\n    \"pastePlaylistUrl\": \"클립보드에서 재생목록 링크 붙여넣기 [Ctrl + V]\",\n    \"pasteUrl\": \"비디오 URL 또는 ID 붙여넣기 [Ctrl + V]\",\n    \"pasteUrlButton\": \"URL 붙여넣기\",\n    \"preparing\": \"준비 중...\",\n    \"processing\": \"처리 중\",\n    \"progress\": \"진행률\",\n    \"showDetails\": \"세부정보 표시\",\n    \"hideDetails\": \"세부정보 숨기기\",\n    \"viewLogs\": \"로그 보기\",\n    \"detailsTab\": \"세부정보\",\n    \"logsTab\": \"로그\",\n    \"logs\": {\n      \"live\": \"실시간 로그\",\n      \"history\": \"저장된 로그\",\n      \"command\": \"yt-dlp 명령어\",\n      \"empty\": \"아직 로그가 없습니다.\",\n      \"scrollPaused\": \"스크롤 일시 중지\"\n    },\n    \"selectAudioFormat\": \"오디오 형식 선택\",\n    \"selectDownloadType\": \"다운로드 유형 선택\",\n    \"selectFormat\": \"형식 선택\",\n    \"startDownload\": \"다운로드 시작\",\n    \"selectVideoFormat\": \"비디오 형식 선택\",\n    \"singleVideo\": \"단일 비디오\",\n    \"speed\": \"속도\",\n    \"title\": \"제목\",\n    \"total\": \"총계\",\n    \"unknownQuality\": \"알 수 없는 품질\",\n    \"unknownSize\": \"알 수 없는 크기\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"비디오\",\n    \"videoInfo\": \"비디오 정보\",\n    \"videoInfoUpdated\": \"비디오 정보 업데이트됨\",\n    \"metadata\": {\n      \"source\": \"원천\",\n      \"playlist\": \"재생목록\",\n      \"format\": \"체재\",\n      \"quality\": \"품질\",\n      \"codec\": \"코덱\",\n      \"savedFile\": \"저장된 파일\",\n      \"url\": \"소스 URL\",\n      \"description\": \"설명\",\n      \"views\": \"조회수\",\n      \"tags\": \"태그\",\n      \"downloadPath\": \"다운로드 경로\",\n      \"createdAt\": \"생성 날짜\",\n      \"startedAt\": \"시작 시간\",\n      \"completedAt\": \"완료 시간\",\n      \"speed\": \"속도\",\n      \"fileSize\": \"파일 크기\",\n      \"width\": \"너비\",\n      \"height\": \"키\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"비디오 코덱\",\n      \"audioCodec\": \"오디오 코덱\",\n      \"formatNote\": \"메모 형식\",\n      \"protocol\": \"규약\",\n      \"subscription\": \"신청\"\n    }\n  },\n  \"error\": {\n    \"title\": \"문제가 발생했습니다\",\n    \"description\": \"예기치 않은 오류가 발생했습니다. 앱을 다시 로드하거나 문제가 계속되면 보고해 주세요.\",\n    \"message\": \"오류 메시지\",\n    \"unknownError\": \"알 수 없는 오류가 발생했습니다\",\n    \"goHome\": \"홈으로\",\n    \"reload\": \"앱 다시 로드\",\n    \"copyReport\": \"오류 보고서 복사\",\n    \"copied\": \"복사됨!\",\n    \"copySuccess\": \"오류 보고서가 클립보드에 복사되었습니다\",\n    \"copyFailed\": \"오류 보고서 복사에 실패했습니다\",\n    \"showDetails\": \"세부 정보 표시\",\n    \"hideDetails\": \"세부 정보 숨기기\",\n    \"stackTrace\": \"스택 트레이스\",\n    \"componentStack\": \"컴포넌트 스택\",\n    \"noStackTrace\": \"사용 가능한 스택 트레이스가 없습니다\",\n    \"fullReport\": \"전체 오류 보고서\",\n    \"helpText\": \"이 오류가 계속되면 위의 오류 보고서를 복사하여 지원 팀에 공유하세요. 연락처 정보는 정보 페이지에서 찾을 수 있습니다.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"세부 정보 복사하려면 클릭\",\n    \"clipboardEmpty\": \"클립보드가 비어있음\",\n    \"downloadFailed\": \"다운로드 실패\",\n    \"downloadNecessaryFilesFailed\": \"필수 파일 다운로드 실패. 네트워크를 확인하고 다시 시도하세요\",\n    \"emptyUrl\": \"URL을 입력하세요\",\n    \"errorDetails\": \"오류 세부 정보\",\n    \"fetchInfoFailed\": \"비디오 정보 가져오기 실패\",\n    \"invalidUrl\": \"클립보드 내용이 올바른 URL이 아닙니다\",\n    \"networkError\": \"오류가 발생했습니다. 네트워크를 확인하고 올바른 URL을 사용하세요\",\n    \"pasteFromClipboard\": \"클립보드에서 붙여넣기 실패\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"취소된 항목 지우기\",\n    \"clearCompleted\": \"완료된 항목 지우기\",\n    \"clearErrors\": \"오류 지우기\",\n    \"clearAll\": \"전체 기록 지우기\",\n    \"clearAllAction\": \"기록 지우기\",\n    \"clearSelection\": \"선택 지우기\",\n    \"confirmClearAllTitle\": \"모든 기록을 지울까요?\",\n    \"confirmClearAllDescription\": \"기록에서 {{count}}개 항목을 제거합니다. 파일은 디스크에 남아 있습니다.\",\n    \"confirmDeleteSelectedTitle\": \"선택한 항목을 제거할까요?\",\n    \"confirmDeleteSelectedDescription\": \"기록에서 {{count}}개 항목을 제거합니다. 파일은 디스크에 남아 있습니다.\",\n    \"alsoDeleteFiles\": \"파일도 삭제\",\n    \"confirmDeletePlaylistTitle\": \"재생목록 기록을 제거할까요?\",\n    \"confirmDeletePlaylistDescription\": \"{{title}}에서 {{count}}개 항목을 제거하고 파일을 삭제합니다.\",\n    \"copyToClipboard\": \"클립보드에 복사\",\n    \"copyUrl\": \"URL 복사\",\n    \"date\": \"날짜\",\n    \"deletePlaylist\": \"재생목록 제거\",\n    \"deleteSelected\": \"선택한 항목 제거\",\n    \"description\": \"다운로드 기록 보기 및 관리\",\n    \"doneSelecting\": \"완료\",\n    \"duration\": \"지속 시간\",\n    \"fileSize\": \"파일 크기\",\n    \"filters\": {\n      \"all\": \"모두\",\n      \"cancelled\": \"취소됨\",\n      \"completed\": \"완료\",\n      \"errors\": \"오류\"\n    },\n    \"noHistory\": \"다운로드 기록이 아직 없습니다\",\n    \"noHistoryDescription\": \"완료된 다운로드가 여기에 표시됩니다\",\n    \"cookiesTipTitle\": \"쿠키로 다운로드 성공률을 높이세요\",\n    \"cookiesTipDescription\": \"쿠키를 설정하면 성공률이 <strong>70%</strong>에서 <strong>99%</strong>로 높아집니다.\",\n    \"cookiesTipCta\": \"쿠키 설정\",\n    \"openDownloadFolder\": \"다운로드 폴더 열기\",\n    \"openFile\": \"파일 열기\",\n    \"openFileLocation\": \"파일 위치 열기\",\n    \"openFolder\": \"폴더 열기\",\n    \"openInBrowser\": \"브라우저에서 열려면 클릭\",\n    \"removeAction\": \"제거\",\n    \"removeItem\": \"항목 제거\",\n    \"deleteFile\": \"파일 삭제\",\n    \"deleteRecord\": \"목록에서 제거\",\n    \"select\": \"선택\",\n    \"selectAll\": \"모두 선택\",\n    \"selectVisible\": \"표시된 항목 선택\",\n    \"selectItem\": \"항목 선택\",\n    \"selectedCount\": \"{{count}}개 선택됨\",\n    \"selectionSummary\": \"표시된 {{total}}개 중 {{selected}}개 선택됨\",\n    \"stats\": {\n      \"cancelled\": \"취소됨\",\n      \"completed\": \"완료\",\n      \"errors\": \"오류\",\n      \"total\": \"총계\"\n    },\n    \"status\": {\n      \"cancelled\": \"취소됨\",\n      \"completed\": \"완료\",\n      \"error\": \"오류\"\n    },\n    \"title\": \"다운로드 기록\"\n  },\n  \"menu\": {\n    \"about\": \"정보\",\n    \"download\": \"다운로드\",\n    \"playlist\": \"재생목록 다운로드\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"구독\",\n    \"preferences\": \"환경설정\",\n    \"supportedSites\": \"지원되는 사이트\",\n    \"theme\": \"테마:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"클립보드에 복사 실패\",\n    \"downloadCompleted\": \"다운로드 완료\",\n    \"downloadAlreadyQueued\": \"이 다운로드는 이미 진행 중입니다\",\n    \"downloadFailed\": \"다운로드 실패\",\n    \"historyCleared\": \"기록이 지워졌습니다\",\n    \"historyClearFailed\": \"기록을 지우지 못했습니다\",\n    \"itemRemoved\": \"항목 제거됨\",\n    \"itemsRemoved\": \"{{count}}개 항목이 제거되었습니다\",\n    \"itemsRemoveFailed\": \"선택한 항목을 제거하지 못했습니다\",\n    \"openFileFailed\": \"파일 열기 실패\",\n    \"openFolderFailed\": \"폴더 열기 실패\",\n    \"playlistHistoryRemoved\": \"재생목록이 제거되고 파일이 삭제되었습니다\",\n    \"playlistHistoryRemoveFailed\": \"재생목록 기록을 제거하지 못했습니다\",\n    \"removeFailed\": \"항목 제거 실패\",\n    \"settingsSaved\": \"설정 저장됨\",\n    \"urlCopied\": \"URL이 클립보드에 복사됨\",\n    \"videoCopied\": \"비디오가 클립보드에 복사됨\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"재생목록\",\n    \"clearPreview\": \"미리보기 지우기\",\n    \"collapsedProgress\": \"재생목록 다운로드 중: {{completed}} / {{total}} 완료\",\n    \"comingSoon\": \"재생목록 다운로드 기능이 곧 출시됩니다!\",\n    \"completed\": \"재생목록 다운로드됨\",\n    \"description\": \"YouTube 재생목록 또는 채널의 모든 비디오 다운로드\",\n    \"downloadFailed\": \"재생목록 다운로드 시작 실패\",\n    \"downloadPlaylist\": \"재생목록 다운로드\",\n    \"downloadType\": \"다운로드 유형\",\n    \"downloading\": \"재생목록 다운로드 중:\",\n    \"endIndex\": \"끝\",\n    \"enterPlaylistUrl\": \"재생목록 URL 입력\",\n    \"fetchFailed\": \"재생목록 정보 가져오기 실패\",\n    \"filenameFormat\": \"재생목록용 파일명 형식\",\n    \"folderFormat\": \"재생목록용 폴더명 형식\",\n    \"foundVideos\": \"재생목록에서 {{count}}개 비디오 발견\",\n    \"groupActive\": \"{{count}} 활성\",\n    \"groupCollapse\": \"접기\",\n    \"groupErrors\": \"{{count}}개 실패\",\n    \"groupExpand\": \"펼치기\",\n    \"groupSummary\": \"{{completed}} / {{total}} 완료\",\n    \"linkLabel\": \"재생목록 URL\",\n    \"noEntries\": \"이 재생목록에는 동영상이 없습니다.\",\n    \"noEntriesInRange\": \"선택한 범위에 동영상이 없습니다.\",\n    \"noRangeSelected\": \"종료 설정 없음 - 전체 재생목록이 선택됨\",\n    \"playlistUrlDescription\": \"재생목록의 모든 비디오를 일괄 다운로드\",\n    \"positionLabel\": \"{{total}}개 항목 중 {{index}}개 항목\",\n    \"previewButton\": \"미리보기 재생목록\",\n    \"previewFailed\": \"재생목록을 미리 볼 수 없습니다.\",\n    \"previewSummary\": \"다운로드하기 전에 재생 목록 항목을 미리 봅니다.\",\n    \"previewRequired\": \"다운로드하기 전에 재생 목록을 미리 봅니다.\",\n    \"range\": \"범위 (선택사항)\",\n    \"resetToDefault\": \"기본값으로 재설정\",\n    \"selectedRange\": \"범위: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}}개 선택됨\",\n    \"downloadCurrentRange\": \"선택 항목 다운로드\",\n    \"showingCount\": \"{{count}}개의 동영상 표시 중\",\n    \"selectEntry\": \"항목 {{index}} 선택\",\n    \"noEntriesSelected\": \"선택된 항목이 없습니다\",\n    \"startIndex\": \"시작 (1)\",\n    \"title\": \"재생목록 다운로드\",\n    \"totalVideos\": \"총 동영상 수: {{count}}\",\n    \"untitled\": \"제목 없는 재생목록\",\n    \"fetchingInfo\": \"재생목록 정보를 가져오는 중...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"정보\",\n    \"advanced\": \"고급\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"앱 설정\",\n    \"audio\": \"오디오 환경설정\",\n    \"browserForCookies\": \"쿠키를 사용할 브라우저 선택\",\n    \"browserForCookiesDescription\": \"인증을 위한 쿠키 추출 브라우저\",\n    \"browserForCookiesWindowsNote\": \"Windows에서는 Firefox 쿠키만 지원됩니다. 다른 브라우저는 쿠키 파일을 수동으로 설정하세요.\",\n    \"browserForCookiesProfile\": \"프로필 이름 또는 경로\",\n    \"browserForCookiesProfileDescription\": \"위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.\",\n    \"browserForCookiesProfilePlaceholder\": \"프로필 이름 또는 전체 경로(선택 사항)\",\n    \"browserForCookiesProfileInvalid\": \"프로필 경로가 유효하지 않습니다. 선택한 브라우저의 프로필 폴더를 선택하세요.\",\n    \"browserForCookiesProfileInvalidPath\": \"해당 폴더가 없습니다. 기존 프로필 폴더를 선택하세요.\",\n    \"browserForCookiesProfileInvalidProfile\": \"기본 브라우저 위치에서 프로필 이름을 찾을 수 없습니다.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"이 플랫폼에서 이 브라우저의 기본 프로필 위치가 알려져 있지 않습니다.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"선택한 브라우저의 프로필 경로를 입력하세요.\",\n    \"cookiesFile\": \"Cookies 파일\",\n    \"cookiesFileDescription\": \"인증을 위해 로드할 Netscape 형식의 쿠키 파일\",\n    \"clearCookiesFile\": \"분명한\",\n    \"cookiesHelpTitle\": \"Cookies 사용\",\n    \"cookiesHelpBrowser\": \"로그인된 세션을 자동으로 재사용하려면 위에서 브라우저를 선택하세요.\",\n    \"cookiesHelpFile\": \"Netscape 쿠키 파일을 내보내고(yt-dlp FAQ 참조) 필요할 때 여기에서 선택하세요.\",\n    \"cookiesGuideTitle\": \"가이드가 필요하신가요?\",\n    \"cookiesGuideDescription\": \"VidBee에서 쿠키를 사용하는 단계별 안내를 확인하세요.\",\n    \"cookiesGuideLink\": \"Cookies 가이드 열기\",\n    \"openLinkError\": \"링크를 열지 못했습니다.\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"설정 파일 사용\",\n    \"configFileDescription\": \"yt-dlp용 사용자 정의 설정 파일\",\n    \"clearConfigFile\": \"분명한\",\n    \"dark\": \"다크\",\n    \"description\": \"다운로드 환경설정 및 앱 설정 구성\",\n    \"directorySelectError\": \"디렉토리 선택 실패\",\n    \"downloadPath\": \"다운로드 위치\",\n    \"downloadPathDescription\": \"다운로드 파일을 저장할 위치 선택\",\n    \"fileSelectError\": \"파일 선택 실패\",\n    \"general\": \"일반\",\n    \"language\": \"언어\",\n    \"languageDescription\": \"앱 인터페이스에 사용할 언어를 선택하세요\",\n    \"light\": \"라이트\",\n    \"hideDockIcon\": \"Dock 아이콘 숨기기\",\n    \"hideDockIconDescription\": \"macOS Dock에서 VidBee를 제거합니다. \\n메뉴 표시줄이나 트레이 아이콘을 사용하여 앱을 다시 엽니다.\",\n    \"launchAtLogin\": \"시작 시 실행\",\n    \"launchAtLoginDescription\": \"컴퓨터에 로그인한 후 자동으로 VidBee를 엽니다.\",\n    \"launchAtLoginUnsupported\": \"자동 실행은 macOS 및 Windows에서만 사용할 수 있습니다.\",\n    \"enableAnalytics\": \"VidBee 개선에 참여해주세요\",\n    \"enableAnalyticsDescription\": \"익명의 사용 데이터를 공유하면 앱이 어떻게 사용되는지 이해하고 개선 우선순위를 정하는 데 도움이 됩니다.\",\n    \"embedChapters\": \"챕터 포함\",\n    \"embedChaptersDescription\": \"사용 가능한 경우 파일에 챕터 마커를 추가\",\n    \"embedMetadata\": \"메타데이터 포함\",\n    \"embedMetadataDescription\": \"사용 가능한 경우 제목, 아티스트 및 기타 메타데이터 기록\",\n    \"embedSubs\": \"자막 포함\",\n    \"embedSubsDescription\": \"자막을 비디오 파일에 포함(mp4, webm, mkv)\",\n    \"embedThumbnail\": \"썸네일 포함\",\n    \"embedThumbnailDescription\": \"썸네일을 커버 아트로 추가\",\n    \"shareWatermark\": \"공유 워터마크\",\n    \"shareWatermarkDescription\": \"원본 제목, 작성자, VidBee 브랜드명을 포함한 워터마크를 추가합니다\",\n    \"maxConcurrentDownloads\": \"최대 활성 다운로드 수\",\n    \"maxConcurrentDownloadsDescription\": \"최대 동시 다운로드 수\",\n    \"none\": \"없음\",\n    \"oneClickDownload\": \"원클릭 다운로드\",\n    \"oneClickDownloadDescription\": \"기본 설정으로 원클릭 다운로드 활성화\",\n    \"oneClickDownloadType\": \"기본 다운로드 유형\",\n    \"oneClickDownloadTypeDescription\": \"원클릭 다운로드의 기본 다운로드 유형을 선택하세요. 품질은 아래 사전 설정을 사용합니다.\",\n    \"oneClickQuality\": \"선호 품질\",\n    \"oneClickQualityDescription\": \"원클릭 다운로드에 사용되는 품질 사전 설정을 선택하세요\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"자동\",\n      \"bad\": \"나쁨\",\n      \"best\": \"최고\",\n      \"good\": \"좋음\",\n      \"normal\": \"보통\",\n      \"worst\": \"최악\"\n    },\n    \"proxy\": \"프록시\",\n    \"proxyDescription\": \"네트워크 요청용 프록시 서버\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"설정 파일 선택\",\n    \"selectPath\": \"선택\",\n    \"showMoreFormats\": \"더 많은 형식 옵션 표시\",\n    \"showMoreFormatsDescription\": \"인터페이스에 추가 형식 옵션 표시\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.\"\n    },\n    \"system\": \"시스템\",\n    \"theme\": \"테마\",\n    \"themeDescription\": \"VidBee용 라이트, 다크 또는 시스템 테마 선택\",\n    \"title\": \"설정\",\n    \"tray\": {\n      \"quit\": \"종료\",\n      \"showHome\": \"홈 표시\"\n    },\n    \"video\": \"비디오 환경설정\"\n  },\n  \"subscriptions\": {\n    \"title\": \"구독\",\n    \"subtitle\": \"{{count}} 구독{{count, plural, one {} other {s}}}\",\n    \"description\": \"RSS 피드를 자동으로 모니터링하고 수동 작업 없이 새 다운로드를 대기열에 추가하세요.\",\n    \"defaults\": {\n      \"title\": \"자동화 기본값\",\n      \"description\": \"구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.\",\n      \"downloadDirectory\": \"디렉토리 다운로드\",\n      \"filenameTemplate\": \"파일 이름 템플릿(파일만)\",\n      \"onlyLatest\": \"최신 영상만 다운로드하세요\",\n      \"onlyLatestDescription\": \"활성화되면 VidBee는 이전 백로그 항목을 건너뛰고 최신 업로드만 가져옵니다.\"\n    },\n    \"add\": {\n      \"title\": \"RSS 추가\",\n      \"description\": \"RSS 피드 링크를 붙여넣으세요. \\nVidBee는 자동으로 피드를 감지합니다.\"\n    },\n    \"fields\": {\n      \"url\": \"피드 URL\",\n      \"keywords\": \"키워드 필터(쉼표로 구분)\",\n      \"tags\": \"자동 태그\",\n      \"customDirectory\": \"맞춤 디렉터리\",\n      \"namingTemplate\": \"사용자 정의 파일 이름 템플릿(파일만 해당)\",\n      \"onlyLatest\": \"최신 영상만 다운로드하세요\",\n      \"onlyLatestDescription\": \"백로그 항목을 무시하고 이 피드에서 최신 업로드만 가져옵니다.\",\n      \"enabled\": \"활성화됨\",\n      \"disabled\": \"장애가 있는\",\n      \"onlyLatestShort\": \"최신만\"\n    },\n    \"actions\": {\n      \"add\": \"추가하다\",\n      \"refresh\": \"새로 고치다\",\n      \"edit\": \"편집하다\",\n      \"remove\": \"제거하다\",\n      \"save\": \"변경사항 저장\",\n      \"selectDirectory\": \"먹다\",\n      \"enable\": \"할 수 있게 하다\",\n      \"disable\": \"장애를 입히다\"\n    },\n    \"items\": {\n      \"title\": \"최근 업로드({{count}})\",\n      \"count\": \"{{count}}개 항목\",\n      \"empty\": \"최근 피드 항목을 찾을 수 없습니다.\",\n      \"status\": {\n        \"queued\": \"대기 중\",\n        \"notQueued\": \"대기열에 추가되지 않음\",\n        \"pending\": \"보류 중\",\n        \"downloading\": \"다운로드 중\",\n        \"processing\": \"처리\",\n        \"completed\": \"완전한\",\n        \"error\": \"실패한\",\n        \"cancelled\": \"취소\"\n      },\n      \"fromChannel\": \"{{channel}}에서\",\n      \"tooltip\": {\n        \"downloadStatus\": \"다운로드 상태: {{status}}\",\n        \"downloadPending\": \"다운로드 세부정보를 기다리는 중...\",\n        \"notQueued\": \"아직 다운로드 대기열에 없습니다\"\n      },\n      \"actions\": {\n        \"open\": \"브라우저에서 열기\",\n        \"queue\": \"다운로드 대기열에 추가\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"신청\",\n      \"unknown\": \"알 수 없는 구독\",\n      \"noThumbnail\": \"미리보기 이미지 없음\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"디렉터리 선택기를 열지 못했습니다.\",\n      \"missingUrl\": \"먼저 채널 링크를 붙여넣으세요.\",\n      \"created\": \"구독이 추가되었습니다\",\n      \"createError\": \"구독을 추가하지 못했습니다.\",\n      \"refreshStarted\": \"새로고침이 시작되었습니다.\",\n      \"removed\": \"구독이 삭제됨\",\n      \"updated\": \"구독이 업데이트되었습니다.\",\n      \"itemQueued\": \"다운로드 대기열에 추가됨\",\n      \"itemAlreadyQueued\": \"이 동영상은 이미 대기열에 있습니다.\",\n      \"queueError\": \"다운로드 대기열에 추가하지 못했습니다.\",\n      \"openLinkError\": \"동영상 링크를 열지 못했습니다.\",\n      \"resolveError\": \"RSS 피드 URL을 확인하지 못했습니다.\",\n      \"duplicateUrl\": \"이 RSS 피드는 이미 구독되어 있습니다.\"\n    },\n    \"detectedFeed\": \"{{platform}} 피드 감지됨 -> {{feed}}\",\n    \"detecting\": \"피드 감지 중...\",\n    \"latestVideo\": \"최신 동영상: {{title}}\",\n    \"lastChecked\": \"마지막 확인: {{time}}\",\n    \"never\": \"절대\",\n    \"empty\": \"아직 구독이 없습니다. \\n즐겨찾는 채널을 추가하여 자동 다운로드를 시작하세요.\",\n    \"edit\": {\n      \"title\": \"{{name}} 수정\",\n      \"description\": \"이 피드에 대한 필터, 태그 및 재정의를 조정하세요.\"\n    },\n    \"status\": {\n      \"title\": \"상태\",\n      \"up-to-date\": \"최신\",\n      \"checking\": \"확인 중\",\n      \"failed\": \"실패한\",\n      \"idle\": \"게으른\",\n      \"tooltip\": {\n        \"updatedAt\": \"업데이트됨: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"RSSHub를 통한 자동 구독\",\n      \"description\": \"VidBee를 RSSHub와 결합하면 다양한 플랫폼에서 자동 구독 및 다운로드가 가능해집니다. \\n일단 설정되면 VidBee는 백그라운드에서 실행되어 최신 비디오와 콘텐츠를 자동으로 다운로드합니다.\",\n      \"learnMore\": \"RSSHub에 대해 자세히 알아보기\",\n      \"openDocs\": \"RSSHub 문서 열기\",\n      \"hint\": \"RSS 피드 URL이 없나요? \\nRSSHub를 사용하여 YouTube, Twitter 및 기타 수천 개의 플랫폼에 대한 RSS 피드를 생성하세요.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"{{sites}} 및 더 많은 사이트를 지원합니다.\",\n    \"moreDescription\": \"완전한 yt-dlp 목록은 커뮤니티에 의해 지속적으로 업데이트됩니다.\",\n    \"moreTitle\": \"다른 사이트가 필요하신가요?\",\n    \"openFullList\": \"지원되는 모든 사이트 목록 열기\",\n    \"pageDescription\": \"VidBee는 수백 개의 소스에 도달하기 위해 yt-dlp를 백그라운드에서 사용합니다.\",\n    \"pageIntro\": \"사람들이 가장 자주 다운로드하는 주요 서비스들입니다.\",\n    \"pageTitle\": \"지원되는 사이트\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"인디 아티스트 앨범 및 커뮤니티 릴리스.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"글로벌 뉴스, 스포츠 및 엔터테인먼트 클립.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"공개 페이지의 피드, Watch 및 Reels 비디오.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"피드, Stories, Reels 및 Highlights 콘텐츠.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Kick 플랫폼의 크리에이터 라이브 스트림 및 리플레이.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"전문 강연, 웨비나 및 학습 비디오.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ 믹스, 라디오 쇼 및 롱폼 오디오.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"일본 애니메이션, 음악 및 라이브 방송 아카이브.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"아이디어 핀, 하우투 Reels 및 라이프스타일 영감 비디오.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"커뮤니티의 임베드 클립 및 호스팅 비디오.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"음악 트랙, 플레이리스트 및 DJ 세트.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"짧은 모바일 비디오, 효과 및 라이브 스트림.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"창의적인 짧은 미디어 및 팬 편집.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"게임, 음악 및 IRL 라이브 스트림 및 VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"타임라인 포스트, Spaces 녹음 및 방송.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"크리에이터 및 비즈니스용 고품질 비디오 호스팅.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"전 세계 크리에이터의 롱폼 및 라이브스트림 비디오.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"공식 뮤직 비디오, 앨범 및 라이브 공연.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"주요 플랫폼\",\n    \"viewAll\": \"지원되는 모든 사이트 보기\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/pt.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Verificar atualizações\",\n      \"download\": \"Download\",\n      \"email\": \"Email\",\n      \"feedback\": \"Feedback\",\n      \"goToDownload\": \"Vá para a página de download\",\n      \"openRepo\": \"Abrir repositório GitHub\",\n      \"view\": \"Ver\",\n      \"visit\": \"Visitar\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Baixar e instalar novas versões automaticamente em segundo plano.\",\n    \"autoUpdateTitle\": \"Atualizações automáticas\",\n    \"betaProgramDescription\": \"Receba builds antecipados e próximos recursos antes de todos.\",\n    \"betaProgramTitle\": \"Canal de visualização\",\n    \"description\": \"VidBee é um baixador gratuito e de código aberto construído com Electron e alimentado por yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Seguir @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Mantenha-se atualizado com as últimas notícias e atualizações do VidBee.\",\n    \"followAuthorSupport\": \"Siga o desenvolvedor no X (Twitter) para obter as últimas atualizações e notícias sobre VidBee.\",\n    \"followAuthorTitle\": \"Seguir o Desenvolvedor\",\n    \"here\": \"aqui\",\n    \"homepage\": \"Página inicial\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Procurando atualizações...\",\n      \"downloadError\": \"Falha ao baixar atualização\",\n      \"downloadUpdate\": \"Baixar e instalar atualização {{version}}?\",\n      \"manualDownloadAction\": \"Baixe agora\",\n      \"noUpdatesAvailable\": \"Você está usando a versão mais recente\",\n      \"restartToUpdate\": \"Reiniciar agora para instalar atualização?\",\n      \"restartNowAction\": \"Reinicie agora\",\n      \"updateAvailable\": \"Atualização disponível: {{version}}\",\n      \"updateAvailableMessage\": \"Uma nova versão {{version}} está disponível. \\nFaça o download no site oficial.\",\n      \"updateDownloaded\": \"Atualização baixada, reinicie para instalar\",\n      \"updateDownloadedVersion\": \"Atualização {{version}} baixada, reinicie para instalar\",\n      \"updateError\": \"Falha ao verificar atualizações: {{error}}\",\n      \"unknownErrorFallback\": \"Erro desconhecido\"\n    },\n    \"preferencesDescription\": \"Ajuste configurações de atualização sem sair desta página.\",\n    \"preferencesTitle\": \"Alternâncias Rápidas\",\n    \"resources\": {\n      \"changelog\": \"Notas da versão\",\n      \"changelogDescription\": \"Acompanhe o que mudou em cada versão.\",\n      \"contact\": \"Suporte por email\",\n      \"contactDescription\": \"Entre em contato diretamente para ajuda ou colaboração.\",\n      \"documentation\": \"Central de ajuda\",\n      \"documentationDescription\": \"Guias, FAQs e fluxos de trabalho comuns.\",\n      \"feedback\": \"Feedback e problemas\",\n      \"feedbackDescription\": \"Compartilhe ideias ou reporte problemas no GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Relatar bugs ou solicitar recursos no GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Compartilhe feedback ou sugestões no X mencionando @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Junte-se à nossa comunidade no Discord para discussões e suporte.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Perguntas frequentes e guias de solução de problemas.\",\n      \"license\": \"Licença\",\n      \"licenseDescription\": \"Revise os termos da licença de código aberto.\",\n      \"website\": \"Site oficial\",\n      \"websiteDescription\": \"Destaques do produto, roadmap e notícias da comunidade.\"\n    },\n    \"resourcesDescription\": \"Links úteis para aprender mais sobre VidBee e manter-se conectado.\",\n    \"resourcesTitle\": \"Recursos\",\n    \"shareActions\": {\n      \"copy\": \"Copiar link\",\n      \"facebook\": \"Compartilhar no Facebook\",\n      \"twitter\": \"Compartilhar no X (Twitter)\"\n    },\n    \"shareDescription\": \"Compartilhe VidBee com sua comunidade em um clique.\",\n    \"shareSupport\": \"Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.\",\n    \"shareTitle\": \"Espalhe a palavra\",\n    \"sourceCode\": \"Código fonte disponível\",\n    \"title\": \"Sobre\",\n    \"version\": \"Versão\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Mais recente: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Nova versão disponível\",\n      \"uptodate\": \"Você está atualizado\",\n      \"error\": \"Não foi possível obter a versão mais recente\"\n    },\n    \"downloadingUpdate\": \"Baixando atualização\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Fechar aplicativo quando download terminar\",\n    \"currentLocation\": \"Local de download atual - \",\n    \"downloadLocation\": \"Local de download\",\n    \"downloadSubs\": \"Baixar legendas se disponíveis\",\n    \"downloadSubsHint\": \"Salvar legendas como arquivos separados quando disponíveis\",\n    \"end\": \"Fim\",\n    \"endHint\": \"Se deixado vazio, será baixado até o final\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Selecionar Local de Download\",\n    \"start\": \"Início\",\n    \"startHint\": \"Se deixado vazio, começará do início\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Legendas\",\n    \"timeRange\": \"Baixar intervalo de tempo específico\",\n    \"title\": \"Opções Avançadas\"\n  },\n  \"app\": {\n    \"description\": \"Baixar vídeos e áudios de centenas de sites\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Ativo\",\n    \"all\": \"Todos\",\n    \"audio\": \"Áudio\",\n    \"back\": \"Voltar\",\n    \"cancel\": \"Cancelar\",\n    \"cancelled\": \"Cancelado\",\n    \"clearCompleted\": \"Limpar Concluídos\",\n    \"clearDownloads\": \"Limpar Downloads\",\n    \"completed\": \"Concluído\",\n    \"downloadAudio\": \"Baixar Áudio\",\n    \"downloadBtn\": \"Baixar\",\n    \"downloadPending\": \"Pendente\",\n    \"downloadQueue\": \"Fila de Download\",\n    \"customDownloadFolder\": \"Pasta de download personalizada\",\n    \"retry\": \"Tentar baixar novamente\",\n    \"autoFolderPlaceholder\": \"Pasta automática (com base nos metadados)\",\n    \"autoFolderHint\": \"Pastas automáticas são criadas a partir de metadados.\",\n    \"useAutoFolder\": \"Usar pasta automática\",\n    \"downloadVideo\": \"Baixar Vídeo\",\n    \"downloading\": \"Baixando...\",\n    \"enterUrl\": \"Inserir URL do Vídeo\",\n    \"enterUrlDescription\": \"Cole ou digite uma URL de vídeo. \",\n    \"error\": \"Erro\",\n    \"fetch\": \"Buscar\",\n    \"fetchingVideoInfo\": \"Buscando informações do vídeo...\",\n    \"feedback\": {\n      \"title\": \"Relatar este erro:\",\n      \"githubUrlTooLong\": \"Este link do GitHub é muito longo. Se não abrir, abra a página da issue e cole os logs manualmente.\"\n    },\n    \"history\": \"Histórico\",\n    \"imageLoadError\": \"Falha ao carregar imagem\",\n    \"imagePlaceholder\": \"Nenhuma imagem disponível\",\n    \"infoUnavailable\": \"Download de Um Clique (Info indisponível)\",\n    \"loading\": \"Carregando\",\n    \"moreOptions\": \"Mais opções\",\n    \"noActiveDownloads\": \"Nenhum download ativo\",\n    \"noAudio\": \"Sem Áudio\",\n    \"noHistory\": \"Nenhum histórico de download\",\n    \"noItems\": \"Nenhum item encontrado\",\n    \"goToSettings\": \"Vá para Configurações\",\n    \"oneClickDownload\": \"Download de Um Clique\",\n    \"oneClickDownloadDescription\": \"Baixar diretamente com configurações padrão sem confirmação\",\n    \"oneClickDownloadTooltip\": \"Cole e baixe na hora, sem etapas\",\n    \"oneClickDownloadNow\": \"Baixar Agora\",\n    \"oneClickDownloadStarted\": \"Download iniciado com configurações padrão\",\n    \"paste\": \"Colar\",\n    \"pastePlaylistUrl\": \"Clique para colar link da playlist da área de transferência [Ctrl + V]\",\n    \"pasteUrl\": \"Clique para colar URL do vídeo ou ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Colar URL\",\n    \"preparing\": \"Preparando...\",\n    \"processing\": \"Processando\",\n    \"progress\": \"Progresso\",\n    \"showDetails\": \"Mostrar detalhes\",\n    \"hideDetails\": \"Ocultar detalhes\",\n    \"viewLogs\": \"Ver logs\",\n    \"detailsTab\": \"Detalhes\",\n    \"logsTab\": \"Logs\",\n    \"logs\": {\n      \"live\": \"Logs ao vivo\",\n      \"history\": \"Logs salvos\",\n      \"command\": \"Comando do yt-dlp\",\n      \"empty\": \"Ainda não há logs.\",\n      \"scrollPaused\": \"Rolagem pausada\"\n    },\n    \"selectAudioFormat\": \"Selecionar Formato de Áudio\",\n    \"selectDownloadType\": \"Selecionar tipo de download\",\n    \"selectFormat\": \"Selecionar Formato\",\n    \"startDownload\": \"Iniciar download\",\n    \"selectVideoFormat\": \"Selecionar Formato de Vídeo\",\n    \"singleVideo\": \"Vídeo Único\",\n    \"speed\": \"Velocidade\",\n    \"title\": \"Título\",\n    \"total\": \"Total\",\n    \"unknownQuality\": \"Qualidade desconhecida\",\n    \"unknownSize\": \"Tamanho desconhecido\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Vídeo\",\n    \"videoInfo\": \"Informações do Vídeo\",\n    \"videoInfoUpdated\": \"Informações do vídeo atualizadas\",\n    \"metadata\": {\n      \"source\": \"Fonte\",\n      \"playlist\": \"Lista de reprodução\",\n      \"format\": \"Formatar\",\n      \"quality\": \"Qualidade\",\n      \"codec\": \"Codec\",\n      \"savedFile\": \"Arquivo salvo\",\n      \"url\": \"URL de origem\",\n      \"description\": \"Descrição\",\n      \"views\": \"Visualizações\",\n      \"tags\": \"Etiquetas\",\n      \"downloadPath\": \"Caminho de download\",\n      \"createdAt\": \"Criado em\",\n      \"startedAt\": \"Começou em\",\n      \"completedAt\": \"Concluído em\",\n      \"speed\": \"Velocidade\",\n      \"fileSize\": \"Tamanho do arquivo\",\n      \"width\": \"Largura\",\n      \"height\": \"Altura\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Codec de vídeo\",\n      \"audioCodec\": \"Codec de áudio\",\n      \"formatNote\": \"Formatar nota\",\n      \"protocol\": \"Protocolo\",\n      \"subscription\": \"Subscrição\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Algo deu errado\",\n    \"description\": \"Ocorreu um erro inesperado. Recarregue o aplicativo ou reporte este problema se persistir.\",\n    \"message\": \"Mensagem de erro\",\n    \"unknownError\": \"Ocorreu um erro desconhecido\",\n    \"goHome\": \"Ir para a página inicial\",\n    \"reload\": \"Recarregar aplicativo\",\n    \"copyReport\": \"Copiar relatório de erro\",\n    \"copied\": \"Copiado!\",\n    \"copySuccess\": \"Relatório de erro copiado para a área de transferência\",\n    \"copyFailed\": \"Falha ao copiar o relatório de erro\",\n    \"showDetails\": \"Mostrar detalhes\",\n    \"hideDetails\": \"Ocultar detalhes\",\n    \"stackTrace\": \"Rastro de pilha\",\n    \"componentStack\": \"Pilha de componentes\",\n    \"noStackTrace\": \"Nenhum rastro de pilha disponível\",\n    \"fullReport\": \"Relatório de erro completo\",\n    \"helpText\": \"Se esse erro persistir, copie o relatório de erro acima e compartilhe com a equipe de suporte. Você pode encontrar as informações de contato na página Sobre.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Clique para copiar detalhes\",\n    \"clipboardEmpty\": \"Área de transferência vazia\",\n    \"downloadFailed\": \"Download falhou\",\n    \"downloadNecessaryFilesFailed\": \"Falha ao baixar arquivos necessários. Verifique sua rede e tente novamente\",\n    \"emptyUrl\": \"Por favor, insira uma URL\",\n    \"errorDetails\": \"Detalhes do Erro\",\n    \"fetchInfoFailed\": \"Falha ao buscar informações do vídeo\",\n    \"invalidUrl\": \"O conteúdo da área de transferência não é uma URL válida\",\n    \"networkError\": \"Algum erro ocorreu. Verifique sua rede e use uma URL correta\",\n    \"pasteFromClipboard\": \"Falha ao colar da área de transferência\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Limpar Cancelados\",\n    \"clearCompleted\": \"Limpar Concluídos\",\n    \"clearErrors\": \"Limpar Erros\",\n    \"clearAll\": \"Limpar todo o histórico\",\n    \"clearAllAction\": \"Limpar histórico\",\n    \"clearSelection\": \"Limpar seleção\",\n    \"confirmClearAllTitle\": \"Limpar todo o histórico?\",\n    \"confirmClearAllDescription\": \"Remover {{count}} itens do seu histórico. Os arquivos permanecem no disco.\",\n    \"confirmDeleteSelectedTitle\": \"Remover itens selecionados?\",\n    \"confirmDeleteSelectedDescription\": \"Remover {{count}} itens do seu histórico. Os arquivos permanecem no disco.\",\n    \"alsoDeleteFiles\": \"Também excluir arquivos\",\n    \"confirmDeletePlaylistTitle\": \"Remover histórico da playlist?\",\n    \"confirmDeletePlaylistDescription\": \"Remover {{count}} itens de {{title}} e excluir seus arquivos.\",\n    \"copyToClipboard\": \"Copiar para área de transferência\",\n    \"copyUrl\": \"Copiar URL\",\n    \"date\": \"Data\",\n    \"deletePlaylist\": \"Remover playlist\",\n    \"deleteSelected\": \"Remover selecionados\",\n    \"description\": \"Ver e gerenciar seu histórico de downloads\",\n    \"doneSelecting\": \"Concluído\",\n    \"duration\": \"Duração\",\n    \"fileSize\": \"Tamanho do Arquivo\",\n    \"filters\": {\n      \"all\": \"Todos\",\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Concluído\",\n      \"errors\": \"Erros\"\n    },\n    \"noHistory\": \"Nenhum histórico de download ainda\",\n    \"noHistoryDescription\": \"Seus downloads concluídos aparecerão aqui\",\n    \"cookiesTipTitle\": \"Aumente o sucesso dos downloads com cookies\",\n    \"cookiesTipDescription\": \"Configure cookies para elevar a taxa de sucesso de <strong>70%</strong> para <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Configurar cookies\",\n    \"openDownloadFolder\": \"Abrir Pasta de Downloads\",\n    \"openFile\": \"Abrir Arquivo\",\n    \"openFileLocation\": \"Abrir Localização do Arquivo\",\n    \"openFolder\": \"Abrir Pasta\",\n    \"openInBrowser\": \"Clique para abrir no navegador\",\n    \"removeAction\": \"Remover\",\n    \"removeItem\": \"Remover Item\",\n    \"deleteFile\": \"Remover Arquivo\",\n    \"deleteRecord\": \"Remover da Lista\",\n    \"select\": \"Selecionar\",\n    \"selectAll\": \"Selecionar tudo\",\n    \"selectVisible\": \"Selecionar visíveis\",\n    \"selectItem\": \"Selecionar item\",\n    \"selectedCount\": \"{{count}} selecionados\",\n    \"selectionSummary\": \"{{selected}} de {{total}} visíveis selecionados\",\n    \"stats\": {\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Concluído\",\n      \"errors\": \"Erros\",\n      \"total\": \"Total\"\n    },\n    \"status\": {\n      \"cancelled\": \"Cancelado\",\n      \"completed\": \"Concluído\",\n      \"error\": \"Erro\"\n    },\n    \"title\": \"Histórico de Downloads\"\n  },\n  \"menu\": {\n    \"about\": \"Sobre\",\n    \"download\": \"Download\",\n    \"playlist\": \"Baixar Playlist\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Assinaturas\",\n    \"preferences\": \"Preferências\",\n    \"supportedSites\": \"Sites Suportados\",\n    \"theme\": \"Tema:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Falha ao copiar para área de transferência\",\n    \"downloadCompleted\": \"Download concluído\",\n    \"downloadAlreadyQueued\": \"Este download já está em andamento\",\n    \"downloadFailed\": \"Download falhou\",\n    \"historyCleared\": \"Histórico limpo\",\n    \"historyClearFailed\": \"Falha ao limpar histórico\",\n    \"itemRemoved\": \"Item removido\",\n    \"itemsRemoved\": \"{{count}} itens removidos\",\n    \"itemsRemoveFailed\": \"Falha ao remover itens selecionados\",\n    \"openFileFailed\": \"Falha ao abrir arquivo\",\n    \"openFolderFailed\": \"Falha ao abrir pasta\",\n    \"playlistHistoryRemoved\": \"Playlist removida e arquivos excluídos\",\n    \"playlistHistoryRemoveFailed\": \"Falha ao remover histórico da playlist\",\n    \"removeFailed\": \"Falha ao remover item\",\n    \"settingsSaved\": \"Configurações salvas\",\n    \"urlCopied\": \"URL copiada para área de transferência\",\n    \"videoCopied\": \"Vídeo copiado para área de transferência\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Lista de reprodução\",\n    \"clearPreview\": \"Limpar visualização\",\n    \"collapsedProgress\": \"Baixando playlist: {{completed}} / {{total}} concluído\",\n    \"comingSoon\": \"Recurso de download de playlist em breve!\",\n    \"completed\": \"Playlist baixada\",\n    \"description\": \"Baixar todos os vídeos de uma playlist ou canal do YouTube\",\n    \"downloadFailed\": \"Falha ao iniciar download da playlist\",\n    \"downloadPlaylist\": \"Baixar Playlist\",\n    \"downloadType\": \"Tipo de Download\",\n    \"downloading\": \"Baixando playlist:\",\n    \"endIndex\": \"Fim\",\n    \"enterPlaylistUrl\": \"Inserir URL da Playlist\",\n    \"fetchFailed\": \"Falha ao buscar informações da playlist\",\n    \"filenameFormat\": \"Formato de nome de arquivo para playlists\",\n    \"folderFormat\": \"Formato de nome de pasta para playlists\",\n    \"foundVideos\": \"Encontrados {{count}} vídeos na playlist\",\n    \"groupActive\": \"{{count}} ativo\",\n    \"groupCollapse\": \"Recolher\",\n    \"groupErrors\": \"{{count}} falhou\",\n    \"groupExpand\": \"Expandir\",\n    \"groupSummary\": \"{{completed}} / {{total}} concluído\",\n    \"linkLabel\": \"URL da Playlist\",\n    \"noEntries\": \"Nenhum vídeo foi encontrado nesta playlist\",\n    \"noEntriesInRange\": \"Nenhum vídeo no intervalo selecionado\",\n    \"noRangeSelected\": \"Sem definição final - playlist completa selecionada\",\n    \"playlistUrlDescription\": \"Baixar todos os vídeos de uma playlist em lote\",\n    \"positionLabel\": \"Item {{index}} de {{total}}\",\n    \"previewButton\": \"Visualizar lista de reprodução\",\n    \"previewFailed\": \"Falha ao visualizar a playlist\",\n    \"previewSummary\": \"Visualize os itens da lista de reprodução antes de fazer o download.\",\n    \"previewRequired\": \"Visualize a lista de reprodução antes de fazer o download.\",\n    \"range\": \"Intervalo (Opcional)\",\n    \"resetToDefault\": \"Redefinir para padrão\",\n    \"selectedRange\": \"Intervalo: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} selecionados\",\n    \"downloadCurrentRange\": \"Baixar selecionados\",\n    \"showingCount\": \"Exibindo {{count}} vídeos\",\n    \"selectEntry\": \"Selecionar entrada {{index}}\",\n    \"noEntriesSelected\": \"Nenhuma entrada selecionada\",\n    \"startIndex\": \"Início (1)\",\n    \"title\": \"Baixar Playlist\",\n    \"totalVideos\": \"Total de vídeos: {{count}}\",\n    \"untitled\": \"Playlist sem título\",\n    \"fetchingInfo\": \"Buscando informações da playlist...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Sobre\",\n    \"advanced\": \"Avançado\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"Configurações do App\",\n    \"audio\": \"Preferências de Áudio\",\n    \"browserForCookies\": \"Selecionar navegador para usar cookies\",\n    \"browserForCookiesDescription\": \"Navegador para extrair cookies para autenticação\",\n    \"browserForCookiesWindowsNote\": \"No Windows, apenas cookies do Firefox são suportados. Para outros navegadores, configure um arquivo de cookies manualmente.\",\n    \"browserForCookiesProfile\": \"Nome do perfil ou caminho\",\n    \"browserForCookiesProfileDescription\": \"Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.\",\n    \"browserForCookiesProfilePlaceholder\": \"Nome do perfil ou caminho completo (opcional)\",\n    \"browserForCookiesProfileInvalid\": \"O caminho do perfil não é válido. Escolha a pasta do perfil do navegador selecionado.\",\n    \"browserForCookiesProfileInvalidPath\": \"Essa pasta não existe. Escolha uma pasta de perfil existente.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Nome do perfil não encontrado no local padrão do navegador.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Nenhum local de perfil padrão é conhecido para este navegador nesta plataforma.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Digite um caminho de perfil para o navegador selecionado.\",\n    \"cookiesFile\": \"Arquivo de cookies\",\n    \"cookiesFileDescription\": \"Arquivo de cookies formatados do Netscape para carregar para autenticação\",\n    \"clearCookiesFile\": \"Claro\",\n    \"cookiesHelpTitle\": \"Usando cookies\",\n    \"cookiesHelpBrowser\": \"Escolha seu navegador acima para reutilizar automaticamente a sessão de login.\",\n    \"cookiesHelpFile\": \"Exporte um arquivo de cookies do Netscape (consulte as perguntas frequentes do yt-dlp) e selecione-o aqui quando necessário.\",\n    \"cookiesGuideTitle\": \"Precisa de um guia?\",\n    \"cookiesGuideDescription\": \"Veja um passo a passo para usar cookies no VidBee.\",\n    \"cookiesGuideLink\": \"Abrir guia de cookies\",\n    \"openLinkError\": \"Falha ao abrir o link\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Usar arquivo de configuração\",\n    \"configFileDescription\": \"Arquivo de configuração personalizado para yt-dlp\",\n    \"clearConfigFile\": \"Claro\",\n    \"dark\": \"Escuro\",\n    \"description\": \"Configure suas preferências de download e configurações do aplicativo\",\n    \"directorySelectError\": \"Falha ao selecionar diretório\",\n    \"downloadPath\": \"Local de download\",\n    \"downloadPathDescription\": \"Escolha onde salvar os arquivos baixados\",\n    \"fileSelectError\": \"Falha ao selecionar arquivo\",\n    \"general\": \"Geral\",\n    \"language\": \"Idioma\",\n    \"languageDescription\": \"Escolha seu idioma preferido para a interface do aplicativo\",\n    \"light\": \"Claro\",\n    \"hideDockIcon\": \"Ocultar ícone do Dock\",\n    \"hideDockIconDescription\": \"Remova o VidBee do Dock do macOS. \\nUse a barra de menu ou o ícone da bandeja para reabrir o aplicativo.\",\n    \"launchAtLogin\": \"Lançar na inicialização\",\n    \"launchAtLoginDescription\": \"Abra o VidBee automaticamente depois de fazer login no seu computador.\",\n    \"launchAtLoginUnsupported\": \"A inicialização automática está disponível apenas no macOS e no Windows.\",\n    \"enableAnalytics\": \"Ajude a melhorar o VidBee\",\n    \"enableAnalyticsDescription\": \"Compartilhe dados de uso anônimos para nos ajudar a entender como o aplicativo é usado e priorizar melhorias.\",\n    \"embedChapters\": \"Incorporar capítulos\",\n    \"embedChaptersDescription\": \"Adicionar marcadores de capítulo ao arquivo quando disponíveis\",\n    \"embedMetadata\": \"Incorporar metadados\",\n    \"embedMetadataDescription\": \"Gravar título, artista e outros metadados quando disponíveis\",\n    \"embedSubs\": \"Incorporar legendas\",\n    \"embedSubsDescription\": \"Incorporar legendas no arquivo de vídeo (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Incorporar miniatura\",\n    \"embedThumbnailDescription\": \"Adicionar a miniatura como arte de capa\",\n    \"shareWatermark\": \"Marca-d'água de compartilhamento\",\n    \"shareWatermarkDescription\": \"Adiciona uma marca-d'água com o título original, o autor e a marca VidBee\",\n    \"maxConcurrentDownloads\": \"Número máximo de downloads ativos\",\n    \"maxConcurrentDownloadsDescription\": \"Número máximo de downloads simultâneos\",\n    \"none\": \"Nenhum\",\n    \"oneClickDownload\": \"Download de Um Clique\",\n    \"oneClickDownloadDescription\": \"Habilitar download de um clique com configurações padrão\",\n    \"oneClickDownloadType\": \"Tipo de download padrão\",\n    \"oneClickDownloadTypeDescription\": \"Escolha o tipo de download padrão para downloads de um clique. A qualidade usa o preset abaixo.\",\n    \"oneClickQuality\": \"Qualidade preferida\",\n    \"oneClickQualityDescription\": \"Selecione o preset de qualidade usado para downloads de um clique\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Automático\",\n      \"bad\": \"Ruim\",\n      \"best\": \"Melhor\",\n      \"good\": \"Bom\",\n      \"normal\": \"Normal\",\n      \"worst\": \"Pior\"\n    },\n    \"proxy\": \"Proxy\",\n    \"proxyDescription\": \"Servidor proxy para requisições de rede\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Selecionar arquivo de configuração\",\n    \"selectPath\": \"Selecionar\",\n    \"showMoreFormats\": \"Mostrar mais opções de formato\",\n    \"showMoreFormatsDescription\": \"Exibir opções de formato adicionais na interface\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Padrão usado quando uma assinatura não substitui seu nome de arquivo.\"\n    },\n    \"system\": \"Sistema\",\n    \"theme\": \"Tema\",\n    \"themeDescription\": \"Escolha um tema claro, escuro ou sistema para VidBee\",\n    \"title\": \"Configurações\",\n    \"tray\": {\n      \"quit\": \"Sair\",\n      \"showHome\": \"Mostrar Início\"\n    },\n    \"video\": \"Preferências de Vídeo\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Assinaturas\",\n    \"subtitle\": \"{{count}} assinatura{{count, plural, one {} other {s}}}\",\n    \"description\": \"Monitore automaticamente feeds RSS e enfileire novos downloads sem trabalho manual.\",\n    \"defaults\": {\n      \"title\": \"Padrões de automação\",\n      \"description\": \"Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.\",\n      \"downloadDirectory\": \"Baixar diretório\",\n      \"filenameTemplate\": \"Modelo de nome de arquivo (somente arquivo)\",\n      \"onlyLatest\": \"Baixe apenas o vídeo mais recente\",\n      \"onlyLatestDescription\": \"Quando ativado, o VidBee ignora os itens mais antigos do backlog e captura apenas o upload mais recente.\"\n    },\n    \"add\": {\n      \"title\": \"Adicionar RSS\",\n      \"description\": \"Cole um link de feed RSS. \\nO VidBee detectará o feed automaticamente.\"\n    },\n    \"fields\": {\n      \"url\": \"URL do feed\",\n      \"keywords\": \"Filtro de palavra-chave (separado por vírgula)\",\n      \"tags\": \"Etiquetas automáticas\",\n      \"customDirectory\": \"Diretório personalizado\",\n      \"namingTemplate\": \"Modelo de nome de arquivo personalizado (somente arquivo)\",\n      \"onlyLatest\": \"Baixe apenas o vídeo mais recente\",\n      \"onlyLatestDescription\": \"Ignore os itens do backlog e busque apenas o upload mais recente deste feed.\",\n      \"enabled\": \"Habilitado\",\n      \"disabled\": \"Desabilitado\",\n      \"onlyLatestShort\": \"Apenas o mais recente\"\n    },\n    \"actions\": {\n      \"add\": \"Adicionar\",\n      \"refresh\": \"Atualizar\",\n      \"edit\": \"Editar\",\n      \"remove\": \"Remover\",\n      \"save\": \"Salvar alterações\",\n      \"selectDirectory\": \"Navegar\",\n      \"enable\": \"Habilitar\",\n      \"disable\": \"Desativar\"\n    },\n    \"items\": {\n      \"title\": \"Últimos envios ({{count}})\",\n      \"count\": \"{{count}} itens\",\n      \"empty\": \"Nenhum item de feed recente encontrado.\",\n      \"status\": {\n        \"queued\": \"Na fila\",\n        \"notQueued\": \"Não está na fila\",\n        \"pending\": \"Pendente\",\n        \"downloading\": \"Baixando\",\n        \"processing\": \"Processamento\",\n        \"completed\": \"Concluído\",\n        \"error\": \"Fracassado\",\n        \"cancelled\": \"Cancelado\"\n      },\n      \"fromChannel\": \"De {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Status do download: {{status}}\",\n        \"downloadPending\": \"Aguardando detalhes do download...\",\n        \"notQueued\": \"Ainda não está na fila de download\"\n      },\n      \"actions\": {\n        \"open\": \"Abrir no navegador\",\n        \"queue\": \"Adicionar à fila de download\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Subscrição\",\n      \"unknown\": \"Assinatura desconhecida\",\n      \"noThumbnail\": \"Sem miniatura\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Falha ao abrir o seletor de diretório.\",\n      \"missingUrl\": \"Cole primeiro o link do canal.\",\n      \"created\": \"Assinatura adicionada\",\n      \"createError\": \"Falha ao adicionar assinatura.\",\n      \"refreshStarted\": \"Atualização iniciada\",\n      \"removed\": \"Assinatura removida\",\n      \"updated\": \"Assinatura atualizada\",\n      \"itemQueued\": \"Adicionado à fila de download\",\n      \"itemAlreadyQueued\": \"Este vídeo já está na fila\",\n      \"queueError\": \"Falha ao adicionar à fila de download.\",\n      \"openLinkError\": \"Falha ao abrir o link do vídeo.\",\n      \"resolveError\": \"Falha ao resolver o URL do feed RSS.\",\n      \"duplicateUrl\": \"Este feed RSS já está inscrito.\"\n    },\n    \"detectedFeed\": \"Feed de {{platform}} detectado -> {{feed}}\",\n    \"detecting\": \"Detectando feed...\",\n    \"latestVideo\": \"Vídeo mais recente: {{title}}\",\n    \"lastChecked\": \"Última verificação: {{time}}\",\n    \"never\": \"Nunca\",\n    \"empty\": \"Ainda não há assinaturas. \\nAdicione seus canais favoritos para iniciar o download automático.\",\n    \"edit\": {\n      \"title\": \"Editar {{name}}\",\n      \"description\": \"Ajuste filtros, tags e substituições para este feed.\"\n    },\n    \"status\": {\n      \"title\": \"Status\",\n      \"up-to-date\": \"Atualizado\",\n      \"checking\": \"Verificando\",\n      \"failed\": \"Fracassado\",\n      \"idle\": \"Parado\",\n      \"tooltip\": {\n        \"updatedAt\": \"Atualizado: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Assinaturas automatizadas com RSSHub\",\n      \"description\": \"Combine VidBee com RSSHub para permitir assinaturas e downloads automatizados de várias plataformas. \\nDepois de configurado, o VidBee é executado em segundo plano e baixa automaticamente os vídeos e conteúdos mais recentes.\",\n      \"learnMore\": \"Saiba mais sobre RSSHub\",\n      \"openDocs\": \"Abra a documentação do RSSHub\",\n      \"hint\": \"Não tem um URL de feed RSS? \\nUse o RSSHub para gerar feeds RSS para YouTube, Twitter e milhares de outras plataformas.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Suporta {{sites}} e mais.\",\n    \"moreDescription\": \"A lista completa do yt-dlp é atualizada constantemente pela comunidade.\",\n    \"moreTitle\": \"Precisa de outro site?\",\n    \"openFullList\": \"Abrir lista completa de sites suportados\",\n    \"pageDescription\": \"VidBee usa yt-dlp nos bastidores para alcançar centenas de fontes.\",\n    \"pageIntro\": \"Aqui estão os serviços principais que as pessoas mais baixam.\",\n    \"pageTitle\": \"Sites Suportados\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Álbuns de artistas independentes e lançamentos da comunidade.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Notícias globais, esportes e clipes de entretenimento.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Vídeos de feed, Watch e Reels de páginas públicas.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Conteúdo de feed, Stories, Reels e Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Streams ao vivo e replays de criadores na plataforma Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Palestras profissionais, webinars e vídeos de aprendizado.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"Mixes de DJ, programas de rádio e áudio long-form.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Animação japonesa, música e arquivo de transmissão ao vivo.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Pins de ideias, Reels de tutoriais e vídeos de inspiração lifestyle.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Clipes incorporados e vídeos hospedados das comunidades.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Faixas musicais, playlists e sets de DJ.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Vídeos curtos móveis, efeitos e streams ao vivo.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Mídia curta criativa e edições de fãs.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Streams ao vivo de gaming, música e IRL e VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Posts de timeline, gravações Spaces e transmissões.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Hospedagem de vídeo de alta qualidade para criadores e empresas.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Vídeo long-form e livestream de criadores em todo o mundo.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Vídeos musicais oficiais, álbuns e performances ao vivo.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Plataformas principais\",\n    \"viewAll\": \"Ver todos os sites suportados\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/ru.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Проверить обновления\",\n      \"download\": \"Скачать\",\n      \"email\": \"Email\",\n      \"feedback\": \"Обратная связь\",\n      \"goToDownload\": \"Перейти на страницу загрузки\",\n      \"openRepo\": \"Открыть репозиторий GitHub\",\n      \"view\": \"Просмотр\",\n      \"visit\": \"Посетить\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Автоматически загружать и устанавливать новые версии в фоновом режиме.\",\n    \"autoUpdateTitle\": \"Автообновления\",\n    \"betaProgramDescription\": \"Получайте ранние сборки и предстоящие функции раньше всех.\",\n    \"betaProgramTitle\": \"Канал предпросмотра\",\n    \"description\": \"VidBee — это бесплатный загрузчик с открытым исходным кодом, созданный на Electron и работающий на yt-dlp.\",\n    \"followAuthorActions\": {\n      \"follow\": \"Подписаться на @nexmoex\"\n    },\n    \"followAuthorDescription\": \"Будьте в курсе последних новостей и обновлений VidBee.\",\n    \"followAuthorSupport\": \"Подпишитесь на разработчика в X (Twitter), чтобы получать последние обновления и новости о VidBee.\",\n    \"followAuthorTitle\": \"Подписаться на разработчика\",\n    \"here\": \"здесь\",\n    \"homepage\": \"Главная страница\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Поиск обновлений...\",\n      \"downloadError\": \"Не удалось загрузить обновление\",\n      \"downloadUpdate\": \"Загрузить и установить обновление {{version}}?\",\n      \"manualDownloadAction\": \"Скачать сейчас\",\n      \"noUpdatesAvailable\": \"Вы используете последнюю версию\",\n      \"restartToUpdate\": \"Перезапустить сейчас для установки обновления?\",\n      \"restartNowAction\": \"Перезапустить сейчас\",\n      \"updateAvailable\": \"Доступно обновление: {{version}}\",\n      \"updateAvailableMessage\": \"Доступна новая версия {{version}}. Пожалуйста, загрузите её с официального сайта.\",\n      \"updateDownloaded\": \"Обновление загружено, перезапустите для установки\",\n      \"updateDownloadedVersion\": \"Обновление {{version}} загружено, перезапустите для установки\",\n      \"updateError\": \"Не удалось проверить обновления: {{error}}\",\n      \"unknownErrorFallback\": \"Неизвестная ошибка\"\n    },\n    \"preferencesDescription\": \"Настройте параметры обновления, не покидая эту страницу.\",\n    \"preferencesTitle\": \"Быстрые переключатели\",\n    \"resources\": {\n      \"changelog\": \"Примечания к выпуску\",\n      \"changelogDescription\": \"Узнайте, что изменилось в каждой версии.\",\n      \"contact\": \"Поддержка по email\",\n      \"contactDescription\": \"Свяжитесь напрямую для получения помощи или сотрудничества.\",\n      \"documentation\": \"Центр помощи\",\n      \"documentationDescription\": \"Руководства, FAQ и общие рабочие процессы.\",\n      \"feedback\": \"Обратная связь и проблемы\",\n      \"feedbackDescription\": \"Поделитесь идеями или сообщите о проблемах на GitHub.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"Сообщайте об ошибках или предлагайте функции на GitHub.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"Поделитесь отзывом или предложениями в X, упомянув @nexmoex.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Присоединяйтесь к нашему сообществу Discord для обсуждений и поддержки.\",\n      \"faq\": \"FAQ\",\n      \"faqDescription\": \"Часто задаваемые вопросы и руководства по устранению неполадок.\",\n      \"license\": \"Лицензия\",\n      \"licenseDescription\": \"Ознакомьтесь с условиями лицензии с открытым исходным кодом.\",\n      \"website\": \"Официальный сайт\",\n      \"websiteDescription\": \"Основные моменты продукта, дорожная карта и новости сообщества.\"\n    },\n    \"resourcesDescription\": \"Полезные ссылки для получения дополнительной информации о VidBee и поддержания связи.\",\n    \"resourcesTitle\": \"Ресурсы\",\n    \"shareActions\": {\n      \"copy\": \"Копировать ссылку\",\n      \"facebook\": \"Поделиться в Facebook\",\n      \"twitter\": \"Поделиться в X (Twitter)\"\n    },\n    \"shareDescription\": \"Поделитесь VidBee с вашим сообществом одним кликом.\",\n    \"shareSupport\": \"Рекомендуйте VidBee своим друзьям, чтобы поддержать наш рост и обновления.\",\n    \"shareTitle\": \"Распространяйте информацию\",\n    \"sourceCode\": \"Исходный код доступен\",\n    \"title\": \"О программе\",\n    \"version\": \"Версия\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Последняя: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Доступна новая версия\",\n      \"uptodate\": \"У вас актуальная версия\",\n      \"error\": \"Не удалось получить последнюю версию\"\n    },\n    \"downloadingUpdate\": \"Загрузка обновления\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"Закрыть приложение после завершения загрузки\",\n    \"currentLocation\": \"Текущее местоположение загрузки - \",\n    \"downloadLocation\": \"Местоположение загрузки\",\n    \"downloadSubs\": \"Загрузить субтитры, если доступны\",\n    \"downloadSubsHint\": \"Сохранять субтитры отдельными файлами, если доступны\",\n    \"end\": \"Конец\",\n    \"endHint\": \"Если оставить пустым, будет загружено до конца\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"Выбрать местоположение загрузки\",\n    \"start\": \"Начало\",\n    \"startHint\": \"Если оставить пустым, начнётся с начала\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Субтитры\",\n    \"timeRange\": \"Загрузить определённый временной диапазон\",\n    \"title\": \"Дополнительные параметры\"\n  },\n  \"app\": {\n    \"description\": \"Загружайте видео и аудио с сотен сайтов\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Активные\",\n    \"all\": \"Все\",\n    \"audio\": \"Аудио\",\n    \"back\": \"Назад\",\n    \"cancel\": \"Отмена\",\n    \"cancelled\": \"Отменено\",\n    \"clearCompleted\": \"Очистить завершённые\",\n    \"clearDownloads\": \"Очистить загрузки\",\n    \"completed\": \"Завершено\",\n    \"downloadAudio\": \"Загрузить аудио\",\n    \"downloadBtn\": \"Загрузить\",\n    \"downloadPending\": \"Ожидание\",\n    \"downloadQueue\": \"Очередь загрузки\",\n    \"customDownloadFolder\": \"Пользовательская папка загрузки\",\n    \"retry\": \"Повторить загрузку\",\n    \"autoFolderPlaceholder\": \"Автоматическая папка (на основе метаданных)\",\n    \"autoFolderHint\": \"Автоматические папки создаются из метаданных.\",\n    \"useAutoFolder\": \"Использовать автоматическую папку\",\n    \"downloadVideo\": \"Загрузить видео\",\n    \"downloading\": \"Загрузка...\",\n    \"enterUrl\": \"Введите URL видео\",\n    \"enterUrlDescription\": \"Вставьте или введите URL видео. \",\n    \"error\": \"Ошибка\",\n    \"fetch\": \"Получить\",\n    \"fetchingVideoInfo\": \"Получение информации о видео...\",\n    \"feedback\": {\n      \"title\": \"Сообщить об этой ошибке:\",\n      \"githubUrlTooLong\": \"Эта ссылка GitHub очень длинная. Если она не открывается, откройте страницу issue и вставьте логи вручную.\"\n    },\n    \"history\": \"История\",\n    \"imageLoadError\": \"Не удалось загрузить изображение\",\n    \"imagePlaceholder\": \"Изображение недоступно\",\n    \"infoUnavailable\": \"Загрузка одним кликом (Информация недоступна)\",\n    \"loading\": \"Загрузка\",\n    \"moreOptions\": \"Дополнительные параметры\",\n    \"noActiveDownloads\": \"Нет активных загрузок\",\n    \"noAudio\": \"Нет аудио\",\n    \"noHistory\": \"Нет истории загрузок\",\n    \"noItems\": \"Элементы не найдены\",\n    \"goToSettings\": \"Перейти в настройки\",\n    \"oneClickDownload\": \"Загрузка одним кликом\",\n    \"oneClickDownloadDescription\": \"Загрузить напрямую с настройками по умолчанию без подтверждения\",\n    \"oneClickDownloadTooltip\": \"Вставьте и скачайте сразу, без лишних шагов\",\n    \"oneClickDownloadNow\": \"Загрузить сейчас\",\n    \"oneClickDownloadStarted\": \"Загрузка начата с настройками по умолчанию\",\n    \"paste\": \"Вставить\",\n    \"pastePlaylistUrl\": \"Нажмите, чтобы вставить ссылку на плейлист из буфера обмена [Ctrl + V]\",\n    \"pasteUrl\": \"Нажмите, чтобы вставить URL видео или ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"Вставить URL\",\n    \"preparing\": \"Подготовка...\",\n    \"processing\": \"Обработка\",\n    \"progress\": \"Прогресс\",\n    \"showDetails\": \"Показать детали\",\n    \"hideDetails\": \"Скрыть детали\",\n    \"viewLogs\": \"Посмотреть логи\",\n    \"detailsTab\": \"Детали\",\n    \"logsTab\": \"Логи\",\n    \"logs\": {\n      \"live\": \"Логи в реальном времени\",\n      \"history\": \"Сохранённые логи\",\n      \"command\": \"Команда yt-dlp\",\n      \"empty\": \"Пока нет логов.\",\n      \"scrollPaused\": \"Прокрутка приостановлена\"\n    },\n    \"selectAudioFormat\": \"Выбрать формат аудио\",\n    \"selectDownloadType\": \"Выберите тип загрузки\",\n    \"selectFormat\": \"Выбрать формат\",\n    \"startDownload\": \"Начать загрузку\",\n    \"selectVideoFormat\": \"Выбрать формат видео\",\n    \"singleVideo\": \"Одно видео\",\n    \"speed\": \"Скорость\",\n    \"title\": \"Название\",\n    \"total\": \"Всего\",\n    \"unknownQuality\": \"Неизвестное качество\",\n    \"unknownSize\": \"Неизвестный размер\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Видео\",\n    \"videoInfo\": \"Информация о видео\",\n    \"videoInfoUpdated\": \"Информация о видео обновлена\",\n    \"metadata\": {\n      \"source\": \"Источник\",\n      \"playlist\": \"Плейлист\",\n      \"format\": \"Формат\",\n      \"quality\": \"Качество\",\n      \"codec\": \"Кодек\",\n      \"savedFile\": \"Сохранённый файл\",\n      \"url\": \"URL источника\",\n      \"description\": \"Описание\",\n      \"views\": \"Просмотры\",\n      \"tags\": \"Теги\",\n      \"downloadPath\": \"Путь загрузки\",\n      \"createdAt\": \"Создано\",\n      \"startedAt\": \"Начато\",\n      \"completedAt\": \"Завершено\",\n      \"speed\": \"Скорость\",\n      \"fileSize\": \"Размер файла\",\n      \"width\": \"Ширина\",\n      \"height\": \"Высота\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Видеокодек\",\n      \"audioCodec\": \"Аудиокодек\",\n      \"formatNote\": \"Примечание формата\",\n      \"protocol\": \"Протокол\",\n      \"subscription\": \"Подписка\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Что-то пошло не так\",\n    \"description\": \"Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение или сообщите об этой проблеме, если она повторится.\",\n    \"message\": \"Сообщение об ошибке\",\n    \"unknownError\": \"Произошла неизвестная ошибка\",\n    \"goHome\": \"На главную\",\n    \"reload\": \"Перезагрузить приложение\",\n    \"copyReport\": \"Копировать отчет об ошибке\",\n    \"copied\": \"Скопировано!\",\n    \"copySuccess\": \"Отчет об ошибке скопирован в буфер обмена\",\n    \"copyFailed\": \"Не удалось скопировать отчет об ошибке\",\n    \"showDetails\": \"Показать детали\",\n    \"hideDetails\": \"Скрыть детали\",\n    \"stackTrace\": \"Трассировка стека\",\n    \"componentStack\": \"Стек компонентов\",\n    \"noStackTrace\": \"Нет доступной трассировки стека\",\n    \"fullReport\": \"Полный отчет об ошибке\",\n    \"helpText\": \"Если эта ошибка повторяется, скопируйте отчет выше и поделитесь им с командой поддержки. Контактные данные можно найти на странице «О программе».\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Нажмите, чтобы скопировать детали\",\n    \"clipboardEmpty\": \"Буфер обмена пуст\",\n    \"downloadFailed\": \"Загрузка не удалась\",\n    \"downloadNecessaryFilesFailed\": \"Не удалось загрузить необходимые файлы. Пожалуйста, проверьте вашу сеть и попробуйте снова\",\n    \"emptyUrl\": \"Пожалуйста, введите URL\",\n    \"errorDetails\": \"Детали ошибки\",\n    \"fetchInfoFailed\": \"Не удалось получить информацию о видео\",\n    \"invalidUrl\": \"Содержимое буфера обмена не является допустимым URL\",\n    \"networkError\": \"Произошла ошибка. Проверьте вашу сеть и используйте правильный URL\",\n    \"pasteFromClipboard\": \"Не удалось вставить из буфера обмена\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"Очистить отменённые\",\n    \"clearCompleted\": \"Очистить завершённые\",\n    \"clearErrors\": \"Очистить ошибки\",\n    \"clearAll\": \"Очистить всю историю\",\n    \"clearAllAction\": \"Очистить историю\",\n    \"clearSelection\": \"Очистить выбор\",\n    \"confirmClearAllTitle\": \"Очистить всю историю?\",\n    \"confirmClearAllDescription\": \"Удалить {{count}} элементов из истории. Файлы останутся на диске.\",\n    \"confirmDeleteSelectedTitle\": \"Удалить выбранные элементы?\",\n    \"confirmDeleteSelectedDescription\": \"Удалить {{count}} элементов из истории. Файлы останутся на диске.\",\n    \"alsoDeleteFiles\": \"Также удалить файлы\",\n    \"confirmDeletePlaylistTitle\": \"Удалить историю плейлиста?\",\n    \"confirmDeletePlaylistDescription\": \"Удалить {{count}} элементов из {{title}} и удалить их файлы.\",\n    \"copyToClipboard\": \"Копировать в буфер обмена\",\n    \"copyUrl\": \"Копировать URL\",\n    \"date\": \"Дата\",\n    \"deletePlaylist\": \"Удалить плейлист\",\n    \"deleteSelected\": \"Удалить выбранные\",\n    \"description\": \"Просмотр и управление историей загрузок\",\n    \"doneSelecting\": \"Готово\",\n    \"duration\": \"Длительность\",\n    \"fileSize\": \"Размер файла\",\n    \"filters\": {\n      \"all\": \"Все\",\n      \"cancelled\": \"Отменённые\",\n      \"completed\": \"Завершённые\",\n      \"errors\": \"Ошибки\"\n    },\n    \"noHistory\": \"Истории загрузок пока нет\",\n    \"noHistoryDescription\": \"Ваши завершённые загрузки появятся здесь\",\n    \"cookiesTipTitle\": \"Повышайте успешность загрузок с cookie\",\n    \"cookiesTipDescription\": \"Настройте cookie, чтобы повысить успешность с <strong>70%</strong> до <strong>99%</strong>.\",\n    \"cookiesTipCta\": \"Настроить cookie\",\n    \"openDownloadFolder\": \"Открыть папку загрузок\",\n    \"openFile\": \"Открыть файл\",\n    \"openFileLocation\": \"Открыть местоположение файла\",\n    \"openFolder\": \"Открыть папку\",\n    \"openInBrowser\": \"Нажмите, чтобы открыть в браузере\",\n    \"removeAction\": \"Удалить\",\n    \"removeItem\": \"Удалить элемент\",\n    \"deleteFile\": \"Удалить файл\",\n    \"deleteRecord\": \"Удалить из списка\",\n    \"select\": \"Выбрать\",\n    \"selectAll\": \"Выбрать все\",\n    \"selectVisible\": \"Выбрать видимые\",\n    \"selectItem\": \"Выбрать элемент\",\n    \"selectedCount\": \"Выбрано: {{count}}\",\n    \"selectionSummary\": \"{{selected}} из {{total}} видимых выбрано\",\n    \"stats\": {\n      \"cancelled\": \"Отменённые\",\n      \"completed\": \"Завершённые\",\n      \"errors\": \"Ошибки\",\n      \"total\": \"Всего\"\n    },\n    \"status\": {\n      \"cancelled\": \"Отменено\",\n      \"completed\": \"Завершено\",\n      \"error\": \"Ошибка\"\n    },\n    \"title\": \"История загрузок\"\n  },\n  \"menu\": {\n    \"about\": \"О программе\",\n    \"download\": \"Загрузить\",\n    \"playlist\": \"Загрузить плейлист\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Подписки\",\n    \"preferences\": \"Настройки\",\n    \"supportedSites\": \"Поддерживаемые сайты\",\n    \"theme\": \"Тема:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Не удалось скопировать в буфер обмена\",\n    \"downloadCompleted\": \"Загрузка завершена\",\n    \"downloadAlreadyQueued\": \"Эта загрузка уже выполняется\",\n    \"downloadFailed\": \"Загрузка не удалась\",\n    \"historyCleared\": \"История очищена\",\n    \"historyClearFailed\": \"Не удалось очистить историю\",\n    \"itemRemoved\": \"Элемент удалён\",\n    \"itemsRemoved\": \"{{count}} элементов удалено\",\n    \"itemsRemoveFailed\": \"Не удалось удалить выбранные элементы\",\n    \"openFileFailed\": \"Не удалось открыть файл\",\n    \"openFolderFailed\": \"Не удалось открыть папку\",\n    \"playlistHistoryRemoved\": \"Плейлист удален, файлы удалены\",\n    \"playlistHistoryRemoveFailed\": \"Не удалось удалить историю плейлиста\",\n    \"removeFailed\": \"Не удалось удалить элемент\",\n    \"settingsSaved\": \"Настройки сохранены\",\n    \"urlCopied\": \"URL скопирован в буфер обмена\",\n    \"videoCopied\": \"Видео скопировано в буфер обмена\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Плейлист\",\n    \"clearPreview\": \"Очистить предпросмотр\",\n    \"collapsedProgress\": \"Загрузка плейлиста: {{completed}} / {{total}} завершено\",\n    \"comingSoon\": \"Функция загрузки плейлиста скоро появится!\",\n    \"completed\": \"Плейлист загружен\",\n    \"description\": \"Загрузить все видео из плейлиста или канала YouTube\",\n    \"downloadFailed\": \"Не удалось начать загрузку плейлиста\",\n    \"downloadPlaylist\": \"Загрузить плейлист\",\n    \"downloadType\": \"Тип загрузки\",\n    \"downloading\": \"Загрузка плейлиста:\",\n    \"endIndex\": \"Конец\",\n    \"enterPlaylistUrl\": \"Введите URL плейлиста\",\n    \"fetchFailed\": \"Не удалось получить информацию о плейлисте\",\n    \"filenameFormat\": \"Формат имени файла для плейлистов\",\n    \"folderFormat\": \"Формат имени папки для плейлистов\",\n    \"foundVideos\": \"Найдено {{count}} видео в плейлисте\",\n    \"groupActive\": \"{{count}} активных\",\n    \"groupCollapse\": \"Свернуть\",\n    \"groupErrors\": \"{{count}} ошибок\",\n    \"groupExpand\": \"Развернуть\",\n    \"groupSummary\": \"{{completed}} / {{total}} завершено\",\n    \"linkLabel\": \"URL плейлиста\",\n    \"noEntries\": \"В этом плейлисте не найдено видео\",\n    \"noEntriesInRange\": \"Нет видео в выбранном диапазоне\",\n    \"noRangeSelected\": \"Конец не установлен - выбран весь плейлист\",\n    \"playlistUrlDescription\": \"Загрузить все видео из плейлиста массово\",\n    \"positionLabel\": \"Элемент {{index}} из {{total}}\",\n    \"previewButton\": \"Предпросмотр плейлиста\",\n    \"previewFailed\": \"Не удалось предпросмотреть плейлист\",\n    \"previewSummary\": \"Предпросмотр элементов плейлиста перед загрузкой.\",\n    \"previewRequired\": \"Предпросмотрите плейлист перед загрузкой.\",\n    \"range\": \"Диапазон (необязательно)\",\n    \"resetToDefault\": \"Сбросить на значения по умолчанию\",\n    \"selectedRange\": \"Диапазон: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} выбрано\",\n    \"downloadCurrentRange\": \"Загрузить выбранное\",\n    \"showingCount\": \"Показано {{count}} видео\",\n    \"selectEntry\": \"Выбрать запись {{index}}\",\n    \"noEntriesSelected\": \"Нет выбранных записей\",\n    \"startIndex\": \"Начало (1)\",\n    \"title\": \"Загрузить плейлист\",\n    \"totalVideos\": \"Всего видео: {{count}}\",\n    \"untitled\": \"Плейлист без названия\",\n    \"fetchingInfo\": \"Получение информации о плейлисте...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"О программе\",\n    \"advanced\": \"Дополнительно\",\n    \"cookiesTab\": \"Cookie\",\n    \"app\": \"Настройки приложения\",\n    \"audio\": \"Настройки аудио\",\n    \"browserForCookies\": \"Выбрать браузер для использования cookie\",\n    \"browserForCookiesDescription\": \"Браузер для извлечения cookie для аутентификации\",\n    \"browserForCookiesWindowsNote\": \"В Windows поддерживаются только cookie Firefox. Для других браузеров вручную укажите файл cookie.\",\n    \"browserForCookiesProfile\": \"Имя профиля или путь\",\n    \"browserForCookiesProfileDescription\": \"Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.\",\n    \"browserForCookiesProfilePlaceholder\": \"Имя профиля или полный путь (необязательно)\",\n    \"browserForCookiesProfileInvalid\": \"Путь профиля недействителен. Выберите папку профиля для выбранного браузера.\",\n    \"browserForCookiesProfileInvalidPath\": \"Эта папка не существует. Выберите существующую папку профиля.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Имя профиля не найдено в стандартном расположении браузера.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Для этого браузера на этой платформе неизвестно стандартное расположение профиля.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Введите путь профиля для выбранного браузера.\",\n    \"cookiesFile\": \"Файл cookie\",\n    \"cookiesFileDescription\": \"Файл cookie в формате Netscape для загрузки для аутентификации\",\n    \"clearCookiesFile\": \"Очистить\",\n    \"cookiesHelpTitle\": \"Использование cookie\",\n    \"cookiesHelpBrowser\": \"Выберите ваш браузер выше, чтобы автоматически использовать его сеанс входа.\",\n    \"cookiesHelpFile\": \"Экспортируйте файл cookie Netscape (см. FAQ yt-dlp) и выберите его здесь при необходимости.\",\n    \"cookiesGuideTitle\": \"Нужна инструкция?\",\n    \"cookiesGuideDescription\": \"Посмотрите пошаговое руководство по использованию cookie в VidBee.\",\n    \"cookiesGuideLink\": \"Открыть руководство по cookie\",\n    \"openLinkError\": \"Не удалось открыть ссылку\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Использовать файл конфигурации\",\n    \"configFileDescription\": \"Пользовательский файл конфигурации для yt-dlp\",\n    \"clearConfigFile\": \"Очистить\",\n    \"dark\": \"Тёмная\",\n    \"description\": \"Настройте ваши предпочтения загрузки и настройки приложения\",\n    \"directorySelectError\": \"Не удалось выбрать директорию\",\n    \"downloadPath\": \"Местоположение загрузки\",\n    \"downloadPathDescription\": \"Выберите, где сохранять загруженные файлы\",\n    \"fileSelectError\": \"Не удалось выбрать файл\",\n    \"general\": \"Общие\",\n    \"language\": \"Язык\",\n    \"languageDescription\": \"Выберите предпочтительный язык интерфейса приложения\",\n    \"light\": \"Светлая\",\n    \"hideDockIcon\": \"Скрыть иконку Dock\",\n    \"hideDockIconDescription\": \"Удалить VidBee из Dock macOS. Используйте строку меню или иконку в трее, чтобы снова открыть приложение.\",\n    \"launchAtLogin\": \"Запускать при входе\",\n    \"launchAtLoginDescription\": \"Автоматически открывать VidBee после входа в систему.\",\n    \"launchAtLoginUnsupported\": \"Автозапуск доступен только в macOS и Windows.\",\n    \"enableAnalytics\": \"Помочь улучшить VidBee\",\n    \"enableAnalyticsDescription\": \"Поделитесь анонимными данными об использовании, чтобы помочь нам понять, как используется приложение, и расставить приоритеты улучшений.\",\n    \"embedChapters\": \"Встраивать главы\",\n    \"embedChaptersDescription\": \"Добавлять маркеры глав в файл, если доступны\",\n    \"embedMetadata\": \"Встраивать метаданные\",\n    \"embedMetadataDescription\": \"Записывать название, исполнителя и другие метаданные, если доступны\",\n    \"embedSubs\": \"Встраивать субтитры\",\n    \"embedSubsDescription\": \"Встраивать субтитры в файл видео (mp4, webm, mkv)\",\n    \"embedThumbnail\": \"Встраивать миниатюру\",\n    \"embedThumbnailDescription\": \"Добавлять миниатюру как обложку\",\n    \"shareWatermark\": \"Водяной знак для обмена\",\n    \"shareWatermarkDescription\": \"Добавляет водяной знак с оригинальным названием, автором и брендом VidBee\",\n    \"maxConcurrentDownloads\": \"Максимальное количество активных загрузок\",\n    \"maxConcurrentDownloadsDescription\": \"Максимальное количество одновременных загрузок\",\n    \"none\": \"Нет\",\n    \"oneClickDownload\": \"Загрузка одним кликом\",\n    \"oneClickDownloadDescription\": \"Включить загрузку одним кликом с настройками по умолчанию\",\n    \"oneClickDownloadType\": \"Тип загрузки по умолчанию\",\n    \"oneClickDownloadTypeDescription\": \"Выберите тип загрузки по умолчанию для загрузок одним кликом. Качество использует предустановку ниже.\",\n    \"oneClickQuality\": \"Предпочтительное качество\",\n    \"oneClickQualityDescription\": \"Выберите предустановку качества, используемую для загрузок одним кликом\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Авто\",\n      \"bad\": \"Плохое\",\n      \"best\": \"Лучшее\",\n      \"good\": \"Хорошее\",\n      \"normal\": \"Обычное\",\n      \"worst\": \"Худшее\"\n    },\n    \"proxy\": \"Прокси\",\n    \"proxyDescription\": \"Прокси-сервер для сетевых запросов\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Выбрать файл конфигурации\",\n    \"selectPath\": \"Выбрать\",\n    \"showMoreFormats\": \"Показать больше вариантов форматов\",\n    \"showMoreFormatsDescription\": \"Отображать дополнительные варианты форматов в интерфейсе\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Шаблон, используемый, когда подписка не переопределяет имя файла.\"\n    },\n    \"system\": \"Системная\",\n    \"theme\": \"Тема\",\n    \"themeDescription\": \"Выберите светлую, тёмную или системную тему для VidBee\",\n    \"title\": \"Настройки\",\n    \"tray\": {\n      \"quit\": \"Выход\",\n      \"showHome\": \"Показать главную\"\n    },\n    \"video\": \"Настройки видео\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Подписки\",\n    \"subtitle\": \"{{count}} подписка{{count, plural, one {} other {}}}\",\n    \"description\": \"Автоматически отслеживайте RSS-каналы и добавляйте новые загрузки в очередь без ручной работы.\",\n    \"defaults\": {\n      \"title\": \"Настройки автоматизации по умолчанию\",\n      \"description\": \"Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.\",\n      \"downloadDirectory\": \"Директория загрузки\",\n      \"filenameTemplate\": \"Шаблон имени файла (только файл)\",\n      \"onlyLatest\": \"Загружать только последнее видео\",\n      \"onlyLatestDescription\": \"При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку.\"\n    },\n    \"add\": {\n      \"title\": \"Добавить RSS\",\n      \"description\": \"Вставьте ссылку на RSS-канал. VidBee автоматически определит канал.\"\n    },\n    \"fields\": {\n      \"url\": \"URL канала\",\n      \"keywords\": \"Фильтр ключевых слов (разделено запятыми)\",\n      \"tags\": \"Автоматические теги\",\n      \"customDirectory\": \"Пользовательская директория\",\n      \"namingTemplate\": \"Пользовательский шаблон имени файла (только файл)\",\n      \"onlyLatest\": \"Загружать только последнее видео\",\n      \"onlyLatestDescription\": \"Игнорировать элементы из очереди и загружать только последнюю загрузку из этого канала.\",\n      \"enabled\": \"Включено\",\n      \"disabled\": \"Отключено\",\n      \"onlyLatestShort\": \"Только последнее\"\n    },\n    \"actions\": {\n      \"add\": \"Добавить\",\n      \"refresh\": \"Обновить\",\n      \"edit\": \"Редактировать\",\n      \"remove\": \"Удалить\",\n      \"save\": \"Сохранить изменения\",\n      \"selectDirectory\": \"Обзор\",\n      \"enable\": \"Включить\",\n      \"disable\": \"Отключить\"\n    },\n    \"items\": {\n      \"title\": \"Последние загрузки ({{count}})\",\n      \"count\": \"{{count}} элементов\",\n      \"empty\": \"Не найдено недавних элементов канала.\",\n      \"status\": {\n        \"queued\": \"В очереди\",\n        \"notQueued\": \"Не в очереди\",\n        \"pending\": \"Ожидание\",\n        \"downloading\": \"Загрузка\",\n        \"processing\": \"Обработка\",\n        \"completed\": \"Завершено\",\n        \"error\": \"Ошибка\",\n        \"cancelled\": \"Отменено\"\n      },\n      \"fromChannel\": \"От {{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"Статус загрузки: {{status}}\",\n        \"downloadPending\": \"Ожидание деталей загрузки...\",\n        \"notQueued\": \"Ещё не в очереди загрузки\"\n      },\n      \"actions\": {\n        \"open\": \"Открыть в браузере\",\n        \"queue\": \"Добавить в очередь загрузки\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Подписка\",\n      \"unknown\": \"Неизвестная подписка\",\n      \"noThumbnail\": \"Нет миниатюры\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Не удалось открыть выбор директории.\",\n      \"missingUrl\": \"Пожалуйста, сначала вставьте ссылку на канал.\",\n      \"created\": \"Подписка добавлена\",\n      \"createError\": \"Не удалось добавить подписку.\",\n      \"refreshStarted\": \"Обновление начато\",\n      \"removed\": \"Подписка удалена\",\n      \"updated\": \"Подписка обновлена\",\n      \"itemQueued\": \"Добавлено в очередь загрузки\",\n      \"itemAlreadyQueued\": \"Это видео уже в очереди\",\n      \"queueError\": \"Не удалось добавить в очередь загрузки.\",\n      \"openLinkError\": \"Не удалось открыть ссылку на видео.\",\n      \"resolveError\": \"Не удалось разрешить URL RSS-канала.\",\n      \"duplicateUrl\": \"Этот RSS-канал уже добавлен.\"\n    },\n    \"detectedFeed\": \"Обнаружен канал {{platform}} -> {{feed}}\",\n    \"detecting\": \"Определение канала...\",\n    \"latestVideo\": \"Последнее видео: {{title}}\",\n    \"lastChecked\": \"Последняя проверка: {{time}}\",\n    \"never\": \"Никогда\",\n    \"empty\": \"Пока нет подписок. Добавьте ваши любимые каналы, чтобы начать автоматическую загрузку.\",\n    \"edit\": {\n      \"title\": \"Редактировать {{name}}\",\n      \"description\": \"Настройте фильтры, теги и переопределения для этого канала.\"\n    },\n    \"status\": {\n      \"title\": \"Статус\",\n      \"up-to-date\": \"Актуально\",\n      \"checking\": \"Проверка\",\n      \"failed\": \"Ошибка\",\n      \"idle\": \"Простой\",\n      \"tooltip\": {\n        \"updatedAt\": \"Обновлено: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"Автоматические подписки с RSSHub\",\n      \"description\": \"Объедините VidBee с RSSHub для включения автоматических подписок и загрузок с различных платформ. После настройки VidBee работает в фоновом режиме и автоматически загружает последние видео и контент.\",\n      \"learnMore\": \"Узнать больше о RSSHub\",\n      \"openDocs\": \"Открыть документацию RSSHub\",\n      \"hint\": \"Нет URL RSS-канала? Используйте RSSHub для создания RSS-каналов для YouTube, Twitter и тысяч других платформ.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"Поддерживает {{sites}} и другие.\",\n    \"moreDescription\": \"Полный список yt-dlp постоянно обновляется сообществом.\",\n    \"moreTitle\": \"Нужен другой сайт?\",\n    \"openFullList\": \"Открыть полный список поддерживаемых сайтов\",\n    \"pageDescription\": \"VidBee использует yt-dlp под капотом для доступа к сотням источников.\",\n    \"pageIntro\": \"Вот основные сервисы, с которых люди чаще всего загружают.\",\n    \"pageTitle\": \"Поддерживаемые сайты\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Альбомы независимых артистов и релизы сообщества.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Глобальные новости, спорт и развлекательные клипы.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Видео из ленты, Watch и Reels с публичных страниц.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Контент из ленты, Stories, Reels и Highlights.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Прямые трансляции и повторы создателей на платформе Kick.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Профессиональные выступления, вебинары и обучающие видео.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ-миксы, радиошоу и длинные аудиоформаты.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Японская анимация, музыка и архив прямых трансляций.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Идеи для пинов, обучающие ролики и видео-вдохновения для образа жизни.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Встроенные клипы и размещённые видео из сообществ.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Музыкальные треки, плейлисты и DJ-сеты.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Короткие мобильные видео, эффекты и прямые трансляции.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Креативные короткие медиа и фанатские правки.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Игровые, музыкальные и IRL прямые трансляции и VOD.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Посты из ленты, записи Spaces и трансляции.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Высококачественный хостинг видео для создателей и бизнеса.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Длинные видео и прямые трансляции от создателей по всему миру.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Официальные музыкальные видео, альбомы и живые выступления.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Основные платформы\",\n    \"viewAll\": \"Просмотреть все поддерживаемые сайты\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/tr.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"Güncellemeleri Kontrol Et\",\n      \"download\": \"İndir\",\n      \"email\": \"E-posta\",\n      \"feedback\": \"Geri Bildirim\",\n      \"goToDownload\": \"İndirme sayfasına git\",\n      \"openRepo\": \"Github Deposunu aç\",\n      \"view\": \"Görüntüle\",\n      \"visit\": \"Ziyaret Et\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"Yeni sürümleri arka planda otomatik olarak indir ve yükleyin.\",\n    \"autoUpdateTitle\": \"Otomatik güncellemeler\",\n    \"betaProgramDescription\": \"Herkesten önce erken sürümleri ve gelecek özellikleri alın.\",\n    \"betaProgramTitle\": \"Kanal önizlemesi\",\n    \"description\": \"VidBee - RSS otomatik indirme, toplu işleme ve 1000'den fazla platform desteği sunan ücretsiz ve açık kaynaklı otomatik video indirici.\",\n    \"followAuthorActions\": {\n      \"follow\": \"@nexmoex'i takip edin\"\n    },\n    \"followAuthorDescription\": \"VidBee ile ilgili en son haberleri ve güncellemeleri takip edin.\",\n    \"followAuthorSupport\": \"VidBee ile ilgili en son güncellemeleri ve haberleri almak için geliştiriciyi X (Twitter) üzerinden takip edin.\",\n    \"followAuthorTitle\": \"Geliştiriciyi takip edin\",\n    \"here\": \"burada\",\n    \"homepage\": \"Ana Sayfa\",\n    \"notifications\": {\n      \"checkingUpdates\": \"Güncellemeler aranıyor...\",\n      \"downloadError\": \"Güncelleme indirilemedi\",\n      \"downloadUpdate\": \"{{version}} güncellemesini indirip yüklemek ister misiniz?\",\n      \"manualDownloadAction\": \"Şimdi indir\",\n      \"noUpdatesAvailable\": \"En son sürümü kullanıyorsunuz\",\n      \"restartToUpdate\": \"Güncellemek için şimdi yeniden başlatmak ister misiniz?\",\n      \"restartNowAction\": \"Şimdi yeniden başlat\",\n      \"updateAvailable\": \"Güncelleme mevcut: {{version}}\",\n      \"updateAvailableMessage\": \"Yeni sürüm {{version}} mevcuttur. Lütfen resmi web sitesinden indirin.\",\n      \"updateDownloaded\": \"Güncelleme indirildi, yüklemek için yeniden başlatın\",\n      \"updateDownloadedVersion\": \"{{version}} güncellemesi indirildi, yüklemek için yeniden başlatın\",\n      \"updateError\": \"Güncellemeleri kontrol edemedi: {{error}}\",\n      \"unknownErrorFallback\": \"Bilinmeyen hata\"\n    },\n    \"preferencesDescription\": \"Bu sayfadan ayrılmadan güncelleme ayarlarını düzenleyin.\",\n    \"preferencesTitle\": \"Hızlı Geçişler\",\n    \"resources\": {\n      \"changelog\": \"Sürüm notları\",\n      \"changelogDescription\": \"Her sürümde nelerin değiştiğini öğrenin.\",\n      \"contact\": \"E-posta desteği\",\n      \"contactDescription\": \"Yardım veya işbirliği için doğrudan iletişime geçin.\",\n      \"documentation\": \"Yardım merkezi\",\n      \"documentationDescription\": \"Kılavuzlar, SSS'ler ve yaygın iş akışları.\",\n      \"feedback\": \"Geri bildirim & sorunlar\",\n      \"feedbackDescription\": \"Fikirlerinizi paylaşın, sorunları bildirin veya birden fazla kanal aracılığıyla geri bildirimde bulunun.\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"GitHub'da hataları bildirin veya özellikler talep edin.\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"@nexmoex'i etiketleyerek X hakkında geri bildirim veya önerilerinizi paylaşın.\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"Tartışmalar ve destek için Discord topluluğumuza katılın.\",\n      \"faq\": \"SSS\",\n      \"faqDescription\": \"SSS ve sorun giderme kılavuzları.\",\n      \"license\": \"Lisans\",\n      \"licenseDescription\": \"Açık kaynak lisans koşullarını inceleyin.\",\n      \"website\": \"Resmi web sitesi\",\n      \"websiteDescription\": \"Ürün özellikleri, yol haritası ve topluluk haberleri.\"\n    },\n    \"resourcesDescription\": \"VidBee hakkında daha fazla bilgi edinmek ve bağlantıda kalmak için faydalı bağlantılar.\",\n    \"resourcesTitle\": \"Kaynaklar\",\n    \"shareActions\": {\n      \"copy\": \"Bağlantıyı kopyala\",\n      \"facebook\": \"Facebook'ta paylaş\",\n      \"twitter\": \"X'te paylaş (Twitter)\"\n    },\n    \"shareDescription\": \"VidBee'yi tek bir tıklama ile topluluğunuzla paylaşın.\",\n    \"shareSupport\": \"VidBee'yi arkadaşlarınıza tavsiye ederek büyümemizi ve güncellemelerimizi destekleyin.\",\n    \"shareTitle\": \"Haberi yayın\",\n    \"sourceCode\": \"Kaynak kodu mevcuttur\",\n    \"title\": \"Hakkında\",\n    \"version\": \"Sürüm\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"Son sürüm: v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"Yeni sürüm mevcut\",\n      \"uptodate\": \"Güncelsiniz\",\n      \"error\": \"En son sürümü alamıyor\"\n    },\n    \"downloadingUpdate\": \"Güncelleme indiriliyor\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"İndirme bittiğinde uygulamayı kapat\",\n    \"currentLocation\": \"Mevcut indirme konumu - \",\n    \"downloadLocation\": \"İndirme konumu\",\n    \"downloadSubs\": \"Mümkünse altyazıları indirin\",\n    \"downloadSubsHint\": \"Mümkün olduğunda altyazıları ayrı dosyalar olarak kaydedin\",\n    \"end\": \"Son\",\n    \"endHint\": \"Boş bırakılırsa, sonuna kadar indirilecektir.\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"İndirme Konumunu Seç\",\n    \"start\": \"Başlat\",\n    \"startHint\": \"Boş bırakılırsa, baştan başlayacaktır.\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"Altyazılar\",\n    \"timeRange\": \"Belirli bir zaman aralığını indirin\",\n    \"title\": \"Gelişmiş Seçenekler\"\n  },\n  \"app\": {\n    \"description\": \"Yüzlerce siteden video ve ses dosyalarını indirin\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"Aktif\",\n    \"all\": \"Tümü\",\n    \"audio\": \"Ses\",\n    \"back\": \"Geri\",\n    \"cancel\": \"İptal Et\",\n    \"cancelled\": \"İptal edildi\",\n    \"clearCompleted\": \"Tamamlananları Temizle\",\n    \"clearDownloads\": \"İndirmeleri Temizle\",\n    \"completed\": \"Tamamlandı\",\n    \"downloadAudio\": \"Sesi İndir\",\n    \"downloadBtn\": \"İndir\",\n    \"downloadPending\": \"Beklemede\",\n    \"downloadQueue\": \"İndirme Kuyruğu\",\n    \"customDownloadFolder\": \"Özel indirme klasörü\",\n    \"retry\": \"İndirmeyi Tekrar Dene\",\n    \"autoFolderPlaceholder\": \"Otomatik klasör (meta verilere göre)\",\n    \"autoFolderHint\": \"Otomatik klasörler meta verilerden oluşturulur.\",\n    \"useAutoFolder\": \"Otomatik klasör kullan\",\n    \"downloadVideo\": \"Videoyu İndir\",\n    \"downloading\": \"İndiriliyor...\",\n    \"enterUrl\": \"Video URL'sini girin\",\n    \"enterUrlDescription\": \"Bir video URL'ini yapıştırın veya yazın. \",\n    \"error\": \"Hata\",\n    \"fetch\": \"Getir\",\n    \"fetchingVideoInfo\": \"Video bilgileri alınıyor...\",\n    \"feedback\": {\n      \"title\": \"Bu hatayı bildir:\",\n      \"githubUrlTooLong\": \"Bu GitHub bağlantısı çok uzundur. Açılmıyorsa, lütfen sorun sayfasını açın ve günlükleri manuel olarak yapıştırın.\"\n    },\n    \"history\": \"Geçmiş\",\n    \"imageLoadError\": \"Görsel yüklenemedi\",\n    \"imagePlaceholder\": \"Görsel mevcut değil\",\n    \"infoUnavailable\": \"Tek Tıkla İndir (Bilgi mevcut değil)\",\n    \"loading\": \"Yükleniyor\",\n    \"moreOptions\": \"Daha fazla seçenek\",\n    \"noActiveDownloads\": \"Aktif indirme yok\",\n    \"noAudio\": \"Ses Yok\",\n    \"noHistory\": \"İndirme geçmişi yok\",\n    \"noItems\": \"Öğeler bulunamadı\",\n    \"goToSettings\": \"Ayarlar'a git\",\n    \"oneClickDownload\": \"Tek Tıkla İndir\",\n    \"oneClickDownloadDescription\": \"Onay olmadan varsayılan ayarlarla doğrudan indir\",\n    \"oneClickDownloadTooltip\": \"Yapıştır ve anında indir, adımları atla\",\n    \"oneClickDownloadNow\": \"Şimdi İndir\",\n    \"oneClickDownloadStarted\": \"İndirme varsayılan ayarlarla başladı\",\n    \"paste\": \"Yapıştır\",\n    \"pastePlaylistUrl\": \"Panodan oynatma listesi bağlantısını yapıştırmak için tıklayın [Ctrl + V]\",\n    \"pasteUrl\": \"Video URL'ini veya kimliğini yapıştırmak için tıklayın [Ctrl + V]\",\n    \"pasteUrlButton\": \"URL'i yapıştır\",\n    \"preparing\": \"Hazırlanıyor...\",\n    \"processing\": \"İşleniyor\",\n    \"progress\": \"İlerleme\",\n    \"showDetails\": \"Ayrıntıları göster\",\n    \"hideDetails\": \"Ayrıntıları gizle\",\n    \"viewLogs\": \"Günlükleri görüntüle\",\n    \"detailsTab\": \"Detaylar\",\n    \"logsTab\": \"Günlükler\",\n    \"logs\": {\n      \"live\": \"Canlı günlükler\",\n      \"history\": \"Kaydedilen günlükler\",\n      \"command\": \"yt-dlp komutu\",\n      \"empty\": \"Henüz kayıt yok.\",\n      \"scrollPaused\": \"Kaydırma duraklatıldı\"\n    },\n    \"selectAudioFormat\": \"Ses Formatını Seçin\",\n    \"selectDownloadType\": \"İndirme türünü seçin\",\n    \"selectFormat\": \"Format seçin\",\n    \"startDownload\": \"İndirme'yi Başlat\",\n    \"selectVideoFormat\": \"Video Formatını Seç\",\n    \"singleVideo\": \"Tekli Video\",\n    \"speed\": \"Hız\",\n    \"title\": \"Başlık\",\n    \"total\": \"Toplam\",\n    \"unknownQuality\": \"Bilinmeyen kalite\",\n    \"unknownSize\": \"Bilinmeyen boyut\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"Video\",\n    \"videoInfo\": \"Video Bilgisi\",\n    \"videoInfoUpdated\": \"Video bilgisi güncellendi\",\n    \"metadata\": {\n      \"source\": \"Kaynak\",\n      \"playlist\": \"Oynatma listesi\",\n      \"format\": \"Format\",\n      \"quality\": \"Kalite\",\n      \"codec\": \"Kodlayıcı\",\n      \"savedFile\": \"Kaydedilen dosya\",\n      \"url\": \"Kaynak URL\",\n      \"description\": \"Açıklama\",\n      \"views\": \"Görüntülemeler\",\n      \"tags\": \"Etiketler\",\n      \"downloadPath\": \"İndirme yolu\",\n      \"createdAt\": \"Oluşturuldu\",\n      \"startedAt\": \"Başlatıldı\",\n      \"completedAt\": \"Tamamlandı\",\n      \"speed\": \"Hız\",\n      \"fileSize\": \"Dosya boyutu\",\n      \"width\": \"Genişlik\",\n      \"height\": \"Yükseklik\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"Video kodlayıcı\",\n      \"audioCodec\": \"Ses kodlayıcı\",\n      \"formatNote\": \"Format notu\",\n      \"protocol\": \"Protokol\",\n      \"subscription\": \"Abonelik\"\n    }\n  },\n  \"error\": {\n    \"title\": \"Bir sorun oluştu\",\n    \"description\": \"Beklenmedik bir hata oluştu. Lütfen uygulamayı yeniden yüklemeyi deneyin veya sorun devam ederse bunu bildirin.\",\n    \"message\": \"Hata Mesajı\",\n    \"unknownError\": \"Bilinmeyen bir hata oluştu\",\n    \"goHome\": \"Ana Sayfa'ya Git\",\n    \"reload\": \"Uygulamayı Yeniden Yükle\",\n    \"copyReport\": \"Kopyalama Hatası Raporu\",\n    \"copied\": \"Kopyalandı!\",\n    \"copySuccess\": \"Hata raporu panoya kopyalandı\",\n    \"copyFailed\": \"Hata raporu kopyalanamadı\",\n    \"showDetails\": \"Ayrıntıları Göster\",\n    \"hideDetails\": \"Ayrıntıları Gizle\",\n    \"stackTrace\": \"Yığın İzleme\",\n    \"componentStack\": \"Bileşen Yığını\",\n    \"noStackTrace\": \"Yığın izleme mevcut değil\",\n    \"fullReport\": \"Tam Hata Raporu\",\n    \"helpText\": \"Bu hata devam ederse, lütfen yukarıdaki hata raporunu kopyalayıp destek ekibiyle paylaşın. İletişim bilgilerini Hakkında sayfasında bulabilirsiniz.\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"Ayrıntıları kopyalamak için tıklayın\",\n    \"clipboardEmpty\": \"Pano boş\",\n    \"downloadFailed\": \"İndirme başarısız\",\n    \"downloadNecessaryFilesFailed\": \"Gerekli dosyalar indirilemedi. Lütfen ağ bağlantınızı kontrol edin ve tekrar deneyin.\",\n    \"emptyUrl\": \"Lütfen URL girin\",\n    \"errorDetails\": \"Hata Ayrıntıları\",\n    \"fetchInfoFailed\": \"Video bilgisi alamadı\",\n    \"invalidUrl\": \"Pano içeriği geçerli bir URL değil\",\n    \"networkError\": \"Bir hata oluştu. Ağınızı kontrol edin ve doğru URL'i kullanın.\",\n    \"pasteFromClipboard\": \"Panodan yapıştırılamadı\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"İptal Edilenleri Temizle\",\n    \"clearCompleted\": \"Tamamlananları Temizle\",\n    \"clearErrors\": \"Hataları Temizle\",\n    \"clearAll\": \"Tüm Geçmişi Temizle\",\n    \"clearAllAction\": \"Geçmişi Temizle\",\n    \"clearSelection\": \"Seçimi Temizle\",\n    \"confirmClearAllTitle\": \"Tüm geçmişi silmek ister misiniz?\",\n    \"confirmClearAllDescription\": \"Geçmişinizden {{count}} öğeyi kaldırın. Dosyalar diskte kalır.\",\n    \"confirmDeleteSelectedTitle\": \"Seçili öğeleri silmek ister misiniz?\",\n    \"confirmDeleteSelectedDescription\": \"Geçmişinizden {{count}} öğeyi kaldırın. Dosyalar diskte kalır.\",\n    \"alsoDeleteFiles\": \"Dosyaları da sil\",\n    \"confirmDeletePlaylistTitle\": \"Oynatma listesi geçmişini silmek ister misiniz?\",\n    \"confirmDeletePlaylistDescription\": \"{{title}} öğesinden {{count}} öğeyi kaldır ve dosyalarını sil.\",\n    \"copyToClipboard\": \"Panoya kopyala\",\n    \"copyUrl\": \"URL'i Kopyala\",\n    \"date\": \"Tarih\",\n    \"deletePlaylist\": \"Oynatma Listesi Kaldır\",\n    \"deleteSelected\": \"Seçileni Kaldır\",\n    \"description\": \"İndirme geçmişinizi görüntüleyin ve yönetin\",\n    \"doneSelecting\": \"Bitti\",\n    \"duration\": \"Süre\",\n    \"fileSize\": \"Dosya Boyutu\",\n    \"filters\": {\n      \"all\": \"Tümü\",\n      \"cancelled\": \"İptal edildi\",\n      \"completed\": \"Tamamlandı\",\n      \"errors\": \"Hatalar\"\n    },\n    \"noHistory\": \"Henüz indirme geçmişi yok\",\n    \"noHistoryDescription\": \"Tamamlanan indirmeleriniz burada görünecektir\",\n    \"cookiesTipTitle\": \"Çerezlerle indirme başarısını artırın\",\n    \"cookiesTipDescription\": \"Çerezleri yapılandırarak başarı oranını <strong>%70</strong>'den <strong>%99</strong>'a çıkarın.\",\n    \"cookiesTipCta\": \"Çerezleri ayarla\",\n    \"openDownloadFolder\": \"İndirme Klasörünü Aç\",\n    \"openFile\": \"Dosya Aç\",\n    \"openFileLocation\": \"Dosya Konumunu Aç\",\n    \"openFolder\": \"Klasörü Aç\",\n    \"openInBrowser\": \"Tarayıcıda açmak için tıklayın\",\n    \"removeAction\": \"Kaldır\",\n    \"removeItem\": \"Öğeyi Kaldır\",\n    \"deleteFile\": \"Dosyayı Sil\",\n    \"deleteRecord\": \"Listeden Kaldır\",\n    \"select\": \"Seç\",\n    \"selectAll\": \"Tümünü Seç\",\n    \"selectVisible\": \"Görünür olanı seç\",\n    \"selectItem\": \"Öğe seç\",\n    \"selectedCount\": \"{{count}} adet seçildi\",\n    \"selectionSummary\": \"{{total}} taneden {{selected}} tanesi görünür\",\n    \"stats\": {\n      \"cancelled\": \"İptal edildi\",\n      \"completed\": \"Tamamlandı\",\n      \"errors\": \"Hatalar\",\n      \"total\": \"Toplam\"\n    },\n    \"status\": {\n      \"cancelled\": \"İptal edildi\",\n      \"completed\": \"Tamamlandı\",\n      \"error\": \"Hata\"\n    },\n    \"title\": \"İndirme Geçmişi\"\n  },\n  \"menu\": {\n    \"about\": \"Hakkında\",\n    \"download\": \"İndir\",\n    \"playlist\": \"Oynatma listesini indir\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"Abonelikler\",\n    \"preferences\": \"Tercihler\",\n    \"supportedSites\": \"Desteklenen Siteler\",\n    \"theme\": \"Tema:\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"Panoya kopyalanamadı\",\n    \"downloadCompleted\": \"İndirme tamamlandı\",\n    \"downloadAlreadyQueued\": \"Bu indirme işlemi zaten devam ediyor.\",\n    \"downloadFailed\": \"İndirme başarısız\",\n    \"historyCleared\": \"Geçmiş temizlendi\",\n    \"historyClearFailed\": \"Geçmiş temizlenemedi\",\n    \"itemRemoved\": \"Öğe kaldırıldı\",\n    \"itemsRemoved\": \"{{count}} öğe kaldırıldı\",\n    \"itemsRemoveFailed\": \"Seçilen öğeler silinemedi\",\n    \"openFileFailed\": \"Dosya açılamadı\",\n    \"openFolderFailed\": \"Klasör açılamadı\",\n    \"playlistHistoryRemoved\": \"Oynatma listesi kaldırıldı ve dosyalar silindi\",\n    \"playlistHistoryRemoveFailed\": \"Oynatma listesi geçmişi silinemedi”\",\n    \"removeFailed\": \"Öğe silinemedi\",\n    \"settingsSaved\": \"Ayarlar kaydedildi\",\n    \"urlCopied\": \"URL panoya kopyalandı\",\n    \"videoCopied\": \"Video panoya kopyalandı\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"Oynatma listesi\",\n    \"clearPreview\": \"Önizlemeyi temizle\",\n    \"collapsedProgress\": \"Oynatma listesi indiriliyor: {{completed}} / {{total}} tamamlandı\",\n    \"comingSoon\": \"Oynatma listesi indirme özelliği yakında geliyor!\",\n    \"completed\": \"Oynatma listesi indirildi\",\n    \"description\": \"YouTube oynatma listesi veya kanalındaki tüm videoları indirin\",\n    \"downloadFailed\": \"Oynatma listesi indirilemedi\",\n    \"downloadPlaylist\": \"Oynatma listesini indir\",\n    \"downloadType\": \"İndirme Türü\",\n    \"downloading\": \"Oynatma listesi indiriliyor:\",\n    \"endIndex\": \"Son\",\n    \"enterPlaylistUrl\": \"Oynatma listesi URL'ini girin\",\n    \"fetchFailed\": \"Oynatma liste bilgileri alınamadı\",\n    \"filenameFormat\": \"Oynatma listeleri için dosya adı biçimi\",\n    \"folderFormat\": \"Oynatma listeleri için klasör adı biçimi\",\n    \"foundVideos\": \"Oynatma listesinde {{count}} video bulundu\",\n    \"groupActive\": \"{{count}} aktif\",\n    \"groupCollapse\": \"Küçült\",\n    \"groupErrors\": \"{{count}} başarısız\",\n    \"groupExpand\": \"Genişlet\",\n    \"groupSummary\": \"{{completed}} / {{total}} tamamlandı\",\n    \"linkLabel\": \"Oynatma listesi URL'i\",\n    \"noEntries\": \"Bu oynatma listesinde video bulunamadı.\",\n    \"noEntriesInRange\": \"Seçilen aralıkta video yok\",\n    \"noRangeSelected\": \"Son ayarlanmamış - tam oynatma listesi seçildi\",\n    \"playlistUrlDescription\": \"Oynatma listesindeki tüm videoları toplu olarak indirin\",\n    \"positionLabel\": \"{{index}} / {{total}} öğe\",\n    \"previewButton\": \"Oynatma listesini önizle\",\n    \"previewFailed\": \"Oynatma liste önizlemesi başarısız\",\n    \"previewSummary\": \"İndirmeden önce oynatma liste öğelerini önizleyin.\",\n    \"previewRequired\": \"İndirmeden önce oynatma listesini önizleyin.\",\n    \"range\": \"Aralık (İsteğe bağlı)\",\n    \"resetToDefault\": \"Varsayılana sıfırla\",\n    \"selectedRange\": \"Aralık: {{start}}-{{end}}\",\n    \"selectedVideos\": \"{{count}} tane seçildi\",\n    \"downloadCurrentRange\": \"Seçilenleri İndir\",\n    \"showingCount\": \"{{count}} video gösteriliyor\",\n    \"selectEntry\": \"Girdi {{index}} seçin\",\n    \"noEntriesSelected\": \"Seçilen girdi yok\",\n    \"startIndex\": \"Başlangıç (1)\",\n    \"title\": \"Oynatma listesini indirin\",\n    \"totalVideos\": \"Toplam video: {{count}}\",\n    \"untitled\": \"Adsız oynatma listesi\",\n    \"fetchingInfo\": \"Oynatma listesi bilgileri alınıyor...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"Hakkında\",\n    \"advanced\": \"Gelişmiş\",\n    \"cookiesTab\": \"Çerezler\",\n    \"app\": \"Uygulama Ayarları\",\n    \"audio\": \"Ses Tercihleri\",\n    \"browserForCookies\": \"Çerezleri kullanmak için tarayıcı seçin\",\n    \"browserForCookiesDescription\": \"Kimlik doğrulama için çerezleri çıkarılacak tarayıcı. Profili otomatik olarak algılamaya çalışacağız.\",\n    \"browserForCookiesWindowsNote\": \"Windows yalnızca Firefox çerezlerini destekler. Diğer tarayıcılar için lütfen çerez dosyasını manuel olarak yapılandırın.\",\n    \"browserForCookiesProfile\": \"Profil adı veya yolu\",\n    \"browserForCookiesProfileDescription\": \"Yukarıda seçilen tarayıcı için profil yolu. Mümkün olduğunda otomatik olarak doldurulur.\",\n    \"browserForCookiesProfilePlaceholder\": \"Profil adı veya tam yol (isteğe bağlı)\",\n    \"browserForCookiesProfileInvalid\": \"Profil yolu geçerli değil. Seçilen tarayıcı için profil klasörünü seçin.\",\n    \"browserForCookiesProfileInvalidPath\": \"Bu klasör mevcut değil. Mevcut bir profil klasörü seçin.\",\n    \"browserForCookiesProfileInvalidProfile\": \"Varsayılan tarayıcı konumunda profil adı bulunamadı.\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"Bu platformda bu tarayıcı için varsayılan profil konumu bilinmemektedir.\",\n    \"browserForCookiesProfileInvalidEmpty\": \"Seçilen tarayıcı için bir profil yolu girin.\",\n    \"cookiesFile\": \"Çerezler dosyası\",\n    \"cookiesFileDescription\": \"Netscape tarafından kimlik doğrulama için yüklenecek şekilde biçimlendirilmiş çerez dosyası\",\n    \"clearCookiesFile\": \"Temizle\",\n    \"cookiesHelpTitle\": \"Çerezlerin kullanımı\",\n    \"cookiesHelpBrowser\": \"Yukarıdan tarayıcınızı seçin, oturum bilgilerinizi otomatik yeniden kullanın.\",\n    \"cookiesHelpFile\": \"Netscape çerez dosyasını dışa aktarın (yt-dlp SSS bölümüne bakın) ve gerektiğinde buradan seçin.\",\n    \"cookiesGuideTitle\": \"Bir rehbere mi ihtiyacınız var?\",\n    \"cookiesGuideDescription\": \"VidBee'de çerezleri kullanmak için adım adım kılavuzu inceleyin.\",\n    \"cookiesGuideLink\": \"Çerez kılavuzunu aç\",\n    \"openLinkError\": \"Bağlantı açılamadı\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"Yapılandırma dosyasını kullanın\",\n    \"configFileDescription\": \"yt-dlp için özel yapılandırma dosyası\",\n    \"clearConfigFile\": \"Temizle\",\n    \"dark\": \"Koyu\",\n    \"description\": \"İndirme tercihlerinizi ve uygulama ayarlarınızı yapılandırın\",\n    \"directorySelectError\": \"Dizini seçilemedi\",\n    \"downloadPath\": \"İndirme konumu\",\n    \"downloadPathDescription\": \"İndirilen dosyaları nereye kaydedeceğinizi seçin\",\n    \"fileSelectError\": \"Dosya seçilemedi\",\n    \"general\": \"Genel\",\n    \"language\": \"Dil\",\n    \"languageDescription\": \"Uygulama arayüzü için tercih ettiğiniz dili seçin\",\n    \"light\": \"Açık\",\n    \"hideDockIcon\": \"Dock simgesini gizle\",\n    \"hideDockIconDescription\": \"VidBee'yi macOS Dock'tan kaldırın. Uygulamayı yeniden açmak için menü çubuğunu veya tepsi simgesini kullanın.\",\n    \"launchAtLogin\": \"Başlangıçta başlat\",\n    \"launchAtLoginDescription\": \"Oturumunuza giriş yaptıktan sonra VidBee'yi otomatik olarak açın.\",\n    \"launchAtLoginUnsupported\": \"Otomatik başlatma yalnızca macOS ve Windows'ta kullanılabilir.\",\n    \"enableAnalytics\": \"VidBee'yi geliştirmeye yardımcı olun\",\n    \"enableAnalyticsDescription\": \"Uygulamanın nasıl kullanıldığını anlamamıza ve iyileştirmelere öncelik vermemize yardımcı olmak için anonim kullanım verilerini paylaşın.\",\n    \"embedChapters\": \"Bölümleri gömün\",\n    \"embedChaptersDescription\": \"Mümkün olduğunda dosyaya bölüm işaretçileri ekleyin\",\n    \"embedMetadata\": \"Meta verileri gömün\",\n    \"embedMetadataDescription\": \"Mümkünse başlık, sanatçı ve diğer meta verileri yazın.\",\n    \"embedSubs\": \"Altyazıları gömün\",\n    \"embedSubsDescription\": \"Altyazıları video dosyasına (mp4, webm, mkv) gömün\",\n    \"embedThumbnail\": \"Küçük resmi gömün\",\n    \"embedThumbnailDescription\": \"Küçük resmi, kapak resmi olarak ekle\",\n    \"shareWatermark\": \"Filigranı paylaş\",\n    \"shareWatermarkDescription\": \"Orijinal başlık, yazar ve VidBee markasını içeren bir filigran ekleyin.\",\n    \"maxConcurrentDownloads\": \"Aktif indirme sayısı sınırı\",\n    \"maxConcurrentDownloadsDescription\": \"Eşzamanlı indirme sayısı sınırı\",\n    \"none\": \"Hiçbiri\",\n    \"oneClickDownload\": \"Tek Tıkla İndir\",\n    \"oneClickDownloadDescription\": \"Varsayılan ayarlarla tek tıklamayla indirmeyi etkinleştir\",\n    \"oneClickDownloadType\": \"Varsayılan indirme türü\",\n    \"oneClickDownloadTypeDescription\": \"Tek tıklamayla indirme için varsayılan indirme türünü seçin. Kalite, aşağıdaki ön ayarı kullanır.\",\n    \"oneClickQuality\": \"Tercih edilen kalite\",\n    \"oneClickQualityDescription\": \"Tek tıklamayla indirme için kullanılan kalite ön ayarını seçin\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"Otomatik\",\n      \"bad\": \"Kötü\",\n      \"best\": \"En iyi\",\n      \"good\": \"İyi\",\n      \"normal\": \"Normal\",\n      \"worst\": \"En kötü\"\n    },\n    \"proxy\": \"Vekil\",\n    \"proxyDescription\": \"Ağ istekleri için vekil sunucusu\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"Ayar dosyasını seçin\",\n    \"selectPath\": \"Seç\",\n    \"showMoreFormats\": \"Daha fazla format seçeneği göster\",\n    \"showMoreFormatsDescription\": \"Arayüzde ek format seçeneklerini göster\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"Abonelik dosya adını geçersiz kılmadığında kullanılan desen.\"\n    },\n    \"system\": \"Sistem\",\n    \"theme\": \"Tema\",\n    \"themeDescription\": \"VidBee için açık, koyu veya sistem teması seçin\",\n    \"title\": \"Ayarlar\",\n    \"tray\": {\n      \"quit\": \"Çık\",\n      \"showHome\": \"Ana Sayfa'yı göster\"\n    },\n    \"video\": \"Video Tercihleri\"\n  },\n  \"subscriptions\": {\n    \"title\": \"Abonelikler\",\n    \"subtitle\": \"{{count}} subscription{{count, plural, one {} other {s}}}\",\n    \"description\": \"RSS beslemelerini otomatik olarak izleyin ve manuel işlem yapmadan yeni indirmeleri sıraya alın.\",\n    \"defaults\": {\n      \"title\": \"Otomasyon varsayılanları\",\n      \"description\": \"Abonelik indirmelerinin nerede depolanacağını ve VidBee'nin yeni videoları ne sıklıkla kontrol edeceğini kontrol edin.\",\n      \"downloadDirectory\": \"İndirme dizini\",\n      \"filenameTemplate\": \"Dosya adı şablonu\",\n      \"onlyLatest\": \"Yalnızca en son videoyu indirin\",\n      \"onlyLatestDescription\": \"Etkinleştirildiğinde, VidBee eski bekleyen öğeleri atlar ve yalnızca en yeni yüklemeleri alır.\"\n    },\n    \"add\": {\n      \"title\": \"RSS Ekle\",\n      \"description\": \"Bir RSS besleme bağlantısını yapıştırın. VidBee beslemeyi otomatik olarak algılayacaktır.\"\n    },\n    \"fields\": {\n      \"url\": \"Besleme URL'i\",\n      \"keywords\": \"Anahtar kelime filtresi (virgülle ayrılmış)\",\n      \"tags\": \"Otomatik etiketler\",\n      \"customDirectory\": \"Özel dizin\",\n      \"namingTemplate\": \"Özel dosya adı şablonu\",\n      \"onlyLatest\": \"Yalnızca en son videoyu indirin\",\n      \"onlyLatestDescription\": \"Birikmiş öğeleri yok say ve bu beslemeden sadece en yeni yüklemeleri al.\",\n      \"enabled\": \"Etkinleştirildi\",\n      \"disabled\": \"Devre dışı Bırakıldı\",\n      \"onlyLatestShort\": \"Sadece en son\"\n    },\n    \"actions\": {\n      \"add\": \"Ekle\",\n      \"refresh\": \"Yenile\",\n      \"edit\": \"Düzenle\",\n      \"remove\": \"Kaldır\",\n      \"save\": \"Değişiklikleri kaydet\",\n      \"selectDirectory\": \"Gözat\",\n      \"enable\": \"Etkinleştir\",\n      \"disable\": \"Devre dışı Bırak\"\n    },\n    \"items\": {\n      \"title\": \"Son yüklemeler ({{count}})\",\n      \"count\": \"{{count}} öğe\",\n      \"empty\": \"Son zamanlarda eklenen haber bulunamadı.\",\n      \"status\": {\n        \"queued\": \"Sıraya alındı\",\n        \"notQueued\": \"Sıraya alınmadı\",\n        \"pending\": \"Beklemede\",\n        \"downloading\": \"İndiriliyor\",\n        \"processing\": \"İşleniyor\",\n        \"completed\": \"Tamamlandı\",\n        \"error\": \"Başarısız\",\n        \"cancelled\": \"İptal edildi\"\n      },\n      \"fromChannel\": \"{{channel}} tarafından\",\n      \"tooltip\": {\n        \"downloadStatus\": \"İndirme durumu: {{status}}\",\n        \"downloadPending\": \"İndirme ayrıntıları bekleniyor...\",\n        \"notQueued\": \"Henüz indirme kuyruğunda değil\"\n      },\n      \"actions\": {\n        \"open\": \"Tarayıcıda aç\",\n        \"queue\": \"İndirme kuyruğuna ekle\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"Abonelik\",\n      \"unknown\": \"Bilinmeyen abonelik\",\n      \"noThumbnail\": \"Küçük resim yok\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"Dizin seçici açılamadı.\",\n      \"missingUrl\": \"Lütfen önce bir kanal bağlantısı yapıştırın.\",\n      \"created\": \"Abonelik eklendi\",\n      \"createError\": \"Abonelik eklenemedi.\",\n      \"duplicateUrl\": \"Bu RSS beslemesine zaten abone olundu.\",\n      \"refreshStarted\": \"Yenileme başladı\",\n      \"removed\": \"Abonelik kaldırıldı\",\n      \"updated\": \"Abonelik güncellendi\",\n      \"itemQueued\": \"İndirme kuyruğuna eklendi\",\n      \"itemAlreadyQueued\": \"Bu video zaten sıraya alınmış\",\n      \"queueError\": \"İndirme kuyruğuna eklenemedi.\",\n      \"openLinkError\": \"Video bağlantısı açılamadı.\",\n      \"resolveError\": \"RSS besleme URL'i çözülemedi.\"\n    },\n    \"detectedFeed\": \"Algılanan {{platform}} beslemesi -> {{feed}}\",\n    \"detecting\": \"Besleme algılanıyor...\",\n    \"latestVideo\": \"Son video: {{title}}\",\n    \"lastChecked\": \"Son kontrol: {{time}}\",\n    \"never\": \"Asla\",\n    \"empty\": \"Henüz abonelik yok. Otomatik indirmeyi başlatmak için favori kanallarınızı ekleyin.\",\n    \"edit\": {\n      \"title\": \"{{name}} düzenle\",\n      \"description\": \"Bu besleme için filtreleri, etiketleri ve geçersiz kılmaları ayarlayın.\"\n    },\n    \"status\": {\n      \"title\": \"Durum\",\n      \"up-to-date\": \"Güncel\",\n      \"checking\": \"Kontrol ediliyor\",\n      \"failed\": \"Başarısız\",\n      \"idle\": \"Boşta\",\n      \"tooltip\": {\n        \"updatedAt\": \"Güncellendi: {{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"RSSHub ile Otomatik Abonelikler\",\n      \"description\": \"VidBee'yi RSSHub ile birleştirerek çeşitli platformlardan otomatik abonelik ve indirme özelliğini etkinleştirin. Kurulum tamamlandıktan sonra VidBee arka planda çalışır ve en son videoları ve içerikleri otomatik olarak indirir.\",\n      \"learnMore\": \"RSSHub hakkında daha fazla bilgi edinin\",\n      \"openDocs\": \"RSSHub Dökümantasyonunu Aç\",\n      \"hint\": \"RSS besleme URL'iniz yok mu? RSSHub'ı kullanarak YouTube, Twitter ve binlerce diğer platform için RSS beslemeleri oluşturun.\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"{{sites}} ve daha fazlasını destekler.\",\n    \"moreDescription\": \"yt-dlp listesinin tamamı topluluk tarafından sürekli güncellenmektedir.\",\n    \"moreTitle\": \"Başka bir siteye mi ihtiyacınız var?\",\n    \"openFullList\": \"Tam desteklenen sitelerin listesini aç\",\n    \"pageDescription\": \"VidBee, yüzlerce kaynağa ulaşmak için arka planda yt-dlp kullanır.\",\n    \"pageIntro\": \"İşte insanların en sık indirdiği ana akış hizmetleri.\",\n    \"pageTitle\": \"Desteklenen Siteler\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"Bağımsız sanatçı albümleri ve topluluk yayınları.\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"Küresel haberler, spor ve eğlence klipleri.\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"Halka açık sayfalardan Akış, İzleme ve Reels videolarını izleyin.\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"Akış, Hikayeler, Reels ve Öne Çıkanlar içeriği.\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Üreticiler, Kick platformunda canlı yayınlar ve tekrarlar yapar.\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"Profesyonel konuşmalar, web seminerleri ve öğrenme videoları.\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ miksleri, radyo programları ve uzun formatlı ses kayıtları.\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"Japon animasyonu, müzik ve canlı yayın arşivi.\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"Fikir pimleri, nasıl yapılır videoları ve yaşam tarzı ilham videoları.\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"Topluluklardan gömülü klipler ve barındırılan videolar.\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"Müzik parçaları, çalma listeleri ve DJ setleri.\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"Kısa formatlı mobil videolar, efektler ve canlı yayınlar.\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"Yaratıcı kısa formatlı medya ve hayran düzenlemeleri.\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"Oyun, müzik, gerçek hayattaki canlı yayınlar ve VOD'lar.\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"Zaman akışı gönderileri, Spaces kayıtları ve yayınlar.\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"Yüksek kaliteli içerik üreticileri ve işletmeler için video barındırma hizmeti.\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"Dünya çapındaki içerik üreticilerden uzun formatlı ve canlı yayın videolar.\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"Resmi müzik videoları, albümler ve canlı performanslar.\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"Ana platformlar\",\n    \"viewAll\": \"Desteklenen tüm siteleri görüntüle\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/zh-TW.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"檢查更新\",\n      \"download\": \"下載\",\n      \"email\": \"電子郵件\",\n      \"feedback\": \"意見回饋\",\n      \"goToDownload\": \"前往下載頁面\",\n      \"openRepo\": \"開啟 GitHub 儲存庫\",\n      \"view\": \"檢視\",\n      \"visit\": \"造訪\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"在背景自動下載並安裝新版本。\",\n    \"autoUpdateTitle\": \"自動更新\",\n    \"betaProgramDescription\": \"搶先獲得預覽版和即將發布的新功能。\",\n    \"betaProgramTitle\": \"預覽通道\",\n    \"description\": \"VidBee 是一個基於 Node.js 和 Electron 構建的自由開源應用，使用 yt-dlp 來完成下載。\",\n    \"followAuthorActions\": {\n      \"follow\": \"關注 @nexmoex\"\n    },\n    \"followAuthorDescription\": \"獲取 VidBee 的最新消息和更新。\",\n    \"followAuthorSupport\": \"在 X (Twitter) 上關注開發者，獲取 VidBee 的最新更新和消息。\",\n    \"followAuthorTitle\": \"關注開發者\",\n    \"here\": \"此處\",\n    \"homepage\": \"首頁\",\n    \"notifications\": {\n      \"checkingUpdates\": \"正在搜尋更新...\",\n      \"downloadError\": \"下載更新失敗\",\n      \"downloadUpdate\": \"下載並安裝更新 {{version}}？\",\n      \"manualDownloadAction\": \"立即下載\",\n      \"noUpdatesAvailable\": \"您正在使用最新版本\",\n      \"restartToUpdate\": \"立即重新啟動以安裝更新？\",\n      \"restartNowAction\": \"立即重新啟動\",\n      \"updateAvailable\": \"發現新版本：{{version}}\",\n      \"updateAvailableMessage\": \"新版本 {{version}} 已推出。請從官方網站下載。\",\n      \"updateDownloaded\": \"更新已下載，重新啟動以安裝\",\n      \"updateDownloadedVersion\": \"下載更新 {{version}}，重新啟動安裝\",\n      \"updateError\": \"檢查更新失敗：{{error}}\",\n      \"unknownErrorFallback\": \"未知錯誤\"\n    },\n    \"preferencesDescription\": \"無需離開此頁即可調整更新設定。\",\n    \"preferencesTitle\": \"快速切換\",\n    \"resources\": {\n      \"changelog\": \"發行說明\",\n      \"changelogDescription\": \"了解每個版本的變更內容。\",\n      \"contact\": \"電子郵件支援\",\n      \"contactDescription\": \"直接聯繫我們以獲取協助或開展合作。\",\n      \"documentation\": \"說明中心\",\n      \"documentationDescription\": \"指南、常見問題和常見流程。\",\n      \"feedback\": \"意見回饋與問題\",\n      \"feedbackDescription\": \"在 GitHub 上分享想法或回報問題。\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"在 GitHub 回報錯誤或提出功能需求。\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"在 X 上提及 @nexmoex 分享回饋或建議。\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"加入我們的 Discord 社群進行討論與支援。\",\n      \"faq\": \"常見問題\",\n      \"faqDescription\": \"常見問題和故障排除指南。\",\n      \"license\": \"授權條款\",\n      \"licenseDescription\": \"查閱開源授權條款。\",\n      \"website\": \"官方網站\",\n      \"websiteDescription\": \"產品亮點、路線圖與社群動態。\"\n    },\n    \"resourcesDescription\": \"了解 VidBee 並保持關注的實用連結。\",\n    \"resourcesTitle\": \"資源\",\n    \"shareActions\": {\n      \"copy\": \"複製連結\",\n      \"facebook\": \"在 Facebook 上分享\",\n      \"twitter\": \"在 X (Twitter) 上分享\"\n    },\n    \"shareDescription\": \"一鍵與您的社群分享 VidBee。\",\n    \"shareSupport\": \"向您的朋友推薦 VidBee 以支援我們的成長和更新。\",\n    \"shareTitle\": \"廣為宣傳\",\n    \"sourceCode\": \"原始碼已開放\",\n    \"title\": \"關於\",\n    \"version\": \"版本\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"最新版本：v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"有新版本可用\",\n      \"uptodate\": \"您已是最新版本\",\n      \"error\": \"無法取得最新版本\"\n    },\n    \"downloadingUpdate\": \"正在下載更新\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"下載完成後關閉應用程式\",\n    \"currentLocation\": \"目前下載位置 - \",\n    \"downloadLocation\": \"下載位置\",\n    \"downloadSubs\": \"若有字幕則下載\",\n    \"downloadSubsHint\": \"可用時將字幕另存為獨立檔案\",\n    \"end\": \"結束\",\n    \"endHint\": \"如果留空，將下載到結尾\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"選擇下載位置\",\n    \"start\": \"開始\",\n    \"startHint\": \"如果留空，將從開頭開始\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"字幕\",\n    \"timeRange\": \"下載指定時間範圍\",\n    \"title\": \"進階選項\"\n  },\n  \"app\": {\n    \"description\": \"從數百個網站下載影片和音訊\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"進行中\",\n    \"all\": \"全部\",\n    \"audio\": \"音訊\",\n    \"back\": \"返回\",\n    \"cancel\": \"取消\",\n    \"cancelled\": \"已取消\",\n    \"clearCompleted\": \"清除已完成\",\n    \"clearDownloads\": \"清除下載\",\n    \"completed\": \"已完成\",\n    \"downloadAudio\": \"下載音訊\",\n    \"downloadBtn\": \"下載\",\n    \"downloadPending\": \"待處理\",\n    \"downloadQueue\": \"下載佇列\",\n    \"customDownloadFolder\": \"自訂下載資料夾\",\n    \"retry\": \"重試下載\",\n    \"autoFolderPlaceholder\": \"自動資料夾（依中繼資料）\",\n    \"autoFolderHint\": \"自動資料夾會由中繼資料建立。\",\n    \"useAutoFolder\": \"使用自動資料夾\",\n    \"downloadVideo\": \"下載影片\",\n    \"downloading\": \"正在下載...\",\n    \"enterUrl\": \"輸入影片連結\",\n    \"enterUrlDescription\": \"貼上或輸入一個影片連結。\",\n    \"error\": \"錯誤\",\n    \"fetch\": \"取得\",\n    \"fetchingVideoInfo\": \"正在取得影片資訊...\",\n    \"feedback\": {\n      \"title\": \"報告此錯誤:\",\n      \"githubUrlTooLong\": \"這個 GitHub 連結很長。如果無法開啟，請開啟 issue 頁面並手動貼上日誌。\"\n    },\n    \"history\": \"歷史\",\n    \"imageLoadError\": \"圖片載入失敗\",\n    \"imagePlaceholder\": \"暫無圖片\",\n    \"infoUnavailable\": \"一鍵下載（資訊不可用）\",\n    \"loading\": \"載入中\",\n    \"moreOptions\": \"更多選項\",\n    \"noActiveDownloads\": \"暫無進行中的下載\",\n    \"noAudio\": \"無音訊\",\n    \"noHistory\": \"暫無下載歷史\",\n    \"noItems\": \"未找到項目\",\n    \"goToSettings\": \"前往“設置”\",\n    \"oneClickDownload\": \"一鍵下載\",\n    \"oneClickDownloadDescription\": \"使用預設設定直接下載，無需確認\",\n    \"oneClickDownloadTooltip\": \"貼上即可即刻下載，省略步驟\",\n    \"oneClickDownloadNow\": \"立即下載\",\n    \"oneClickDownloadStarted\": \"已使用預設設定開始下載\",\n    \"paste\": \"貼上\",\n    \"pastePlaylistUrl\": \"點擊從剪貼簿貼上播放清單連結 [Ctrl + V]\",\n    \"pasteUrl\": \"點擊貼上影片連結或 ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"貼上網址\",\n    \"preparing\": \"正在準備...\",\n    \"processing\": \"處理中\",\n    \"progress\": \"進度\",\n    \"showDetails\": \"顯示詳情\",\n    \"hideDetails\": \"隱藏詳細信息\",\n    \"viewLogs\": \"查看日誌\",\n    \"detailsTab\": \"詳細資料\",\n    \"logsTab\": \"日誌\",\n    \"logs\": {\n      \"live\": \"即時日誌\",\n      \"history\": \"已儲存日誌\",\n      \"command\": \"yt-dlp 指令\",\n      \"empty\": \"尚無日誌。\",\n      \"scrollPaused\": \"捲動已暫停\"\n    },\n    \"selectAudioFormat\": \"選擇音訊格式\",\n    \"selectDownloadType\": \"選擇下載類型\",\n    \"selectFormat\": \"選擇格式\",\n    \"startDownload\": \"開始下載\",\n    \"selectVideoFormat\": \"選擇影片格式\",\n    \"singleVideo\": \"單個影片\",\n    \"speed\": \"速度\",\n    \"title\": \"標題\",\n    \"total\": \"總計\",\n    \"unknownQuality\": \"未知品質\",\n    \"unknownSize\": \"未知大小\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"影片\",\n    \"videoInfo\": \"影片資訊\",\n    \"videoInfoUpdated\": \"影片資訊已更新\",\n    \"metadata\": {\n      \"source\": \"來源\",\n      \"playlist\": \"播放列表\",\n      \"format\": \"格式\",\n      \"quality\": \"品質\",\n      \"codec\": \"編解碼器\",\n      \"savedFile\": \"保存的文件\",\n      \"url\": \"來源網址\",\n      \"description\": \"描述\",\n      \"views\": \"意見\",\n      \"tags\": \"標籤\",\n      \"downloadPath\": \"下載路徑\",\n      \"createdAt\": \"創建於\",\n      \"startedAt\": \"開始於\",\n      \"completedAt\": \"完成於\",\n      \"speed\": \"速度\",\n      \"fileSize\": \"文件大小\",\n      \"width\": \"寬度\",\n      \"height\": \"高度\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"視頻編解碼器\",\n      \"audioCodec\": \"音頻編解碼器\",\n      \"formatNote\": \"格式註釋\",\n      \"protocol\": \"協定\",\n      \"subscription\": \"訂閱\"\n    }\n  },\n  \"error\": {\n    \"title\": \"發生錯誤\",\n    \"description\": \"發生未預期的錯誤。請重新載入應用程式，若問題持續請回報。\",\n    \"message\": \"錯誤訊息\",\n    \"unknownError\": \"發生未知錯誤\",\n    \"goHome\": \"回到首頁\",\n    \"reload\": \"重新載入應用程式\",\n    \"copyReport\": \"複製錯誤報告\",\n    \"copied\": \"已複製！\",\n    \"copySuccess\": \"錯誤報告已複製到剪貼簿\",\n    \"copyFailed\": \"無法複製錯誤報告\",\n    \"showDetails\": \"顯示詳細資訊\",\n    \"hideDetails\": \"隱藏詳細資訊\",\n    \"stackTrace\": \"堆疊追蹤\",\n    \"componentStack\": \"元件堆疊\",\n    \"noStackTrace\": \"沒有可用的堆疊追蹤\",\n    \"fullReport\": \"完整錯誤報告\",\n    \"helpText\": \"若此錯誤持續，請複製上方的錯誤報告並與支援團隊分享。聯絡資訊可在「關於」頁面找到。\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"點擊複製詳情\",\n    \"clipboardEmpty\": \"剪貼簿為空\",\n    \"downloadFailed\": \"下載失敗\",\n    \"downloadNecessaryFilesFailed\": \"必要檔案下載失敗。請檢查網路後再試\",\n    \"emptyUrl\": \"請輸入連結\",\n    \"errorDetails\": \"錯誤詳情\",\n    \"fetchInfoFailed\": \"取得影片資訊失敗\",\n    \"invalidUrl\": \"剪貼簿內容不是有效的網址\",\n    \"networkError\": \"發生錯誤。請檢查網路並確認連結正確\",\n    \"pasteFromClipboard\": \"從剪貼簿貼上失敗\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"清除已取消\",\n    \"clearCompleted\": \"清除已完成\",\n    \"clearErrors\": \"清除錯誤\",\n    \"clearAll\": \"清除所有歷史紀錄\",\n    \"clearAllAction\": \"清除歷史紀錄\",\n    \"clearSelection\": \"清除選取\",\n    \"confirmClearAllTitle\": \"清除所有歷史紀錄？\",\n    \"confirmClearAllDescription\": \"從歷史紀錄移除 {{count}} 項。檔案仍會保留在磁碟上。\",\n    \"confirmDeleteSelectedTitle\": \"移除選取項目？\",\n    \"confirmDeleteSelectedDescription\": \"從歷史紀錄移除 {{count}} 項。檔案仍會保留在磁碟上。\",\n    \"alsoDeleteFiles\": \"同時刪除檔案\",\n    \"confirmDeletePlaylistTitle\": \"移除播放清單歷史紀錄？\",\n    \"confirmDeletePlaylistDescription\": \"從 {{title}} 移除 {{count}} 項並刪除其檔案。\",\n    \"copyToClipboard\": \"複製到剪貼簿\",\n    \"copyUrl\": \"複製連結\",\n    \"date\": \"日期\",\n    \"deletePlaylist\": \"移除播放清單\",\n    \"deleteSelected\": \"移除選取\",\n    \"description\": \"檢視並管理下載歷史\",\n    \"doneSelecting\": \"完成\",\n    \"duration\": \"時長\",\n    \"fileSize\": \"檔案大小\",\n    \"filters\": {\n      \"all\": \"全部\",\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"errors\": \"錯誤\"\n    },\n    \"noHistory\": \"暫無下載歷史\",\n    \"noHistoryDescription\": \"完成的下載會顯示在這裡\",\n    \"cookiesTipTitle\": \"設定 Cookies 提升下載成功率\",\n    \"cookiesTipDescription\": \"設定 Cookies 可將下載成功率從 <strong>70%</strong> 提升到 <strong>99%</strong>。\",\n    \"cookiesTipCta\": \"前往設定 Cookies\",\n    \"openDownloadFolder\": \"開啟下載資料夾\",\n    \"openFile\": \"開啟檔案\",\n    \"openFileLocation\": \"開啟檔案位置\",\n    \"openFolder\": \"開啟資料夾\",\n    \"openInBrowser\": \"點擊在瀏覽器中開啟\",\n    \"removeAction\": \"移除\",\n    \"removeItem\": \"移除項目\",\n    \"deleteFile\": \"刪除檔案\",\n    \"deleteRecord\": \"從列表中移除\",\n    \"select\": \"選取\",\n    \"selectAll\": \"全選\",\n    \"selectVisible\": \"選取可見項目\",\n    \"selectItem\": \"選取項目\",\n    \"selectedCount\": \"已選取 {{count}} 項\",\n    \"selectionSummary\": \"已選取可見項目 {{selected}} / {{total}}\",\n    \"stats\": {\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"errors\": \"錯誤\",\n      \"total\": \"總計\"\n    },\n    \"status\": {\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"error\": \"錯誤\"\n    },\n    \"title\": \"下載歷史\"\n  },\n  \"menu\": {\n    \"about\": \"關於\",\n    \"download\": \"下載\",\n    \"playlist\": \"下載播放清單\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"訂閱\",\n    \"preferences\": \"偏好設定\",\n    \"supportedSites\": \"支援的網站\",\n    \"theme\": \"主題：\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"複製到剪貼簿失敗\",\n    \"downloadCompleted\": \"下載完成\",\n    \"downloadAlreadyQueued\": \"此下載已在進行中\",\n    \"downloadFailed\": \"下載失敗\",\n    \"historyCleared\": \"歷史紀錄已清除\",\n    \"historyClearFailed\": \"清除歷史紀錄失敗\",\n    \"itemRemoved\": \"項目已移除\",\n    \"itemsRemoved\": \"已移除 {{count}} 項\",\n    \"itemsRemoveFailed\": \"移除選取項目失敗\",\n    \"openFileFailed\": \"開啟檔案失敗\",\n    \"openFolderFailed\": \"開啟資料夾失敗\",\n    \"playlistHistoryRemoved\": \"播放清單已移除並刪除檔案\",\n    \"playlistHistoryRemoveFailed\": \"移除播放清單歷史紀錄失敗\",\n    \"removeFailed\": \"移除項目失敗\",\n    \"settingsSaved\": \"設定已儲存\",\n    \"urlCopied\": \"連結已複製到剪貼簿\",\n    \"videoCopied\": \"影片已複製到剪貼簿\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"播放列表\",\n    \"clearPreview\": \"清晰預覽\",\n    \"collapsedProgress\": \"正在下載播放清單：{{completed}} / {{total}} 已完成\",\n    \"comingSoon\": \"播放清單下載功能即將推出！\",\n    \"completed\": \"播放清單已下載\",\n    \"description\": \"下載 YouTube 播放清單或頻道中的全部影片\",\n    \"downloadFailed\": \"啟動播放清單下載失敗\",\n    \"downloadPlaylist\": \"下載播放清單\",\n    \"downloadType\": \"下載類型\",\n    \"downloading\": \"正在下載播放清單：\",\n    \"endIndex\": \"結束\",\n    \"enterPlaylistUrl\": \"輸入播放清單連結\",\n    \"fetchFailed\": \"取得播放清單資訊失敗\",\n    \"filenameFormat\": \"播放清單檔案名稱格式\",\n    \"folderFormat\": \"播放清單資料夾命名格式\",\n    \"foundVideos\": \"在播放清單中找到 {{count}} 個影片\",\n    \"groupActive\": \"{{count}} 個活躍\",\n    \"groupCollapse\": \"收合\",\n    \"groupErrors\": \"{{count}} 失敗\",\n    \"groupExpand\": \"展開\",\n    \"groupSummary\": \"{{completed}} / {{total}} 已完成\",\n    \"linkLabel\": \"播放清單連結\",\n    \"noEntries\": \"在此播放列表中找不到視頻\",\n    \"noEntriesInRange\": \"所選範圍內沒有視頻\",\n    \"noRangeSelected\": \"沒有結束設置 - 已選擇完整播放列表\",\n    \"playlistUrlDescription\": \"批量下載播放清單中的所有影片\",\n    \"positionLabel\": \"第 {{index}} 項，共 {{total}} 項\",\n    \"previewButton\": \"預覽播放列表\",\n    \"previewFailed\": \"預覽播放列表失敗\",\n    \"previewSummary\": \"下載前預覽播放列表項目。\",\n    \"previewRequired\": \"下載前預覽播放列表。\",\n    \"range\": \"範圍（可選）\",\n    \"resetToDefault\": \"恢復預設\",\n    \"selectedRange\": \"範圍：{{start}}-{{end}}\",\n    \"selectedVideos\": \"已選取 {{count}} 項\",\n    \"downloadCurrentRange\": \"下載選取項目\",\n    \"showingCount\": \"顯示 {{count}} 個視頻\",\n    \"selectEntry\": \"選取項目 {{index}}\",\n    \"noEntriesSelected\": \"未選取任何項目\",\n    \"startIndex\": \"開始（1）\",\n    \"title\": \"下載播放清單\",\n    \"totalVideos\": \"視頻總數：{{count}}\",\n    \"untitled\": \"無標題播放列表\",\n    \"fetchingInfo\": \"正在取得播放清單資訊...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"關於\",\n    \"advanced\": \"進階\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"應用程式設定\",\n    \"audio\": \"音訊偏好\",\n    \"browserForCookies\": \"選擇用於讀取 Cookies 的瀏覽器\",\n    \"browserForCookiesDescription\": \"用於身份驗證的瀏覽器 Cookies 提取\",\n    \"browserForCookiesWindowsNote\": \"Windows 僅支援 Firefox 的 Cookies，其他瀏覽器請手動設定 Cookies 檔案。\",\n    \"browserForCookiesProfile\": \"設定檔名稱或路徑\",\n    \"browserForCookiesProfileDescription\": \"上方選取之瀏覽器的設定檔路徑。如可用將自動填入。\",\n    \"browserForCookiesProfilePlaceholder\": \"設定檔名稱或完整路徑（選填）\",\n    \"browserForCookiesProfileInvalid\": \"設定檔路徑無效。請選擇所選瀏覽器的設定檔資料夾。\",\n    \"browserForCookiesProfileInvalidPath\": \"該資料夾不存在。請選擇現有的設定檔資料夾。\",\n    \"browserForCookiesProfileInvalidProfile\": \"在預設瀏覽器位置找不到設定檔名稱。\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"此平台上該瀏覽器沒有已知的預設設定檔位置。\",\n    \"browserForCookiesProfileInvalidEmpty\": \"請輸入所選瀏覽器的設定檔路徑。\",\n    \"cookiesFile\": \"Cookies 文件\",\n    \"cookiesFileDescription\": \"要加載以進行身份​​驗證的 Netscape 格式的 Cookies 文件\",\n    \"clearCookiesFile\": \"清除\",\n    \"cookiesHelpTitle\": \"使用 Cookies\",\n    \"cookiesHelpBrowser\": \"選擇上面的瀏覽器以自動重用其登錄會話。\",\n    \"cookiesHelpFile\": \"導出 Netscape Cookies 文件（請參閱 yt-dlp FAQ）並在需要時在此處選擇它。\",\n    \"cookiesGuideTitle\": \"需要指引嗎？\",\n    \"cookiesGuideDescription\": \"查看在 VidBee 中使用 Cookies 的分步指南。\",\n    \"cookiesGuideLink\": \"開啟 Cookies 指南\",\n    \"openLinkError\": \"無法打開鏈接\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"使用設定檔\",\n    \"configFileDescription\": \"yt-dlp 的自訂設定檔\",\n    \"clearConfigFile\": \"清除\",\n    \"dark\": \"深色\",\n    \"description\": \"設定下載偏好和應用程式設定\",\n    \"directorySelectError\": \"選擇目錄失敗\",\n    \"downloadPath\": \"下載位置\",\n    \"downloadPathDescription\": \"選擇儲存下載檔案的位置\",\n    \"fileSelectError\": \"選擇檔案失敗\",\n    \"general\": \"一般\",\n    \"language\": \"語言\",\n    \"languageDescription\": \"選擇應用程式介面的偏好語言\",\n    \"light\": \"淺色\",\n    \"hideDockIcon\": \"隱藏 Dock 圖標\",\n    \"hideDockIconDescription\": \"從 macOS Dock 中刪除 VidBee。使用菜單欄或託盤圖標重新打開應用程序。\",\n    \"launchAtLogin\": \"啟動時啟動\",\n    \"launchAtLoginDescription\": \"登錄計算機後自動打開 VidBee。\",\n    \"launchAtLoginUnsupported\": \"自動啟動僅適用於 macOS 和 Windows。\",\n    \"enableAnalytics\": \"幫助改進 VidBee\",\n    \"enableAnalyticsDescription\": \"共享匿名使用數據，幫助我們了解應用程序的使用情況並確定改進的優先順序。\",\n    \"embedChapters\": \"嵌入章節\",\n    \"embedChaptersDescription\": \"可用時在檔案中加入章節標記\",\n    \"embedMetadata\": \"嵌入中繼資料\",\n    \"embedMetadataDescription\": \"可用時寫入標題、藝術家與其他中繼資料\",\n    \"embedSubs\": \"嵌入字幕\",\n    \"embedSubsDescription\": \"將字幕嵌入影片檔案（mp4、webm、mkv）\",\n    \"embedThumbnail\": \"嵌入縮圖\",\n    \"embedThumbnailDescription\": \"將縮圖作為封面圖\",\n    \"shareWatermark\": \"分享水印\",\n    \"shareWatermarkDescription\": \"加入包含原始標題、作者與 VidBee 品牌的水印\",\n    \"maxConcurrentDownloads\": \"最大活動下載數\",\n    \"maxConcurrentDownloadsDescription\": \"最大同時下載數量\",\n    \"none\": \"無\",\n    \"oneClickDownload\": \"一鍵下載\",\n    \"oneClickDownloadDescription\": \"啟用使用預設設定的一鍵下載\",\n    \"oneClickDownloadType\": \"預設下載類型\",\n    \"oneClickDownloadTypeDescription\": \"選擇一鍵下載的預設下載類型。品質使用下面的預設。\",\n    \"oneClickQuality\": \"首選品質\",\n    \"oneClickQualityDescription\": \"選擇用於一鍵下載的品質預設\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"自動\",\n      \"bad\": \"較差\",\n      \"best\": \"最佳\",\n      \"good\": \"良好\",\n      \"normal\": \"標準\",\n      \"worst\": \"最差\"\n    },\n    \"proxy\": \"代理伺服器\",\n    \"proxyDescription\": \"網路請求的代理伺服器\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"選擇設定檔\",\n    \"selectPath\": \"選擇\",\n    \"showMoreFormats\": \"顯示更多格式選項\",\n    \"showMoreFormatsDescription\": \"在介面中顯示額外的格式選項\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"當訂閱不覆蓋其文件名時使用的模式。\"\n    },\n    \"system\": \"系統\",\n    \"theme\": \"主題\",\n    \"themeDescription\": \"為 VidBee 選擇淺色、深色或系統主題\",\n    \"title\": \"設定\",\n    \"tray\": {\n      \"quit\": \"結束\",\n      \"showHome\": \"顯示首頁\"\n    },\n    \"video\": \"影片偏好\"\n  },\n  \"subscriptions\": {\n    \"title\": \"訂閱\",\n    \"subtitle\": \"{{count}} 訂閱{{count，複數，一個 {} 其它 {s}}}\",\n    \"description\": \"自動監控 RSS 源並對新下載進行排隊，無需手動操作。\",\n    \"defaults\": {\n      \"title\": \"自動化默認值\",\n      \"description\": \"控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。\",\n      \"downloadDirectory\": \"下載目錄\",\n      \"filenameTemplate\": \"文件名模板（僅限文件）\",\n      \"onlyLatest\": \"僅下載最新視頻\",\n      \"onlyLatestDescription\": \"啟用後，VidBee 會跳過較舊的積壓項目，僅獲取最新上傳的項目。\"\n    },\n    \"add\": {\n      \"title\": \"添加RSS\",\n      \"description\": \"粘貼 RSS 源鏈接。 VidBee 將自動檢測提要。\"\n    },\n    \"fields\": {\n      \"url\": \"提要網址\",\n      \"keywords\": \"關鍵字過濾器（逗號分隔）\",\n      \"tags\": \"自動標籤\",\n      \"customDirectory\": \"自定義目錄\",\n      \"namingTemplate\": \"自定義文件名模板（僅限文件）\",\n      \"onlyLatest\": \"僅下載最新視頻\",\n      \"onlyLatestDescription\": \"忽略積壓的項目並僅從此源中獲取最新上傳的內容。\",\n      \"enabled\": \"啟用\",\n      \"disabled\": \"殘疾人\",\n      \"onlyLatestShort\": \"僅最新\"\n    },\n    \"actions\": {\n      \"add\": \"添加\",\n      \"refresh\": \"重新整理\",\n      \"edit\": \"編輯\",\n      \"remove\": \"消除\",\n      \"save\": \"保存更改\",\n      \"selectDirectory\": \"瀏覽\",\n      \"enable\": \"使能夠\",\n      \"disable\": \"禁用\"\n    },\n    \"items\": {\n      \"title\": \"最新上傳 ({{count}})\",\n      \"count\": \"{{count}} 件\",\n      \"empty\": \"未找到最近的 Feed 項目。\",\n      \"status\": {\n        \"queued\": \"排隊\",\n        \"notQueued\": \"未排隊\",\n        \"pending\": \"待辦的\",\n        \"downloading\": \"正在下載\",\n        \"processing\": \"加工\",\n        \"completed\": \"完全的\",\n        \"error\": \"失敗的\",\n        \"cancelled\": \"取消\"\n      },\n      \"fromChannel\": \"來自{{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"下載狀態：{{status}}\",\n        \"downloadPending\": \"等待下載詳細信息...\",\n        \"notQueued\": \"尚未在下載隊列中\"\n      },\n      \"actions\": {\n        \"open\": \"在瀏覽器中打開\",\n        \"queue\": \"添加到下載隊列\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"訂閱\",\n      \"unknown\": \"未知訂閱\",\n      \"noThumbnail\": \"無縮略圖\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"無法打開目錄選擇器。\",\n      \"missingUrl\": \"請先粘貼頻道鏈接。\",\n      \"created\": \"已添加訂閱\",\n      \"createError\": \"添加訂閱失敗。\",\n      \"refreshStarted\": \"刷新開始\",\n      \"removed\": \"訂閱已刪除\",\n      \"updated\": \"訂閱已更新\",\n      \"itemQueued\": \"添加到下載隊列\",\n      \"itemAlreadyQueued\": \"該視頻已排隊\",\n      \"queueError\": \"無法添加到下載隊列。\",\n      \"openLinkError\": \"無法打開視頻鏈接。\",\n      \"resolveError\": \"無法解析 RSS 源 URL。\",\n      \"duplicateUrl\": \"此 RSS 訂閱已存在。\"\n    },\n    \"detectedFeed\": \"檢測到 {{platform}} feed -> {{feed}}\",\n    \"detecting\": \"檢測飼料...\",\n    \"latestVideo\": \"最新視頻：{{title}}\",\n    \"lastChecked\": \"最後檢查時間：{{time}}\",\n    \"never\": \"絕不\",\n    \"empty\": \"還沒有訂閱。添加您喜愛的頻道以開始自動下載。\",\n    \"edit\": {\n      \"title\": \"編輯{{name}}\",\n      \"description\": \"調整此提要的過濾器、標籤和覆蓋。\"\n    },\n    \"status\": {\n      \"title\": \"地位\",\n      \"up-to-date\": \"最新\",\n      \"checking\": \"檢查\",\n      \"failed\": \"失敗的\",\n      \"idle\": \"閒置的\",\n      \"tooltip\": {\n        \"updatedAt\": \"更新時間：{{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"使用 RSSHub 自動訂閱\",\n      \"description\": \"將 VidBee 與 RSSHub 結合起來，可以從各種平台實現自動訂閱和下載。設置完成後，VidBee 將在後台運行並自動下載最新的視頻和內容。\",\n      \"learnMore\": \"了解有關 RSSHub 的更多信息\",\n      \"openDocs\": \"打開 RSSHub 文檔\",\n      \"hint\": \"沒有 RSS 源 URL？使用 RSSHub 為 YouTube、Twitter 和數千個其他平台生成 RSS 源。\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"支援 {{sites}} 等更多網站。\",\n    \"moreDescription\": \"完整的 yt-dlp 清單由社群持續更新。\",\n    \"moreTitle\": \"需要其他網站？\",\n    \"openFullList\": \"開啟全部支援網站清單\",\n    \"pageDescription\": \"VidBee 使用 yt-dlp 覆蓋數百個資源。\",\n    \"pageIntro\": \"以下是大家最常下載的主流服務。\",\n    \"pageTitle\": \"支援的網站\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"獨立藝術家專輯和社群發布。\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"全球新聞、體育和娛樂片段。\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"來自公開頁面的動態、觀看和 Reels 影片。\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"動態、故事、Reels 和精選內容。\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Kick 平台上的創作者直播和回放。\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"專業演講、網路研討會和學習影片。\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ 混音、廣播節目和長音訊。\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"日本動畫、音樂和直播檔案。\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"創意圖釘、教學 Reels 和生活方式靈感影片。\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"來自社群的嵌入片段和託管影片。\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"音樂曲目、播放清單和 DJ 套裝。\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"短影片、特效和直播。\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"創意短影片和粉絲編輯。\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"遊戲、音樂和 IRL 直播和 VOD。\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"時間軸貼文、Spaces 錄音和廣播。\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"高品質創作者和商業影片託管。\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"來自全球創作者的長影片和直播。\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"官方音樂影片、專輯和現場表演。\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"主流平台\",\n    \"viewAll\": \"檢視全部支援的網站\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/zh.json",
    "content": "{\n  \"about\": {\n    \"actions\": {\n      \"checkUpdates\": \"检查更新\",\n      \"download\": \"下载\",\n      \"email\": \"邮件\",\n      \"feedback\": \"反馈\",\n      \"goToDownload\": \"前往下载页面\",\n      \"openRepo\": \"打开 GitHub 仓库\",\n      \"view\": \"查看\",\n      \"visit\": \"访问\"\n    },\n    \"appName\": \"VidBee\",\n    \"autoUpdateDescription\": \"在后台自动下载并安装新版本。\",\n    \"autoUpdateTitle\": \"自动更新\",\n    \"betaProgramDescription\": \"抢先获得预览版和即将发布的新功能。\",\n    \"betaProgramTitle\": \"预览通道\",\n    \"description\": \"这是一个基于 Node.js 和 Electron 构建的自由开源应用，使用 yt-dlp 来完成下载。\",\n    \"followAuthorActions\": {\n      \"follow\": \"关注 @nexmoex\"\n    },\n    \"followAuthorDescription\": \"获取 VidBee 的最新消息和更新。\",\n    \"followAuthorSupport\": \"在 X (Twitter) 上关注开发者，获取 VidBee 的最新更新和消息。\",\n    \"followAuthorTitle\": \"关注开发者\",\n    \"here\": \"此处\",\n    \"homepage\": \"主页\",\n    \"notifications\": {\n      \"checkingUpdates\": \"正在查找更新...\",\n      \"downloadError\": \"下载更新失败\",\n      \"downloadUpdate\": \"下载并安装更新 {{version}}？\",\n      \"manualDownloadAction\": \"立即下载\",\n      \"noUpdatesAvailable\": \"您正在使用最新版本\",\n      \"restartToUpdate\": \"立即重启以安装更新？\",\n      \"restartNowAction\": \"立即重新启动\",\n      \"updateAvailable\": \"发现新版本: {{version}}\",\n      \"updateAvailableMessage\": \"新版本 {{version}} 已推出。\\n请从官方网站下载。\",\n      \"updateDownloaded\": \"更新已下载，重启以安装\",\n      \"updateDownloadedVersion\": \"下载更新 {{version}}，重新启动安装\",\n      \"updateError\": \"检查更新失败: {{error}}\",\n      \"unknownErrorFallback\": \"未知错误\"\n    },\n    \"preferencesDescription\": \"无需离开此页即可调整更新设置。\",\n    \"preferencesTitle\": \"快速切换\",\n    \"resources\": {\n      \"changelog\": \"发行说明\",\n      \"changelogDescription\": \"了解每个版本的变更内容。\",\n      \"contact\": \"邮件支持\",\n      \"contactDescription\": \"直接联系我们以获取帮助或开展合作。\",\n      \"documentation\": \"帮助中心\",\n      \"documentationDescription\": \"指南、常见问题和常见流程。\",\n      \"feedback\": \"反馈与问题\",\n      \"feedbackDescription\": \"在 GitHub 上分享想法或报告问题。\",\n      \"githubIssues\": \"GitHub\",\n      \"githubIssuesDescription\": \"在 GitHub 上报告问题或请求功能。\",\n      \"xFeedback\": \"Twitter\",\n      \"xFeedbackDescription\": \"在 X 上提及 @nexmoex 分享反馈或建议。\",\n      \"discord\": \"Discord\",\n      \"discordDescription\": \"加入我们的 Discord 社区进行讨论和支持。\",\n      \"faq\": \"常见问题\",\n      \"faqDescription\": \"常见问题和故障排除指南。\",\n      \"license\": \"许可证\",\n      \"licenseDescription\": \"查阅开源许可证条款。\",\n      \"website\": \"官方网站\",\n      \"websiteDescription\": \"产品亮点、路线图与社区动态。\"\n    },\n    \"resourcesDescription\": \"了解 VidBee 并保持关注的实用链接。\",\n    \"resourcesTitle\": \"资源\",\n    \"shareActions\": {\n      \"copy\": \"复制链接\",\n      \"facebook\": \"在 Facebook 上分享\",\n      \"twitter\": \"在 X 上分享（推特）\"\n    },\n    \"shareDescription\": \"一键与您的社区分享 VidBee。\",\n    \"shareSupport\": \"向您的朋友推荐VidBee，以支持我们的成长和更新。\",\n    \"shareTitle\": \"分享这个应用\",\n    \"sourceCode\": \"源代码已开放\",\n    \"title\": \"关于\",\n    \"version\": \"版本\",\n    \"versionLabel\": \"v{{version}}\",\n    \"latestVersionBadge\": \"最新版本：v{{version}}\",\n    \"latestVersionStatus\": {\n      \"available\": \"有新版本可用\",\n      \"uptodate\": \"您已是最新版本\",\n      \"error\": \"无法获取最新版本\"\n    },\n    \"downloadingUpdate\": \"正在下载更新\"\n  },\n  \"advancedOptions\": {\n    \"closeWhenDone\": \"下载完成后关闭应用\",\n    \"currentLocation\": \"当前下载位置 - \",\n    \"downloadLocation\": \"下载位置\",\n    \"downloadSubs\": \"若有字幕则下载\",\n    \"downloadSubsHint\": \"可用时将字幕保存为单独文件\",\n    \"end\": \"结束\",\n    \"endHint\": \"如果留空，将下载到结尾\",\n    \"endPlaceholder\": \"10:00\",\n    \"selectLocation\": \"选择下载位置\",\n    \"start\": \"开始\",\n    \"startHint\": \"如果留空，将从开头开始\",\n    \"startPlaceholder\": \"00:00\",\n    \"subtitles\": \"字幕\",\n    \"timeRange\": \"下载指定时间范围\",\n    \"title\": \"高级选项\"\n  },\n  \"app\": {\n    \"description\": \"从数百个网站下载视频和音频\",\n    \"title\": \"VidBee\"\n  },\n  \"download\": {\n    \"active\": \"进行中\",\n    \"all\": \"全部\",\n    \"audio\": \"音频\",\n    \"back\": \"返回\",\n    \"cancel\": \"取消\",\n    \"cancelled\": \"已取消\",\n    \"clearCompleted\": \"清除已完成\",\n    \"clearDownloads\": \"清除下载\",\n    \"completed\": \"已完成\",\n    \"downloadAudio\": \"下载音频\",\n    \"downloadBtn\": \"下载\",\n    \"downloadPending\": \"待处理\",\n    \"downloadQueue\": \"下载队列\",\n    \"customDownloadFolder\": \"自定义下载文件夹\",\n    \"retry\": \"重试下载\",\n    \"autoFolderPlaceholder\": \"自动文件夹（基于元数据）\",\n    \"autoFolderHint\": \"自动文件夹由元数据创建。\",\n    \"useAutoFolder\": \"使用自动文件夹\",\n    \"downloadVideo\": \"下载视频\",\n    \"downloading\": \"正在下载...\",\n    \"enterUrl\": \"输入视频链接\",\n    \"enterUrlDescription\": \"粘贴或输入一个视频链接。\",\n    \"error\": \"错误\",\n    \"fetch\": \"获取\",\n    \"fetchingVideoInfo\": \"正在获取视频信息...\",\n    \"feedback\": {\n      \"title\": \"报告此错误:\",\n      \"githubUrlTooLong\": \"这个 GitHub 链接很长。如果无法打开，请打开 issue 页面并手动粘贴日志。\"\n    },\n    \"history\": \"历史\",\n    \"imageLoadError\": \"图像加载失败\",\n    \"imagePlaceholder\": \"暂无图像\",\n    \"infoUnavailable\": \"一键下载（信息不可用）\",\n    \"loading\": \"加载中\",\n    \"moreOptions\": \"更多选项\",\n    \"noActiveDownloads\": \"暂无进行中的下载\",\n    \"noAudio\": \"无音频\",\n    \"noHistory\": \"暂无下载历史\",\n    \"noItems\": \"未找到项目\",\n    \"goToSettings\": \"前往“设置”\",\n    \"oneClickDownload\": \"一键下载\",\n    \"oneClickDownloadDescription\": \"使用默认设置直接下载，无需确认\",\n    \"oneClickDownloadTooltip\": \"粘贴链接即刻下载，无需繁琐步骤\",\n    \"oneClickDownloadNow\": \"立即下载\",\n    \"oneClickDownloadStarted\": \"已使用默认设置开始下载\",\n    \"paste\": \"粘贴\",\n    \"pastePlaylistUrl\": \"点击从剪贴板粘贴播放列表链接 [Ctrl + V]\",\n    \"pasteUrl\": \"点击粘贴视频链接或 ID [Ctrl + V]\",\n    \"pasteUrlButton\": \"粘贴链接\",\n    \"preparing\": \"正在准备...\",\n    \"processing\": \"处理中\",\n    \"progress\": \"进度\",\n    \"showDetails\": \"显示详情\",\n    \"hideDetails\": \"隐藏详细信息\",\n    \"viewLogs\": \"查看日志\",\n    \"detailsTab\": \"详情\",\n    \"logsTab\": \"日志\",\n    \"logs\": {\n      \"live\": \"实时日志\",\n      \"history\": \"已保存日志\",\n      \"command\": \"yt-dlp 命令\",\n      \"empty\": \"暂无日志。\",\n      \"scrollPaused\": \"滚动已暂停\"\n    },\n    \"selectAudioFormat\": \"选择音频格式\",\n    \"selectDownloadType\": \"选择下载类型\",\n    \"selectFormat\": \"选择格式\",\n    \"startDownload\": \"开始下载\",\n    \"selectVideoFormat\": \"选择视频格式\",\n    \"singleVideo\": \"单个视频\",\n    \"speed\": \"速度\",\n    \"title\": \"标题\",\n    \"total\": \"总计\",\n    \"unknownQuality\": \"未知质量\",\n    \"unknownSize\": \"未知大小\",\n    \"urlPlaceholder\": \"https://www.youtube.com/watch?v=...\",\n    \"video\": \"视频\",\n    \"videoInfo\": \"视频信息\",\n    \"videoInfoUpdated\": \"视频信息已更新\",\n    \"metadata\": {\n      \"source\": \"来源\",\n      \"playlist\": \"播放列表\",\n      \"format\": \"格式\",\n      \"quality\": \"质量\",\n      \"codec\": \"编解码器\",\n      \"savedFile\": \"保存的文件\",\n      \"url\": \"来源网址\",\n      \"description\": \"描述\",\n      \"views\": \"意见\",\n      \"tags\": \"标签\",\n      \"downloadPath\": \"下载路径\",\n      \"createdAt\": \"创建于\",\n      \"startedAt\": \"开始于\",\n      \"completedAt\": \"完成于\",\n      \"speed\": \"速度\",\n      \"fileSize\": \"文件大小\",\n      \"width\": \"宽度\",\n      \"height\": \"高度\",\n      \"fps\": \"FPS\",\n      \"videoCodec\": \"视频编解码器\",\n      \"audioCodec\": \"音频编解码器\",\n      \"formatNote\": \"格式注释\",\n      \"protocol\": \"协议\",\n      \"subscription\": \"订阅\"\n    }\n  },\n  \"error\": {\n    \"title\": \"出了点问题\",\n    \"description\": \"发生了意外错误。请尝试重新加载应用，若问题持续请报告。\",\n    \"message\": \"错误信息\",\n    \"unknownError\": \"发生未知错误\",\n    \"goHome\": \"返回首页\",\n    \"reload\": \"重新加载应用\",\n    \"copyReport\": \"复制错误报告\",\n    \"copied\": \"已复制！\",\n    \"copySuccess\": \"错误报告已复制到剪贴板\",\n    \"copyFailed\": \"复制错误报告失败\",\n    \"showDetails\": \"显示详情\",\n    \"hideDetails\": \"隐藏详情\",\n    \"stackTrace\": \"堆栈跟踪\",\n    \"componentStack\": \"组件堆栈\",\n    \"noStackTrace\": \"没有可用的堆栈跟踪\",\n    \"fullReport\": \"完整错误报告\",\n    \"helpText\": \"如果此错误持续，请复制以上错误报告并与支持团队分享。联系信息可在“关于”页面找到。\"\n  },\n  \"errors\": {\n    \"clickToCopy\": \"点击复制详情\",\n    \"clipboardEmpty\": \"剪贴板为空\",\n    \"downloadFailed\": \"下载失败\",\n    \"downloadNecessaryFilesFailed\": \"必要文件下载失败。请检查网络后再试\",\n    \"emptyUrl\": \"请输入链接\",\n    \"errorDetails\": \"错误详情\",\n    \"fetchInfoFailed\": \"获取视频信息失败\",\n    \"invalidUrl\": \"剪贴板内容不是有效的 URL\",\n    \"networkError\": \"发生错误。请检查网络并确认链接正确\",\n    \"pasteFromClipboard\": \"从剪贴板粘贴失败\"\n  },\n  \"history\": {\n    \"clearCancelled\": \"清除已取消\",\n    \"clearCompleted\": \"清除已完成\",\n    \"clearErrors\": \"清除错误\",\n    \"clearAll\": \"清除全部历史记录\",\n    \"clearAllAction\": \"清除历史记录\",\n    \"clearSelection\": \"清除选择\",\n    \"confirmClearAllTitle\": \"清除所有历史记录？\",\n    \"confirmClearAllDescription\": \"从历史记录中移除 {{count}} 项。文件仍保留在磁盘上。\",\n    \"confirmDeleteSelectedTitle\": \"移除所选项？\",\n    \"confirmDeleteSelectedDescription\": \"从历史记录中移除 {{count}} 项。文件仍保留在磁盘上。\",\n    \"alsoDeleteFiles\": \"同时删除文件\",\n    \"confirmDeletePlaylistTitle\": \"移除播放列表历史记录？\",\n    \"confirmDeletePlaylistDescription\": \"从 {{title}} 移除 {{count}} 项并删除其文件。\",\n    \"copyToClipboard\": \"复制到剪贴板\",\n    \"copyUrl\": \"复制链接\",\n    \"date\": \"日期\",\n    \"deletePlaylist\": \"移除播放列表\",\n    \"deleteSelected\": \"移除所选\",\n    \"description\": \"查看并管理下载历史\",\n    \"doneSelecting\": \"完成\",\n    \"duration\": \"时长\",\n    \"fileSize\": \"文件大小\",\n    \"filters\": {\n      \"all\": \"全部\",\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"errors\": \"错误\"\n    },\n    \"noHistory\": \"暂无下载历史\",\n    \"noHistoryDescription\": \"完成的下载会显示在这里\",\n    \"cookiesTipTitle\": \"配置 Cookies 提升下载成功率\",\n    \"cookiesTipDescription\": \"配置 Cookies 可将下载成功率从 <strong>70%</strong> 提升到 <strong>99%</strong>。\",\n    \"cookiesTipCta\": \"去配置 Cookies\",\n    \"openDownloadFolder\": \"打开下载文件夹\",\n    \"openFile\": \"打开文件\",\n    \"openFileLocation\": \"打开文件位置\",\n    \"openFolder\": \"打开文件夹\",\n    \"openInBrowser\": \"点击在浏览器中打开\",\n    \"removeAction\": \"移除\",\n    \"removeItem\": \"移除项目\",\n    \"deleteFile\": \"删除文件\",\n    \"deleteRecord\": \"从列表中移除\",\n    \"select\": \"选择\",\n    \"selectAll\": \"全选\",\n    \"selectVisible\": \"选择可见项\",\n    \"selectItem\": \"选择条目\",\n    \"selectedCount\": \"已选择 {{count}} 项\",\n    \"selectionSummary\": \"已选择可见项 {{selected}} / {{total}}\",\n    \"stats\": {\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"errors\": \"错误\",\n      \"total\": \"总计\"\n    },\n    \"status\": {\n      \"cancelled\": \"已取消\",\n      \"completed\": \"已完成\",\n      \"error\": \"错误\"\n    },\n    \"title\": \"下载历史\"\n  },\n  \"menu\": {\n    \"about\": \"关于\",\n    \"download\": \"下载\",\n    \"playlist\": \"下载播放列表\",\n    \"rss\": \"RSS\",\n    \"subscriptions\": \"订阅\",\n    \"preferences\": \"偏好设置\",\n    \"supportedSites\": \"支持的网站\",\n    \"theme\": \"主题：\"\n  },\n  \"notifications\": {\n    \"copyFailed\": \"复制到剪贴板失败\",\n    \"downloadCompleted\": \"下载完成\",\n    \"downloadAlreadyQueued\": \"此下载已在进行中\",\n    \"downloadFailed\": \"下载失败\",\n    \"historyCleared\": \"历史记录已清除\",\n    \"historyClearFailed\": \"清除历史记录失败\",\n    \"itemRemoved\": \"项目已移除\",\n    \"itemsRemoved\": \"已移除 {{count}} 项\",\n    \"itemsRemoveFailed\": \"移除所选项失败\",\n    \"openFileFailed\": \"打开文件失败\",\n    \"openFolderFailed\": \"打开文件夹失败\",\n    \"playlistHistoryRemoved\": \"已移除播放列表并删除文件\",\n    \"playlistHistoryRemoveFailed\": \"移除播放列表历史记录失败\",\n    \"removeFailed\": \"移除项目失败\",\n    \"settingsSaved\": \"设置已保存\",\n    \"urlCopied\": \"链接已复制到剪贴板\",\n    \"videoCopied\": \"视频已复制到剪贴板\"\n  },\n  \"playlist\": {\n    \"badgeLabel\": \"播放列表\",\n    \"clearPreview\": \"清晰预览\",\n    \"collapsedProgress\": \"正在下载播放列表：已完成 {{completed}} / {{total}}\",\n    \"comingSoon\": \"播放列表下载功能即将推出！\",\n    \"completed\": \"播放列表已下载\",\n    \"description\": \"下载 YouTube 播放列表或频道中的全部视频\",\n    \"downloadFailed\": \"启动播放列表下载失败\",\n    \"downloadPlaylist\": \"下载播放列表\",\n    \"downloadType\": \"下载类型\",\n    \"downloading\": \"正在下载播放列表：\",\n    \"endIndex\": \"结束\",\n    \"enterPlaylistUrl\": \"输入播放列表链接\",\n    \"fetchFailed\": \"获取播放列表信息失败\",\n    \"filenameFormat\": \"播放列表文件名格式\",\n    \"folderFormat\": \"播放列表文件夹命名格式\",\n    \"foundVideos\": \"在播放列表中找到 {{count}} 个视频\",\n    \"groupActive\": \"{{count}} 个活跃\",\n    \"groupCollapse\": \"折叠\",\n    \"groupErrors\": \"{{count}} 失败\",\n    \"groupExpand\": \"展开\",\n    \"groupSummary\": \"{{completed}} / {{total}}已完成\",\n    \"linkLabel\": \"播放列表链接\",\n    \"noEntries\": \"在此播放列表中找不到视频\",\n    \"noEntriesInRange\": \"所选范围内没有视频\",\n    \"noRangeSelected\": \"没有结束设置 - 已选择完整播放列表\",\n    \"playlistUrlDescription\": \"批量下载播放列表中的所有视频\",\n    \"positionLabel\": \"第 {{index}} 项，共 {{total}} 项\",\n    \"previewButton\": \"预览播放列表\",\n    \"previewFailed\": \"预览播放列表失败\",\n    \"previewSummary\": \"下载前预览播放列表项目。\",\n    \"previewRequired\": \"下载前预览播放列表。\",\n    \"range\": \"范围（可选）\",\n    \"resetToDefault\": \"恢复默认\",\n    \"selectedRange\": \"范围：{{start}}-{{end}}\",\n    \"selectedVideos\": \"已选择 {{count}} 项\",\n    \"downloadCurrentRange\": \"下载所选\",\n    \"showingCount\": \"显示 {{count}} 个视频\",\n    \"selectEntry\": \"选择条目 {{index}}\",\n    \"noEntriesSelected\": \"未选择任何条目\",\n    \"startIndex\": \"开始（1）\",\n    \"title\": \"下载播放列表\",\n    \"totalVideos\": \"视频总数：{{count}}\",\n    \"untitled\": \"无标题播放列表\",\n    \"fetchingInfo\": \"正在获取播放列表信息...\"\n  },\n  \"settings\": {\n    \"aboutTab\": \"关于\",\n    \"advanced\": \"高级\",\n    \"cookiesTab\": \"Cookies\",\n    \"app\": \"应用设置\",\n    \"audio\": \"音频偏好\",\n    \"browserForCookies\": \"选择用于读取 Cookies 的浏览器\",\n    \"browserForCookiesDescription\": \"用于身份验证的浏览器 Cookies 提取\",\n    \"browserForCookiesWindowsNote\": \"Windows 仅支持 Firefox 的 Cookies，其他浏览器请手动配置 Cookies 文件。\",\n    \"browserForCookiesProfile\": \"配置文件名称或路径\",\n    \"browserForCookiesProfileDescription\": \"上方所选浏览器的配置文件路径。如可用会自动填写。\",\n    \"browserForCookiesProfilePlaceholder\": \"配置文件名称或完整路径（可选）\",\n    \"browserForCookiesProfileInvalid\": \"配置文件路径无效。请选择所选浏览器的配置文件文件夹。\",\n    \"browserForCookiesProfileInvalidPath\": \"该文件夹不存在。请选择现有的配置文件文件夹。\",\n    \"browserForCookiesProfileInvalidProfile\": \"在默认浏览器位置未找到配置文件名称。\",\n    \"browserForCookiesProfileInvalidUnsupported\": \"此平台上该浏览器没有已知的默认配置文件位置。\",\n    \"browserForCookiesProfileInvalidEmpty\": \"请输入所选浏览器的配置文件路径。\",\n    \"cookiesFile\": \"Cookies 文件\",\n    \"cookiesFileDescription\": \"要加载以进行身份​​验证的 Netscape 格式的 Cookies 文件\",\n    \"clearCookiesFile\": \"清除\",\n    \"cookiesHelpTitle\": \"使用 Cookies\",\n    \"cookiesHelpBrowser\": \"选择上面的浏览器以自动重用其登录会话。\",\n    \"cookiesHelpFile\": \"导出 Netscape Cookies 文件（请参阅 yt-dlp FAQ）并在需要时在此处选择它。\",\n    \"cookiesGuideTitle\": \"需要指引吗？\",\n    \"cookiesGuideDescription\": \"查看在 VidBee 中使用 Cookies 的分步指南。\",\n    \"cookiesGuideLink\": \"打开 Cookies 指南\",\n    \"openLinkError\": \"无法打开链接\",\n    \"browserOptions\": {\n      \"brave\": \"Brave\",\n      \"chrome\": \"Chrome\",\n      \"chromium\": \"Chromium\",\n      \"edge\": \"Edge\",\n      \"firefox\": \"Firefox\",\n      \"opera\": \"Opera\",\n      \"safari\": \"Safari\",\n      \"vivaldi\": \"Vivaldi\",\n      \"whale\": \"Whale\"\n    },\n    \"configFile\": \"使用配置文件\",\n    \"configFileDescription\": \"yt-dlp 的自定义配置文件\",\n    \"clearConfigFile\": \"清除\",\n    \"dark\": \"深色\",\n    \"description\": \"配置下载偏好和应用设置\",\n    \"directorySelectError\": \"选择目录失败\",\n    \"downloadPath\": \"下载位置\",\n    \"downloadPathDescription\": \"选择保存下载文件的位置\",\n    \"fileSelectError\": \"选择文件失败\",\n    \"general\": \"通用\",\n    \"language\": \"语言\",\n    \"languageDescription\": \"选择应用界面的首选语言\",\n    \"light\": \"浅色\",\n    \"hideDockIcon\": \"隐藏 Dock 图标\",\n    \"hideDockIconDescription\": \"从 macOS Dock 中删除 VidBee。\\n使用菜单栏或托盘图标重新打开应用程序。\",\n    \"launchAtLogin\": \"启动时启动\",\n    \"launchAtLoginDescription\": \"登录计算机后自动打开 VidBee。\",\n    \"launchAtLoginUnsupported\": \"自动启动仅适用于 macOS 和 Windows。\",\n    \"enableAnalytics\": \"帮助改进 VidBee\",\n    \"enableAnalyticsDescription\": \"共享匿名使用数据，帮助我们了解应用程序的使用情况并确定改进的优先顺序。\",\n    \"embedChapters\": \"嵌入章节\",\n    \"embedChaptersDescription\": \"可用时在文件中添加章节标记\",\n    \"embedMetadata\": \"嵌入元数据\",\n    \"embedMetadataDescription\": \"可用时写入标题、艺术家和其他元数据\",\n    \"embedSubs\": \"嵌入字幕\",\n    \"embedSubsDescription\": \"将字幕嵌入视频文件（mp4、webm、mkv）\",\n    \"embedThumbnail\": \"嵌入缩略图\",\n    \"embedThumbnailDescription\": \"将缩略图作为封面图\",\n    \"shareWatermark\": \"分享水印\",\n    \"shareWatermarkDescription\": \"添加包含原视频标题、作者和 VidBee 品牌的水印\",\n    \"maxConcurrentDownloads\": \"最大活动下载数\",\n    \"maxConcurrentDownloadsDescription\": \"最大同时下载数量\",\n    \"none\": \"无\",\n    \"oneClickDownload\": \"一键下载\",\n    \"oneClickDownloadDescription\": \"启用使用默认设置的一键下载\",\n    \"oneClickDownloadType\": \"默认下载类型\",\n    \"oneClickDownloadTypeDescription\": \"选择一键下载的默认下载类型。质量使用下面的预设。\",\n    \"oneClickQuality\": \"首选质量\",\n    \"oneClickQualityDescription\": \"选择用于一键下载的质量预设\",\n    \"oneClickQualityOptions\": {\n      \"auto\": \"自动\",\n      \"bad\": \"较差\",\n      \"best\": \"最佳\",\n      \"good\": \"良好\",\n      \"normal\": \"标准\",\n      \"worst\": \"最差\"\n    },\n    \"proxy\": \"代理\",\n    \"proxyDescription\": \"网络请求的代理服务器\",\n    \"proxyPlaceholder\": \"http://proxy:port\",\n    \"selectConfigFile\": \"选择配置文件\",\n    \"selectPath\": \"选择\",\n    \"showMoreFormats\": \"显示更多格式选项\",\n    \"showMoreFormatsDescription\": \"在界面中显示额外的格式选项\",\n    \"subscriptionDefaults\": {\n      \"filenameDescription\": \"当订阅不覆盖其文件名时使用的模式。\"\n    },\n    \"system\": \"系统\",\n    \"theme\": \"主题\",\n    \"themeDescription\": \"为 VidBee 选择浅色、深色或系统主题\",\n    \"title\": \"设置\",\n    \"tray\": {\n      \"quit\": \"退出\",\n      \"showHome\": \"显示首页\"\n    },\n    \"video\": \"视频偏好\"\n  },\n  \"subscriptions\": {\n    \"title\": \"订阅\",\n    \"subtitle\": \"{{count}} 订阅{{count，复数，一个 {} 其它 {s}}}\",\n    \"description\": \"自动监控 RSS 源并对新下载进行排队，无需手动操作。\",\n    \"defaults\": {\n      \"title\": \"自动化默认值\",\n      \"description\": \"控制订阅下载的存储位置以及 VidBee 检查新视频的频率。\",\n      \"downloadDirectory\": \"下载目录\",\n      \"filenameTemplate\": \"文件名模板（仅限文件）\",\n      \"onlyLatest\": \"仅下载最新视频\",\n      \"onlyLatestDescription\": \"启用后，VidBee 会跳过较旧的积压项目，仅获取最新上传的项目。\"\n    },\n    \"add\": {\n      \"title\": \"添加RSS\",\n      \"description\": \"粘贴 RSS 源链接。 \\nVidBee 将自动检测提要。\"\n    },\n    \"fields\": {\n      \"url\": \"提要网址\",\n      \"keywords\": \"关键字过滤器（逗号分隔）\",\n      \"tags\": \"自动标签\",\n      \"customDirectory\": \"自定义目录\",\n      \"namingTemplate\": \"自定义文件名模板（仅限文件）\",\n      \"onlyLatest\": \"仅下载最新视频\",\n      \"onlyLatestDescription\": \"忽略积压的项目并仅从此源中获取最新上传的内容。\",\n      \"enabled\": \"启用\",\n      \"disabled\": \"残疾人\",\n      \"onlyLatestShort\": \"仅最新\"\n    },\n    \"actions\": {\n      \"add\": \"添加\",\n      \"refresh\": \"刷新\",\n      \"edit\": \"编辑\",\n      \"remove\": \"消除\",\n      \"save\": \"保存更改\",\n      \"selectDirectory\": \"浏览\",\n      \"enable\": \"使能够\",\n      \"disable\": \"禁用\"\n    },\n    \"items\": {\n      \"title\": \"最新上传 ({{count}})\",\n      \"count\": \"{{count}} 件\",\n      \"empty\": \"未找到最近的 Feed 项目。\",\n      \"status\": {\n        \"queued\": \"排队\",\n        \"notQueued\": \"未排队\",\n        \"pending\": \"待办的\",\n        \"downloading\": \"正在下载\",\n        \"processing\": \"加工\",\n        \"completed\": \"完全的\",\n        \"error\": \"失败的\",\n        \"cancelled\": \"取消\"\n      },\n      \"fromChannel\": \"来自{{channel}}\",\n      \"tooltip\": {\n        \"downloadStatus\": \"下载状态：{{status}}\",\n        \"downloadPending\": \"等待下载详细信息...\",\n        \"notQueued\": \"尚未在下载队列中\"\n      },\n      \"actions\": {\n        \"open\": \"在浏览器中打开\",\n        \"queue\": \"添加到下载队列\"\n      }\n    },\n    \"labels\": {\n      \"subscription\": \"订阅\",\n      \"unknown\": \"未知订阅\",\n      \"noThumbnail\": \"无缩略图\"\n    },\n    \"notifications\": {\n      \"directoryError\": \"无法打开目录选择器。\",\n      \"missingUrl\": \"请先粘贴频道链接。\",\n      \"created\": \"已添加订阅\",\n      \"createError\": \"添加订阅失败。\",\n      \"refreshStarted\": \"刷新开始\",\n      \"removed\": \"订阅已删除\",\n      \"updated\": \"订阅已更新\",\n      \"itemQueued\": \"添加到下载队列\",\n      \"itemAlreadyQueued\": \"该视频已排队\",\n      \"queueError\": \"无法添加到下载队列。\",\n      \"openLinkError\": \"无法打开视频链接。\",\n      \"resolveError\": \"无法解析 RSS 源 URL。\",\n      \"duplicateUrl\": \"该 RSS 订阅已存在。\"\n    },\n    \"detectedFeed\": \"检测到 {{platform}} feed -> {{feed}}\",\n    \"detecting\": \"检测饲料...\",\n    \"latestVideo\": \"最新视频：{{title}}\",\n    \"lastChecked\": \"最后检查时间：{{time}}\",\n    \"never\": \"绝不\",\n    \"empty\": \"还没有订阅。\\n添加您喜爱的频道以开始自动下载。\",\n    \"edit\": {\n      \"title\": \"编辑{{name}}\",\n      \"description\": \"调整此提要的过滤器、标签和覆盖。\"\n    },\n    \"status\": {\n      \"title\": \"地位\",\n      \"up-to-date\": \"最新\",\n      \"checking\": \"检查\",\n      \"failed\": \"失败的\",\n      \"idle\": \"闲置的\",\n      \"tooltip\": {\n        \"updatedAt\": \"更新时间：{{time}}\"\n      }\n    },\n    \"rssHub\": {\n      \"title\": \"使用 RSSHub 自动订阅\",\n      \"description\": \"将 VidBee 与 RSSHub 结合起来，可以从各种平台实现自动订阅和下载。\\n设置完成后，VidBee 将在后台运行并自动下载最新的视频和内容。\",\n      \"learnMore\": \"了解有关 RSSHub 的更多信息\",\n      \"openDocs\": \"打开 RSSHub 文档\",\n      \"hint\": \"没有 RSS 源 URL？\\n使用 RSSHub 为 YouTube、Twitter 和数千个其他平台生成 RSS 源。\"\n    }\n  },\n  \"sites\": {\n    \"homeInlineDescription\": \"支持 {{sites}} 等更多网站。\",\n    \"moreDescription\": \"完整的 yt-dlp 列表由社区持续更新。\",\n    \"moreTitle\": \"需要其他网站？\",\n    \"openFullList\": \"打开全部支持网站列表\",\n    \"pageDescription\": \"VidBee 使用 yt-dlp 覆盖数百个资源。\",\n    \"pageIntro\": \"以下是大家最常下载的主流服务。\",\n    \"pageTitle\": \"支持的网站\",\n    \"popular\": {\n      \"bandcamp\": {\n        \"description\": \"独立艺术家专辑和社区发布。\",\n        \"label\": \"Bandcamp\"\n      },\n      \"dailymotion\": {\n        \"description\": \"全球新闻、体育和娱乐片段。\",\n        \"label\": \"Dailymotion\"\n      },\n      \"facebook\": {\n        \"description\": \"来自公共页面的动态、观看和 Reels 视频。\",\n        \"label\": \"Facebook\"\n      },\n      \"instagram\": {\n        \"description\": \"动态、故事、Reels 和精选内容。\",\n        \"label\": \"Instagram\"\n      },\n      \"kick\": {\n        \"description\": \"Kick 平台上的创作者直播和回放。\",\n        \"label\": \"Kick\"\n      },\n      \"linkedin\": {\n        \"description\": \"专业演讲、网络研讨会和学习视频。\",\n        \"label\": \"LinkedIn\"\n      },\n      \"mixcloud\": {\n        \"description\": \"DJ 混音、广播节目和长音频。\",\n        \"label\": \"Mixcloud\"\n      },\n      \"niconico\": {\n        \"description\": \"日本动画、音乐和直播档案。\",\n        \"label\": \"Niconico\"\n      },\n      \"pinterest\": {\n        \"description\": \"创意图钉、教程 Reels 和生活方式灵感视频。\",\n        \"label\": \"Pinterest\"\n      },\n      \"reddit\": {\n        \"description\": \"来自社区的嵌入片段和托管视频。\",\n        \"label\": \"Reddit\"\n      },\n      \"soundcloud\": {\n        \"description\": \"音乐曲目、播放列表和 DJ 套装。\",\n        \"label\": \"SoundCloud\"\n      },\n      \"tiktok\": {\n        \"description\": \"短视频、特效和直播。\",\n        \"label\": \"TikTok\"\n      },\n      \"tumblr\": {\n        \"description\": \"创意短视频和粉丝编辑。\",\n        \"label\": \"Tumblr\"\n      },\n      \"twitch\": {\n        \"description\": \"游戏、音乐和 IRL 直播和 VOD。\",\n        \"label\": \"Twitch\"\n      },\n      \"twitter\": {\n        \"description\": \"时间线帖子、Spaces 录音和广播。\",\n        \"label\": \"X (Twitter)\"\n      },\n      \"vimeo\": {\n        \"description\": \"高质量创作者和商业视频托管。\",\n        \"label\": \"Vimeo\"\n      },\n      \"youtube\": {\n        \"description\": \"来自全球创作者的长视频和直播。\",\n        \"label\": \"YouTube\"\n      },\n      \"youtubemusic\": {\n        \"description\": \"官方音乐视频、专辑和现场表演。\",\n        \"label\": \"YouTube Music\"\n      }\n    },\n    \"popularSection\": \"主流平台\",\n    \"viewAll\": \"查看全部支持的网站\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/resources.ts",
    "content": "import type { Resource } from 'i18next'\nimport ar from './locales/ar.json'\nimport de from './locales/de.json'\nimport en from './locales/en.json'\nimport es from './locales/es.json'\nimport fr from './locales/fr.json'\nimport id from './locales/id.json'\nimport it from './locales/it.json'\nimport ja from './locales/ja.json'\nimport ko from './locales/ko.json'\nimport pt from './locales/pt.json'\nimport ru from './locales/ru.json'\nimport tr from './locales/tr.json'\nimport zhTw from './locales/zh-TW.json'\nimport zh from './locales/zh.json'\nimport { type LanguageCode, supportedLanguageCodes } from './languages'\n\nexport type TranslationDictionary = typeof en\n\nconst translationDictionaries: Record<LanguageCode, TranslationDictionary> = {\n  ar,\n  de,\n  en,\n  es,\n  fr,\n  id,\n  it,\n  ja,\n  ko,\n  pt,\n  ru,\n  tr,\n  'zh-TW': zhTw,\n  zh\n}\n\nexport const translationResources: Resource = Object.fromEntries(\n  supportedLanguageCodes.map((code) => [\n    code,\n    {\n      translation: translationDictionaries[code] ?? en\n    }\n  ])\n)\n"
  },
  {
    "path": "packages/i18n/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n  \"name\": \"@vidbee/ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./theme.css\": \"./src/theme.css\",\n    \"./base.css\": \"./src/base.css\",\n    \"./components/ui/*\": \"./src/components/ui/*.tsx\",\n    \"./lib/cn\": \"./src/lib/cn.ts\",\n    \"./lib/use-add-url-interaction\": \"./src/lib/use-add-url-interaction.ts\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"i18next\": \"^25.6.0\",\n    \"lucide-react\": \"^0.545.0\",\n    \"next-themes\": \"^0.4.6\",\n    \"react-i18next\": \"^16.1.2\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.3.1\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.2.0\",\n    \"typescript\": \"^5.9.2\",\n    \"unplugin-icons\": \"^22.4.2\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/src/base.css",
    "content": "@source \"./components/ui/**/*.tsx\";\n\n@layer base {\n\t* {\n\t\t@apply border-border;\n\t}\n\n\thtml,\n\tbody,\n\t#root {\n\t\theight: 100%;\n\t}\n\n\tbody {\n\t\t@apply bg-background text-foreground;\n\t}\n\n\tinput,\n\ttextarea,\n\t[contenteditable=\"true\"],\n\t[contenteditable=\"\"] {\n\t\t@apply select-text;\n\t}\n\n\tinput::placeholder,\n\ttextarea::placeholder {\n\t\topacity: 0.4;\n\t}\n}\n\n.drag-region {\n\t-webkit-app-region: drag;\n\tapp-region: drag;\n}\n\n.no-drag {\n\t-webkit-app-region: no-drag;\n\tapp-region: no-drag;\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/accordion.tsx",
    "content": "import * as AccordionPrimitive from '@radix-ui/react-accordion'\nimport { cn } from '../../lib/cn'\nimport { ChevronDownIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {\n  return <AccordionPrimitive.Root data-slot=\"accordion\" {...props} />\n}\n\nfunction AccordionItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Item>) {\n  return (\n    <AccordionPrimitive.Item\n      className={cn('border-b last:border-b-0', className)}\n      data-slot=\"accordion-item\"\n      {...props}\n    />\n  )\n}\n\nfunction AccordionTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {\n  return (\n    <AccordionPrimitive.Header className=\"flex\">\n      <AccordionPrimitive.Trigger\n        className={cn(\n          'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',\n          className\n        )}\n        data-slot=\"accordion-trigger\"\n        {...props}\n      >\n        {children}\n        <ChevronDownIcon className=\"pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200\" />\n      </AccordionPrimitive.Trigger>\n    </AccordionPrimitive.Header>\n  )\n}\n\nfunction AccordionContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Content>) {\n  return (\n    <AccordionPrimitive.Content\n      className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n      data-slot=\"accordion-content\"\n      {...props}\n    >\n      <div className={cn('pt-0 pb-4', className)}>{children}</div>\n    </AccordionPrimitive.Content>\n  )\n}\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "packages/ui/src/components/ui/add-url-popover.tsx",
    "content": "import { Plus } from 'lucide-react'\nimport { useId } from 'react'\nimport { Button } from './button'\nimport { Label } from './label'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\nimport { Textarea } from './textarea'\n\ninterface AddUrlPopoverProps {\n  open: boolean\n  value: string\n  triggerLabel: string\n  title: string\n  placeholder: string\n  cancelLabel: string\n  confirmLabel: string\n  confirmDisabled?: boolean\n  invalidMessage?: string\n  onOpenChange: (open: boolean) => void\n  onTriggerClick: () => void\n  onValueChange: (value: string) => void\n  onCancel: () => void\n  onConfirm: () => void\n}\n\nexport const AddUrlPopover = ({\n  open,\n  value,\n  triggerLabel,\n  title,\n  placeholder,\n  cancelLabel,\n  confirmLabel,\n  confirmDisabled = false,\n  invalidMessage,\n  onOpenChange,\n  onTriggerClick,\n  onValueChange,\n  onCancel,\n  onConfirm\n}: AddUrlPopoverProps) => {\n  const textareaId = useId()\n\n  return (\n    <Popover onOpenChange={onOpenChange} open={open}>\n      <PopoverTrigger asChild>\n        <Button className=\"rounded-full\" onClick={onTriggerClick}>\n          <Plus className=\"h-4 w-4\" />\n          {triggerLabel}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" className=\"w-[360px] space-y-3\">\n        <div className=\"space-y-2\">\n          <Label htmlFor={textareaId}>{title}</Label>\n          <Textarea\n            autoFocus\n            id={textareaId}\n            onChange={(event) => {\n              onValueChange(event.target.value.replace(/\\r?\\n/g, ''))\n            }}\n            placeholder={placeholder}\n            rows={4}\n            value={value}\n          />\n        </div>\n        {invalidMessage ? <p className=\"text-destructive text-xs\">{invalidMessage}</p> : null}\n        <div className=\"flex justify-end gap-2\">\n          <Button onClick={onCancel} variant=\"outline\">\n            {cancelLabel}\n          </Button>\n          <Button disabled={confirmDisabled} onClick={onConfirm}>\n            {confirmLabel}\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/app-sidebar-icons.tsx",
    "content": "import type { AppSidebarIcon } from './app-sidebar'\nimport MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'\nimport MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'\nimport MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'\nimport MingcuteDownload3Line from '~icons/mingcute/download-3-line'\nimport MingcuteInformationFill from '~icons/mingcute/information-fill'\nimport MingcuteInformationLine from '~icons/mingcute/information-line'\nimport MingcuteRssFill from '~icons/mingcute/rss-fill'\nimport MingcuteRssLine from '~icons/mingcute/rss-line'\nimport MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'\nimport MingcuteSettingsLine from '~icons/mingcute/settings-3-line'\n\ninterface AppSidebarIcons {\n  home: AppSidebarIcon\n  subscriptions: AppSidebarIcon\n  supportedSites: AppSidebarIcon\n  settings: AppSidebarIcon\n  about: AppSidebarIcon\n}\n\nconst appSidebarIcons: AppSidebarIcons = {\n  home: {\n    active: MingcuteDownload3Fill,\n    inactive: MingcuteDownload3Line\n  },\n  subscriptions: {\n    active: MingcuteRssFill,\n    inactive: MingcuteRssLine\n  },\n  supportedSites: {\n    active: MingcuteCheckCircleFill,\n    inactive: MingcuteCheckCircleLine\n  },\n  settings: {\n    active: MingcuteSettingsFill,\n    inactive: MingcuteSettingsLine\n  },\n  about: {\n    active: MingcuteInformationFill,\n    inactive: MingcuteInformationLine\n  }\n}\n\nexport { appSidebarIcons }\nexport type { AppSidebarIcons }\n"
  },
  {
    "path": "packages/ui/src/components/ui/app-sidebar.tsx",
    "content": "import type * as React from 'react'\nimport { cn } from '../../lib/cn'\nimport { Button } from './button'\nimport { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'\n\ninterface AppSidebarIcon {\n  active: React.ComponentType<{ className?: string }>\n  inactive: React.ComponentType<{ className?: string }>\n}\n\ninterface AppSidebarItem {\n  id: string\n  label: string\n  icon: AppSidebarIcon\n  active?: boolean\n  disabled?: boolean\n  indicator?: boolean\n  showLabel?: boolean\n  showTooltip?: boolean\n  onClick?: () => void\n}\n\ninterface AppSidebarProps {\n  appName?: string\n  logoSrc?: string\n  logoAlt?: string\n  className?: string\n  items: AppSidebarItem[]\n  bottomItems?: AppSidebarItem[]\n}\n\nconst renderSidebarItem = (item: AppSidebarItem) => {\n  const IconComponent = item.active ? item.icon.active : item.icon.inactive\n  const showLabel = item.showLabel ?? true\n\n  const button = (\n    <Button\n      className={cn('no-drag relative h-12 w-12 rounded-2xl', item.active && 'bg-primary/10')}\n      disabled={item.disabled}\n      onClick={item.onClick}\n      size=\"icon\"\n      variant=\"ghost\"\n    >\n      <IconComponent className={cn('h-5! w-5!', item.active && 'text-primary')} />\n      {item.indicator ? (\n        <span className=\"absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500\" />\n      ) : null}\n    </Button>\n  )\n\n  return (\n    <div className=\"flex flex-col items-center gap-1\" key={item.id}>\n      {item.showTooltip ? (\n        <Tooltip>\n          <TooltipTrigger asChild>{button}</TooltipTrigger>\n          <TooltipContent side=\"right\">\n            <p>{item.label}</p>\n          </TooltipContent>\n        </Tooltip>\n      ) : (\n        button\n      )}\n\n      {showLabel ? (\n        <span className=\"px-3 text-center text-muted-foreground text-xs leading-tight\">\n          {item.label}\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\nexport function AppSidebar({\n  appName = 'App',\n  logoSrc = './app-icon.png',\n  logoAlt = 'App icon',\n  className,\n  items,\n  bottomItems = []\n}: AppSidebarProps) {\n  return (\n    <aside\n      className={cn(\n        'drag-region flex w-20 min-w-20 max-w-20 flex-col items-center gap-2 border-border/60 border-r bg-background/77 py-4',\n        className\n      )}\n    >\n      <div className=\"mt-4 flex flex-col items-center gap-1 py-3\">\n        <div className=\"flex h-12 w-12 items-center justify-center\">\n          <img alt={logoAlt} className=\"h-10 w-10\" src={logoSrc} />\n        </div>\n        <span className=\"text-center font-bold text-muted-foreground text-xs leading-tight\">\n          {appName}\n        </span>\n      </div>\n\n      {items.map((item) => renderSidebarItem(item))}\n\n      <div className=\"flex-1\" />\n\n      {bottomItems.map((item) => renderSidebarItem(item))}\n    </aside>\n  )\n}\n\nexport type { AppSidebarIcon, AppSidebarItem, AppSidebarProps }\n"
  },
  {
    "path": "packages/ui/src/components/ui/badge.tsx",
    "content": "import { Slot } from '@radix-ui/react-slot'\nimport { cn } from '../../lib/cn'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport type * as React from 'react'\n\nconst badgeVariants = cva(\n  'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3',\n  {\n    variants: {\n      variant: {\n        default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',\n        destructive:\n          'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',\n        outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'\n      }\n    },\n    defaultVariants: {\n      variant: 'default'\n    }\n  }\n)\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'span'\n\n  return <Comp className={cn(badgeVariants({ variant }), className)} data-slot=\"badge\" {...props} />\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "packages/ui/src/components/ui/button.tsx",
    "content": "import { Slot } from '@radix-ui/react-slot'\nimport { cn } from '../../lib/cn'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport * as React from 'react'\n\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n        outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',\n        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground',\n        link: 'text-primary underline-offset-4 hover:underline'\n      },\n      size: {\n        default: 'h-9 px-4 py-2',\n        sm: 'h-8 rounded-md px-3 text-xs',\n        lg: 'h-10 rounded-md px-8',\n        icon: 'h-9 w-9'\n      }\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default'\n    }\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : 'button'\n    return (\n      <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />\n    )\n  }\n)\nButton.displayName = 'Button'\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "packages/ui/src/components/ui/card.tsx",
    "content": "import { cn } from '../../lib/cn'\nimport * as React from 'react'\n\nconst Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      className={cn('rounded-lg bg-muted/30 text-card-foreground', className)}\n      ref={ref}\n      {...props}\n    />\n  )\n)\nCard.displayName = 'Card'\n\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div className={cn('flex flex-col space-y-1.5 p-6', className)} ref={ref} {...props} />\n  )\n)\nCardHeader.displayName = 'CardHeader'\n\nconst CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      className={cn('font-semibold leading-none tracking-tight', className)}\n      ref={ref}\n      {...props}\n    />\n  )\n)\nCardTitle.displayName = 'CardTitle'\n\nconst CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div className={cn('text-muted-foreground text-sm', className)} ref={ref} {...props} />\n  )\n)\nCardDescription.displayName = 'CardDescription'\n\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div className={cn('p-6 pt-0', className)} ref={ref} {...props} />\n  )\n)\nCardContent.displayName = 'CardContent'\n\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div className={cn('flex items-center p-6 pt-0', className)} ref={ref} {...props} />\n  )\n)\nCardFooter.displayName = 'CardFooter'\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }\n"
  },
  {
    "path": "packages/ui/src/components/ui/checkbox.tsx",
    "content": "import * as CheckboxPrimitive from '@radix-ui/react-checkbox'\nimport { cn } from '../../lib/cn'\nimport { CheckIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      className={cn(\n        'peer size-4 shrink-0 rounded-[4px] border shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40',\n        className\n      )}\n      data-slot=\"checkbox\"\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        className=\"grid place-content-center text-current transition-none\"\n        data-slot=\"checkbox-indicator\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  )\n}\nexport { Checkbox }\n"
  },
  {
    "path": "packages/ui/src/components/ui/context-menu.tsx",
    "content": "import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'\nimport { cn } from '../../lib/cn'\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />\n}\n\nfunction ContextMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />\n}\n\nfunction ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />\n}\n\nfunction ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />\n}\n\nfunction ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />\n}\n\nfunction ContextMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return <ContextMenuPrimitive.RadioGroup data-slot=\"context-menu-radio-group\" {...props} />\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      className={cn(\n        \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"context-menu-sub-trigger\"\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction ContextMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className\n      )}\n      data-slot=\"context-menu-sub-content\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className\n        )}\n        data-slot=\"context-menu-content\"\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  )\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: 'default' | 'destructive'\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      className={cn(\n        \"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"context-menu-item\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      checked={checked}\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"context-menu-checkbox-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"context-menu-radio-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  )\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      className={cn('px-2 py-1.5 font-medium text-foreground text-sm data-[inset]:pl-8', className)}\n      data-inset={inset}\n      data-slot=\"context-menu-label\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      className={cn('-mx-1 my-1 h-px bg-border', className)}\n      data-slot=\"context-menu-separator\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}\n      data-slot=\"context-menu-shortcut\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/dialog.tsx",
    "content": "import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { cn } from '../../lib/cn'\nimport { XIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className\n      )}\n      data-slot=\"dialog-overlay\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg',\n          className\n        )}\n        data-slot=\"dialog-content\"\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            className=\"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\"\n            data-slot=\"dialog-close\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-2 text-center sm:text-left', className)}\n      data-slot=\"dialog-header\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}\n      data-slot=\"dialog-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      className={cn('font-semibold text-lg leading-none', className)}\n      data-slot=\"dialog-title\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      className={cn('text-muted-foreground text-sm', className)}\n      data-slot=\"dialog-description\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/download-dialog-layout.tsx",
    "content": "import { List, Rocket, Video } from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\nimport { Button } from './button'\nimport { Dialog, DialogContent, DialogFooter, DialogHeader } from './dialog'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'\nimport { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'\n\ninterface DownloadDialogLayoutProps {\n  open: boolean\n  lockDialogHeight: boolean\n  oneClickDownloadEnabled: boolean\n  oneClickTooltip: string\n  activeTab: 'single' | 'playlist'\n  singleTabLabel: string\n  playlistTabLabel: string\n  addUrlPopover: ReactNode\n  singleTabContent: ReactNode\n  playlistTabContent: ReactNode\n  footer: ReactNode\n  onOpenChange: (open: boolean) => void\n  onToggleOneClickDownload: () => void\n  onActiveTabChange: (tab: 'single' | 'playlist') => void\n}\n\nexport const DownloadDialogLayout = ({\n  open,\n  lockDialogHeight,\n  oneClickDownloadEnabled,\n  oneClickTooltip,\n  activeTab,\n  singleTabLabel,\n  playlistTabLabel,\n  addUrlPopover,\n  singleTabContent,\n  playlistTabContent,\n  footer,\n  onOpenChange,\n  onToggleOneClickDownload,\n  onActiveTabChange\n}: DownloadDialogLayoutProps) => {\n  return (\n    <Dialog onOpenChange={onOpenChange} open={open}>\n      <div className=\"flex items-center gap-4\">\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div className=\"relative\">\n              <Button\n                className=\"rounded-full\"\n                onClick={onToggleOneClickDownload}\n                size=\"icon\"\n                variant=\"ghost\"\n              >\n                <Rocket className=\"h-4 w-4 text-muted-foreground\" />\n              </Button>\n              <span\n                className={`absolute top-0 -right-2 inline-flex h-3.5 items-center justify-center whitespace-nowrap rounded-full px-1 font-semibold text-xs leading-none ${oneClickDownloadEnabled ? 'bg-being-green-400 text-primary-foreground' : 'bg-secondary text-secondary-foreground'}`}\n              >\n                {oneClickDownloadEnabled ? 'ON' : 'OFF'}\n              </span>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent className=\"max-w-xs\" side=\"bottom\">\n            {oneClickTooltip}\n          </TooltipContent>\n        </Tooltip>\n        {addUrlPopover}\n      </div>\n      <DialogContent\n        className={cn(\n          'flex max-h-[90vh] flex-col gap-0 overflow-hidden p-5 sm:max-w-xl',\n          lockDialogHeight && 'h-[90vh]'\n        )}\n      >\n        <Tabs\n          className=\"flex min-h-0 w-full flex-1 flex-col gap-0\"\n          defaultValue=\"single\"\n          onValueChange={(value) => onActiveTabChange(value as 'single' | 'playlist')}\n          value={activeTab}\n        >\n          <DialogHeader>\n            <TabsList>\n              <TabsTrigger onClick={() => onActiveTabChange('single')} value=\"single\">\n                <Video className=\"h-3.5 w-3.5\" />\n                {singleTabLabel}\n              </TabsTrigger>\n              <TabsTrigger onClick={() => onActiveTabChange('playlist')} value=\"playlist\">\n                <List className=\"h-3.5 w-3.5\" />\n                {playlistTabLabel}\n              </TabsTrigger>\n            </TabsList>\n          </DialogHeader>\n          <TabsContent className=\"mt-0 flex min-h-0 flex-1 flex-col\" value=\"single\">\n            {singleTabContent}\n          </TabsContent>\n          <TabsContent className=\"mt-0 flex min-h-0 flex-1 flex-col\" value=\"playlist\">\n            {playlistTabContent}\n          </TabsContent>\n        </Tabs>\n        <DialogFooter className=\"relative z-10 shrink-0 border-t bg-background pt-3\">\n          {footer}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/download-empty-state.tsx",
    "content": "import { History as HistoryIcon } from 'lucide-react'\nimport { cn } from '../../lib/cn'\n\ninterface DownloadEmptyStateProps {\n  message: string\n  className?: string\n}\n\nexport const DownloadEmptyState = ({ message, className }: DownloadEmptyStateProps) => {\n  return (\n    <div\n      className={cn(\n        'flex flex-col items-center justify-center gap-3 rounded-xl border border-border/60 border-dashed px-6 py-10 text-center text-muted-foreground',\n        className\n      )}\n    >\n      <HistoryIcon className=\"h-10 w-10 opacity-50\" />\n      <p className=\"font-medium text-sm\">{message}</p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/download-filter-bar.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\nimport { Button } from './button'\n\nexport interface DownloadFilterItem<TFilter extends string> {\n  key: TFilter\n  label: string\n  count: number\n}\n\ninterface DownloadFilterBarProps<TFilter extends string> {\n  filters: Array<DownloadFilterItem<TFilter>>\n  activeFilter: TFilter\n  onFilterChange: (filter: TFilter) => void\n  actions?: ReactNode\n}\n\nexport const DownloadFilterBar = <TFilter extends string>({\n  filters,\n  activeFilter,\n  onFilterChange,\n  actions\n}: DownloadFilterBarProps<TFilter>) => {\n  return (\n    <div className=\"flex flex-wrap items-center justify-between gap-2 text-sm\">\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {filters.map((filter) => {\n          const isActive = activeFilter === filter.key\n          return (\n            <Button\n              className={\n                isActive\n                  ? 'h-8 rounded-full px-3 shadow-sm'\n                  : 'h-8 rounded-full border border-border/60 px-3'\n              }\n              key={filter.key}\n              onClick={() => onFilterChange(filter.key)}\n              size=\"sm\"\n              variant={isActive ? 'secondary' : 'ghost'}\n            >\n              <span>{filter.label}</span>\n              <span\n                className={cn(\n                  'ml-1 min-w-5 rounded-full px-1 font-medium text-neutral-900 text-xs',\n                  isActive ? 'bg-neutral-100' : 'bg-neutral-200'\n                )}\n              >\n                {filter.count}\n              </span>\n            </Button>\n          )\n        })}\n      </div>\n      {actions ? <div className=\"flex items-center gap-2\">{actions}</div> : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/dropdown-menu.tsx",
    "content": "import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'\nimport { cn } from '../../lib/cn'\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return <DropdownMenuPrimitive.Trigger data-slot=\"dropdown-menu-trigger\" {...props} />\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className\n        )}\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: 'default' | 'destructive'\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      className={cn(\n        \"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-item\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      checked={checked}\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"dropdown-menu-checkbox-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return <DropdownMenuPrimitive.RadioGroup data-slot=\"dropdown-menu-radio-group\" {...props} />\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"dropdown-menu-radio-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      className={cn('px-2 py-1.5 font-medium text-sm data-[inset]:pl-8', className)}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-label\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      className={cn('-mx-1 my-1 h-px bg-border', className)}\n      data-slot=\"dropdown-menu-separator\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}\n      data-slot=\"dropdown-menu-shortcut\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      className={cn(\n        \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-sub-trigger\"\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className\n      )}\n      data-slot=\"dropdown-menu-sub-content\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/feedback-link-buttons.tsx",
    "content": "import { BookOpen, Github, MessageCircle, Twitter } from 'lucide-react'\nimport { type MouseEvent, useMemo } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { Button, type ButtonProps } from './button'\n\nexport const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[Bug]: Download error report'\n\nconst FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'\nconst FEEDBACK_UNKNOWN_ERROR = 'Unknown error'\nconst FEEDBACK_UNKNOWN_VALUE = 'Unknown'\nconst FEEDBACK_SOURCE_LABEL = 'Source URL'\nconst FEEDBACK_ERROR_LABEL = 'Error'\nconst FEEDBACK_COMMAND_LABEL = 'yt-dlp command'\nconst FEEDBACK_MAX_GITHUB_URL_LENGTH = 7000\nconst FAQ_URL = 'https://docs.vidbee.org/faq/'\n\nconst normalizeErrorText = (value?: string | null): string =>\n  value ? value.replace(/\\s+/g, ' ').trim() : ''\n\nconst clampText = (value: string, maxLength: number): string =>\n  value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value\n\nconst buildIssueLogs = (\n  errorText: string,\n  sourceUrl: string | undefined,\n  ytDlpCommand: string | undefined,\n  urlLabel: string,\n  errorLabel: string,\n  commandLabel: string\n): string => {\n  const lines: string[] = []\n  if (sourceUrl) {\n    lines.push(`**${urlLabel}:**\\n${sourceUrl}\\n`)\n  }\n  if (ytDlpCommand) {\n    lines.push(`**${commandLabel}:**\\n\\`\\`\\`bash\\n${ytDlpCommand}\\n\\`\\`\\`\\n`)\n  }\n  lines.push(`**${errorLabel}:**\\n${errorText}`)\n  return lines.join('\\n')\n}\n\ninterface FeedbackLinkButtonsProps {\n  error?: string | null\n  sourceUrl?: string | null\n  issueTitle?: string\n  includeAppInfo?: boolean\n  appInfo?: {\n    appVersion?: string | null\n    osVersion?: string | null\n  }\n  buttonVariant?: ButtonProps['variant']\n  buttonSize?: ButtonProps['size']\n  buttonClassName?: string\n  iconClassName?: string\n  onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void\n  ytDlpCommand?: string\n  useSimpleGithubUrl?: boolean\n  wrapperClassName?: string\n  showGroupSeparator?: boolean\n}\n\nexport const FeedbackLinkButtons = ({\n  error,\n  sourceUrl,\n  issueTitle = '[Bug]: ',\n  includeAppInfo = false,\n  appInfo,\n  buttonVariant = 'outline',\n  buttonSize = 'sm',\n  buttonClassName,\n  iconClassName,\n  onLinkClick,\n  ytDlpCommand,\n  useSimpleGithubUrl = false,\n  wrapperClassName = 'flex flex-wrap gap-2',\n  showGroupSeparator = false\n}: FeedbackLinkButtonsProps) => {\n  const { t } = useTranslation()\n  const appVersion = appInfo?.appVersion ?? ''\n  const osVersion = appInfo?.osVersion ?? ''\n\n  const links = useMemo(() => {\n    const compactError = normalizeErrorText(error)\n    const tweetError = compactError ? clampText(compactError, 160) : ''\n    const versionLabels = [\n      appVersion ? `v${appVersion}` : null,\n      osVersion ? osVersion : null\n    ].filter(Boolean)\n    const tweetPrefix = versionLabels.length\n      ? `${FEEDBACK_TWEET_PREFIX} ${versionLabels.join(' ')}`\n      : FEEDBACK_TWEET_PREFIX\n    const tweetText = encodeURIComponent(\n      tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix\n    )\n    const issueError = compactError || FEEDBACK_UNKNOWN_ERROR\n    const resolvedSourceUrl = sourceUrl?.trim() || undefined\n    const normalizedCommand = ytDlpCommand?.trim() || undefined\n    const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)\n    const issueLogs = shouldIncludeLogs\n      ? buildIssueLogs(\n          issueError,\n          resolvedSourceUrl,\n          normalizedCommand,\n          FEEDBACK_SOURCE_LABEL,\n          FEEDBACK_ERROR_LABEL,\n          FEEDBACK_COMMAND_LABEL\n        )\n      : null\n    const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE\n    const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE\n\n    let githubUrl: string\n    if (useSimpleGithubUrl) {\n      githubUrl = 'https://github.com/nexmoe/VidBee/issues/new/choose'\n    } else {\n      const issueParams = new URLSearchParams({\n        template: 'bug_report.yml',\n        title: issueTitle\n      })\n\n      if (issueLogs) {\n        issueParams.set('logs', issueLogs)\n      }\n      if (includeAppInfo) {\n        issueParams.set('app_version', appVersionValue)\n        issueParams.set('os_version', osVersionValue)\n      }\n\n      githubUrl = `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`\n    }\n\n    const feedbackLinks = [\n      {\n        icon: Github,\n        label: t('about.resources.githubIssues'),\n        href: githubUrl,\n        group: 'feedback'\n      },\n      {\n        icon: Twitter,\n        label: t('about.resources.xFeedback'),\n        href: `https://x.com/intent/tweet?text=${tweetText}`,\n        group: 'feedback'\n      },\n      {\n        icon: MessageCircle,\n        label: t('about.resources.discord'),\n        href: 'https://discord.gg/uBqXV6QPdm',\n        group: 'feedback'\n      }\n    ]\n\n    if (error) {\n      feedbackLinks.push({\n        icon: BookOpen,\n        label: t('about.resources.faq') ?? 'FAQ',\n        href: FAQ_URL,\n        group: 'utility'\n      })\n    }\n\n    return feedbackLinks\n  }, [\n    appVersion,\n    error,\n    includeAppInfo,\n    issueTitle,\n    osVersion,\n    sourceUrl,\n    t,\n    ytDlpCommand,\n    useSimpleGithubUrl\n  ])\n\n  const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>, href: string) => {\n    if (href.startsWith('https://github.com') && href.length >= FEEDBACK_MAX_GITHUB_URL_LENGTH) {\n      toast.info(t('download.feedback.githubUrlTooLong'))\n    }\n    onLinkClick?.(event)\n  }\n\n  const feedbackLinks = links.filter((link) => link.group === 'feedback')\n  const utilityLinks = links.filter((link) => link.group === 'utility')\n\n  return (\n    <div className={wrapperClassName}>\n      {utilityLinks.length > 0 && (\n        <>\n          {utilityLinks.map((resource) => {\n            return (\n              <Button\n                asChild\n                className={buttonClassName}\n                key={resource.label}\n                size={buttonSize}\n                variant={buttonVariant}\n              >\n                <a\n                  href={resource.href}\n                  onClick={(event) => handleLinkClick(event, resource.href)}\n                  rel=\"noreferrer\"\n                  target=\"_blank\"\n                >\n                  {resource.label}\n                </a>\n              </Button>\n            )\n          })}\n          {showGroupSeparator && <div className=\"mx-1 h-4 border-border/40 border-l\" />}\n        </>\n      )}\n      {feedbackLinks.map((resource) => {\n        const Icon = resource.icon\n        return (\n          <Button\n            asChild\n            className={buttonClassName}\n            key={resource.label}\n            size={buttonSize}\n            variant={buttonVariant}\n          >\n            <a\n              href={resource.href}\n              onClick={(event) => handleLinkClick(event, resource.href)}\n              rel=\"noreferrer\"\n              target=\"_blank\"\n            >\n              <Icon className={iconClassName} />\n              {resource.label}\n            </a>\n          </Button>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/hover-card.tsx",
    "content": "import * as HoverCardPrimitive from '@radix-ui/react-hover-card'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {\n  return <HoverCardPrimitive.Root data-slot=\"hover-card\" {...props} />\n}\n\nfunction HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {\n  return <HoverCardPrimitive.Trigger data-slot=\"hover-card-trigger\" {...props} />\n}\n\nfunction HoverCardContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {\n  return (\n    <HoverCardPrimitive.Portal data-slot=\"hover-card-portal\">\n      <HoverCardPrimitive.Content\n        align={align}\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className\n        )}\n        data-slot=\"hover-card-content\"\n        sideOffset={sideOffset}\n        {...props}\n      />\n    </HoverCardPrimitive.Portal>\n  )\n}\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n"
  },
  {
    "path": "packages/ui/src/components/ui/image-with-placeholder.tsx",
    "content": "import { cn } from '../../lib/cn'\nimport { ImageIcon } from 'lucide-react'\nimport { useState } from 'react'\n\ninterface ImageWithPlaceholderProps {\n  src?: string\n  alt: string\n  className?: string\n  placeholderClassName?: string\n  fallbackIcon?: React.ReactNode\n  onError?: () => void\n  onLoad?: () => void\n}\n\nexport function ImageWithPlaceholder({\n  src,\n  alt,\n  className,\n  placeholderClassName,\n  fallbackIcon,\n  onError,\n  onLoad\n}: ImageWithPlaceholderProps) {\n  const [hasError, setHasError] = useState(false)\n  const [isLoading, setIsLoading] = useState(true)\n\n  const handleError = () => {\n    setHasError(true)\n    setIsLoading(false)\n    onError?.()\n  }\n\n  const handleLoad = () => {\n    setIsLoading(false)\n    onLoad?.()\n  }\n\n  // Show placeholder if no src, error occurred, or still loading\n  if (!src || hasError) {\n    return (\n      <div\n        className={cn('flex items-center justify-center bg-muted text-muted-foreground', className)}\n      >\n        {fallbackIcon || <ImageIcon className=\"h-6 w-6\" />}\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn('relative h-full w-full', className)}>\n      {isLoading && (\n        <div\n          className={cn(\n            'absolute inset-0 flex items-center justify-center bg-muted text-muted-foreground',\n            placeholderClassName\n          )}\n        >\n          {fallbackIcon || <ImageIcon className=\"h-6 w-6\" />}\n        </div>\n      )}\n      <img\n        alt={alt}\n        className={cn('h-full w-full object-cover', isLoading && 'opacity-0')}\n        onError={handleError}\n        onLoad={handleLoad}\n        src={src}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/input.tsx",
    "content": "import { cn } from '../../lib/cn'\nimport * as React from 'react'\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        className={cn(\n          'flex h-9 w-full rounded-md border bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n          className\n        )}\n        ref={ref}\n        type={type}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = 'Input'\n\nexport { Input }\n"
  },
  {
    "path": "packages/ui/src/components/ui/item.tsx",
    "content": "import { Slot } from '@radix-ui/react-slot'\nimport { Separator } from './separator'\nimport { cn } from '../../lib/cn'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport type * as React from 'react'\n\nfunction ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('group/item-group flex flex-col overflow-hidden rounded-md', className)}\n      data-slot=\"item-group\"\n      {...props}\n    />\n  )\n}\n\nfunction ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n  return (\n    <div className=\"bg-muted/40 px-4\">\n      <Separator\n        className={cn('my-0', className)}\n        data-slot=\"item-separator\"\n        orientation=\"horizontal\"\n        {...props}\n      />\n    </div>\n  )\n}\n\nconst itemVariants = cva(\n  'group/item flex flex-wrap items-center border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        outline: 'border-border',\n        muted: 'bg-muted/40'\n      },\n      size: {\n        default: 'gap-4 p-4',\n        sm: 'gap-2.5 px-4 py-3'\n      },\n      rounded: {\n        default: 'rounded-none',\n        none: 'rounded-none',\n        top: 'rounded-t-md',\n        bottom: 'rounded-b-md',\n        both: 'rounded-md'\n      }\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n      rounded: 'default'\n    }\n  }\n)\n\nfunction Item({\n  className,\n  variant = 'default',\n  size = 'default',\n  rounded = 'default',\n  asChild = false,\n  ...props\n}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'div'\n  return (\n    <Comp\n      className={cn(itemVariants({ variant, size, rounded, className }))}\n      data-rounded={rounded}\n      data-size={size}\n      data-slot=\"item\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nconst itemMediaVariants = cva(\n  'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        icon: \"size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4\",\n        image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover'\n      }\n    },\n    defaultVariants: {\n      variant: 'default'\n    }\n  }\n)\n\nfunction ItemMedia({\n  className,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {\n  return (\n    <div\n      className={cn(itemMediaVariants({ variant, className }))}\n      data-slot=\"item-media\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nfunction ItemContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}\n      data-slot=\"item-content\"\n      {...props}\n    />\n  )\n}\n\nfunction ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex w-fit items-center gap-2 font-medium text-sm leading-snug', className)}\n      data-slot=\"item-title\"\n      {...props}\n    />\n  )\n}\n\nfunction ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {\n  return (\n    <p\n      className={cn(\n        'line-clamp-2 text-balance font-normal text-muted-foreground text-sm leading-normal',\n        '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',\n        className\n      )}\n      data-slot=\"item-description\"\n      {...props}\n    />\n  )\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div className={cn('flex items-center gap-2', className)} data-slot=\"item-actions\" {...props} />\n  )\n}\n\nfunction ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex basis-full items-center justify-between gap-2', className)}\n      data-slot=\"item-header\"\n      {...props}\n    />\n  )\n}\n\nfunction ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex basis-full items-center justify-between gap-2', className)}\n      data-slot=\"item-footer\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemActions,\n  ItemGroup,\n  ItemSeparator,\n  ItemTitle,\n  ItemDescription,\n  ItemHeader,\n  ItemFooter\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/label.tsx",
    "content": "import * as LabelPrimitive from '@radix-ui/react-label'\nimport { cn } from '../../lib/cn'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport * as React from 'react'\n\nconst labelVariants = cva(\n  'font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'\n)\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />\n))\nLabel.displayName = LabelPrimitive.Root.displayName\n\nexport { Label }\n"
  },
  {
    "path": "packages/ui/src/components/ui/popover.tsx",
    "content": "import * as PopoverPrimitive from '@radix-ui/react-popover'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        align={align}\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className\n        )}\n        data-slot=\"popover-content\"\n        sideOffset={sideOffset}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  )\n}\n\nfunction PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "packages/ui/src/components/ui/progress.tsx",
    "content": "import * as ProgressPrimitive from '@radix-ui/react-progress'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Progress({\n  className,\n  value,\n  ...props\n}: React.ComponentProps<typeof ProgressPrimitive.Root>) {\n  return (\n    <ProgressPrimitive.Root\n      className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}\n      data-slot=\"progress\"\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        className=\"h-full w-full flex-1 bg-primary transition-all\"\n        data-slot=\"progress-indicator\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n    </ProgressPrimitive.Root>\n  )\n}\n\nexport { Progress }\n"
  },
  {
    "path": "packages/ui/src/components/ui/radio-group.tsx",
    "content": "import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'\nimport { cn } from '../../lib/cn'\nimport { CircleIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction RadioGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {\n  return (\n    <RadioGroupPrimitive.Root\n      className={cn('grid gap-3', className)}\n      data-slot=\"radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {\n  return (\n    <RadioGroupPrimitive.Item\n      className={cn(\n        'aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40',\n        className\n      )}\n      data-slot=\"radio-group-item\"\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator\n        className=\"relative flex items-center justify-center\"\n        data-slot=\"radio-group-indicator\"\n      >\n        <CircleIcon className=\"absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  )\n}\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "packages/ui/src/components/ui/remote-image.tsx",
    "content": "import { Loader2 } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport { ImageWithPlaceholder } from './image-with-placeholder'\n\ninterface RemoteImageProps {\n  src?: string | null\n  alt: string\n  className?: string\n  placeholderClassName?: string\n  fallbackIcon?: React.ReactNode\n  loadingIcon?: React.ReactNode\n  onError?: () => void\n  onLoadingChange?: (loading: boolean) => void\n  useCache?: boolean\n  cacheResolver?: (url: string) => Promise<string | null | undefined>\n  localUrlPrefixes?: string[]\n  cacheTimeoutMs?: number\n}\n\nconst DEFAULT_CACHE_TIMEOUT_MS = 30_000\nconst DEFAULT_LOCAL_URL_PREFIXES = ['file://', 'data:', 'blob:']\n\nconst isHttpUrl = (value: string): boolean => {\n  return value.startsWith('http://') || value.startsWith('https://')\n}\n\nconst isLocalUrl = (value: string, prefixes: readonly string[]): boolean => {\n  for (const prefix of prefixes) {\n    if (value.startsWith(prefix)) {\n      return true\n    }\n  }\n\n  return false\n}\n\nexport function RemoteImage({\n  src,\n  alt,\n  className,\n  placeholderClassName,\n  fallbackIcon,\n  loadingIcon,\n  onError,\n  onLoadingChange,\n  useCache = true,\n  cacheResolver,\n  localUrlPrefixes = DEFAULT_LOCAL_URL_PREFIXES,\n  cacheTimeoutMs = DEFAULT_CACHE_TIMEOUT_MS\n}: RemoteImageProps) {\n  const [resolvedSrc, setResolvedSrc] = useState<string | undefined>()\n  const [isResolving, setIsResolving] = useState(false)\n  const [isImageLoading, setIsImageLoading] = useState(true)\n\n  useEffect(() => {\n    let isActive = true\n\n    const resolveSource = async () => {\n      const value = src ?? undefined\n\n      if (!value) {\n        setResolvedSrc(undefined)\n        setIsResolving(false)\n        return\n      }\n\n      const shouldResolveFromCache =\n        useCache &&\n        Boolean(cacheResolver) &&\n        isHttpUrl(value) &&\n        !isLocalUrl(value, localUrlPrefixes)\n\n      if (!shouldResolveFromCache || !cacheResolver) {\n        setResolvedSrc(value)\n        setIsResolving(false)\n        return\n      }\n\n      setIsResolving(true)\n\n      let timeoutId = -1\n\n      try {\n        const timeoutPromise = new Promise<undefined>((resolve) => {\n          timeoutId = window.setTimeout(() => resolve(undefined), cacheTimeoutMs)\n        })\n\n        const resolved = await Promise.race([\n          cacheResolver(value).then((output) => output ?? undefined),\n          timeoutPromise\n        ])\n\n        if (!isActive) {\n          return\n        }\n\n        setResolvedSrc(resolved)\n      } catch {\n        if (!isActive) {\n          return\n        }\n\n        setResolvedSrc(undefined)\n      } finally {\n        if (timeoutId >= 0) {\n          window.clearTimeout(timeoutId)\n        }\n        if (isActive) {\n          setIsResolving(false)\n        }\n      }\n    }\n\n    void resolveSource()\n\n    return () => {\n      isActive = false\n    }\n  }, [cacheResolver, cacheTimeoutMs, localUrlPrefixes, src, useCache])\n\n  useEffect(() => {\n    if (resolvedSrc) {\n      setIsImageLoading(true)\n      return\n    }\n\n    setIsImageLoading(false)\n  }, [resolvedSrc])\n\n  const isLoading = isResolving || isImageLoading\n\n  useEffect(() => {\n    onLoadingChange?.(isLoading)\n  }, [isLoading, onLoadingChange])\n\n  const defaultLoadingIcon = <Loader2 className=\"h-6 w-6 animate-spin\" />\n  const displayLoadingIcon = loadingIcon ?? defaultLoadingIcon\n\n  return (\n    <ImageWithPlaceholder\n      alt={alt}\n      className={className}\n      fallbackIcon={isLoading ? displayLoadingIcon : fallbackIcon}\n      onError={onError}\n      onLoad={() => setIsImageLoading(false)}\n      placeholderClassName={placeholderClassName}\n      src={resolvedSrc}\n    />\n  )\n}\n\nexport type { RemoteImageProps }\n"
  },
  {
    "path": "packages/ui/src/components/ui/scroll-area.tsx",
    "content": "import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      className={cn('relative', className)}\n      data-slot=\"scroll-area\"\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        className=\"size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        data-slot=\"scroll-area-viewport\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  )\n}\n\nfunction ScrollBar({\n  className,\n  orientation = 'vertical',\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      className={cn(\n        'flex touch-none select-none p-px transition-colors',\n        orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',\n        orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',\n        className\n      )}\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        className=\"relative flex-1 rounded-full bg-border\"\n        data-slot=\"scroll-area-thumb\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  )\n}\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "packages/ui/src/components/ui/select.tsx",
    "content": "import * as SelectPrimitive from '@radix-ui/react-select'\nimport { cn } from '../../lib/cn'\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react'\nimport * as React from 'react'\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    className={cn(\n      'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',\n      className\n    )}\n    ref={ref}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    ref={ref}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    ref={ref}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = 'popper', ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',\n        position === 'popper' &&\n          'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',\n        className\n      )}\n      position={position}\n      ref={ref}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          'p-1',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n))\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    className={cn('px-2 py-1.5 font-semibold text-sm', className)}\n    ref={ref}\n    {...props}\n  />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    className={cn(\n      'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    ref={ref}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    className={cn('-mx-1 my-1 h-px bg-muted', className)}\n    ref={ref}\n    {...props}\n  />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/separator.tsx",
    "content": "import * as SeparatorPrimitive from '@radix-ui/react-separator'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Separator({\n  className,\n  orientation = 'horizontal',\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      className={cn(\n        'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',\n        className\n      )}\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "packages/ui/src/components/ui/sheet.tsx",
    "content": "import * as SheetPrimitive from '@radix-ui/react-dialog'\nimport { cn } from '../../lib/cn'\nimport { XIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />\n}\n\nfunction SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />\n}\n\nfunction SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />\n}\n\nfunction SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className\n      )}\n      data-slot=\"sheet-overlay\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = 'right',\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: 'top' | 'right' | 'bottom' | 'left'\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        className={cn(\n          'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500',\n          side === 'right' &&\n            'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n          side === 'left' &&\n            'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n          side === 'top' &&\n            'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n          side === 'bottom' &&\n            'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n          className\n        )}\n        data-slot=\"sheet-content\"\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  )\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-1.5 p-4', className)}\n      data-slot=\"sheet-header\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('mt-auto flex flex-col gap-2 p-4', className)}\n      data-slot=\"sheet-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      className={cn('font-semibold text-foreground', className)}\n      data-slot=\"sheet-title\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      className={cn('text-muted-foreground text-sm', className)}\n      data-slot=\"sheet-description\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/sidebar.tsx",
    "content": "import { CheckCircle2, Download, Info, Rss, Settings } from 'lucide-react'\nimport type * as React from 'react'\nimport { cn } from '../../lib/cn'\nimport { Button } from './button'\nimport { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'\n\ntype Page = 'home' | 'subscriptions' | 'settings' | 'about'\ntype NavigationTarget = Page | 'supported-sites'\n\ninterface SidebarIcon {\n  active: React.ComponentType<{ className?: string }>\n  inactive: React.ComponentType<{ className?: string }>\n}\n\ninterface SidebarLabels {\n  download: string\n  subscriptions: string\n  supportedSites: string\n  settings: string\n  about: string\n}\n\ninterface SidebarIcons {\n  home: SidebarIcon\n  subscriptions: SidebarIcon\n  supportedSites: SidebarIcon\n  settings: SidebarIcon\n  about: SidebarIcon\n}\n\ninterface SidebarProps {\n  currentPage: Page\n  onPageChange: (page: Page) => void\n  onOpenSupportedSites: () => void\n  labels: SidebarLabels\n  updateAvailable?: boolean\n  logoSrc?: string\n  logoAlt?: string\n  appName?: string\n  className?: string\n  icons?: Partial<SidebarIcons>\n}\n\ninterface NavigationItem {\n  id: NavigationTarget\n  icon: SidebarIcon\n  label: string\n  onClick?: () => void\n}\n\ninterface PageNavigationItem {\n  id: Page\n  icon: SidebarIcon\n  label: string\n}\n\nconst defaultIcons: SidebarIcons = {\n  home: {\n    active: Download,\n    inactive: Download\n  },\n  subscriptions: {\n    active: Rss,\n    inactive: Rss\n  },\n  supportedSites: {\n    active: CheckCircle2,\n    inactive: CheckCircle2\n  },\n  settings: {\n    active: Settings,\n    inactive: Settings\n  },\n  about: {\n    active: Info,\n    inactive: Info\n  }\n}\n\nconst getIcon = (icons: Partial<SidebarIcons> | undefined, key: keyof SidebarIcons): SidebarIcon => {\n  return icons?.[key] ?? defaultIcons[key]\n}\n\nexport function Sidebar({\n  currentPage,\n  onPageChange,\n  onOpenSupportedSites,\n  labels,\n  updateAvailable = false,\n  logoSrc = './app-icon.png',\n  logoAlt = 'App icon',\n  appName = 'App',\n  className,\n  icons\n}: SidebarProps) {\n  const navigationItems: NavigationItem[] = [\n    {\n      id: 'home',\n      icon: getIcon(icons, 'home'),\n      label: labels.download\n    },\n    {\n      id: 'subscriptions',\n      icon: getIcon(icons, 'subscriptions'),\n      label: labels.subscriptions\n    },\n    {\n      id: 'supported-sites',\n      icon: getIcon(icons, 'supportedSites'),\n      label: labels.supportedSites,\n      onClick: onOpenSupportedSites\n    }\n  ]\n\n  const bottomNavigationItems: PageNavigationItem[] = [\n    {\n      id: 'settings',\n      icon: getIcon(icons, 'settings'),\n      label: labels.settings\n    },\n    {\n      id: 'about',\n      icon: getIcon(icons, 'about'),\n      label: labels.about\n    }\n  ]\n\n  const renderNavigationItem = (item: NavigationItem, showLabel = true) => {\n    const isActive = item.id !== 'supported-sites' && currentPage === item.id\n    const IconComponent = isActive ? item.icon.active : item.icon.inactive\n    const handleClick = item.onClick ?? (() => onPageChange(item.id as Page))\n\n    return (\n      <div className=\"flex flex-col items-center gap-1\" key={item.id}>\n        <Button\n          className={cn('no-drag h-12 w-12 rounded-2xl', isActive && 'bg-primary/10')}\n          onClick={handleClick}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <IconComponent className={cn('h-5! w-5!', isActive && 'text-primary')} />\n        </Button>\n\n        {showLabel ? (\n          <span className=\"px-3 text-center text-muted-foreground text-xs leading-tight\">{item.label}</span>\n        ) : null}\n      </div>\n    )\n  }\n\n  return (\n    <aside\n      className={cn(\n        'drag-region flex w-20 min-w-20 max-w-20 flex-col items-center gap-2 border-border/60 border-r bg-background/77 py-4',\n        className\n      )}\n    >\n      <div className=\"mt-4 flex flex-col items-center gap-1 py-3\">\n        <div className=\"flex h-12 w-12 items-center justify-center\">\n          <img alt={logoAlt} className=\"h-10 w-10\" src={logoSrc} />\n        </div>\n        <span className=\"text-center font-bold text-muted-foreground text-xs leading-tight\">{appName}</span>\n      </div>\n\n      {navigationItems.map((item) => renderNavigationItem(item))}\n\n      <div className=\"flex-1\" />\n\n      {bottomNavigationItems.map((item) => {\n        const isActive = currentPage === item.id\n        const IconComponent = isActive ? item.icon.active : item.icon.inactive\n        const showUpdateDot = item.id === 'about' && updateAvailable\n\n        return (\n          <div className=\"flex flex-col items-center gap-1\" key={item.id}>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  className={cn('no-drag relative h-12 w-12 rounded-2xl', isActive && 'bg-primary/10')}\n                  onClick={() => onPageChange(item.id)}\n                  size=\"icon\"\n                  variant=\"ghost\"\n                >\n                  <IconComponent className={cn('h-5! w-5!', isActive && 'text-primary')} />\n                  {showUpdateDot ? (\n                    <span className=\"absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500\" />\n                  ) : null}\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent side=\"right\">\n                <p>{item.label}</p>\n              </TooltipContent>\n            </Tooltip>\n          </div>\n        )\n      })}\n    </aside>\n  )\n}\n\nexport type { Page as SidebarPage, SidebarIcon, SidebarIcons, SidebarLabels, SidebarProps }\n"
  },
  {
    "path": "packages/ui/src/components/ui/sonner.tsx",
    "content": "import { useTheme } from 'next-themes'\nimport { Toaster as Sonner } from 'sonner'\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = 'system' } = useTheme()\n\n  return (\n    <Sonner\n      className=\"toaster group\"\n      theme={theme as ToasterProps['theme']}\n      toastOptions={{\n        classNames: {\n          toast:\n            'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',\n          description: 'group-[.toast]:text-muted-foreground',\n          actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',\n          cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'\n        }\n      }}\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "packages/ui/src/components/ui/switch.tsx",
    "content": "import * as SwitchPrimitives from '@radix-ui/react-switch'\nimport { cn } from '../../lib/cn'\nimport * as React from 'react'\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n      className\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'\n      )}\n    />\n  </SwitchPrimitives.Root>\n))\nSwitch.displayName = SwitchPrimitives.Root.displayName\n\nexport { Switch }\n"
  },
  {
    "path": "packages/ui/src/components/ui/table.tsx",
    "content": "import { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Table({ className, ...props }: React.ComponentProps<'table'>) {\n  return (\n    <div className=\"relative w-full overflow-x-auto\" data-slot=\"table-container\">\n      <table\n        className={cn('w-full caption-bottom text-sm', className)}\n        data-slot=\"table\"\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {\n  return <thead className={cn('[&_tr]:border-b', className)} data-slot=\"table-header\" {...props} />\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {\n  return (\n    <tbody\n      className={cn('[&_tr:last-child]:border-0', className)}\n      data-slot=\"table-body\"\n      {...props}\n    />\n  )\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {\n  return (\n    <tfoot\n      className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}\n      data-slot=\"table-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<'tr'>) {\n  return (\n    <tr\n      className={cn(\n        'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',\n        className\n      )}\n      data-slot=\"table-row\"\n      {...props}\n    />\n  )\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<'th'>) {\n  return (\n    <th\n      className={cn(\n        'h-10 whitespace-nowrap px-2 text-left align-middle font-medium text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className\n      )}\n      data-slot=\"table-head\"\n      {...props}\n    />\n  )\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<'td'>) {\n  return (\n    <td\n      className={cn(\n        'whitespace-nowrap p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className\n      )}\n      data-slot=\"table-cell\"\n      {...props}\n    />\n  )\n}\n\nfunction TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {\n  return (\n    <caption\n      className={cn('mt-4 text-muted-foreground text-sm', className)}\n      data-slot=\"table-caption\"\n      {...props}\n    />\n  )\n}\n\nexport { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }\n"
  },
  {
    "path": "packages/ui/src/components/ui/tabs.tsx",
    "content": "'use client'\n\nimport * as TabsPrimitive from '@radix-ui/react-tabs'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      className={cn('flex flex-col gap-2', className)}\n      data-slot=\"tabs\"\n      {...props}\n    />\n  )\n}\n\nfunction TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      className={cn(\n        'inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground',\n        className\n      )}\n      data-slot=\"tabs-list\"\n      {...props}\n    />\n  )\n}\n\nfunction TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      className={cn(\n        \"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground text-sm transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"tabs-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return (\n    <TabsPrimitive.Content\n      className={cn('flex-1 outline-none', className)}\n      data-slot=\"tabs-content\"\n      {...props}\n    />\n  )\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "packages/ui/src/components/ui/textarea.tsx",
    "content": "import { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {\n  return (\n    <textarea\n      className={cn(\n        'field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40',\n        className\n      )}\n      data-slot=\"textarea\"\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "packages/ui/src/components/ui/title-bar.tsx",
    "content": "import { Copy, Maximize2, Minus, X } from 'lucide-react'\nimport type * as React from 'react'\nimport { cn } from '../../lib/cn'\nimport { Button } from './button'\n\ninterface TitleBarProps {\n  platform?: string\n  isMaximized?: boolean\n  onMinimize?: () => void\n  onMaximize?: () => void\n  onClose?: () => void\n  className?: string\n  icons?: {\n    minimize?: React.ComponentType<{ className?: string }>\n    maximize?: React.ComponentType<{ className?: string }>\n    restore?: React.ComponentType<{ className?: string }>\n    close?: React.ComponentType<{ className?: string }>\n  }\n}\n\nexport function TitleBar({\n  platform,\n  isMaximized = false,\n  onMinimize,\n  onMaximize,\n  onClose,\n  className,\n  icons\n}: TitleBarProps) {\n  const MinimizeIcon = icons?.minimize ?? Minus\n  const MaximizeIcon = icons?.maximize ?? Maximize2\n  const RestoreIcon = icons?.restore ?? Copy\n  const CloseIcon = icons?.close ?? X\n\n  const isMac = platform === 'darwin'\n  const containerClass = cn(\n    'flex drag-region bg-background select-none',\n    isMac ? 'h-10 items-center px-4' : 'justify-end px-5 pt-4',\n    className\n  )\n\n  if (isMac) {\n    return <div className={containerClass} />\n  }\n\n  return (\n    <div className={containerClass}>\n      <div className=\"no-drag flex items-center gap-1\">\n        <Button\n          className=\"h-8 w-8 hover:bg-muted\"\n          disabled={!onMinimize}\n          onClick={onMinimize}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <MinimizeIcon className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          className=\"h-8 w-8 hover:bg-muted\"\n          disabled={!onMaximize}\n          onClick={onMaximize}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          {isMaximized ? <RestoreIcon className=\"h-4 w-4\" /> : <MaximizeIcon className=\"h-4 w-4\" />}\n        </Button>\n        <Button\n          className=\"h-8 w-8 hover:bg-red-500 hover:text-white\"\n          disabled={!onClose}\n          onClick={onClose}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <CloseIcon className=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nexport type { TitleBarProps }\n"
  },
  {
    "path": "packages/ui/src/components/ui/tooltip.tsx",
    "content": "'use client'\n\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip'\nimport { cn } from '../../lib/cn'\nimport type * as React from 'react'\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        className={cn(\n          'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out',\n          className\n        )}\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "packages/ui/src/lib/cn.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport const cn = (...inputs: ClassValue[]): string => {\n\treturn twMerge(clsx(inputs));\n};\n"
  },
  {
    "path": "packages/ui/src/lib/use-add-url-interaction.ts",
    "content": "import { useCallback, useState } from 'react'\n\nconst isLikelyUrl = (value: string): boolean => {\n  try {\n    const parsed = new URL(value)\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:'\n  } catch {\n    return false\n  }\n}\n\ninterface UseAddUrlInteractionOptions {\n  activeTab: 'single' | 'playlist'\n  isOneClickDownloadEnabled: boolean\n  isPlaylistBusy: boolean\n  onEmptyUrl: () => void\n  onInvalidUrl: () => void\n  onOneClickDownload: (url: string) => Promise<void> | void\n  onParsePlaylist: (url: string) => Promise<void> | void\n  onParseSingle: (url: string) => Promise<void> | void\n}\n\ninterface UseAddUrlInteractionResult {\n  addUrlPopoverOpen: boolean\n  addUrlValue: string\n  canConfirmAddUrl: boolean\n  hasAddUrlValue: boolean\n  handleConfirmAddUrl: () => Promise<void>\n  handleOpenAddUrlPopover: () => Promise<void>\n  setAddUrlPopoverOpen: (open: boolean) => void\n  setAddUrlValue: (value: string) => void\n}\n\nexport const useAddUrlInteraction = ({\n  activeTab,\n  isOneClickDownloadEnabled,\n  isPlaylistBusy,\n  onEmptyUrl,\n  onInvalidUrl,\n  onOneClickDownload,\n  onParsePlaylist,\n  onParseSingle\n}: UseAddUrlInteractionOptions): UseAddUrlInteractionResult => {\n  const [addUrlPopoverOpen, setAddUrlPopoverOpen] = useState(false)\n  const [addUrlValue, setAddUrlValue] = useState('')\n\n  const trimmedAddUrlValue = addUrlValue.trim()\n  const hasAddUrlValue = trimmedAddUrlValue.length > 0\n  const canConfirmAddUrl = hasAddUrlValue && isLikelyUrl(trimmedAddUrlValue)\n\n  const handleOpenAddUrlPopover = useCallback(async () => {\n    setAddUrlPopoverOpen(true)\n    if (!navigator.clipboard?.readText) {\n      setAddUrlValue('')\n      return\n    }\n\n    try {\n      const text = await navigator.clipboard.readText()\n      const trimmedUrl = text.trim()\n      setAddUrlValue(isLikelyUrl(trimmedUrl) ? trimmedUrl : '')\n    } catch {\n      setAddUrlValue('')\n    }\n  }, [])\n\n  const handleConfirmAddUrl = useCallback(async () => {\n    const trimmedUrl = addUrlValue.trim()\n    if (!trimmedUrl) {\n      onEmptyUrl()\n      return\n    }\n    if (!isLikelyUrl(trimmedUrl)) {\n      onInvalidUrl()\n      return\n    }\n\n    setAddUrlPopoverOpen(false)\n\n    if (activeTab === 'playlist') {\n      if (isPlaylistBusy) {\n        return\n      }\n      await onParsePlaylist(trimmedUrl)\n      return\n    }\n\n    if (isOneClickDownloadEnabled) {\n      await onOneClickDownload(trimmedUrl)\n      return\n    }\n\n    await onParseSingle(trimmedUrl)\n  }, [\n    activeTab,\n    addUrlValue,\n    isOneClickDownloadEnabled,\n    isPlaylistBusy,\n    onEmptyUrl,\n    onInvalidUrl,\n    onOneClickDownload,\n    onParsePlaylist,\n    onParseSingle\n  ])\n\n  return {\n    addUrlPopoverOpen,\n    addUrlValue,\n    canConfirmAddUrl,\n    hasAddUrlValue,\n    handleConfirmAddUrl,\n    handleOpenAddUrlPopover,\n    setAddUrlPopoverOpen,\n    setAddUrlValue\n  }\n}\n"
  },
  {
    "path": "packages/ui/src/theme.css",
    "content": ":root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.1884 0.0128 248.5103);\n  --card: oklch(0.9881 0 0);\n  --card-foreground: oklch(0.1884 0.0128 248.5103);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.1884 0.0128 248.5103);\n  --primary: oklch(0.8223 0.1704 79.8747);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.1884 0.0128 248.5103);\n  --secondary-foreground: oklch(1 0 0);\n  --muted: oklch(0.9227 0.0011 17.1793);\n  --muted-foreground: oklch(0.1884 0.0128 248.5103);\n  --accent: oklch(0.9485 0.0162 64.6689);\n  --accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --destructive: oklch(0.6188 0.2376 25.7658);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.8929 0.0133 82.4013);\n  --input: oklch(0.9823 0.0029 84.5589);\n  --ring: oklch(0.8223 0.1704 79.8747);\n  --chart-1: oklch(0.6723 0.1606 244.9955);\n  --chart-2: oklch(0.6907 0.1554 160.3454);\n  --chart-3: oklch(0.8214 0.16 82.5337);\n  --chart-4: oklch(0.7064 0.1822 151.7125);\n  --chart-5: oklch(0.5919 0.2186 10.5826);\n  --sidebar: oklch(0.9784 0.0011 197.1387);\n  --sidebar-foreground: oklch(0.1884 0.0128 248.5103);\n  --sidebar-primary: oklch(0.8223 0.1704 79.8747);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.9485 0.0162 64.6689);\n  --sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --sidebar-border: oklch(0.933 0.0108 76.5962);\n  --sidebar-ring: oklch(0.8223 0.1704 79.8747);\n  --font-sans: Open Sans, sans-serif;\n  --font-serif: Georgia, serif;\n  --font-mono: Menlo, monospace;\n  --radius: 0.625rem;\n  --shadow-x: 0px;\n  --shadow-y: 2px;\n  --shadow-blur: 0px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0;\n  --shadow-color: #1da1f2;\n  --shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-sm:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-md:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-lg:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-xl:\n    0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0),\n    0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0);\n  --shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0);\n  --tracking-normal: 0em;\n  --spacing: 0.25rem;\n}\n\n.dark {\n  --background: oklch(0 0 0);\n  --foreground: oklch(0.9328 0.0025 228.7857);\n  --card: oklch(0.2097 0.008 274.5332);\n  --card-foreground: oklch(0.8853 0 0);\n  --popover: oklch(0 0 0);\n  --popover-foreground: oklch(0.9328 0.0025 228.7857);\n  --primary: oklch(0.8223 0.1704 79.8747);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.9622 0.0035 219.5331);\n  --secondary-foreground: oklch(0.1884 0.0128 248.5103);\n  --muted: oklch(0.3485 0 0);\n  --muted-foreground: oklch(0.5637 0.0078 247.9662);\n  --accent: oklch(0.1928 0.0331 242.5459);\n  --accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --destructive: oklch(0.6188 0.2376 25.7658);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.2674 0.0047 248.0045);\n  --input: oklch(0.302 0.0288 244.8244);\n  --ring: oklch(0.8223 0.1704 79.8747);\n  --chart-1: oklch(0.8223 0.1704 79.8747);\n  --chart-2: oklch(0.6907 0.1554 160.3454);\n  --chart-3: oklch(0.8214 0.16 82.5337);\n  --chart-4: oklch(0.7064 0.1822 151.7125);\n  --chart-5: oklch(0.5919 0.2186 10.5826);\n  --sidebar: oklch(0.2097 0.008 274.5332);\n  --sidebar-foreground: oklch(0.8853 0 0);\n  --sidebar-primary: oklch(0.8223 0.1704 79.8747);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.1928 0.0331 242.5459);\n  --sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);\n  --sidebar-border: oklch(0.3795 0.022 240.5943);\n  --sidebar-ring: oklch(0.8223 0.1704 79.8747);\n  --font-sans: Open Sans, sans-serif;\n  --font-serif: Georgia, serif;\n  --font-mono: Menlo, monospace;\n}\n\n@theme inline {\n  --color-being-green-50: #effaf4;\n  --color-being-green-100: #d8f3e3;\n  --color-being-green-200: #b4e6cb;\n  --color-being-green-300: #67c99a;\n  --color-being-green-400: #4fb889;\n  --color-being-green-500: #2c9d6e;\n  --color-being-green-600: #1d7e58;\n  --color-being-green-700: #176549;\n  --color-being-green-800: #15503a;\n  --color-being-green-900: #124231;\n  --color-being-green-950: #09251c;\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n\n  --font-sans: var(--font-sans);\n  --font-mono: var(--font-mono);\n  --font-serif: var(--font-serif);\n\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --radius-2xl: calc(var(--radius) + 8px);\n  --radius-3xl: calc(var(--radius) + 12px);\n  --radius-4xl: calc(var(--radius) + 16px);\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"types\": [\"react\", \"react-dom\", \"unplugin-icons/types/react\"]\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\"]\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - .\n  - apps/*\n  - packages/*\n"
  },
  {
    "path": "skills-lock.json",
    "content": "{\n  \"version\": 1,\n  \"skills\": {\n    \"orpc-contract-first\": {\n      \"source\": \"langgenius/dify\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"1e5b26066bf6e6ce8c5c1e5b3e1d62e7d231f013052fee2a12da6b3a76e8b183\"\n    }\n  }\n}\n"
  }
]