Repository: nexmoe/VidBee Branch: main Commit: e9d7a99e1bd4 Files: 366 Total size: 1.6 MB Directory structure: gitextract_plngy_re/ ├── .agents/ │ └── skills/ │ ├── orpc-contract-first/ │ │ └── SKILL.md │ └── release-skills/ │ └── SKILL.md ├── .claude/ │ └── CLAUDE.md ├── .cursor/ │ └── hooks.json ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── build.yml │ ├── ci.yml │ ├── docker-publish.yml │ ├── extension-build.yml │ ├── extension-publish.yml │ ├── release.yml │ ├── translator.yaml │ └── ytdlp-auto-release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps/ │ ├── api/ │ │ ├── Dockerfile │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── database-migrate.ts │ │ │ │ ├── downloader.ts │ │ │ │ ├── history-record-mapper.ts │ │ │ │ ├── history-store.ts │ │ │ │ ├── rpc-router.ts │ │ │ │ ├── sse.ts │ │ │ │ └── web-settings-store.ts │ │ │ └── server.ts │ │ └── tsconfig.json │ ├── desktop/ │ │ ├── build/ │ │ │ ├── after-pack.cjs │ │ │ ├── entitlements.mac.plist │ │ │ └── icon.icns │ │ ├── changelogs/ │ │ │ ├── CHANGELOG.fr.md │ │ │ ├── CHANGELOG.md │ │ │ ├── CHANGELOG.ru.md │ │ │ └── CHANGELOG.zh.md │ │ ├── components.json │ │ ├── dev-app-update.yml │ │ ├── drizzle.config.ts │ │ ├── electron-builder.yml │ │ ├── electron.vite.config.ts │ │ ├── package.json │ │ ├── release-metadata.json │ │ ├── resources/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ └── drizzle/ │ │ │ ├── 0000_swift_aaron_stack.sql │ │ │ ├── 0001_smiling_agent_zero.sql │ │ │ ├── 0002_smooth_impossible_man.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ └── _journal.json │ │ ├── scripts/ │ │ │ ├── check-locales.js │ │ │ ├── check-ytdlp.js │ │ │ ├── ensure-native-deps.mjs │ │ │ ├── postinstall.mjs │ │ │ ├── set-console-encoding.js │ │ │ ├── setup-dev-binaries.js │ │ │ └── ytdlp-auto-release.mjs │ │ ├── src/ │ │ │ ├── main/ │ │ │ │ ├── assets.d.ts │ │ │ │ ├── config/ │ │ │ │ │ └── logger-config.ts │ │ │ │ ├── download-engine/ │ │ │ │ │ ├── args-builder.ts │ │ │ │ │ └── format-utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ipc/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── services/ │ │ │ │ │ ├── app-service.ts │ │ │ │ │ ├── browser-cookies-service.ts │ │ │ │ │ ├── download-service.ts │ │ │ │ │ ├── file-system-service.ts │ │ │ │ │ ├── history-service.ts │ │ │ │ │ ├── settings-service.ts │ │ │ │ │ ├── subscription-service.ts │ │ │ │ │ ├── thumbnail-service.ts │ │ │ │ │ ├── update-service.ts │ │ │ │ │ └── window-service.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── command-utils.ts │ │ │ │ │ ├── database/ │ │ │ │ │ │ ├── migrate.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ ├── database-path.ts │ │ │ │ │ ├── database.ts │ │ │ │ │ ├── download-engine.ts │ │ │ │ │ ├── download-queue.ts │ │ │ │ │ ├── download-session-store.ts │ │ │ │ │ ├── ffmpeg-manager.ts │ │ │ │ │ ├── history-manager.ts │ │ │ │ │ ├── path-resolver.ts │ │ │ │ │ ├── progress-utils.ts │ │ │ │ │ ├── subscription-manager.ts │ │ │ │ │ ├── subscription-scheduler.ts │ │ │ │ │ ├── thumbnail-cache.ts │ │ │ │ │ ├── watermark-utils.ts │ │ │ │ │ ├── youtube-extractor-args.ts │ │ │ │ │ └── ytdlp-manager.ts │ │ │ │ ├── local-api.ts │ │ │ │ ├── settings.ts │ │ │ │ ├── tray.ts │ │ │ │ └── utils/ │ │ │ │ ├── auto-launch.ts │ │ │ │ ├── dock.ts │ │ │ │ ├── logger.ts │ │ │ │ └── path-helpers.ts │ │ │ ├── preload/ │ │ │ │ ├── index.d.ts │ │ │ │ └── index.ts │ │ │ ├── renderer/ │ │ │ │ ├── index.html │ │ │ │ └── src/ │ │ │ │ ├── App.tsx │ │ │ │ ├── assets/ │ │ │ │ │ ├── global.css │ │ │ │ │ ├── main.css │ │ │ │ │ ├── theme.css │ │ │ │ │ └── title-bar.css │ │ │ │ ├── components/ │ │ │ │ │ ├── download/ │ │ │ │ │ │ ├── DownloadDialog.tsx │ │ │ │ │ │ ├── DownloadItem.tsx │ │ │ │ │ │ ├── PlaylistDownload.tsx │ │ │ │ │ │ ├── PlaylistDownloadGroup.tsx │ │ │ │ │ │ ├── SingleVideoDownload.tsx │ │ │ │ │ │ └── UnifiedDownloadHistory.tsx │ │ │ │ │ ├── error/ │ │ │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ │ │ └── ErrorPage.tsx │ │ │ │ │ ├── feedback/ │ │ │ │ │ │ └── FeedbackLinks.tsx │ │ │ │ │ ├── playlist/ │ │ │ │ │ │ └── PlaylistPreviewCard.tsx │ │ │ │ │ ├── subscription/ │ │ │ │ │ │ └── SubscriptionFormDialog.tsx │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── accordion.tsx │ │ │ │ │ │ ├── add-url-popover.tsx │ │ │ │ │ │ ├── badge.tsx │ │ │ │ │ │ ├── button.tsx │ │ │ │ │ │ ├── card.tsx │ │ │ │ │ │ ├── checkbox.tsx │ │ │ │ │ │ ├── context-menu.tsx │ │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ │ ├── download-dialog-layout.tsx │ │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ │ ├── hover-card.tsx │ │ │ │ │ │ ├── image-with-placeholder.tsx │ │ │ │ │ │ ├── input.tsx │ │ │ │ │ │ ├── item.tsx │ │ │ │ │ │ ├── label.tsx │ │ │ │ │ │ ├── popover.tsx │ │ │ │ │ │ ├── progress.tsx │ │ │ │ │ │ ├── radio-group.tsx │ │ │ │ │ │ ├── remote-image.tsx │ │ │ │ │ │ ├── scroll-area.tsx │ │ │ │ │ │ ├── select.tsx │ │ │ │ │ │ ├── separator.tsx │ │ │ │ │ │ ├── sheet.tsx │ │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ │ ├── sonner.tsx │ │ │ │ │ │ ├── switch.tsx │ │ │ │ │ │ ├── table.tsx │ │ │ │ │ │ ├── tabs.tsx │ │ │ │ │ │ ├── textarea.tsx │ │ │ │ │ │ ├── title-bar.tsx │ │ │ │ │ │ └── tooltip.tsx │ │ │ │ │ └── video/ │ │ │ │ │ └── AdvancedOptions.tsx │ │ │ │ ├── data/ │ │ │ │ │ └── popularSites.ts │ │ │ │ ├── env.d.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-cached-thumbnail.ts │ │ │ │ │ ├── use-download-events.ts │ │ │ │ │ ├── use-history-sync.ts │ │ │ │ │ └── use-ipc-example.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── ipc.ts │ │ │ │ │ ├── logger.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── main.tsx │ │ │ │ ├── pages/ │ │ │ │ │ ├── About.tsx │ │ │ │ │ ├── Home.tsx │ │ │ │ │ ├── Settings.tsx │ │ │ │ │ └── Subscriptions.tsx │ │ │ │ └── store/ │ │ │ │ ├── downloads.ts │ │ │ │ ├── settings.ts │ │ │ │ ├── subscriptions.ts │ │ │ │ ├── update.ts │ │ │ │ └── video.ts │ │ │ └── shared/ │ │ │ ├── constants.ts │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ └── ipc.ts │ │ │ └── utils/ │ │ │ ├── download-file.ts │ │ │ └── format-preferences.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── tsconfig.web.json │ ├── docs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── biome.config.json │ │ ├── content/ │ │ │ ├── cookies.mdx │ │ │ ├── faq.mdx │ │ │ ├── fr/ │ │ │ │ ├── cookies.mdx │ │ │ │ ├── faq.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── meta.json │ │ │ │ ├── protocol.mdx │ │ │ │ └── rss.mdx │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── protocol.mdx │ │ │ ├── rss.mdx │ │ │ ├── ru/ │ │ │ │ ├── cookies.mdx │ │ │ │ ├── faq.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── meta.json │ │ │ │ ├── protocol.mdx │ │ │ │ └── rss.mdx │ │ │ └── zh/ │ │ │ ├── cookies.mdx │ │ │ ├── faq.mdx │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── protocol.mdx │ │ │ └── rss.mdx │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── public/ │ │ │ └── ICONS.md │ │ ├── scripts/ │ │ │ └── post-export.js │ │ ├── source.config.ts │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── (docs)/ │ │ │ │ │ ├── [[...slug]]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── [lang]/ │ │ │ │ │ ├── (docs)/ │ │ │ │ │ │ ├── [[...slug]]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── api/ │ │ │ │ │ └── search/ │ │ │ │ │ └── route.ts │ │ │ │ ├── global.css │ │ │ │ ├── layout.tsx │ │ │ │ └── sitemap.ts │ │ │ ├── components/ │ │ │ │ └── ai/ │ │ │ │ └── page-actions.tsx │ │ │ ├── lib/ │ │ │ │ ├── cn.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── layout.shared.tsx │ │ │ │ └── source.ts │ │ │ ├── mdx-components.tsx │ │ │ └── middleware.ts │ │ └── tsconfig.json │ ├── extension/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── assets/ │ │ │ └── content.css │ │ ├── entrypoints/ │ │ │ ├── background.ts │ │ │ └── popup/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── index.html │ │ │ └── main.tsx │ │ ├── package.json │ │ ├── public/ │ │ │ └── _locales/ │ │ │ └── en/ │ │ │ └── messages.json │ │ ├── tsconfig.json │ │ └── wxt.config.ts │ └── web/ │ ├── Dockerfile │ ├── README.md │ ├── biome.json │ ├── package.json │ ├── public/ │ │ ├── manifest.json │ │ └── robots.txt │ ├── src/ │ │ ├── components/ │ │ │ ├── download/ │ │ │ │ ├── download-dialog.tsx │ │ │ │ ├── download-item.tsx │ │ │ │ ├── playlist-download-group.tsx │ │ │ │ ├── playlist-download.tsx │ │ │ │ ├── single-video-download.tsx │ │ │ │ └── types.ts │ │ │ ├── layout/ │ │ │ │ └── app-shell.tsx │ │ │ └── pages/ │ │ │ ├── about-page.tsx │ │ │ ├── download-page.tsx │ │ │ └── settings-page.tsx │ │ ├── env.d.ts │ │ ├── hooks/ │ │ │ ├── use-web-download-settings.ts │ │ │ └── use-web-settings.ts │ │ ├── lib/ │ │ │ ├── download-format-preferences.ts │ │ │ ├── i18n.ts │ │ │ ├── orpc-client.ts │ │ │ ├── orpc-download-settings.ts │ │ │ ├── remote-image-proxy.ts │ │ │ └── web-settings.ts │ │ ├── routeTree.gen.ts │ │ ├── router.tsx │ │ ├── routes/ │ │ │ ├── __root.tsx │ │ │ ├── about.tsx │ │ │ ├── index.tsx │ │ │ └── settings.tsx │ │ └── styles.css │ ├── tsconfig.json │ └── vite.config.ts ├── biome.json ├── conductor.json ├── docker-compose.yml ├── package.json ├── packages/ │ ├── db/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── history.ts │ │ └── tsconfig.json │ ├── downloader-core/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── browser-cookies-setting.ts │ │ │ ├── contract.ts │ │ │ ├── download-file.ts │ │ │ ├── downloader-core.ts │ │ │ ├── format-preferences.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── types.ts │ │ │ └── yt-dlp-args.ts │ │ └── tsconfig.json │ ├── i18n/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── init.ts │ │ │ ├── languages.ts │ │ │ ├── locales/ │ │ │ │ ├── ar.json │ │ │ │ ├── de.json │ │ │ │ ├── en.json │ │ │ │ ├── es.json │ │ │ │ ├── fr.json │ │ │ │ ├── id.json │ │ │ │ ├── it.json │ │ │ │ ├── ja.json │ │ │ │ ├── ko.json │ │ │ │ ├── pt.json │ │ │ │ ├── ru.json │ │ │ │ ├── tr.json │ │ │ │ ├── zh-TW.json │ │ │ │ └── zh.json │ │ │ └── resources.ts │ │ └── tsconfig.json │ └── ui/ │ ├── package.json │ ├── src/ │ │ ├── base.css │ │ ├── components/ │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── add-url-popover.tsx │ │ │ ├── app-sidebar-icons.tsx │ │ │ ├── app-sidebar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog.tsx │ │ │ ├── download-dialog-layout.tsx │ │ │ ├── download-empty-state.tsx │ │ │ ├── download-filter-bar.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── feedback-link-buttons.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── image-with-placeholder.tsx │ │ │ ├── input.tsx │ │ │ ├── item.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── remote-image.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── title-bar.tsx │ │ │ └── tooltip.tsx │ │ ├── lib/ │ │ │ ├── cn.ts │ │ │ └── use-add-url-interaction.ts │ │ └── theme.css │ └── tsconfig.json ├── pnpm-workspace.yaml └── skills-lock.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/orpc-contract-first/SKILL.md ================================================ --- name: orpc-contract-first description: Guide for implementing oRPC contract-first API patterns in Dify frontend. Triggers when creating new API contracts, adding service endpoints, integrating TanStack Query with typed contracts, or migrating legacy service calls to oRPC. Use for all API layer work in web/contract and web/service directories. --- # oRPC Contract-First Development ## Project Structure ``` web/contract/ ├── base.ts # Base contract (inputStructure: 'detailed') ├── router.ts # Router composition & type exports ├── marketplace.ts # Marketplace contracts └── console/ # Console contracts by domain ├── system.ts └── billing.ts ``` ## Workflow 1. **Create contract** in `web/contract/console/{domain}.ts` - Import `base` from `../base` and `type` from `@orpc/contract` - Define route with `path`, `method`, `input`, `output` 2. **Register in router** at `web/contract/router.ts` - Import directly from domain file (no barrel files) - Nest by API prefix: `billing: { invoices, bindPartnerStack }` 3. **Create hooks** in `web/service/use-{domain}.ts` - Use `consoleQuery.{group}.{contract}.queryKey()` for query keys - Use `consoleClient.{group}.{contract}()` for API calls ## Key Rules - **Input structure**: Always use `{ params, query?, body? }` format - **Path params**: Use `{paramName}` in path, match in `params` object - **Router nesting**: Group by API prefix (e.g., `/billing/*` → `billing: {}`) - **No barrel files**: Import directly from specific files - **Types**: Import from `@/types/`, use `type()` helper ## Type Export ```typescript export type ConsoleInputs = InferContractRouterInputs ``` ================================================ FILE: .agents/skills/release-skills/SKILL.md ================================================ --- name: release-skills description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送". --- # Release Skills Universal release workflow supporting any project type with multi-language changelog. ## Quick Start Just run `/release-skills` - auto-detects your project configuration. ## Supported Projects | Project Type | Version File | Auto-Detected | |--------------|--------------|---------------| | Node.js | package.json | ✓ | | Python | pyproject.toml | ✓ | | Rust | Cargo.toml | ✓ | | Claude Plugin | marketplace.json | ✓ | | Generic | VERSION / version.txt | ✓ | ## Options | Flag | Description | |------|-------------| | `--dry-run` | Preview changes without executing | | `--major` | Force major version bump | | `--minor` | Force minor version bump | | `--patch` | Force patch version bump | ## Workflow ### Step 1: Detect Project Configuration 1. Check for `.releaserc.yml` (optional config override) 2. Auto-detect version file by scanning (priority order): - `package.json` (Node.js) - `pyproject.toml` (Python) - `Cargo.toml` (Rust) - `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin) - `VERSION` or `version.txt` (Generic) 3. Scan for changelog files using glob patterns: - `CHANGELOG*.md` - `HISTORY*.md` - `CHANGES*.md` 4. Identify language of each changelog by filename suffix 5. Display detected configuration **Language Detection Rules**: | Filename Pattern | Language | |------------------|----------| | `CHANGELOG.md` (no suffix) | en (default) | | `CHANGELOG.zh.md` / `CHANGELOG_CN.md` / `CHANGELOG.zh-CN.md` | zh | | `CHANGELOG.ja.md` / `CHANGELOG_JP.md` | ja | | `CHANGELOG.ko.md` / `CHANGELOG_KR.md` | ko | | `CHANGELOG.de.md` / `CHANGELOG_DE.md` | de | | `CHANGELOG.fr.md` / `CHANGELOG_FR.md` | fr | | `CHANGELOG.es.md` / `CHANGELOG_ES.md` | es | | `CHANGELOG.{lang}.md` | Corresponding language code | **Output Example**: ``` Project detected: Version file: package.json (1.2.3) Changelogs: - CHANGELOG.md (en) - CHANGELOG.zh.md (zh) - CHANGELOG.ja.md (ja) ``` ### Step 2: Analyze Changes Since Last Tag ```bash LAST_TAG=$(git tag --sort=-v:refname | head -1) git log ${LAST_TAG}..HEAD --oneline git diff ${LAST_TAG}..HEAD --stat ``` Categorize by conventional commit types: | Type | Description | |------|-------------| | feat | New features | | fix | Bug fixes | | docs | Documentation | | refactor | Code refactoring | | perf | Performance improvements | | test | Test changes | | style | Formatting, styling | | chore | Maintenance (skip in changelog) | **Breaking Change Detection**: - Commit message starts with `BREAKING CHANGE` - Commit body/footer contains `BREAKING CHANGE:` - Removed public APIs, renamed exports, changed interfaces If breaking changes detected, warn user: "Breaking changes detected. Consider major version bump (--major flag)." ### Step 3: Determine Version Bump Rules (in priority order): 1. User flag `--major/--minor/--patch` → Use specified 2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0) 3. `feat:` commits present → Minor bump (1.2.x → 1.3.0) 4. Otherwise → Patch bump (1.2.3 → 1.2.4) Display version change: `1.2.3 → 1.3.0` ### Step 4: Generate Multi-language Changelogs For each detected changelog file: 1. **Identify language** from filename suffix 2. **Detect third-party contributors**: - Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"` - For each merged PR, identify the PR author via `gh pr view --json author --jq '.author.login'` - Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`) - If PR author ≠ repo owner → third-party contributor 3. **Generate content in that language**: - Section titles in target language - Change descriptions written naturally in target language (not translated) - Date format: YYYY-MM-DD (universal) - **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry 4. **Insert at file head** (preserve existing content) **Section Title Translations** (built-in): | Type | en | zh | ja | ko | de | fr | es | |------|----|----|----|----|----|----|-----| | feat | Features | 新功能 | 新機能 | 새로운 기능 | Funktionen | Fonctionnalités | Características | | fix | Fixes | 修复 | 修正 | 수정 | Fehlerbehebungen | Corrections | Correcciones | | docs | Documentation | 文档 | ドキュメント | 문서 | Dokumentation | Documentation | Documentación | | refactor | Refactor | 重构 | リファクタリング | 리팩토링 | Refactoring | Refactorisation | Refactorización | | perf | Performance | 性能优化 | パフォーマンス | 성능 | Leistung | Performance | Rendimiento | | breaking | Breaking Changes | 破坏性变更 | 破壊的変更 | 주요 변경사항 | Breaking Changes | Changements majeurs | Cambios importantes | **Changelog Format**: ```markdown ## {VERSION} - {YYYY-MM-DD} ### Features - Description of new feature - Description of third-party contribution (by @username) ### Fixes - Description of fix ### Documentation - Description of docs changes ``` Only include sections that have changes. Omit empty sections. **Third-Party Attribution Rules**: - Only add `(by @username)` for contributors who are NOT the repo owner - Use GitHub username with `@` prefix - Place at the end of the changelog entry line - Apply to all languages consistently (always use `(by @username)` format, not translated) **Multi-language Example**: English (CHANGELOG.md): ```markdown ## 1.3.0 - 2026-01-22 ### Features - Add user authentication module (by @contributor1) - Support OAuth2 login ### Fixes - Fix memory leak in connection pool ``` Chinese (CHANGELOG.zh.md): ```markdown ## 1.3.0 - 2026-01-22 ### 新功能 - 新增用户认证模块 (by @contributor1) - 支持 OAuth2 登录 ### 修复 - 修复连接池内存泄漏问题 ``` Japanese (CHANGELOG.ja.md): ```markdown ## 1.3.0 - 2026-01-22 ### 新機能 - ユーザー認証モジュールを追加 (by @contributor1) - OAuth2 ログインをサポート ### 修正 - コネクションプールのメモリリークを修正 ``` ### Step 5: Group Changes by Skill/Module Analyze commits since last tag and group by affected skill/module: 1. **Identify changed files** per commit 2. **Group by skill/module**: - `skills//*` → Group under that skill - Root files (CLAUDE.md, etc.) → Group as "project" - Multiple skills in one commit → Split into multiple groups 3. **For each group**, identify related README updates needed **Example Grouping**: ``` baoyu-cover-image: - feat: add new style options - fix: handle transparent backgrounds → README updates: options table baoyu-comic: - refactor: improve panel layout algorithm → No README updates needed project: - docs: update CLAUDE.md architecture section ``` ### Step 6: Commit Each Skill/Module Separately For each skill/module group (in order of changes): 1. **Check README updates needed**: - Scan `README*.md` for mentions of this skill/module - Verify options/flags documented correctly - Update usage examples if syntax changed - Update feature descriptions if behavior changed 2. **Stage and commit**: ```bash git add skills//* git add README.md README.zh.md # If updated for this skill git commit -m "(): " ``` 3. **Commit message format**: - Use conventional commit format: `(): ` - ``: feat, fix, refactor, docs, perf, etc. - ``: skill name or "project" - ``: Clear, meaningful description of changes **Example Commits**: ```bash git commit -m "feat(baoyu-cover-image): add watercolor and minimalist styles" git commit -m "fix(baoyu-comic): improve panel layout for long dialogues" git commit -m "docs(project): update architecture documentation" ``` **Common README Updates Needed**: | Change Type | README Section to Check | |-------------|------------------------| | New options/flags | Options table, usage examples | | Renamed options | Options table, usage examples | | New features | Feature description, examples | | Breaking changes | Migration notes, deprecation warnings | | Restructured internals | Architecture section (if exposed to users) | ### Step 7: Generate Changelog and Update Version 1. **Generate multi-language changelogs** (as described in Step 4) 2. **Update version file**: - Read version file (JSON/TOML/text) - Update version number - Write back (preserve formatting) **Version Paths by File Type**: | File | Path | |------|------| | package.json | `$.version` | | pyproject.toml | `project.version` | | Cargo.toml | `package.version` | | marketplace.json | `$.metadata.version` | | VERSION / version.txt | Direct content | ### Step 8: User Confirmation Before creating the release commit, ask user to confirm: **Use AskUserQuestion with two questions**: 1. **Version bump** (single select): - Show recommended version based on Step 3 analysis - Options: recommended (with label), other semver options - Example: `1.2.3 → 1.3.0 (Recommended)`, `1.2.3 → 1.2.4`, `1.2.3 → 2.0.0` 2. **Push to remote** (single select): - Options: "Yes, push after commit", "No, keep local only" **Example Output Before Confirmation**: ``` Commits created: 1. feat(baoyu-cover-image): add watercolor and minimalist styles 2. fix(baoyu-comic): improve panel layout for long dialogues 3. docs(project): update architecture documentation Changelog preview (en): ## 1.3.0 - 2026-01-22 ### Features - Add watercolor and minimalist styles to cover-image ### Fixes - Improve panel layout for long dialogues in comic Ready to create release commit and tag. ``` ### Step 9: Create Release Commit and Tag After user confirmation: 1. **Stage version and changelog files**: ```bash git add git add CHANGELOG*.md ``` 2. **Create release commit**: ```bash git commit -m "chore: release v{VERSION}" ``` 3. **Create tag**: ```bash git tag v{VERSION} ``` 4. **Push if user confirmed** (Step 8): ```bash git push origin main git push origin v{VERSION} ``` **Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution. **Post-Release Output**: ``` Release v1.3.0 created. Commits: 1. feat(baoyu-cover-image): add watercolor and minimalist styles 2. fix(baoyu-comic): improve panel layout for long dialogues 3. docs(project): update architecture documentation 4. chore: release v1.3.0 Tag: v1.3.0 Status: Pushed to origin # or "Local only - run git push when ready" ``` ## Configuration (.releaserc.yml) Optional config file in project root to override defaults: ```yaml # .releaserc.yml - Optional configuration # Version file (auto-detected if not specified) version: file: package.json path: $.version # JSONPath for JSON, dotted path for TOML # Changelog files (auto-detected if not specified) changelog: files: - path: CHANGELOG.md lang: en - path: CHANGELOG.zh.md lang: zh - path: CHANGELOG.ja.md lang: ja # Section mapping (conventional commit type → changelog section) # Use null to skip a type in changelog sections: feat: Features fix: Fixes docs: Documentation refactor: Refactor perf: Performance test: Tests chore: null # Commit message format commit: message: "chore: release v{version}" # Tag format tag: prefix: v # Results in v1.0.0 sign: false # Additional files to include in release commit include: - README.md - package.json ``` ## Dry-Run Mode When `--dry-run` is specified: ``` === DRY RUN MODE === Project detected: Version file: package.json (1.2.3) Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh) Last tag: v1.2.3 Proposed version: v1.3.0 Changes grouped by skill/module: baoyu-cover-image: - feat: add watercolor style - feat: add minimalist style → Commit: feat(baoyu-cover-image): add watercolor and minimalist styles → README updates: options table baoyu-comic: - fix: panel layout for long dialogues → Commit: fix(baoyu-comic): improve panel layout for long dialogues → No README updates Changelog preview (en): ## 1.3.0 - 2026-01-22 ### Features - Add watercolor and minimalist styles to cover-image ### Fixes - Improve panel layout for long dialogues in comic Changelog preview (zh): ## 1.3.0 - 2026-01-22 ### 新功能 - 为 cover-image 添加水彩和极简风格 ### 修复 - 改进 comic 长对话的面板布局 Commits to create: 1. feat(baoyu-cover-image): add watercolor and minimalist styles 2. fix(baoyu-comic): improve panel layout for long dialogues 3. chore: release v1.3.0 No changes made. Run without --dry-run to execute. ``` ## Example Usage ``` /release-skills # Auto-detect version bump /release-skills --dry-run # Preview only /release-skills --minor # Force minor bump /release-skills --patch # Force patch bump /release-skills --major # Force major bump (with confirmation) ``` ## When to Use Trigger this skill when user requests: - "release", "发布", "create release", "new version", "新版本" - "bump version", "update version", "更新版本" - "prepare release" - "push to remote" (with uncommitted changes) **Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first. ================================================ FILE: .claude/CLAUDE.md ================================================ # Ultracite Code Standards This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. ## Quick Reference - **Format code**: `pnpm dlx ultracite fix` - **Check for issues**: `pnpm dlx ultracite check` - **Diagnose setup**: `pnpm dlx ultracite doctor` Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. --- ## Core Principles Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. ### Type Safety & Explicitness - Use explicit types for function parameters and return values when they enhance clarity - Prefer `unknown` over `any` when the type is genuinely unknown - Use const assertions (`as const`) for immutable values and literal types - Leverage TypeScript's type narrowing instead of type assertions - Use meaningful variable names instead of magic numbers - extract constants with descriptive names ### Modern JavaScript/TypeScript - Use arrow functions for callbacks and short functions - Prefer `for...of` loops over `.forEach()` and indexed `for` loops - Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access - Prefer template literals over string concatenation - Use destructuring for object and array assignments - Use `const` by default, `let` only when reassignment is needed, never `var` ### Async & Promises - Always `await` promises in async functions - don't forget to use the return value - Use `async/await` syntax instead of promise chains for better readability - Handle errors appropriately in async code with try-catch blocks - Don't use async functions as Promise executors ### React & JSX - Use function components over class components - Call hooks at the top level only, never conditionally - Specify all dependencies in hook dependency arrays correctly - Use the `key` prop for elements in iterables (prefer unique IDs over array indices) - Nest children between opening and closing tags instead of passing as props - Don't define components inside other components - Use semantic HTML and ARIA attributes for accessibility: - Provide meaningful alt text for images - Use proper heading hierarchy - Add labels for form inputs - Include keyboard event handlers alongside mouse events - Use semantic elements (` {singleVideoState.customDownloadPath && ( )} )} {/* Download Location - Playlist */} {activeTab === 'playlist' && playlistInfo && !playlistPreviewLoading && (
{playlistCustomDownloadPath && ( )}
)} {/* Advanced Options - Playlist (when no playlist info) */} {activeTab === 'playlist' && !playlistInfo && !playlistPreviewLoading && (
{ setAdvancedOptionsOpen(checked === true) }} />
)}
{activeTab === 'single' ? ( videoInfo || loading ? ( !loading && videoInfo ? ( ) : null ) : ( ) ) : playlistInfo && !playlistPreviewLoading ? ( ) : playlistPreviewLoading ? null : ( )}
} lockDialogHeight={lockDialogHeight} onActiveTabChange={setActiveTab} oneClickDownloadEnabled={settings.oneClickDownload} oneClickTooltip={t('download.oneClickDownloadTooltip')} onOpenChange={setOpen} onToggleOneClickDownload={() => { saveSetting({ key: 'oneClickDownload', value: !settings.oneClickDownload }) }} open={open} playlistTabContent={ } playlistTabLabel={t('download.metadata.playlist')} singleTabContent={ } singleTabLabel={t('download.singleVideo')} /> ) } ================================================ FILE: apps/desktop/src/renderer/src/components/download/DownloadItem.tsx ================================================ import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { Checkbox } from '@renderer/components/ui/checkbox' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '@renderer/components/ui/context-menu' import { Progress } from '@renderer/components/ui/progress' import { RemoteImage } from '@renderer/components/ui/remote-image' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@renderer/components/ui/sheet' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' import type { DownloadItem as DownloadItemPayload } from '@shared/types' import { DOWNLOAD_FEEDBACK_ISSUE_TITLE, FeedbackLinkButtons } from '@vidbee/ui/components/ui/feedback-link-buttons' import { useAtomValue, useSetAtom } from 'jotai' import { AlertCircle, CheckCircle2, Copy, File, FolderOpen, Loader2, Play, RotateCw, Trash2, X } from 'lucide-react' import { type ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { buildFilePathCandidates, normalizeSavedFileName } from '../../../../shared/utils/download-file' import { ipcServices } from '../../lib/ipc' import { addDownloadAtom, type DownloadRecord, removeDownloadAtom, removeHistoryRecordAtom } from '../../store/downloads' import { settingsAtom } from '../../store/settings' import { useAppInfo } from '../feedback/FeedbackLinks' const tryFileOperation = async ( paths: string[], operation: (filePath: string) => Promise ): Promise => { for (const filePath of paths) { const success = await operation(filePath) if (success) { return true } } return false } const getSavedFileExtension = (fileName?: string): string | undefined => { const normalized = normalizeSavedFileName(fileName) if (!normalized) { return undefined } if (!normalized.includes('.')) { return undefined } const ext = normalized.split('.').pop() return ext?.toLowerCase() } const resolveDownloadExtension = (download: DownloadRecord): string => { const savedExt = getSavedFileExtension(download.savedFileName) if (savedExt) { return savedExt } const selectedExt = download.selectedFormat?.ext?.toLowerCase() if (selectedExt) { return selectedExt } return download.type === 'audio' ? 'mp3' : 'mp4' } const getFormatLabel = (download: DownloadRecord): string | undefined => { if (download.selectedFormat?.ext) { return download.selectedFormat.ext.toUpperCase() } const savedExt = getSavedFileExtension(download.savedFileName) return savedExt ? savedExt.toUpperCase() : undefined } const getQualityLabel = (download: DownloadRecord): string | undefined => { const format = download.selectedFormat if (!format) { return undefined } if (format.height) { return `${format.height}p${format.fps === 60 ? '60' : ''}` } if (format.format_note) { return format.format_note } if (typeof format.quality === 'number') { return format.quality.toString() } return undefined } const sanitizeCodec = (codec?: string | null): string | undefined => { if (!codec || codec === 'none') { return undefined } return codec } const getCodecLabel = (download: DownloadRecord): string | undefined => { const format = download.selectedFormat if (!format) { return undefined } if (download.type === 'audio') { return sanitizeCodec(format.acodec) } return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec) } interface DownloadItemProps { download: DownloadRecord isSelected?: boolean onToggleSelect?: (id: string) => void } interface MetadataDetail { label: string value: ReactNode } const formatFileSize = (bytes?: number) => { if (!bytes) { return '' } const sizes = ['B', 'KB', 'MB', 'GB'] const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1) return `${(bytes / 1024 ** order).toFixed(1)} ${sizes[order]}` } const formatDuration = (seconds?: number) => { if (!seconds) { return '' } const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) const s = Math.floor(seconds % 60) if (h > 0) { return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` } return `${m}:${s.toString().padStart(2, '0')}` } const formatDate = (timestamp?: number) => { if (!timestamp) { return '' } return new Date(timestamp).toLocaleString() } const formatDateShort = (timestamp?: number) => { if (!timestamp) { return '' } const date = new Date(timestamp) return date.toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) { const { t } = useTranslation() const appInfo = useAppInfo() const settings = useAtomValue(settingsAtom) const addDownload = useSetAtom(addDownloadAtom) const removeDownload = useSetAtom(removeDownloadAtom) const removeHistory = useSetAtom(removeHistoryRecordAtom) const isHistory = download.entryType === 'history' const isSubscriptionDownload = download.origin === 'subscription' const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown') const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt const actionsContainerClass = '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' const resolvedExtension = resolveDownloadExtension(download) const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName) const selectionEnabled = isHistory && Boolean(onToggleSelect) // Track if the file exists const [fileExists, setFileExists] = useState(false) const [sheetOpen, setSheetOpen] = useState(false) const [activeTab, setActiveTab] = useState<'details' | 'logs'>('details') const [pendingTab, setPendingTab] = useState<'details' | 'logs' | null>(null) const [logAutoScroll, setLogAutoScroll] = useState(true) const logContainerRef = useRef(null) const lastSheetOpenRef = useRef(false) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) // Check if file exists when download data changes useEffect(() => { const checkFileExists = async () => { if (!(download.title && download.downloadPath)) { setFileExists(false) return } try { const formatForPath = resolvedExtension const filePaths = buildFilePathCandidates( download.downloadPath, download.title, formatForPath, download.savedFileName ) for (const filePath of filePaths) { const exists = await ipcServices.fs.fileExists(filePath) if (exists) { setFileExists(true) return } } setFileExists(false) } catch (error) { console.error('Failed to check file existence:', error) setFileExists(false) } } checkFileExists() }, [download.title, download.downloadPath, download.savedFileName, resolvedExtension]) const handleCancel = async () => { if (isHistory) { return } try { await ipcServices.download.cancelDownload(download.id) removeDownload(download.id) } catch (error) { console.error('Failed to cancel download:', error) } } const handleRetryDownload = async () => { if (!download.url) { toast.error(t('errors.emptyUrl')) return } const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` const customDownloadPath = download.downloadPath?.trim() || undefined const formatId = download.selectedFormat?.format_id const downloadItem: DownloadItemPayload = { id, url: download.url, title: download.title || t('download.fetchingVideoInfo'), thumbnail: download.thumbnail, type: download.type, status: 'pending', progress: { percent: 0 }, duration: download.duration, description: download.description, channel: download.channel, uploader: download.uploader, viewCount: download.viewCount, tags: download.tags, selectedFormat: download.selectedFormat, playlistId: download.playlistId, playlistTitle: download.playlistTitle, playlistIndex: download.playlistIndex, playlistSize: download.playlistSize, origin: download.origin, subscriptionId: download.subscriptionId, createdAt: Date.now() } try { const started = await ipcServices.download.startDownload(id, { url: download.url, type: download.type, format: formatId, audioFormat: download.type === 'video' ? 'best' : undefined, customDownloadPath, tags: download.tags, origin: download.origin, subscriptionId: download.subscriptionId }) if (!started) { toast.info(t('notifications.downloadAlreadyQueued')) return } addDownload(downloadItem) } catch (error) { console.error('Failed to retry download:', error) toast.error(t('notifications.downloadFailed')) } } const handleOpenFolder = async () => { try { const downloadPath = download.downloadPath || settings.downloadPath const format = resolvedExtension const filePaths = buildFilePathCandidates( downloadPath, download.title, format, download.savedFileName ) const success = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.openFileLocation(filePath) ) if (!success) { toast.error(t('notifications.openFolderFailed')) } } catch (error) { console.error('Failed to open file location:', error) toast.error(t('notifications.openFolderFailed')) } } const handleOpenFile = async () => { try { const downloadPath = download.downloadPath || settings.downloadPath if (!(downloadPath && download.title)) { toast.error(t('notifications.openFileFailed')) return } const format = resolvedExtension const filePaths = buildFilePathCandidates( downloadPath, download.title, format, download.savedFileName ) const success = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.openFile(filePath) ) if (!success) { toast.error(t('notifications.openFileFailed')) } } catch (error) { console.error('Failed to open file:', error) toast.error(t('notifications.openFileFailed')) } } const handleCopyLink = async () => { if (!download.url) { toast.error(t('notifications.copyFailed')) return } if (!navigator.clipboard?.writeText) { toast.error(t('notifications.copyFailed')) return } try { await navigator.clipboard.writeText(download.url) toast.success(t('notifications.urlCopied')) } catch (error) { console.error('Failed to copy link:', error) toast.error(t('notifications.copyFailed')) } } // Check if copy to clipboard is available const canCopyToClipboard = () => { return Boolean(download.title && download.downloadPath && fileExists) } // need title, downloadPath, format const handleCopyToClipboard = async () => { if (!canCopyToClipboard()) { toast.error(t('notifications.copyFailed')) return } // Type guard: these values are guaranteed to exist after canCopyToClipboard() check const downloadPath = download.downloadPath const format = resolvedExtension const title = download.title if (!(downloadPath && title)) { toast.error(t('notifications.copyFailed')) return } try { // Generate file path using downloadPath + title + ext const filePaths = buildFilePathCandidates(downloadPath, title, format, download.savedFileName) const success = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.copyFileToClipboard(filePath) ) if (!success) { toast.error(t('notifications.copyFailed')) return } toast.success(t('notifications.videoCopied')) } catch (error) { console.error('Failed to copy file to clipboard:', error) toast.error(t('notifications.copyFailed')) } } const handleDeleteFile = async () => { try { const downloadPath = download.downloadPath || settings.downloadPath if (!(downloadPath && download.title)) { toast.error(t('notifications.removeFailed')) return } const format = resolvedExtension const filePaths = buildFilePathCandidates( downloadPath, download.title, format, download.savedFileName ) const deleted = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.deleteFile(filePath) ) if (!deleted) { toast.error(t('notifications.removeFailed')) return } setFileExists(false) if (isHistory) { await ipcServices.history.removeHistoryItem(download.id) removeHistory(download.id) } else { removeDownload(download.id) } } catch (error) { console.error('Failed to delete file:', error) toast.error(t('notifications.removeFailed')) } } const handleDeleteRecord = async () => { try { if (isHistory) { await ipcServices.history.removeHistoryItem(download.id) removeHistory(download.id) } else { removeDownload(download.id) } } catch (error) { console.error('Failed to remove record:', error) toast.error(t('notifications.removeFailed')) } } const getStatusIcon = () => { switch (download.status) { case 'completed': return case 'error': return case 'downloading': case 'processing': return case 'pending': return case 'cancelled': return default: return null } } const getStatusText = () => { switch (download.status) { case 'completed': return t('download.completed') case 'error': return t('download.error') case 'downloading': return t('download.downloading') case 'processing': return t('download.processing') case 'pending': return t('download.downloadPending') case 'cancelled': return t('download.cancelled') default: return '' } } const statusIcon = getStatusIcon() const statusText = getStatusText() const progressInfo = download.progress const isInProgressStatus = download.status === 'downloading' || download.status === 'processing' || download.status === 'pending' const isCompletedStatus = download.status === 'completed' const canRetry = download.status === 'error' const showCopyAction = download.status === 'completed' && fileExists const showOpenFolderAction = Boolean( download.title && (download.downloadPath || settings.downloadPath) ) const showInlineProgress = Boolean( progressInfo && download.status !== 'completed' && download.status !== 'error' ) const canCopyLink = Boolean(download.url) const canOpenFile = isCompletedStatus && fileExists const canDeleteFile = isCompletedStatus && fileExists const sourceDisplay = download.uploader && download.channel && download.uploader !== download.channel ? `${download.uploader} • ${download.channel}` : download.uploader || download.channel || '' const metadataDetails: MetadataDetail[] = [] if (timestamp) { metadataDetails.push({ label: t('history.date'), value: formatDate(timestamp) }) } if (sourceDisplay) { metadataDetails.push({ label: t('download.metadata.source'), value: sourceDisplay }) } if (download.playlistId) { metadataDetails.push({ label: t('download.metadata.playlist'), value: ( {download.playlistTitle || t('playlist.untitled')} {download.playlistIndex !== undefined && download.playlistSize !== undefined ? ( {` ${t('playlist.positionLabel', { index: download.playlistIndex, total: download.playlistSize })}`} ) : null} ) }) } if (download.duration) { metadataDetails.push({ label: t('history.duration'), value: formatDuration(download.duration) }) } const selectedFormatSize = download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined const formatLabelValue = getFormatLabel(download) if (formatLabelValue) { metadataDetails.push({ label: t('download.metadata.format'), value: formatLabelValue }) } const qualityLabel = getQualityLabel(download) if (qualityLabel) { metadataDetails.push({ label: t('download.metadata.quality'), value: qualityLabel }) } if (inlineFileSize) { metadataDetails.push({ label: t('history.fileSize'), value: inlineFileSize }) } const codecValue = getCodecLabel(download) if (codecValue) { metadataDetails.push({ label: t('download.metadata.codec'), value: codecValue }) } if (normalizedSavedFileName || download.savedFileName) { metadataDetails.push({ label: t('download.metadata.savedFile'), value: normalizedSavedFileName ?? download.savedFileName }) } if (download.url) { metadataDetails.push({ label: t('download.metadata.url'), value: ( {download.url} ) }) } // Additional metadata fields if (download.description) { metadataDetails.push({ label: t('download.metadata.description'), value: {download.description} }) } if (download.viewCount !== undefined && download.viewCount !== null) { metadataDetails.push({ label: t('download.metadata.views'), value: download.viewCount.toLocaleString() }) } if (download.tags && download.tags.length > 0) { metadataDetails.push({ label: t('download.metadata.tags'), value: (
{download.tags.map((tag) => ( {tag} ))}
) }) } if (download.downloadPath) { metadataDetails.push({ label: t('download.metadata.downloadPath'), value: {download.downloadPath} }) } // Timestamps if (download.createdAt && download.createdAt !== timestamp) { metadataDetails.push({ label: t('download.metadata.createdAt'), value: formatDate(download.createdAt) }) } if (download.startedAt) { metadataDetails.push({ label: t('download.metadata.startedAt'), value: formatDate(download.startedAt) }) } if (download.completedAt && download.completedAt !== timestamp) { metadataDetails.push({ label: t('download.metadata.completedAt'), value: formatDate(download.completedAt) }) } // Speed if (download.speed) { metadataDetails.push({ label: t('download.metadata.speed'), value: download.speed }) } // File size (if different from inlineFileSize) if (download.fileSize && download.fileSize !== selectedFormatSize) { metadataDetails.push({ label: t('download.metadata.fileSize'), value: formatFileSize(download.fileSize) }) } // Selected format details if (download.selectedFormat) { if (download.selectedFormat.width) { metadataDetails.push({ label: t('download.metadata.width'), value: `${download.selectedFormat.width}px` }) } if (download.selectedFormat.height && !qualityLabel) { metadataDetails.push({ label: t('download.metadata.height'), value: `${download.selectedFormat.height}px` }) } if (download.selectedFormat.fps) { metadataDetails.push({ label: t('download.metadata.fps'), value: `${download.selectedFormat.fps}` }) } if (download.selectedFormat.vcodec) { metadataDetails.push({ label: t('download.metadata.videoCodec'), value: download.selectedFormat.vcodec }) } if (download.selectedFormat.acodec) { metadataDetails.push({ label: t('download.metadata.audioCodec'), value: download.selectedFormat.acodec }) } if (download.selectedFormat.format_note) { metadataDetails.push({ label: t('download.metadata.formatNote'), value: download.selectedFormat.format_note }) } if (download.selectedFormat.protocol) { metadataDetails.push({ label: t('download.metadata.protocol'), value: download.selectedFormat.protocol.toUpperCase() }) } } if (isSubscriptionDownload) { metadataDetails.push({ label: t('download.metadata.subscription'), value: subscriptionLabel }) } const hasMetadataDetails = metadataDetails.length > 0 const logContent = download.ytDlpLog ?? '' const hasLogContent = logContent.trim().length > 0 const ytDlpCommand = download.ytDlpCommand?.trim() const hasYtDlpCommand = Boolean(ytDlpCommand) const canShowSheet = hasMetadataDetails || isInProgressStatus || hasLogContent const isSelectedHistory = selectionEnabled && isSelected useEffect(() => { const wasOpen = lastSheetOpenRef.current lastSheetOpenRef.current = sheetOpen if (!sheetOpen || wasOpen) { return } const defaultTab = hasMetadataDetails ? 'details' : 'logs' setActiveTab(pendingTab ?? defaultTab) setPendingTab(null) setLogAutoScroll(true) }, [hasMetadataDetails, pendingTab, sheetOpen]) useEffect(() => { if (!(sheetOpen && logAutoScroll && logContent)) { return } const container = logContainerRef.current if (container) { container.scrollTop = container.scrollHeight } }, [logAutoScroll, logContent, sheetOpen]) const handleLogScroll = () => { const container = logContainerRef.current if (!container) { return } const { scrollTop, scrollHeight, clientHeight } = container const isNearBottom = scrollHeight - scrollTop - clientHeight < 24 setLogAutoScroll(isNearBottom) } const openLogsSheet = () => { if (!canShowSheet) { return } setPendingTab(sheetOpen ? null : 'logs') setActiveTab('logs') setLogAutoScroll(true) setSheetOpen(true) } return (
onToggleSelect?.(download.id), onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onToggleSelect?.(download.id) } }, role: 'button', tabIndex: 0, 'aria-label': t('history.selectItem') } : {})} > {/* Thumbnail */}
{selectionEnabled && (
onToggleSelect?.(download.id)} onClick={(event) => event.stopPropagation()} />
)} } src={download.thumbnail} />
{/* Content */}

