From d18ada4ee7334a540e154d78087461eb51f7d318 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:24:41 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .agents/skills/orpc-contract-first/SKILL.md | 46 + .agents/skills/release-pro-max/SKILL.md | 500 + .claude/CLAUDE.md | 123 + .cursor/hooks.json | 10 + .dockerignore | 10 + .editorconfig | 10 + .gitattributes | 4 + .github/ISSUE_TEMPLATE/bug_report.yml | 37 + .github/ISSUE_TEMPLATE/feature_request.yml | 38 + .github/workflows/build.yml | 201 + .github/workflows/ci.yml | 11 + .github/workflows/cli-publish.yml | 122 + .github/workflows/docker-publish.yml | 71 + .github/workflows/extension-build.yml | 47 + .github/workflows/extension-publish.yml | 84 + .github/workflows/release.yml | 73 + .github/workflows/translator.yaml | 26 + .github/workflows/ytdlp-auto-release.yml | 129 + .gitignore | 22 + .husky/pre-commit | 68 + .node-version | 1 + .npmrc | 4 + .vscode/extensions.json | 3 + .vscode/settings.json | 59 + AGENTS.md | 132 + CONTRIBUTING.md | 105 + LICENSE | 21 + README.md | 161 + README.wehub.md | 7 + apps/api/Dockerfile | 46 + apps/api/package.json | 34 + apps/api/scripts/ensure-node-sqlite.mjs | 36 + apps/api/scripts/migrate-history.ts | 361 + apps/api/src/index.ts | 28 + apps/api/src/lib/database-migrate.ts | 14 + apps/api/src/lib/downloader.ts | 19 + apps/api/src/lib/projection.ts | 58 + apps/api/src/lib/rpc-router.ts | 591 + apps/api/src/lib/sse.ts | 62 + apps/api/src/lib/subscriptions-host.ts | 161 + apps/api/src/lib/subscriptions-router.ts | 112 + apps/api/src/lib/task-queue-host.ts | 158 + apps/api/src/lib/web-settings-store.ts | 73 + apps/api/src/lib/yt-dlp-info.ts | 237 + apps/api/src/server.ts | 348 + apps/api/tsconfig.json | 12 + apps/cli/CHANGELOG.md | 29 + apps/cli/LICENSE | 21 + apps/cli/README.md | 201 + .../__integration__/local-equivalence.test.ts | 157 + apps/cli/dist/bin/vidbee.mjs | 5 + apps/cli/dist/index.mjs | 27548 ++++++++++++++++ apps/cli/package.json | 80 + apps/cli/scripts/build.mjs | 55 + apps/cli/src/bin.ts | 28 + apps/cli/src/download/build-input.ts | 191 + apps/cli/src/download/enqueue.ts | 75 + apps/cli/src/download/probe.ts | 161 + apps/cli/src/envelope/index.ts | 136 + apps/cli/src/index.ts | 36 + apps/cli/src/local-info/index.ts | 8 + apps/cli/src/local-info/upgrade.ts | 181 + apps/cli/src/local-info/version.ts | 72 + apps/cli/src/parser/index.ts | 296 + apps/cli/src/parser/probe-flags.ts | 87 + apps/cli/src/parser/redact.ts | 199 + apps/cli/src/parser/reserved-flags.ts | 58 + apps/cli/src/runtime.ts | 364 + apps/cli/src/subcommands/index.ts | 297 + apps/cli/src/transport/automation-client.ts | 312 + apps/cli/src/transport/autostart.ts | 130 + apps/cli/src/transport/connect.ts | 152 + apps/cli/src/transport/descriptor.ts | 173 + apps/cli/src/transport/index.ts | 101 + apps/cli/src/transport/local-client.ts | 191 + apps/cli/test/automation-client.test.ts | 117 + apps/cli/test/autostart.test.ts | 117 + apps/cli/test/build-input.test.ts | 90 + apps/cli/test/connect.test.ts | 86 + apps/cli/test/descriptor.test.ts | 115 + apps/cli/test/enqueue.test.ts | 110 + apps/cli/test/envelope.test.ts | 43 + apps/cli/test/local-info.test.ts | 156 + apps/cli/test/parse.test.ts | 295 + apps/cli/test/probe-flags.test.ts | 85 + apps/cli/test/probe.test.ts | 93 + apps/cli/test/redact.test.ts | 78 + apps/cli/test/runtime.test.ts | 480 + apps/cli/test/subcommands.test.ts | 52 + apps/cli/test/transport.test.ts | 87 + apps/cli/tsconfig.json | 14 + apps/cli/vitest.config.ts | 9 + apps/desktop/.env.example | 6 + apps/desktop/build/after-install.sh | 16 + apps/desktop/build/after-pack.cjs | 75 + apps/desktop/build/entitlements.mac.plist | 14 + apps/desktop/build/icon.icns | Bin 0 -> 325811 bytes apps/desktop/build/icon.ico | Bin 0 -> 10827 bytes apps/desktop/build/icon.png | Bin 0 -> 14607 bytes apps/desktop/changelogs/CHANGELOG.fr.md | 303 + apps/desktop/changelogs/CHANGELOG.md | 353 + apps/desktop/changelogs/CHANGELOG.ru.md | 309 + apps/desktop/changelogs/CHANGELOG.zh.md | 346 + apps/desktop/components.json | 21 + apps/desktop/dev-app-update.yml | 4 + apps/desktop/docs/glitchtip.md | 73 + apps/desktop/docs/sentry-fixes.md | 137 + apps/desktop/drizzle.config.ts | 7 + apps/desktop/electron-builder.yml | 77 + apps/desktop/electron.vite.config.ts | 92 + apps/desktop/package.json | 117 + apps/desktop/release-metadata.json | 3 + apps/desktop/resources/.gitignore | 18 + apps/desktop/resources/README.md | 91 + .../drizzle/0000_swift_aaron_stack.sql | 66 + .../drizzle/0001_smiling_agent_zero.sql | 1 + .../drizzle/0002_smooth_impossible_man.sql | 1 + .../resources/drizzle/meta/0000_snapshot.json | 451 + .../resources/drizzle/meta/0001_snapshot.json | 458 + .../resources/drizzle/meta/0002_snapshot.json | 465 + .../resources/drizzle/meta/_journal.json | 27 + apps/desktop/resources/icon.png | Bin 0 -> 14607 bytes apps/desktop/resources/tray-icon.png | Bin 0 -> 924 bytes apps/desktop/scripts/check-locales.js | 103 + apps/desktop/scripts/check-ytdlp.js | 102 + .../create-windows-portable-folder.ps1 | 234 + apps/desktop/scripts/dev-update-notice.mjs | 17 + apps/desktop/scripts/ensure-native-deps.mjs | 46 + apps/desktop/scripts/postinstall.mjs | 24 + apps/desktop/scripts/set-console-encoding.js | 28 + apps/desktop/scripts/setup-dev-binaries.js | 1070 + apps/desktop/scripts/stamp-release.mjs | 41 + .../scripts/upload-glitchtip-sourcemaps.mjs | 58 + apps/desktop/scripts/ytdlp-assets.js | 20 + apps/desktop/scripts/ytdlp-auto-release.mjs | 232 + apps/desktop/src/main/assets.d.ts | 36 + apps/desktop/src/main/config/logger-config.ts | 35 + .../src/main/download-engine/args-builder.ts | 56 + .../src/main/download-engine/format-utils.ts | 216 + apps/desktop/src/main/index.ts | 935 + apps/desktop/src/main/ipc/index.ts | 28 + .../src/main/ipc/services/app-service.ts | 103 + .../ipc/services/browser-cookies-service.ts | 274 + .../src/main/ipc/services/download-service.ts | 68 + .../main/ipc/services/file-system-service.ts | 467 + .../src/main/ipc/services/history-service.ts | 55 + .../src/main/ipc/services/settings-service.ts | 67 + .../src/main/ipc/services/site-icon-url.ts | 7 + .../main/ipc/services/subscription-service.ts | 128 + .../main/ipc/services/thumbnail-service.ts | 20 + .../src/main/ipc/services/update-service.ts | 111 + .../src/main/ipc/services/window-service.ts | 42 + .../src/main/lib/automation-descriptor.ts | 160 + .../src/main/lib/bounded-output-buffer.ts | 56 + .../src/main/lib/bundled-resources-path.ts | 43 + apps/desktop/src/main/lib/command-utils.ts | 44 + apps/desktop/src/main/lib/database-path.ts | 6 + apps/desktop/src/main/lib/database.ts | 40 + apps/desktop/src/main/lib/database/migrate.ts | 217 + apps/desktop/src/main/lib/database/schema.ts | 57 + apps/desktop/src/main/lib/download-facade.ts | 420 + apps/desktop/src/main/lib/ffmpeg-manager.ts | 112 + apps/desktop/src/main/lib/glitchtip.ts | 154 + apps/desktop/src/main/lib/history-manager.ts | 209 + apps/desktop/src/main/lib/projection.ts | 258 + apps/desktop/src/main/lib/settings-effects.ts | 57 + .../src/main/lib/startup-dependencies.ts | 30 + .../src/main/lib/subscriptions-host.ts | 273 + apps/desktop/src/main/lib/task-queue-host.ts | 137 + .../src/main/lib/task-queue-migrate.ts | 497 + apps/desktop/src/main/lib/thumbnail-cache.ts | 159 + apps/desktop/src/main/lib/update-channel.ts | 34 + .../src/main/lib/youtube-extractor-args.ts | 21 + apps/desktop/src/main/lib/yt-dlp-info.ts | 221 + apps/desktop/src/main/lib/ytdlp-manager.ts | 236 + apps/desktop/src/main/local-api.ts | 611 + apps/desktop/src/main/portable.ts | 137 + apps/desktop/src/main/settings.ts | 188 + apps/desktop/src/main/tray.ts | 195 + apps/desktop/src/main/utils/auto-launch.ts | 44 + apps/desktop/src/main/utils/dock.ts | 25 + apps/desktop/src/main/utils/logger.ts | 25 + apps/desktop/src/main/utils/path-helpers.ts | 19 + apps/desktop/src/preload/index.d.ts | 13 + apps/desktop/src/preload/index.ts | 45 + apps/desktop/src/renderer/index.html | 16 + apps/desktop/src/renderer/public/app-icon.png | Bin 0 -> 14607 bytes apps/desktop/src/renderer/src/App.tsx | 302 + .../src/renderer/src/assets/app-icon.png | Bin 0 -> 14607 bytes .../src/renderer/src/assets/global.css | 4 + apps/desktop/src/renderer/src/assets/main.css | 24 + .../desktop/src/renderer/src/assets/theme.css | 160 + .../src/renderer/src/assets/title-bar.css | 10 + .../src/components/changelog/Changelog.tsx | 110 + .../components/download/DownloadDialog.tsx | 1006 + .../src/components/download/DownloadItem.tsx | 1401 + .../components/download/PlaylistDownload.tsx | 248 + .../download/PlaylistDownloadGroup.tsx | 169 + .../download/SingleVideoDownload.tsx | 794 + .../download/UnifiedDownloadHistory.tsx | 621 + .../download/audio-format-preferences.ts | 41 + .../download/download-item-utils.ts | 16 + .../download/format-presentation.ts | 111 + .../src/components/error/ErrorBoundary.tsx | 164 + .../src/components/error/ErrorPage.tsx | 200 + .../src/components/feedback/FeedbackLinks.tsx | 59 + .../playlist/PlaylistPreviewCard.tsx | 94 + .../subscription/SubscriptionFormDialog.tsx | 338 + .../renderer/src/components/ui/accordion.tsx | 8 + .../src/components/ui/add-url-popover.tsx | 3 + .../src/renderer/src/components/ui/badge.tsx | 3 + .../src/renderer/src/components/ui/button.tsx | 3 + .../src/renderer/src/components/ui/card.tsx | 10 + .../renderer/src/components/ui/checkbox.tsx | 3 + .../src/components/ui/context-menu.tsx | 35 + .../src/renderer/src/components/ui/dialog.tsx | 25 + .../components/ui/download-dialog-layout.tsx | 3 + .../src/components/ui/dropdown-menu.tsx | 35 + .../renderer/src/components/ui/hover-card.tsx | 3 + .../components/ui/image-with-placeholder.tsx | 3 + .../src/renderer/src/components/ui/input.tsx | 3 + .../src/renderer/src/components/ui/item.tsx | 25 + .../src/renderer/src/components/ui/label.tsx | 3 + .../renderer/src/components/ui/popover.tsx | 8 + .../renderer/src/components/ui/progress.tsx | 3 + .../src/components/ui/radio-group.tsx | 3 + .../src/components/ui/remote-image.tsx | 30 + .../src/components/ui/scroll-area.tsx | 3 + .../src/renderer/src/components/ui/select.tsx | 25 + .../renderer/src/components/ui/separator.tsx | 3 + .../src/renderer/src/components/ui/sheet.tsx | 21 + .../renderer/src/components/ui/sidebar.tsx | 78 + .../src/renderer/src/components/ui/sonner.tsx | 3 + .../src/renderer/src/components/ui/switch.tsx | 3 + .../src/renderer/src/components/ui/table.tsx | 12 + .../src/renderer/src/components/ui/tabs.tsx | 3 + .../renderer/src/components/ui/textarea.tsx | 3 + .../renderer/src/components/ui/title-bar.tsx | 50 + .../renderer/src/components/ui/tooltip.tsx | 8 + .../update/UpdateOutdatedNotice.tsx | 111 + .../components/update/UpdateRestartNotice.tsx | 40 + .../src/components/video/AdvancedOptions.tsx | 90 + .../src/renderer/src/data/popularSites.ts | 26 + apps/desktop/src/renderer/src/env.d.ts | 15 + .../src/hooks/use-cached-thumbnail.ts | 49 + .../renderer/src/hooks/use-download-events.ts | 224 + .../renderer/src/hooks/use-history-sync.ts | 36 + .../src/renderer/src/hooks/use-ipc-example.ts | 129 + .../hooks/use-rybbit-daily-client-version.ts | 55 + .../renderer/src/hooks/use-rybbit-script.ts | 56 + apps/desktop/src/renderer/src/i18n.ts | 9 + .../src/lib/download-error-guidance.ts | 131 + .../src/renderer/src/lib/download-url.ts | 26 + .../renderer/src/lib/glitchtip-feedback.ts | 95 + .../desktop/src/renderer/src/lib/glitchtip.ts | 144 + apps/desktop/src/renderer/src/lib/ipc.ts | 45 + apps/desktop/src/renderer/src/lib/logger.ts | 20 + .../src/renderer/src/lib/rybbit-client.ts | 114 + .../renderer/src/lib/subscription-status.ts | 60 + apps/desktop/src/renderer/src/lib/url.ts | 23 + apps/desktop/src/renderer/src/lib/utils.ts | 6 + apps/desktop/src/renderer/src/main.tsx | 164 + apps/desktop/src/renderer/src/pages/About.tsx | 475 + apps/desktop/src/renderer/src/pages/Home.tsx | 35 + .../src/renderer/src/pages/Settings.tsx | 1078 + .../src/renderer/src/pages/Subscriptions.tsx | 688 + .../src/renderer/src/store/downloads.ts | 243 + .../src/renderer/src/store/settings.ts | 64 + .../src/renderer/src/store/subscriptions.ts | 79 + apps/desktop/src/renderer/src/store/update.ts | 21 + .../src/store/video-request-coordinator.ts | 26 + apps/desktop/src/renderer/src/store/video.ts | 64 + apps/desktop/src/shared/constants.ts | 2 + apps/desktop/src/shared/glitchtip-env.d.ts | 3 + .../src/shared/telemetry/issue-filter.ts | 476 + .../telemetry/yt-dlp-error-classifier.ts | 304 + apps/desktop/src/shared/types/index.ts | 369 + apps/desktop/src/shared/types/ipc.ts | 2 + .../desktop/src/shared/utils/download-file.ts | 67 + .../src/shared/utils/format-preferences.ts | 11 + .../src/shared/utils/video-info-metadata.ts | 129 + apps/desktop/tsconfig.json | 17 + apps/desktop/tsconfig.node.json | 21 + apps/desktop/tsconfig.web.json | 24 + apps/docs/.gitignore | 21 + apps/docs/.node-version | 1 + apps/docs/README.md | 28 + apps/docs/content/cookies.mdx | 99 + apps/docs/content/faq.mdx | 85 + apps/docs/content/formats.mdx | 33 + apps/docs/content/fr/cookies.mdx | 99 + apps/docs/content/fr/faq.mdx | 38 + apps/docs/content/fr/formats.mdx | 33 + apps/docs/content/fr/index.mdx | 22 + apps/docs/content/fr/meta.json | 14 + apps/docs/content/fr/protocol.mdx | 149 + apps/docs/content/fr/rss.mdx | 54 + apps/docs/content/index.mdx | 22 + apps/docs/content/meta.json | 14 + apps/docs/content/protocol.mdx | 149 + apps/docs/content/rss.mdx | 54 + apps/docs/content/ru/cookies.mdx | 99 + apps/docs/content/ru/faq.mdx | 38 + apps/docs/content/ru/formats.mdx | 33 + apps/docs/content/ru/index.mdx | 22 + apps/docs/content/ru/meta.json | 14 + apps/docs/content/ru/protocol.mdx | 149 + apps/docs/content/ru/rss.mdx | 54 + apps/docs/content/zh/cookies.mdx | 99 + apps/docs/content/zh/faq.mdx | 38 + apps/docs/content/zh/formats.mdx | 33 + apps/docs/content/zh/index.mdx | 22 + apps/docs/content/zh/meta.json | 14 + apps/docs/content/zh/protocol.mdx | 149 + apps/docs/content/zh/rss.mdx | 54 + apps/docs/package.json | 34 + apps/docs/press.config.tsx | 247 + apps/docs/public/ICONS.md | 101 + apps/docs/public/apple-touch-icon.png | Bin 0 -> 10115 bytes apps/docs/public/browser-cookies.png | Bin 0 -> 1953489 bytes apps/docs/public/cookies-file.png | Bin 0 -> 216381 bytes .../public/cookies/cookies-error-sign-in.png | Bin 0 -> 119532 bytes apps/docs/public/cookies/cookies-file.png | Bin 0 -> 899084 bytes .../cookies-settings-download-success.png | Bin 0 -> 374420 bytes .../cookies/cookies-settings-firefox.png | Bin 0 -> 279804 bytes .../cookies-settings-select-browser.png | Bin 0 -> 207976 bytes apps/docs/public/favicon.png | Bin 0 -> 1830 bytes apps/docs/public/icon-128.png | Bin 0 -> 7022 bytes apps/docs/public/icon-16.png | Bin 0 -> 924 bytes apps/docs/public/icon-192.png | Bin 0 -> 10812 bytes apps/docs/public/icon-256.png | Bin 0 -> 14770 bytes apps/docs/public/icon-32.png | Bin 0 -> 1830 bytes apps/docs/public/icon-48.png | Bin 0 -> 2693 bytes apps/docs/public/icon-512.png | Bin 0 -> 14607 bytes apps/docs/public/icon-64.png | Bin 0 -> 3499 bytes apps/docs/public/icon-original.png | Bin 0 -> 14607 bytes apps/docs/public/icon.png | Bin 0 -> 14607 bytes apps/docs/public/robots.txt | 3 + apps/docs/public/rss/rss-add-dialog.png | Bin 0 -> 256302 bytes apps/docs/public/rss/rss-overview.png | Bin 0 -> 321672 bytes apps/docs/scripts/post-build.mjs | 82 + apps/docs/source.config.ts | 17 + apps/docs/src/app.css | 4 + apps/docs/tsconfig.json | 19 + apps/docs/waku.config.ts | 11 + apps/extension/.gitignore | 29 + apps/extension/README.md | 54 + apps/extension/assets/content.css | 166 + apps/extension/entrypoints/background.ts | 207 + apps/extension/entrypoints/popup/App.css | 341 + apps/extension/entrypoints/popup/App.tsx | 500 + apps/extension/entrypoints/popup/index.html | 13 + apps/extension/entrypoints/popup/main.tsx | 13 + apps/extension/package.json | 28 + .../public/_locales/en/messages.json | 14 + apps/extension/public/icon/128.png | Bin 0 -> 7022 bytes apps/extension/public/icon/16.png | Bin 0 -> 924 bytes apps/extension/public/icon/32.png | Bin 0 -> 1830 bytes apps/extension/public/icon/48.png | Bin 0 -> 2693 bytes .../public/icon/icon-loading-128.png | Bin 0 -> 7416 bytes .../extension/public/icon/icon-loading-16.png | Bin 0 -> 744 bytes .../extension/public/icon/icon-loading-32.png | Bin 0 -> 1777 bytes .../extension/public/icon/icon-loading-48.png | Bin 0 -> 2696 bytes .../public/icon/icon-success-128.png | Bin 0 -> 7443 bytes .../extension/public/icon/icon-success-16.png | Bin 0 -> 751 bytes .../extension/public/icon/icon-success-32.png | Bin 0 -> 1780 bytes .../extension/public/icon/icon-success-48.png | Bin 0 -> 2707 bytes apps/extension/tsconfig.json | 8 + apps/extension/wxt.config.ts | 13 + apps/web/Dockerfile | 28 + apps/web/README.md | 17 + apps/web/biome.json | 35 + apps/web/package.json | 59 + apps/web/public/app-icon.png | Bin 0 -> 13739 bytes apps/web/public/favicon.ico | Bin 0 -> 7861 bytes apps/web/public/logo192.png | Bin 0 -> 11387 bytes apps/web/public/logo512.png | Bin 0 -> 13739 bytes apps/web/public/manifest.json | 25 + apps/web/public/robots.txt | 3 + apps/web/public/tanstack-circle-logo.png | Bin 0 -> 265387 bytes apps/web/public/tanstack-word-logo-white.svg | 1 + .../components/download/download-dialog.tsx | 777 + .../src/components/download/download-item.tsx | 1219 + .../download/playlist-download-group.tsx | 200 + .../components/download/playlist-download.tsx | 280 + .../download/single-video-download.tsx | 905 + apps/web/src/components/download/types.ts | 7 + apps/web/src/components/layout/app-shell.tsx | 95 + apps/web/src/components/pages/about-page.tsx | 466 + .../src/components/pages/download-page.tsx | 751 + .../src/components/pages/settings-page.tsx | 1222 + apps/web/src/env.d.ts | 1 + .../src/hooks/use-web-download-settings.ts | 34 + apps/web/src/hooks/use-web-settings.ts | 109 + apps/web/src/lib/add-url-shortcut.test.ts | 74 + .../src/lib/download-format-preferences.ts | 37 + apps/web/src/lib/i18n.ts | 9 + apps/web/src/lib/orpc-client.ts | 24 + apps/web/src/lib/orpc-download-settings.ts | 17 + apps/web/src/lib/remote-image-proxy.ts | 15 + apps/web/src/lib/web-settings.ts | 227 + apps/web/src/routeTree.gen.ts | 104 + apps/web/src/router.tsx | 20 + apps/web/src/routes/__root.tsx | 70 + apps/web/src/routes/about.tsx | 6 + apps/web/src/routes/index.tsx | 6 + apps/web/src/routes/settings.tsx | 6 + apps/web/src/styles.css | 5 + apps/web/tsconfig.json | 29 + apps/web/vite.config.ts | 69 + biome.json | 80 + conductor.json | 6 + docker-compose.yml | 44 + .../tests/after-pack-resources-path.test.cjs | 22 + .../tests/setup-dev-binaries-timeout.test.mjs | 28 + package.json | 87 + packages/db/package.json | 29 + packages/db/src/history.ts | 35 + packages/db/src/subscriptions.ts | 158 + packages/db/src/task-queue.ts | 167 + packages/db/tsconfig.json | 11 + packages/downloader-core/package.json | 27 + .../src/browser-cookies-setting.ts | 31 + packages/downloader-core/src/contract.ts | 68 + packages/downloader-core/src/download-file.ts | 54 + .../downloader-core/src/downloader-core.ts | 1064 + .../downloader-core/src/format-preferences.ts | 92 + packages/downloader-core/src/index.ts | 58 + packages/downloader-core/src/schemas.ts | 362 + packages/downloader-core/src/types.ts | 262 + packages/downloader-core/src/yt-dlp-args.ts | 464 + .../downloader-core/src/yt-dlp-executor.ts | 663 + packages/downloader-core/tsconfig.json | 12 + packages/i18n/package.json | 22 + packages/i18n/src/index.ts | 11 + packages/i18n/src/init.ts | 37 + packages/i18n/src/languages.ts | 110 + packages/i18n/src/locales/ar.json | 712 + packages/i18n/src/locales/de.json | 712 + packages/i18n/src/locales/en.json | 712 + packages/i18n/src/locales/es.json | 712 + packages/i18n/src/locales/fr.json | 712 + packages/i18n/src/locales/id.json | 712 + packages/i18n/src/locales/it.json | 712 + packages/i18n/src/locales/ja.json | 712 + packages/i18n/src/locales/ko.json | 712 + packages/i18n/src/locales/pt.json | 712 + packages/i18n/src/locales/ru.json | 712 + packages/i18n/src/locales/tr.json | 712 + packages/i18n/src/locales/zh-TW.json | 712 + packages/i18n/src/locales/zh.json | 712 + packages/i18n/src/resources.ts | 44 + packages/i18n/tsconfig.json | 12 + packages/subscriptions-core/package.json | 43 + packages/subscriptions-core/src/api.ts | 373 + .../subscriptions-core/src/auto-download.ts | 112 + packages/subscriptions-core/src/contract.ts | 39 + packages/subscriptions-core/src/feed-items.ts | 189 + .../subscriptions-core/src/feed-parser.ts | 62 + .../subscriptions-core/src/feed-resolver.ts | 111 + packages/subscriptions-core/src/index.ts | 38 + packages/subscriptions-core/src/leader.ts | 295 + packages/subscriptions-core/src/scheduler.ts | 182 + packages/subscriptions-core/src/schemas.ts | 127 + packages/subscriptions-core/src/store.ts | 531 + packages/subscriptions-core/src/types.ts | 180 + packages/subscriptions-core/test/api.test.ts | 270 + .../test/auto-download.test.ts | 105 + .../subscriptions-core/test/contract.test.ts | 69 + .../test/feed-items.test.ts | 76 + .../test/feed-resolver.test.ts | 71 + .../subscriptions-core/test/leader.test.ts | 167 + .../subscriptions-core/test/scheduler.test.ts | 114 + packages/subscriptions-core/tsconfig.json | 14 + packages/subscriptions-core/vitest.config.ts | 9 + .../three-host-equivalence.test.ts | 414 + packages/task-queue/package.json | 38 + packages/task-queue/src/api/index.ts | 833 + packages/task-queue/src/classifier/index.ts | 311 + packages/task-queue/src/contract.ts | 40 + packages/task-queue/src/events/index.ts | 125 + packages/task-queue/src/executor/index.ts | 102 + packages/task-queue/src/fsm/index.ts | 204 + packages/task-queue/src/index.ts | 111 + packages/task-queue/src/persist/adapter.ts | 86 + packages/task-queue/src/persist/index.ts | 10 + packages/task-queue/src/persist/memory.ts | 132 + packages/task-queue/src/persist/sqlite.ts | 454 + packages/task-queue/src/process/index.ts | 11 + .../src/process/kill-process-tree.ts | 34 + .../task-queue/src/process/pid-start-time.ts | 111 + packages/task-queue/src/process/registry.ts | 199 + packages/task-queue/src/process/watchdog.ts | 117 + packages/task-queue/src/projection/index.ts | 290 + packages/task-queue/src/scheduler/index.ts | 7 + .../src/scheduler/retry-scheduler.ts | 138 + .../task-queue/src/scheduler/scheduler.ts | 270 + packages/task-queue/src/schemas.ts | 159 + packages/task-queue/src/store/index.ts | 162 + packages/task-queue/src/types.ts | 190 + packages/task-queue/src/util/async-mutex.ts | 37 + packages/task-queue/src/util/min-heap.ts | 85 + packages/task-queue/test/_fixtures.ts | 41 + packages/task-queue/test/classifier.test.ts | 141 + packages/task-queue/test/contract.test.ts | 93 + packages/task-queue/test/fsm.test.ts | 152 + packages/task-queue/test/orchestrator.test.ts | 376 + .../task-queue/test/process-registry.test.ts | 77 + packages/task-queue/test/projection.test.ts | 236 + .../task-queue/test/retry-scheduler.test.ts | 94 + packages/task-queue/test/scheduler.test.ts | 162 + packages/task-queue/test/store.test.ts | 63 + packages/task-queue/tsconfig.json | 14 + packages/task-queue/vitest.config.ts | 9 + packages/ui/package.json | 51 + packages/ui/src/base.css | 39 + packages/ui/src/components/ui/accordion.tsx | 61 + .../ui/src/components/ui/add-url-popover.tsx | 77 + .../src/components/ui/app-sidebar-icons.tsx | 52 + packages/ui/src/components/ui/app-sidebar.tsx | 106 + packages/ui/src/components/ui/badge.tsx | 36 + packages/ui/src/components/ui/button.tsx | 48 + packages/ui/src/components/ui/card.tsx | 54 + packages/ui/src/components/ui/checkbox.tsx | 25 + .../ui/src/components/ui/context-menu.tsx | 221 + packages/ui/src/components/ui/dialog.tsx | 126 + .../components/ui/download-dialog-layout.tsx | 106 + .../components/ui/download-empty-state.tsx | 21 + .../src/components/ui/download-filter-bar.tsx | 57 + .../ui/src/components/ui/dropdown-menu.tsx | 225 + .../components/ui/feedback-link-buttons.tsx | 240 + packages/ui/src/components/ui/hover-card.tsx | 35 + .../components/ui/image-with-placeholder.tsx | 70 + packages/ui/src/components/ui/input.tsx | 21 + packages/ui/src/components/ui/item.tsx | 182 + packages/ui/src/components/ui/label.tsx | 18 + packages/ui/src/components/ui/popover.tsx | 39 + packages/ui/src/components/ui/progress.tsx | 25 + packages/ui/src/components/ui/radio-group.tsx | 42 + .../ui/src/components/ui/remote-image.tsx | 152 + packages/ui/src/components/ui/scroll-area.tsx | 53 + packages/ui/src/components/ui/select.tsx | 149 + packages/ui/src/components/ui/separator.tsx | 25 + packages/ui/src/components/ui/sheet.tsx | 127 + packages/ui/src/components/ui/sidebar.tsx | 201 + packages/ui/src/components/ui/sonner.tsx | 27 + packages/ui/src/components/ui/switch.tsx | 26 + packages/ui/src/components/ui/table.tsx | 89 + packages/ui/src/components/ui/tabs.tsx | 53 + packages/ui/src/components/ui/textarea.tsx | 17 + packages/ui/src/components/ui/title-bar.tsx | 81 + packages/ui/src/components/ui/tooltip.tsx | 56 + packages/ui/src/lib/cn.ts | 6 + packages/ui/src/lib/url-kind.ts | 39 + .../ui/src/lib/use-add-url-interaction.ts | 125 + packages/ui/src/lib/use-add-url-shortcut.ts | 82 + packages/ui/src/lib/use-mount-effect.ts | 11 + packages/ui/src/theme.css | 160 + packages/ui/tsconfig.json | 13 + pnpm-lock.yaml | 17705 ++++++++++ pnpm-workspace.yaml | 11 + screenshots/browser-script.png | Bin 0 -> 448764 bytes screenshots/download-queue.png | Bin 0 -> 1473792 bytes screenshots/main-interface.png | Bin 0 -> 1553696 bytes scripts/Formula/vidbee.rb | 40 + scripts/cli-install.sh | 195 + skills-lock.json | 15 + 567 files changed, 118275 insertions(+) create mode 100644 .agents/skills/orpc-contract-first/SKILL.md create mode 100644 .agents/skills/release-pro-max/SKILL.md create mode 100644 .claude/CLAUDE.md create mode 100644 .cursor/hooks.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/cli-publish.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/extension-build.yml create mode 100644 .github/workflows/extension-publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/translator.yaml create mode 100644 .github/workflows/ytdlp-auto-release.yml create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .node-version create mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/package.json create mode 100755 apps/api/scripts/ensure-node-sqlite.mjs create mode 100644 apps/api/scripts/migrate-history.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/lib/database-migrate.ts create mode 100644 apps/api/src/lib/downloader.ts create mode 100644 apps/api/src/lib/projection.ts create mode 100644 apps/api/src/lib/rpc-router.ts create mode 100644 apps/api/src/lib/sse.ts create mode 100644 apps/api/src/lib/subscriptions-host.ts create mode 100644 apps/api/src/lib/subscriptions-router.ts create mode 100644 apps/api/src/lib/task-queue-host.ts create mode 100644 apps/api/src/lib/web-settings-store.ts create mode 100644 apps/api/src/lib/yt-dlp-info.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/cli/CHANGELOG.md create mode 100644 apps/cli/LICENSE create mode 100644 apps/cli/README.md create mode 100644 apps/cli/__integration__/local-equivalence.test.ts create mode 100755 apps/cli/dist/bin/vidbee.mjs create mode 100644 apps/cli/dist/index.mjs create mode 100644 apps/cli/package.json create mode 100644 apps/cli/scripts/build.mjs create mode 100755 apps/cli/src/bin.ts create mode 100644 apps/cli/src/download/build-input.ts create mode 100644 apps/cli/src/download/enqueue.ts create mode 100644 apps/cli/src/download/probe.ts create mode 100644 apps/cli/src/envelope/index.ts create mode 100644 apps/cli/src/index.ts create mode 100644 apps/cli/src/local-info/index.ts create mode 100644 apps/cli/src/local-info/upgrade.ts create mode 100644 apps/cli/src/local-info/version.ts create mode 100644 apps/cli/src/parser/index.ts create mode 100644 apps/cli/src/parser/probe-flags.ts create mode 100644 apps/cli/src/parser/redact.ts create mode 100644 apps/cli/src/parser/reserved-flags.ts create mode 100644 apps/cli/src/runtime.ts create mode 100644 apps/cli/src/subcommands/index.ts create mode 100644 apps/cli/src/transport/automation-client.ts create mode 100644 apps/cli/src/transport/autostart.ts create mode 100644 apps/cli/src/transport/connect.ts create mode 100644 apps/cli/src/transport/descriptor.ts create mode 100644 apps/cli/src/transport/index.ts create mode 100644 apps/cli/src/transport/local-client.ts create mode 100644 apps/cli/test/automation-client.test.ts create mode 100644 apps/cli/test/autostart.test.ts create mode 100644 apps/cli/test/build-input.test.ts create mode 100644 apps/cli/test/connect.test.ts create mode 100644 apps/cli/test/descriptor.test.ts create mode 100644 apps/cli/test/enqueue.test.ts create mode 100644 apps/cli/test/envelope.test.ts create mode 100644 apps/cli/test/local-info.test.ts create mode 100644 apps/cli/test/parse.test.ts create mode 100644 apps/cli/test/probe-flags.test.ts create mode 100644 apps/cli/test/probe.test.ts create mode 100644 apps/cli/test/redact.test.ts create mode 100644 apps/cli/test/runtime.test.ts create mode 100644 apps/cli/test/subcommands.test.ts create mode 100644 apps/cli/test/transport.test.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/cli/vitest.config.ts create mode 100644 apps/desktop/.env.example create mode 100755 apps/desktop/build/after-install.sh create mode 100644 apps/desktop/build/after-pack.cjs create mode 100644 apps/desktop/build/entitlements.mac.plist create mode 100644 apps/desktop/build/icon.icns create mode 100644 apps/desktop/build/icon.ico create mode 100644 apps/desktop/build/icon.png create mode 100644 apps/desktop/changelogs/CHANGELOG.fr.md create mode 100644 apps/desktop/changelogs/CHANGELOG.md create mode 100644 apps/desktop/changelogs/CHANGELOG.ru.md create mode 100644 apps/desktop/changelogs/CHANGELOG.zh.md create mode 100644 apps/desktop/components.json create mode 100644 apps/desktop/dev-app-update.yml create mode 100644 apps/desktop/docs/glitchtip.md create mode 100644 apps/desktop/docs/sentry-fixes.md create mode 100644 apps/desktop/drizzle.config.ts create mode 100644 apps/desktop/electron-builder.yml create mode 100644 apps/desktop/electron.vite.config.ts create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/release-metadata.json create mode 100644 apps/desktop/resources/.gitignore create mode 100644 apps/desktop/resources/README.md create mode 100644 apps/desktop/resources/drizzle/0000_swift_aaron_stack.sql create mode 100644 apps/desktop/resources/drizzle/0001_smiling_agent_zero.sql create mode 100644 apps/desktop/resources/drizzle/0002_smooth_impossible_man.sql create mode 100644 apps/desktop/resources/drizzle/meta/0000_snapshot.json create mode 100644 apps/desktop/resources/drizzle/meta/0001_snapshot.json create mode 100644 apps/desktop/resources/drizzle/meta/0002_snapshot.json create mode 100644 apps/desktop/resources/drizzle/meta/_journal.json create mode 100644 apps/desktop/resources/icon.png create mode 100644 apps/desktop/resources/tray-icon.png create mode 100644 apps/desktop/scripts/check-locales.js create mode 100644 apps/desktop/scripts/check-ytdlp.js create mode 100644 apps/desktop/scripts/create-windows-portable-folder.ps1 create mode 100644 apps/desktop/scripts/dev-update-notice.mjs create mode 100644 apps/desktop/scripts/ensure-native-deps.mjs create mode 100644 apps/desktop/scripts/postinstall.mjs create mode 100644 apps/desktop/scripts/set-console-encoding.js create mode 100755 apps/desktop/scripts/setup-dev-binaries.js create mode 100644 apps/desktop/scripts/stamp-release.mjs create mode 100644 apps/desktop/scripts/upload-glitchtip-sourcemaps.mjs create mode 100644 apps/desktop/scripts/ytdlp-assets.js create mode 100644 apps/desktop/scripts/ytdlp-auto-release.mjs create mode 100644 apps/desktop/src/main/assets.d.ts create mode 100644 apps/desktop/src/main/config/logger-config.ts create mode 100644 apps/desktop/src/main/download-engine/args-builder.ts create mode 100644 apps/desktop/src/main/download-engine/format-utils.ts create mode 100644 apps/desktop/src/main/index.ts create mode 100644 apps/desktop/src/main/ipc/index.ts create mode 100644 apps/desktop/src/main/ipc/services/app-service.ts create mode 100644 apps/desktop/src/main/ipc/services/browser-cookies-service.ts create mode 100644 apps/desktop/src/main/ipc/services/download-service.ts create mode 100644 apps/desktop/src/main/ipc/services/file-system-service.ts create mode 100644 apps/desktop/src/main/ipc/services/history-service.ts create mode 100644 apps/desktop/src/main/ipc/services/settings-service.ts create mode 100644 apps/desktop/src/main/ipc/services/site-icon-url.ts create mode 100644 apps/desktop/src/main/ipc/services/subscription-service.ts create mode 100644 apps/desktop/src/main/ipc/services/thumbnail-service.ts create mode 100644 apps/desktop/src/main/ipc/services/update-service.ts create mode 100644 apps/desktop/src/main/ipc/services/window-service.ts create mode 100644 apps/desktop/src/main/lib/automation-descriptor.ts create mode 100644 apps/desktop/src/main/lib/bounded-output-buffer.ts create mode 100644 apps/desktop/src/main/lib/bundled-resources-path.ts create mode 100644 apps/desktop/src/main/lib/command-utils.ts create mode 100644 apps/desktop/src/main/lib/database-path.ts create mode 100644 apps/desktop/src/main/lib/database.ts create mode 100644 apps/desktop/src/main/lib/database/migrate.ts create mode 100644 apps/desktop/src/main/lib/database/schema.ts create mode 100644 apps/desktop/src/main/lib/download-facade.ts create mode 100644 apps/desktop/src/main/lib/ffmpeg-manager.ts create mode 100644 apps/desktop/src/main/lib/glitchtip.ts create mode 100644 apps/desktop/src/main/lib/history-manager.ts create mode 100644 apps/desktop/src/main/lib/projection.ts create mode 100644 apps/desktop/src/main/lib/settings-effects.ts create mode 100644 apps/desktop/src/main/lib/startup-dependencies.ts create mode 100644 apps/desktop/src/main/lib/subscriptions-host.ts create mode 100644 apps/desktop/src/main/lib/task-queue-host.ts create mode 100644 apps/desktop/src/main/lib/task-queue-migrate.ts create mode 100644 apps/desktop/src/main/lib/thumbnail-cache.ts create mode 100644 apps/desktop/src/main/lib/update-channel.ts create mode 100644 apps/desktop/src/main/lib/youtube-extractor-args.ts create mode 100644 apps/desktop/src/main/lib/yt-dlp-info.ts create mode 100644 apps/desktop/src/main/lib/ytdlp-manager.ts create mode 100644 apps/desktop/src/main/local-api.ts create mode 100644 apps/desktop/src/main/portable.ts create mode 100644 apps/desktop/src/main/settings.ts create mode 100644 apps/desktop/src/main/tray.ts create mode 100644 apps/desktop/src/main/utils/auto-launch.ts create mode 100644 apps/desktop/src/main/utils/dock.ts create mode 100644 apps/desktop/src/main/utils/logger.ts create mode 100644 apps/desktop/src/main/utils/path-helpers.ts create mode 100644 apps/desktop/src/preload/index.d.ts create mode 100644 apps/desktop/src/preload/index.ts create mode 100644 apps/desktop/src/renderer/index.html create mode 100644 apps/desktop/src/renderer/public/app-icon.png create mode 100644 apps/desktop/src/renderer/src/App.tsx create mode 100644 apps/desktop/src/renderer/src/assets/app-icon.png create mode 100644 apps/desktop/src/renderer/src/assets/global.css create mode 100644 apps/desktop/src/renderer/src/assets/main.css create mode 100644 apps/desktop/src/renderer/src/assets/theme.css create mode 100644 apps/desktop/src/renderer/src/assets/title-bar.css create mode 100644 apps/desktop/src/renderer/src/components/changelog/Changelog.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/DownloadDialog.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/DownloadItem.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx create mode 100644 apps/desktop/src/renderer/src/components/download/audio-format-preferences.ts create mode 100644 apps/desktop/src/renderer/src/components/download/download-item-utils.ts create mode 100644 apps/desktop/src/renderer/src/components/download/format-presentation.ts create mode 100644 apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx create mode 100644 apps/desktop/src/renderer/src/components/error/ErrorPage.tsx create mode 100644 apps/desktop/src/renderer/src/components/feedback/FeedbackLinks.tsx create mode 100644 apps/desktop/src/renderer/src/components/playlist/PlaylistPreviewCard.tsx create mode 100644 apps/desktop/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/accordion.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/add-url-popover.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/badge.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/button.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/card.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/checkbox.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/context-menu.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/dialog.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/download-dialog-layout.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/dropdown-menu.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/hover-card.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/image-with-placeholder.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/input.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/item.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/label.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/popover.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/progress.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/radio-group.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/remote-image.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/scroll-area.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/select.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/separator.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/sheet.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/sidebar.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/sonner.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/switch.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/table.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tabs.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/textarea.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/title-bar.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tooltip.tsx create mode 100644 apps/desktop/src/renderer/src/components/update/UpdateOutdatedNotice.tsx create mode 100644 apps/desktop/src/renderer/src/components/update/UpdateRestartNotice.tsx create mode 100644 apps/desktop/src/renderer/src/components/video/AdvancedOptions.tsx create mode 100644 apps/desktop/src/renderer/src/data/popularSites.ts create mode 100644 apps/desktop/src/renderer/src/env.d.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-cached-thumbnail.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-download-events.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-history-sync.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-ipc-example.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-rybbit-daily-client-version.ts create mode 100644 apps/desktop/src/renderer/src/hooks/use-rybbit-script.ts create mode 100644 apps/desktop/src/renderer/src/i18n.ts create mode 100644 apps/desktop/src/renderer/src/lib/download-error-guidance.ts create mode 100644 apps/desktop/src/renderer/src/lib/download-url.ts create mode 100644 apps/desktop/src/renderer/src/lib/glitchtip-feedback.ts create mode 100644 apps/desktop/src/renderer/src/lib/glitchtip.ts create mode 100644 apps/desktop/src/renderer/src/lib/ipc.ts create mode 100644 apps/desktop/src/renderer/src/lib/logger.ts create mode 100644 apps/desktop/src/renderer/src/lib/rybbit-client.ts create mode 100644 apps/desktop/src/renderer/src/lib/subscription-status.ts create mode 100644 apps/desktop/src/renderer/src/lib/url.ts create mode 100644 apps/desktop/src/renderer/src/lib/utils.ts create mode 100644 apps/desktop/src/renderer/src/main.tsx create mode 100644 apps/desktop/src/renderer/src/pages/About.tsx create mode 100644 apps/desktop/src/renderer/src/pages/Home.tsx create mode 100644 apps/desktop/src/renderer/src/pages/Settings.tsx create mode 100644 apps/desktop/src/renderer/src/pages/Subscriptions.tsx create mode 100644 apps/desktop/src/renderer/src/store/downloads.ts create mode 100644 apps/desktop/src/renderer/src/store/settings.ts create mode 100644 apps/desktop/src/renderer/src/store/subscriptions.ts create mode 100644 apps/desktop/src/renderer/src/store/update.ts create mode 100644 apps/desktop/src/renderer/src/store/video-request-coordinator.ts create mode 100644 apps/desktop/src/renderer/src/store/video.ts create mode 100644 apps/desktop/src/shared/constants.ts create mode 100644 apps/desktop/src/shared/glitchtip-env.d.ts create mode 100644 apps/desktop/src/shared/telemetry/issue-filter.ts create mode 100644 apps/desktop/src/shared/telemetry/yt-dlp-error-classifier.ts create mode 100644 apps/desktop/src/shared/types/index.ts create mode 100644 apps/desktop/src/shared/types/ipc.ts create mode 100644 apps/desktop/src/shared/utils/download-file.ts create mode 100644 apps/desktop/src/shared/utils/format-preferences.ts create mode 100644 apps/desktop/src/shared/utils/video-info-metadata.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/tsconfig.node.json create mode 100644 apps/desktop/tsconfig.web.json create mode 100644 apps/docs/.gitignore create mode 100644 apps/docs/.node-version create mode 100644 apps/docs/README.md create mode 100644 apps/docs/content/cookies.mdx create mode 100644 apps/docs/content/faq.mdx create mode 100644 apps/docs/content/formats.mdx create mode 100644 apps/docs/content/fr/cookies.mdx create mode 100644 apps/docs/content/fr/faq.mdx create mode 100644 apps/docs/content/fr/formats.mdx create mode 100644 apps/docs/content/fr/index.mdx create mode 100644 apps/docs/content/fr/meta.json create mode 100644 apps/docs/content/fr/protocol.mdx create mode 100644 apps/docs/content/fr/rss.mdx create mode 100644 apps/docs/content/index.mdx create mode 100644 apps/docs/content/meta.json create mode 100644 apps/docs/content/protocol.mdx create mode 100644 apps/docs/content/rss.mdx create mode 100644 apps/docs/content/ru/cookies.mdx create mode 100644 apps/docs/content/ru/faq.mdx create mode 100644 apps/docs/content/ru/formats.mdx create mode 100644 apps/docs/content/ru/index.mdx create mode 100644 apps/docs/content/ru/meta.json create mode 100644 apps/docs/content/ru/protocol.mdx create mode 100644 apps/docs/content/ru/rss.mdx create mode 100644 apps/docs/content/zh/cookies.mdx create mode 100644 apps/docs/content/zh/faq.mdx create mode 100644 apps/docs/content/zh/formats.mdx create mode 100644 apps/docs/content/zh/index.mdx create mode 100644 apps/docs/content/zh/meta.json create mode 100644 apps/docs/content/zh/protocol.mdx create mode 100644 apps/docs/content/zh/rss.mdx create mode 100644 apps/docs/package.json create mode 100644 apps/docs/press.config.tsx create mode 100644 apps/docs/public/ICONS.md create mode 100644 apps/docs/public/apple-touch-icon.png create mode 100644 apps/docs/public/browser-cookies.png create mode 100644 apps/docs/public/cookies-file.png create mode 100644 apps/docs/public/cookies/cookies-error-sign-in.png create mode 100644 apps/docs/public/cookies/cookies-file.png create mode 100644 apps/docs/public/cookies/cookies-settings-download-success.png create mode 100644 apps/docs/public/cookies/cookies-settings-firefox.png create mode 100644 apps/docs/public/cookies/cookies-settings-select-browser.png create mode 100644 apps/docs/public/favicon.png create mode 100644 apps/docs/public/icon-128.png create mode 100644 apps/docs/public/icon-16.png create mode 100644 apps/docs/public/icon-192.png create mode 100644 apps/docs/public/icon-256.png create mode 100644 apps/docs/public/icon-32.png create mode 100644 apps/docs/public/icon-48.png create mode 100644 apps/docs/public/icon-512.png create mode 100644 apps/docs/public/icon-64.png create mode 100644 apps/docs/public/icon-original.png create mode 100644 apps/docs/public/icon.png create mode 100644 apps/docs/public/robots.txt create mode 100644 apps/docs/public/rss/rss-add-dialog.png create mode 100644 apps/docs/public/rss/rss-overview.png create mode 100644 apps/docs/scripts/post-build.mjs create mode 100644 apps/docs/source.config.ts create mode 100644 apps/docs/src/app.css create mode 100644 apps/docs/tsconfig.json create mode 100644 apps/docs/waku.config.ts create mode 100644 apps/extension/.gitignore create mode 100644 apps/extension/README.md create mode 100644 apps/extension/assets/content.css create mode 100644 apps/extension/entrypoints/background.ts create mode 100644 apps/extension/entrypoints/popup/App.css create mode 100644 apps/extension/entrypoints/popup/App.tsx create mode 100644 apps/extension/entrypoints/popup/index.html create mode 100644 apps/extension/entrypoints/popup/main.tsx create mode 100644 apps/extension/package.json create mode 100644 apps/extension/public/_locales/en/messages.json create mode 100644 apps/extension/public/icon/128.png create mode 100644 apps/extension/public/icon/16.png create mode 100644 apps/extension/public/icon/32.png create mode 100644 apps/extension/public/icon/48.png create mode 100644 apps/extension/public/icon/icon-loading-128.png create mode 100644 apps/extension/public/icon/icon-loading-16.png create mode 100644 apps/extension/public/icon/icon-loading-32.png create mode 100644 apps/extension/public/icon/icon-loading-48.png create mode 100644 apps/extension/public/icon/icon-success-128.png create mode 100644 apps/extension/public/icon/icon-success-16.png create mode 100644 apps/extension/public/icon/icon-success-32.png create mode 100644 apps/extension/public/icon/icon-success-48.png create mode 100644 apps/extension/tsconfig.json create mode 100644 apps/extension/wxt.config.ts create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/README.md create mode 100644 apps/web/biome.json create mode 100644 apps/web/package.json create mode 100644 apps/web/public/app-icon.png create mode 100644 apps/web/public/favicon.ico create mode 100644 apps/web/public/logo192.png create mode 100644 apps/web/public/logo512.png create mode 100644 apps/web/public/manifest.json create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/public/tanstack-circle-logo.png create mode 100644 apps/web/public/tanstack-word-logo-white.svg create mode 100644 apps/web/src/components/download/download-dialog.tsx create mode 100644 apps/web/src/components/download/download-item.tsx create mode 100644 apps/web/src/components/download/playlist-download-group.tsx create mode 100644 apps/web/src/components/download/playlist-download.tsx create mode 100644 apps/web/src/components/download/single-video-download.tsx create mode 100644 apps/web/src/components/download/types.ts create mode 100644 apps/web/src/components/layout/app-shell.tsx create mode 100644 apps/web/src/components/pages/about-page.tsx create mode 100644 apps/web/src/components/pages/download-page.tsx create mode 100644 apps/web/src/components/pages/settings-page.tsx create mode 100644 apps/web/src/env.d.ts create mode 100644 apps/web/src/hooks/use-web-download-settings.ts create mode 100644 apps/web/src/hooks/use-web-settings.ts create mode 100644 apps/web/src/lib/add-url-shortcut.test.ts create mode 100644 apps/web/src/lib/download-format-preferences.ts create mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/lib/orpc-client.ts create mode 100644 apps/web/src/lib/orpc-download-settings.ts create mode 100644 apps/web/src/lib/remote-image-proxy.ts create mode 100644 apps/web/src/lib/web-settings.ts create mode 100644 apps/web/src/routeTree.gen.ts create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/about.tsx create mode 100644 apps/web/src/routes/index.tsx create mode 100644 apps/web/src/routes/settings.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 biome.json create mode 100644 conductor.json create mode 100644 docker-compose.yml create mode 100644 internal/tests/after-pack-resources-path.test.cjs create mode 100644 internal/tests/setup-dev-binaries-timeout.test.mjs create mode 100644 package.json create mode 100644 packages/db/package.json create mode 100644 packages/db/src/history.ts create mode 100644 packages/db/src/subscriptions.ts create mode 100644 packages/db/src/task-queue.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/downloader-core/package.json create mode 100644 packages/downloader-core/src/browser-cookies-setting.ts create mode 100644 packages/downloader-core/src/contract.ts create mode 100644 packages/downloader-core/src/download-file.ts create mode 100644 packages/downloader-core/src/downloader-core.ts create mode 100644 packages/downloader-core/src/format-preferences.ts create mode 100644 packages/downloader-core/src/index.ts create mode 100644 packages/downloader-core/src/schemas.ts create mode 100644 packages/downloader-core/src/types.ts create mode 100644 packages/downloader-core/src/yt-dlp-args.ts create mode 100644 packages/downloader-core/src/yt-dlp-executor.ts create mode 100644 packages/downloader-core/tsconfig.json create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/src/init.ts create mode 100644 packages/i18n/src/languages.ts create mode 100644 packages/i18n/src/locales/ar.json create mode 100644 packages/i18n/src/locales/de.json create mode 100644 packages/i18n/src/locales/en.json create mode 100644 packages/i18n/src/locales/es.json create mode 100644 packages/i18n/src/locales/fr.json create mode 100644 packages/i18n/src/locales/id.json create mode 100644 packages/i18n/src/locales/it.json create mode 100644 packages/i18n/src/locales/ja.json create mode 100644 packages/i18n/src/locales/ko.json create mode 100644 packages/i18n/src/locales/pt.json create mode 100644 packages/i18n/src/locales/ru.json create mode 100644 packages/i18n/src/locales/tr.json create mode 100644 packages/i18n/src/locales/zh-TW.json create mode 100644 packages/i18n/src/locales/zh.json create mode 100644 packages/i18n/src/resources.ts create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/subscriptions-core/package.json create mode 100644 packages/subscriptions-core/src/api.ts create mode 100644 packages/subscriptions-core/src/auto-download.ts create mode 100644 packages/subscriptions-core/src/contract.ts create mode 100644 packages/subscriptions-core/src/feed-items.ts create mode 100644 packages/subscriptions-core/src/feed-parser.ts create mode 100644 packages/subscriptions-core/src/feed-resolver.ts create mode 100644 packages/subscriptions-core/src/index.ts create mode 100644 packages/subscriptions-core/src/leader.ts create mode 100644 packages/subscriptions-core/src/scheduler.ts create mode 100644 packages/subscriptions-core/src/schemas.ts create mode 100644 packages/subscriptions-core/src/store.ts create mode 100644 packages/subscriptions-core/src/types.ts create mode 100644 packages/subscriptions-core/test/api.test.ts create mode 100644 packages/subscriptions-core/test/auto-download.test.ts create mode 100644 packages/subscriptions-core/test/contract.test.ts create mode 100644 packages/subscriptions-core/test/feed-items.test.ts create mode 100644 packages/subscriptions-core/test/feed-resolver.test.ts create mode 100644 packages/subscriptions-core/test/leader.test.ts create mode 100644 packages/subscriptions-core/test/scheduler.test.ts create mode 100644 packages/subscriptions-core/tsconfig.json create mode 100644 packages/subscriptions-core/vitest.config.ts create mode 100644 packages/task-queue/__integration__/three-host-equivalence.test.ts create mode 100644 packages/task-queue/package.json create mode 100644 packages/task-queue/src/api/index.ts create mode 100644 packages/task-queue/src/classifier/index.ts create mode 100644 packages/task-queue/src/contract.ts create mode 100644 packages/task-queue/src/events/index.ts create mode 100644 packages/task-queue/src/executor/index.ts create mode 100644 packages/task-queue/src/fsm/index.ts create mode 100644 packages/task-queue/src/index.ts create mode 100644 packages/task-queue/src/persist/adapter.ts create mode 100644 packages/task-queue/src/persist/index.ts create mode 100644 packages/task-queue/src/persist/memory.ts create mode 100644 packages/task-queue/src/persist/sqlite.ts create mode 100644 packages/task-queue/src/process/index.ts create mode 100644 packages/task-queue/src/process/kill-process-tree.ts create mode 100644 packages/task-queue/src/process/pid-start-time.ts create mode 100644 packages/task-queue/src/process/registry.ts create mode 100644 packages/task-queue/src/process/watchdog.ts create mode 100644 packages/task-queue/src/projection/index.ts create mode 100644 packages/task-queue/src/scheduler/index.ts create mode 100644 packages/task-queue/src/scheduler/retry-scheduler.ts create mode 100644 packages/task-queue/src/scheduler/scheduler.ts create mode 100644 packages/task-queue/src/schemas.ts create mode 100644 packages/task-queue/src/store/index.ts create mode 100644 packages/task-queue/src/types.ts create mode 100644 packages/task-queue/src/util/async-mutex.ts create mode 100644 packages/task-queue/src/util/min-heap.ts create mode 100644 packages/task-queue/test/_fixtures.ts create mode 100644 packages/task-queue/test/classifier.test.ts create mode 100644 packages/task-queue/test/contract.test.ts create mode 100644 packages/task-queue/test/fsm.test.ts create mode 100644 packages/task-queue/test/orchestrator.test.ts create mode 100644 packages/task-queue/test/process-registry.test.ts create mode 100644 packages/task-queue/test/projection.test.ts create mode 100644 packages/task-queue/test/retry-scheduler.test.ts create mode 100644 packages/task-queue/test/scheduler.test.ts create mode 100644 packages/task-queue/test/store.test.ts create mode 100644 packages/task-queue/tsconfig.json create mode 100644 packages/task-queue/vitest.config.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/base.css create mode 100644 packages/ui/src/components/ui/accordion.tsx create mode 100644 packages/ui/src/components/ui/add-url-popover.tsx create mode 100644 packages/ui/src/components/ui/app-sidebar-icons.tsx create mode 100644 packages/ui/src/components/ui/app-sidebar.tsx create mode 100644 packages/ui/src/components/ui/badge.tsx create mode 100644 packages/ui/src/components/ui/button.tsx create mode 100644 packages/ui/src/components/ui/card.tsx create mode 100644 packages/ui/src/components/ui/checkbox.tsx create mode 100644 packages/ui/src/components/ui/context-menu.tsx create mode 100644 packages/ui/src/components/ui/dialog.tsx create mode 100644 packages/ui/src/components/ui/download-dialog-layout.tsx create mode 100644 packages/ui/src/components/ui/download-empty-state.tsx create mode 100644 packages/ui/src/components/ui/download-filter-bar.tsx create mode 100644 packages/ui/src/components/ui/dropdown-menu.tsx create mode 100644 packages/ui/src/components/ui/feedback-link-buttons.tsx create mode 100644 packages/ui/src/components/ui/hover-card.tsx create mode 100644 packages/ui/src/components/ui/image-with-placeholder.tsx create mode 100644 packages/ui/src/components/ui/input.tsx create mode 100644 packages/ui/src/components/ui/item.tsx create mode 100644 packages/ui/src/components/ui/label.tsx create mode 100644 packages/ui/src/components/ui/popover.tsx create mode 100644 packages/ui/src/components/ui/progress.tsx create mode 100644 packages/ui/src/components/ui/radio-group.tsx create mode 100644 packages/ui/src/components/ui/remote-image.tsx create mode 100644 packages/ui/src/components/ui/scroll-area.tsx create mode 100644 packages/ui/src/components/ui/select.tsx create mode 100644 packages/ui/src/components/ui/separator.tsx create mode 100644 packages/ui/src/components/ui/sheet.tsx create mode 100644 packages/ui/src/components/ui/sidebar.tsx create mode 100644 packages/ui/src/components/ui/sonner.tsx create mode 100644 packages/ui/src/components/ui/switch.tsx create mode 100644 packages/ui/src/components/ui/table.tsx create mode 100644 packages/ui/src/components/ui/tabs.tsx create mode 100644 packages/ui/src/components/ui/textarea.tsx create mode 100644 packages/ui/src/components/ui/title-bar.tsx create mode 100644 packages/ui/src/components/ui/tooltip.tsx create mode 100644 packages/ui/src/lib/cn.ts create mode 100644 packages/ui/src/lib/url-kind.ts create mode 100644 packages/ui/src/lib/use-add-url-interaction.ts create mode 100644 packages/ui/src/lib/use-add-url-shortcut.ts create mode 100644 packages/ui/src/lib/use-mount-effect.ts create mode 100644 packages/ui/src/theme.css create mode 100644 packages/ui/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 screenshots/browser-script.png create mode 100644 screenshots/download-queue.png create mode 100644 screenshots/main-interface.png create mode 100644 scripts/Formula/vidbee.rb create mode 100755 scripts/cli-install.sh create mode 100644 skills-lock.json diff --git a/.agents/skills/orpc-contract-first/SKILL.md b/.agents/skills/orpc-contract-first/SKILL.md new file mode 100644 index 0000000..4e3bfc7 --- /dev/null +++ b/.agents/skills/orpc-contract-first/SKILL.md @@ -0,0 +1,46 @@ +--- +name: orpc-contract-first +description: Guide for implementing oRPC contract-first API patterns in Dify frontend. Triggers when creating new API contracts, adding service endpoints, integrating TanStack Query with typed contracts, or migrating legacy service calls to oRPC. Use for all API layer work in web/contract and web/service directories. +--- + +# oRPC Contract-First Development + +## Project Structure + +``` +web/contract/ +├── base.ts # Base contract (inputStructure: 'detailed') +├── router.ts # Router composition & type exports +├── marketplace.ts # Marketplace contracts +└── console/ # Console contracts by domain + ├── system.ts + └── billing.ts +``` + +## Workflow + +1. **Create contract** in `web/contract/console/{domain}.ts` + - Import `base` from `../base` and `type` from `@orpc/contract` + - Define route with `path`, `method`, `input`, `output` + +2. **Register in router** at `web/contract/router.ts` + - Import directly from domain file (no barrel files) + - Nest by API prefix: `billing: { invoices, bindPartnerStack }` + +3. **Create hooks** in `web/service/use-{domain}.ts` + - Use `consoleQuery.{group}.{contract}.queryKey()` for query keys + - Use `consoleClient.{group}.{contract}()` for API calls + +## Key Rules + +- **Input structure**: Always use `{ params, query?, body? }` format +- **Path params**: Use `{paramName}` in path, match in `params` object +- **Router nesting**: Group by API prefix (e.g., `/billing/*` → `billing: {}`) +- **No barrel files**: Import directly from specific files +- **Types**: Import from `@/types/`, use `type()` helper + +## Type Export + +```typescript +export type ConsoleInputs = InferContractRouterInputs +``` diff --git a/.agents/skills/release-pro-max/SKILL.md b/.agents/skills/release-pro-max/SKILL.md new file mode 100644 index 0000000..faf99c9 --- /dev/null +++ b/.agents/skills/release-pro-max/SKILL.md @@ -0,0 +1,500 @@ +--- +name: release-pro-max +description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送". +--- + +# Release Skills + +Universal release workflow supporting any project type with multi-language changelog. + +## Quick Start + +Just run `/release-pro-max` - auto-detects your project configuration. + +## Supported Projects + +| Project Type | Version File | Auto-Detected | +|--------------|--------------|---------------| +| Node.js | package.json | ✓ | +| Python | pyproject.toml | ✓ | +| Rust | Cargo.toml | ✓ | +| Claude Plugin | marketplace.json | ✓ | +| Generic | VERSION / version.txt | ✓ | + +## Options + +| Flag | Description | +|------|-------------| +| `--dry-run` | Preview changes without executing | +| `--major` | Force major version bump | +| `--minor` | Force minor version bump | +| `--patch` | Force patch version bump | + +## Workflow + +### Step 1: Detect Project Configuration + +1. Check for `.releaserc.yml` (optional config override) +2. Auto-detect version file by scanning (priority order): + - `package.json` (Node.js) + - `pyproject.toml` (Python) + - `Cargo.toml` (Rust) + - `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin) + - `VERSION` or `version.txt` (Generic) +3. Scan for changelog files using glob patterns: + - `CHANGELOG*.md` + - `HISTORY*.md` + - `CHANGES*.md` +4. Identify language of each changelog by filename suffix +5. Display detected configuration + +**Language Detection Rules**: + +Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code. + +| Pattern | Example | Language | +|---------|---------|----------| +| No suffix | `CHANGELOG.md` | en (default) | +| `_{LANG}` (uppercase) | `CHANGELOG_CN.md`, `CHANGELOG_JP.md` | Corresponding language | +| `.{lang}` (lowercase) | `CHANGELOG.zh.md`, `CHANGELOG.ja.md` | Corresponding language | +| `.{lang-region}` | `CHANGELOG.zh-CN.md` | Corresponding region variant | + +Common language codes: `zh` (Chinese), `ja` (Japanese), `ko` (Korean), `de` (German), `fr` (French), `es` (Spanish). + +**Output Example**: +``` +Project detected: + Version file: package.json (1.2.3) + Changelogs: + - CHANGELOG.md (en) + - CHANGELOG.zh.md (zh) + - CHANGELOG.ja.md (ja) +``` + +### Step 2: Analyze Changes Since Last Tag + +```bash +LAST_TAG=$(git tag --sort=-v:refname | head -1) +git log ${LAST_TAG}..HEAD --oneline +git diff ${LAST_TAG}..HEAD --stat +``` + +Categorize by conventional commit types: + +| Type | Description | +|------|-------------| +| feat | New features | +| fix | Bug fixes | +| docs | Documentation | +| refactor | Code refactoring | +| perf | Performance improvements | +| test | Test changes | +| style | Formatting, styling | +| chore | Maintenance (skip in changelog) | + +> **Note**: This categorization is for internal analysis only. When writing changelog entries (Step 4), ALL descriptions must be rewritten in user-facing language. See Step 4 writing guidelines. + +**Breaking Change Detection**: +- Commit message starts with `BREAKING CHANGE` +- Commit body/footer contains `BREAKING CHANGE:` +- Removed public APIs, renamed exports, changed interfaces + +If breaking changes detected, warn user: "Breaking changes detected. Consider major version bump (--major flag)." + +### Step 3: Determine Version Bump + +Rules (in priority order): +1. User flag `--major/--minor/--patch` → Use specified +2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0) +3. `feat:` commits present → Minor bump (1.2.x → 1.3.0) +4. Otherwise → Patch bump (1.2.3 → 1.2.4) + +Display version change: `1.2.3 → 1.3.0` + +### Step 4: Generate Multi-language Changelogs + +For each detected changelog file: + +1. **Identify language** from filename suffix +2. **Detect third-party contributors**: + - Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"` + - For each merged PR, identify the PR author via `gh pr view --json author --jq '.author.login'` + - Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`) + - If PR author ≠ repo owner → third-party contributor +3. **Generate content in that language**: + - Section titles in target language + - Change descriptions written naturally in target language (not translated) + - Date format: YYYY-MM-DD (universal) + - **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry +4. **Insert at file head** (preserve existing content) + +**⚠️ CRITICAL: User-Facing Writing Guidelines** + +Changelog is written for **end users**, NOT developers. Every entry must describe what changed **from the user's perspective** and highlight the value it brings. Follow these rules strictly: + +- **DO NOT** include any technical/programming terms: no "refactor", "component", "module", "API", "SDK", "runtime", "middleware", "state management", "IPC", "store", "hook", "cache invalidation", "dependency injection", etc. +- **DO NOT** mention internal code structure: no file names, function names, class names, variable names, database tables, or architecture details. +- **DO NOT** include engineering process items: no "code cleanup", "refactoring", "migration", "dependency update", "CI/CD", "build optimization", "type safety improvement", etc. +- **DO** describe what the user can now do, see, or experience differently. +- **DO** use plain, everyday language that any non-technical person can understand. +- **DO** focus on user benefits and outcomes, not implementation details. + +**How to transform technical commits into user-facing entries**: + +| Technical commit | User-facing entry | +|-----------------|-------------------| +| `refactor: reorganize desktop component structure` | *(skip — no user-visible change)* | +| `feat: add OAuth2 authentication module` | 支持使用第三方账号登录(如 Google、GitHub) | +| `fix: fix memory leak in connection pool` | 修复了长时间使用后应用变卡的问题 | +| `perf: optimize image loading pipeline` | 图片加载速度更快了 | +| `feat: improve document import and library actions` | 导入文档更方便,书库管理操作更顺手 | +| `fix: update sign-in and sign-up form components` | 优化了登录和注册页面的体验 | +| `refactor: improve IPC flow and store sync` | *(skip — no user-visible change)* | + +**Filtering rules**: +- **Skip entirely**: commits that are purely internal (refactor, code cleanup, test-only, CI/CD, dependency updates) with NO user-visible effect. +- **Rewrite**: commits that have user-visible effects but are described technically — rewrite them in user language. +- Only `feat` and `fix` type changes typically produce user-facing entries. `perf` entries are included only when the improvement is noticeable to users. +- If after filtering, a section would be empty, omit that section entirely. + +**Section Title Translations** (built-in, user-facing only): + +| Type | en | zh | ja | ko | de | fr | es | +|------|----|----|----|----|----|----|-----| +| feat | What's New | 新增功能 | 新機能 | 새로운 기능 | Neuigkeiten | Nouveautés | Novedades | +| fix | Improvements | 改进与修复 | 改善 | 개선 사항 | Verbesserungen | Améliorations | Mejoras | +| perf | Faster & Smoother | 更快更流畅 | パフォーマンス向上 | 더 빠르게 | Schneller & Besser | Plus rapide | Más rápido | +| breaking | Important Changes | 重要变更 | 重要な変更 | 중요 변경사항 | Wichtige Änderungen | Changements importants | Cambios importantes | + +> **Note**: `docs`, `refactor`, `test`, `style`, `chore` types are **excluded** from the changelog. They are internal engineering concerns with no direct user value. + +**Changelog Format**: + +```markdown +## {VERSION} - {YYYY-MM-DD} + +### What's New +- User-facing description of what they can now do (by @username) + +### Improvements +- User-facing description of what got better +``` + +Only include sections that have changes. Omit empty sections. Remember: every line must be understandable by a non-technical user. + +**Third-Party Attribution Rules**: +- Only add `(by @username)` for contributors who are NOT the repo owner +- Use GitHub username with `@` prefix +- Place at the end of the changelog entry line +- Apply to all languages consistently (always use `(by @username)` format, not translated) + +**Multi-language Example**: + +English (CHANGELOG.md): +```markdown +## 1.3.0 - 2026-01-22 + +### What's New +- Sign in with your Google or GitHub account (by @contributor1) +- Link your existing account with third-party login + +### Improvements +- Fixed an issue where the app would slow down after extended use +``` + +Chinese (CHANGELOG.zh.md): +```markdown +## 1.3.0 - 2026-01-22 + +### 新增功能 +- 支持使用 Google 或 GitHub 账号登录 (by @contributor1) +- 可以将已有账号与第三方登录方式绑定 + +### 改进与修复 +- 修复了长时间使用后应用变卡的问题 +``` + +Japanese (CHANGELOG.ja.md): +```markdown +## 1.3.0 - 2026-01-22 + +### 新機能 +- GoogleやGitHubアカウントでログインできるようになりました (by @contributor1) +- 既存アカウントとサードパーティログインの連携が可能に + +### 改善 +- 長時間使用時にアプリが遅くなる問題を修正 +``` + +### Step 5: Group Changes by Skill/Module + +Analyze commits since last tag and group by affected skill/module: + +1. **Identify changed files** per commit +2. **Group by skill/module**: + - `skills//*` → 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 + ### What's New + - New cover styles available: watercolor and minimalist + ### Improvements + - Comics with longer conversations now display more cleanly + +Ready to create release commit and tag. +``` + +### Step 9: Create Release Commit and Tag + +After user confirmation: + +1. **Stage version and changelog files**: + ```bash + git add + git add CHANGELOG*.md + ``` + +2. **Create release commit**: + ```bash + git commit -m "chore: release v{VERSION}" + ``` + +3. **Create tag**: + ```bash + git tag v{VERSION} + ``` + +4. **Push if user confirmed** (Step 8): + ```bash + git push origin main + git push origin v{VERSION} + ``` + +**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution. + +**Post-Release Output**: +``` +Release v1.3.0 created. + +Commits: + 1. feat(baoyu-cover-image): add watercolor and minimalist styles + 2. fix(baoyu-comic): improve panel layout for long dialogues + 3. docs(project): update architecture documentation + 4. chore: release v1.3.0 + +Tag: v1.3.0 +Status: Pushed to origin # or "Local only - run git push when ready" +``` + +## Configuration (.releaserc.yml) + +Optional config file in project root to override defaults: + +```yaml +# .releaserc.yml - Optional configuration + +# Version file (auto-detected if not specified) +version: + file: package.json + path: $.version # JSONPath for JSON, dotted path for TOML + +# Changelog files (auto-detected if not specified) +changelog: + files: + - path: CHANGELOG.md + lang: en + - path: CHANGELOG.zh.md + lang: zh + - path: CHANGELOG.ja.md + lang: ja + + # Section mapping (conventional commit type → changelog section) + # Use null to skip a type in changelog + # Sections should use user-facing names, not technical terms + sections: + feat: "What's New" + fix: Improvements + perf: "Faster & Smoother" + docs: null # Skip — internal only + refactor: null # Skip — internal only + test: null # Skip — internal only + chore: null # Skip — internal only + +# Commit message format +commit: + message: "chore: release v{version}" + +# Tag format +tag: + prefix: v # Results in v1.0.0 + sign: false + +# Additional files to include in release commit +include: + - README.md + - package.json +``` + +## Dry-Run Mode + +When `--dry-run` is specified: + +``` +=== DRY RUN MODE === + +Project detected: + Version file: package.json (1.2.3) + Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh) + +Last tag: v1.2.3 +Proposed version: v1.3.0 + +Changes grouped by skill/module: + baoyu-cover-image: + - feat: add watercolor style + - feat: add minimalist style + → Commit: feat(baoyu-cover-image): add watercolor and minimalist styles + → README updates: options table + + baoyu-comic: + - fix: panel layout for long dialogues + → Commit: fix(baoyu-comic): improve panel layout for long dialogues + → No README updates + +Changelog preview (en): + ## 1.3.0 - 2026-01-22 + ### What's New + - New cover styles available: watercolor and minimalist + ### Improvements + - Comics with longer conversations now display more cleanly + +Changelog preview (zh): + ## 1.3.0 - 2026-01-22 + ### 新增功能 + - 新增封面风格:水彩和极简 + ### 改进与修复 + - 长对话的漫画排版更加美观 + +Commits to create: + 1. feat(baoyu-cover-image): add watercolor and minimalist styles + 2. fix(baoyu-comic): improve panel layout for long dialogues + 3. chore: release v1.3.0 + +No changes made. Run without --dry-run to execute. +``` + +## Example Usage + +``` +/release-pro-max # Auto-detect version bump +/release-pro-max --dry-run # Preview only +/release-pro-max --minor # Force minor bump +/release-pro-max --patch # Force patch bump +/release-pro-max --major # Force major bump (with confirmation) +``` + +## When to Use + +Trigger this skill when user requests: +- "release", "发布", "create release", "new version", "新版本" +- "bump version", "update version", "更新版本" +- "prepare release" +- "push to remote" (with uncommitted changes) + +**Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..76b4361 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,123 @@ +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `pnpm dlx ultracite fix` +- **Check for issues**: `pnpm dlx ultracite check` +- **Diagnose setup**: `pnpm dlx ultracite doctor` + +Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (` + + + + {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')} + /> + ) +} diff --git a/apps/desktop/src/renderer/src/components/download/DownloadItem.tsx b/apps/desktop/src/renderer/src/components/download/DownloadItem.tsx new file mode 100644 index 0000000..e1457aa --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/DownloadItem.tsx @@ -0,0 +1,1401 @@ +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 { getDownloadErrorGuidance } from '../../lib/download-error-guidance' +import { sendGlitchTipFeedback } from '../../lib/glitchtip-feedback' +import { ipcServices } from '../../lib/ipc' +import { + addDownloadAtom, + type DownloadRecord, + removeDownloadAtom, + removeHistoryRecordAtom +} from '../../store/downloads' +import { settingsAtom } from '../../store/settings' +import { useAppInfo } from '../feedback/FeedbackLinks' +import { shouldOpenHistoryItemOnDoubleClick } from './download-item-utils' + +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 +} + +/** + * True when yt-dlp's resolved format id does not include the user-picked + * format id as a `+`-separated component. Used to surface the fallback + * notice on completed rows so users know their preferred quality wasn't + * actually delivered. + */ +const didFallbackFromPick = (download: DownloadRecord): boolean => { + const picked = download.selectedFormat?.format_id + const resolved = download.resolvedFormatId + if (!(picked && resolved)) { + return false + } + const parts = resolved + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + return !parts.includes(picked) +} + +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) + // Saved yt-dlp log fetched on demand for terminal items (live stream is gone). + const [savedLog, setSavedLog] = useState(null) + 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).slice(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, + title: download.title, + thumbnail: download.thumbnail, + description: download.description, + channel: download.channel, + uploader: download.uploader, + viewCount: download.viewCount, + duration: download.duration, + selectedFormat: download.selectedFormat, + 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 isTerminalStatus = + download.status === 'completed' || + download.status === 'error' || + download.status === 'cancelled' + 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 displayErrorMessage = getDownloadErrorGuidance(download.error) ?? download.error + + 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 liveLog = download.ytDlpLog ?? '' + const logContent = liveLog.trim().length > 0 ? liveLog : (savedLog ?? '') + const hasLogContent = logContent.trim().length > 0 + const ytDlpCommand = download.ytDlpCommand?.trim() + const hasYtDlpCommand = Boolean(ytDlpCommand) + const canShowSheet = hasMetadataDetails || isInProgressStatus || hasLogContent || isTerminalStatus + + 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]) + + // Lazily load the persisted yt-dlp log when opening a terminal item that has + // no live log (e.g. a failed download reopened from history). + useEffect(() => { + if (!(sheetOpen && isTerminalStatus) || liveLog.trim().length > 0 || savedLog !== null) { + return + } + let cancelled = false + void (async () => { + try { + const item = await ipcServices.history.getHistoryById(download.id) + if (!cancelled) { + setSavedLog(item?.ytDlpLog ?? '') + } + } catch { + if (!cancelled) { + setSavedLog('') + } + } + })() + return () => { + cancelled = true + } + }, [sheetOpen, isTerminalStatus, liveLog, savedLog, download.id]) + + 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 ( + + + + + +

