Compare commits

...

121 Commits

Author SHA1 Message Date
Nexmoe
2bf7af9b25 chore: release v1.1.12 2026-01-15 21:23:02 +08:00
Nexmoe
fe55ac1a79 fix(settings): avoid creating default download folder (#116) 2026-01-15 21:21:09 +08:00
Nexmoe
c1d1a1b912 feat(feedback): attach yt-dlp command (#114) 2026-01-15 21:07:37 +08:00
Nexmoe
9d377dd464 refactor(download): split dialog into single/playlist (#115) 2026-01-15 20:49:24 +08:00
Nexmoe
be00f5b2a4 chore: release v1.1.11 2026-01-14 22:07:22 +08:00
Nexmoe
2d69ce81a4 feat(download): enrich error feedback links (#101)
* feat(download): enrich feedback payloads

* fix(ci): stabilize locale and ffmpeg fetch
2026-01-14 21:50:42 +08:00
Nexmoe
4a9ee1406b fix(subscriptions): add horizontal tab scroll (#93)
* fix(subscriptions): add horizontal tab scroll

* fix(ui): add scroll areas to pages

* fix(downloads): adjust history spacing
2026-01-14 20:26:00 +08:00
Nexmoe
aed6842319 fix(i18n): fill missing locale translations (#100)
* fix(i18n): fill missing locale translations

* fix(i18n): keep interpolation keys
2026-01-14 20:25:39 +08:00
Nexmoe
e7957d09ec fix(settings): disable embed thumbnail by default (#99) 2026-01-14 19:54:43 +08:00
Nexmoe
5057d18e09 fix(renderer): disable text selection (#98) 2026-01-14 19:54:41 +08:00
Nexmoe
3c1f52b06b fix(subscriptions): update rsshub docs link (#97) 2026-01-14 19:54:37 +08:00
Nexmoe
f061b6b61e fix(renderer): refine subscriptions layout (#92) 2026-01-13 22:20:31 +08:00
Nexmoe
43cabf65e9 feat(subscriptions): follow one-click defaults (#91) 2026-01-13 21:41:00 +08:00
Nexmoe
da3643bc79 fix(download): guard bilibili subtitle embed (#90) 2026-01-13 21:30:33 +08:00
Nexmoe
976cb82bf9 feat(download): refine progress and dialog layout (#88) 2026-01-13 21:02:23 +08:00
Nexmoe
14f80309af feat(extension): refine error UI and branding (#89) 2026-01-13 21:02:20 +08:00
Nexmoe
8acd761189 chore: release v1.1.10 2026-01-12 22:29:52 +08:00
Nexmoe
20c6ae6543 fix(ci): notarize dmg in workflow (#87)
* chore: release v1.1.9

* fix(ci): notarize dmg in workflow
2026-01-12 22:29:14 +08:00
Nexmoe
66f2211d68 fix(build): move mac tool signing to beforeSign (#86)
* fix(build): move tool signing to beforeSign

* ci(macos): verify signing and notarization

* fix(build): move signing hook to afterPack
2026-01-12 21:53:39 +08:00
Nexmoe
ac183b4e3c build(mac): sign bundled tools (#85) 2026-01-12 21:19:18 +08:00
Nexmoe
877f088136 chore: release v1.1.8 2026-01-12 10:14:49 +08:00
Nexmoe
5ee08a3e62 chore(build): add macOS signing secrets and validation
- Introduced new secrets for macOS signing in the build workflow.
- Added validation to check for the presence of required secrets before proceeding with code signing setup.
2026-01-12 10:13:58 +08:00
Nexmoe
137a07cf4e chore(build): enhance macOS signing and notarization process
- Updated electron-builder configuration to enable hardened runtime and notarization for macOS builds.
- Added a new GitHub Actions step to set up macOS signing with necessary certificates and keys.
2026-01-12 09:58:44 +08:00
Nexmoe
21a5adbdf2 fix(update): localize update toast (#82) 2026-01-11 16:56:34 +08:00
Nexmoe
81f52f86e5 fix(about): hide check updates when available (#80) 2026-01-11 16:34:48 +08:00
Nexmoe
c8147cafe3 fix(download): inline progress details (#81) 2026-01-11 16:34:40 +08:00
Nexmoe
7074f698e5 chore: release v1.1.7 2026-01-11 15:46:37 +08:00
Nexmoe
b254bb6dba chore(build): reduce packaged artifacts (#79) 2026-01-11 15:44:20 +08:00
Nexmoe
1caca0ea9a feat(settings): add embed features (#76)
* feat(settings): add embed options

* feat(settings): default embed on

* refactor(setup-dev-binaries): enhance download error handling and version checks for binaries
2026-01-11 15:24:33 +08:00
Nexmoe
12ec142a77 ci(release): notify Cloudflare Pages (#78) 2026-01-11 15:21:40 +08:00
Nexmoe
0136da410d chore: release v1.1.6 2026-01-11 10:21:42 +08:00
Nexmoe
4971a5fe8a feat(settings): improve cookie profile handling (#74)
* feat(settings): improve cookie profile handling

* fix(settings): normalize profile input
2026-01-11 10:16:28 +08:00
Nexmoe
d2806164a2 feat(extension): integrate local video info API (#67)
* feat(extension): integrate local video info API

* chore(repo): clean up popup styles

* feat(extension): revamp popup UI

* chore(repo): revert docs and drop content script
2026-01-11 00:05:46 +08:00
Nexmoe
e332ec4ddc chore: release v1.1.5 2026-01-10 19:18:55 +08:00
Nexmoe
3b74712f57 fix(subscription): improve cover image selection (#73)
* fix(subscriptions): improve rss cover fallback

* fix(subscriptions): match img src first
2026-01-10 19:18:03 +08:00
Nexmoe
475e4127c2 fix(remote-image): add load timeout (#72) 2026-01-10 18:39:00 +08:00
Nexmoe
3041307aa2 fix: Advanced settings tab broken (#71) 2026-01-10 18:30:18 +08:00
Nexmoe
e2ab8dce60 chore: release v1.1.4 2026-01-09 21:39:01 +08:00
Nexmoe
c1806e5321 fix(main): hide window on autostart (#68) 2026-01-09 21:03:25 +08:00
Nexmoe
92494966c6 fix(settings): merge defaults for getAll (#66) 2026-01-09 20:53:08 +08:00
Nexmoe
f04680b8c2 chore: release v1.1.3 2026-01-02 16:17:42 +08:00
Nexmoe
a5b94411fe fix(download): handle empty format selections (#64) 2026-01-02 16:16:04 +08:00
Nexmoe
71ae4a4425 fix(download): add format fallback selectors (#63) 2026-01-01 14:41:59 +08:00
Nexmoe
e8413aefae feat(ui): show update indicator on About (#58)
* feat(ui): add update indicator for about

* fix(ci): remove unused import

* fix(ci): retry binary downloads
2025-12-26 16:46:55 +08:00
Nexmoe
b229225b6e chore: release v1.1.2 2025-12-26 13:04:52 +08:00
Nexmoe
5497ed6245 chore(templates): simplify bug report (#57) 2025-12-26 13:04:30 +08:00
Nexmoe
155bf652f2 fix(ci): harden download steps (#56) 2025-12-26 13:02:21 +08:00
Nexmoe
86bd77d995 fix(download): improve format fallbacks (#55) 2025-12-26 13:02:07 +08:00
Nexmoe
b38b356c22 feat(issue): add issue templates (#54) 2025-12-26 12:53:51 +08:00
Nexmoe
5020605590 chore: release v1.1.1 2025-12-25 22:20:18 +08:00
Nexmoe
b15c3d8ce5 feat(about): refresh description and links (#52) 2025-12-25 22:18:04 +08:00
Nexmoe
a51655697d fix(update): streamline update notification (#51) 2025-12-23 09:43:14 +08:00
Nexmoe
f5c26f5f02 Animate advanced options panels (#47) 2025-12-21 13:32:46 +08:00
Nexmoe
3bc9d76f6e Add JS runtime support for yt-dlp #36 (#46)
* Add yt-dlp JS runtime support

* Update build scripts and logging

* fix: update README to include DeepWiki badge and correct contributor badge logo
2025-12-21 10:38:04 +08:00
Nexmoe
5751d73d5b Limit electron languages to English (#45) 2025-12-20 22:34:06 +08:00
Nexmoe
50bd9f1659 fix: update README download links to point to new VidBee website
* Replace individual OS download links with a single link to the VidBee download page for improved clarity and accessibility.
2025-12-20 21:49:46 +08:00
Nexmoe
e7627e1132 chore: release v1.1.0 2025-12-20 19:26:01 +08:00
Nexmoe
d7b131d795 feat: update configuration and improve download components
* Modify biome.json to exclude specific directories from scanning and add experimental scanner ignores.
* Refine .vscode/settings.json for better Tailwind CSS integration and maintain locales paths.
* Simplify DownloadDialog component by removing unused imports and optimizing button rendering.
* Enhance DownloadItem component's accessibility and interaction handling.
* Introduce Input component in AdvancedOptions for improved user input handling.
2025-12-20 19:25:45 +08:00
Nexmoe
74dbb223ce feat: enhance README with browser script and RSS auto-download features
* Update VidBee description to include RSS auto-download automation.
* Add section for VidBee browser script installation and usage, detailing supported sites for quick downloads.
* Introduce new screenshot for the browser script feature.
2025-12-20 19:14:13 +08:00
Nexmoe
11a931f865 feat: add VidBee quick download user script and update .gitignore
* Introduce a new user script for quick downloads from various video platforms.
* Update .gitignore to exclude 'dist' and include 'dist-ssr' directory.
2025-12-20 19:04:42 +08:00
Nexmoe
79a78d4322 feat: initialize VidBee download extension with core functionality
* Add .gitignore files for main and monkey directories.
* Create initial project structure for VidBee download extension.
* Implement main functionality in src/main/index.ts to handle deep links.
* Develop UI components including DownloadDialog and VidBee download button.
* Set up TypeScript configuration and Vite for build process.
* Add styles for download button and tooltips.
* Integrate localization for user interface elements.
* Establish deep link handling in the renderer for improved user experience.
2025-12-20 19:03:13 +08:00
Nexmoe
a1c44baf65 feat: refactor download components and enhance UI/UX
* Rename ElectronStore to LegacyStore for clarity in subscription manager.
* Remove unused deep link state in AppContent and add a TODO for future handling.
* Introduce a new DownloadDialog component for managing downloads.
* Update DownloadItem to include a details sheet for video metadata.
* Enhance PlaylistDownloadGroup to persist expanded state in local storage.
* Improve UnifiedDownloadHistory layout and integrate DownloadDialog for better user interaction.
* Update various UI components for consistency and improved styling.
* Add new localization strings for download actions across multiple languages.
2025-12-20 17:03:12 +08:00
Nexmoe
86e6b93476 Add custom download folders and refine RSS dialog #37 (#44)
* Add custom download folders

* Refine RSS defaults and dialog layout

* Record template folder in download path

* Store template folder in history paths

* Resolve template vars for history paths

* Default RSS directory to Subscriptions
2025-12-20 13:05:12 +08:00
Nexmoe
dbcd77963f Add bulk history actions and selection UX #37 (#42)
* Add collapsed playlist progress

* Improve history bulk actions and selection UX

* Fix ffmpeg download resolution in setup script
2025-12-20 12:07:13 +08:00
Nexmoe
58a4d2da36 Handle download deep links (#41)
* Add download deep link support

* Queue deep links until renderer ready
2025-12-19 22:18:11 +08:00
Nexmoe
db16d5d051 Remove supported sites page (#40)
* Remove supported sites page

* Remove supported sites page component
2025-12-19 20:41:58 +08:00
Nexmoe
41f40b6e71 Use icon tabs with tooltips (#38)
* feat: icon tabs with tooltips

* chore: add conductor config
2025-12-19 18:03:03 +08:00
Nexmoe
1ee717888f chore: release v1.0.2 2025-12-06 16:46:10 +08:00
Nexmoe
c7815eb81a feat: register vidbee protocol and respect vidbee:// paths in images (#35) 2025-12-06 16:45:48 +08:00
Nexmoe
3946b5ef2f feat: resolve ~ in config paths before passing to engine (#34) 2025-11-29 11:10:16 +08:00
Nexmoe
ef99eb1f59 feat: add Chromium option closes #32 (#33) 2025-11-22 19:28:20 +08:00
Nexmoe
31607bd947 chore: release v1.0.1 2025-11-16 17:08:13 +08:00
Nexmoe
452132ce16 feat(i18n): add comprehensive i18n (#31)
* feat(i18n): add comprehensive i18n

* fix(i18n): unify zh locale variable names to English keys for templating
2025-11-16 12:30:53 +08:00
Nexmoe
f84f3ef02a feat(settings): add auto-launch handling and UI toggle support (#29) 2025-11-15 16:16:16 +08:00
Nexmoe
6c3f070797 chore: release v1.0.0 2025-11-15 12:30:16 +08:00
Nexmoe
62a339e5b9 feat: rss-subscriptions (#28)
* feat(subscriptions): add subscription atoms and types for feeds and rules

* refactor(types): remove obsolete format/quality/codec fields and related logic to simplify download and avoid redundant storage

* feat: add download_history schema, migration, and tag utils

* refactor(dialog): convert to functional wrappers, simplify overlay and

* feat(rss): add enclosure support, refine thumbnail lookup and UI labels
feat(settings): add toggleable anonymous analytics collection and loader script

* feat(settings): add toggleable anonymous analytics collection and loader script

* feat(i18n): add subscriptions UI strings and import downloads types

* refactor(subscriptions): remove legacy status fields and migrate items ordering

* feat: add Radix HoverCard dependency and wrap tabs with HoverCard for

* feat(db): move download history schema into migrate + drizzle config

* feat(subscriptions): add item queueing UI and IPC handler

* feat(downloads): normalize saved filenames, improve candidates and UI display
2025-11-15 12:29:31 +08:00
Nexmoe
f1fd40176f feat(ui): make sidebar a draggable title bar and opt out buttons from drag region (#26) 2025-11-15 12:06:59 +08:00
Nexmoe
e5d8629ba3 feat(ui): make sidebar a draggable title bar and opt out buttons from drag region (#24) 2025-11-11 22:27:25 +08:00
Nexmoe
3375f94848 feat(images): add RemoteImage with caching and loading hooks, and (#25) 2025-11-11 22:23:58 +08:00
Nexmoe
9f7206abfe feat(history): migrate from electron-store to SQLite with drizzle and (#21) 2025-11-10 22:04:52 +08:00
Nexmoe
334c7426c2 feat(sites): show site icons and update supported sites layout (#23)
Modified SupportedSites.tsx:
- Added ImageWithPlaceholder import and IPC service import to fetch site icons.
- Implemented SiteIcon component that loads site icon via ipcServices.app.getSiteIcon and handles errors/cancellation.
- Updated layout: adjusted grid to include lg:grid-cols-3 and restructured list items into anchor cards with icon, label, external link icon, and description with truncation.
These changes are focused on UI improvements for the SupportedSites page and fetching site icons from the main process.
2025-11-10 21:22:59 +08:00
Nexmoe
bd7f4a433c chore: optimize ui and save structure (#19)
* feat: persist and use saved file name for downloads

* feat(settings): ensure and migrate download directory to VidBee/Downloads

* feat: format download dates with locale date and time for clarity

* feat(files): try multiple candidate file paths for file ops and DRY logic

* fix(download): handle multi-path file deletion and warn on failure
2025-11-08 21:28:51 +08:00
Nexmoe
27287238aa feat: rewrite README header and intro with badges and (#20) 2025-11-08 18:56:57 +08:00
Nexmoe
486767fca9 chore: optimize update function (#18)
* chore: add zip target with arm64 and x64 architectures to dmg/targets

* feat(updates): move update UI handling to About page and add progress UI and i18n key
2025-11-08 18:29:26 +08:00
Nexmoe
e22fe7679a chore: release v0.3.5 2025-11-08 13:45:05 +08:00
Nexmoe
7c2e526c49 feat(ui): add one-click texts, file existence checks, clipboard copy (#15)
* feat(ui): add one-click texts, file existence checks, clipboard copy

* refactor(ui): remove unused clearCompleted and adjust card bg
2025-11-08 13:44:28 +08:00
Nexmoe
2f5776d1e0 feat(theme): update primary/accent colors to new OKLCH values (#17) 2025-11-08 13:41:05 +08:00
Nexmoe
7901fc7e82 feat(settings): remove 'auto' preset and default to 'best' to (#16) 2025-11-08 13:40:17 +08:00
Nexmoe
612800bef2 chore: release v0.3.4 2025-11-03 20:20:41 +08:00
Nexmoe
41b58a6f70 feat(format-selector): exclude HLS, sort and improve labels for formats (#13) 2025-11-03 20:13:18 +08:00
Nexmoe
1a8e26a847 feat(update): add download action and guide users to download page when (#12)
* feat(update): add download action and guide users to download page when

* fix(updater): always initialize auto-updater in non-prod builds for testing
2025-11-02 19:35:49 +08:00
Nexmoe
0bcddddc37 chore: release v0.3.3 2025-11-02 17:59:39 +08:00
Nexmoe
99163865e2 fix(release): update release script to ensure main branch is checked out and up-to-date before running checks 2025-11-02 17:59:30 +08:00
Nexmoe
40113a2b53 feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and (#11)
* feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and

* fix(download-engine): let yt-dlp merge format to avoid codec conflicts and use actual format for filenames

* chore(ci): replace monolithic job with platform-specific reusable jobs

* chore(ci): run build matrix for windows, macos, linux in one to

* feat(ci): remove platform matrix and platform gating to simplify build

* chore(ci): pin github-translate-action to specific commit to ensure
2025-11-02 17:42:33 +08:00
Nexmoe
75c8b19f31 feat: add GitHub Actions workflow for translation automation
Create a new workflow to automate translation tasks for issues, comments, discussions, and pull requests. This includes permissions for writing to issues and discussions, and utilizes the github-translate-action for title modifications.
2025-10-31 20:27:59 +08:00
Nexmoe
2a28b0db85 chore: release v0.3.2 2025-10-31 20:21:46 +08:00
Nexmoe
ca4b851ba4 Update mac build script to output separate mac packages (#9)
* build: produce separate mac binaries

* chore(build): add artifactName for mac builds to include arch in file name
2025-10-31 20:20:51 +08:00
Nexmoe
0742dac057 chore: release v0.3.1 2025-10-30 22:36:50 +08:00
Nexmoe
9d2e35c322 chore: update maintainer email in electron-builder configuration 2025-10-30 22:35:42 +08:00
Nexmoe
526bd994d8 feat(updates): add auto-update notifications and handlers (#8)
Add update-related UI and IPC handling to App. Integrate
i18n and sonner to show localized toast notifications update
lifecycle events (available, download started, progress, downloaded,
errors, and restart prompt). Use a ref to guard against concurrent
download attempts and read auto-update preference from settings atom.
Wire IPC event names and services, and ensure graceful error handling
and user-triggered download/restart actions.

This enables better user feedback for updates and supports both
automatic and manual update flows.
2025-10-30 22:25:02 +08:00
Nexmoe
37c1f22aee Improve Linux compatibility (#6)
* Improve Linux compatibility

* Run CI on Linux builds

* Update release workflow for Linux builds
2025-10-30 21:36:00 +08:00
Nexmoe
10a97b1f4a support of macOS x64 packing (#7) 2025-10-30 21:31:42 +08:00
Nexmoe
11eaaf0f88 chore: release v0.3.0 2025-10-29 20:34:05 +08:00
Nexmoe
512b9d1175 feat: playlist (#2)
* feat: implement playlist download feature with UI components, enhance download service for playlist handling, and update translations for playlist-related terms

* refactor: replace console logs with log.info for auto-updater events and enhance zh-TW translations with new keys and updates
2025-10-29 20:32:27 +08:00
Nexmoe
a1307bdd0b feat: add hide-dock setting and fix about text handling (#3)
- Add "hideDockIcon" setting label and description to English locale,
  enabling users to remove VidBee from the macOS Dock and rely on the
  menu bar/tray icon.
- Apply dock visibility changes when settings change, when settings
  are loaded, and after settings are reset by calling
  applyDockVisibility from the settings service.
- Update About page to use about.description for share text and display
  content, replacing the removed tagline key usage.
- Remove unused "tagline" entries from multiple locale files (en, fr,
  it, pt, ja, zh) to keep translations in sync with UI text changes.

These changes add OS integration for hiding the dock icon and ensure UI
copy is consistent and correctly internationalized.
2025-10-29 20:32:03 +08:00
Nexmoe
06132a0704 chore: release v0.2.2 2025-10-27 22:01:44 +08:00
Nexmoe
a7e4eb865b feat: add cookies file support in settings and download engine, enhance i18n with new cookie-related translations 2025-10-27 22:01:11 +08:00
Nexmoe
5246ab8369 feat: implement version comparison for updates, enhance TitleBar component with platform support, and improve language handling in settings 2025-10-27 20:23:29 +08:00
Nexmoe
baa887da2e chore: release v0.2.1 2025-10-26 12:52:50 +08:00
Nexmoe
be40f087ad feat: enhance i18n support by dynamically loading translations and updating popular sites with localized labels 2025-10-25 17:36:59 +08:00
Nexmoe
ad62bd07aa feat: enhance About page with latest version check and status display 2025-10-25 16:27:44 +08:00
Nexmoe
365b9c0660 feat: add flag-icons package and improve language handling in sidebar and i18n 2025-10-25 15:59:03 +08:00
Nexmoe
a1f5e0480c Merge branch 'main' of https://github.com/nexmoe/VidBee 2025-10-25 15:31:16 +08:00
Nexmoe
00a7950e6a feat: update en.json with new follow author section and improved notifications 2025-10-25 15:31:12 +08:00
Nexmoe
598ed9c964 docs: add macOS installation notes to README for resolving "file is damaged" error 2025-10-25 14:44:47 +08:00
Nexmoe
2b3edab4eb chore: update CI workflow to run on Windows and download yt-dlp binaries 2025-10-25 11:34:58 +08:00
Nexmoe
d0bdfce803 chore: remove draft option from release workflow 2025-10-25 11:31:09 +08:00
Nexmoe
17a4568492 chore: enable release notes generation in workflow 2025-10-25 11:30:51 +08:00
Nexmoe
840a432b7f chore: release v0.2.0 2025-10-25 11:30:03 +08:00
Nexmoe
cc17e29fbe chore: macos icon 2025-10-25 11:25:22 +08:00
Nexmoe
dd5f0a729b feat: test for mac 2025-10-25 11:06:40 +08:00
167 changed files with 29752 additions and 2685 deletions

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Bug Report
description: Report a problem or regression
title: "[Bug]: "
labels:
- bug
body:
- type: input
id: app_version
attributes:
label: App version
description: The VidBee version (e.g., 1.2.3)
placeholder: 1.2.3
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
placeholder: macOS 14.2
validations:
required: true
- type: textarea
id: actual
attributes:
label: Observed behavior
placeholder: What actually happened
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs or screenshots
description: Paste relevant logs or add screenshots
placeholder: Attach files or paste logs here
validations:
required: false

View File

@@ -0,0 +1,38 @@
name: Feature Request
description: Suggest an idea or improvement
title: "[Feature]: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem are you trying to solve?
placeholder: I want to...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the feature or change you want
placeholder: It would be great if...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other solutions or workarounds you considered
placeholder: I tried...
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context
description: Add any other context or screenshots
placeholder: Links, screenshots, or related issues
validations:
required: false

234
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,234 @@
name: Build
on:
workflow_call:
inputs:
upload_artifacts:
required: false
type: boolean
default: false
description: 'Whether to upload build artifacts'
secrets:
MAC_CERT_P12_BASE64:
required: false
MAC_CERT_P12_PASSWORD:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_API_KEY_P8_BASE64:
required: false
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: windows
os: windows-latest
build_script: pnpm run build:win
ytdlp_asset: yt-dlp.exe
ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
ffmpeg_output: ffmpeg.exe
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
ffmpeg_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
ytdlp_asset: yt-dlp
ytdlp_output: yt-dlp_linux
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Download ffmpeg binary (Windows)
if: matrix.platform == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$ffmpegUrl = '${{ matrix.ffmpeg_url }}'
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
Copy-Item -Path $source -Destination $destination -Force
Remove-Item ffmpeg.zip -Force
Remove-Item ffmpeg -Recurse -Force
- name: Download ffmpeg binary (macOS)
if: matrix.platform == 'macos'
shell: bash
env:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
run: |
set -euo pipefail
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
if [[ ! -f "$arm_bin" ]]; then
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
fi
if [[ ! -f "$x86_bin" ]]; then
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
fi
if [[ ! -f "$arm_bin" ]]; then
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
exit 1
fi
if [[ ! -f "$x86_bin" ]]; then
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
exit 1
fi
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
chmod +x "resources/$FFMPEG_OUTPUT"
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
- name: Download ffmpeg binary (Linux)
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
exit 1
fi
mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
chmod +x "resources/${{ matrix.ffmpeg_output }}"
rm -rf ffmpeg.tar.xz ffmpeg
- name: Download yt-dlp binary
shell: bash
run: |
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
chmod +x "resources/${{ matrix.ytdlp_output }}"
fi
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Setup macOS signing
if: matrix.platform == 'macos'
shell: bash
env:
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
# Check if all required secrets are present
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
echo "::notice::macOS signing secrets not available, skipping code signing setup"
exit 0
fi
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
- name: Build application
run: ${{ matrix.build_script }}
- name: Verify macOS codesign and notarization
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
shell: bash
run: |
set -euo pipefail
apps_found=0
while IFS= read -r app; do
apps_found=1
echo "Verifying codesign for $app"
codesign --verify --deep --strict --verbose=2 "$app"
spctl -a -t exec -vv "$app"
echo "Validating notarization ticket for $app"
xcrun stapler validate "$app"
done < <(find dist -type d -name "*.app" -prune -print)
if [[ "$apps_found" -eq 0 ]]; then
echo "::error::No .app bundles found in dist"
exit 1
fi
dmgs_found=0
while IFS= read -r dmg; do
dmgs_found=1
echo "Submitting DMG for notarization: $dmg"
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
echo "Stapling notarization ticket for $dmg"
xcrun stapler staple "$dmg"
echo "Validating notarization ticket for $dmg"
xcrun stapler validate "$dmg"
done < <(find dist -type f -name "*.dmg" -print)
if [[ "$dmgs_found" -eq 0 ]]; then
echo "::notice::No DMG artifacts found to validate"
fi
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/
retention-days: 1

View File

@@ -5,31 +5,5 @@ on:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- name: Lint and format check
run: pnpm run check
- name: Build check
run: pnpm run build
build:
uses: ./.github/workflows/build.yml

84
.github/workflows/extension-publish.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Publish Extension
on:
push:
branches: [ main ]
paths:
- extension/package.json
jobs:
detect-version:
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.version_check.outputs.changed }}
current_version: ${{ steps.version_check.outputs.current_version }}
previous_version: ${{ steps.version_check.outputs.previous_version }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if extension version changed
id: version_check
shell: bash
run: |
set -euo pipefail
before_sha="${{ github.event.before }}"
current_version=$(node -e "const fs = require('fs'); const v = JSON.parse(fs.readFileSync('extension/package.json', 'utf8')).version; process.stdout.write(v);")
previous_version=""
if git cat-file -e "${before_sha}:extension/package.json" 2>/dev/null; then
previous_version=$(git show "${before_sha}:extension/package.json" | node -e "let data=''; process.stdin.on('data', d => data += d); process.stdin.on('end', () => { const v = JSON.parse(data).version; process.stdout.write(v); });")
fi
echo "current_version=${current_version}" >> "$GITHUB_OUTPUT"
echo "previous_version=${previous_version}" >> "$GITHUB_OUTPUT"
if [[ -n "${previous_version}" && "${current_version}" == "${previous_version}" ]]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
submit:
needs: detect-version
if: needs.detect-version.outputs.changed == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: extension
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: extension/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Zip extensions
run: |
pnpm zip
- name: Submit to stores
run: |
pnpm wxt submit \
--chrome-zip .output/*-chrome.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
CHROME_PUBLISH_TARGET: "default"
CHROME_SKIP_SUBMIT_REVIEW: false

View File

@@ -6,62 +6,30 @@ on:
- v*.*.*
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
secrets: inherit
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest]
needs: [build]
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
node-version: 20
pattern: dist-*
merge-multiple: true
path: dist/
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Download yt-dlp binaries
run: |
# Download yt-dlp.exe for Windows
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
# Download yt-dlp for macOS (for cross-platform builds)
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
# chmod +x resources/yt-dlp_macos
# Download yt-dlp for Linux (for cross-platform builds)
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
chmod +x resources/yt-dlp_linux
- name: Lint and format check
run: pnpm run check
# - name: build-linux
# if: matrix.os == 'ubuntu-latest'
# run: pnpm run build:linux
# - name: build-mac
# if: matrix.os == 'macos-latest'
# run: pnpm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'
run: pnpm run build:win
- name: release
- name: Release
uses: softprops/action-gh-release@v1
with:
draft: true
generate_release_notes: true
files: |
dist/*.exe
dist/*.zip
@@ -76,3 +44,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Notify Cloudflare Pages
env:
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
run: |
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"

29
.github/workflows/translator.yaml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'translator'
on:
issues:
types: [opened, edited]
issue_comment:
types: [created, edited]
discussion:
types: [created, edited]
discussion_comment:
types: [created, edited]
pull_request_target:
types: [opened, edited]
pull_request_review_comment:
types: [created, edited]
jobs:
translate:
permissions:
issues: write
discussions: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
IS_MODIFY_TITLE: true

6
.gitignore vendored
View File

@@ -1,7 +1,9 @@
node_modules
dist
/dist
out
.conductor/
.wxt
.output
.DS_Store
.eslintcache
*.log*

View File

@@ -25,5 +25,8 @@
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
"i18n-ally.keystyle": "nested"
"i18n-ally.keystyle": "nested",
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -1,5 +1,7 @@
1. use pnpm instead of npm
2. use pnpm run check after tasks to check code
3. Support i18n, only translate the English version of en.json
3. Support i18n. When writing business logic, initially only translate the English version of en.json
4. use English for comments&console
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
7. If there is an error when running `pnpm run check:i18n`, please complete the missing corresponding translation files and fields. Ensure the translation is done into the corresponding language, rather than directly copying the English version.

View File

@@ -58,6 +58,7 @@ src/
- Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
## Working on Changes
- Keep each pull request focused on a single problem or feature.

166
README.md
View File

@@ -1,81 +1,141 @@
# 🐝 VidBee
<div align="center">
<img src="build/icon.png" alt="VidBee icon" width="120" />
<h3>Download videos from almost any website worldwide</h3>
<p>Best-in-class UI interface - Clean, intuitive, and powerful</p>
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
<a href="https://github.com/nexmoe/VidBee">
<img src="build/icon.png" alt="Logo" width="80" height="80">
</a>
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<br />
<br />
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
<br />
<br />
</p>
</div>
## ✨ Core Features
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
## 👋🏻 Getting Started
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
Feel free to try it using the following methods:
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
### 🍎 macOS Installation Notes
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
```bash
xattr -rd com.apple.quarantine /Applications/VidBee.app/
```
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
### 🌐 Browser Script (Quick Download)
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
![VidBee Browser Script](screenshots/browser-script.png)
**Installation:**
1. Install a userscript manager extension:
- [Tampermonkey](https://www.tampermonkey.net/) (Recommended)
- [Violentmonkey](https://violentmonkey.github.io/)
- [Greasemonkey](https://www.greasespot.net/)
2. Install the VidBee Quick Download script:
- [Install from Greasy Fork](https://greasyfork.org/zh-CN/scripts/559595-vidbee-quick-download)
**Usage:**
After installation, when you visit supported video websites (YouTube, Bilibili, TikTok, Instagram, Twitter, etc.), a download button will appear on the page. Click the button to quickly send the video URL to VidBee desktop app for downloading.
**Supported Sites:**
The script works on popular video platforms including:
- YouTube, Bilibili, TikTok, Vimeo, Dailymotion
- Twitch, Twitter/X, Instagram, Facebook
- Reddit, SoundCloud, Niconico, Kick
- Bandcamp, Mixcloud, and more
> [!IMPORTANT]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
## ✨ Features
### 🌍 Global Video Download Support
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
- Localized interface support in many languages
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
![VidBee Main Interface](screenshots/main-interface.png)
### 🎨 Best-in-class UI Experience
- **Modern Design** - Clean and beautiful interface
- **Intuitive Operations** - One-click pause/resume/retry
- **Real-time Progress** - Detailed download progress tracking and status management
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
## 📥 Download & Install
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
2. **Choose your platform**:
- **Windows**: Download `vidbee-x.x.x-setup.exe`
- **macOS**: Download `vidbee-x.x.x.dmg`
- **Linux**: Download `vidbee-x.x.x.AppImage`
3. **Install and run** the application
## 📸 Screenshots
![VidBee Main Interface](screenshots/main-interface.png)
*Clean and intuitive interface with download queue management*
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
![VidBee Download Queue](screenshots/download-queue.png)
*Comprehensive download queue with progress tracking and status management*
### 📡 RSS Auto Download
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
## 🌐 Supported Sites
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
| Video Platforms | Audio & Other Platforms |
| --- | --- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
| :--------------- | :---------------------- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
## ?? Contributing
## 🤝 Contributing
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
## 📄 License
This project is distributed under the MIT License. See `LICENSE` for details.
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
## 🙏 Thanks
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [Electron](https://www.electronjs.org/)
- [React](https://react.dev/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
- [React](https://react.dev/) - The UI library
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components

View File

@@ -39,7 +39,18 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.json",
"!monkey/dist",
"!dist",
"!out",
"!build"
],
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
},
"vcs": {
"enabled": true,

58
build/after-pack.cjs Normal file
View File

@@ -0,0 +1,58 @@
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
const findAppBundle = (appOutDir) => {
const entries = fs.readdirSync(appOutDir)
const app = entries.find((entry) => entry.endsWith('.app'))
return app ? path.join(appOutDir, app) : null
}
const resolveSigningIdentity = () =>
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
const signBinary = (targetPath, entitlementsPath) => {
const identity = resolveSigningIdentity()
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
if (identity !== '-') {
args.push('--options', 'runtime', '--timestamp')
}
args.push(targetPath)
execFileSync('codesign', args, { stdio: 'inherit' })
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') {
return
}
const appBundle = findAppBundle(context.appOutDir)
if (!appBundle) {
console.warn('afterPack: No .app bundle found, skipping tool signing.')
return
}
const resourcesPath = path.join(
appBundle,
'Contents',
'Resources',
'app.asar.unpacked',
'resources'
)
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
for (const binary of BINARIES) {
const targetPath = path.join(resourcesPath, binary)
if (!fs.existsSync(targetPath)) {
console.warn(`afterPack: Missing ${binary}, skipping.`)
continue
}
console.log(`afterPack: Signing ${binary} with entitlements.`)
signBinary(targetPath, entitlementsPath)
}
}

View File

@@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

Binary file not shown.

6
conductor.json Normal file
View File

@@ -0,0 +1,6 @@
{
"scripts": {
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
"run": "pnpm run dev"
}
}

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/main/lib/database/schema.ts',
out: './resources/drizzle',
dialect: 'sqlite'
})

View File

@@ -2,13 +2,23 @@ appId: com.vidbee
productName: VidBee
directories:
buildResources: build
afterPack: build/after-pack.cjs
protocols:
- name: VidBee
schemes:
- vidbee
files:
- '!**/.vscode/*'
- '!**/.context/**'
- '!**/.github/**'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!extension/**'
- '!monkey/**'
- '!screenshots/**'
asarUnpack:
- resources/**
win:
@@ -19,16 +29,25 @@ nsis:
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
notarize: true
artifactName: ${name}-${version}-${arch}.${ext}
target:
- target: zip
arch:
- arm64
- x64
- target: dmg
arch:
- arm64
- x64
linux:
target:
- AppImage
- snap
- deb
maintainer: yourname@example.com
maintainer: nexmoex@gmail.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
@@ -38,3 +57,5 @@ publish:
url: https://github.com/nexmoe/vidbee/releases/latest/download
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
electronLanguages:
- en

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

29
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.*

54
extension/README.md Normal file
View File

@@ -0,0 +1,54 @@
# VidBee Video Downloader Extension
VidBee Video Downloader is a lightweight browser companion for the VidBee desktop app. It detects the video on your current tab, shows the available formats, and hands the URL to VidBee so the download happens in the desktop app instead of your browser.
## Why install it?
- **One-click handoff:** Send the current video page to VidBee without copy-pasting links.
- **See formats before downloading:** Preview resolutions, file sizes, and audio-only options in the popup.
- **More reliable downloads:** VidBee handles large files and multi-format downloads better than most browsers.
- **Works on 1,000+ sites:** The extension uses the same site support as the VidBee app.
## What it does
1. Reads the active tab URL when you open the popup.
2. Asks the VidBee desktop app (running locally) to analyze the video.
3. Displays the formats it finds and lets you open VidBee to download.
## Requirements
- VidBee desktop app installed.
- VidBee running while you use the extension.
## How to use
1. Open a supported video page.
2. Click the VidBee extension icon.
3. Review available formats.
4. Click **Download with VidBee** to start the download in the desktop app.
## Development
```bash
pnpm install
pnpm dev
```
Build or package:
```bash
pnpm build
pnpm zip
```
Firefox builds:
```bash
pnpm dev:firefox
pnpm build:firefox
pnpm zip:firefox
```
## Notes on privacy
The extension only sends the current tab URL to the local VidBee app on `127.0.0.1` and stores temporary results in browser storage for faster reloads.

View File

@@ -0,0 +1,163 @@
.vidbee-download-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
transition: all 0.2s ease;
overflow: visible;
}
.vidbee-download-container.vidbee-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0);
}
.vidbee-download-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
opacity: 0.6;
overflow: visible;
}
.vidbee-download-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
.vidbee-download-button:active {
transform: scale(0.95);
}
.vidbee-download-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 16px;
height: 16px;
}
.vidbee-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 50%;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateY(-50%) translateX(4px);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
line-height: 1;
}
.vidbee-tooltip::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
border-left-color: rgba(0, 0, 0, 0.9);
}
.vidbee-download-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.vidbee-close-button {
position: absolute;
top: -6px;
right: -6px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: rgba(255, 77, 77, 0.9);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
z-index: 1;
opacity: 0;
pointer-events: none;
overflow: visible;
}
.vidbee-download-container:hover .vidbee-close-button {
opacity: 1;
pointer-events: auto;
}
.vidbee-close-button:hover {
background: rgba(255, 77, 77, 1);
transform: scale(1.15);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
}
.vidbee-close-button:active {
transform: scale(0.9);
}
.vidbee-close-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 10px;
height: 10px;
}
.vidbee-close-button .vidbee-tooltip {
right: calc(100% + 6px);
top: 50%;
left: auto;
transform: translateY(-50%) translateX(4px);
}
.vidbee-close-button .vidbee-tooltip::after {
left: 100%;
top: 50%;
right: auto;
transform: translateY(-50%);
border-left-color: rgba(0, 0, 0, 0.9);
border-top-color: transparent;
}
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}

View File

@@ -0,0 +1,203 @@
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const STATUS_TIMEOUT_MS = 800
const INFO_TIMEOUT_MS = 60000
const CACHE_TTL_MS = 5 * 60 * 1000
const pendingRequests = new Map<string, Promise<void>>()
const defaultIconPaths = {
16: 'icon/16.png',
32: 'icon/32.png',
48: 'icon/48.png',
128: 'icon/128.png'
}
const loadingIconPaths = {
16: 'icon/icon-loading-16.png',
32: 'icon/icon-loading-32.png',
48: 'icon/icon-loading-48.png',
128: 'icon/icon-loading-128.png'
}
const successIconPaths = {
16: 'icon/icon-success-16.png',
32: 'icon/icon-success-32.png',
48: 'icon/icon-success-48.png',
128: 'icon/icon-success-128.png'
}
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
const paths =
status === 'loading'
? loadingIconPaths
: status === 'success'
? successIconPaths
: defaultIconPaths
const options = tabId ? { path: paths, tabId } : { path: paths }
void browser.action.setIcon(options)
}
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
if (!response.ok) {
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
const combined = [message, details].filter(Boolean).join('\n\n')
throw new Error(combined || `Request failed: ${response.status}`)
}
return data as T
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('signal is aborted')) {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('Failed to fetch')) {
throw new Error('VidBee app not responding on this port.')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
const findAvailablePort = async (): Promise<number | null> => {
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
const baseUrl = `http://127.0.0.1:${port}`
try {
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
return port
} catch {
// Keep scanning.
}
}
return null
}
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
const port = await findAvailablePort()
if (!port) {
throw new Error('VidBee app not found on localhost.')
}
const baseUrl = `http://127.0.0.1:${port}`
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
if (!tokenResponse.token) {
throw new Error('Failed to acquire token from VidBee.')
}
return fetchJson<VideoInfo>(
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
tokenResponse.token
)}`,
INFO_TIMEOUT_MS
)
}
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return {}
return map
}
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
const now = Date.now()
for (const [key, entry] of Object.entries(map)) {
if (now - entry.fetchedAt > CACHE_TTL_MS) {
delete map[key]
}
}
}
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const map = await getCacheMap()
pruneCache(map)
const cache = map[url]
if (!cache) return null
return cache
}
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
const map = await getCacheMap()
pruneCache(map)
map[cache.url] = cache
await browser.storage.local.set({ videoInfoCacheByUrl: map })
}
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
if (pendingRequests.has(url)) {
return pendingRequests.get(url) as Promise<void>
}
const task = (async () => {
const existing = await loadCache(url)
if (existing?.status === 'ready') {
setActionIcon('success', tabId)
return
}
setActionIcon('loading', tabId)
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
try {
const info = await requestVideoInfo(url)
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
setActionIcon('success', tabId)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
setActionIcon('default', tabId)
}
})()
pendingRequests.set(url, task)
try {
await task
} finally {
pendingRequests.delete(url)
}
}
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
if (message.type !== 'video-info:fetch' || !message.url) {
return
}
void fetchAndCache(message.url, sender.tab?.id)
})
})

View File

@@ -0,0 +1,317 @@
:root {
--bg: #ffffff;
--fg: #111111;
--fg-secondary: #757575;
--border: #f0f0f0;
--accent: #000000;
--error: #e00000;
--success: #00c853;
--warning: #ffd600;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--fg);
}
#root {
width: 360px;
min-height: 200px;
padding: 24px;
box-sizing: border-box;
}
.app {
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 13px;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
color: var(--fg);
}
.status-indicator {
font-size: 11px;
font-weight: 500;
color: var(--fg-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--border);
}
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
.status-dot.ok { background-color: var(--success); }
.status-dot.error { background-color: var(--error); }
.video-info {
display: grid;
grid-template-columns: 1fr 90px;
gap: 20px;
align-items: start;
}
.video-details h2 {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
margin: 0 0 8px 0;
color: var(--fg);
}
.meta-row {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.thumbnail {
width: 90px;
height: 50px;
background: var(--border);
object-fit: cover;
border-radius: 6px;
display: block;
}
.formats-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.format-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Sticky headers for long lists */
.sticky-title {
position: sticky;
top: 0;
background: var(--bg);
padding: 4px 0;
z-index: 10;
}
.group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-secondary);
font-weight: 600;
border-bottom: 2px solid var(--border);
padding-bottom: 4px;
}
.format-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.format-table th {
text-align: left;
font-weight: 400;
color: var(--fg-secondary);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
padding-top: 6px;
}
.format-table td {
padding: 6px 0;
border-bottom: 1px solid var(--border);
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.format-table tr:last-child td {
border-bottom: none;
}
.col-id { width: 50px; color: var(--fg-secondary); }
.col-ext { width: 50px; }
.col-size { width: 60px; text-align: right; }
.format-table th.col-size { text-align: right; }
.empty-state {
font-size: 12px;
color: var(--fg-secondary);
padding: 12px 0;
font-style: italic;
text-align: center;
}
.error-banner {
font-size: 12px;
color: var(--error);
line-height: 1.5;
background: rgba(224, 0, 0, 0.05);
padding: 12px;
border-radius: 6px;
}
.primary-button {
background: var(--fg);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
width: 100%;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.primary-button:active {
transform: scale(0.98);
}
.error-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.error-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--fg);
}
.error-description {
font-size: 12px;
color: var(--fg-secondary);
margin: 0;
line-height: 1.5;
}
.action-grid {
display: grid;
gap: 12px;
}
.action-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: #fafafa;
}
.action-title {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-text {
font-size: 13px;
color: var(--fg);
margin: 0;
line-height: 1.5;
}
.secondary-button {
border: 1px solid var(--fg);
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: transparent;
color: var(--fg);
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
}
.secondary-button:hover {
background: var(--border);
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
gap: 16px;
flex: 1;
width: 100%;
}
.spinner {
width: 24px;
height: 24px;
border: 2.5px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 13px;
font-weight: 500;
color: var(--fg-secondary);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

View File

@@ -0,0 +1,473 @@
import { useEffect, useMemo, useState } from 'react'
import './App.css'
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
const CACHE_TTL_MS = 60 * 60 * 1000
const isValidHttpUrl = (value?: string): boolean => {
if (!value) return false
return value.startsWith('http://') || value.startsWith('https://')
}
const formatDuration = (value?: number): string => {
if (!value || value <= 0) return 'Unknown'
const totalSeconds = Math.round(value)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const paddedSeconds = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
}
const formatBytes = (value?: number): string => {
if (!value || value <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
const isVideoFormat = (format: VideoFormat): boolean => {
if (format.vcodec && format.vcodec !== 'none') {
return true
}
return Boolean(format.resolution || format.width || format.height)
}
const isAudioFormat = (format: VideoFormat): boolean => {
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
type VideoGroup = {
label: string
height: number
formats: VideoFormat[]
}
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return null
const cached = map[url]
if (!cached) return null
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
return cached
}
const sanitizeError = (error: string): string => {
const message = error.toLowerCase()
if (
message.includes('localhost') ||
message.includes('fetch') ||
message.includes('network') ||
message.includes('connect') ||
message.includes('failed to request')
) {
return 'Client connection failed'
}
return error
}
function App() {
const [info, setInfo] = useState<VideoInfo | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const [retryTrigger, setRetryTrigger] = useState(0)
useEffect(() => {
let active = true
const targetState = { url: '' }
const handleStorageChange = (
changes: Record<string, browser.storage.StorageChange>,
areaName: string
) => {
if (!active || areaName !== 'local') return
const change = changes.videoInfoCacheByUrl
if (!change?.newValue) return
const map = change.newValue as Record<string, VideoInfoCacheEntry>
const next = map[targetState.url]
if (!next) return
if (next.status === 'ready' && next.info) {
setInfo(next.info)
setError(null)
setLoading(false)
} else if (next.status === 'error' && next.error) {
setError(sanitizeError(next.error))
setInfo(null)
setLoading(false)
} else if (next.status === 'pending') {
setLoading(true)
}
}
browser.storage.onChanged.addListener(handleStorageChange)
const loadInfo = async () => {
setLoading(true)
setError(null)
setInfo(null)
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
if (!isValidHttpUrl(tab?.url)) {
setError('Please open a valid video page first.')
setLoading(false)
return
}
const targetUrl = tab.url as string
targetState.url = targetUrl
setCurrentUrl(targetUrl)
const cached = await loadCachedInfo(targetUrl)
const shouldBypassCache = retryTrigger > 0
if (cached && !shouldBypassCache) {
if (cached.status === 'ready' && cached.info) {
setInfo(cached.info)
setLoading(false)
return
}
if (cached.status === 'error' && cached.error) {
setError(sanitizeError(cached.error))
setLoading(false)
return
}
}
try {
await browser.runtime.sendMessage({
type: 'video-info:fetch',
url: targetUrl
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to request video info.'
setError(sanitizeError(message))
setLoading(false)
}
const latest = await loadCachedInfo(targetUrl)
if (latest && latest.status === 'ready' && latest.info) {
setInfo(latest.info)
setError(null)
setLoading(false)
} else if (latest && latest.status === 'error' && latest.error) {
setError(sanitizeError(latest.error))
setInfo(null)
setLoading(false)
}
}
void loadInfo()
return () => {
active = false
browser.storage.onChanged.removeListener(handleStorageChange)
}
}, [retryTrigger])
const formats = useMemo(() => info?.formats ?? [], [info])
const groupedFormats = useMemo(() => {
const video: VideoFormat[] = []
const audio: VideoFormat[] = []
const other: VideoFormat[] = []
for (const format of formats) {
if (isVideoFormat(format)) {
video.push(format)
} else if (isAudioFormat(format)) {
audio.push(format)
} else {
other.push(format)
}
}
return { video, audio, other }
}, [formats])
const groupedVideoFormats = useMemo(() => {
const raw = groupedFormats.video
if (!raw.length) return []
const groups: Record<number, VideoFormat[]> = {}
const noHeight: VideoFormat[] = []
for (const f of raw) {
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
const heightVal = h ? Number(h) : 0
if (heightVal > 0) {
if (!groups[heightVal]) groups[heightVal] = []
groups[heightVal].push(f)
} else {
noHeight.push(f)
}
}
const sortedLabels = Object.keys(groups)
.map(Number)
.sort((a, b) => b - a)
const result: VideoGroup[] = sortedLabels.map((h) => ({
label: `${h}p`,
height: h,
formats: groups[h].sort((a, b) => {
const sa = a.filesize || a.filesize_approx || 0
const sb = b.filesize || b.filesize_approx || 0
return sb - sa
})
}))
if (noHeight.length > 0) {
result.push({
label: 'Other',
height: 0,
formats: noHeight
})
}
return result
}, [groupedFormats.video])
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const clientLaunchDelayMs = 2000
const openClientApp = async () => {
window.location.href = 'vidbee://'
await wait(clientLaunchDelayMs)
}
const handleOpenClient = () => {
if (!currentUrl) return
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const handleOpenClientAndRetry = async () => {
await openClientApp()
setRetryTrigger((count) => count + 1)
}
const handleRetry = () => {
setRetryTrigger((count) => count + 1)
}
const isInvalidPageError = error === 'Please open a valid video page first.'
const isClientConnectionError = Boolean(error?.includes('Client connection failed'))
const errorTitle = isInvalidPageError
? 'Open a video page'
: isClientConnectionError
? 'Connect the VidBee app'
: 'Something went wrong'
const errorDescription = isInvalidPageError
? 'Navigate to a supported video page, then try again.'
: isClientConnectionError
? 'The extension needs the VidBee desktop app to be running.'
: 'Try again in a moment.'
const renderStatus = () => {
if (loading)
return (
<span className="status-indicator">
<div className="status-dot loading" /> Working
</span>
)
if (error)
return (
<span className="status-indicator">
<div className="status-dot error" /> Error
</span>
)
if (info)
return (
<span className="status-indicator">
<div className="status-dot ok" /> Ready
</span>
)
return (
<span className="status-indicator">
<div className="status-dot" /> Idle
</span>
)
}
return (
<div className="app">
<header>
<h1>VidBee</h1>
{renderStatus()}
</header>
{loading && (
<div className="loading-container">
<div className="spinner" />
<div className="loading-text">Analyzing video...</div>
</div>
)}
{!loading && error && (
<div className="error-container">
<div className="error-header">
<h2 className="error-title">{errorTitle}</h2>
<p className="error-description">{errorDescription}</p>
</div>
<div className="error-banner">{error}</div>
{isClientConnectionError ? (
<div className="action-grid">
<div className="action-card">
<p className="action-title">Client installed</p>
<p className="action-text">
Start VidBee and keep it running, then we will retry automatically.
</p>
<button
type="button"
className="secondary-button"
onClick={handleOpenClientAndRetry}
>
Open Client
</button>
</div>
<div className="action-card">
<p className="action-title">Need the app?</p>
<p className="action-text">
Download VidBee once, install it, then come back here to try again.
</p>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="secondary-button"
>
Download VidBee
</a>
</div>
</div>
) : (
<div className="action-card">
<p className="action-title">Try again</p>
<p className="action-text">
{isInvalidPageError
? 'Open a supported video page, then retry.'
: 'Retry after a moment.'}
</p>
<button type="button" className="secondary-button" onClick={handleRetry}>
Retry
</button>
</div>
)}
</div>
)}
{!loading && !error && info && (
<>
<section className="video-info">
<div className="video-details">
<h2>{info.title || 'Untitled video'}</h2>
<div className="meta-row">
<span>{formatDuration(info.duration)}</span>
<span></span>
<span>{formats.length} formats</span>
</div>
</div>
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
</section>
<button type="button" className="primary-button" onClick={handleOpenClient}>
Download with VidBee
</button>
<section className="formats-section">
{groupedVideoFormats.map((group) => (
<div className="format-group" key={group.label}>
<div className="group-title sticky-title">{group.label}</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{group.formats.map((f) => (
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
<div className="empty-state">No compatible formats.</div>
)}
{groupedFormats.audio.length > 0 && (
<div className="format-group">
<div className="group-title">Audio Only</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{groupedFormats.audio.map((f) => (
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}

28
extension/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "vidbee-extension",
"description": "manifest.json description",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.1.5",
"typescript": "^5.9.3",
"wxt": "^0.20.6"
}
}

3541
extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"extensionName": {
"message": "VidBee Video Downloader"
},
"extensionDescription": {
"message": "Download videos from over 1,000 websites with VidBee."
},
"downloadWithVidBee": {
"message": "Download with VidBee"
},
"hideButton": {
"message": "Hide"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

7
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}

13
extension/wxt.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'wxt'
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
name: '__MSG_extensionName__',
description: '__MSG_extensionDescription__',
default_locale: 'en',
host_permissions: ['http://127.0.0.1/*'],
permissions: ['activeTab', 'storage']
}
})

BIN
icon.icns

Binary file not shown.

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

23
monkey/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,156 @@
// ==UserScript==
// @name vidbee-quick-download
// @namespace vidbee
// @version 0.0.1
// @icon https://vidbee.org/favicon.svg
// @match https://www.youtube.com/*
// @match https://youtube.com/*
// @match https://music.youtube.com/*
// @match https://www.bilibili.com/*
// @match https://bilibili.com/*
// @match https://www.tiktok.com/*
// @match https://tiktok.com/*
// @match https://vimeo.com/*
// @match https://www.vimeo.com/*
// @match https://www.dailymotion.com/*
// @match https://dailymotion.com/*
// @match https://www.twitch.tv/*
// @match https://twitch.tv/*
// @match https://twitter.com/*
// @match https://www.twitter.com/*
// @match https://x.com/*
// @match https://www.x.com/*
// @match https://www.instagram.com/*
// @match https://instagram.com/*
// @match https://www.facebook.com/*
// @match https://facebook.com/*
// @match https://fb.com/*
// @match https://www.fb.com/*
// @match https://www.reddit.com/*
// @match https://reddit.com/*
// @match https://soundcloud.com/*
// @match https://www.soundcloud.com/*
// @match https://www.nicovideo.jp/*
// @match https://nicovideo.jp/*
// @match https://kick.com/*
// @match https://www.kick.com/*
// @match https://bandcamp.com/*
// @match https://*.bandcamp.com/*
// @match https://www.mixcloud.com/*
// @match https://mixcloud.com/*
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));};
const styleCss = '.vidbee-download-container{position:fixed;bottom:16px;right:16px;z-index:9999;transition:all .2s ease;overflow:visible}.vidbee-download-container.vidbee-hidden{opacity:0;pointer-events:none;transform:scale(0)}.vidbee-download-button{position:relative;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;background:#00000080;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fffc;border:1px solid rgba(255,255,255,.1);border-radius:50%;box-shadow:0 2px 8px #0003;cursor:pointer;font-size:0;transition:all .2s ease;opacity:.6;overflow:visible}.vidbee-download-button:hover{opacity:1;background:#000000b3;border-color:#fff3;box-shadow:0 4px 12px #0000004d;transform:scale(1.1)}.vidbee-download-button:active{transform:scale(.95)}.vidbee-download-button svg{flex-shrink:0;stroke:currentColor;width:16px;height:16px}.vidbee-tooltip{position:absolute;right:calc(100% + 8px);top:50%;padding:6px 10px;background:#000000e6;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:500;white-space:nowrap;border-radius:4px;box-shadow:0 2px 8px #0000004d;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(-50%) translate(4px);z-index:10000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1}.vidbee-tooltip:after{content:"";position:absolute;left:100%;top:50%;transform:translateY(-50%);border:4px solid transparent;border-left-color:#000000e6}.vidbee-download-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}.vidbee-close-button{position:absolute;top:-6px;right:-6px;display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;background:#ff4d4de6;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;box-shadow:0 2px 4px #0000004d;cursor:pointer;font-size:0;transition:all .2s ease;z-index:1;opacity:0;pointer-events:none;overflow:visible}.vidbee-download-container:hover .vidbee-close-button{opacity:1;pointer-events:auto}.vidbee-close-button:hover{background:#ff4d4d;transform:scale(1.15);box-shadow:0 3px 6px #0006}.vidbee-close-button:active{transform:scale(.9)}.vidbee-close-button svg{flex-shrink:0;stroke:currentColor;width:10px;height:10px}.vidbee-close-button .vidbee-tooltip{right:calc(100% + 6px);top:50%;left:auto;transform:translateY(-50%) translate(4px)}.vidbee-close-button .vidbee-tooltip:after{left:100%;top:50%;right:auto;transform:translateY(-50%);border-left-color:#000000e6;border-top-color:transparent}.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}';
importCSS(styleCss);
function getVideoUrl() {
return window.location.href;
}
function hideButtonTemporarily() {
const container = document.getElementById("vidbee-download-btn");
if (container) {
container.classList.add("vidbee-hidden");
setTimeout(() => {
if (container) {
container.classList.remove("vidbee-hidden");
}
}, 5e3);
}
}
function createVidBeeButton() {
if (document.getElementById("vidbee-download-btn")) {
return;
}
const videoUrl = getVideoUrl();
if (!videoUrl) {
return;
}
const container = document.createElement("div");
container.id = "vidbee-download-btn";
container.className = "vidbee-download-container";
const button = document.createElement("button");
button.className = "vidbee-download-button";
button.setAttribute("aria-label", "Download with VidBee");
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span class="vidbee-tooltip">Download with VidBee</span>
`;
const closeButton = document.createElement("button");
closeButton.className = "vidbee-close-button";
closeButton.setAttribute("aria-label", "Hide button");
closeButton.innerHTML = `
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<span class="vidbee-tooltip">Hide</span>
`;
closeButton.addEventListener("click", (e) => {
e.stopPropagation();
hideButtonTemporarily();
});
let clickTimer = null;
let clickCount = 0;
button.addEventListener("click", () => {
clickCount++;
if (clickCount === 1) {
clickTimer = window.setTimeout(() => {
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`;
window.location.href = vidbeeUrl;
clickCount = 0;
}, 300);
} else if (clickCount === 2) {
if (clickTimer !== null) {
clearTimeout(clickTimer);
}
clickCount = 0;
hideButtonTemporarily();
}
});
container.appendChild(button);
container.appendChild(closeButton);
document.body.appendChild(container);
}
function init() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", createVidBeeButton);
} else {
createVidBeeButton();
}
let lastUrl = location.href;
let urlCheckTimer = null;
const checkUrlChange = () => {
const currentUrl = location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
const oldButton = document.getElementById("vidbee-download-btn");
if (oldButton) {
oldButton.remove();
}
const hostname = window.location.hostname;
const delay = hostname.includes("bilibili.com") ? 800 : 500;
setTimeout(createVidBeeButton, delay);
}
};
new MutationObserver(() => {
if (urlCheckTimer !== null) {
clearTimeout(urlCheckTimer);
}
urlCheckTimer = window.setTimeout(checkUrlChange, 100);
}).observe(document.body, { childList: true, subtree: true });
window.addEventListener("popstate", () => {
setTimeout(checkUrlChange, 300);
});
}
init();
})();

16
monkey/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "vidbee-quick-download",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vite-plugin-monkey": "^7.1.1"
}
}

943
monkey/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,943 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
typescript:
specifier: ^5.9.2
version: 5.9.3
vite:
specifier: ^7.1.3
version: 7.3.0
vite-plugin-monkey:
specifier: ^7.1.1
version: 7.1.8(postcss@8.5.6)(vite@7.3.0)
packages:
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@rollup/rollup-android-arm-eabi@4.54.0':
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.54.0':
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.54.0':
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.54.0':
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.54.0':
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.54.0':
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.54.0':
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.54.0':
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.54.0':
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.54.0':
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
cuint@0.2.2:
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.4.0:
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
engines: {node: '>=18'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
htmlparser2@10.0.0:
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-wsl@3.1.0:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
mime@2.5.2:
resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
engines: {node: '>=4.0.0'}
hasBin: true
minimatch@3.0.8:
resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
postcss-url@10.1.3:
resolution: {integrity: sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==}
engines: {node: '>=10'}
peerDependencies:
postcss: ^8.0.0
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.54.0:
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
systemjs@6.15.1:
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite-plugin-monkey@7.1.8:
resolution: {integrity: sha512-FGz1jlHcodt+L120UcHLiN0jXqQZdZHxLuHcGQg8Zjas6BulxLM1oiGUg3M95HbjYVFH6ygrOJoZ5YCYu1purw==}
peerDependencies:
vite: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
vite:
optional: true
vite@7.3.0:
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xxhashjs@0.2.2:
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.54.0':
optional: true
'@rollup/rollup-android-arm64@4.54.0':
optional: true
'@rollup/rollup-darwin-arm64@4.54.0':
optional: true
'@rollup/rollup-darwin-x64@4.54.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.54.0':
optional: true
'@rollup/rollup-freebsd-x64@4.54.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.54.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.54.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.54.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.54.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.54.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.54.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.54.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.54.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.54.0':
optional: true
'@types/estree@1.0.8': {}
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
acorn@8.15.0: {}
balanced-match@1.0.2: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
concat-map@0.0.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
cuint@0.2.2: {}
default-browser-id@5.0.1: {}
default-browser@5.4.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-lazy-prop@3.0.0: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
entities@4.5.0: {}
entities@6.0.1: {}
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fsevents@2.3.3:
optional: true
htmlparser2@10.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 6.0.1
import-meta-resolve@4.2.0: {}
is-docker@3.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-wsl@3.1.0:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@3.1.0:
dependencies:
semver: 6.3.1
mime@2.5.2: {}
minimatch@3.0.8:
dependencies:
brace-expansion: 1.1.12
mrmime@2.0.1: {}
nanoid@3.3.11: {}
open@10.2.0:
dependencies:
default-browser: 5.4.0
define-lazy-prop: 3.0.0
is-inside-container: 1.0.0
wsl-utils: 0.1.0
path-key@3.1.1: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
postcss-url@10.1.3(postcss@8.5.6):
dependencies:
make-dir: 3.1.0
mime: 2.5.2
minimatch: 3.0.8
postcss: 8.5.6
xxhashjs: 0.2.2
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.54.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.54.0
'@rollup/rollup-android-arm64': 4.54.0
'@rollup/rollup-darwin-arm64': 4.54.0
'@rollup/rollup-darwin-x64': 4.54.0
'@rollup/rollup-freebsd-arm64': 4.54.0
'@rollup/rollup-freebsd-x64': 4.54.0
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
'@rollup/rollup-linux-arm64-gnu': 4.54.0
'@rollup/rollup-linux-arm64-musl': 4.54.0
'@rollup/rollup-linux-loong64-gnu': 4.54.0
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
'@rollup/rollup-linux-riscv64-musl': 4.54.0
'@rollup/rollup-linux-s390x-gnu': 4.54.0
'@rollup/rollup-linux-x64-gnu': 4.54.0
'@rollup/rollup-linux-x64-musl': 4.54.0
'@rollup/rollup-openharmony-arm64': 4.54.0
'@rollup/rollup-win32-arm64-msvc': 4.54.0
'@rollup/rollup-win32-ia32-msvc': 4.54.0
'@rollup/rollup-win32-x64-gnu': 4.54.0
'@rollup/rollup-win32-x64-msvc': 4.54.0
fsevents: 2.3.3
run-applescript@7.1.0: {}
semver@6.3.1: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
source-map-js@1.2.1: {}
systemjs@6.15.1: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
typescript@5.9.3: {}
vite-plugin-monkey@7.1.8(postcss@8.5.6)(vite@7.3.0):
dependencies:
acorn: 8.15.0
acorn-walk: 8.3.4
cross-spawn: 7.0.6
htmlparser2: 10.0.0
import-meta-resolve: 4.2.0
magic-string: 0.30.21
mrmime: 2.0.1
open: 10.2.0
picocolors: 1.1.1
postcss-url: 10.1.3(postcss@8.5.6)
systemjs: 6.15.1
optionalDependencies:
vite: 7.3.0
transitivePeerDependencies:
- postcss
vite@7.3.0:
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.54.0
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
which@2.0.2:
dependencies:
isexe: 2.0.0
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0
xxhashjs@0.2.2:
dependencies:
cuint: 0.2.2

146
monkey/src/main.ts Normal file
View File

@@ -0,0 +1,146 @@
import './style.css'
// Get current video URL
// yt-dlp can handle all URLs directly, so we just return the current URL
function getVideoUrl(): string | null {
return window.location.href
}
// Temporarily hide button
function hideButtonTemporarily(): void {
const container = document.getElementById('vidbee-download-btn')
if (container) {
container.classList.add('vidbee-hidden')
// Auto restore after 5 seconds
setTimeout(() => {
if (container) {
container.classList.remove('vidbee-hidden')
}
}, 5000)
}
}
// Create VidBee download button
function createVidBeeButton(): void {
// Check if button already exists
if (document.getElementById('vidbee-download-btn')) {
return
}
const videoUrl = getVideoUrl()
if (!videoUrl) {
return
}
// Create button container
const container = document.createElement('div')
container.id = 'vidbee-download-btn'
container.className = 'vidbee-download-container'
// Create main download button
const button = document.createElement('button')
button.className = 'vidbee-download-button'
button.setAttribute('aria-label', 'Download with VidBee')
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span class="vidbee-tooltip">Download with VidBee</span>
`
// Create close button
const closeButton = document.createElement('button')
closeButton.className = 'vidbee-close-button'
closeButton.setAttribute('aria-label', 'Hide button')
closeButton.innerHTML = `
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<span class="vidbee-tooltip">Hide</span>
`
// Handle close button click - temporarily hide
closeButton.addEventListener('click', (e) => {
e.stopPropagation()
hideButtonTemporarily()
})
let clickTimer: number | null = null
let clickCount = 0
// Handle main button click event - single click for download
button.addEventListener('click', () => {
clickCount++
if (clickCount === 1) {
clickTimer = window.setTimeout(() => {
// Single click - trigger download
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
window.location.href = vidbeeUrl
clickCount = 0
}, 300)
} else if (clickCount === 2) {
// Double click - temporarily hide
if (clickTimer !== null) {
clearTimeout(clickTimer)
}
clickCount = 0
hideButtonTemporarily()
}
})
// Assemble container
container.appendChild(button)
container.appendChild(closeButton)
// Insert container directly to body (fixed position)
document.body.appendChild(container)
}
// Initialize when page loads
function init(): void {
// Wait for page to fully load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createVidBeeButton)
} else {
createVidBeeButton()
}
// Handle SPA navigation (when navigating between videos on sites like YouTube, Bilibili, etc.)
let lastUrl = location.href
let urlCheckTimer: number | null = null
const checkUrlChange = () => {
const currentUrl = location.href
if (currentUrl !== lastUrl) {
lastUrl = currentUrl
// Remove old button and create new one
const oldButton = document.getElementById('vidbee-download-btn')
if (oldButton) {
oldButton.remove()
}
// Wait a bit for the page to update (different sites have different update speeds)
const hostname = window.location.hostname
const delay = hostname.includes('bilibili.com') ? 800 : 500
setTimeout(createVidBeeButton, delay)
}
}
// Use MutationObserver for DOM changes (works for most SPA sites)
new MutationObserver(() => {
if (urlCheckTimer !== null) {
clearTimeout(urlCheckTimer)
}
urlCheckTimer = window.setTimeout(checkUrlChange, 100)
}).observe(document.body, { childList: true, subtree: true })
// Also listen to popstate for browser navigation
window.addEventListener('popstate', () => {
setTimeout(checkUrlChange, 300)
})
}
init()

169
monkey/src/style.css Normal file
View File

@@ -0,0 +1,169 @@
/* VidBee Download Button Container */
.vidbee-download-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
transition: all 0.2s ease;
overflow: visible;
}
/* Hidden state */
.vidbee-download-container.vidbee-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0);
}
/* Main Download Button */
.vidbee-download-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
opacity: 0.6;
overflow: visible;
}
.vidbee-download-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
.vidbee-download-button:active {
transform: scale(0.95);
}
.vidbee-download-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 16px;
height: 16px;
}
/* Tooltip */
.vidbee-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 50%;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateY(-50%) translateX(4px);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1;
}
.vidbee-tooltip::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
border-left-color: rgba(0, 0, 0, 0.9);
}
.vidbee-download-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
/* Close Button - Hidden by default */
.vidbee-close-button {
position: absolute;
top: -6px;
right: -6px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: rgba(255, 77, 77, 0.9);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
z-index: 1;
opacity: 0;
pointer-events: none;
overflow: visible;
}
/* Show close button on container hover */
.vidbee-download-container:hover .vidbee-close-button {
opacity: 1;
pointer-events: auto;
}
.vidbee-close-button:hover {
background: rgba(255, 77, 77, 1);
transform: scale(1.15);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
}
.vidbee-close-button:active {
transform: scale(0.9);
}
.vidbee-close-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 10px;
height: 10px;
}
/* Close button tooltip positioning */
.vidbee-close-button .vidbee-tooltip {
right: calc(100% + 6px);
top: 50%;
left: auto;
transform: translateY(-50%) translateX(4px);
}
.vidbee-close-button .vidbee-tooltip::after {
left: 100%;
top: 50%;
right: auto;
transform: translateY(-50%);
border-left-color: rgba(0, 0, 0, 0.9);
border-top-color: transparent;
}
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}

4
monkey/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-monkey/client" />
//// <reference types="vite-plugin-monkey/global" />
/// <reference types="vite-plugin-monkey/style" />

24
monkey/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
monkey/tsconfig.node.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

67
monkey/vite.config.ts Normal file
View File

@@ -0,0 +1,67 @@
import { defineConfig } from 'vite'
import monkey from 'vite-plugin-monkey'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
monkey({
entry: 'src/main.ts',
userscript: {
icon: 'https://vidbee.org/favicon.svg',
namespace: 'vidbee',
match: [
// YouTube
'https://www.youtube.com/*',
'https://youtube.com/*',
'https://music.youtube.com/*',
// Bilibili (哔哩哔哩)
'https://www.bilibili.com/*',
'https://bilibili.com/*',
// TikTok
'https://www.tiktok.com/*',
'https://tiktok.com/*',
// Vimeo
'https://vimeo.com/*',
'https://www.vimeo.com/*',
// Dailymotion
'https://www.dailymotion.com/*',
'https://dailymotion.com/*',
// Twitch
'https://www.twitch.tv/*',
'https://twitch.tv/*',
// Twitter/X
'https://twitter.com/*',
'https://www.twitter.com/*',
'https://x.com/*',
'https://www.x.com/*',
// Instagram
'https://www.instagram.com/*',
'https://instagram.com/*',
// Facebook
'https://www.facebook.com/*',
'https://facebook.com/*',
'https://fb.com/*',
'https://www.fb.com/*',
// Reddit
'https://www.reddit.com/*',
'https://reddit.com/*',
// SoundCloud
'https://soundcloud.com/*',
'https://www.soundcloud.com/*',
// NicoNico
'https://www.nicovideo.jp/*',
'https://nicovideo.jp/*',
// Kick
'https://kick.com/*',
'https://www.kick.com/*',
// Bandcamp
'https://bandcamp.com/*',
'https://*.bandcamp.com/*',
// Mixcloud
'https://www.mixcloud.com/*',
'https://mixcloud.com/*'
]
}
})
]
})

View File

@@ -1,24 +1,27 @@
{
"name": "vidbee",
"version": "0.1.8",
"version": "1.1.12",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
"homepage": "https://github.com/nexmoe/vidbee",
"scripts": {
"check": "biome check --write . && pnpm run typecheck",
"check": "pnpm run check:i18n && biome check --write . && pnpm run typecheck",
"check:i18n": "node scripts/check-locales.js",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp"
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
"build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "git checkout main && git pull && pnpm run check && bumpp",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
@@ -26,10 +29,14 @@
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
@@ -40,13 +47,17 @@
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@tailwindcss/vite": "^4.1.13",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.7",
"electron-ipc-decorator": "^0.2.0",
"electron-log": "^5.4.3",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"flag-icons": "^7.5.0",
"i18next": "^25.5.3",
"jotai": "^2.15.0",
"lucide-react": "^0.544.0",
@@ -54,9 +65,11 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^16.0.0",
"react-router": "^7.9.4",
"rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"yt-dlp-wrap-plus": "^2.3.20",
"zod": "^4.1.11"
},
@@ -69,6 +82,7 @@
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"bumpp": "^10.3.1",
"drizzle-kit": "^0.31.7",
"electron": "^38.1.2",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",

746
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,13 @@
yt-dlp.exe
yt-dlp_macos
yt-dlp_linux
ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
deno.exe
deno
# But keep the README
!README.md
!.gitignore

View File

@@ -48,8 +48,44 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
```
## ffmpeg Binaries
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
### Required Files
1. **Windows**: `ffmpeg.exe`
2. **macOS**: `ffmpeg_macos`
3. **Linux**: `ffmpeg_linux`
### How to Download
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
### Note
- If you don't place binaries here, the app will attempt to download them at runtime
- The app will automatically use the bundled version if available
- File sizes: ~10-15 MB per binary
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
## JS Runtime (Deno)
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
### Required Files
1. **Windows**: `deno.exe`
2. **macOS**: `deno`
3. **Linux**: `deno`
### How to Download
- Visit: <https://github.com/denoland/deno/releases/latest>
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
### Note
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.

View File

@@ -0,0 +1,66 @@
CREATE TABLE `download_history` (
`id` text PRIMARY KEY NOT NULL,
`url` text NOT NULL,
`title` text NOT NULL,
`thumbnail` text,
`type` text NOT NULL,
`status` text NOT NULL,
`download_path` text,
`saved_file_name` text,
`file_size` integer,
`duration` integer,
`downloaded_at` integer NOT NULL,
`completed_at` integer,
`sort_key` integer NOT NULL,
`error` text,
`description` text,
`channel` text,
`uploader` text,
`view_count` integer,
`tags` text,
`origin` text,
`subscription_id` text,
`selected_format` text,
`playlist_id` text,
`playlist_title` text,
`playlist_index` integer,
`playlist_size` integer
);
--> statement-breakpoint
CREATE TABLE `subscription_items` (
`subscription_id` text NOT NULL,
`item_id` text NOT NULL,
`title` text NOT NULL,
`url` text NOT NULL,
`published_at` integer NOT NULL,
`thumbnail` text,
`added` integer NOT NULL,
`download_id` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
PRIMARY KEY(`subscription_id`, `item_id`)
);
--> statement-breakpoint
CREATE INDEX `subscription_items_subscription_idx` ON `subscription_items` (`subscription_id`);--> statement-breakpoint
CREATE TABLE `subscriptions` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`source_url` text NOT NULL,
`feed_url` text NOT NULL,
`platform` text NOT NULL,
`keywords` text NOT NULL,
`tags` text NOT NULL,
`only_latest` integer NOT NULL,
`enabled` integer NOT NULL,
`cover_url` text,
`latest_video_title` text,
`latest_video_published_at` integer,
`last_checked_at` integer,
`last_success_at` integer,
`status` text NOT NULL,
`last_error` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`download_directory` text,
`naming_template` text
);

View File

@@ -0,0 +1,451 @@
{
"version": "6",
"dialect": "sqlite",
"id": "91501763-7554-435d-91d8-382c52ee65e4",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail": {
"name": "thumbnail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_path": {
"name": "download_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"saved_file_name": {
"name": "saved_file_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_key": {
"name": "sort_key",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel": {
"name": "channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uploader": {
"name": "uploader",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"selected_format": {
"name": "selected_format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_id": {
"name": "playlist_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_title": {
"name": "playlist_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_index": {
"name": "playlist_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_size": {
"name": "playlist_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscription_items": {
"name": "subscription_items",
"columns": {
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail": {
"name": "thumbnail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added": {
"name": "added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_id": {
"name": "download_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"subscription_items_subscription_idx": {
"name": "subscription_items_subscription_idx",
"columns": ["subscription_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"subscription_items_pk": {
"columns": ["subscription_id", "item_id"],
"name": "subscription_items_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feed_url": {
"name": "feed_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keywords": {
"name": "keywords",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"only_latest": {
"name": "only_latest",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_url": {
"name": "cover_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_video_title": {
"name": "latest_video_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_video_published_at": {
"name": "latest_video_published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_checked_at": {
"name": "last_checked_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_success_at": {
"name": "last_success_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_directory": {
"name": "download_directory",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"naming_template": {
"name": "naming_template",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1763176841336,
"tag": "0000_swift_aaron_stack",
"breakpoints": true
}
]
}

BIN
resources/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

100
scripts/check-locales.js Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
const fs = require('node:fs')
const path = require('node:path')
const localesDir = path.join(__dirname, '..', 'src', 'renderer', 'src', 'locales')
const baseLocaleFile = 'en.json'
const readJson = (filePath) => {
try {
const raw = fs.readFileSync(filePath, 'utf8')
return JSON.parse(raw)
} catch (error) {
console.error(`ERROR: Failed to read ${filePath}`)
console.error(String(error))
process.exit(1)
}
}
const collectLeafKeys = (value, prefix = '', keys = new Set()) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, child] of Object.entries(value)) {
const next = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) {
collectLeafKeys(child, next, keys)
} else {
keys.add(next)
}
}
return keys
}
if (prefix) {
keys.add(prefix)
}
return keys
}
if (!fs.existsSync(localesDir)) {
console.error(`ERROR: Locales directory not found: ${localesDir}`)
process.exit(1)
}
const localeFiles = fs
.readdirSync(localesDir)
.filter((file) => file.endsWith('.json'))
.sort()
if (!localeFiles.includes(baseLocaleFile)) {
console.error(`ERROR: Base locale file not found: ${baseLocaleFile}`)
process.exit(1)
}
const baseLocalePath = path.join(localesDir, baseLocaleFile)
const baseLocaleData = readJson(baseLocalePath)
const baseKeys = collectLeafKeys(baseLocaleData)
let hasMissing = false
let hasExtra = false
for (const file of localeFiles) {
if (file === baseLocaleFile) {
continue
}
const localePath = path.join(localesDir, file)
const localeData = readJson(localePath)
const localeKeys = collectLeafKeys(localeData)
const missing = [...baseKeys].filter((key) => !localeKeys.has(key))
const extra = [...localeKeys].filter((key) => !baseKeys.has(key))
if (missing.length > 0) {
hasMissing = true
console.error(`ERROR: Missing keys in ${file}`)
for (const key of missing) {
console.error(` - ${key}`)
}
}
if (extra.length > 0) {
hasExtra = true
console.warn(`WARN: Extra keys in ${file}`)
for (const key of extra) {
console.warn(` - ${key}`)
}
}
}
if (hasMissing) {
process.exit(1)
}
if (hasExtra) {
console.log('INFO: No missing keys, but extra keys were found.')
process.exit(0)
}
console.log('OK: All locale files include every key from en.json.')

View File

@@ -3,38 +3,6 @@
const fs = require('node:fs')
const path = require('node:path')
/**
* Check if yt-dlp binary exists in resources directory
* Usage: node scripts/check-ytdlp.js [platform]
* Platform options: win, mac, linux
* Exit with error code 1 if not found
*/
function checkYtDlpExists(platform) {
const platformMap = {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
}
const filename = platformMap[platform]
if (!filename) {
console.error('❌ Error: Invalid platform specified!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const ytdlpPath = path.join(__dirname, '..', 'resources', filename)
if (!fs.existsSync(ytdlpPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
console.error('You can download it from: https://github.com/yt-dlp/yt-dlp/releases/latest')
process.exit(1)
}
console.log(`${filename} found in resources/ directory`)
}
// Get platform from command line arguments
const platform = process.argv[2]
@@ -44,5 +12,72 @@ if (!platform) {
process.exit(1)
}
// Run the check
checkYtDlpExists(platform)
const supportedPlatforms = ['win', 'mac', 'linux']
if (!supportedPlatforms.includes(platform)) {
console.error('❌ Error: Invalid platform specified!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const binaries = [
{
label: 'yt-dlp',
filenameMap: {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
},
help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
}
},
{
label: 'ffmpeg',
filenameMap: {
win: 'ffmpeg.exe',
mac: 'ffmpeg_macos',
linux: 'ffmpeg_linux'
},
help: {
win: 'https://ffmpeg.org/download.html',
linux: 'https://ffmpeg.org/download.html',
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
}
},
{
label: 'deno',
filenameMap: {
win: 'deno.exe',
mac: 'deno',
linux: 'deno'
},
help: {
default: 'https://github.com/denoland/deno/releases/latest'
}
}
]
let hasMissingBinary = false
for (const binary of binaries) {
const filename = binary.filenameMap[platform]
const binaryPath = path.join(__dirname, '..', 'resources', filename)
if (!fs.existsSync(binaryPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
const help =
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
if (help) {
console.error(`See ${help}`)
}
hasMissingBinary = true
} else {
console.log(`${filename} found in resources/ directory`)
}
}
if (hasMissingBinary) {
process.exit(1)
}

701
scripts/setup-dev-binaries.js Executable file
View File

@@ -0,0 +1,701 @@
#!/usr/bin/env node
/**
* Development environment setup script
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
*/
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { execSync, spawnSync } = require('node:child_process')
const https = require('node:https')
const http = require('node:http')
// Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
const GITHUB_TOKEN =
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
// Platform configuration
const PLATFORM_CONFIG = {
win32: {
ytdlp: {
asset: 'yt-dlp.exe',
output: 'yt-dlp.exe'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
output: 'ffmpeg.exe',
extract: 'unzip',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
binaryName: 'ffmpeg.exe'
}
}
},
darwin: {
ytdlp: {
asset: 'yt-dlp_macos',
output: 'yt-dlp_macos'
},
ffmpeg: {
// For development, download only the architecture matching current system
arm64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-arm64.*\.zip$/i
}
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-x86_64.*\.zip$/i
}
}
}
},
linux: {
ytdlp: {
asset: 'yt-dlp',
output: 'yt-dlp_linux'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
extract: 'tar',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
}
}
}
// Utility functions
function log(message, type = 'info') {
const icons = {
info: '📦',
success: '✅',
error: '❌',
warn: '⚠️',
download: '⬇️'
}
console.log(`${icons[type] || ''} ${message}`)
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function safeUnlink(filePath) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
function getDownloadHeaders(url) {
const headers = {
'User-Agent': 'vidbee-setup',
Accept: '*/*'
}
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
return headers
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
let downloadedBytes = 0
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
safeUnlink(dest)
const redirectUrl = response.headers.location
if (!redirectUrl) {
return reject(new Error(`Redirect without location for ${url}`))
}
log(`Redirected to ${redirectUrl}`, 'info')
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
}
const contentLength = response.headers['content-length']
if (response.statusCode !== 200) {
file.close()
safeUnlink(dest)
return reject(
new Error(
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
)
)
}
response.on('data', (chunk) => {
downloadedBytes += chunk.length
})
response.pipe(file)
file.on('finish', () => {
file.close()
log(
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
downloadedBytes ? 'success' : 'warn'
)
resolve()
})
})
request.setTimeout(30000, () => {
request.destroy(new Error('Download timeout'))
})
request.on('error', (err) => {
file.close()
safeUnlink(dest)
log(`Download error for ${url}: ${err.message}`, 'error')
reject(err)
})
})
}
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
await downloadFile(url, dest)
return
} catch (error) {
lastError = error
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
await new Promise((resolve) => setTimeout(resolve, backoff))
}
}
}
throw lastError
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const headers = {
'User-Agent': 'vidbee-setup',
Accept: 'application/vnd.github+json'
}
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
protocol
.get(url, { headers }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
return fetchJson(response.headers.location).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
}
let body = ''
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
try {
resolve(JSON.parse(body))
} catch (error) {
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
}
})
})
.on('error', (err) => {
reject(err)
})
})
}
function inferFfmpegInnerPath(assetName, binaryName) {
if (!assetName) {
return null
}
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
if (!match) {
return null
}
return `${match[1]}/bin/${binaryName}`
}
async function resolveReleaseAsset(release) {
if (!release) {
return null
}
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
if (repoCandidates.length === 0) {
return null
}
let lastError
for (const repo of repoCandidates) {
try {
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
const assets = Array.isArray(data.assets) ? data.assets : []
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
if (match?.browser_download_url) {
return { name: match.name, url: match.browser_download_url }
}
lastError = new Error(`No matching assets found in ${repo}`)
} catch (error) {
lastError = error
}
}
if (lastError) {
throw lastError
}
return null
}
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
if (platform === 'win32') {
// Use PowerShell Expand-Archive on Windows
try {
const zipAbsPath = path.resolve(zipPath)
const extractAbsDir = path.resolve(extractDir)
execSync(
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
{ stdio: 'inherit' }
)
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
} else {
// Use unzip command on macOS/Linux
try {
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
}
}
function extractTarXz(tarPath, extractDir) {
ensureDir(extractDir)
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
}
function setExecutable(filePath) {
if (os.platform() !== 'win32') {
fs.chmodSync(filePath, 0o755)
}
}
function fileExists(filePath) {
return fs.existsSync(filePath)
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) {
return 'unknown size'
}
if (bytes >= 1024 * 1024) {
return `${Math.round(bytes / (1024 * 1024))} MB`
}
return `${Math.round(bytes / 1024)} KB`
}
function checkBinary(filePath, args, label) {
const result = spawnSync(filePath, args, {
encoding: 'utf8',
timeout: 8000,
windowsHide: true
})
if (result.error) {
return { ok: false, message: result.error.message }
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
return { ok: false, message: output || `exit code ${result.status}` }
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
}
function getDenoAssetName(platform, arch) {
if (platform === 'win32') {
if (arch === 'arm64') {
return 'deno-aarch64-pc-windows-msvc.zip'
}
return 'deno-x86_64-pc-windows-msvc.zip'
}
if (platform === 'darwin') {
if (arch === 'arm64') {
return 'deno-aarch64-apple-darwin.zip'
}
return 'deno-x86_64-apple-darwin.zip'
}
if (platform === 'linux') {
if (arch === 'arm64') {
return 'deno-aarch64-unknown-linux-gnu.zip'
}
return 'deno-x86_64-unknown-linux-gnu.zip'
}
return null
}
function getDenoOutputName(platform) {
return platform === 'win32' ? 'deno.exe' : 'deno'
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ${asset}...`, 'download')
const url = `${YTDLP_BASE_URL}/${asset}`
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
throw error
}
}
async function downloadFfmpegWindows(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Windows...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
if (inferred) {
innerPath = inferred
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegMac(config) {
const arch = os.arch()
const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64']
if (!ffmpegConfig) {
throw new Error(`Unsupported architecture: ${arch}`)
}
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegLinux(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Linux...`, 'download')
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
if (inferred) {
innerPath = inferred
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFileWithRetry(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempTar)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadDenoRuntime() {
const platform = os.platform()
const arch = os.arch()
const assetName = getDenoAssetName(platform, arch)
if (!assetName) {
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
return
}
const outputName = getDenoOutputName(platform)
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
}
log(`${outputName} already exists, skipping download`, 'info')
return
}
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting Deno runtime...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, outputName)
if (!fileExists(sourcePath)) {
throw new Error(`Deno binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
}
log(`Downloaded ${outputName} successfully`, 'success')
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
const config = PLATFORM_CONFIG[platform]
if (!config) {
log(`Unsupported platform: ${platform}`, 'error')
process.exit(1)
}
log(`Setting up development binaries for ${platform}...`, 'info')
ensureDir(RESOURCES_DIR)
try {
// Download yt-dlp
await downloadYtDlp(config)
// Download JS runtime (Deno)
await downloadDenoRuntime()
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)
} else if (platform === 'darwin') {
await downloadFfmpegMac(config)
} else if (platform === 'linux') {
await downloadFfmpegLinux(config)
}
log('Development environment setup completed!', 'success')
} catch (error) {
log(`Setup failed: ${error.message}`, 'error')
process.exit(1)
}
}
// Run setup
if (require.main === module) {
setup()
}
module.exports = { setup }

View File

@@ -5,15 +5,6 @@ import log from 'electron-log/main'
* Set log format, file path, transport methods, etc.
*/
export function configureLogger() {
// Configure console output format - support colors and scope, time in gray
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
// Enable console colors
log.transports.console.useStyles = true
// Configure file output format - include scope information
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
// Set log levels
// Development: show all logs
// Production: show info level and above only

View File

@@ -1,20 +1,47 @@
import path from 'node:path'
import type { AppSettings, DownloadOptions } from '../../shared/types'
import { resolvePathWithHome } from '../utils/path-helpers'
export const sanitizeFilenameTemplate = (template: string): string => {
const trimmed = template.trim()
if (!trimmed) {
return '%(title)s via VidBee.%(ext)s'
}
const normalized = trimmed.replace(/\\/g, '/')
const safeParts = normalized
.split('/')
.map((part) => part.trim())
.filter((part) => part !== '' && part !== '.' && part !== '..')
.map((part) => part.replace(/[<>:"|?*]/g, '-').replace(/[. ]+$/g, ''))
.filter((part) => part !== '')
return safeParts.length === 0 ? '%(title)s via VidBee.%(ext)s' : safeParts.join('/')
}
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
const format = options.format
const audioFormat = options.audioFormat
const audioFormatIds = (options.audioFormatIds ?? []).filter((id) => id.trim() !== '')
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
if (format && audioFormat === '') {
return format
}
if (format && (format.includes('/') || format.includes('+') || format.includes('['))) {
return format
}
if (audioFormatIds.length > 0) {
const baseVideo = format && format !== 'best' ? format : 'bestvideo*'
return `${baseVideo}+${audioFormatIds.join('+')}`
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
return 'bestvideo+bestaudio/best'
}
return `bestvideo+${audioFormat}`
}
@@ -41,25 +68,47 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
return format
}
const isBilibiliUrl = (url: string): boolean => {
try {
const host = new URL(url).hostname.toLowerCase()
return (
host === 'bilibili.com' ||
host.endsWith('.bilibili.com') ||
host === 'b23.tv' ||
host.endsWith('.b23.tv') ||
host === 'bili.tv' ||
host.endsWith('.bili.tv')
)
} catch {
return false
}
}
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
settings: AppSettings,
jsRuntimeArgs: string[] = []
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
const args: string[] = ['--no-playlist', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
args.push('-f', resolveVideoFormatSelector(options))
const formatSelector = resolveVideoFormatSelector(options)
args.push('-f', formatSelector)
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
args.push('--audio-multistreams')
} else if (formatSelector.includes('mergeall')) {
args.push('--audio-multistreams')
}
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
// based on codec compatibility. Forcing MP4 can cause failures
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
} else if (options.type === 'audio') {
args.push('-f', resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
@@ -69,13 +118,38 @@ export const buildDownloadArgs = (
args.push('--download-sections', `*${start}-${end || ''}`)
}
const embedSubs = settings.embedSubs
const embedMetadata = settings.embedMetadata
const embedChapters = settings.embedChapters
const hasSubtitleAuth =
(settings.browserForCookies && settings.browserForCookies !== 'none') ||
Boolean(settings.cookiesPath?.trim())
const shouldAttemptSubtitles = !isBilibiliUrl(options.url) || hasSubtitleAuth
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
if (shouldAttemptSubtitles) {
if (embedSubs) {
args.push('--sub-langs', 'all')
} else {
args.push('--write-subs')
}
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
} else {
args.push('--no-embed-subs')
}
if (process.platform !== 'darwin') {
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
}
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
// Output path with proper encoding handling
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
const filenameTemplate = sanitizeFilenameTemplate(
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
)
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
args.push('-o', outputTemplate)
// Add options for better filename handling
@@ -90,12 +164,22 @@ export const buildDownloadArgs = (
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
if (settings.configPath) {
args.push('--config-location', settings.configPath)
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
if (jsRuntimeArgs.length > 0) {
args.push(...jsRuntimeArgs)
}
args.push(options.url)

View File

@@ -6,7 +6,6 @@ import type {
} from '../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -15,7 +14,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -187,7 +185,7 @@ export const resolveSelectedFormat = (
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
const preset = settings.oneClickQuality ?? 'best'
if (options.type === 'video') {
const videoFormats = formats.filter(
@@ -196,7 +194,7 @@ export const resolveSelectedFormat = (
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
if (options.type === 'audio') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&

View File

@@ -1,15 +1,30 @@
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
import {
app,
BrowserWindow,
type BrowserWindowConstructorOptions,
ipcMain,
protocol,
shell
} from 'electron'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { configureLogger } from './config/logger-config'
import { services } from './ipc'
import { downloadEngine } from './lib/download-engine'
import { ffmpegManager } from './lib/ffmpeg-manager'
import { subscriptionManager } from './lib/subscription-manager'
import { subscriptionScheduler } from './lib/subscription-scheduler'
import { ytdlpManager } from './lib/ytdlp-manager'
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
import { applyAutoLaunchSetting } from './utils/auto-launch'
import { applyDockVisibility } from './utils/dock'
// Initialize electron-log for main process
log.initialize()
@@ -17,21 +32,120 @@ log.initialize()
// Configure logger settings
configureLogger()
const RENDERER_DIST_PATH = join(__dirname, '../renderer')
protocol.registerSchemesAsPrivileged([
{
scheme: APP_PROTOCOL,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true
}
}
])
let mainWindow: BrowserWindow | null = null
let isQuitting = false
interface DeepLinkData {
url: string
type: 'single' | 'playlist'
}
const pendingDeepLinkUrls: DeepLinkData[] = []
let isRendererReady = false
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
try {
const parsed = new URL(rawUrl)
if (parsed.protocol !== `${APP_PROTOCOL}:`) {
return null
}
const host = parsed.hostname
const path = parsed.pathname.replace(/^\/+/, '')
const isDownloadLink = host === 'download' || path.startsWith('download')
if (!isDownloadLink) {
return null
}
const targetUrl = parsed.searchParams.get('url')
if (!targetUrl || !targetUrl.trim()) {
return null
}
const typeParam = parsed.searchParams.get('type')
const type = typeParam === 'playlist' ? 'playlist' : 'single'
return {
url: targetUrl.trim(),
type
}
} catch (error) {
log.warn('Failed to parse deep link:', error)
return null
}
}
const deliverDeepLink = (data: DeepLinkData): void => {
if (!mainWindow || !isRendererReady) {
pendingDeepLinkUrls.push(data)
return
}
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
mainWindow.webContents.send('download:deeplink', data)
}
const flushPendingDeepLinks = (): void => {
if (!mainWindow || !isRendererReady || pendingDeepLinkUrls.length === 0) {
return
}
const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length)
for (const data of pending) {
mainWindow.webContents.send('download:deeplink', data)
}
}
const handleDeepLinkUrl = (rawUrl: string): void => {
const data = parseDownloadDeepLink(rawUrl)
if (!data) {
log.warn('Ignored unsupported deep link:', rawUrl)
return
}
deliverDeepLink(data)
}
const handleDeepLinkArgv = (argv: string[]): void => {
for (const arg of argv) {
if (arg.startsWith(`${APP_PROTOCOL}://`)) {
handleDeepLinkUrl(arg)
}
}
}
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
})
export function createWindow(): void {
// Create the browser window
mainWindow = new BrowserWindow({
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200,
height: 800,
show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS
autoHideMenuBar: true,
icon: appIcon, // Set application icon
frame: false,
vibrancy: 'fullscreen-ui', // on MacOS
backgroundMaterial: 'acrylic', // on Windows 11
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -39,7 +153,20 @@ export function createWindow(): void {
nodeIntegration: false,
webSecurity: false // Allow drag regions to work
}
})
}
if (isMac) {
windowOptions.titleBarStyle = 'hidden'
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
windowOptions.vibrancy = 'fullscreen-ui'
}
if (isWindows) {
windowOptions.backgroundMaterial = 'acrylic'
}
// Create the browser window
mainWindow = new BrowserWindow(windowOptions)
mainWindow.on('close', (event) => {
const closeToTray = settingsManager.get('closeToTray')
@@ -50,6 +177,9 @@ export function createWindow(): void {
})
mainWindow.on('ready-to-show', () => {
if (shouldStartHidden) {
return
}
mainWindow?.show()
})
@@ -63,13 +193,57 @@ export function createWindow(): void {
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
}
mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
isRendererReady = true
flushPendingDeepLinks()
})
// Setup error handling for renderer process
setupRendererErrorHandling()
// Setup download engine event forwarding to renderer
setupDownloadEvents()
}
function setupRendererErrorHandling(): void {
if (!mainWindow) return
// Handle uncaught exceptions in renderer process
mainWindow.webContents.on('unresponsive', () => {
log.error('Renderer process became unresponsive')
})
mainWindow.webContents.on('responsive', () => {
log.info('Renderer process became responsive again')
})
// Listen for renderer errors via IPC
ipcMain.on('error:renderer', (_event, errorData) => {
log.error('Renderer error received:', errorData)
// Log detailed error information
if (errorData.error) {
log.error('Error name:', errorData.error.name)
log.error('Error message:', errorData.error.message)
if (errorData.error.stack) {
log.error('Error stack:', errorData.error.stack)
}
}
if (errorData.errorInfo?.componentStack) {
log.error('Component stack:', errorData.errorInfo.componentStack)
}
if (errorData.context) {
log.error('Error context:', errorData.context)
}
})
}
function setupDownloadEvents(): void {
downloadEngine.on('download-started', (id: string) => {
mainWindow?.webContents.send('download:started', id)
@@ -92,14 +266,69 @@ function setupDownloadEvents(): void {
})
}
function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') {
console.log('Skipping auto-updater initialization in development mode')
return
function sanitizeRequestPath(requestUrl: URL): string {
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
const trimmedLeading = rawPath.replace(/^\/+/, '')
const cleaned = trimmedLeading.replace(/\/+$/, '')
return cleaned || 'index.html'
}
function isWithinBase(targetPath: string, basePath: string): boolean {
const relativePath = relative(basePath, targetPath)
return !relativePath.startsWith('..') && !isAbsolute(relativePath)
}
function resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {
const sanitizedPath = sanitizeRequestPath(requestUrl)
const [rootSegment, ...restSegments] = sanitizedPath.split('/')
const rendererPath = restSegments.join('/') || 'index.html'
if (rootSegment === 'renderer') {
const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)
if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {
return rendererTarget
}
}
const userDataTarget = resolve(userDataPath, sanitizedPath)
if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {
return userDataTarget
}
const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)
if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {
return rendererFallback
}
return null
}
function registerVidbeeProtocol(): void {
try {
console.log('Initializing auto-updater...')
const userDataPath = app.getPath('userData')
protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {
const requestUrl = new URL(request.url)
const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)
if (!filePath) {
log.error(`File not found for ${request.url}`)
callback({ error: -6 })
return
}
callback(filePath)
})
} catch (error) {
log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)
}
}
function initAutoUpdater(): void {
try {
log.info('Initializing auto-updater...')
log.transports.file.level = 'info'
autoUpdater.logger = log
@@ -108,56 +337,74 @@ function initAutoUpdater(): void {
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
mainWindow?.webContents.send('update:available', info)
// If auto-update is enabled, the update will be downloaded automatically
// because autoDownload is set to true
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, update will be downloaded automatically')
}
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
// Check for updates immediately if auto-update is enabled
const autoUpdateEnabled = settingsManager.get('autoUpdate')
if (autoUpdateEnabled) {
log.info('Auto-update is enabled, checking for updates immediately...')
// Use checkForUpdates instead of checkForUpdatesAndNotify
// because we have our own notification system and want to ensure immediate download
void autoUpdater.checkForUpdates()
} else {
log.info('Auto-update is disabled, skipping automatic update check')
}
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
}
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', (_event, argv) => {
handleDeepLinkArgv(argv)
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
})
}
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLinkUrl(url)
})
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -165,15 +412,42 @@ app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.vidbee')
registerVidbeeProtocol()
const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)
if (!registered) {
log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)
}
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
// Enable F12 to toggle DevTools in both development and production
window.webContents.on('before-input-event', (_, input) => {
if (input.key === 'F12') {
if (window.webContents.isDevToolsOpened()) {
window.webContents.closeDevTools()
} else {
window.webContents.openDevTools()
}
}
})
})
// IPC services are automatically registered by electron-ipc-decorator when imported
log.info('IPC services available:', Object.keys(services))
// Initialize ffmpeg
try {
log.info('Initializing ffmpeg...')
await ffmpegManager.initialize()
log.info('ffmpeg initialized successfully')
} catch (error) {
log.error('Failed to initialize ffmpeg:', error)
}
// Initialize yt-dlp
try {
log.info('Initializing yt-dlp...')
@@ -183,6 +457,11 @@ app.whenReady().then(async () => {
log.error('Failed to initialize yt-dlp:', error)
}
await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
createWindow()
initAutoUpdater()
@@ -190,6 +469,10 @@ app.whenReady().then(async () => {
// Create system tray
createTray()
subscriptionScheduler.start()
handleDeepLinkArgv(process.argv)
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
@@ -223,6 +506,7 @@ app.on('window-all-closed', () => {
// Cleanup tray on quit
app.on('will-quit', () => {
destroyTray()
void stopExtensionApiServer()
})
// In this file you can include the rest of your app's specific main process

View File

@@ -1,9 +1,11 @@
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
import { AppService } from './services/app-service'
import { BrowserCookiesService } from './services/browser-cookies-service'
import { DownloadService } from './services/download-service'
import { FileSystemService } from './services/file-system-service'
import { HistoryService } from './services/history-service'
import { SettingsService } from './services/settings-service'
import { SubscriptionService } from './services/subscription-service'
import { ThumbnailService } from './services/thumbnail-service'
import { UpdateService } from './services/update-service'
import { WindowService } from './services/window-service'
@@ -11,10 +13,12 @@ import { WindowService } from './services/window-service'
// Create services with automatic type inference
export const services = createServices([
AppService,
BrowserCookiesService,
DownloadService,
FileSystemService,
HistoryService,
SettingsService,
SubscriptionService,
ThumbnailService,
UpdateService,
WindowService

View File

@@ -1,6 +1,7 @@
import os from 'node:os'
import { app, BrowserWindow, dialog } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
class AppService extends IpcService {
static readonly groupName = 'app'
@@ -15,6 +16,37 @@ class AppService extends IpcService {
return os.platform()
}
@IpcMethod()
getOsVersion(_context: IpcContext): string {
const platform = os.platform()
const platformLabel =
platform === 'darwin'
? 'macOS'
: platform === 'win32'
? 'Windows'
: platform === 'linux'
? 'Linux'
: platform
const systemVersion =
typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'
? (process as { getSystemVersion: () => string }).getSystemVersion()
: typeof os.version === 'function'
? os.version()
: os.release()
if (platform === 'win32') {
const buildToken = systemVersion.split('.').at(-1) ?? ''
const buildNumber = Number.parseInt(buildToken, 10)
const windowsName =
Number.isFinite(buildNumber) && buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
return Number.isFinite(buildNumber)
? `${windowsName} (build ${buildNumber})`
: `${platformLabel} ${systemVersion}`.trim()
}
return `${platformLabel} ${systemVersion}`.trim()
}
@IpcMethod()
quit(_context: IpcContext): void {
app.quit()
@@ -32,6 +64,26 @@ class AppService extends IpcService {
return dialog.showMessageBox(options)
}
@IpcMethod()
async getSiteIcon(_context: IpcContext, domain: string): Promise<string | null> {
try {
const iconUrl = `https://unavatar.io/${domain}`
const response = await fetch(iconUrl)
if (!response.ok) {
return null
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const contentType = response.headers.get('content-type') || 'image/png'
const base64 = buffer.toString('base64')
return `data:${contentType};base64,${base64}`
} catch (error) {
scopedLoggers.system.error('Failed to fetch site icon:', error)
return null
}
}
}
export { AppService }

View File

@@ -0,0 +1,270 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { resolvePathWithHome } from '../../utils/path-helpers'
class BrowserCookiesService extends IpcService {
static readonly groupName = 'browserCookies'
private buildValidationResult(valid: boolean, reason?: string) {
if (valid) {
return { valid }
}
return { valid, reason }
}
private isDirectory(target: string): boolean {
try {
return fs.statSync(target).isDirectory()
} catch {
return false
}
}
private pickFirstDirectory(paths: string[]): string {
for (const candidate of paths) {
if (this.isDirectory(candidate)) {
return candidate
}
}
return ''
}
private normalizeProfileInput(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, '')
}
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
if (platform === 'win32') {
if (browser === 'edge') {
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
}
if (browser === 'whale') {
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
}
if (browser === 'opera') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
}
}
if (platform === 'darwin') {
if (browser === 'edge') {
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
}
if (browser === 'whale') {
return [
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
]
}
if (browser === 'opera') {
return [
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
}
if (browser === 'safari') {
return [path.join(homeDir, 'Library', 'Safari')]
}
}
if (platform === 'linux') {
if (browser === 'edge') {
return [path.join(homeDir, '.config', 'microsoft-edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, '.config', 'google-chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, '.config', 'chromium')]
}
if (browser === 'brave') {
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, '.config', 'vivaldi')]
}
if (browser === 'whale') {
return [path.join(homeDir, '.config', 'naver-whale')]
}
if (browser === 'opera') {
return [path.join(homeDir, '.config', 'opera')]
}
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
if (platform === 'freebsd') {
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
return []
}
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
const base = baseDirs[0]
if (!base) {
return ''
}
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
return base
}
return path.join(base, 'Default')
}
private findFirefoxProfilePath(profilesDir: string): string {
if (!this.isDirectory(profilesDir)) {
return ''
}
const entries = fs
.readdirSync(profilesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b))
const preferred =
entries.find((name) => name.endsWith('.default-release')) ??
entries.find((name) => name.endsWith('.default')) ??
entries[0]
return preferred ? path.join(profilesDir, preferred) : ''
}
@IpcMethod()
getBrowserProfilePath(_context: IpcContext, browser: string): string {
if (!browser || browser === 'none') {
return ''
}
const homeDir = os.homedir()
const platform = os.platform()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
if (browser === 'firefox') {
const profilesDir = baseDirs[0]
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
return profilePath || fallbackPath
}
if (browser === 'safari') {
const safariPath = baseDirs[0]
if (safariPath && this.isDirectory(safariPath)) {
return safariPath
}
return fallbackPath
}
if (baseDirs.length === 0) {
return fallbackPath
}
let detectedPath = ''
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidates =
browser === 'opera'
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
detectedPath = this.pickFirstDirectory(candidates)
if (detectedPath) {
break
}
}
return detectedPath || fallbackPath
}
@IpcMethod()
validateBrowserProfilePath(
_context: IpcContext,
browser: string,
profilePath: string
): { valid: boolean; reason?: string } {
if (!browser || browser === 'none') {
return this.buildValidationResult(false, 'browserUnsupported')
}
const normalizedInput = this.normalizeProfileInput(profilePath)
if (!normalizedInput) {
return this.buildValidationResult(false, 'empty')
}
const resolvedInput = resolvePathWithHome(normalizedInput)
if (resolvedInput && this.isDirectory(resolvedInput)) {
return this.buildValidationResult(true)
}
const looksLikePath =
resolvedInput &&
(path.isAbsolute(resolvedInput) ||
resolvedInput.includes('/') ||
resolvedInput.includes('\\'))
if (looksLikePath) {
return this.buildValidationResult(false, 'pathNotFound')
}
const platform = os.platform()
const homeDir = os.homedir()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
if (baseDirs.length === 0) {
return this.buildValidationResult(false, 'browserUnsupported')
}
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidate = path.join(baseDir, normalizedInput)
if (this.isDirectory(candidate)) {
return this.buildValidationResult(true)
}
}
return this.buildValidationResult(false, 'profileNotFound')
}
}
export { BrowserCookiesService }

View File

@@ -3,8 +3,10 @@ import type {
DownloadItem,
DownloadOptions,
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../../shared/types'
import { downloadEngine } from '../../lib/download-engine'
@@ -16,6 +18,14 @@ class DownloadService extends IpcService {
return downloadEngine.getVideoInfo(url)
}
@IpcMethod()
async getVideoInfoWithCommand(
_context: IpcContext,
url: string
): Promise<VideoInfoCommandResult> {
return downloadEngine.getVideoInfoWithCommand(url)
}
@IpcMethod()
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
return downloadEngine.getPlaylistInfo(url)
@@ -45,7 +55,7 @@ class DownloadService extends IpcService {
async startPlaylistDownload(
_context: IpcContext,
options: PlaylistDownloadOptions
): Promise<string[]> {
): Promise<PlaylistDownloadResult> {
return downloadEngine.startPlaylistDownload(options)
}
}

View File

@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { execFile, execSync } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { clipboard, dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
const execFileAsync = promisify(execFile)
@@ -40,7 +41,23 @@ class FileSystemService extends IpcService {
@IpcMethod()
getDefaultDownloadPath(_context: IpcContext): string {
return `${os.homedir()}/Downloads`
const fallbackPath = path.join(os.homedir(), 'Downloads')
if (process.platform === 'linux' || process.platform === 'freebsd') {
try {
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
if (xdgPath) {
return xdgPath
}
} catch (error) {
scopedLoggers.system.warn(
'Unable to resolve XDG download directory, falling back to default:',
error
)
}
}
return fallbackPath
}
@IpcMethod()
@@ -62,7 +79,7 @@ class FileSystemService extends IpcService {
if (stats?.isDirectory()) {
const result = await shell.openPath(normalizedPath)
if (result) {
console.error('Failed to open directory:', result)
scopedLoggers.system.error('Failed to open directory:', result)
return false
}
return true
@@ -75,16 +92,16 @@ class FileSystemService extends IpcService {
if (parentStats?.isDirectory()) {
const result = await shell.openPath(parentDirectory)
if (result) {
console.error('Failed to open parent directory:', result)
scopedLoggers.system.error('Failed to open parent directory:', result)
return false
}
return true
}
console.error('File or directory does not exist:', normalizedPath)
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
return false
} catch (error) {
console.error('Failed to open file location:', error)
scopedLoggers.system.error('Failed to open file location:', error)
return false
}
}
@@ -109,7 +126,7 @@ class FileSystemService extends IpcService {
return true
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
return false
}
}
@@ -137,7 +154,7 @@ class FileSystemService extends IpcService {
await shell.openExternal(url)
return true
} catch (error) {
console.error('Failed to open external URL:', error)
scopedLoggers.system.error('Failed to open external URL:', error)
return false
}
}
@@ -153,7 +170,10 @@ class FileSystemService extends IpcService {
])
return
} catch (error) {
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'PowerShell clipboard copy failed, falling back to manual buffer:',
error
)
}
const winPath = resolvedPath.replace(/\//g, '\\')
@@ -181,7 +201,10 @@ class FileSystemService extends IpcService {
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
return
} catch (error) {
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'osascript clipboard copy failed, falling back to manual buffer:',
error
)
}
const entries = [
@@ -216,6 +239,25 @@ class FileSystemService extends IpcService {
.replace(/'/g, '&apos;')
}
@IpcMethod()
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
return stats?.isFile() ?? false
} catch (error) {
scopedLoggers.system.error('Failed to check file existence:', error)
return false
}
}
@IpcMethod()
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
try {
@@ -263,7 +305,7 @@ class FileSystemService extends IpcService {
return false
} catch (error) {
console.error('Failed to delete file:', error)
scopedLoggers.system.error('Failed to delete file:', error)
return false
}
}

View File

@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
return historyManager.removeHistoryItem(id)
}
@IpcMethod()
removeHistoryItems(_context: IpcContext, ids: string[]): number {
return historyManager.removeHistoryItems(ids)
}
@IpcMethod()
removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
return historyManager.removeHistoryByPlaylistId(playlistId)
}
@IpcMethod()
clearHistory(_context: IpcContext): void {
historyManager.clearHistory()
}
@IpcMethod()
getHistoryCount(_context: IpcContext): {
active: number

View File

@@ -2,6 +2,8 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types'
import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray'
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
import { applyDockVisibility } from '../../utils/dock'
class SettingsService extends IpcService {
static readonly groupName = 'settings'
@@ -18,6 +20,14 @@ class SettingsService extends IpcService {
if (key === 'language') {
updateTrayMenu()
}
if (key === 'hideDockIcon') {
applyDockVisibility(value as AppSettings['hideDockIcon'])
}
if (key === 'launchAtLogin') {
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
}
}
@IpcMethod()
@@ -32,11 +42,21 @@ class SettingsService extends IpcService {
if (settings.language) {
updateTrayMenu()
}
if (typeof settings.hideDockIcon === 'boolean') {
applyDockVisibility(settings.hideDockIcon)
}
if (typeof settings.launchAtLogin === 'boolean') {
applyAutoLaunchSetting(settings.launchAtLogin)
}
}
@IpcMethod()
reset(_context: IpcContext): void {
settingsManager.reset()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
}
}

View File

@@ -0,0 +1,167 @@
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type {
SubscriptionCreatePayload,
SubscriptionResolvedFeed,
SubscriptionRule,
SubscriptionUpdatePayload
} from '../../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
import { settingsManager } from '../../settings'
interface CreateSubscriptionOptions {
url: string
keywords?: string[]
tags?: string[]
onlyDownloadLatest?: boolean
downloadDirectory?: string
namingTemplate?: string
enabled?: boolean
}
const ensureUrlHasProtocol = (value: string): string => {
if (!value) {
return value
}
if (!/^https?:\/\//i.test(value)) {
return `https://${value}`
}
return value
}
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
const normalized = ensureUrlHasProtocol(rawUrl.trim())
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
if (youTubeChannelMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
platform: 'youtube'
}
}
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'youtube'
}
}
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
if (youTubeUserMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
platform: 'youtube'
}
}
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
if (youTubeHandleMatch) {
const handle = youTubeHandleMatch[1].replace('@', '')
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
platform: 'youtube'
}
}
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
if (biliSpaceMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
platform: 'bilibili'
}
}
if (/rsshub\.app\/bilibili/i.test(normalized)) {
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'bilibili'
}
}
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'custom'
}
}
class SubscriptionService extends IpcService {
static readonly groupName = 'subscriptions'
@IpcMethod()
list(_context: IpcContext): SubscriptionRule[] {
return subscriptionManager.getAll()
}
@IpcMethod()
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
return resolveFeedFromInput(url)
}
@IpcMethod()
async create(
_context: IpcContext,
options: CreateSubscriptionOptions
): Promise<SubscriptionRule> {
const resolved = resolveFeedFromInput(options.url)
const settings = settingsManager.getAll()
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
const payload: SubscriptionCreatePayload = {
sourceUrl: resolved.sourceUrl,
feedUrl: resolved.feedUrl,
platform: resolved.platform,
keywords: options.keywords,
tags: options.tags,
onlyDownloadLatest:
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,
namingTemplate: sanitizeFilenameTemplate(
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
),
enabled: options.enabled ?? true
}
const created = subscriptionManager.add(payload)
void subscriptionScheduler.runNow(created.id)
return created
}
@IpcMethod()
update(
_context: IpcContext,
id: string,
updates: SubscriptionUpdatePayload
): SubscriptionRule | undefined {
const normalized: SubscriptionUpdatePayload = { ...updates }
if (typeof normalized.namingTemplate === 'string') {
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
}
return subscriptionManager.update(id, normalized)
}
@IpcMethod()
remove(_context: IpcContext, id: string): boolean {
return subscriptionManager.remove(id)
}
@IpcMethod()
async refresh(_context: IpcContext, id?: string): Promise<void> {
await subscriptionScheduler.runNow(id)
}
@IpcMethod()
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
return subscriptionScheduler.queueItem(id, itemId)
}
}
export { SubscriptionService }

View File

@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { autoUpdater } from 'electron-updater'
import { settingsManager } from '../../settings'
const isNewerVersion = (latest: string, current: string): boolean => {
const toSegments = (version: string) =>
version.split(/[.-]/).map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isNaN(parsed) ? 0 : parsed
})
const latestSegments = toSegments(latest)
const currentSegments = toSegments(current)
const maxLength = Math.max(latestSegments.length, currentSegments.length)
for (let index = 0; index < maxLength; index += 1) {
const latestValue = latestSegments[index] ?? 0
const currentValue = currentSegments[index] ?? 0
if (latestValue > currentValue) {
return true
}
if (latestValue < currentValue) {
return false
}
}
return false
}
class UpdateService extends IpcService {
static readonly groupName = 'update'
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
_context: IpcContext
): Promise<{ available: boolean; version?: string; error?: string }> {
try {
// In production, use checkForUpdatesAndNotify for automatic notifications
const result = await autoUpdater.checkForUpdatesAndNotify()
const currentVersion = app.getVersion()
const result = await autoUpdater.checkForUpdates()
const latestVersion = result?.updateInfo?.version
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
return {
available: true,
version: latestVersion
}
}
return {
available: result !== null,
version: result?.updateInfo?.version
available: false,
version: latestVersion ?? currentVersion
}
} catch (error) {
return {

View File

@@ -0,0 +1,6 @@
import { join } from 'node:path'
import { app } from 'electron'
export const getDatabaseFilePath = (): string => {
return join(app.getPath('userData'), 'vidbee.db')
}

View File

@@ -0,0 +1,97 @@
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { type MigrationMeta, readMigrationFiles } from 'drizzle-orm/migrator'
import { app } from 'electron'
import log from 'electron-log/main'
const MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'
const MIGRATIONS_TABLE = '__drizzle_migrations'
const KNOWN_TABLES = ['download_history', 'subscriptions', 'subscription_items']
const migrationsTableDefinition = sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(MIGRATIONS_TABLE)} (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)
`
export const runMigrations = (database: BetterSQLite3Database): void => {
const migrationsFolder = resolveMigrationsFolder()
if (!migrationsFolder) {
log.warn('database: drizzle migrations folder not found, skipping migrations')
return
}
try {
ensureBaseline(database, migrationsFolder)
migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
} catch (error) {
log.error('database: failed to run drizzle migrations', error)
}
}
const resolveMigrationsFolder = (): string | null => {
const candidates = new Set<string>()
candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))
candidates.add(resolve(__dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))
if (process.resourcesPath) {
candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))
candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))
}
try {
candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))
} catch {
// app might not be ready yet, ignore
}
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
const ensureBaseline = (database: BetterSQLite3Database, migrationsFolder: string): void => {
if (hasTable(database, MIGRATIONS_TABLE)) {
return
}
const hasExistingSchema = KNOWN_TABLES.some((table) => hasTable(database, table))
if (!hasExistingSchema) {
return
}
log.info('database: detected existing schema, seeding drizzle migrations baseline')
database.run(migrationsTableDefinition)
let migrations: MigrationMeta[]
try {
migrations = readMigrationFiles({ migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
} catch (error) {
log.error('database: failed to read drizzle migrations while seeding baseline', error)
return
}
database.transaction((tx) => {
for (const migration of migrations) {
tx.run(
sql`INSERT INTO ${sql.identifier(MIGRATIONS_TABLE)} ("hash", "created_at") VALUES(${migration.hash}, ${migration.folderMillis})`
)
}
})
}
const hasTable = (database: BetterSQLite3Database, tableName: string): boolean => {
const rows = database.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}`
)
return rows.length > 0
}

View File

@@ -0,0 +1,84 @@
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const downloadHistoryTable = sqliteTable('download_history', {
id: text('id').primaryKey(),
url: text('url').notNull(),
title: text('title').notNull(),
thumbnail: text('thumbnail'),
type: text('type').notNull(),
status: text('status').notNull(),
downloadPath: text('download_path'),
savedFileName: text('saved_file_name'),
fileSize: integer('file_size', { mode: 'number' }),
duration: integer('duration', { mode: 'number' }),
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
error: text('error'),
ytDlpCommand: text('yt_dlp_command'),
description: text('description'),
channel: text('channel'),
uploader: text('uploader'),
viewCount: integer('view_count', { mode: 'number' }),
tags: text('tags'),
origin: text('origin'),
subscriptionId: text('subscription_id'),
selectedFormat: text('selected_format'),
playlistId: text('playlist_id'),
playlistTitle: text('playlist_title'),
playlistIndex: integer('playlist_index', { mode: 'number' }),
playlistSize: integer('playlist_size', { mode: 'number' })
})
export const subscriptionsTable = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
title: text('title').notNull(),
sourceUrl: text('source_url').notNull(),
feedUrl: text('feed_url').notNull(),
platform: text('platform').notNull(),
keywords: text('keywords').notNull(),
tags: text('tags').notNull(),
onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),
enabled: integer('enabled', { mode: 'number' }).notNull(),
coverUrl: text('cover_url'),
latestVideoTitle: text('latest_video_title'),
latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),
lastCheckedAt: integer('last_checked_at', { mode: 'number' }),
lastSuccessAt: integer('last_success_at', { mode: 'number' }),
status: text('status').notNull(),
lastError: text('last_error'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
downloadDirectory: text('download_directory'),
namingTemplate: text('naming_template')
})
export const subscriptionItemsTable = sqliteTable(
'subscription_items',
{
subscriptionId: text('subscription_id').notNull(),
itemId: text('item_id').notNull(),
title: text('title').notNull(),
url: text('url').notNull(),
publishedAt: integer('published_at', { mode: 'number' }).notNull(),
thumbnail: text('thumbnail'),
added: integer('added', { mode: 'number' }).notNull(),
downloadId: text('download_id'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull()
},
(table) => ({
pk: primaryKey({
columns: [table.subscriptionId, table.itemId],
name: 'subscription_items_pk'
}),
subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)
})
)
export type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
export type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
export type SubscriptionRow = typeof subscriptionsTable.$inferSelect
export type SubscriptionInsert = typeof subscriptionsTable.$inferInsert
export type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect
export type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { scopedLoggers } from '../utils/logger'
class FfmpegManager {
private ffmpegPath: string | null = null
async initialize(): Promise<void> {
this.ffmpegPath = await this.findFfmpegBinary()
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
}
getPath(): string {
if (!this.ffmpegPath) {
throw new Error('ffmpeg not initialized. Call initialize() first.')
}
return this.ffmpegPath
}
private getResourcesPath(): string {
if (process.env.NODE_ENV === 'development') {
return path.join(process.cwd(), 'resources')
}
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
}
private async findFfmpegBinary(): Promise<string> {
const platform = os.platform()
const resourceCandidates: string[] = []
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
return process.env.FFMPEG_PATH
}
if (platform === 'win32') {
resourceCandidates.push('ffmpeg.exe')
} else if (platform === 'darwin') {
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
} else {
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
}
const resourcesPath = this.getResourcesPath()
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
scopedLoggers.engine.warn(
'Failed to set executable permission on ffmpeg binary:',
error
)
}
}
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
return fullPath
}
}
if (platform === 'darwin') {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for (const candidate of commonPaths) {
if (fs.existsSync(candidate)) {
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
return candidate
}
}
}
if (platform === 'linux' || platform === 'freebsd') {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
return systemPath
}
} catch (_error) {
// Ignore error and continue
}
}
if (platform === 'win32') {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system ffmpeg:', output)
return output
}
} catch (_error) {
// Ignore error and continue
}
}
throw new Error(
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
)
}
}
export const ffmpegManager = new FfmpegManager()

View File

@@ -1,52 +1,484 @@
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import DatabaseConstructor from 'better-sqlite3'
import { eq, inArray, sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { app } from 'electron'
import log from 'electron-log/main'
import type { DownloadHistoryItem } from '../../shared/types'
import { runMigrations } from './database/migrate'
import {
type DownloadHistoryInsert,
type DownloadHistoryRow,
downloadHistoryTable
} from './database/schema'
import { getDatabaseFilePath } from './database-path'
const logger = log.scope('history-manager')
const TAG_SEPARATOR = '\n'
const createDownloadHistoryTableSql = sql`
CREATE TABLE IF NOT EXISTS download_history (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
thumbnail TEXT,
type TEXT NOT NULL,
status TEXT NOT NULL,
download_path TEXT,
saved_file_name TEXT,
file_size INTEGER,
duration INTEGER,
downloaded_at INTEGER NOT NULL,
completed_at INTEGER,
sort_key INTEGER NOT NULL,
error TEXT,
yt_dlp_command TEXT,
description TEXT,
channel TEXT,
uploader TEXT,
view_count INTEGER,
tags TEXT,
origin TEXT,
subscription_id TEXT,
selected_format TEXT,
playlist_id TEXT,
playlist_title TEXT,
playlist_index INTEGER,
playlist_size INTEGER
)
`
const renameDownloadHistoryTableSql = sql`
ALTER TABLE download_history RENAME TO download_history_legacy
`
const dropLegacyDownloadHistoryTableSql = sql`
DROP TABLE download_history_legacy
`
const copyDownloadHistoryFromLegacySql = sql`
INSERT INTO download_history (
id,
url,
title,
thumbnail,
type,
status,
download_path,
saved_file_name,
file_size,
duration,
downloaded_at,
completed_at,
sort_key,
error,
description,
channel,
uploader,
view_count,
tags,
origin,
subscription_id,
selected_format,
playlist_id,
playlist_title,
playlist_index,
playlist_size
)
SELECT
id,
url,
title,
thumbnail,
type,
status,
download_path,
saved_file_name,
file_size,
duration,
downloaded_at,
completed_at,
sort_key,
error,
description,
channel,
uploader,
view_count,
tags,
origin,
subscription_id,
selected_format,
playlist_id,
playlist_title,
playlist_index,
playlist_size
FROM download_history_legacy
`
const sanitizeList = (values?: string[]): string[] => {
if (!values || values.length === 0) {
return []
}
return values
.map((value) => value.trim())
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
}
const serializeTags = (values?: string[]): string | null => {
const sanitized = sanitizeList(values)
return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null
}
const parseTags = (value: string | null): string[] | undefined => {
if (!value) {
return undefined
}
const parsed = value
.split(TAG_SEPARATOR)
.map((tag) => tag.trim())
.filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)
return parsed.length > 0 ? parsed : undefined
}
const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
id: text('id').primaryKey(),
status: text('status').notNull(),
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
payload: text('payload').notNull()
})
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
class HistoryManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
private db: BetterSQLite3Database | null = null
private history: Map<string, DownloadHistoryItem> = new Map()
private schemaChecked = false
private migrationChecked = false
constructor() {
this.store = new Store({
name: 'download-history',
defaults: {
items: []
}
})
this.loadHistory()
this.initialize()
}
private loadHistory(): void {
private initialize(): void {
try {
const historyArray = this.store.get('items', [])
this.history = new Map(historyArray.map((item) => [item.id, item]))
this.getDatabase()
this.ensureStructuredSchema()
this.ensureLegacyMigration()
this.loadHistoryFromDatabase()
} catch (error) {
console.error('Failed to load download history:', error)
logger.error('history-db failed to initialize', error)
}
}
private saveHistory(): void {
private getDatabase(): BetterSQLite3Database {
if (this.db) {
return this.db
}
const databasePath = this.getDatabasePath()
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
const database = drizzle(sqlite)
runMigrations(database)
this.db = database
logger.info(`history-db initialized at ${databasePath}`)
return this.db
}
private getDatabasePath(): string {
return getDatabaseFilePath()
}
private getLegacyStorePath(): string {
return join(app.getPath('userData'), 'download-history.json')
}
private ensureStructuredSchema(): void {
if (this.schemaChecked) {
return
}
this.schemaChecked = true
try {
const historyArray = Array.from(this.history.values())
this.store.set('items', historyArray)
const database = this.getDatabase()
const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`)
const hasPayloadColumn = columns.some((column) => column.name === 'payload')
const hasUrlColumn = columns.some((column) => column.name === 'url')
if (hasPayloadColumn || !hasUrlColumn) {
this.migrateLegacyPayloadTable()
return
}
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
const requiredColumns = ['yt_dlp_command']
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
const missingRequired = requiredColumns.some(
(columnName) => !columns.some((column) => column.name === columnName)
)
const needsRebuild = hasDeprecated || missingRequired
if (needsRebuild) {
this.rebuildDownloadHistoryTable()
}
} catch (error) {
console.error('Failed to save download history:', error)
logger.error('history-db failed to inspect schema', error)
}
}
private migrateLegacyPayloadTable(): void {
const database = this.getDatabase()
logger.info('history-db migrating legacy payload schema to structured columns')
try {
let migratedCount = 0
database.transaction(
(tx) => {
tx.run(renameDownloadHistoryTableSql)
tx.run(createDownloadHistoryTableSql)
const legacyRows = tx.select().from(legacyDownloadHistoryTable).all()
migratedCount = legacyRows.length
for (const legacyRow of legacyRows) {
const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow))
tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run()
}
tx.run(dropLegacyDownloadHistoryTableSql)
},
{ behavior: 'immediate' }
)
logger.info(`history-db migrated ${migratedCount} rows to new schema`)
} catch (error) {
logger.error('history-db failed to migrate legacy payload rows', error)
throw error
}
}
private rebuildDownloadHistoryTable(): void {
const database = this.getDatabase()
logger.info('history-db rebuilding download_history table to latest schema')
try {
database.transaction(
(tx) => {
tx.run(renameDownloadHistoryTableSql)
tx.run(createDownloadHistoryTableSql)
tx.run(copyDownloadHistoryFromLegacySql)
tx.run(dropLegacyDownloadHistoryTableSql)
},
{ behavior: 'immediate' }
)
logger.info('history-db rebuilt download_history table')
} catch (error) {
logger.error('history-db failed to rebuild download_history schema', error)
throw error
}
}
private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem {
try {
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
return {
...parsed,
status: (parsed.status ?? row.status) as DownloadHistoryItem['status'],
downloadedAt: parsed.downloadedAt ?? row.downloadedAt,
completedAt: parsed.completedAt ?? row.completedAt ?? undefined
}
} catch (error) {
logger.warn('history-db falling back while migrating payload row', { id: row.id, error })
return {
id: row.id,
url: row.id,
title: `Download ${row.id}`,
type: 'video',
status: row.status as DownloadHistoryItem['status'],
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined
}
}
}
private ensureLegacyMigration(): void {
if (this.migrationChecked) {
return
}
this.migrationChecked = true
const legacyPath = this.getLegacyStorePath()
if (!existsSync(legacyPath)) {
return
}
try {
const raw = readFileSync(legacyPath, 'utf-8')
const parsed = JSON.parse(raw) as { items?: DownloadHistoryItem[] }
const items = parsed.items ?? []
if (items.length === 0) {
return
}
const database = this.getDatabase()
for (const legacyItem of items) {
const normalized = this.normalizeItem(legacyItem)
const insertPayload = this.mapItemToInsert(normalized)
database
.insert(downloadHistoryTable)
.values(insertPayload)
.onConflictDoUpdate({
target: downloadHistoryTable.id,
set: this.mapItemToUpdate(insertPayload)
})
.run()
}
try {
renameSync(legacyPath, `${legacyPath}.bak`)
} catch (renameError) {
logger.warn(
'history-db migrated legacy store but could not rename original file',
renameError
)
}
logger.info(`history-db migrated ${items.length} entries from legacy electron-store data`)
} catch (error) {
logger.error('history-db failed to migrate legacy store', error)
}
}
private loadHistoryFromDatabase(): void {
try {
const database = this.getDatabase()
const rows = database.select().from(downloadHistoryTable).all()
this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))
} catch (error) {
logger.error('history-db failed to load rows', error)
this.history = new Map()
}
}
private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {
const fallbackTimestamp = Date.now()
const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp
const status = item.status ?? 'pending'
return {
...item,
status,
downloadedAt
}
}
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
return {
id: item.id,
url: item.url,
title: item.title,
thumbnail: item.thumbnail ?? null,
type: item.type,
status: item.status,
downloadPath: item.downloadPath ?? null,
savedFileName: item.savedFileName ?? null,
fileSize: item.fileSize ?? null,
duration: item.duration ?? null,
downloadedAt: item.downloadedAt,
completedAt: item.completedAt ?? null,
sortKey: item.completedAt ?? item.downloadedAt,
error: item.error ?? null,
ytDlpCommand: item.ytDlpCommand ?? null,
description: item.description ?? null,
channel: item.channel ?? null,
uploader: item.uploader ?? null,
viewCount: item.viewCount ?? null,
tags: serializeTags(item.tags) ?? null,
origin: item.origin ?? null,
subscriptionId: item.subscriptionId ?? null,
selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,
playlistId: item.playlistId ?? null,
playlistTitle: item.playlistTitle ?? null,
playlistIndex: item.playlistIndex ?? null,
playlistSize: item.playlistSize ?? null
}
}
private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {
const { id: _id, ...rest } = payload
return rest
}
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
let selectedFormat: DownloadHistoryItem['selectedFormat']
if (row.selectedFormat) {
try {
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
} catch (error) {
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
}
}
const tags = parseTags(row.tags ?? null)
return {
id: row.id,
url: row.url,
title: row.title,
thumbnail: row.thumbnail ?? undefined,
type: row.type as DownloadHistoryItem['type'],
status: row.status as DownloadHistoryItem['status'],
downloadPath: row.downloadPath ?? undefined,
savedFileName: row.savedFileName ?? undefined,
fileSize: row.fileSize ?? undefined,
duration: row.duration ?? undefined,
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined,
error: row.error ?? undefined,
ytDlpCommand: row.ytDlpCommand ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,
viewCount: row.viewCount ?? undefined,
tags,
origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,
subscriptionId: row.subscriptionId ?? undefined,
selectedFormat,
playlistId: row.playlistId ?? undefined,
playlistTitle: row.playlistTitle ?? undefined,
playlistIndex: row.playlistIndex ?? undefined,
playlistSize: row.playlistSize ?? undefined
}
}
addHistoryItem(item: DownloadHistoryItem): void {
this.history.set(item.id, item)
this.saveHistory()
const normalized = this.normalizeItem(item)
const insertPayload = this.mapItemToInsert(normalized)
try {
const database = this.getDatabase()
database
.insert(downloadHistoryTable)
.values(insertPayload)
.onConflictDoUpdate({
target: downloadHistoryTable.id,
set: this.mapItemToUpdate(insertPayload)
})
.run()
this.history.set(normalized.id, normalized)
} catch (error) {
logger.error('history-db failed to upsert item', { id: normalized.id, error })
}
}
getHistory(): DownloadHistoryItem[] {
return Array.from(this.history.values()).sort((a, b) => {
const aTime = a.completedAt || a.downloadedAt
const bTime = b.completedAt || b.downloadedAt
const aTime = a.completedAt ?? a.downloadedAt
const bTime = b.completedAt ?? b.downloadedAt
return bTime - aTime
})
}
@@ -56,30 +488,107 @@ class HistoryManager {
}
removeHistoryItem(id: string): boolean {
const deleted = this.history.delete(id)
if (deleted) {
this.saveHistory()
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.id, id))
.run()
const removedFromMap = this.history.delete(id)
return result.changes > 0 || removedFromMap
} catch (error) {
logger.error('history-db failed to delete item', { id, error })
return false
}
}
removeHistoryItems(ids: string[]): number {
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
if (uniqueIds.length === 0) {
return 0
}
let removedCount = 0
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(inArray(downloadHistoryTable.id, uniqueIds))
.run()
for (const id of uniqueIds) {
if (this.history.delete(id)) {
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
return removedCount
}
}
removeHistoryByPlaylistId(playlistId: string): number {
const normalized = playlistId.trim()
if (!normalized) {
return 0
}
let removedCount = 0
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.playlistId, normalized))
.run()
for (const [id, item] of this.history.entries()) {
if (item.playlistId === normalized) {
this.history.delete(id)
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
return removedCount
}
return deleted
}
clearHistory(): void {
this.history.clear()
this.saveHistory()
try {
const database = this.getDatabase()
database.delete(downloadHistoryTable).run()
this.history.clear()
} catch (error) {
logger.error('history-db failed to clear items', error)
}
}
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
let removedCount = 0
for (const [id, item] of this.history.entries()) {
if (item.status === status) {
this.history.delete(id)
removedCount++
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.status, status))
.run()
for (const [id, item] of this.history.entries()) {
if (item.status === status) {
this.history.delete(id)
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to clear items by status', { status, error })
return removedCount
}
if (removedCount > 0) {
this.saveHistory()
}
return removedCount
}
getHistoryCount(): {
@@ -111,6 +620,15 @@ class HistoryManager {
return counts
}
hasHistoryForUrl(url: string): boolean {
for (const item of this.history.values()) {
if (item.url === url) {
return true
}
}
return false
}
}
export const historyManager = new HistoryManager()

View File

@@ -0,0 +1,643 @@
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
import DatabaseConstructor from 'better-sqlite3'
import { and, desc, eq, inArray } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import log from 'electron-log/main'
import type {
SubscriptionCreatePayload,
SubscriptionFeedItem,
SubscriptionRule,
SubscriptionStatus,
SubscriptionUpdatePayload
} from '../../shared/types'
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
import { runMigrations } from './database/migrate'
import {
type SubscriptionInsert,
type SubscriptionItemRow,
type SubscriptionRow,
subscriptionItemsTable,
subscriptionsTable
} from './database/schema'
import { getDatabaseFilePath } from './database-path'
const sanitizeList = (values?: string[]): string[] => {
if (!values || values.length === 0) {
return []
}
return values
.map((value) => value.trim())
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
}
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
log.error('Failed to ensure subscription directory:', error)
}
}
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
const parseStringArray = (value: string | null | undefined): string[] => {
if (!value) {
return []
}
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
} catch {
return []
}
}
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
export class SubscriptionManager extends EventEmitter {
private sqlite: BetterSqlite3Instance | null = null
private db: BetterSQLite3Database | null = null
constructor() {
super()
try {
this.getDatabase()
this.migrateLegacyStore()
} catch (error) {
log.error('subscriptions: failed to initialize database', error)
}
}
getAll(): SubscriptionRule[] {
const database = this.getDatabase()
const rows = database
.select()
.from(subscriptionsTable)
.orderBy(desc(subscriptionsTable.updatedAt))
.all()
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
}
getById(id: string): SubscriptionRule | undefined {
const database = this.getDatabase()
const row = database
.select()
.from(subscriptionsTable)
.where(eq(subscriptionsTable.id, id))
.get()
if (!row) {
return undefined
}
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
}
add(payload: SubscriptionCreatePayload): SubscriptionRule {
const timestamp = Date.now()
const keywords = sanitizeList(payload.keywords)
const tags = sanitizeList(payload.tags)
const record: SubscriptionRule = {
id: randomUUID(),
title: payload.sourceUrl,
sourceUrl: payload.sourceUrl,
feedUrl: payload.feedUrl,
platform: payload.platform,
keywords,
tags,
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
enabled: payload.enabled ?? true,
coverUrl: undefined,
latestVideoTitle: undefined,
latestVideoPublishedAt: undefined,
lastCheckedAt: undefined,
lastSuccessAt: undefined,
status: 'idle',
lastError: undefined,
createdAt: timestamp,
updatedAt: timestamp,
downloadDirectory: payload.downloadDirectory,
namingTemplate: payload.namingTemplate
? sanitizeFilenameTemplate(payload.namingTemplate)
: undefined,
items: []
}
ensureDirectoryExists(payload.downloadDirectory)
this.insertRecord(record)
this.emitUpdates()
return record
}
update(
id: string,
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
): SubscriptionRule | undefined {
const existing = this.getById(id)
if (!existing) {
return undefined
}
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
const next: SubscriptionRule = {
...existing,
...updates,
keywords: keywords ?? existing.keywords,
tags: tags ?? existing.tags,
updatedAt: Date.now()
}
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
next.title = updates.sourceUrl
}
if (updates.namingTemplate) {
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
}
ensureDirectoryExists(next.downloadDirectory)
this.updateRecord(next)
this.emitUpdates()
return next
}
remove(id: string): boolean {
const database = this.getDatabase()
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
if ((result.changes ?? 0) > 0) {
database
.delete(subscriptionItemsTable)
.where(eq(subscriptionItemsTable.subscriptionId, id))
.run()
this.emitUpdates()
return true
}
return false
}
replaceFeedItems(
subscriptionId: string,
items: SubscriptionFeedItem[],
silent: boolean = false
): void {
const database = this.getDatabase()
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
const now = Date.now()
database.transaction((tx) => {
tx.delete(subscriptionItemsTable)
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
.run()
for (const item of orderedItems) {
tx.insert(subscriptionItemsTable)
.values({
subscriptionId,
itemId: item.id,
title: item.title,
url: item.url,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail ?? null,
added: booleanToNumber(item.addedToQueue),
downloadId: item.downloadId ?? null,
createdAt: item.publishedAt,
updatedAt: now
})
.run()
}
})
if (!silent) {
this.emitUpdates()
}
}
updateFeedItemQueueState(
subscriptionId: string,
itemId: string,
updates: { added?: boolean; downloadId?: string | null }
): void {
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
return
}
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
updatedAt: Date.now()
}
if (updates.added !== undefined) {
setPayload.added = booleanToNumber(updates.added)
}
if (Object.hasOwn(updates, 'downloadId')) {
setPayload.downloadId = updates.downloadId ?? null
}
const database = this.getDatabase()
const result = database
.update(subscriptionItemsTable)
.set(setPayload)
.where(
and(
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
eq(subscriptionItemsTable.itemId, itemId)
)
)
.run()
if ((result.changes ?? 0) > 0) {
this.emitUpdates()
}
}
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
if (records.length === 0) {
return records
}
const ids = records.map((record) => record.id)
const database = this.getDatabase()
const rows = database
.select()
.from(subscriptionItemsTable)
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
.orderBy(desc(subscriptionItemsTable.publishedAt))
.all()
const grouped = new Map<string, SubscriptionFeedItem[]>()
for (const row of rows) {
const item = this.mapItemRowToFeedItem(row)
const list = grouped.get(row.subscriptionId)
if (list) {
list.push(item)
} else {
grouped.set(row.subscriptionId, [item])
}
}
return records.map((record) => ({
...record,
items: grouped.get(record.id) ?? []
}))
}
private getDatabase(): BetterSQLite3Database {
if (this.db) {
return this.db
}
const databasePath = this.getDatabasePath()
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
this.sqlite.pragma('journal_mode = WAL')
this.sqlite.pragma('foreign_keys = ON')
this.db = drizzle(this.sqlite)
runMigrations(this.db)
this.ensureSubscriptionsSchema()
this.ensureItemsSchema()
log.info('subscriptions: database initialized at', databasePath)
return this.db
}
private ensureItemsSchema(): void {
if (!this.sqlite) {
return
}
try {
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
name: string
}>
const hasAdded = columns.some((column) => column.name === 'added')
if (!hasAdded) {
this.sqlite
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
.run()
}
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
const hasLegacyStatus = columns.some((column) => column.name === 'status')
const needsMigration = hasDownloaded || hasLegacyStatus
if (needsMigration) {
if (hasDownloaded) {
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
}
const sqlite = this.sqlite
const migrate = sqlite.transaction(() => {
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
sqlite
.prepare(
`CREATE TABLE subscription_items_new (
subscription_id TEXT NOT NULL,
item_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
published_at INTEGER NOT NULL,
thumbnail TEXT,
added INTEGER NOT NULL,
download_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (subscription_id, item_id)
)`
)
.run()
sqlite
.prepare(
`INSERT INTO subscription_items_new (
subscription_id,
item_id,
title,
url,
published_at,
thumbnail,
added,
download_id,
created_at,
updated_at
)
SELECT
subscription_id,
item_id,
title,
url,
published_at,
thumbnail,
added,
download_id,
created_at,
updated_at
FROM subscription_items`
)
.run()
sqlite.prepare(`DROP TABLE subscription_items`).run()
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
sqlite
.prepare(
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
)
.run()
})
migrate()
log.info('subscriptions: removed legacy columns from subscription_items')
}
} catch (error) {
log.warn('subscriptions: failed to ensure subscription_items schema', error)
}
}
private ensureSubscriptionsSchema(): void {
if (!this.sqlite) {
return
}
try {
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
name: string
}>
const hasLegacyColumns = columns.some((column) =>
['seen_item_ids', 'last_item_id'].includes(column.name)
)
if (!hasLegacyColumns) {
return
}
const sqlite = this.sqlite
const migrate = sqlite.transaction(() => {
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
sqlite
.prepare(
`CREATE TABLE subscriptions_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_url TEXT NOT NULL,
feed_url TEXT NOT NULL,
platform TEXT NOT NULL,
keywords TEXT NOT NULL,
tags TEXT NOT NULL,
only_latest INTEGER NOT NULL,
enabled INTEGER NOT NULL,
cover_url TEXT,
latest_video_title TEXT,
latest_video_published_at INTEGER,
last_checked_at INTEGER,
last_success_at INTEGER,
status TEXT NOT NULL,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
download_directory TEXT,
naming_template TEXT
)`
)
.run()
sqlite
.prepare(
`INSERT INTO subscriptions_new (
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
)
SELECT
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
FROM subscriptions`
)
.run()
sqlite.prepare(`DROP TABLE subscriptions`).run()
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
})
migrate()
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
} catch (error) {
log.warn('subscriptions: failed to ensure subscriptions schema', error)
}
}
private getDatabasePath(): string {
return getDatabaseFilePath()
}
private migrateLegacyStore(): void {
try {
const ElectronStore = require('electron-store')
// Access the default export
const LegacyStore = ElectronStore.default || ElectronStore
const store = new LegacyStore({
name: 'subscriptions',
defaults: {
subscriptions: []
}
})
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
if (!legacyData || legacyData.length === 0) {
return
}
if (this.getAll().length > 0) {
return
}
for (const legacyItem of legacyData) {
try {
const normalized: SubscriptionRule = {
...legacyItem,
keywords: sanitizeList(legacyItem.keywords),
tags: sanitizeList(legacyItem.tags),
items: []
}
this.insertRecord(normalized)
const legacyFeedItemsRaw = Array.isArray(
(legacyItem as { items?: SubscriptionFeedItem[] }).items
)
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
: []
if (legacyFeedItemsRaw.length > 0) {
const converted = legacyFeedItemsRaw.map((item) => ({
id: item.id,
url: item.url,
title: item.title,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail,
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
downloadId: item.downloadId
}))
this.replaceFeedItems(normalized.id, converted, true)
}
} catch (error) {
log.error('subscriptions: failed to migrate legacy record', error)
}
}
store.clear()
this.emitUpdates()
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
} catch (error) {
log.warn('subscriptions: legacy migration skipped', error)
}
}
private insertRecord(record: SubscriptionRule): void {
const database = this.getDatabase()
const payload = this.mapRecordToInsert(record)
database
.insert(subscriptionsTable)
.values(payload)
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
.run()
}
private updateRecord(record: SubscriptionRule): void {
const database = this.getDatabase()
const payload = this.mapRecordToInsert(record)
database
.insert(subscriptionsTable)
.values(payload)
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
.run()
}
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
return {
id: record.id,
title: record.title,
sourceUrl: record.sourceUrl,
feedUrl: record.feedUrl,
platform: record.platform,
keywords: stringifyArray(record.keywords),
tags: stringifyArray(record.tags),
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
enabled: booleanToNumber(record.enabled),
coverUrl: record.coverUrl,
latestVideoTitle: record.latestVideoTitle,
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
lastCheckedAt: record.lastCheckedAt ?? null,
lastSuccessAt: record.lastSuccessAt ?? null,
status: record.status,
lastError: record.lastError,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
downloadDirectory: record.downloadDirectory,
namingTemplate: record.namingTemplate
? sanitizeFilenameTemplate(record.namingTemplate)
: undefined
}
}
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
return {
id: row.id,
title: row.title,
sourceUrl: row.sourceUrl,
feedUrl: row.feedUrl,
platform: row.platform as SubscriptionRule['platform'],
keywords: parseStringArray(row.keywords),
tags: parseStringArray(row.tags),
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
enabled: numberToBoolean(row.enabled),
coverUrl: row.coverUrl ?? undefined,
latestVideoTitle: row.latestVideoTitle ?? undefined,
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
lastCheckedAt: row.lastCheckedAt ?? undefined,
lastSuccessAt: row.lastSuccessAt ?? undefined,
status: row.status as SubscriptionStatus,
lastError: row.lastError ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
downloadDirectory: row.downloadDirectory ?? undefined,
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
items: []
}
}
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
return {
id: row.itemId,
url: row.url,
title: row.title,
publishedAt: row.publishedAt,
thumbnail: row.thumbnail ?? undefined,
addedToQueue: numberToBoolean(row.added),
downloadId: row.downloadId ?? undefined
}
}
private emitUpdates(): void {
this.emit('subscriptions:updated', this.getAll())
}
}
export const subscriptionManager = new SubscriptionManager()

View File

@@ -0,0 +1,542 @@
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import log from 'electron-log/main'
import Parser from 'rss-parser'
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
import {
buildAudioFormatPreference,
buildVideoFormatPreference
} from '../../shared/utils/format-preferences'
import { settingsManager } from '../settings'
import { downloadEngine } from './download-engine'
import { historyManager } from './history-manager'
import { subscriptionManager } from './subscription-manager'
const logger = log.scope('subscriptions')
type ParserItem = {
title?: string
link?: string
guid?: string
id?: string
isoDate?: string
pubDate?: string
youtubeId?: string
content?: string
contentSnippet?: string
contentEncoded?: string
summary?: string
description?: string
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
mediaContent?: Array<{ url?: string }> | { url?: string }
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
[key: string]: unknown
}
type TrackedDownload = {
subscriptionId: string
itemId: string
url: string
retries: number
downloadId: string
}
type FeedItem = {
id: string
url: string
title: string
publishedAt: number
thumbnail?: string
}
const parser = new Parser<Record<string, never>, ParserItem>({
customFields: {
item: [
['yt:videoId', 'youtubeId'],
['media:thumbnail', 'mediaThumbnail'],
['media:content', 'mediaContent'],
['enclosure', 'enclosure'],
['content:encoded', 'contentEncoded'],
['description', 'description']
]
}
})
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
return `sub_${base}`
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
}
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
logger.warn('Failed to ensure subscription download directory:', error)
}
}
export class SubscriptionScheduler extends EventEmitter {
private timer?: NodeJS.Timeout
private checking = false
private pendingRun = false
private downloads: Map<string, TrackedDownload> = new Map()
constructor() {
super()
downloadEngine.on('download-completed', (id: string) => {
const tracked = this.downloads.get(id)
if (!tracked) {
return
}
this.downloads.delete(id)
subscriptionManager.update(tracked.subscriptionId, {
status: 'up-to-date',
lastSuccessAt: Date.now()
})
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
downloadId: id
})
})
downloadEngine.on('download-error', (id: string, error: Error) => {
const tracked = this.downloads.get(id)
if (!tracked) {
return
}
const currentRetries = tracked.retries ?? 0
if (currentRetries < 1) {
logger.warn('Retrying failed subscription download', { id, error })
this.queueDownload(
tracked.subscriptionId,
tracked.itemId,
tracked.url,
currentRetries + 1
).catch((queueError) => {
logger.error('Retry queue failed:', queueError)
})
return
}
this.downloads.delete(id)
subscriptionManager.update(tracked.subscriptionId, {
status: 'failed',
lastCheckedAt: Date.now()
})
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
downloadId: null
})
})
}
start(): void {
this.scheduleNextRun(0)
}
refreshInterval(): void {
if (this.timer) {
clearTimeout(this.timer)
}
this.scheduleNextRun()
}
async runNow(subscriptionId?: string): Promise<void> {
if (subscriptionId) {
const target = subscriptionManager.getById(subscriptionId)
if (target?.enabled) {
await this.checkSubscription(target)
}
return
}
await this.checkAll()
}
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
const subscription = subscriptionManager.getById(subscriptionId)
if (!subscription) {
return false
}
const item = subscription.items.find((entry) => entry.id === itemId)
if (!item || item.addedToQueue) {
return false
}
await this.queueDownload(subscriptionId, itemId, item.url)
return true
}
private scheduleNextRun(initialDelay?: number): void {
if (this.timer) {
clearTimeout(this.timer)
}
const intervalHours = 3 // Default check interval: 3 hours
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
this.timer = setTimeout(() => {
void this.checkAll().finally(() => this.scheduleNextRun())
}, delayMs)
}
private async checkAll(): Promise<void> {
if (this.checking) {
this.pendingRun = true
return
}
this.checking = true
try {
const subscriptions = subscriptionManager
.getAll()
.filter((subscription) => subscription.enabled)
for (const subscription of subscriptions) {
await this.checkSubscription(subscription)
}
} catch (error) {
logger.error('Failed to run subscription sync', error)
} finally {
this.checking = false
if (this.pendingRun) {
this.pendingRun = false
void this.checkAll()
}
}
}
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
const startedAt = Date.now()
subscriptionManager.update(subscription.id, {
status: 'checking',
lastCheckedAt: startedAt,
lastError: undefined
})
try {
const feed = await parser.parseURL(subscription.feedUrl)
const feedItems = Array.isArray(feed.items) ? feed.items : []
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
const recentItems = this.filterRecentItems(subscription, normalizedItems)
const unseenItems = this.filterNewItems(subscription, recentItems)
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
const keywordFiltered =
keywords.length > 0
? unseenItems.filter((item) => {
const lowered = item.title.toLowerCase()
return keywords.some((keyword) => lowered.includes(keyword))
})
: unseenItems
const deduped = keywordFiltered
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
.sort((a, b) => b.publishedAt - a.publishedAt)
const itemsToDownload =
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
subscriptionManager.replaceFeedItems(
subscription.id,
this.buildFeedItems(normalizedItems, subscription)
)
if (itemsToDownload.length > 0) {
for (const item of itemsToDownload) {
await this.queueDownload(subscription.id, item.id, item.url)
}
}
const latestItem = normalizedItems[0]
const coverUrl = this.resolveSubscriptionCover(
feed,
normalizedItems,
feedItems as ParserItem[]
)
subscriptionManager.update(subscription.id, {
status: 'up-to-date',
lastSuccessAt: Date.now(),
lastError: undefined,
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
coverUrl: coverUrl ?? subscription.coverUrl,
title:
typeof feed.title === 'string' && feed.title.trim().length > 0
? feed.title.trim()
: subscription.title,
sourceUrl:
typeof feed.link === 'string' && feed.link.trim().length > 0
? feed.link.trim()
: subscription.sourceUrl
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown RSS error'
subscriptionManager.update(subscription.id, {
status: 'failed',
lastError: message,
lastCheckedAt: Date.now()
})
logger.error('Subscription check failed:', { id: subscription.id, error })
}
}
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
const normalized: FeedItem[] = []
for (const item of items) {
const id = this.resolveItemId(item)
if (!id || !item.link || !item.title) {
continue
}
normalized.push({
id,
url: item.link,
title: item.title,
publishedAt: this.resolvePublishedAt(item),
thumbnail: this.resolveThumbnail(item)
})
}
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
}
private buildFeedItems(
items: FeedItem[],
subscription: SubscriptionRule
): SubscriptionFeedItem[] {
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
return items.map((item) => {
const tracked = this.getTrackedDownloadByUrl(item.url)
const existing = existingItems.get(item.id)
return {
id: item.id,
url: item.url,
title: item.title,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail,
addedToQueue:
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
downloadId: tracked?.downloadId ?? existing?.downloadId
}
})
}
private resolveItemId(item: ParserItem): string | null {
const idCandidate =
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
if (!idCandidate) {
return null
}
return idCandidate.trim()
}
private resolvePublishedAt(item: ParserItem): number {
const candidates = [item.isoDate, item.pubDate]
for (const candidate of candidates) {
if (!candidate) {
continue
}
const timestamp = Date.parse(candidate)
if (!Number.isNaN(timestamp)) {
return timestamp
}
}
return Date.now()
}
private resolveThumbnail(item: ParserItem): string | undefined {
// Try media:thumbnail first
const thumbnail = item.mediaThumbnail
if (Array.isArray(thumbnail)) {
const found = thumbnail.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
return thumbnail.url as string | undefined
}
// Try enclosure (for RSS feeds with image/jpeg type)
const enclosure = item.enclosure
if (Array.isArray(enclosure)) {
const imageEnclosure = enclosure.find(
(entry) => entry?.url && entry?.type?.startsWith('image/')
)
if (imageEnclosure?.url) return imageEnclosure.url
}
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
const enc = enclosure as { url?: string; type?: string }
if (enc.url && enc.type?.startsWith('image/')) {
return enc.url
}
}
// Try media:content as fallback
const mediaContent = item.mediaContent
if (Array.isArray(mediaContent)) {
const found = mediaContent.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
return mediaContent.url as string | undefined
}
// Try to parse an image from HTML fields
const htmlCandidates = [
item.content,
item.contentEncoded,
item.description,
item.summary,
item.contentSnippet
]
for (const html of htmlCandidates) {
const imageUrl = this.extractImageFromHtml(html)
if (imageUrl) {
return imageUrl
}
}
return undefined
}
private resolveSubscriptionCover(
feed: Parser.Output<ParserItem>,
items: FeedItem[],
rawItems: ParserItem[]
): string | undefined {
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
if (feedImageUrl) {
return feedImageUrl
}
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
if (itunesImageUrl) {
return itunesImageUrl
}
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
if (itemThumbnail) {
return itemThumbnail
}
for (const item of rawItems) {
const thumbnail = this.resolveThumbnail(item)
if (thumbnail) {
return thumbnail
}
}
return undefined
}
private extractImageFromHtml(html?: string): string | undefined {
if (!html) {
return undefined
}
const srcMatch = html.match(
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
)
if (srcMatch?.[2]) {
return srcMatch[2]
}
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
if (srcsetMatch?.[2]) {
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
if (firstCandidate) {
return firstCandidate
}
}
return undefined
}
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
if (lastKnownPublishedAt === 0) {
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
}
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
}
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
}
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
const seenIds = new Set(subscription.items.map((item) => item.id))
return items.filter((item) => !seenIds.has(item.id))
}
private async queueDownload(
subscriptionId: string,
itemId: string,
url: string,
retryCount = 0
): Promise<void> {
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
const isRetry = retryCount > 0
if (this.downloads.has(downloadId) && !isRetry) {
return
}
const subscription = subscriptionManager.getById(subscriptionId)
if (!subscription) {
return
}
const settings = settingsManager.getAll()
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
const namingTemplate =
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
const downloadType = settings.oneClickDownloadType ?? 'video'
const formatPreference =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
ensureDirectoryExists(downloadDirectory)
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
try {
downloadEngine.startDownload(downloadId, {
url,
type: downloadType,
format: formatPreference,
customDownloadPath: downloadDirectory,
customFilenameTemplate: namingTemplate,
tags,
origin: 'subscription',
subscriptionId
})
this.downloads.set(downloadId, {
subscriptionId,
itemId,
url,
retries: retryCount,
downloadId
})
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
added: true,
downloadId
})
} catch (error) {
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
subscriptionManager.update(subscriptionId, {
status: 'failed'
})
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
added: false,
downloadId: null
})
}
}
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
for (const tracked of this.downloads.values()) {
if (tracked.url === url) {
return tracked
}
}
return undefined
}
}
export const subscriptionScheduler = new SubscriptionScheduler()

View File

@@ -2,8 +2,9 @@ import crypto from 'node:crypto'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app } from 'electron'
import { scopedLoggers } from '../utils/logger'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
@@ -37,7 +38,11 @@ export class ThumbnailCache {
return null
}
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
if (
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
originalUrl.startsWith('file://') ||
originalUrl.startsWith('data:')
) {
return originalUrl
}
@@ -59,7 +64,7 @@ export class ThumbnailCache {
const existingPath = await this.findExistingPath(basePath, defaultExtension)
if (existingPath) {
return pathToFileURL(existingPath).toString()
return this.toAppProtocolUrl(existingPath)
}
const response = await fetch(originalUrl)
@@ -75,9 +80,9 @@ export class ThumbnailCache {
const finalPath = `${basePath}${extension}`
await fsPromises.writeFile(finalPath, buffer)
return pathToFileURL(finalPath).toString()
return this.toAppProtocolUrl(finalPath)
} catch (error) {
console.error('Failed to cache thumbnail:', error)
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
return null
}
}
@@ -132,6 +137,13 @@ export class ThumbnailCache {
const basePath = path.join(cacheDir, `${hash}`)
return { basePath, defaultExtension: extension }
}
private toAppProtocolUrl(filePath: string): string {
const userDataPath = app.getPath('userData')
const relativePath = path.relative(userDataPath, filePath).replace(/\\/g, '/')
return `${APP_PROTOCOL_SCHEME}${relativePath}`
}
}
export const thumbnailCache = new ThumbnailCache()

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type YTDlpWrap from 'yt-dlp-wrap-plus'
import { scopedLoggers } from '../utils/logger'
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
class YtDlpManager {
private ytdlpPath: string | null = null
private ytdlpInstance: YTDlpWrapInstance | null = null
private jsRuntimeArgs: string[] = []
async initialize(): Promise<void> {
this.ytdlpPath = await this.findOrDownloadYtDlp()
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
console.log('yt-dlp initialized at:', this.ytdlpPath)
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
}
getInstance(): YTDlpWrapInstance {
@@ -33,6 +36,10 @@ class YtDlpManager {
return this.ytdlpPath
}
getJsRuntimeArgs(): string[] {
return [...this.jsRuntimeArgs]
}
private getResourcesPath(): string {
// In development, read from project root's resources
if (process.env.NODE_ENV === 'development') {
@@ -57,7 +64,7 @@ class YtDlpManager {
// Check environment variable first
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
return process.env.YTDLP_PATH
}
@@ -65,13 +72,13 @@ class YtDlpManager {
const resourcesPath = this.getResourcesPath()
const bundledPath = path.join(resourcesPath, bundledName)
if (fs.existsSync(bundledPath)) {
console.log('Using bundled yt-dlp:', bundledPath)
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
// Make executable on Unix-like systems if needed
if (platform !== 'win32') {
try {
fs.chmodSync(bundledPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission:', error)
scopedLoggers.engine.warn('Failed to set executable permission:', error)
}
}
return bundledPath
@@ -82,7 +89,7 @@ class YtDlpManager {
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
console.log('Using system yt-dlp:', p)
scopedLoggers.engine.info('Using system yt-dlp:', p)
return p
}
}
@@ -93,7 +100,7 @@ class YtDlpManager {
try {
const systemPath = execSync('which yt-dlp').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system yt-dlp:', systemPath)
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
return systemPath
}
} catch (_error) {
@@ -115,6 +122,92 @@ class YtDlpManager {
)
}
private resolveJsRuntimeArgs(): string[] {
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
if (!runtime || runtime === 'none') {
return []
}
const runtimePath = this.resolveJsRuntimePath(runtime)
if (runtimePath) {
return ['--js-runtimes', `${runtime}:${runtimePath}`]
}
if (process.env.YTDLP_JS_RUNTIME) {
scopedLoggers.engine.warn(
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
)
} else {
scopedLoggers.engine.warn(
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
)
}
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
}
private resolveJsRuntimePath(runtime: string): string | null {
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
if (envPath && fs.existsSync(envPath)) {
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
return envPath
}
const platform = os.platform()
const resourcesPath = this.getResourcesPath()
const resourceCandidates: string[] = []
if (runtime === 'deno') {
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
} else if (runtime === 'node') {
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
} else if (runtime === 'bun') {
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
} else if (runtime === 'quickjs') {
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
} else {
resourceCandidates.push(runtime)
if (platform === 'win32' && !runtime.endsWith('.exe')) {
resourceCandidates.push(`${runtime}.exe`)
}
}
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
}
}
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
return fullPath
}
}
try {
if (platform === 'win32') {
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system JS runtime:', output)
return output
}
} else {
const systemPath = execSync(`which ${runtime}`).toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
return systemPath
}
}
} catch (_error) {
// Runtime not found in PATH
}
return null
}
// Removed runtime download/update to avoid network dependency in production builds
}

191
src/main/local-api.ts Normal file
View File

@@ -0,0 +1,191 @@
import crypto from 'node:crypto'
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import log from 'electron-log/main'
import { downloadEngine } from './lib/download-engine'
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const TOKEN_TTL_MS = 60_000
type TokenRecord = {
expiresAt: number
}
let server: http.Server | null = null
let serverPort: number | null = null
const tokens = new Map<string, TokenRecord>()
const isLoopbackAddress = (address?: string | null): boolean => {
if (!address) return false
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
}
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end(JSON.stringify(body))
}
const writeEmpty = (res: http.ServerResponse, status: number): void => {
res.writeHead(status, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end()
}
const issueToken = (): string => {
const token = crypto.randomBytes(16).toString('hex')
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
return token
}
const consumeToken = (token?: string | null): boolean => {
if (!token) return false
const record = tokens.get(token)
if (!record) return false
if (Date.now() > record.expiresAt) {
tokens.delete(token)
return false
}
tokens.delete(token)
return true
}
const handleRequest = async (
req: http.IncomingMessage,
res: http.ServerResponse
): Promise<void> => {
try {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
writeJson(res, 403, { error: 'Forbidden' })
return
}
if (req.method === 'OPTIONS') {
writeEmpty(res, 204)
return
}
if (!req.url) {
writeJson(res, 400, { error: 'Missing URL' })
return
}
const requestUrl = new URL(req.url, 'http://127.0.0.1')
const pathname = requestUrl.pathname
if (req.method !== 'GET') {
writeJson(res, 405, { error: 'Method not allowed' })
return
}
if (pathname === '/token') {
const token = issueToken()
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
return
}
if (pathname === '/video-info') {
const token = requestUrl.searchParams.get('token')
if (!consumeToken(token)) {
writeJson(res, 401, { error: 'Invalid token' })
return
}
const targetUrl = requestUrl.searchParams.get('url')
if (!targetUrl || !targetUrl.trim()) {
writeJson(res, 400, { error: 'Missing url' })
return
}
try {
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
writeJson(res, 200, {
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
formats: info.formats ?? []
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
const details =
error instanceof Error
? error.stack
: typeof error === 'object' && error && 'stderr' in error
? String((error as { stderr?: unknown }).stderr ?? '')
: undefined
writeJson(res, 500, { error: message, details })
}
return
}
if (pathname === '/status') {
writeJson(res, 200, { ok: true })
return
}
writeJson(res, 404, { error: 'Not found' })
} catch (error) {
const message = error instanceof Error ? error.message : 'Unhandled request error'
writeJson(res, 500, { error: message })
}
}
const startServerOnPort = (port: number): Promise<http.Server> =>
new Promise((resolve, reject) => {
const httpServer = http.createServer((req, res) => {
void handleRequest(req, res)
})
httpServer.once('error', (error) => {
httpServer.close()
reject(error)
})
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
})
export async function startExtensionApiServer(): Promise<number | null> {
if (server && serverPort) {
return serverPort
}
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
try {
server = await startServerOnPort(port)
const address = server.address() as AddressInfo | null
serverPort = address?.port ?? port
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
return serverPort
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err.code !== 'EADDRINUSE') {
log.warn('Extension API failed to start on port:', port, err)
}
}
}
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
return null
}
export async function stopExtensionApiServer(): Promise<void> {
if (!server) return
await new Promise<void>((resolve) => {
server?.close(() => resolve())
})
server = null
serverPort = null
tokens.clear()
}

View File

@@ -1,13 +1,30 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { AppSettings } from '../shared/types'
import { defaultSettings } from '../shared/types'
import { scopedLoggers } from './utils/logger'
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
const ensureDirectoryExists = (dir: string) => {
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
scopedLoggers.system.error('Failed to ensure download directory:', error)
}
}
const resolveDefaultDownloadPath = () => {
return path.join(os.homedir(), 'Downloads', 'VidBee')
}
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
class SettingsManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
@@ -16,9 +33,10 @@ class SettingsManager {
this.store = new Store({
defaults: {
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
downloadPath: DEFAULT_DOWNLOAD_PATH
}
})
this.ensureDownloadDirectory()
}
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
@@ -26,15 +44,25 @@ class SettingsManager {
}
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
if (key === 'downloadPath' && typeof value === 'string') {
ensureDirectoryExists(value)
}
this.store.set(key, value)
}
getAll(): AppSettings {
return this.store.store
return {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH,
...this.store.store
}
}
setAll(settings: Partial<AppSettings>): void {
for (const [key, value] of Object.entries(settings)) {
if (key === 'downloadPath' && typeof value === 'string') {
ensureDirectoryExists(value)
}
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
}
}
@@ -43,9 +71,24 @@ class SettingsManager {
this.store.clear()
this.store.set({
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
downloadPath: DEFAULT_DOWNLOAD_PATH
})
}
private ensureDownloadDirectory(): void {
try {
const currentPath: string | undefined = this.store.get('downloadPath')
const normalizedDownloadPath =
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
? DEFAULT_DOWNLOAD_PATH
: currentPath
if (normalizedDownloadPath !== currentPath) {
this.store.set('downloadPath', normalizedDownloadPath)
}
} catch (error) {
scopedLoggers.system.error('Failed to verify download directory:', error)
}
}
}
export const settingsManager = new SettingsManager()

View File

@@ -1,5 +1,7 @@
import { normalizeLanguageCode } from '@shared/languages'
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
import appIcon from '../../resources/icon.png?asset'
import trayIcon from '../../resources/tray-icon.png?asset'
import { settingsManager } from './settings'
let tray: Tray | null = null
@@ -8,7 +10,7 @@ let tray: Tray | null = null
* Get translated text based on current language setting
*/
function t(key: 'showHome' | 'quit'): string {
const language = settingsManager.get('language') || 'en'
const language = normalizeLanguageCode(settingsManager.get('language'))
const translations = {
en: {
@@ -16,12 +18,12 @@ function t(key: 'showHome' | 'quit'): string {
quit: 'Quit'
},
zh: {
showHome: '打开首页',
quit: '关闭应用'
showHome: '显示主页',
quit: '退出应用'
}
}
const displayLang = language.startsWith('en') ? 'en' : 'zh'
const displayLang = language.startsWith('zh') ? 'zh' : 'en'
return translations[displayLang][key]
}
@@ -85,9 +87,17 @@ export function createTray(): void {
return
}
const trayIcon = nativeImage.createFromPath(appIcon)
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
const trayIconImage = nativeImage.createFromPath(iconPath)
tray = new Tray(trayIcon)
// For macOS, ensure the icon is properly sized
if (process.platform === 'darwin') {
// Resize to 16x16 for macOS tray
trayIconImage.setTemplateImage(true)
}
tray = new Tray(trayIconImage)
// Set tooltip
tray.setToolTip('VidBee')

View File

@@ -0,0 +1,38 @@
import { app } from 'electron'
import log from 'electron-log/main'
const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])
export function isAutoLaunchSupported(): boolean {
return SUPPORTED_PLATFORMS.has(process.platform)
}
export function applyAutoLaunchSetting(enabled: boolean): void {
if (!isAutoLaunchSupported()) {
log.info('Auto launch is not supported on this platform, skipping setting update')
return
}
const updateSetting = () => {
try {
const options: Parameters<typeof app.setLoginItemSettings>[0] = {
openAtLogin: enabled
}
if (process.platform === 'darwin') {
options.openAsHidden = true
}
app.setLoginItemSettings(options)
log.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)
} catch (error) {
log.error('Failed to update login item settings:', error)
}
}
if (app.isReady()) {
updateSetting()
} else {
app.once('ready', updateSetting)
}
}

16
src/main/utils/dock.ts Normal file
View File

@@ -0,0 +1,16 @@
import { app } from 'electron'
/**
* Apply Dock visibility preference on macOS.
*/
export function applyDockVisibility(hideDockIcon: boolean): void {
if (process.platform !== 'darwin' || !app.dock) {
return
}
if (hideDockIcon) {
app.dock.hide()
} else {
app.dock.show()
}
}

View File

@@ -0,0 +1,19 @@
import os from 'node:os'
import path from 'node:path'
export const resolvePathWithHome = (rawPath?: string | null): string | undefined => {
const trimmed = rawPath?.trim()
if (!trimmed) {
return undefined
}
if (trimmed === '~') {
return os.homedir()
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2))
}
return trimmed
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<title>VidBee</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
</head>
<body>

Some files were not shown because too many files have changed in this diff Show More