{download.title}

{download.type === 'audio' && ( {t('download.audio')} )} {isSubscriptionDownload && ( {t('subscriptions.labels.subscription')} )}
{/* Status */} {statusIcon && (
{statusIcon}

{statusText}

)} {showInlineProgress && (
{(progressInfo?.percent ?? 0).toFixed(1)}% {progressInfo?.downloaded && progressInfo?.total && ( {progressInfo.downloaded} / {progressInfo.total} )} {progressInfo?.currentSpeed && ( {progressInfo.currentSpeed} )} {progressInfo?.eta && ( ETA: {progressInfo.eta} )}
)} {/* Timestamp */} {timestamp && ( {formatDateShort(timestamp)} )} {/* Quality */} {qualityLabel && ( <> {(statusIcon || timestamp) && ( )} {qualityLabel} )} {/* File size */} {inlineFileSize && ( <> {(statusIcon || timestamp || qualityLabel) && ( )} {inlineFileSize} )}
{canRetry && (

{t('download.retry')}

)} {isHistory ? ( <> {showCopyAction && (

{t('history.copyToClipboard')}

)} {showOpenFolderAction && (

{t('history.openFolder')}

)} ) : ( <> {showCopyAction && (

{t('history.copyToClipboard')}

)} {showOpenFolderAction && (

{t('history.openFolder')}

)} {(download.status === 'downloading' || download.status === 'pending' || download.status === 'processing') && ( )} )}
{/* Progress */} {download.progress && download.status !== 'completed' && download.status !== 'error' && (
)} {/* Error message */} {download.status === 'error' && download.error && (

{download.error}

{t('download.feedback.title')}: {canShowSheet && ( )} event.stopPropagation()} showGroupSeparator={canShowSheet} sourceUrl={download.url} wrapperClassName="flex flex-wrap items-center gap-1.5" ytDlpCommand={download.ytDlpCommand} />
)}
{/* Video Details Sheet */} {canShowSheet && (
{download.title} {t('download.videoInfo')} setActiveTab(value as 'details' | 'logs')} value={activeTab} >
{t('download.detailsTab')} {t('download.logsTab')}
{metadataDetails.map((item) => (
{item.label}
{item.value}
))}
{isInProgressStatus ? t('download.logs.live') : t('download.logs.history')} {logAutoScroll ? null : ( {t('download.logs.scrollPaused')} )}
{hasYtDlpCommand && (
{t('download.logs.command')}
{ytDlpCommand}
)}
{hasLogContent ? logContent : t('download.logs.empty')}
)}
{isInProgressStatus ? ( <> {canRetry && ( {t('download.retry')} )} {t('history.openFileLocation')} {canShowSheet && ( setSheetOpen(true)}> )} {t('download.cancel')} ) : ( <> {isCompletedStatus && ( {t('history.copyToClipboard')} )} {canRetry && ( {t('download.retry')} )} {t('history.openFile')} {t('history.openFileLocation')} {canShowSheet && ( setSheetOpen(true)}> )} {t('history.deleteFile')} )}
) } ================================================ FILE: apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx ================================================ import { Checkbox } from '@renderer/components/ui/checkbox' import { Input } from '@renderer/components/ui/input' import { Label } from '@renderer/components/ui/label' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@renderer/components/ui/select' import { cn } from '@renderer/lib/utils' import type { PlaylistInfo } from '@shared/types' import { AlertCircle, List, Loader2 } from 'lucide-react' import type { Dispatch, SetStateAction } from 'react' import { useTranslation } from 'react-i18next' interface PlaylistDownloadProps { playlistPreviewLoading: boolean playlistPreviewError: string | null playlistInfo: PlaylistInfo | null playlistBusy: boolean selectedPlaylistEntries: PlaylistInfo['entries'] selectedEntryIds: Set downloadType: 'video' | 'audio' downloadTypeId: string startIndex: string endIndex: string advancedOptionsOpen: boolean setSelectedEntryIds: Dispatch>> setStartIndex: Dispatch> setEndIndex: Dispatch> setDownloadType: Dispatch> } export function PlaylistDownload({ playlistPreviewLoading, playlistPreviewError, playlistInfo, playlistBusy, selectedPlaylistEntries, selectedEntryIds, downloadType, downloadTypeId, startIndex, endIndex, advancedOptionsOpen, setSelectedEntryIds, setStartIndex, setEndIndex, setDownloadType }: PlaylistDownloadProps) { const { t } = useTranslation() return ( <> {playlistPreviewLoading && !playlistPreviewError && (