{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' && ( +
+ +
+ )} + + {/* Format-fallback hint: shown on completed rows when yt-dlp's + resolved format id does not contain the user-picked id (e.g. + user picked Bilibili 4K but only 1080p actually streamed). */} + {download.status === 'completed' && didFallbackFromPick(download) && ( +
+ + {t('download.formatFallbackTitle')} + + + {t('download.formatFallbackHint', { + resolved: download.resolvedFormatId + })} + +
+ )} + + {/* Error message */} + {download.status === 'error' && download.error && ( +
+

+ {displayErrorMessage} +

+
+ + {t('download.feedback.title')}: + + {canShowSheet && ( + + )} + { + event.stopPropagation() + return sendGlitchTipFeedback({ + associatedEventId: download.glitchTipEventId, + appInfo, + error: download.error, + sourceUrl: download.url, + ytDlpCommand: download.ytDlpCommand, + ytDlpLog: download.ytDlpLog + }) + }} + onLinkClick={(event) => 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')} + + + + + )} + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx b/apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx new file mode 100644 index 0000000..8dc67b1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/PlaylistDownload.tsx @@ -0,0 +1,248 @@ +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} + /> +
+
+
+
+
+
+
+
+ )} + + ) +} diff --git a/apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx b/apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx new file mode 100644 index 0000000..aad291e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/PlaylistDownloadGroup.tsx @@ -0,0 +1,169 @@ +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) => ( +
+ +
+ ))} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx b/apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx new file mode 100644 index 0000000..167fab2 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/SingleVideoDownload.tsx @@ -0,0 +1,794 @@ +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 { sendGlitchTipFeedback } from '../../lib/glitchtip-feedback' +import { settingsAtom } from '../../store/settings' +import { useAppInfo } from '../feedback/FeedbackLinks' +import { pickPreferredAudioFormatId } from './audio-format-preferences' +import { getDisplayFormats } from './format-presentation' + +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 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 { videoFormats: finalVideos, audioFormats: finalAudios } = getDisplayFormats({ + formats, + type, + codec + }) + + 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 preferredFormatId = pickPreferredAudioFormatId( + finalAudios, + settings.preferredAudioLanguage + ) + if (preferredFormatId) { + onFormatChange(preferredFormatId) + } + } + } + }, [ + formats, + settings.oneClickQuality, + type, + selectedFormat, + onFormatChange, + pickVideoFormatForPreset, + codec, + settings.preferredAudioLanguage + ]) + + 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')} + +
+ + sendGlitchTipFeedback({ + appInfo, + error, + sourceUrl: feedbackSourceUrl, + ytDlpCommand, + ytDlpLog: error + }) + } + sourceUrl={feedbackSourceUrl} + ytDlpCommand={ytDlpCommand} + /> +
+
+
+ )} + + {!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} + /> + +
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx b/apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx new file mode 100644 index 0000000..63b6b8c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/UnifiedDownloadHistory.tsx @@ -0,0 +1,621 @@ +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 { type ReactNode, 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 { + topContent?: ReactNode + onOpenSupportedSites?: () => void + onOpenSettings?: () => void + onOpenCookiesSettings?: () => void +} + +export function UnifiedDownloadHistory({ + topContent, + 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} + /> + + + + {topContent} + {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)} + /> + +
+ )} + + + + +
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/download/audio-format-preferences.ts b/apps/desktop/src/renderer/src/components/download/audio-format-preferences.ts new file mode 100644 index 0000000..1f3a1e1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/audio-format-preferences.ts @@ -0,0 +1,41 @@ +interface AudioFormatLike { + format_id: string + language?: string +} + +/** + * Pick the best matching audio format id for a saved language preference from issue #60. + */ +export const pickPreferredAudioFormatId = ( + formats: AudioFormatLike[], + preferredLanguage?: string +): string | undefined => { + if (formats.length === 0) { + return undefined + } + + const normalizedPreferredLanguage = preferredLanguage?.trim().toLowerCase() + if (!normalizedPreferredLanguage) { + return formats[0]?.format_id + } + + const exactMatch = formats.find( + (format) => format.language?.trim().toLowerCase() === normalizedPreferredLanguage + ) + if (exactMatch) { + return exactMatch.format_id + } + + const baseLanguageMatch = formats.find((format) => { + const normalizedLanguage = format.language?.trim().toLowerCase() + if (!normalizedLanguage) { + return false + } + return ( + normalizedLanguage.startsWith(`${normalizedPreferredLanguage}-`) || + normalizedPreferredLanguage.startsWith(`${normalizedLanguage}-`) + ) + }) + + return baseLanguageMatch?.format_id ?? formats[0]?.format_id +} diff --git a/apps/desktop/src/renderer/src/components/download/download-item-utils.ts b/apps/desktop/src/renderer/src/components/download/download-item-utils.ts new file mode 100644 index 0000000..b289ee7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/download-item-utils.ts @@ -0,0 +1,16 @@ +interface DoubleClickHistoryTarget { + entryType: 'active' | 'history' + fileExists: boolean + status?: string +} + +/** + * Decide whether a history row should open the saved file on double click for issue #154. + */ +export const shouldOpenHistoryItemOnDoubleClick = ({ + entryType, + fileExists, + status +}: DoubleClickHistoryTarget): boolean => { + return entryType === 'history' && fileExists && status === 'completed' +} diff --git a/apps/desktop/src/renderer/src/components/download/format-presentation.ts b/apps/desktop/src/renderer/src/components/download/format-presentation.ts new file mode 100644 index 0000000..fe69d10 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/download/format-presentation.ts @@ -0,0 +1,111 @@ +import type { VideoFormat } from '@shared/types' + +interface GetDisplayFormatsInput { + formats: VideoFormat[] + type: 'video' | 'audio' + codec?: string +} + +interface DisplayFormatsResult { + videoFormats: VideoFormat[] + audioFormats: VideoFormat[] +} + +/** + * Return the formats that should stay visible in the picker. + * + * Issue refs: #312. + */ +export const getDisplayFormats = ({ + formats, + type, + codec +}: GetDisplayFormatsInput): DisplayFormatsResult => { + const isVideoFormat = (format: VideoFormat): boolean => + format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none' + const isAudioFormat = (format: VideoFormat): boolean => + !!format.acodec && + format.acodec !== 'none' && + (format.video_ext === 'none' || !format.video_ext || !format.vcodec || format.vcodec === 'none') + + const getFileSize = (format: VideoFormat): number => + format.filesize ?? format.filesize_approx ?? 0 + const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat): number => { + const heightDiff = (b.height ?? 0) - (a.height ?? 0) + if (heightDiff !== 0) { + return heightDiff + } + + const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0) + if (fpsDiff !== 0) { + return fpsDiff + } + + const sizeDiff = getFileSize(b) - getFileSize(a) + if (sizeDiff !== 0) { + return sizeDiff + } + + return a.format_id.localeCompare(b.format_id) + } + const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat): number => { + const qualityDiff = (b.tbr ?? b.quality ?? 0) - (a.tbr ?? a.quality ?? 0) + if (qualityDiff !== 0) { + return qualityDiff + } + + const sizeDiff = getFileSize(b) - getFileSize(a) + if (sizeDiff !== 0) { + return sizeDiff + } + + return a.format_id.localeCompare(b.format_id) + } + const getVideoGroupKey = (format: VideoFormat): string => { + const height = format.height ?? 0 + const fps = format.fps ?? 0 + const ext = format.ext || 'unknown' + const vcodec = format.vcodec || 'unknown' + return `${height}:${fps}:${ext}:${vcodec}` + } + + const videos = formats.filter(isVideoFormat) + const audios = formats.filter(isAudioFormat) + const groupedByVideoKey = new Map() + + for (const format of videos) { + const groupKey = getVideoGroupKey(format) + const existing = groupedByVideoKey.get(groupKey) ?? [] + existing.push(format) + groupedByVideoKey.set(groupKey, existing) + } + + const videoFormats = Array.from(groupedByVideoKey.values()) + .map((group) => group.sort(sortVideoFormatsByQuality)[0]) + .sort(sortVideoFormatsByQuality) + + if (type !== 'audio' || codec !== 'auto') { + return { + videoFormats, + audioFormats: [...audios].sort(sortAudioFormatsByQuality) + } + } + + const groupedByAudioQuality = new Map() + for (const format of audios) { + const groupKey = format.tbr + ? `tbr_${format.tbr}` + : format.quality + ? `quality_${format.quality}` + : 'unknown' + const existing = groupedByAudioQuality.get(groupKey) ?? [] + existing.push(format) + groupedByAudioQuality.set(groupKey, existing) + } + + const audioFormats = Array.from(groupedByAudioQuality.values()) + .map((group) => group.sort(sortAudioFormatsByQuality)[0]) + .sort(sortAudioFormatsByQuality) + + return { videoFormats, audioFormats } +} diff --git a/apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx b/apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx new file mode 100644 index 0000000..df8abf6 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/error/ErrorBoundary.tsx @@ -0,0 +1,164 @@ +import { ipcServices } from '@renderer/lib/ipc' +import { logger } from '@renderer/lib/logger' +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { captureRendererException } from '../../lib/glitchtip' +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) + }) + + captureRendererException(error, { + extra: { + componentStack: errorInfo.componentStack, + url: window.location.href + }, + fingerprint: ['react-error-boundary', error.name, error.message], + tags: { + source: 'react.error-boundary' + } + }) + + // 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 + } +} diff --git a/apps/desktop/src/renderer/src/components/error/ErrorPage.tsx b/apps/desktop/src/renderer/src/components/error/ErrorPage.tsx new file mode 100644 index 0000000..132e247 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/error/ErrorPage.tsx @@ -0,0 +1,200 @@ +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')}

+