Compare commits
22 Commits
v0.3.4
...
feat/subsc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1893b5d954 | ||
|
|
0de2098755 | ||
|
|
448a15fce4 | ||
|
|
5af393ef84 | ||
|
|
fe59f1d2bf | ||
|
|
c2b2c35da6 | ||
|
|
41e4a4993c | ||
|
|
0bd27a96c3 | ||
|
|
d36313fe97 | ||
|
|
9040224319 | ||
|
|
56351941ee | ||
|
|
e5d8629ba3 | ||
|
|
3375f94848 | ||
|
|
9f7206abfe | ||
|
|
334c7426c2 | ||
|
|
bd7f4a433c | ||
|
|
27287238aa | ||
|
|
486767fca9 | ||
|
|
e22fe7679a | ||
|
|
7c2e526c49 | ||
|
|
2f5776d1e0 | ||
|
|
7901fc7e82 |
125
README.md
125
README.md
@@ -1,36 +1,37 @@
|
||||
# 🐝 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&style=flat-square&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=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&style=flat-square&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&style=flat-square&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&style=flat-square" /></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.
|
||||
|
||||
### 🌍 Global Video Download Support
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
- **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
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
Feel free to try it using the following methods:
|
||||
|
||||
- **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
|
||||
| Operating System | Source |
|
||||
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
@@ -42,49 +43,69 @@ 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.
|
||||
|
||||
## 📸 Screenshots
|
||||
> [!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
|
||||
|
||||
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.
|
||||
|
||||

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

|
||||
*Comprehensive download queue with progress tracking and status management*
|
||||
|
||||
## 🌐 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
|
||||
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal 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'
|
||||
})
|
||||
@@ -23,6 +23,10 @@ mac:
|
||||
notarize: false
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
@@ -19,7 +19,8 @@
|
||||
"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 --x64 --arm64",
|
||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp"
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -27,8 +28,10 @@
|
||||
"@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-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -41,9 +44,11 @@
|
||||
"@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",
|
||||
"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",
|
||||
@@ -56,9 +61,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"
|
||||
},
|
||||
@@ -71,6 +78,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",
|
||||
|
||||
644
pnpm-lock.yaml
generated
644
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal 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
|
||||
);
|
||||
451
resources/drizzle/meta/0000_snapshot.json
Normal file
451
resources/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
resources/drizzle/meta/_journal.json
Normal file
13
resources/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1763176841336,
|
||||
"tag": "0000_swift_aaron_stack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
||||
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
||||
}
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
@@ -80,7 +86,12 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
@@ -22,6 +24,10 @@ configureLogger()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
@@ -80,6 +86,10 @@ export function createWindow(): void {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -214,6 +224,8 @@ app.whenReady().then(async () => {
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -15,6 +16,7 @@ export const services = createServices([
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
SettingsService,
|
||||
SubscriptionService,
|
||||
ThumbnailService,
|
||||
UpdateService,
|
||||
WindowService
|
||||
|
||||
@@ -32,6 +32,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) {
|
||||
console.error('Failed to fetch site icon:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AppService }
|
||||
|
||||
@@ -229,6 +229,25 @@ class FileSystemService extends IpcService {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@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) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
@@ -9,12 +11,20 @@ class SettingsService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||
return settingsManager.get(key)
|
||||
const value = settingsManager.get(key)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||
settingsManager.set(key, value)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
||||
} else {
|
||||
settingsManager.set(key, value)
|
||||
}
|
||||
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
@@ -23,15 +33,30 @@ class SettingsService extends IpcService {
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAll(_context: IpcContext): AppSettings {
|
||||
return settingsManager.getAll()
|
||||
const settings = settingsManager.getAll()
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
settingsManager.setAll(settings)
|
||||
|
||||
if (settings.language) {
|
||||
@@ -41,12 +66,17 @@ class SettingsService extends IpcService {
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
src/main/ipc/services/subscription-service.ts
Normal file
164
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} 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 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 || settings.downloadPath,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || settings.subscriptionFilenameTemplate
|
||||
),
|
||||
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 }
|
||||
6
src/main/lib/database-path.ts
Normal file
6
src/main/lib/database-path.ts
Normal 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')
|
||||
}
|
||||
97
src/main/lib/database/migrate.ts
Normal file
97
src/main/lib/database/migrate.ts
Normal 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
|
||||
}
|
||||
83
src/main/lib/database/schema.ts
Normal file
83
src/main/lib/database/schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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'),
|
||||
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
|
||||
@@ -395,6 +395,8 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
const createdAt = Date.now()
|
||||
const settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -402,7 +404,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
@@ -411,7 +416,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath
|
||||
downloadPath: targetDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,7 +427,8 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadPath = settings.downloadPath
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
@@ -430,9 +439,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let actualQuality: string | null = null
|
||||
let actualCodec: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -444,20 +452,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
if (selectedFormat) {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
|
||||
if (selectedFormat.height) {
|
||||
actualQuality = `${selectedFormat.height}p${
|
||||
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
|
||||
}`
|
||||
} else if (selectedFormat.format_note) {
|
||||
actualQuality = selectedFormat.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = selectedFormat.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
@@ -502,18 +496,6 @@ class DownloadEngine extends EventEmitter {
|
||||
selectedFormat = candidate
|
||||
actualFormat = candidate.ext || actualFormat
|
||||
|
||||
if (candidate.height) {
|
||||
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
|
||||
} else if (candidate.format_note) {
|
||||
actualQuality = candidate.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = candidate.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
@@ -521,7 +503,39 @@ class DownloadEngine extends EventEmitter {
|
||||
return true
|
||||
}
|
||||
|
||||
const args = buildDownloadArgs(options, downloadPath, settings)
|
||||
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
||||
|
||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||
if (!rawPath) {
|
||||
return
|
||||
}
|
||||
const trimmed = rawPath.trim().replace(/^"|"$/g, '')
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
lastKnownOutputPath = path.isAbsolute(trimmed)
|
||||
? trimmed
|
||||
: path.join(resolvedDownloadPath, trimmed)
|
||||
}
|
||||
|
||||
const extractOutputPathFromLog = (message: string): void => {
|
||||
const destinationMatch = message.match(/Destination:\s*(.+)$/)
|
||||
if (destinationMatch) {
|
||||
captureOutputPath(destinationMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/)
|
||||
if (mergingMatch) {
|
||||
captureOutputPath(mergingMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const movingMatch = message.match(/Moving file to\s+"(.+?)"/)
|
||||
if (movingMatch) {
|
||||
captureOutputPath(movingMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Check if format selector contains '+' which means video and audio will be merged
|
||||
const formatSelector =
|
||||
@@ -629,16 +643,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (extMatch && !actualFormat) {
|
||||
actualFormat = extMatch[1]
|
||||
}
|
||||
|
||||
const heightMatch = formatInfo.match(/(\d+)p/)
|
||||
if (heightMatch && !actualQuality) {
|
||||
actualQuality = `${heightMatch[1]}p`
|
||||
}
|
||||
|
||||
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
|
||||
if (codecMatch && !actualCodec) {
|
||||
actualCodec = codecMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +653,10 @@ class DownloadEngine extends EventEmitter {
|
||||
applySelectedFormat(formatMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
@@ -675,32 +683,44 @@ class DownloadEngine extends EventEmitter {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fileName = `${sanitizedTitle}.${extension}`
|
||||
const finalOutputPath = path.join(downloadPath, fileName)
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Generated file path for ID:',
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = finalOutputPath
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
// Try to find the actual file - yt-dlp may generate files with slightly different names
|
||||
const stats = await fs.stat(finalOutputPath)
|
||||
fileSize = stats.size
|
||||
actualFilePath = finalOutputPath
|
||||
} catch (error) {
|
||||
// If the expected file doesn't exist, try to find it by scanning the directory
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const files = await fs.readdir(downloadPath)
|
||||
// Look for files matching the title pattern with the correct extension
|
||||
let located = false
|
||||
for (const candidate of candidatePaths) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(candidate)
|
||||
fileSize = stats.size
|
||||
actualFilePath = candidate
|
||||
located = true
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!located) {
|
||||
const files = await fs.readdir(resolvedDownloadPath)
|
||||
const matchingFiles = files.filter((file) => {
|
||||
const baseName = file.replace(/\.[^.]+$/, '')
|
||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||
@@ -711,30 +731,33 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
// Use the most recently modified file if multiple matches
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(downloadPath, file)
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
return { file, path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
return { path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
})
|
||||
)
|
||||
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
||||
actualFilePath = mostRecent.path
|
||||
fileSize = mostRecent.size
|
||||
located = true
|
||||
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
||||
} else if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
if (!located) {
|
||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id, error)
|
||||
}
|
||||
} catch (scanError) {
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError)
|
||||
}
|
||||
} else if (!fileSize) {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id)
|
||||
}
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,13 +765,13 @@ class DownloadEngine extends EventEmitter {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
|
||||
const savedFileName = path.basename(actualFilePath)
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
this.emit('download-completed', id)
|
||||
@@ -834,15 +857,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.fileSize !== undefined) {
|
||||
historyUpdates.fileSize = updates.fileSize
|
||||
}
|
||||
if (updates.format !== undefined) {
|
||||
historyUpdates.format = updates.format
|
||||
}
|
||||
if (updates.quality !== undefined) {
|
||||
historyUpdates.quality = updates.quality
|
||||
}
|
||||
if (updates.codec !== undefined) {
|
||||
historyUpdates.codec = updates.codec
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
@@ -870,6 +884,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -879,8 +896,8 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
@@ -907,14 +924,13 @@ class DownloadEngine extends EventEmitter {
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
format: completedDownload?.item.format,
|
||||
quality: completedDownload?.item.quality,
|
||||
codec: completedDownload?.item.codec,
|
||||
description: completedDownload?.item.description,
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
viewCount: completedDownload?.item.viewCount,
|
||||
tags: completedDownload?.item.tags,
|
||||
origin: completedDownload?.item.origin,
|
||||
subscriptionId: completedDownload?.item.subscriptionId,
|
||||
playlistId: completedDownload?.item.playlistId,
|
||||
playlistTitle: completedDownload?.item.playlistTitle,
|
||||
playlistIndex: completedDownload?.item.playlistIndex,
|
||||
@@ -928,6 +944,8 @@ class DownloadEngine extends EventEmitter {
|
||||
updates: Partial<DownloadHistoryItem>
|
||||
): void {
|
||||
const existing = historyManager.getHistoryById(id)
|
||||
const resolvedDownloadPath =
|
||||
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||
const base: DownloadHistoryItem = existing ?? {
|
||||
id,
|
||||
url: options.url,
|
||||
@@ -935,20 +953,20 @@ class DownloadEngine extends EventEmitter {
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
format: updates.format,
|
||||
quality: updates.quality,
|
||||
codec: updates.codec,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
viewCount: updates.viewCount,
|
||||
tags: updates.tags,
|
||||
tags: updates.tags ?? options.tags,
|
||||
origin: updates.origin ?? options.origin,
|
||||
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
|
||||
// Download-specific format info
|
||||
selectedFormat: updates.selectedFormat,
|
||||
playlistId: updates.playlistId,
|
||||
@@ -965,7 +983,11 @@ class DownloadEngine extends EventEmitter {
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
status: updates.status ?? base.status,
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
|
||||
downloadPath: resolvedDownloadPath ?? base.downloadPath,
|
||||
tags: updates.tags ?? base.tags,
|
||||
origin: updates.origin ?? base.origin,
|
||||
subscriptionId: updates.subscriptionId ?? base.subscriptionId
|
||||
}
|
||||
|
||||
historyManager.addHistoryItem(merged)
|
||||
|
||||
@@ -1,52 +1,476 @@
|
||||
// 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, 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,
|
||||
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 needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
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,
|
||||
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,
|
||||
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 +480,52 @@ 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
|
||||
}
|
||||
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 +557,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()
|
||||
|
||||
641
src/main/lib/subscription-manager.ts
Normal file
641
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
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 LegacyStore = require('electron-store')
|
||||
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()
|
||||
458
src/main/lib/subscription-scheduler.ts
Normal file
458
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
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 { 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
|
||||
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<{ item: ParserItem }>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
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 = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
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]
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? 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
|
||||
}
|
||||
|
||||
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() || settings.subscriptionFilenameTemplate
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
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()
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AppSettings } from '../shared/types'
|
||||
@@ -8,6 +9,24 @@ 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) {
|
||||
console.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
|
||||
class SettingsManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
@@ -16,9 +35,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,6 +46,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -35,6 +58,9 @@ class SettingsManager {
|
||||
|
||||
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,8 +69,25 @@ class SettingsManager {
|
||||
this.store.clear()
|
||||
this.store.set({
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(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)
|
||||
}
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to verify download directory:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import { Sidebar } from '@renderer/components/ui/sidebar'
|
||||
import { Toaster } from '@renderer/components/ui/sonner'
|
||||
import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||
import { useAtom } from 'jotai'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -11,18 +12,81 @@ import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { SupportedSites } from './pages/SupportedSites'
|
||||
import { settingsAtom } from './store/settings'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const { t } = useTranslation()
|
||||
const autoUpdateEnabled = settings.autoUpdate
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptions()
|
||||
|
||||
const handleSubscriptions = (...args: unknown[]) => {
|
||||
const list = args[0]
|
||||
if (Array.isArray(list)) {
|
||||
setSubscriptions(list as SubscriptionRule[])
|
||||
}
|
||||
}
|
||||
|
||||
ipcEvents.on('subscriptions:updated', handleSubscriptions)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)
|
||||
}
|
||||
}, [loadSubscriptions, setSubscriptions])
|
||||
|
||||
// Load or remove analytics script based on settings
|
||||
useEffect(() => {
|
||||
const scriptId = 'analytics-script'
|
||||
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
||||
|
||||
if (settings.enableAnalytics) {
|
||||
// Remove existing script if it exists
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
}
|
||||
|
||||
// Create and append new script
|
||||
const script = document.createElement('script')
|
||||
script.id = scriptId
|
||||
script.src = 'https://rybbit.102417.xyz/api/script.js'
|
||||
script.setAttribute('data-site-id', '7bc6f6d625a4')
|
||||
script.defer = true
|
||||
script.async = true
|
||||
document.head.appendChild(script)
|
||||
analyticsScriptRef.current = script
|
||||
} else {
|
||||
// Remove script if analytics is disabled
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
analyticsScriptRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
const script = document.getElementById(scriptId)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
}
|
||||
}, [settings.enableAnalytics])
|
||||
|
||||
useEffect(() => {
|
||||
// Get platform info to determine if we should show title bar
|
||||
@@ -61,35 +125,6 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoToDownloadPage = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open('https://vidbee.org/download/', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
if (autoUpdateEnabled) {
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
// No need to manually call downloadUpdate() because autoDownload is true
|
||||
} else {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
resetDownloadState()
|
||||
@@ -135,33 +170,45 @@ function AppContent() {
|
||||
})
|
||||
}
|
||||
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
// Only listen to update events that should be shown globally
|
||||
// update:available is handled in About page only
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.on('update:error', handleUpdateError)
|
||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [autoUpdateEnabled, t])
|
||||
}, [t])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'subscriptions':
|
||||
return <Subscriptions />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
return <SupportedSites />
|
||||
default:
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css';
|
||||
@import "tw-animate-css";
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -8,5 +10,9 @@
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,32 +60,32 @@
|
||||
--card-foreground: oklch(0.8853 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.9328 0.0025 228.7857);
|
||||
--primary: oklch(0.6692 0.1607 245.0110);
|
||||
--primary: oklch(0.8223 0.1704 79.8747);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9622 0.0035 219.5331);
|
||||
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--muted: oklch(0.2090 0 0);
|
||||
--muted: oklch(0.3485 0 0);
|
||||
--muted-foreground: oklch(0.5637 0.0078 247.9662);
|
||||
--accent: oklch(0.1928 0.0331 242.5459);
|
||||
--accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.2674 0.0047 248.0045);
|
||||
--input: oklch(0.3020 0.0288 244.8244);
|
||||
--ring: oklch(0.6818 0.1584 243.3540);
|
||||
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||
--ring: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-1: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||
--sidebar: oklch(0.2097 0.0080 274.5332);
|
||||
--sidebar-foreground: oklch(0.8853 0 0);
|
||||
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
|
||||
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-border: oklch(0.3795 0.0220 240.5943);
|
||||
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import {
|
||||
type DownloadRecord,
|
||||
@@ -16,19 +27,110 @@ import {
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
// Helper function to generate file path with proper path separators
|
||||
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
|
||||
const fileName = `${title}.${format}`
|
||||
// Use proper path joining for cross-platform compatibility
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
const generateFilePathCandidates = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const candidateFileNames = savedFileName
|
||||
? [savedFileName]
|
||||
: [`${title} via VidBee.${format}`, `${title}.${format}`]
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
return `${normalizedDownloadPath}/${fileName}`
|
||||
return Array.from(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
}
|
||||
|
||||
const tryFileOperation = async (
|
||||
paths: string[],
|
||||
operation: (filePath: string) => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
for (const filePath of paths) {
|
||||
const success = await operation(filePath)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||
if (!fileName) {
|
||||
return undefined
|
||||
}
|
||||
const normalized = fileName.trim()
|
||||
if (!normalized.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
const ext = normalized.split('.').pop()
|
||||
return ext?.toLowerCase()
|
||||
}
|
||||
|
||||
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
if (savedExt) {
|
||||
return savedExt
|
||||
}
|
||||
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||
if (selectedExt) {
|
||||
return selectedExt
|
||||
}
|
||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||
}
|
||||
|
||||
const getFormatLabel = (download: DownloadRecord): string | undefined => {
|
||||
if (download.selectedFormat?.ext) {
|
||||
return download.selectedFormat.ext.toUpperCase()
|
||||
}
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
return savedExt ? savedExt.toUpperCase() : undefined
|
||||
}
|
||||
|
||||
const getQualityLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitizeCodec = (codec?: string | null): string | undefined => {
|
||||
if (!codec || codec === 'none') {
|
||||
return undefined
|
||||
}
|
||||
return codec
|
||||
}
|
||||
|
||||
const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (download.type === 'audio' || download.type === 'extract') {
|
||||
return sanitizeCodec(format.acodec)
|
||||
}
|
||||
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
label: string
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
@@ -52,20 +154,71 @@ const formatDate = (timestamp?: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateShort = (timestamp?: number) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
const isHistory = download.entryType === 'history'
|
||||
const isSubscriptionDownload = download.origin === 'subscription'
|
||||
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
|
||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||
const actionsContainerBaseClass =
|
||||
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
|
||||
const actionsContainerClass = showActionsWithoutHover
|
||||
? actionsContainerBaseClass
|
||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
const checkFileExists = async () => {
|
||||
if (!download.title || !download.downloadPath) {
|
||||
setFileExists(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formatForPath = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
download.downloadPath,
|
||||
download.title,
|
||||
formatForPath,
|
||||
download.savedFileName
|
||||
)
|
||||
for (const filePath of filePaths) {
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
if (exists) {
|
||||
setFileExists(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
setFileExists(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
setFileExists(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -79,12 +232,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.openFileLocation(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.openFileLocation(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -93,20 +252,40 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return Boolean(download.title && download.downloadPath && fileExists)
|
||||
}
|
||||
|
||||
// need title, downloadPath, format
|
||||
const handleCopyToClipboard = async () => {
|
||||
if (!download.title || !download.downloadPath || !download.format) {
|
||||
if (!canCopyToClipboard()) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||
const downloadPath = download.downloadPath
|
||||
const format = resolvedExtension
|
||||
const title = download.title
|
||||
|
||||
if (!downloadPath || !title) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath
|
||||
const format = download.format
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.copyFileToClipboard(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.copyFileToClipboard(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
@@ -122,16 +301,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
// Remove from history first
|
||||
await ipcServices.history.removeHistoryItem(download.id)
|
||||
|
||||
// Then try to delete the file
|
||||
await ipcServices.fs.deleteFile(filePath)
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
console.warn('Failed to delete download file for history item:', download.id)
|
||||
}
|
||||
|
||||
removeHistory(download.id)
|
||||
toast.success(t('notifications.itemRemoved'))
|
||||
@@ -180,14 +368,256 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const statusIcon = getStatusIcon()
|
||||
const statusText = getStatusText()
|
||||
const sourceDisplay =
|
||||
download.uploader && download.channel && download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader || download.channel || ''
|
||||
|
||||
const metadataDetails: MetadataDetail[] = []
|
||||
|
||||
if (timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('history.date'),
|
||||
value: formatDate(timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceDisplay) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.source'),
|
||||
value: sourceDisplay
|
||||
})
|
||||
}
|
||||
|
||||
if (download.playlistId) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.playlist'),
|
||||
value: (
|
||||
<span>
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined && download.playlistSize !== undefined ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{` ${t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.duration) {
|
||||
metadataDetails.push({
|
||||
label: t('history.duration'),
|
||||
value: formatDuration(download.duration)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFormatSize =
|
||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||
|
||||
const formatLabelValue = getFormatLabel(download)
|
||||
|
||||
if (formatLabelValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.format'),
|
||||
value: formatLabelValue
|
||||
})
|
||||
}
|
||||
|
||||
const qualityLabel = getQualityLabel(download)
|
||||
|
||||
if (qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.quality'),
|
||||
value: qualityLabel
|
||||
})
|
||||
}
|
||||
|
||||
if (inlineFileSize) {
|
||||
metadataDetails.push({
|
||||
label: t('history.fileSize'),
|
||||
value: inlineFileSize
|
||||
})
|
||||
}
|
||||
|
||||
const codecValue = getCodecLabel(download)
|
||||
if (codecValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.codec'),
|
||||
value: codecValue
|
||||
})
|
||||
}
|
||||
|
||||
if (download.savedFileName) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.savedFile'),
|
||||
value: download.savedFileName
|
||||
})
|
||||
}
|
||||
|
||||
if (download.url) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.url'),
|
||||
value: (
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="wrap-break-word text-primary hover:underline"
|
||||
>
|
||||
{download.url}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Additional metadata fields
|
||||
if (download.description) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.description'),
|
||||
value: <span className="wrap-break-word">{download.description}</span>
|
||||
})
|
||||
}
|
||||
|
||||
if (download.viewCount !== undefined && download.viewCount !== null) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.views'),
|
||||
value: download.viewCount.toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
if (download.tags && download.tags.length > 0) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.tags'),
|
||||
value: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{download.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.downloadPath) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.downloadPath'),
|
||||
value: <span className="wrap-break-word font-mono text-xs">{download.downloadPath}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if (download.createdAt && download.createdAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.createdAt'),
|
||||
value: formatDate(download.createdAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.startedAt) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.startedAt'),
|
||||
value: formatDate(download.startedAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.completedAt && download.completedAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.completedAt'),
|
||||
value: formatDate(download.completedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// Speed
|
||||
if (download.speed) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.speed'),
|
||||
value: download.speed
|
||||
})
|
||||
}
|
||||
|
||||
// File size (if different from inlineFileSize)
|
||||
if (download.fileSize && download.fileSize !== selectedFormatSize) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fileSize'),
|
||||
value: formatFileSize(download.fileSize)
|
||||
})
|
||||
}
|
||||
|
||||
// Selected format details
|
||||
if (download.selectedFormat) {
|
||||
if (download.selectedFormat.width) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.width'),
|
||||
value: `${download.selectedFormat.width}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.height && !qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.height'),
|
||||
value: `${download.selectedFormat.height}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.fps) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fps'),
|
||||
value: `${download.selectedFormat.fps}`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.vcodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.videoCodec'),
|
||||
value: download.selectedFormat.vcodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.acodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.audioCodec'),
|
||||
value: download.selectedFormat.acodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.format_note) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.formatNote'),
|
||||
value: download.selectedFormat.format_note
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.protocol) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.protocol'),
|
||||
value: download.selectedFormat.protocol.toUpperCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSubscriptionDownload) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.subscription'),
|
||||
value: subscriptionLabel
|
||||
})
|
||||
}
|
||||
|
||||
const hasMetadataDetails = metadataDetails.length > 0
|
||||
|
||||
return (
|
||||
<div className="group relative w-full max-w-full overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
||||
<ImageWithPlaceholder
|
||||
src={thumbnailSrc}
|
||||
<RemoteImage
|
||||
src={download.thumbnail}
|
||||
alt={download.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
@@ -198,115 +628,111 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
{download.title}
|
||||
</p>
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
|
||||
{download.type}
|
||||
</Badge>
|
||||
{(statusIcon || statusText) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{statusIcon}
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{download.playlistId && (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
|
||||
>
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined && (
|
||||
<span className="ml-1 text-muted-foreground/80">
|
||||
{t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{timestamp ? (
|
||||
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
|
||||
<span className="truncate text-muted-foreground/80">
|
||||
{formatDateShort(timestamp)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{/* Source link */}
|
||||
{(sourceDisplay || download.url) &&
|
||||
(download.url ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-[180px] truncate hover:text-primary transition-colors"
|
||||
>
|
||||
{sourceDisplay || download.url}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate">{sourceDisplay}</span>
|
||||
))}
|
||||
|
||||
<span className="truncate max-w-[120px] text-left">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{download.uploader &&
|
||||
download.channel &&
|
||||
download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader
|
||||
? `${download.uploader}`
|
||||
: download.channel
|
||||
? `${download.channel}`
|
||||
: ''}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
|
||||
{download.selectedFormat ? (
|
||||
{/* Playlist info */}
|
||||
{download.playlistId && (
|
||||
<>
|
||||
{download.selectedFormat.height ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.height}p
|
||||
{download.selectedFormat.fps === 60 ? '60' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.ext ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.ext.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
|
||||
<span className="text-[10px] opacity-75">
|
||||
{formatFileSize(
|
||||
download.selectedFormat.filesize ||
|
||||
download.selectedFormat.filesize_approx
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.quality ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.quality}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.format ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.format.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.codec ? (
|
||||
<span className="text-[10px] opacity-75">{download.codec}</span>
|
||||
) : null}
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="max-w-[200px] truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined &&
|
||||
` (${download.playlistIndex}/${download.playlistSize})`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality badge */}
|
||||
{qualityLabel && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{qualityLabel}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* File size */}
|
||||
{inlineFileSize && <span>{inlineFileSize}</span>}
|
||||
|
||||
{/* Details toggle */}
|
||||
{hasMetadataDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={detailsOpen ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((prev) => !prev)}
|
||||
>
|
||||
{detailsOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{detailsOpen && hasMetadataDetails && (
|
||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-3 text-xs">
|
||||
{metadataDetails.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{item.label}</span>
|
||||
<span className="flex-1 wrap-break-word text-foreground">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={actionsContainerClass}>
|
||||
{isHistory ? (
|
||||
@@ -320,7 +746,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!download.title || !download.downloadPath || !download.format}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -373,6 +799,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { History as HistoryIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
@@ -17,7 +17,6 @@ export function UnifiedDownloadHistory() {
|
||||
const { t } = useTranslation()
|
||||
const allRecords = useAtomValue(downloadsArrayAtom)
|
||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||
const clearCompleted = useSetAtom(clearCompletedAtom)
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
|
||||
useHistorySync()
|
||||
@@ -99,33 +98,9 @@ export function UnifiedDownloadHistory() {
|
||||
return { order, groups }
|
||||
}, [filteredRecords])
|
||||
|
||||
const hasCompletedActive = allRecords.some(
|
||||
(item) => item.entryType === 'active' && item.status === 'completed'
|
||||
)
|
||||
const handleClearCompleted = () => {
|
||||
clearCompleted()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('download.downloadQueue')}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{hasCompletedActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 border border-border/60 px-3"
|
||||
onClick={handleClearCompleted}
|
||||
>
|
||||
{t('download.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<CardHeader className="gap-4 p-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
@@ -155,7 +130,7 @@ export function UnifiedDownloadHistory() {
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 overflow-hidden w-full">
|
||||
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<HistoryIcon className="h-10 w-10 opacity-50" />
|
||||
@@ -191,6 +166,6 @@ export function UnifiedDownloadHistory() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@renderer/components/ui/dialog'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { settingsAtom } from '@renderer/store/settings'
|
||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||
import type { SubscriptionResolvedFeed, SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const sanitizeCommaList = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||
|
||||
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
|
||||
|
||||
export interface SubscriptionFormData {
|
||||
url?: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface SubscriptionFormDialogProps {
|
||||
mode: 'add' | 'edit'
|
||||
subscription?: SubscriptionRule
|
||||
open: boolean
|
||||
onSave: (data: SubscriptionFormData) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SubscriptionFormDialog({
|
||||
mode,
|
||||
subscription,
|
||||
open,
|
||||
onSave,
|
||||
onClose
|
||||
}: SubscriptionFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||
|
||||
// Form state
|
||||
const [url, setUrl] = useState('')
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [onlyLatest, setOnlyLatest] = useState(false)
|
||||
const [downloadDirectory, setDownloadDirectory] = useState('')
|
||||
const [namingTemplate, setNamingTemplate] = useState('')
|
||||
|
||||
// Feed detection state
|
||||
const [detectedFeed, setDetectedFeed] = useState<SubscriptionResolvedFeed | null>(null)
|
||||
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||
|
||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevDefaultPathRef = useRef(settings.downloadPath)
|
||||
const urlInputId = useId()
|
||||
|
||||
// Initialize form values based on mode
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'edit' && subscription) {
|
||||
setUrl(subscription.feedUrl)
|
||||
setKeywords(subscription.keywords.join(', '))
|
||||
setTags(subscription.tags.join(', '))
|
||||
setOnlyLatest(subscription.onlyDownloadLatest)
|
||||
setDownloadDirectory(subscription.downloadDirectory || '')
|
||||
setNamingTemplate(subscription.namingTemplate || '')
|
||||
} else {
|
||||
// Add mode - use defaults from settings
|
||||
setUrl('')
|
||||
setKeywords('')
|
||||
setTags('')
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
setDownloadDirectory(settings.downloadPath)
|
||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||
}
|
||||
setDetectedFeed(null)
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
subscription,
|
||||
settings.subscriptionOnlyLatestDefault,
|
||||
settings.downloadPath,
|
||||
settings.subscriptionFilenameTemplate
|
||||
])
|
||||
|
||||
// Sync download directory with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
const newPath = settings.downloadPath
|
||||
setDownloadDirectory((prev) => {
|
||||
if (!prev || prev === prevDefaultPathRef.current) {
|
||||
return newPath
|
||||
}
|
||||
return prev
|
||||
})
|
||||
prevDefaultPathRef.current = newPath
|
||||
}
|
||||
}, [settings.downloadPath, mode])
|
||||
|
||||
// Sync naming template with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||
}
|
||||
}, [settings.subscriptionFilenameTemplate, mode])
|
||||
|
||||
// Sync onlyLatest with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
}
|
||||
}, [settings.subscriptionOnlyLatestDefault, mode])
|
||||
|
||||
// Feed detection logic
|
||||
useEffect(() => {
|
||||
if (!url.trim()) {
|
||||
setDetectedFeed(null)
|
||||
return
|
||||
}
|
||||
|
||||
// In edit mode, don't detect if URL hasn't changed
|
||||
if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {
|
||||
setDetectedFeed(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
|
||||
detectTimeout.current = setTimeout(async () => {
|
||||
setDetectingFeed(true)
|
||||
try {
|
||||
const result = await resolveFeed(url.trim())
|
||||
setDetectedFeed(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
setDetectedFeed(null)
|
||||
} finally {
|
||||
setDetectingFeed(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
}
|
||||
}, [url, resolveFeed, mode, subscription])
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setDownloadDirectory(path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('subscriptions.notifications.directoryError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRSSHubDocs = async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate URL for add mode
|
||||
if (mode === 'add' && !url.trim()) {
|
||||
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
const formData: SubscriptionFormData = {
|
||||
keywords: sanitizeCommaList(keywords),
|
||||
tags: sanitizeCommaList(tags),
|
||||
onlyDownloadLatest: onlyLatest,
|
||||
downloadDirectory: downloadDirectory || undefined,
|
||||
namingTemplate: namingTemplate || undefined
|
||||
}
|
||||
|
||||
// Include URL if it's provided and different from current (for edit mode)
|
||||
if (url.trim()) {
|
||||
if (
|
||||
mode === 'add' ||
|
||||
(mode === 'edit' && subscription && url.trim() !== subscription.feedUrl)
|
||||
) {
|
||||
try {
|
||||
await resolveFeed(url.trim())
|
||||
formData.url = url.trim()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
toast.error(t('subscriptions.notifications.resolveError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(formData)
|
||||
}
|
||||
|
||||
const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'
|
||||
const descriptionKey =
|
||||
mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'
|
||||
const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' && subscription
|
||||
? t(titleKey, { name: subscription.title })
|
||||
: t(titleKey)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t(descriptionKey)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
|
||||
<Input
|
||||
id={urlInputId}
|
||||
value={url}
|
||||
placeholder={t('subscriptions.placeholders.url')}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
{detectedFeed && (
|
||||
<Badge variant="outline" className="w-fit text-xs">
|
||||
{t('subscriptions.detectedFeed', {
|
||||
platform: detectedFeed.platform,
|
||||
feed: detectedFeed.feedUrl
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{detectingFeed && (
|
||||
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
|
||||
)}
|
||||
{mode === 'add' && !url.trim() && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground flex-1">
|
||||
{t('subscriptions.rssHub.hint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title={t('subscriptions.rssHub.openDocs')}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.tags')}</Label>
|
||||
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={downloadDirectory} readOnly />
|
||||
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
|
||||
{t('subscriptions.actions.selectDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
||||
<Input
|
||||
value={namingTemplate}
|
||||
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{mode === 'add' && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup
|
||||
}
|
||||
@@ -1,101 +1,126 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
DialogTrigger
|
||||
}
|
||||
|
||||
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type * as React from 'react'
|
||||
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -9,6 +9,7 @@ interface ImageWithPlaceholderProps {
|
||||
placeholderClassName?: string
|
||||
fallbackIcon?: React.ReactNode
|
||||
onError?: () => void
|
||||
onLoad?: () => void
|
||||
}
|
||||
|
||||
export function ImageWithPlaceholder({
|
||||
@@ -17,7 +18,8 @@ export function ImageWithPlaceholder({
|
||||
className,
|
||||
placeholderClassName,
|
||||
fallbackIcon,
|
||||
onError
|
||||
onError,
|
||||
onLoad
|
||||
}: ImageWithPlaceholderProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -30,6 +32,7 @@ export function ImageWithPlaceholder({
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false)
|
||||
onLoad?.()
|
||||
}
|
||||
|
||||
// Show placeholder if no src, error occurred, or still loading
|
||||
|
||||
121
src/renderer/src/components/ui/remote-image.tsx
Normal file
121
src/renderer/src/components/ui/remote-image.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ImageWithPlaceholder } from './image-with-placeholder'
|
||||
|
||||
interface RemoteImageProps {
|
||||
/**
|
||||
* Remote image URL or local file path
|
||||
* If it's a remote URL (http/https), it will be cached automatically
|
||||
* If it's a local path (file:// or data:), it will be used directly
|
||||
*/
|
||||
src?: string | null
|
||||
alt: string
|
||||
className?: string
|
||||
placeholderClassName?: string
|
||||
fallbackIcon?: React.ReactNode
|
||||
/**
|
||||
* Custom loading icon/indicator
|
||||
* @default Loader2 spinner
|
||||
*/
|
||||
loadingIcon?: React.ReactNode
|
||||
onError?: () => void
|
||||
/**
|
||||
* Callback when loading state changes
|
||||
*/
|
||||
onLoadingChange?: (loading: boolean) => void
|
||||
/**
|
||||
* Whether to use thumbnail cache for remote URLs
|
||||
* @default true
|
||||
*/
|
||||
useCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified remote image component with automatic caching support
|
||||
*
|
||||
* This component automatically handles:
|
||||
* - Remote URL caching via thumbnail cache service
|
||||
* - Local file paths (file://, data:)
|
||||
* - Loading states and error handling
|
||||
* - Placeholder display with loading indicator
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Remote URL with automatic caching
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
|
||||
*
|
||||
* // Local file path (no caching)
|
||||
* <RemoteImage src="file:///path/to/image.jpg" alt="Local" />
|
||||
*
|
||||
* // Without cache (direct load)
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
|
||||
*
|
||||
* // Custom loading icon
|
||||
* <RemoteImage
|
||||
* src="https://example.com/image.jpg"
|
||||
* alt="Example"
|
||||
* loadingIcon={<CustomSpinner />}
|
||||
* />
|
||||
*
|
||||
* // Track loading state
|
||||
* <RemoteImage
|
||||
* src="https://example.com/image.jpg"
|
||||
* alt="Example"
|
||||
* onLoadingChange={(loading) => console.log('Loading:', loading)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function RemoteImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
placeholderClassName,
|
||||
fallbackIcon,
|
||||
loadingIcon,
|
||||
onError,
|
||||
onLoadingChange,
|
||||
useCache = true
|
||||
}: RemoteImageProps) {
|
||||
const shouldUseCache =
|
||||
useCache &&
|
||||
src &&
|
||||
!src.startsWith('file://') &&
|
||||
!src.startsWith('data:') &&
|
||||
(src.startsWith('http://') || src.startsWith('https://'))
|
||||
|
||||
const cachedSrc = useCachedThumbnail(shouldUseCache ? src : undefined)
|
||||
|
||||
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
|
||||
|
||||
const [isImageLoading, setIsImageLoading] = useState(true)
|
||||
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
|
||||
const isLoading = isCacheLoading || isImageLoading
|
||||
|
||||
useEffect(() => {
|
||||
if (imageSrc) {
|
||||
setIsImageLoading(true)
|
||||
} else {
|
||||
setIsImageLoading(false)
|
||||
}
|
||||
}, [imageSrc])
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(isLoading)
|
||||
}, [isLoading, onLoadingChange])
|
||||
|
||||
const defaultLoadingIcon = <Loader2 className="h-6 w-6 animate-spin" />
|
||||
const displayLoadingIcon = loadingIcon ?? defaultLoadingIcon
|
||||
|
||||
return (
|
||||
<ImageWithPlaceholder
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
placeholderClassName={placeholderClassName}
|
||||
fallbackIcon={isLoading ? displayLoadingIcon : fallbackIcon}
|
||||
onError={onError}
|
||||
onLoad={() => setIsImageLoading(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import '../../assets/title-bar.css'
|
||||
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
||||
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
||||
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
||||
@@ -18,10 +19,12 @@ import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||
import MingcuteRssLine from '~icons/mingcute/rss-line'
|
||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
interface NavigationItem {
|
||||
id: Page
|
||||
@@ -52,6 +55,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
},
|
||||
label: t('menu.download')
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
icon: {
|
||||
active: MingcuteRssFill,
|
||||
inactive: MingcuteRssLine
|
||||
},
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
icon: {
|
||||
@@ -101,21 +112,15 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
||||
{item.label}
|
||||
@@ -126,9 +131,9 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
{/* App Logo */}
|
||||
<div className="flex flex-col items-center gap-1 py-4 mb-1">
|
||||
<div className="flex flex-col items-center gap-1 py-3 mt-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center">
|
||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||
</div>
|
||||
@@ -148,7 +153,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-12 h-12">
|
||||
<Button variant="ghost" size="icon" className="no-drag w-12 h-12">
|
||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -192,7 +197,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
export interface PopularSite {
|
||||
id: string
|
||||
url: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
export const popularSites: PopularSite[] = [
|
||||
{ id: 'youtube' },
|
||||
{ id: 'youtubemusic' },
|
||||
{ id: 'tiktok' },
|
||||
{ id: 'facebook' },
|
||||
{ id: 'instagram' },
|
||||
{ id: 'twitter' },
|
||||
{ id: 'soundcloud' },
|
||||
{ id: 'reddit' },
|
||||
{ id: 'vimeo' },
|
||||
{ id: 'dailymotion' },
|
||||
{ id: 'twitch' },
|
||||
{ id: 'linkedin' },
|
||||
{ id: 'pinterest' },
|
||||
{ id: 'tumblr' },
|
||||
{ id: 'mixcloud' },
|
||||
{ id: 'niconico' },
|
||||
{ id: 'kick' },
|
||||
{ id: 'bandcamp' }
|
||||
{ id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },
|
||||
{ id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },
|
||||
{ id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },
|
||||
{ id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },
|
||||
{ id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },
|
||||
{ id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },
|
||||
{ id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },
|
||||
{ id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },
|
||||
{ id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },
|
||||
{ id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },
|
||||
{ id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },
|
||||
{ id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },
|
||||
{ id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },
|
||||
{ id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },
|
||||
{ id: 'kick', url: 'https://kick.com', domain: 'kick.com' },
|
||||
{ id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }
|
||||
]
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"available": "New version available",
|
||||
"uptodate": "You're up to date",
|
||||
"error": "Unable to fetch the latest version"
|
||||
}
|
||||
},
|
||||
"downloadingUpdate": "Downloading update"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Close app when download finishes",
|
||||
@@ -139,8 +140,10 @@
|
||||
"noAudio": "No Audio",
|
||||
"noHistory": "No download history",
|
||||
"noItems": "No items found",
|
||||
"goToSettings": "Go to Settings",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
|
||||
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
|
||||
"oneClickDownloadNow": "Download Now",
|
||||
"oneClickDownloadStarted": "Download started with default settings",
|
||||
"paste": "Paste",
|
||||
@@ -149,6 +152,8 @@
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectFormat": "Select Format",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
@@ -161,7 +166,33 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Video Information",
|
||||
"videoInfoUpdated": "Video information updated"
|
||||
"videoInfoUpdated": "Video information updated",
|
||||
"metadata": {
|
||||
"source": "Source",
|
||||
"playlist": "Playlist",
|
||||
"format": "Format",
|
||||
"quality": "Quality",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Saved file",
|
||||
"url": "Source URL",
|
||||
"description": "Description",
|
||||
"views": "Views",
|
||||
"tags": "Tags",
|
||||
"downloadPath": "Download path",
|
||||
"createdAt": "Created at",
|
||||
"startedAt": "Started at",
|
||||
"completedAt": "Completed at",
|
||||
"speed": "Speed",
|
||||
"fileSize": "File size",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Video codec",
|
||||
"audioCodec": "Audio codec",
|
||||
"formatNote": "Format note",
|
||||
"protocol": "Protocol",
|
||||
"subscription": "Subscription"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Click to copy details",
|
||||
@@ -215,6 +246,8 @@
|
||||
"about": "About",
|
||||
"download": "Download",
|
||||
"playlist": "Download Playlist",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Subscriptions",
|
||||
"preferences": "Preferences",
|
||||
"supportedSites": "Supported Sites",
|
||||
"theme": "Theme:"
|
||||
@@ -295,6 +328,7 @@
|
||||
},
|
||||
"configFile": "Use configuration file",
|
||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||
"clearConfigFile": "Clear",
|
||||
"dark": "Dark",
|
||||
"description": "Configure your download preferences and application settings",
|
||||
"directorySelectError": "Failed to select directory",
|
||||
@@ -306,6 +340,8 @@
|
||||
"light": "Light",
|
||||
"hideDockIcon": "Hide Dock icon",
|
||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||
"enableAnalytics": "Help improve VidBee",
|
||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
@@ -330,6 +366,10 @@
|
||||
"selectPath": "Select",
|
||||
"showMoreFormats": "Show more format options",
|
||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
||||
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||
@@ -340,6 +380,120 @@
|
||||
},
|
||||
"video": "Video Preferences"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Subscriptions",
|
||||
"subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}",
|
||||
"description": "Automatically monitor RSS feeds and queue new downloads without manual work.",
|
||||
"defaults": {
|
||||
"title": "Automation defaults",
|
||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||
"downloadDirectory": "Download directory",
|
||||
"filenameTemplate": "Filename template (file only)",
|
||||
"checkInterval": "Check interval (hours)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||
},
|
||||
"add": {
|
||||
"title": "Add RSS",
|
||||
"description": "Paste an RSS feed link. VidBee will detect the feed automatically."
|
||||
},
|
||||
"fields": {
|
||||
"url": "Feed URL",
|
||||
"keywords": "Keyword filter (comma separated)",
|
||||
"tags": "Auto tags",
|
||||
"customDirectory": "Custom directory",
|
||||
"namingTemplate": "Custom filename template (file only)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"onlyLatestShort": "Only latest"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"save": "Save changes",
|
||||
"selectDirectory": "Browse",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"items": {
|
||||
"title": "Latest uploads ({{count}})",
|
||||
"count": "{{count}} items",
|
||||
"empty": "No recent feed items found.",
|
||||
"status": {
|
||||
"queued": "Queued",
|
||||
"notQueued": "Not queued",
|
||||
"pending": "Pending",
|
||||
"downloading": "Downloading",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"error": "Failed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"fromChannel": "From {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "Download status: {{status}}",
|
||||
"downloadPending": "Waiting for download details...",
|
||||
"notQueued": "Not in the download queue yet"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Open in browser",
|
||||
"queue": "Add to download queue"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "Subscription",
|
||||
"unknown": "Unknown subscription",
|
||||
"noThumbnail": "No thumbnail"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "Failed to open the directory picker.",
|
||||
"missingUrl": "Please paste a channel link first.",
|
||||
"created": "Subscription added",
|
||||
"createError": "Failed to add subscription.",
|
||||
"refreshStarted": "Refresh started",
|
||||
"removed": "Subscription removed",
|
||||
"updated": "Subscription updated",
|
||||
"itemQueued": "Added to download queue",
|
||||
"itemAlreadyQueued": "This video is already queued",
|
||||
"queueError": "Failed to add to download queue.",
|
||||
"openLinkError": "Failed to open the video link.",
|
||||
"resolveError": "Failed to resolve RSS feed URL."
|
||||
},
|
||||
"detectedFeed": "Detected {{platform}} feed -> {{feed}}",
|
||||
"detecting": "Detecting feed...",
|
||||
"latestVideo": "Latest video: {{title}}",
|
||||
"lastChecked": "Last checked: {{time}}",
|
||||
"never": "Never",
|
||||
"empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.",
|
||||
"edit": {
|
||||
"title": "Edit {{name}}",
|
||||
"description": "Tweak filters, tags, and overrides for this feed."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"up-to-date": "Up to date",
|
||||
"checking": "Checking",
|
||||
"failed": "Failed",
|
||||
"idle": "Idle",
|
||||
"tooltip": {
|
||||
"updatedAt": "Updated: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "Automated Subscriptions with RSSHub",
|
||||
"description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.",
|
||||
"learnMore": "Learn more about RSSHub",
|
||||
"openDocs": "Open RSSHub Documentation",
|
||||
"hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supports {{sites}} and more.",
|
||||
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
interface AboutResource {
|
||||
@@ -48,6 +49,7 @@ export function About() {
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
@@ -72,6 +74,49 @@ export function About() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for update events only in About page
|
||||
useEffect(() => {
|
||||
if (!window?.api) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: versionLabel
|
||||
})
|
||||
// Reset download progress when new update is available
|
||||
setUpdateDownloadProgress(0)
|
||||
}
|
||||
|
||||
const handleUpdateDownloadProgress = (rawProgress: unknown) => {
|
||||
const progress = (rawProgress ?? {}) as { percent?: number }
|
||||
if (typeof progress?.percent === 'number') {
|
||||
setUpdateDownloadProgress(progress.percent)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = () => {
|
||||
// Clear progress when download is complete
|
||||
setUpdateDownloadProgress(null)
|
||||
}
|
||||
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
ipcEvents.on('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
@@ -87,12 +132,7 @@ export function About() {
|
||||
|
||||
if (result.available) {
|
||||
// The update will be downloaded automatically because autoDownload is enabled
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
@@ -127,12 +167,7 @@ export function About() {
|
||||
const result = await ipcServices.update.checkForUpdates()
|
||||
|
||||
if (result.available) {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
@@ -281,6 +316,19 @@ export function About() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{updateDownloadProgress !== null && (
|
||||
<div className="space-y-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('about.downloadingUpdate')}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{updateDownloadProgress.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={updateDownloadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -319,49 +367,49 @@ export function About() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.followAuthorTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.followAuthorDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">
|
||||
{t('about.followAuthorSupport')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openShareUrl('https://x.com/nexmoex')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.followAuthorActions.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.shareDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.shareActions.twitter')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
|
||||
<Facebook className="h-4 w-4" />
|
||||
{t('about.shareActions.facebook')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
{t('about.shareActions.copy')}
|
||||
</Button>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.shareActions.twitter')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
|
||||
<Facebook className="h-4 w-4" />
|
||||
{t('about.shareActions.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyShareLink}
|
||||
className="gap-2"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
{t('about.shareActions.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">
|
||||
{t('about.followAuthorSupport')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openShareUrl('https://x.com/nexmoex')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.followAuthorActions.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
|
||||
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from '../store/video'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
@@ -51,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
@@ -72,7 +70,7 @@ const dedupe = (candidates: Array<string | undefined>): string[] => {
|
||||
}
|
||||
|
||||
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
||||
settings.oneClickQuality ?? 'auto'
|
||||
settings.oneClickQuality ?? 'best'
|
||||
|
||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||
if (preset === 'worst') {
|
||||
@@ -126,9 +124,10 @@ const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||
|
||||
interface HomeProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
}
|
||||
|
||||
export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
||||
const { t } = useTranslation()
|
||||
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
|
||||
const [loading] = useAtom(videoInfoLoadingAtom)
|
||||
@@ -143,6 +142,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||
const inlinePreviewSites = popularSites
|
||||
.slice(0, 3)
|
||||
.map((site) => t(`sites.popular.${site.id}.label`))
|
||||
@@ -537,262 +537,275 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Tabs defaultValue="single" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||
<ListVideo className="h-4 w-4" />
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6">
|
||||
{/* URL Input Card */}
|
||||
{!videoInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('download.enterUrl')}</CardTitle>
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||
className="w-full gap-0"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="px-0"
|
||||
onClick={() => onOpenSupportedSites?.()}
|
||||
>
|
||||
{t('sites.viewAll')}
|
||||
</Button>
|
||||
</div>
|
||||
{activeTab === 'single' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 h-auto"
|
||||
onClick={() => onOpenSupportedSites?.()}
|
||||
>
|
||||
{t('sites.viewAll')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
t('playlist.playlistUrlDescription')
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('download.urlPlaceholder')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6 mt-0">
|
||||
{/* URL Input Card */}
|
||||
{!videoInfo && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('download.urlPlaceholder')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
{settings.oneClickDownload ? (
|
||||
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download.oneClickDownloadNow')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{t('download.fetch')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
{settings.oneClickDownload ? (
|
||||
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download.oneClickDownloadNow')}
|
||||
</>
|
||||
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||
</div>
|
||||
{onOpenSettings && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-2 py-0 text-xs"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
{t('download.goToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{t('download.fetch')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t('download.oneClickDownload')}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('download.oneClickDownloadDescription')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Video Info and Download Options */}
|
||||
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="space-y-6 mt-0">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => {
|
||||
setPlaylistUrl(e.target.value)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.startIndex')}
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.endIndex')}
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Video Info and Download Options */}
|
||||
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
|
||||
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => {
|
||||
setPlaylistUrl(e.target.value)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
disabled={playlistBusy}
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.startIndex')}
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.endIndex')}
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{playlistInfo && (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{playlistInfo && (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{playlistPreviewError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{playlistPreviewError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Playlist Preview Card (outside main card) */}
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory />
|
||||
|
||||
@@ -26,6 +26,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
const clampSubscriptionInterval = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, parsed))
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
@@ -235,9 +243,6 @@ export function Settings() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t('settings.oneClickQualityOptions.auto')}
|
||||
</SelectItem>
|
||||
<SelectItem value="best">
|
||||
{t('settings.oneClickQualityOptions.best')}
|
||||
</SelectItem>
|
||||
@@ -296,6 +301,33 @@ export function Settings() {
|
||||
)}
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t('settings.subscriptionDefaults.intervalDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={settings.subscriptionCheckIntervalHours}
|
||||
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
|
||||
onBlur={(event) =>
|
||||
void handleSettingChange(
|
||||
'subscriptionCheckIntervalHours',
|
||||
clampSubscriptionInterval(event.target.value)
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||
@@ -352,6 +384,13 @@ export function Settings() {
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('configPath', '')}
|
||||
disabled={!settings.configPath}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
@@ -426,6 +465,21 @@ export function Settings() {
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.enableAnalytics}
|
||||
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
665
src/renderer/src/pages/Subscriptions.tsx
Normal file
665
src/renderer/src/pages/Subscriptions.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
import {
|
||||
type SubscriptionFormData,
|
||||
SubscriptionFormDialog
|
||||
} from '@renderer/components/subscription/SubscriptionFormDialog'
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@renderer/components/ui/context-menu'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { type DownloadRecord, downloadsArrayAtom } from '@renderer/store/downloads'
|
||||
import {
|
||||
createSubscriptionAtom,
|
||||
refreshSubscriptionAtom,
|
||||
removeSubscriptionAtom,
|
||||
resolveFeedAtom,
|
||||
subscriptionsAtom,
|
||||
updateSubscriptionAtom
|
||||
} from '@renderer/store/subscriptions'
|
||||
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const statusStyles: Record<
|
||||
SubscriptionRule['status'],
|
||||
{ dotClass: string; textClass: string; label: string }
|
||||
> = {
|
||||
'up-to-date': {
|
||||
dotClass: 'bg-emerald-500',
|
||||
textClass: 'text-emerald-600',
|
||||
label: 'subscriptions.status.up-to-date'
|
||||
},
|
||||
checking: {
|
||||
dotClass: 'bg-sky-500',
|
||||
textClass: 'text-sky-600',
|
||||
label: 'subscriptions.status.checking'
|
||||
},
|
||||
failed: {
|
||||
dotClass: 'bg-red-500',
|
||||
textClass: 'text-red-600',
|
||||
label: 'subscriptions.status.failed'
|
||||
},
|
||||
idle: {
|
||||
dotClass: 'bg-muted-foreground',
|
||||
textClass: 'text-muted-foreground',
|
||||
label: 'subscriptions.status.idle'
|
||||
}
|
||||
}
|
||||
|
||||
const disabledStatusStyle = {
|
||||
dotClass: 'bg-zinc-400',
|
||||
textClass: 'text-muted-foreground',
|
||||
label: 'subscriptions.fields.disabled'
|
||||
}
|
||||
|
||||
type SubscriptionItemStatus = DownloadStatus | 'queued' | 'notQueued'
|
||||
|
||||
const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
|
||||
notQueued: 'subscriptions.items.status.notQueued',
|
||||
queued: 'subscriptions.items.status.queued',
|
||||
pending: 'subscriptions.items.status.pending',
|
||||
downloading: 'subscriptions.items.status.downloading',
|
||||
processing: 'subscriptions.items.status.processing',
|
||||
completed: 'subscriptions.items.status.completed',
|
||||
error: 'subscriptions.items.status.error',
|
||||
cancelled: 'subscriptions.items.status.cancelled'
|
||||
}
|
||||
|
||||
function SubscriptionTab({
|
||||
subscription,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
isActive
|
||||
}: SubscriptionTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const isDisabled = !subscription.enabled
|
||||
const statusMeta = isDisabled ? disabledStatusStyle : statusStyles[subscription.status]
|
||||
const statusDescription =
|
||||
subscription.status === 'failed' && subscription.lastError
|
||||
? subscription.lastError
|
||||
: t(statusStyles[subscription.status].label)
|
||||
const lastUpdatedTimestamp =
|
||||
subscription.lastCheckedAt ?? subscription.updatedAt ?? subscription.createdAt ?? null
|
||||
const lastUpdatedLabel = lastUpdatedTimestamp
|
||||
? dayjs(lastUpdatedTimestamp).format('YYYY-MM-DD HH:mm')
|
||||
: t('subscriptions.never')
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
await onUpdate({ enabled: checked })
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await onRefresh()
|
||||
toast.success(t('subscriptions.notifications.refreshStarted'))
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
await onRemove()
|
||||
toast.success(t('subscriptions.notifications.removed'))
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<HoverCard openDelay={0} closeDelay={0}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<HoverCardTrigger asChild>
|
||||
<TabsTrigger
|
||||
value={subscription.id}
|
||||
className={cn(
|
||||
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
|
||||
isActive && 'bg-muted/45'
|
||||
)}
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden transition-colors">
|
||||
<RemoteImage
|
||||
src={subscription.coverUrl}
|
||||
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||
className="h-full w-full object-cover rounded-full overflow-hidden"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',
|
||||
statusMeta.dotClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</HoverCardTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<HoverCardContent className="max-w-xs space-y-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</p>
|
||||
<p className="text-xs">{statusDescription}</p>
|
||||
<p className="text-xs">
|
||||
{t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('subscriptions.actions.refresh')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4" />
|
||||
{t('subscriptions.actions.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>
|
||||
<Power className="h-4 w-4" />
|
||||
{subscription.enabled
|
||||
? t('subscriptions.actions.disable')
|
||||
: t('subscriptions.actions.enable')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('subscriptions.actions.remove')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<SubscriptionFormDialog
|
||||
mode="edit"
|
||||
subscription={subscription}
|
||||
open={editOpen}
|
||||
onSave={async (data) => {
|
||||
await onUpdate(data)
|
||||
toast.success(t('subscriptions.notifications.updated'))
|
||||
setEditOpen(false)
|
||||
}}
|
||||
onClose={() => setEditOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Subscriptions() {
|
||||
const { t } = useTranslation()
|
||||
const [subscriptions] = useAtom(subscriptionsAtom)
|
||||
const updateSubscription = useSetAtom(updateSubscriptionAtom)
|
||||
const removeSubscription = useSetAtom(removeSubscriptionAtom)
|
||||
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
|
||||
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [selectedTab, setSelectedTab] = useState<string>('')
|
||||
|
||||
const sortedSubscriptions = useMemo(
|
||||
() =>
|
||||
[...subscriptions].sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)
|
||||
),
|
||||
[subscriptions]
|
||||
)
|
||||
|
||||
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||
const handleUpdateSubscription = useCallback(
|
||||
async (id: string, data: SubscriptionRuleUpdateForm) => {
|
||||
const updatePayload: Parameters<typeof updateSubscription>[0]['data'] = {
|
||||
keywords: data.keywords,
|
||||
tags: data.tags,
|
||||
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||
downloadDirectory: data.downloadDirectory,
|
||||
namingTemplate: data.namingTemplate,
|
||||
enabled: data.enabled
|
||||
}
|
||||
|
||||
// If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform
|
||||
if (data.url) {
|
||||
try {
|
||||
const resolved = await resolveFeed(data.url)
|
||||
updatePayload.sourceUrl = resolved.sourceUrl
|
||||
updatePayload.feedUrl = resolved.feedUrl
|
||||
updatePayload.platform = resolved.platform
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed URL:', error)
|
||||
toast.error(t('subscriptions.notifications.resolveError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateSubscription({ id, data: updatePayload })
|
||||
await refreshSubscription(id)
|
||||
},
|
||||
[refreshSubscription, updateSubscription, resolveFeed, t]
|
||||
)
|
||||
|
||||
const createSubscription = useSetAtom(createSubscriptionAtom)
|
||||
|
||||
const handleCreateSubscription = useCallback(
|
||||
async (data: SubscriptionFormData) => {
|
||||
if (!data.url) {
|
||||
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSubscription({
|
||||
url: data.url,
|
||||
keywords: data.keywords?.join(', '),
|
||||
tags: data.tags?.join(', '),
|
||||
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||
downloadDirectory: data.downloadDirectory,
|
||||
namingTemplate: data.namingTemplate,
|
||||
enabled: data.enabled
|
||||
})
|
||||
toast.success(t('subscriptions.notifications.created'))
|
||||
setAddDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create subscription:', error)
|
||||
toast.error(t('subscriptions.notifications.createError'))
|
||||
}
|
||||
},
|
||||
[createSubscription, t]
|
||||
)
|
||||
|
||||
const handleOpenRSSHubDocs = useCallback(async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Filter subscriptions based on selected tab
|
||||
const displayedSubscriptions = useMemo(() => {
|
||||
if (!selectedTab) {
|
||||
return []
|
||||
}
|
||||
return sortedSubscriptions.filter((sub) => sub.id === selectedTab)
|
||||
}, [selectedTab, sortedSubscriptions])
|
||||
|
||||
// Set default tab to first subscription if available
|
||||
useEffect(() => {
|
||||
if (!selectedTab && sortedSubscriptions.length > 0) {
|
||||
// Set to first subscription if no tab is selected
|
||||
setSelectedTab(sortedSubscriptions[0].id)
|
||||
} else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {
|
||||
// If selected subscription no longer exists, switch to first available
|
||||
if (sortedSubscriptions.length > 0) {
|
||||
setSelectedTab(sortedSubscriptions[0].id)
|
||||
} else {
|
||||
setSelectedTab('')
|
||||
}
|
||||
}
|
||||
}, [selectedTab, sortedSubscriptions])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Channel Tabs Header */}
|
||||
<div className="">
|
||||
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-auto">
|
||||
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0 px-6">
|
||||
{/* Subscription Channel Tabs */}
|
||||
{sortedSubscriptions.map((subscription) => (
|
||||
<SubscriptionTab
|
||||
key={subscription.id}
|
||||
subscription={subscription}
|
||||
isActive={subscription.id === selectedTab}
|
||||
onRefresh={() => refreshSubscription(subscription.id)}
|
||||
onRemove={() => removeSubscription(subscription.id)}
|
||||
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
|
||||
/>
|
||||
))}
|
||||
{/* Add RSS Button */}
|
||||
<Button
|
||||
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||
variant="ghost"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{t('subscriptions.add.title')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative space-y-8 p-6">
|
||||
<section className="space-y-4">
|
||||
{sortedSubscriptions.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : !selectedTab ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayedSubscriptions.map((subscription) => (
|
||||
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RSSHub Info Card */}
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t('subscriptions.rssHub.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="gap-2"
|
||||
>
|
||||
{t('subscriptions.rssHub.openDocs')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SubscriptionFormDialog
|
||||
mode="add"
|
||||
open={addDialogOpen}
|
||||
onSave={handleCreateSubscription}
|
||||
onClose={() => setAddDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubscriptionTabProps {
|
||||
subscription: SubscriptionRule
|
||||
isActive: boolean
|
||||
onRefresh: () => Promise<void>
|
||||
onRemove: () => Promise<void>
|
||||
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
|
||||
}
|
||||
|
||||
type SubscriptionRuleUpdateForm = SubscriptionFormData
|
||||
|
||||
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
|
||||
const { t } = useTranslation()
|
||||
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
|
||||
const downloads = useAtomValue(downloadsArrayAtom)
|
||||
const [historyStatusMap, setHistoryStatusMap] = useState<Record<string, DownloadStatus | null>>(
|
||||
{}
|
||||
)
|
||||
const downloadLookup = useMemo(() => {
|
||||
const map = new Map<string, DownloadRecord>()
|
||||
downloads.forEach((record) => {
|
||||
map.set(record.id, record)
|
||||
})
|
||||
return map
|
||||
}, [downloads])
|
||||
|
||||
useEffect(() => {
|
||||
const queuedDownloadIds = Array.from(
|
||||
new Set(
|
||||
feedItems
|
||||
.filter((item) => item.addedToQueue && item.downloadId)
|
||||
.map((item) => item.downloadId as string)
|
||||
)
|
||||
)
|
||||
|
||||
const missingIds = queuedDownloadIds.filter(
|
||||
(downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined
|
||||
)
|
||||
|
||||
if (missingIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const fetchHistoryStatuses = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
missingIds.map(async (downloadId) => {
|
||||
try {
|
||||
const historyItem = await ipcServices.history.getHistoryById(downloadId)
|
||||
return { downloadId, status: historyItem?.status ?? null }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch download history entry:', error)
|
||||
return { downloadId, status: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setHistoryStatusMap((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
|
||||
for (const { downloadId, status } of results) {
|
||||
if (next[downloadId] === status) {
|
||||
continue
|
||||
}
|
||||
next[downloadId] = status
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? next : prev
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve download history statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchHistoryStatuses()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [feedItems, downloadLookup, historyStatusMap])
|
||||
|
||||
const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {
|
||||
if (!item.addedToQueue) {
|
||||
return 'notQueued'
|
||||
}
|
||||
if (!item.downloadId) {
|
||||
return 'queued'
|
||||
}
|
||||
const matchedDownload = downloadLookup.get(item.downloadId)
|
||||
if (!matchedDownload) {
|
||||
const cachedHistoryStatus = historyStatusMap[item.downloadId]
|
||||
if (cachedHistoryStatus) {
|
||||
return cachedHistoryStatus
|
||||
}
|
||||
return 'queued'
|
||||
}
|
||||
return matchedDownload.status
|
||||
}
|
||||
|
||||
const handleOpenItem = async (url: string) => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to open subscription item link:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueueItem = useCallback(
|
||||
async (item: SubscriptionFeedItem) => {
|
||||
if (item.addedToQueue) {
|
||||
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const queued = await ipcServices.subscriptions.queueItem(subscription.id, item.id)
|
||||
if (queued) {
|
||||
toast.success(t('subscriptions.notifications.itemQueued'))
|
||||
return
|
||||
}
|
||||
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||
} catch (error) {
|
||||
console.error('Failed to queue subscription item:', error)
|
||||
toast.error(t('subscriptions.notifications.queueError'))
|
||||
}
|
||||
},
|
||||
[subscription.id, t]
|
||||
)
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.items.empty')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{feedItems.map((item) => {
|
||||
const itemStatus = resolveItemStatus(item)
|
||||
const hasResolvedDownloadStatus =
|
||||
item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued'
|
||||
const badgeLabel = item.addedToQueue
|
||||
? t('subscriptions.items.status.queued')
|
||||
: t('subscriptions.items.status.notQueued')
|
||||
const tooltipLabel = item.addedToQueue
|
||||
? hasResolvedDownloadStatus
|
||||
? t('subscriptions.items.tooltip.downloadStatus', {
|
||||
status: t(subscriptionItemStatusLabels[itemStatus])
|
||||
})
|
||||
: t('subscriptions.items.tooltip.downloadPending')
|
||||
: t('subscriptions.items.tooltip.notQueued')
|
||||
const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
|
||||
return (
|
||||
<ContextMenu key={`${subscription.id}-${item.id}`}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<article className="group transition-all">
|
||||
<div className="relative w-full overflow-hidden bg-muted aspect-video rounded-2xl">
|
||||
{item.thumbnail ? (
|
||||
<RemoteImage
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.labels.noThumbnail')}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-black/5 to-transparent" />
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
|
||||
{subscription.coverUrl ? (
|
||||
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
|
||||
<RemoteImage
|
||||
src={subscription.coverUrl}
|
||||
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
|
||||
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-40 truncate text-xs">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
|
||||
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
|
||||
badgeClass
|
||||
)}
|
||||
>
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className="text-base font-semibold leading-snug text-card-foreground"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="rounded-full px-4"
|
||||
onClick={() => void handleOpenItem(item.url)}
|
||||
title={t('subscriptions.items.actions.open')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleQueueItem(item)}
|
||||
disabled={item.addedToQueue}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('subscriptions.items.actions.queue')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleOpenItem(item.url)}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t('subscriptions.items.actions.open')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,45 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
|
||||
|
||||
function SiteIcon({ domain, alt }: { domain: string; alt: string }) {
|
||||
const [iconUrl, setIconUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadIcon = async () => {
|
||||
try {
|
||||
const dataUrl = await ipcServices.app.getSiteIcon(domain)
|
||||
if (!cancelled) {
|
||||
setIconUrl(dataUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
if (!cancelled) {
|
||||
setIconUrl(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadIcon()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [domain])
|
||||
|
||||
return <RemoteImage src={iconUrl} alt={alt} className="w-full h-full" />
|
||||
}
|
||||
|
||||
export function SupportedSites() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -28,7 +62,7 @@ export function SupportedSites() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{popularSites.map((site) => {
|
||||
const labelKey = `sites.popular.${site.id}.label`
|
||||
const descriptionKey = `sites.popular.${site.id}.description`
|
||||
@@ -37,11 +71,28 @@ export function SupportedSites() {
|
||||
const hasDescription = description !== descriptionKey
|
||||
|
||||
return (
|
||||
<li key={site.id} className="rounded-md border border-border px-6 py-5">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hasDescription ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
<li key={site.id}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-muted/50 hover:border-primary/50 group"
|
||||
>
|
||||
<div className="shrink-0 w-10 h-10 rounded-xs overflow-hidden">
|
||||
<SiteIcon domain={site.domain} alt={label} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{label}</p>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</div>
|
||||
{hasDescription ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type DownloadRecord = DownloadItem & {
|
||||
entryType: 'active' | 'history'
|
||||
downloadedAt?: number
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
}
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
@@ -27,9 +28,6 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
fileSize: item.fileSize,
|
||||
format: item.format,
|
||||
quality: item.quality,
|
||||
codec: item.codec,
|
||||
createdAt: item.downloadedAt,
|
||||
startedAt: item.downloadedAt,
|
||||
completedAt: item.completedAt ?? item.downloadedAt,
|
||||
@@ -43,6 +41,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
savedFileName: item.savedFileName,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
|
||||
79
src/renderer/src/store/subscriptions.ts
Normal file
79
src/renderer/src/store/subscriptions.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '@shared/types'
|
||||
import { atom } from 'jotai'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
const normalizeCommaList = (value?: string): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||
}
|
||||
|
||||
export const subscriptionsAtom = atom<SubscriptionRule[]>([])
|
||||
|
||||
export const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => {
|
||||
set(subscriptionsAtom, subscriptions)
|
||||
})
|
||||
|
||||
export const loadSubscriptionsAtom = atom(null, async (_get, set) => {
|
||||
try {
|
||||
const subscriptions = await ipcServices.subscriptions.list()
|
||||
set(subscriptionsAtom, subscriptions)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscriptions:', error)
|
||||
}
|
||||
})
|
||||
|
||||
export interface CreateSubscriptionForm {
|
||||
url: string
|
||||
keywords?: string
|
||||
tags?: string
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const createSubscriptionAtom = atom(
|
||||
null,
|
||||
async (_get, _set, payload: CreateSubscriptionForm) => {
|
||||
await ipcServices.subscriptions.create({
|
||||
url: payload.url,
|
||||
keywords: normalizeCommaList(payload.keywords),
|
||||
tags: normalizeCommaList(payload.tags),
|
||||
onlyDownloadLatest: payload.onlyDownloadLatest,
|
||||
downloadDirectory: payload.downloadDirectory,
|
||||
namingTemplate: payload.namingTemplate,
|
||||
enabled: payload.enabled
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const updateSubscriptionAtom = atom(
|
||||
null,
|
||||
async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => {
|
||||
await ipcServices.subscriptions.update(update.id, update.data)
|
||||
}
|
||||
)
|
||||
|
||||
export const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => {
|
||||
await ipcServices.subscriptions.remove(id)
|
||||
})
|
||||
|
||||
export const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => {
|
||||
await ipcServices.subscriptions.refresh(id)
|
||||
})
|
||||
|
||||
export const resolveFeedAtom = atom(
|
||||
null,
|
||||
async (_get, _set, url: string): Promise<SubscriptionResolvedFeed> => {
|
||||
return ipcServices.subscriptions.resolve(url)
|
||||
}
|
||||
)
|
||||
@@ -62,9 +62,7 @@ export interface DownloadItem {
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
savedFileName?: string
|
||||
// Timestamps
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
@@ -75,6 +73,8 @@ export interface DownloadItem {
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
@@ -84,6 +84,16 @@ export interface DownloadItem {
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface SubscriptionFeedItem {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
addedToQueue: boolean
|
||||
downloadId?: string
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
id: string
|
||||
url: string
|
||||
@@ -92,21 +102,20 @@ export interface DownloadHistoryItem {
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
status: DownloadStatus
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
fileSize?: number
|
||||
duration?: number
|
||||
downloadedAt: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
// Enhanced video information
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
@@ -126,6 +135,11 @@ export interface DownloadOptions {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
customDownloadPath?: string
|
||||
customFilenameTemplate?: string
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
@@ -171,8 +185,69 @@ export interface PlaylistDownloadResult {
|
||||
entries: PlaylistDownloadEntry[]
|
||||
}
|
||||
|
||||
// Subscription types
|
||||
export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'
|
||||
|
||||
export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'
|
||||
|
||||
export interface SubscriptionRule {
|
||||
id: string
|
||||
title: string
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
keywords: string[]
|
||||
tags: string[]
|
||||
onlyDownloadLatest: boolean
|
||||
enabled: boolean
|
||||
coverUrl?: string
|
||||
latestVideoTitle?: string
|
||||
latestVideoPublishedAt?: number
|
||||
lastCheckedAt?: number
|
||||
lastSuccessAt?: number
|
||||
status: SubscriptionStatus
|
||||
lastError?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
items: SubscriptionFeedItem[]
|
||||
}
|
||||
|
||||
export interface SubscriptionResolvedFeed {
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
}
|
||||
|
||||
export interface SubscriptionCreatePayload {
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionUpdatePayload {
|
||||
title?: string
|
||||
sourceUrl?: string
|
||||
feedUrl?: string
|
||||
platform?: SubscriptionPlatform
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
enabled?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
items?: SubscriptionFeedItem[]
|
||||
}
|
||||
|
||||
// Settings types
|
||||
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
export interface AppSettings {
|
||||
downloadPath: string
|
||||
@@ -191,6 +266,10 @@ export interface AppSettings {
|
||||
closeToTray: boolean
|
||||
hideDockIcon: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionFilenameTemplate: string
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
@@ -206,8 +285,12 @@ export const defaultSettings: AppSettings = {
|
||||
theme: 'system',
|
||||
oneClickDownload: false,
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'auto',
|
||||
oneClickQuality: 'best',
|
||||
closeToTray: false,
|
||||
hideDockIcon: false,
|
||||
autoUpdate: true
|
||||
autoUpdate: true,
|
||||
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
subscriptionCheckIntervalHours: 3,
|
||||
enableAnalytics: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user