{t('playlist.fetchingInfo')}

)} {playlistPreviewError && (

{t('playlist.previewFailed')}

{playlistPreviewError}

)} {playlistInfo && !playlistPreviewLoading && (

{playlistInfo.title}

{t('playlist.foundVideos', { count: playlistInfo.entryCount })} {selectedPlaylistEntries.length !== playlistInfo.entryCount && ( <> {t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })} )}
{playlistInfo.entries.map((entry) => { const isSelected = selectedEntryIds.has(entry.id) const isInRange = selectedEntryIds.size === 0 && selectedPlaylistEntries.some((playlistEntry) => playlistEntry.id === entry.id) const handleToggle = () => { setSelectedEntryIds((prev) => { const next = new Set(prev) if (next.has(entry.id)) { next.delete(entry.id) } else { next.add(entry.id) } return next }) if (selectedEntryIds.size === 0) { setStartIndex('1') setEndIndex('') } } return ( ) })}
{ setStartIndex(event.target.value) if (selectedEntryIds.size > 0) { setSelectedEntryIds(new Set()) } }} placeholder="1" value={startIndex} /> - { setEndIndex(event.target.value) if (selectedEntryIds.size > 0) { setSelectedEntryIds(new Set()) } }} placeholder={playlistInfo?.entryCount.toString() || 'End'} value={endIndex} />
)} ) } ================================================ FILE: apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx ================================================ import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { DownloadRecord } from '../../store/downloads' import { Button } from '../ui/button' import { Progress } from '../ui/progress' import { DownloadItem } from './DownloadItem' interface PlaylistDownloadGroupProps { groupId: string title: string records: DownloadRecord[] totalCount: number selectedIds?: Set onToggleSelect?: (id: string) => void onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void } const STORAGE_KEY_PREFIX = 'playlist_expanded_' const getStorageKey = (groupId: string): string => { return `${STORAGE_KEY_PREFIX}${groupId}` } const loadExpandedState = (groupId: string): boolean => { try { const stored = localStorage.getItem(getStorageKey(groupId)) return stored === 'true' } catch (error) { console.error('Failed to load playlist expanded state:', error) return false } } const saveExpandedState = (groupId: string, isExpanded: boolean): void => { try { localStorage.setItem(getStorageKey(groupId), String(isExpanded)) } catch (error) { console.error('Failed to save playlist expanded state:', error) } } export function PlaylistDownloadGroup({ groupId, title, records, totalCount, selectedIds, onToggleSelect, onDeletePlaylist }: PlaylistDownloadGroupProps) { const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId)) useEffect(() => { saveExpandedState(groupId, isExpanded) }, [groupId, isExpanded]) const completedCount = records.filter((record) => record.status === 'completed').length const errorCount = records.filter((record) => record.status === 'error').length const activeCount = records.filter((record) => ['downloading', 'processing', 'pending'].includes(record.status) ).length const displayTitle = title || t('playlist.untitled') const historyRecords = records.filter((record) => record.entryType === 'history') const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist) const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand') const totalProgress = records.reduce((acc, record) => { if (record.status === 'completed') { return acc + 1 } if (record.progress?.percent && record.progress.percent > 0) { return acc + Math.min(record.progress.percent, 100) / 100 } return acc }, 0) const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0 return (
{canDeletePlaylist && ( )}
{!isExpanded && totalCount > 0 && ( )}
{records.map((record) => (
))}
) } ================================================ FILE: apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx ================================================ import { Button } from '@renderer/components/ui/button' import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder' import { Label } from '@renderer/components/ui/label' import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@renderer/components/ui/select' import { Separator } from '@renderer/components/ui/separator' import { cn } from '@renderer/lib/utils' import type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types' import { DOWNLOAD_FEEDBACK_ISSUE_TITLE, FeedbackLinkButtons } from '@vidbee/ui/components/ui/feedback-link-buttons' import { useAtom } from 'jotai' import { AlertCircle, ExternalLink, Loader2, Settings2 } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail' import { settingsAtom } from '../../store/settings' import { useAppInfo } from '../feedback/FeedbackLinks' export interface SingleVideoState { title: string activeTab: 'video' | 'audio' selectedVideoFormat: string selectedAudioFormat: string customDownloadPath: string selectedContainer?: string selectedCodec?: string selectedFps?: string } interface SingleVideoDownloadProps { loading: boolean error: string | null videoInfo: VideoInfo | null state: SingleVideoState feedbackSourceUrl?: string | null ytDlpCommand?: string onStateChange: (state: Partial) => void } const qualityPresetToVideoHeight: Record = { best: null, good: 1080, normal: 720, bad: 480, worst: 360 } const formatDuration = (seconds?: number): string => { if (!seconds) { return '00:00' } const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const remainingSeconds = Math.floor(seconds % 60) if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds .toString() .padStart(2, '0')}` } return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` } const getCodecShortName = (codec?: string): string => { if (!codec || codec === 'none') { return 'Unknown' } return codec.split('.')[0].toUpperCase() } const isHlsFormat = (format: VideoFormat): boolean => format.protocol === 'm3u8' || format.protocol === 'm3u8_native' const isHttpProtocol = (format: VideoFormat): boolean => !!format.protocol && format.protocol.startsWith('http') const filterFormatsByType = ( formats: VideoInfo['formats'], activeTab: 'video' | 'audio' ): VideoInfo['formats'] => { if (!formats) { return [] } return formats.filter((format) => { if (activeTab === 'video') { return format.vcodec && format.vcodec !== 'none' } return ( format.acodec && format.acodec !== 'none' && (format.video_ext === 'none' || !format.video_ext || !format.vcodec || format.vcodec === 'none') ) }) } interface FormatListProps { formats: VideoFormat[] type: 'video' | 'audio' codec?: string selectedFormat: string onFormatChange: (formatId: string) => void } const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: FormatListProps) => { const { t } = useTranslation() const [settings] = useAtom(settingsAtom) const [videoFormats, setVideoFormats] = useState([]) const [audioFormats, setAudioFormats] = useState([]) const getFileSize = useCallback((format: VideoFormat): number => { return format.filesize ?? format.filesize_approx ?? 0 }, []) const sortVideoFormatsByQuality = useCallback( (a: VideoFormat, b: VideoFormat) => { const aHeight = a.height ?? 0 const bHeight = b.height ?? 0 if (aHeight !== bHeight) { return bHeight - aHeight } const aFps = a.fps ?? 0 const bFps = b.fps ?? 0 if (aFps !== bFps) { return bFps - aFps } const aHasSize = !!(a.filesize || a.filesize_approx) const bHasSize = !!(b.filesize || b.filesize_approx) if (aHasSize !== bHasSize) { return bHasSize ? 1 : -1 } return getFileSize(b) - getFileSize(a) }, [getFileSize] ) const sortAudioFormatsByQuality = useCallback( (a: VideoFormat, b: VideoFormat) => { const aQuality = a.tbr ?? a.quality ?? 0 const bQuality = b.tbr ?? b.quality ?? 0 if (aQuality !== bQuality) { return bQuality - aQuality } const aHasSize = !!(a.filesize || a.filesize_approx) const bHasSize = !!(b.filesize || b.filesize_approx) if (aHasSize !== bHasSize) { return bHasSize ? 1 : -1 } return getFileSize(b) - getFileSize(a) }, [getFileSize] ) const pickVideoFormatForPreset = useCallback( (presetFormats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => { if (presetFormats.length === 0) { return null } const heightLimit = qualityPresetToVideoHeight[preset] const sorted = [...presetFormats].sort(sortVideoFormatsByQuality) if (preset === 'worst') { return sorted.at(-1) ?? sorted[0] } if (!heightLimit) { return sorted[0] } const matchingLimit = sorted.find((format) => { if (!format.height) { return false } return format.height <= heightLimit }) return matchingLimit ?? sorted[0] }, [sortVideoFormatsByQuality] ) useEffect(() => { const isVideoFormat = (format: VideoFormat) => format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none' const isAudioFormat = (format: VideoFormat) => format.acodec && format.acodec !== 'none' && (format.video_ext === 'none' || !format.video_ext || !format.vcodec || format.vcodec === 'none') const videos = formats.filter(isVideoFormat) const audios = formats.filter(isAudioFormat) const groupedByHeight = new Map() videos.forEach((format) => { const height = format.height ?? 0 const existing = groupedByHeight.get(height) || [] existing.push(format) groupedByHeight.set(height, existing) }) const finalVideos = Array.from(groupedByHeight.values()).map((group) => { return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0] }) let finalAudios = audios if (codec === 'auto' && type === 'audio') { const groupedByQuality = new Map() audios.forEach((format) => { const qualityKey = format.tbr ? `tbr_${format.tbr}` : format.quality ? `quality_${format.quality}` : 'unknown' const existing = groupedByQuality.get(qualityKey) || [] existing.push(format) groupedByQuality.set(qualityKey, existing) }) finalAudios = Array.from(groupedByQuality.values()).map((group) => { return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0] }) } finalVideos.sort(sortVideoFormatsByQuality) finalAudios.sort(sortAudioFormatsByQuality) setVideoFormats(finalVideos) setAudioFormats(finalAudios) if (type === 'video') { const videosWithAudio = finalVideos.filter( (format) => format.acodec && format.acodec !== 'none' ) const autoVideos = finalAudios.length > 0 ? finalVideos : videosWithAudio.length > 0 ? videosWithAudio : finalVideos const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedFormat) if (autoVideos.length > 0 && !(selectedFormat && hasSelectedVideo)) { const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality) if (preferred) { onFormatChange(preferred.format_id) } } } else { const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedFormat) if (finalAudios.length > 0 && !(selectedFormat && hasSelectedAudio)) { const best = finalAudios[0] onFormatChange(best.format_id) } } }, [ formats, settings.oneClickQuality, type, selectedFormat, onFormatChange, pickVideoFormatForPreset, codec, getFileSize, sortVideoFormatsByQuality, sortAudioFormatsByQuality ]) const formatSize = (bytes?: number) => { if (!bytes) { return t('download.unknownSize') } const mb = bytes / 1_000_000 return `${mb.toFixed(2)} MB` } const formatMetaLabel = (format: VideoFormat) => { const parts: string[] = [] const pushPart = (label: string, value?: string) => { if (!value) { return } parts.push(`${label}:${value}`) } pushPart('proto', format.protocol) pushPart('lang', format.language?.trim()) if (format.tbr) { pushPart('tbr', `${Math.round(format.tbr)}k`) } if (typeof format.quality === 'number') { pushPart('q', String(format.quality)) } if (format.vcodec && format.vcodec !== 'none') { pushPart('vcodec', format.vcodec) } if (format.acodec && format.acodec !== 'none') { pushPart('acodec', format.acodec) } return parts.join(' • ') } const formatVideoQuality = (format: VideoFormat) => { if (format.height) { return `${format.height}p${format.fps === 60 ? '60' : ''}` } if (format.format_note) { return format.format_note } if (typeof format.quality === 'number') { return format.quality.toString() } return t('download.unknownQuality') } const formatAudioQuality = (format: VideoFormat) => { if (format.tbr) { return `${Math.round(format.tbr)} kbps` } if (format.format_note) { return format.format_note } if (typeof format.quality === 'number') { return format.quality.toString() } return t('download.unknownQuality') } const formatVideoDetail = (format: VideoFormat) => { const parts: string[] = [] parts.push(format.ext.toUpperCase()) if (format.vcodec) { parts.push(format.vcodec.split('.')[0].toUpperCase()) } if (format.acodec && format.acodec !== 'none') { parts.push(format.acodec.split('.')[0].toUpperCase()) } return parts.join(' • ') } const formatAudioDetail = (format: VideoFormat) => { const parts: string[] = [] const ext = format.ext === 'webm' ? 'opus' : format.ext parts.push(ext.toUpperCase()) if (format.acodec) { parts.push(format.acodec.split('.')[0].toUpperCase()) } return parts.join(' • ') } const list = type === 'video' ? videoFormats : audioFormats if (list.length === 0) { return null } return ( {list.map((format) => { const qualityLabel = type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format) const detailLabel = type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format) const thirdColumnLabel = type === 'video' ? format.fps ? `${format.fps}fps` : '' : format.acodec ? format.acodec.split('.')[0].toUpperCase() : '' const sizeLabel = formatSize(format.filesize || format.filesize_approx) const metaLabel = formatMetaLabel(format) const isSelected = selectedFormat === format.format_id return ( ) })} ) } export function SingleVideoDownload({ loading, error, videoInfo, state, feedbackSourceUrl, ytDlpCommand, onStateChange }: SingleVideoDownloadProps) { const { t } = useTranslation() const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail) const [showAdvanced, setShowAdvanced] = useState(false) const appInfo = useAppInfo() const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state const displayTitle = title || videoInfo?.title || t('download.fetchingVideoInfo') const relevantFormats = useMemo(() => { if (!videoInfo?.formats) { return [] } const baseFormats = filterFormatsByType(videoInfo.formats, activeTab) if (baseFormats.length === 0) { return [] } const hasHttpFormats = baseFormats.some(isHttpProtocol) if (!hasHttpFormats) { return baseFormats } const nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format)) return nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats }, [videoInfo?.formats, activeTab]) const containers = useMemo(() => { if (relevantFormats.length === 0) { return [] } const exts = new Set(relevantFormats.map((format) => format.ext)) return Array.from(exts).sort() }, [relevantFormats]) useEffect(() => { if (containers.length === 0) { return undefined } if (selectedContainer && !containers.includes(selectedContainer)) { let defaultContainer: string if (activeTab === 'video') { defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0] } else { defaultContainer = containers.includes('m4a') ? 'm4a' : containers.includes('mp3') ? 'mp3' : containers[0] } const timer = setTimeout(() => { onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' }) }, 0) return () => clearTimeout(timer) } if (!selectedContainer) { let defaultContainer: string if (activeTab === 'video') { defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0] } else { defaultContainer = containers.includes('m4a') ? 'm4a' : containers.includes('mp3') ? 'mp3' : containers[0] } const timer = setTimeout(() => { onStateChange({ selectedContainer: defaultContainer }) }, 0) return () => clearTimeout(timer) } return undefined }, [containers, selectedContainer, activeTab, onStateChange]) const formatsByContainer = useMemo(() => { if (relevantFormats.length === 0) { return [] } if (!selectedContainer) { return relevantFormats } return relevantFormats.filter((format) => format.ext === selectedContainer) }, [relevantFormats, selectedContainer]) const codecs = useMemo(() => { if (formatsByContainer.length === 0) { return [] } const SetVals = new Set() formatsByContainer.forEach((format) => { if (activeTab === 'video') { const c = format.vcodec if (c && c !== 'none') { SetVals.add(getCodecShortName(c)) } } else { const c = format.acodec if (c && c !== 'none') { SetVals.add(getCodecShortName(c)) } } }) return Array.from(SetVals).sort() }, [formatsByContainer, activeTab]) useEffect(() => { if (codecs.length === 0) { return undefined } if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) { const timer = setTimeout(() => { onStateChange({ selectedCodec: 'auto' }) }, 0) return () => clearTimeout(timer) } return undefined }, [codecs, selectedCodec, onStateChange]) const formatsByCodec = useMemo(() => { if (!selectedCodec || selectedCodec === 'auto') { return formatsByContainer } return formatsByContainer.filter((format) => { if (activeTab === 'video') { const c = format.vcodec return c && c !== 'none' && getCodecShortName(c) === selectedCodec } const c = format.acodec return c && c !== 'none' && getCodecShortName(c) === selectedCodec }) }, [formatsByContainer, selectedCodec, activeTab]) const framerates = useMemo(() => { if (activeTab !== 'video') { return [] } const SetVals = new Set() formatsByCodec.forEach((format) => { if (format.fps) { SetVals.add(format.fps) } }) return Array.from(SetVals).sort((a, b) => b - a) }, [formatsByCodec, activeTab]) const filteredFormats = useMemo(() => { let res = formatsByCodec if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') { res = res.filter((format) => format.fps === Number(selectedFps)) } return res }, [formatsByCodec, selectedFps, activeTab]) return (
{loading && !error && (

{t('download.fetchingVideoInfo')}

)} {error && (

{t('errors.fetchInfoFailed')}

{error}

{t('download.feedback.title')}
)} {!loading && videoInfo && (
{formatDuration(videoInfo.duration)}

{displayTitle}

{videoInfo.uploader && ( {videoInfo.uploader} )} {videoInfo.webpage_url && ( )}
{activeTab === 'video' && (
)}
onStateChange( activeTab === 'video' ? { selectedVideoFormat: formatId } : { selectedAudioFormat: formatId } ) } selectedFormat={ activeTab === 'video' ? state.selectedVideoFormat : state.selectedAudioFormat } type={activeTab} />
)}
) } ================================================ FILE: apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx ================================================ import { Button } from '@renderer/components/ui/button' import { CardContent, CardHeader } from '@renderer/components/ui/card' import { Checkbox } from '@renderer/components/ui/checkbox' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog' import { cn } from '@renderer/lib/utils' import { DownloadEmptyState } from '@vidbee/ui/components/ui/download-empty-state' import { DownloadFilterBar, type DownloadFilterItem } from '@vidbee/ui/components/ui/download-filter-bar' import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { toast } from 'sonner' import { buildFilePathCandidates, normalizeSavedFileName } from '../../../../shared/utils/download-file' import { useHistorySync } from '../../hooks/use-history-sync' import { ipcServices } from '../../lib/ipc' import type { DownloadRecord } from '../../store/downloads' import { downloadStatsAtom, downloadsArrayAtom, removeHistoryRecordsAtom, removeHistoryRecordsByPlaylistAtom } from '../../store/downloads' import { settingsAtom } from '../../store/settings' import { ScrollArea } from '../ui/scroll-area' import { DownloadDialog } from './DownloadDialog' import { DownloadItem } from './DownloadItem' import { PlaylistDownloadGroup } from './PlaylistDownloadGroup' type StatusFilter = 'all' | 'active' | 'completed' | 'error' type ConfirmAction = | { type: 'delete-selected'; ids: string[] } | { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] } const tryFileOperation = async ( paths: string[], operation: (filePath: string) => Promise ): Promise => { for (const filePath of paths) { const success = await operation(filePath) if (success) { return true } } return false } const getSavedFileExtension = (fileName?: string): string | undefined => { const normalized = normalizeSavedFileName(fileName) if (!normalized) { return undefined } if (!normalized.includes('.')) { return undefined } const ext = normalized.split('.').pop() return ext?.toLowerCase() } const resolveDownloadExtension = (download: DownloadRecord): string => { const savedExt = getSavedFileExtension(download.savedFileName) if (savedExt) { return savedExt } const selectedExt = download.selectedFormat?.ext?.toLowerCase() if (selectedExt) { return selectedExt } return download.type === 'audio' ? 'mp3' : 'mp4' } const isEditableTarget = (target: EventTarget | null): boolean => { if (!(target && target instanceof HTMLElement)) { return false } if (target.isContentEditable) { return true } const tagName = target.tagName return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' } interface UnifiedDownloadHistoryProps { onOpenSupportedSites?: () => void onOpenSettings?: () => void onOpenCookiesSettings?: () => void } export function UnifiedDownloadHistory({ onOpenSupportedSites, onOpenSettings, onOpenCookiesSettings }: UnifiedDownloadHistoryProps) { const { t } = useTranslation() const allRecords = useAtomValue(downloadsArrayAtom) const downloadStats = useAtomValue(downloadStatsAtom) const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom) const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom) const settings = useAtomValue(settingsAtom) const [statusFilter, setStatusFilter] = useState('all') const [selectedIds, setSelectedIds] = useState>(new Set()) const [confirmAction, setConfirmAction] = useState(null) const [confirmBusy, setConfirmBusy] = useState(false) const [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false) const alsoDeleteFilesId = useId() const hasCookieConfig = useMemo(() => { const cookiesPath = settings.cookiesPath?.trim() if (cookiesPath) { return true } const browserSetting = settings.browserForCookies?.trim() return Boolean(browserSetting && browserSetting !== 'none') }, [settings.browserForCookies, settings.cookiesPath]) const showCookiesTip = !hasCookieConfig const canOpenCookiesSettings = Boolean(onOpenCookiesSettings ?? onOpenSettings) useHistorySync() const historyRecords = useMemo( () => allRecords.filter((record) => record.entryType === 'history'), [allRecords] ) const selectedCount = selectedIds.size const filteredRecords = useMemo(() => { return allRecords.filter((record) => { switch (statusFilter) { case 'all': return true case 'active': return ( record.status === 'downloading' || record.status === 'processing' || record.status === 'pending' ) case 'completed': case 'error': return record.status === statusFilter default: return true } }) }, [allRecords, statusFilter]) const visibleHistoryIds = useMemo( () => filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id), [filteredRecords] ) const filters: DownloadFilterItem[] = [ { key: 'all', label: t('download.all'), count: downloadStats.total }, { key: 'active', label: t('download.active'), count: downloadStats.active }, { key: 'completed', label: t('download.completed'), count: downloadStats.completed }, { key: 'error', label: t('download.error'), count: downloadStats.error } ] const selectableIds = useMemo(() => { if (visibleHistoryIds.length === 0) { return [] } const ids = new Set(visibleHistoryIds) const playlistIds = new Set( filteredRecords .filter((record) => record.entryType === 'history' && record.playlistId) .map((record) => record.playlistId as string) ) if (playlistIds.size === 0) { return Array.from(ids) } for (const record of historyRecords) { if (record.playlistId && playlistIds.has(record.playlistId)) { ids.add(record.id) } } return Array.from(ids) }, [filteredRecords, historyRecords, visibleHistoryIds]) const selectableCount = selectableIds.length const visibleSelectableCount = visibleHistoryIds.length const selectionSummary = selectableCount === 0 ? t('history.selectedCount', { count: selectedCount }) : selectableCount > visibleSelectableCount ? t('history.selectedCount', { count: selectedCount }) : t('history.selectionSummary', { selected: selectedCount, total: selectableCount }) useEffect(() => { if (selectedIds.size === 0) { return } const historyIdSet = new Set(historyRecords.map((record) => record.id)) setSelectedIds((prev) => { let changed = false const next = new Set() for (const id of prev) { if (historyIdSet.has(id)) { next.add(id) } else { changed = true } } return changed ? next : prev }) }, [historyRecords, selectedIds.size]) const handleToggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) } const handleClearSelection = () => { setSelectedIds(new Set()) } const handleRequestDeleteSelected = () => { if (selectedIds.size === 0) { return } setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) }) } const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => { if (ids.length === 0) { return } setConfirmAction({ type: 'delete-playlist', playlistId, title, ids }) } const pruneSelectedIds = (ids: string[]) => { if (ids.length === 0) { return } setSelectedIds((prev) => { const next = new Set(prev) let changed = false ids.forEach((id) => { if (next.delete(id)) { changed = true } }) return changed ? next : prev }) } const confirmContent = useMemo(() => { if (!confirmAction) { return null } switch (confirmAction.type) { case 'delete-selected': { return { title: t('history.confirmDeleteSelectedTitle'), description: t('history.confirmDeleteSelectedDescription', { count: confirmAction.ids.length }), actionLabel: t('history.removeAction') } } case 'delete-playlist': { return { title: t('history.confirmDeletePlaylistTitle'), description: t('history.confirmDeletePlaylistDescription', { count: confirmAction.ids.length, title: confirmAction.title }), actionLabel: t('history.removeAction') } } default: return null } }, [confirmAction, t]) const deleteHistoryFiles = async (records: DownloadRecord[]) => { const failedIds: string[] = [] for (const record of records) { if (!record.title) { continue } const downloadPath = record.downloadPath || settings.downloadPath if (!downloadPath) { continue } const formatForPath = resolveDownloadExtension(record) const filePaths = buildFilePathCandidates( downloadPath, record.title, formatForPath, record.savedFileName ) const deleted = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.deleteFile(filePath) ) if (!deleted) { failedIds.push(record.id) } } if (failedIds.length > 0) { console.warn('Failed to delete some playlist files:', failedIds) } } const handleConfirmAction = async () => { if (!confirmAction) { return } setConfirmBusy(true) try { if (confirmAction.type === 'delete-selected') { await ipcServices.history.removeHistoryItems(confirmAction.ids) removeHistoryRecords(confirmAction.ids) if (alsoDeleteFiles) { const idSet = new Set(confirmAction.ids) const recordsToDelete = historyRecords.filter((record) => idSet.has(record.id)) await deleteHistoryFiles(recordsToDelete) } pruneSelectedIds(confirmAction.ids) toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length })) } if (confirmAction.type === 'delete-playlist') { const idSet = new Set(confirmAction.ids) const playlistRecords = historyRecords.filter((record) => idSet.has(record.id)) await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId) removeHistoryRecordsByPlaylist(confirmAction.playlistId) await deleteHistoryFiles(playlistRecords) pruneSelectedIds(confirmAction.ids) toast.success( t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length }) ) } setConfirmAction(null) setAlsoDeleteFiles(false) } catch (error) { if (confirmAction.type === 'delete-selected') { console.error('Failed to remove selected history items:', error) toast.error(t('notifications.itemsRemoveFailed')) } if (confirmAction.type === 'delete-playlist') { console.error('Failed to remove playlist history:', error) toast.error(t('notifications.playlistHistoryRemoveFailed')) } } finally { setConfirmBusy(false) } } const groupedView = useMemo(() => { const groups = new Map< string, { id: string; title: string; totalCount: number; records: DownloadRecord[] } >() const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> = [] for (const record of filteredRecords) { if (record.playlistId) { let group = groups.get(record.playlistId) if (!group) { group = { id: record.playlistId, title: record.playlistTitle || record.title, totalCount: record.playlistSize || 0, records: [] } groups.set(record.playlistId, group) order.push({ type: 'group', id: record.playlistId }) } group.records.push(record) if (!group.title && record.playlistTitle) { group.title = record.playlistTitle } if (!group.totalCount && record.playlistSize) { group.totalCount = record.playlistSize } } else { order.push({ type: 'single', record }) } } for (const group of groups.values()) { group.records.sort((a, b) => { const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER if (aIndex !== bIndex) { return aIndex - bIndex } return b.createdAt - a.createdAt }) if (!group.totalCount) { group.totalCount = group.records.length } } return { order, groups } }, [filteredRecords]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) { return } if (isEditableTarget(event.target)) { return } if (event.key === 'Escape') { if (confirmAction) { return } if (selectedIds.size === 0) { return } setSelectedIds(new Set()) return } if (!(event.metaKey || event.ctrlKey)) { return } if (event.key.toLowerCase() !== 'a') { return } if (selectableIds.length === 0) { return } event.preventDefault() setSelectedIds(new Set(selectableIds)) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [confirmAction, selectableIds, selectedIds]) return (
} activeFilter={statusFilter} filters={filters} onFilterChange={setStatusFilter} /> {showCookiesTip && (

{t('history.cookiesTipTitle')}

}} i18nKey="history.cookiesTipDescription" />

)} {filteredRecords.length === 0 ? ( ) : (
{groupedView.order.map((item) => { if (item.type === 'single') { return ( ) } const group = groupedView.groups.get(item.id) if (!group) { return null } return ( ) })}
)}
{selectedCount > 0 && (
{selectionSummary}
)} { if (!(open || confirmBusy)) { setConfirmAction(null) setAlsoDeleteFiles(false) } }} open={Boolean(confirmAction)} > {confirmContent && ( {confirmContent.title} {confirmContent.description} {confirmAction?.type === 'delete-selected' && (
setAlsoDeleteFiles(checked === true)} />
)}
)}
) } ================================================ FILE: apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx ================================================ import { ipcServices } from '@renderer/lib/ipc' import { logger } from '@renderer/lib/logger' import { Component, type ErrorInfo, type ReactNode } from 'react' import { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage' interface Props { children: ReactNode onError?: (error: Error, errorInfo: ErrorInfo) => void fallback?: (errorInfo: ErrorInfoType) => ReactNode } interface State { hasError: boolean errorInfo: ErrorInfoType | null } export class ErrorBoundary extends Component { constructor(props: Props) { super(props) this.state = { hasError: false, errorInfo: null } } static getDerivedStateFromError(error: Error): Partial { const errorInfo = { error, timestamp: Date.now(), context: { url: window.location.href, userAgent: navigator.userAgent, platform: navigator.platform } } // Log error details immediately logger.error('ErrorBoundary: getDerivedStateFromError called', { errorName: error.name, errorMessage: error.message, errorStack: error.stack, url: errorInfo.context.url, timestamp: errorInfo.timestamp }) return { hasError: true, errorInfo } } async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise { logger.error('ErrorBoundary caught an error:', { errorName: error.name, errorMessage: error.message, errorStack: error.stack, componentStack: errorInfo.componentStack, errorInfo: JSON.stringify(errorInfo, null, 2) }) // Get app version if available let appVersion: string | undefined try { if (window?.api && ipcServices?.app) { appVersion = await ipcServices.app.getVersion() logger.info('ErrorBoundary: App version retrieved', { appVersion }) } } catch (err) { logger.warn('Failed to get app version:', err) } // Update state with component stack and version if (this.state.errorInfo) { this.setState({ errorInfo: { ...this.state.errorInfo, context: { ...this.state.errorInfo.context, version: appVersion }, errorInfo: { componentStack: errorInfo.componentStack || undefined } } }) } // Call optional error handler if (this.props.onError) { this.props.onError(error, errorInfo) } // Send error to main process if available if (window?.api) { try { window.api.send('error:renderer', { error: { name: error.name, message: error.message, stack: error.stack }, errorInfo: { componentStack: errorInfo.componentStack }, timestamp: Date.now(), context: { url: window.location.href, userAgent: navigator.userAgent, platform: navigator.platform, version: appVersion } }) } catch (err) { logger.error('Failed to send error to main process:', err) } } } handleReload = (): void => { this.setState({ hasError: false, errorInfo: null }) window.location.reload() } handleGoHome = (): void => { this.setState({ hasError: false, errorInfo: null }) window.location.hash = '/' window.location.reload() } render(): ReactNode { if (this.state.hasError && this.state.errorInfo) { if (this.props.fallback) { return this.props.fallback(this.state.errorInfo) } return ( ) } return this.props.children } } ================================================ FILE: apps/desktop/src/renderer/src/components/error/ErrorPage.tsx ================================================ import { Button } from '@renderer/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@renderer/components/ui/card' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Textarea } from '@renderer/components/ui/textarea' import { logger } from '@renderer/lib/logger' import { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' export interface ErrorInfo { error: Error errorInfo?: { componentStack?: string } timestamp: number context?: { url?: string userAgent?: string platform?: string version?: string } } interface ErrorPageProps { errorInfo: ErrorInfo onReload?: () => void onGoHome?: () => void } export function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) { const { t } = useTranslation() const [showDetails, setShowDetails] = useState(false) const [copied, setCopied] = useState(false) const errorReport = generateErrorReport(errorInfo) const handleCopy = async () => { try { await navigator.clipboard.writeText(errorReport) setCopied(true) toast.success(t('error.copySuccess')) setTimeout(() => setCopied(false), 2000) } catch (error) { logger.error('Failed to copy error report:', error) toast.error(t('error.copyFailed')) } } const handleReload = () => { if (onReload) { onReload() } else { window.location.reload() } } return (
{t('error.title')} {t('error.description')}
{/* Error Message */}

{t('error.message')}

{errorInfo.error.message || t('error.unknownError')}

{/* Actions */}
{onGoHome && ( )}
{/* Error Details */} {showDetails && (

{t('error.stackTrace')}

                    {errorInfo.error.stack || t('error.noStackTrace')}
                  
{errorInfo.errorInfo?.componentStack && (

{t('error.componentStack')}

                      {errorInfo.errorInfo.componentStack}
                    
)}

{t('error.fullReport')}