19 Commits

Author SHA1 Message Date
Peifan Li
40536d1963 chore(release): v1.3.7 2025-12-02 13:03:02 -05:00
Peifan Li
5341bf842b docs: Update README with Python and yt-dlp installation instructions 2025-12-02 13:02:58 -05:00
Peifan Li
26184ba3c5 feat: Add bgutil-ytdlp-pot-provider integration 2025-12-02 12:56:12 -05:00
Peifan Li
1e5884d454 refactor: Update character set for sanitizing filename 2025-12-02 12:28:18 -05:00
Peifan Li
04790fdddf fix: Update versions to 1.3.5 and revise features 2025-12-02 00:06:50 -05:00
Peifan Li
86426f8ed0 chore(release): v1.3.5 2025-12-02 00:04:44 -05:00
Peifan Li
6a42b658b3 feat: subscription for youtube platfrom 2025-12-02 00:04:34 -05:00
Peifan Li
7caa924264 feat: subscription for youtube platfrom 2025-12-01 22:51:39 -05:00
Peifan Li
50ae0864c1 fix: Update package versions to 1.3.4 2025-12-01 18:02:54 -05:00
Peifan Li
6ad84e20d9 chore(release): v1.3.4 2025-12-01 18:00:33 -05:00
Peifan Li
b49bfc8b6c refactor: Update VideoCard to handle video playing state 2025-12-01 18:00:26 -05:00
Peifan Li
1d421f7fb8 fix: Update package-lock.json versions to 1.3.3 2025-12-01 17:17:59 -05:00
Peifan Li
881a159777 chore(release): v1.3.3 2025-12-01 17:15:59 -05:00
Peifan Li
26fd63eada feat: Add hover functionality to VideoCard 2025-12-01 16:53:04 -05:00
Peifan Li
f20ecd42e1 feat: Add pagination and toggle for sidebar in Home page 2025-12-01 16:46:56 -05:00
Peifan Li
ae8507a609 style: Update Header component UI for manageDownloads 2025-12-01 14:30:08 -05:00
Peifan Li
7969412091 feat: Add upload and scan modals on DownloadPage 2025-12-01 14:16:47 -05:00
Peifan Li
c88909b658 feat: Add batch download feature 2025-12-01 13:26:40 -05:00
Peifan Li
618d905e6d fix: Update package versions to 1.3.2 in lock files 2025-11-30 17:17:49 -05:00
48 changed files with 2905 additions and 271 deletions

View File

@@ -1,6 +1,6 @@
# MyTube
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##)包括微博小红书x.com等。
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,支持频道订阅与自动下载,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##)包括微博小红书x.com等。
[English](README.md)
@@ -17,11 +17,13 @@
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
- **Bilibili 支持**支持下载单个视频、多P视频以及整个合集/系列。
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
- **批量下载**:一次性添加多个视频链接到下载队列。
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
- **搜索功能**:支持在本地库中搜索视频,或在线搜索 YouTube 视频。
- **收藏夹**:创建自定义收藏夹以整理您的视频。
- **订阅功能**:订阅您喜爱的频道,并在新视频发布时收到通知。
- **现代化 UI**:响应式深色主题界面,包含“返回主页”功能和玻璃拟态效果。
- **主题支持**:支持在明亮和深色模式之间切换,支持平滑过渡。
- **登录保护**:通过密码登录页面保护您的应用。
@@ -153,6 +155,11 @@ npm run lint:fix # 修复前端代码检查错误
- `PUT /api/collections/:id` - 更新收藏夹 (添加/移除视频)
- `DELETE /api/collections/:id` - 删除收藏夹
### 订阅
- `GET /api/subscriptions` - 获取所有订阅
- `POST /api/subscriptions` - 创建新订阅
- `DELETE /api/subscriptions/:id` - 删除订阅
### 设置与系统
- `GET /api/settings` - 获取应用设置
- `POST /api/settings` - 更新应用设置
@@ -227,6 +234,16 @@ MAX_FILE_SIZE=500000000
[![Star History Chart](https://api.star-history.com/svg?repos=franklioxygen/MyTube&type=date&legend=bottom-right)](https://www.star-history.com/#franklioxygen/MyTube&type=date&legend=bottom-right)
## 免责声明
- 使用目的与限制 本软件(及相关代码、文档)仅供个人学习、研究及技术交流使用。严禁将本软件用于任何形式的商业用途,或利用本软件进行违反国家法律法规的犯罪活动。
- 责任界定 开发者对用户使用本软件的具体行为概不知情,亦无法控制。因用户非法或不当使用本软件(包括但不限于侵犯第三方版权、下载违规内容等)而产生的任何法律责任、纠纷或损失,均由用户自行承担,开发者不承担任何直接、间接或连带责任。
- 二次开发与分发 本项目代码开源,任何个人或组织基于本项目代码进行修改、二次开发时,应遵守开源协议。 特别声明: 若第三方人为修改代码以规避、去除本软件原有的用户认证机制/安全限制,并进行公开分发或传播,由此引发的一切责任事件及法律后果,需由该代码修改发布者承担全部责任。我们强烈不建议用户规避或篡改任何安全验证机制。
- 非盈利声明 本项目为完全免费的开源项目。开发者从未在任何平台发布捐赠信息,本软件本身不收取任何费用,亦不提供任何形式的付费增值服务。任何声称代表本项目收取费用、销售软件或寻求捐赠的信息均为虚假信息,请用户仔细甄别,谨防上当受骗。
## 许可证
MIT

View File

@@ -1,6 +1,6 @@
# MyTube
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
A YouTube/Bilibili/MissAV video downloader and player that supports channel subscriptions and auto-downloads, allowing you to save videos and thumbnails locally. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
[中文](README-zh.md)
@@ -17,6 +17,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
- **Batch Download**: Add multiple video URLs at once to the download queue.
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.
@@ -27,6 +28,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
- **Login Protection**: Secure your application with a password login page.
- **Internationalization**: Support for multiple languages including English, Chinese, Spanish, French, German, Japanese, Korean, Arabic, and Portuguese.
- **Pagination**: Efficiently browse large libraries with pagination support.
- **Subscriptions**: Manage subscriptions to channels or creators to automatically download new content.
- **Video Rating**: Rate your videos with a 5-star system.
- **Mobile Optimizations**: Mobile-friendly tags menu and optimized layout for smaller screens.
- **Temp Files Cleanup**: Manage storage by cleaning up temporary download files directly from settings.
@@ -72,6 +74,8 @@ mytube/
- Node.js (v14 or higher)
- npm (v6 or higher)
- Docker (optional, for containerized deployment)
- Python 3.8+ (for yt-dlp and PO Token provider)
- yt-dlp (installed via pip/pipx)
### Installation
@@ -98,6 +102,16 @@ mytube/
cd ../backend && npm install
```
**Note**: The backend installation will automatically build the `bgutil-ytdlp-pot-provider` server. However, you must ensure `yt-dlp` and the `bgutil-ytdlp-pot-provider` python plugin are installed in your environment:
```bash
# Install yt-dlp and the plugin
pip install yt-dlp bgutil-ytdlp-pot-provider
# OR using pipx (recommended)
pipx install yt-dlp
pipx inject yt-dlp bgutil-ytdlp-pot-provider
```
#### Using npm Scripts
You can use npm scripts from the root directory:
@@ -153,6 +167,11 @@ npm run lint:fix # Fix linting errors for frontend
- `PUT /api/collections/:id` - Update a collection (add/remove videos)
- `DELETE /api/collections/:id` - Delete a collection
### Subscriptions
- `GET /api/subscriptions` - Get all subscriptions
- `POST /api/subscriptions` - Create a new subscription
- `DELETE /api/subscriptions/:id` - Delete a subscription
### Settings & System
- `GET /api/settings` - Get application settings
- `POST /api/settings` - Update application settings
@@ -227,6 +246,16 @@ For detailed instructions on how to deploy MyTube using Docker or on QNAP Contai
[![Star History Chart](https://api.star-history.com/svg?repos=franklioxygen/MyTube&type=date&legend=bottom-right)](https://www.star-history.com/#franklioxygen/MyTube&type=date&legend=bottom-right)
## Disclaimer
- Purpose and Restrictions This software (including code and documentation) is intended solely for personal learning, research, and technical exchange. It is strictly prohibited to use this software for any commercial purposes or for any illegal activities that violate local laws and regulations.
- Liability The developer is unaware of and has no control over how users utilize this software. Any legal liabilities, disputes, or damages arising from the illegal or improper use of this software (including but not limited to copyright infringement) shall be borne solely by the user. The developer assumes no direct, indirect, or joint liability.
- Modifications and Distribution This project is open-source. Any individual or organization modifying or forking this code must comply with the open-source license. Important: If a third party modifies the code to bypass or remove the original user authentication/security mechanisms and distributes such versions, the modifier/distributor bears full responsibility for any consequences. We strongly discourage bypassing or tampering with any security verification mechanisms.
- Non-Profit Statement This is a completely free open-source project. The developer does not accept donations and has never published any donation pages. The software itself allows no charges and offers no paid services. Please be vigilant and beware of any scams or misleading information claiming to collect fees on behalf of this project.
## License
MIT

View File

@@ -12,6 +12,12 @@ ENV YOUTUBE_DL_SKIP_PYTHON_CHECK=1
RUN npm ci
COPY . .
# Build bgutil-ytdlp-pot-provider
WORKDIR /app/bgutil-ytdlp-pot-provider/server
RUN npm install && npx tsc
WORKDIR /app
RUN npm run build
# Stage 2: Production
@@ -30,6 +36,9 @@ RUN apk add --no-cache \
py3-pip && \
ln -sf python3 /usr/bin/python
# Install yt-dlp and bgutil-ytdlp-pot-provider
RUN pip3 install yt-dlp bgutil-ytdlp-pot-provider --break-system-packages
# Environment variables
ENV NODE_ENV=production
ENV PORT=5551
@@ -44,6 +53,8 @@ RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
# Copy drizzle migrations
COPY --from=builder /app/drizzle ./drizzle
# Copy bgutil-ytdlp-pot-provider
COPY --from=builder /app/bgutil-ytdlp-pot-provider /app/bgutil-ytdlp-pot-provider
# Create necessary directories
RUN mkdir -p uploads/videos uploads/images data

Submodule backend/bgutil-ytdlp-pot-provider added at 9c3cc1a21d

View File

@@ -0,0 +1,11 @@
CREATE TABLE `subscriptions` (
`id` text PRIMARY KEY NOT NULL,
`author` text NOT NULL,
`author_url` text NOT NULL,
`interval` integer NOT NULL,
`last_video_link` text,
`last_check` integer,
`download_count` integer DEFAULT 0,
`created_at` integer NOT NULL,
`platform` text DEFAULT 'YouTube'
);

View File

@@ -0,0 +1,581 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e34144d1-add0-4bb0-b9d3-852c5fa0384e",
"prevId": "a4f15b55-7d41-46eb-a976-c89e80c42797",
"tables": {
"collection_videos": {
"name": "collection_videos",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"collection_videos_collection_id_collections_id_fk": {
"name": "collection_videos_collection_id_collections_id_fk",
"tableFrom": "collection_videos",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"collection_videos_video_id_videos_id_fk": {
"name": "collection_videos_video_id_videos_id_fk",
"tableFrom": "collection_videos",
"tableTo": "videos",
"columnsFrom": [
"video_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"collection_videos_collection_id_video_id_pk": {
"columns": [
"collection_id",
"video_id"
],
"name": "collection_videos_collection_id_video_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"downloads": {
"name": "downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_size": {
"name": "downloaded_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speed": {
"name": "speed",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"interval": {
"name": "interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_video_link": {
"name": "last_video_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check": {
"name": "last_check",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'YouTube'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"videos": {
"name": "videos",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_filename": {
"name": "video_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_filename": {
"name": "thumbnail_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"part_number": {
"name": "part_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_parts": {
"name": "total_parts",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"series_title": {
"name": "series_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1764190450949,
"tag": "0002_romantic_colossus",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1764631012929,
"tag": "0003_puzzling_energizer",
"breakpoints": true
}
]
}

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.3.1",
"version": "1.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.3.1",
"version": "1.3.5",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",
@@ -20,8 +20,9 @@
"express": "^4.18.2",
"fs-extra": "^11.2.0",
"multer": "^1.4.5-lts.1",
"path": "^0.12.7",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
"uuid": "^13.0.0",
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
@@ -32,7 +33,9 @@
"@types/fs-extra": "^11.0.4",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.7",
"nodemon": "^3.0.3",
@@ -1788,6 +1791,13 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -1859,6 +1869,13 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -5358,6 +5375,15 @@
"node": ">=10"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -5700,16 +5726,6 @@
"node": ">= 0.8"
}
},
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
"license": "MIT",
"dependencies": {
"process": "^0.11.1",
"util": "^0.10.3"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5888,15 +5904,6 @@
"node": ">=6"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -7309,27 +7316,12 @@
"node": ">= 0.8"
}
},
"node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
"license": "MIT",
"dependencies": {
"inherits": "2.0.3"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/util/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -7339,6 +7331,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.2",
"version": "1.3.7",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",
@@ -8,7 +8,8 @@
"build": "tsc",
"generate": "drizzle-kit generate",
"test": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"postinstall": "cd bgutil-ytdlp-pot-provider/server && npm install && npx tsc"
},
"keywords": [],
"author": "",
@@ -26,7 +27,9 @@
"express": "^4.18.2",
"fs-extra": "^11.2.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
"uuid": "^13.0.0",
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
@@ -37,7 +40,9 @@
"@types/fs-extra": "^11.0.4",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.7",
"nodemon": "^3.0.3",

View File

@@ -18,6 +18,7 @@ interface Settings {
openListApiUrl?: string;
openListToken?: string;
cloudDrivePath?: string;
homeSidebarOpen?: boolean;
}
const defaultSettings: Settings = {
@@ -30,7 +31,8 @@ const defaultSettings: Settings = {
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
cloudDrivePath: '',
homeSidebarOpen: true
};
export const getSettings = async (_req: Request, res: Response) => {

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { subscriptionService } from '../services/subscriptionService';
export const createSubscription = async (req: Request, res: Response) => {
try {
const { url, interval } = req.body;
console.log('Creating subscription:', { url, interval, body: req.body });
if (!url || !interval) {
return res.status(400).json({ error: 'URL and interval are required' });
}
const subscription = await subscriptionService.subscribe(url, parseInt(interval));
res.status(201).json(subscription);
} catch (error: any) {
console.error('Error creating subscription:', error);
if (error.message === 'Subscription already exists') {
return res.status(409).json({ error: 'Subscription already exists' });
}
res.status(500).json({ error: error.message || 'Failed to create subscription' });
}
};
export const getSubscriptions = async (req: Request, res: Response) => {
try {
const subscriptions = await subscriptionService.listSubscriptions();
res.json(subscriptions);
} catch (error) {
console.error('Error fetching subscriptions:', error);
res.status(500).json({ error: 'Failed to fetch subscriptions' });
}
};
export const deleteSubscription = async (req: Request, res: Response) => {
try {
const { id } = req.params;
await subscriptionService.unsubscribe(id);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error deleting subscription:', error);
res.status(500).json({ error: 'Failed to delete subscription' });
}
};

View File

@@ -105,3 +105,15 @@ export const downloadHistory = sqliteTable('download_history', {
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
totalSize: text('total_size'),
});
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
author: text('author').notNull(),
authorUrl: text('author_url').notNull(),
interval: integer('interval').notNull(), // Check interval in minutes
lastVideoLink: text('last_video_link'),
lastCheck: integer('last_check'), // Timestamp
downloadCount: integer('download_count').default(0),
createdAt: integer('created_at').notNull(),
platform: text('platform').default('YouTube'),
});

View File

@@ -42,4 +42,10 @@ router.post("/collections", collectionController.createCollection);
router.put("/collections/:id", collectionController.updateCollection);
router.delete("/collections/:id", collectionController.deleteCollection);
// Subscription routes
import * as subscriptionController from "../controllers/subscriptionController";
router.post("/subscriptions", subscriptionController.createSubscription);
router.get("/subscriptions", subscriptionController.getSubscriptions);
router.delete("/subscriptions/:id", subscriptionController.deleteSubscription);
export default router;

View File

@@ -44,6 +44,11 @@ app.use('/api/settings', settingsRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
// Start subscription scheduler
import("./services/subscriptionService").then(({ subscriptionService }) => {
subscriptionService.startScheduler();
}).catch(err => console.error("Failed to start subscription service:", err));
// Run duration backfill in background
import("./services/metadataService").then(service => {
service.backfillDurations();

View File

@@ -7,6 +7,9 @@ import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
const YT_DLP_PATH = process.env.YT_DLP_PATH || "yt-dlp";
const PROVIDER_SCRIPT = process.env.BGUTIL_SCRIPT_PATH || path.join(process.cwd(), "bgutil-ytdlp-pot-provider/server/build/generate_once.js");
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
try {
@@ -54,7 +57,8 @@ export class YtDlpDownloader {
noWarnings: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
} as any);
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
} as any, { execPath: YT_DLP_PATH } as any);
if (!searchResults || !(searchResults as any).entries) {
return [];
@@ -87,7 +91,8 @@ export class YtDlpDownloader {
noWarnings: true,
preferFreeFormats: true,
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
} as any);
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
} as any, { execPath: YT_DLP_PATH } as any);
return {
title: info.title || "Video",
@@ -106,6 +111,57 @@ export class YtDlpDownloader {
}
}
// Get the latest video URL from a channel
static async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
try {
console.log("Fetching latest video for channel:", channelUrl);
// Append /videos to channel URL to ensure we get videos and not the channel tab
let targetUrl = channelUrl;
if (channelUrl.includes('youtube.com/') && !channelUrl.includes('/videos') && !channelUrl.includes('/shorts') && !channelUrl.includes('/streams')) {
// Check if it looks like a channel URL
if (channelUrl.includes('/@') || channelUrl.includes('/channel/') || channelUrl.includes('/c/') || channelUrl.includes('/user/')) {
targetUrl = `${channelUrl}/videos`;
console.log("Modified channel URL to:", targetUrl);
}
}
// Use yt-dlp to get the first video in the channel (playlist)
const result = await youtubedl(targetUrl, {
dumpSingleJson: true,
playlistEnd: 5,
noWarnings: true,
flatPlaylist: true, // We only need the ID/URL, not full info
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
} as any, { execPath: YT_DLP_PATH } as any);
// If it's a playlist/channel, 'entries' will contain the videos
if ((result as any).entries && (result as any).entries.length > 0) {
// Iterate through entries to find a valid video
// Sometimes the first entry is the channel/tab itself (e.g. id starts with UC)
for (const entry of (result as any).entries) {
// Skip entries that look like channel IDs (start with UC and are 24 chars)
// or entries without a title/url that look like metadata
if (entry.id && entry.id.startsWith('UC') && entry.id.length === 24) {
continue;
}
const videoId = entry.id;
if (videoId) {
return `https://www.youtube.com/watch?v=${videoId}`;
}
if (entry.url) {
return entry.url;
}
}
}
return null;
} catch (error) {
console.error("Error fetching latest video URL:", error);
return null;
}
}
// Download video
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected URL:", videoUrl);
@@ -128,7 +184,8 @@ export class YtDlpDownloader {
dumpSingleJson: true,
noWarnings: true,
preferFreeFormats: true,
} as any);
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
} as any, { execPath: YT_DLP_PATH } as any);
console.log("Video info:", {
title: info.title,
@@ -195,8 +252,11 @@ export class YtDlpDownloader {
];
}
// Add PO Token provider args
flags.extractorArgs = `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`;
// Use exec to capture stdout for progress
const subprocess = youtubedl.exec(videoUrl, flags);
const subprocess = youtubedl.exec(videoUrl, flags, { execPath: YT_DLP_PATH } as any);
if (onStart) {
onStart(() => {

View File

@@ -0,0 +1,169 @@
import { eq } from 'drizzle-orm';
import cron, { ScheduledTask } from 'node-cron';
import { v4 as uuidv4 } from 'uuid';
import { db } from '../db';
import { subscriptions } from '../db/schema';
import { downloadYouTubeVideo } from './downloadService';
import { YtDlpDownloader } from './downloaders/YtDlpDownloader';
export interface Subscription {
id: string;
author: string;
authorUrl: string;
interval: number;
lastVideoLink?: string;
lastCheck?: number;
downloadCount: number;
createdAt: number;
platform: string;
}
export class SubscriptionService {
private static instance: SubscriptionService;
private checkTask: ScheduledTask | null = null;
private constructor() { }
public static getInstance(): SubscriptionService {
if (!SubscriptionService.instance) {
SubscriptionService.instance = new SubscriptionService();
}
return SubscriptionService.instance;
}
async subscribe(authorUrl: string, interval: number): Promise<Subscription> {
// Validate URL (basic check)
if (!authorUrl.includes('youtube.com')) {
throw new Error('Invalid YouTube URL');
}
// Check if already subscribed
const existing = await db.select().from(subscriptions).where(eq(subscriptions.authorUrl, authorUrl));
if (existing.length > 0) {
throw new Error('Subscription already exists');
}
// Extract author from URL if possible
let authorName = 'Unknown Author';
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
if (match && match[1]) {
authorName = match[1];
} else {
// Fallback: try to extract from other URL formats
const parts = authorUrl.split('/');
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (lastPart) authorName = lastPart;
}
}
// We skip heavy getVideoInfo here to ensure fast response.
// The scheduler will eventually fetch new videos and we can update author name then if needed.
let lastVideoLink = '';
const newSubscription: Subscription = {
id: uuidv4(),
author: authorName,
authorUrl,
interval,
lastVideoLink,
lastCheck: Date.now(),
downloadCount: 0,
createdAt: Date.now(),
platform: 'YouTube'
};
await db.insert(subscriptions).values(newSubscription);
return newSubscription;
}
async unsubscribe(id: string): Promise<void> {
await db.delete(subscriptions).where(eq(subscriptions.id, id));
}
async listSubscriptions(): Promise<Subscription[]> {
// @ts-ignore - Drizzle type inference might be tricky with raw select sometimes, but this should be fine.
// Actually, db.select().from(subscriptions) returns the inferred type.
return await db.select().from(subscriptions);
}
async checkSubscriptions(): Promise<void> {
// console.log('Checking subscriptions...'); // Too verbose
const allSubs = await this.listSubscriptions();
for (const sub of allSubs) {
const now = Date.now();
const lastCheck = sub.lastCheck || 0;
const intervalMs = sub.interval * 60 * 1000;
if (now - lastCheck >= intervalMs) {
try {
console.log(`Checking subscription for ${sub.author}...`);
// 1. Fetch latest video link
// We need a robust way to get the latest video.
// We can use `yt-dlp --print webpage_url --playlist-end 1 "channel_url"`
// We'll need to expose a method in `downloadService` or `YtDlpDownloader` for this.
// For now, let's assume `getLatestVideoUrl` exists.
const latestVideoUrl = await this.getLatestVideoUrl(sub.authorUrl);
if (latestVideoUrl && latestVideoUrl !== sub.lastVideoLink) {
console.log(`New video found for ${sub.author}: ${latestVideoUrl}`);
// 2. Download the video
// We use `downloadYouTubeVideo` from downloadService`.
// We might want to associate this download with the subscription for tracking?
// The requirement says "update last_video_link value".
await downloadYouTubeVideo(latestVideoUrl);
// 3. Update subscription record
await db.update(subscriptions)
.set({
lastVideoLink: latestVideoUrl,
lastCheck: now,
downloadCount: (sub.downloadCount || 0) + 1
})
.where(eq(subscriptions.id, sub.id));
} else {
// Just update lastCheck
await db.update(subscriptions)
.set({ lastCheck: now })
.where(eq(subscriptions.id, sub.id));
}
} catch (error) {
console.error(`Error checking subscription for ${sub.author}:`, error);
}
}
}
}
startScheduler() {
if (this.checkTask) {
this.checkTask.stop();
}
// Run every minute
this.checkTask = cron.schedule('* * * * *', () => {
this.checkSubscriptions();
});
console.log('Subscription scheduler started (node-cron).');
}
// Helper to get latest video URL.
// This should probably be in YtDlpDownloader, but for now we can implement it here using a similar approach.
// We need to import `exec` or similar to run yt-dlp.
// Since `YtDlpDownloader` is in `services/downloaders`, we should probably add a method there.
// But to keep it self-contained for now, I'll assume we can add it to `YtDlpDownloader` later or mock it.
// Let's try to use `YtDlpDownloader.getLatestVideoUrl` if we can add it.
// For now, I will implement a placeholder that uses `YtDlpDownloader`'s internal logic if possible,
// or just calls `getVideoInfo` and hopes it works for channels (it might not give the *latest* video URL directly).
// BETTER APPROACH: Add `getLatestVideoUrl` to `YtDlpDownloader` class.
// I will do that in a separate step. For now, I'll define the interface.
private async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
return await YtDlpDownloader.getLatestVideoUrl(channelUrl);
}
}
export const subscriptionService = SubscriptionService.getInstance();

View File

@@ -109,7 +109,7 @@ export function sanitizeFilename(filename: string): string {
// Replace only unsafe characters for filesystems
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
const sanitized = withoutHashtags
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
.replace(/[\/\\:*?"<>|%,'!;=+\$@^`{}~\[\]()&]/g, "_") // Replace unsafe filesystem and URL characters
.replace(/\s+/g, "_"); // Replace spaces with underscores
// Truncate to 200 characters to avoid ENAMETOOLONG errors (filesystem limit is usually 255 bytes)

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.3.1",
"version": "1.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.3.1",
"version": "1.3.5",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.2",
"version": "1.3.7",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,8 +1,7 @@
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
import { useEffect, useMemo, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import './App.css';
import AnimatedRoutes from './components/AnimatedRoutes';
import BilibiliPartsModal from './components/BilibiliPartsModal';
import Footer from './components/Footer';
import Header from './components/Header';
@@ -12,7 +11,16 @@ import { DownloadProvider, useDownload } from './contexts/DownloadContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { SnackbarProvider } from './contexts/SnackbarContext';
import { VideoProvider, useVideo } from './contexts/VideoContext';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import DownloadPage from './pages/DownloadPage';
import Home from './pages/Home';
import InstructionPage from './pages/InstructionPage';
import LoginPage from './pages/LoginPage';
import ManagePage from './pages/ManagePage';
import SettingsPage from './pages/SettingsPage';
import SubscriptionsPage from './pages/SubscriptionsPage';
import VideoPlayer from './pages/VideoPlayer';
import getTheme from './theme';
function AppContent() {
@@ -97,7 +105,17 @@ function AppContent() {
/>
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
<AnimatedRoutes />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/manage" element={<ManagePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/downloads" element={<DownloadPage />} />
<Route path="/collections/:id" element={<CollectionPage />} />
<Route path="/author/:name" element={<AuthorVideos />} />
<Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/instruction" element={<InstructionPage />} />
</Routes>
</Box>
<Footer />

View File

@@ -0,0 +1,54 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Typography
} from '@mui/material';
import React from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface AlertModalProps {
open: boolean;
onClose: () => void;
title: string;
message: string;
}
const AlertModal: React.FC<AlertModalProps> = ({ open, onClose, title, message }) => {
const { t } = useLanguage();
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
slotProps={{
paper: {
sx: { borderRadius: 2 }
}
}}
>
<DialogTitle sx={{ m: 0, p: 2 }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{title}
</Typography>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ color: 'text.primary' }}>
{message}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} variant="contained" color="primary" autoFocus>
{t('confirm')}
</Button>
</DialogActions>
</Dialog>
);
};
export default AlertModal;

View File

@@ -0,0 +1,71 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface BatchDownloadModalProps {
open: boolean;
onClose: () => void;
onConfirm: (urls: string[]) => void;
}
const BatchDownloadModal: React.FC<BatchDownloadModalProps> = ({ open, onClose, onConfirm }) => {
const { t } = useLanguage();
const [text, setText] = useState('');
const handleConfirm = () => {
const urls = text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
onConfirm(urls);
setText('');
onClose();
};
const handleClose = () => {
setText('');
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('batchDownload') || 'Batch Download'}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
{t('batchDownloadDescription') || 'Paste multiple URLs below, one per line.'}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="urls"
label={t('urls') || 'URLs'}
type="text"
fullWidth
multiline
rows={10}
variant="outlined"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="https://www.youtube.com/watch?v=...\nhttps://www.bilibili.com/video/..."
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>{t('cancel') || 'Cancel'}</Button>
<Button onClick={handleConfirm} variant="contained" disabled={!text.trim()}>
{t('addToQueue') || 'Add to Queue'}
</Button>
</DialogActions>
</Dialog>
);
};
export default BatchDownloadModal;

View File

@@ -0,0 +1,30 @@
import { Box, Paper, Typography } from '@mui/material';
import React from 'react';
import { en } from '../utils/locales/en';
const Disclaimer: React.FC = () => {
return (
<Box sx={{ mt: 4, mb: 2 }}>
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.6)' : 'background.paper',
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
borderRadius: 4,
backdropFilter: 'blur(10px)'
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 1, color: 'primary.main' }}>
{en.disclaimerTitle}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{en.disclaimerText}
</Typography>
</Paper>
</Box>
);
};
export default Disclaimer;

View File

@@ -2,11 +2,12 @@ import {
Brightness4,
Brightness7,
Clear,
CloudUpload,
Download,
Help,
Menu as MenuIcon,
Search,
Settings,
Subscriptions,
VideoLibrary
} from '@mui/icons-material';
import {
@@ -17,7 +18,7 @@ import {
CircularProgress,
ClickAwayListener,
Collapse,
Divider,
Fade,
IconButton,
InputAdornment,
Menu,
@@ -39,7 +40,7 @@ import { Collection, Video } from '../types';
import AuthorsList from './AuthorsList';
import Collections from './Collections';
import TagsList from './TagsList';
import UploadModal from './UploadModal';
interface DownloadInfo {
id: string;
@@ -84,7 +85,6 @@ const Header: React.FC<HeaderProps> = ({
const [error, setError] = useState<string>('');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
const navigate = useNavigate();
const theme = useTheme();
@@ -166,21 +166,11 @@ const Header: React.FC<HeaderProps> = ({
}
};
const handleUploadSuccess = () => {
if (window.location.pathname === '/') {
window.location.reload();
} else {
navigate('/');
}
};
const renderActionButtons = () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={t('uploadVideo')}>
<IconButton color="inherit" onClick={() => setUploadModalOpen(true)}>
<CloudUpload />
</IconButton>
</Tooltip>
{(
<>
@@ -201,6 +191,8 @@ const Header: React.FC<HeaderProps> = ({
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
width: 320,
maxHeight: '50vh',
overflowY: 'auto',
'& .MuiAvatar-root': {
width: 32,
height: 32,
@@ -224,7 +216,12 @@ const Header: React.FC<HeaderProps> = ({
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
slots={{ transition: Fade }}
>
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
<Download sx={{ mr: 2 }} /> {t('manageDownloads') || 'Manage Downloads'}
</MenuItem>
{activeDownloads.map((download) => (
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
@@ -284,12 +281,6 @@ const Header: React.FC<HeaderProps> = ({
</MenuItem>
))
]}
<Divider />
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
<Typography variant="body2" color="primary" sx={{ fontWeight: 'bold', width: '100%', textAlign: 'center' }}>
{t('manageDownloads') || 'Manage Downloads'}
</Typography>
</MenuItem>
</Menu>
</>
)}
@@ -318,6 +309,7 @@ const Header: React.FC<HeaderProps> = ({
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
width: 320,
'&:before': {
content: '""',
display: 'block',
@@ -335,13 +327,20 @@ const Header: React.FC<HeaderProps> = ({
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
slots={{ transition: Fade }}
>
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/subscriptions'); }}>
<Subscriptions sx={{ mr: 2 }} /> {t('subscriptions')}
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/settings'); }}>
<Settings sx={{ mr: 2 }} /> {t('settings')}
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/instruction'); }}>
<Help sx={{ mr: 2 }} /> {t('instruction')}
</MenuItem>
</Menu>
</Box>
);
@@ -484,11 +483,7 @@ const Header: React.FC<HeaderProps> = ({
)}
</Toolbar>
<UploadModal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onUploadSuccess={handleUploadSuccess}
/>
</AppBar>
</ClickAwayListener>

View File

@@ -0,0 +1,97 @@
import { Close } from '@mui/icons-material';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
TextField,
Typography
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface SubscribeModalProps {
open: boolean;
onClose: () => void;
onConfirm: (interval: number) => void;
authorName?: string;
url: string;
}
const SubscribeModal: React.FC<SubscribeModalProps> = ({
open,
onClose,
onConfirm,
authorName,
url
}) => {
const { t } = useLanguage();
const [interval, setInterval] = useState<number>(60); // Default 60 minutes
const handleConfirm = () => {
onConfirm(interval);
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: { borderRadius: 2 }
}
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{t('subscribeToAuthor')}
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
color: (theme) => theme.palette.grey[500],
}}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
{t('subscribeConfirmationMessage', { author: authorName || url })}
</DialogContentText>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('subscribeDescription')}
</Typography>
<TextField
autoFocus
margin="dense"
id="interval"
label={t('checkIntervalMinutes')}
type="number"
fullWidth
variant="outlined"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
inputProps={{ min: 1 }}
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} color="inherit">
{t('cancel')}
</Button>
<Button onClick={handleConfirm} variant="contained" color="primary">
{t('subscribe')}
</Button>
</DialogActions>
</Dialog>
);
};
export default SubscribeModal;

View File

@@ -14,7 +14,7 @@ import {
useMediaQuery,
useTheme
} from '@mui/material';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Collection, Video } from '../types';
@@ -43,6 +43,38 @@ const VideoCard: React.FC<VideoCardProps> = ({
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// Helper to parse duration to seconds
const parseDuration = (duration: string | number | undefined): number => {
if (!duration) return 0;
if (typeof duration === 'number') return duration;
if (duration.includes(':')) {
const parts = duration.split(':').map(part => parseInt(part, 10));
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
}
const parsed = parseInt(duration, 10);
return isNaN(parsed) ? 0 : parsed;
};
const handleMouseEnter = () => {
if (!isMobile && video.videoPath) {
setIsHovered(true);
}
};
const handleMouseLeave = () => {
setIsHovered(false);
setIsVideoPlaying(false);
};
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString: string) => {
@@ -158,8 +190,52 @@ const VideoCard: React.FC<VideoCardProps> = ({
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
}}
>
<CardActionArea onClick={handleVideoNavigation} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<CardActionArea
onClick={handleVideoNavigation}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
{isHovered && video.videoPath && (
<Box
component="video"
ref={videoRef}
src={`${BACKEND_URL}${video.videoPath}`}
muted
autoPlay
playsInline
onPlaying={() => setIsVideoPlaying(true)}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
bgcolor: 'black'
}}
onLoadedMetadata={(e) => {
const videoEl = e.target as HTMLVideoElement;
const duration = parseDuration(video.duration);
if (duration > 5) {
videoEl.currentTime = Math.max(0, (duration / 2) - 2.5);
}
}}
onTimeUpdate={(e) => {
const videoEl = e.target as HTMLVideoElement;
const duration = parseDuration(video.duration);
const startTime = Math.max(0, (duration / 2) - 2.5);
const endTime = startTime + 5;
if (videoEl.currentTime >= endTime) {
videoEl.currentTime = startTime;
videoEl.play();
}
}}
/>
)}
<CardMedia
component="img"
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
@@ -170,7 +246,10 @@ const VideoCard: React.FC<VideoCardProps> = ({
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover'
objectFit: 'cover',
opacity: (isHovered && isVideoPlaying) ? 0 : 1,
transition: 'opacity 0.2s',
pointerEvents: 'none' // Ensure hover events pass through to the video if needed, though parent handles it
}}
onError={(e) => {
const target = e.target as HTMLImageElement;

View File

@@ -92,6 +92,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input or textarea
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'ArrowLeft') {
handleSeek(-10);
} else if (e.key === 'ArrowRight') {
handleSeek(10);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
@@ -217,7 +237,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
</Stack>
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Stack direction="row" spacing={0.5} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title="-10m">
<Button variant="outlined" onClick={() => handleSeek(-600)}>
<KeyboardDoubleArrowLeft />

View File

@@ -167,6 +167,33 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
</Typography>
</Box>
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => onTagsUpdate(newValue)}
slotProps={{
chip: { variant: 'outlined', size: 'small' }
}}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
slotProps={{
input: { ...params.InputProps, disableUnderline: true }
}}
/>
)}
sx={{ flexGrow: 1 }}
/>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
@@ -223,7 +250,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
<Divider sx={{ my: 2 }} />
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 1, sm: 3 }} alignItems={{ xs: 'flex-start', sm: 'center' }} flexWrap="wrap">
{video.sourceUrl && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
@@ -256,8 +283,8 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="subtitle2" sx={{ mr: 1 }}>{t('collections')}:</Typography>
{videoCollections.map(c => (
<Chip
key={c.id}
@@ -267,40 +294,14 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
color="secondary"
variant="outlined"
clickable
sx={{ mb: 1 }}
size="small"
sx={{ my: 0.5 }}
/>
))}
</Stack>
</Box>
)}
</Box>
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => onTagsUpdate(newValue)}
slotProps={{
chip: { variant: 'outlined', size: 'small' }
}}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
slotProps={{
input: { ...params.InputProps, disableUnderline: true }
}}
/>
)}
sx={{ flexGrow: 1 }}
/>
</Box>
</Box>
);
};

View File

@@ -1,6 +1,8 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import AlertModal from '../components/AlertModal';
import SubscribeModal from '../components/SubscribeModal';
import { DownloadInfo } from '../types';
import { useCollection } from './CollectionContext';
import { useLanguage } from './LanguageContext';
@@ -141,6 +143,15 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
try {
// Check for YouTube channel URL
// Regex for: @username, channel/ID, user/username, c/customURL
const channelRegex = /youtube\.com\/(?:@|channel\/|user\/|c\/)/;
if (channelRegex.test(videoUrl)) {
setSubscribeUrl(videoUrl);
setShowSubscribeModal(true);
return { success: true };
}
// Check if it's a Bilibili URL
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
setIsCheckingParts(true);
@@ -267,6 +278,31 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
};
// Subscription logic
const [showSubscribeModal, setShowSubscribeModal] = useState(false);
const [showDuplicateModal, setShowDuplicateModal] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState('');
const handleSubscribe = async (interval: number) => {
try {
await axios.post(`${API_URL}/subscriptions`, {
url: subscribeUrl,
interval
});
showSnackbar(t('subscribedSuccessfully'));
setShowSubscribeModal(false);
setSubscribeUrl('');
} catch (error: any) {
console.error('Error subscribing:', error);
if (error.response && error.response.status === 409) {
setShowSubscribeModal(false);
setShowDuplicateModal(true);
} else {
showSnackbar(t('error'));
}
}
};
return (
<DownloadContext.Provider value={{
activeDownloads,
@@ -280,6 +316,18 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
handleDownloadCurrentBilibiliPart
}}>
{children}
<SubscribeModal
open={showSubscribeModal}
onClose={() => setShowSubscribeModal(false)}
onConfirm={handleSubscribe}
url={subscribeUrl}
/>
<AlertModal
open={showDuplicateModal}
onClose={() => setShowDuplicateModal(false)}
title={t('error')}
message={t('subscriptionAlreadyExists')}
/>
</DownloadContext.Provider>
);
};

View File

@@ -2,8 +2,11 @@ import {
Cancel as CancelIcon,
CheckCircle as CheckCircleIcon,
ClearAll as ClearAllIcon,
CloudUpload,
Delete as DeleteIcon,
Error as ErrorIcon
Error as ErrorIcon,
FindInPage,
PlaylistAdd as PlaylistAddIcon
} from '@mui/icons-material';
import {
Box,
@@ -14,6 +17,7 @@ import {
List,
ListItem,
ListItemText,
Pagination,
Paper,
Tab,
Tabs,
@@ -22,11 +26,15 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import BatchDownloadModal from '../components/BatchDownloadModal';
import ConfirmationModal from '../components/ConfirmationModal';
import UploadModal from '../components/UploadModal';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
const ITEMS_PER_PAGE = 20;
interface DownloadHistoryItem {
id: string;
@@ -70,9 +78,48 @@ function CustomTabPanel(props: TabPanelProps) {
const DownloadPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { activeDownloads, queuedDownloads } = useDownload();
const { activeDownloads, queuedDownloads, handleVideoSubmit } = useDownload();
const queryClient = useQueryClient();
const [tabValue, setTabValue] = useState(0);
const [showBatchModal, setShowBatchModal] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [showScanConfirmModal, setShowScanConfirmModal] = useState(false);
const [queuePage, setQueuePage] = useState(1);
const [historyPage, setHistoryPage] = useState(1);
// Scan files mutation
const scanMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/scan-files`);
return res.data;
},
onSuccess: (data) => {
showSnackbar(t('scanFilesSuccess').replace('{count}', data.addedCount.toString()) || `Scan complete. ${data.addedCount} files added.`);
},
onError: (error: any) => {
showSnackbar(`${t('scanFilesFailed') || 'Scan failed'}: ${error.response?.data?.details || error.message}`);
}
});
const handleUploadSuccess = () => {
window.location.reload();
};
const handleBatchSubmit = async (urls: string[]) => {
// We'll process them sequentially to be safe, or just fire them all.
// Let's fire them all but with a small delay or just let the context handle it.
// Since handleVideoSubmit is async, we can await them.
let addedCount = 0;
for (const url of urls) {
if (url.trim()) {
await handleVideoSubmit(url.trim());
addedCount++;
}
}
if (addedCount > 0) {
showSnackbar(t('batchTasksAdded', { count: addedCount }) || `${addedCount} tasks added`);
}
};
// Fetch history with polling
const { data: history = [] } = useQuery({
@@ -211,9 +258,46 @@ const DownloadPage: React.FC = () => {
return (
<Box sx={{ width: '100%', p: 2 }}>
<Typography variant="h4" gutterBottom>
{t('downloads') || 'Downloads'}
</Typography>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', sm: 'center' },
mb: 2,
gap: { xs: 2, sm: 0 }
}}>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
{t('downloads') || 'Downloads'}
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', width: { xs: '100%', sm: 'auto' } }}>
<Button
variant="outlined"
size="small"
startIcon={<FindInPage />}
onClick={() => setShowScanConfirmModal(true)}
disabled={scanMutation.isPending}
>
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
</Button>
<Button
variant="contained"
size="small"
startIcon={<PlaylistAddIcon />}
onClick={() => setShowBatchModal(true)}
>
{t('addBatchTasks') || 'Add batch tasks'}
</Button>
<Button
variant="contained"
size="small"
startIcon={<CloudUpload />}
onClick={() => setUploadModalOpen(true)}
>
{t('uploadVideo') || 'Upload Video'}
</Button>
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="download tabs">
<Tab label={t('activeDownloads') || 'Active Downloads'} />
@@ -286,25 +370,39 @@ const DownloadPage: React.FC = () => {
{queuedDownloads.length === 0 ? (
<Typography color="textSecondary">{t('noQueuedDownloads') || 'No queued downloads'}</Typography>
) : (
<List>
{queuedDownloads.map((download) => (
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={download.title}
secondary={t('queued') || 'Queued'}
/>
</ListItem>
</Paper>
))}
</List>
<>
<List>
{queuedDownloads
.slice((queuePage - 1) * ITEMS_PER_PAGE, queuePage * ITEMS_PER_PAGE)
.map((download) => (
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={download.title}
secondary={t('queued') || 'Queued'}
/>
</ListItem>
</Paper>
))}
</List>
{queuedDownloads.length > ITEMS_PER_PAGE && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={Math.ceil(queuedDownloads.length / ITEMS_PER_PAGE)}
page={queuePage}
onChange={(_: React.ChangeEvent<unknown>, page: number) => setQueuePage(page)}
color="primary"
/>
</Box>
)}
</>
)}
</CustomTabPanel>
@@ -323,51 +421,88 @@ const DownloadPage: React.FC = () => {
{history.length === 0 ? (
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
) : (
<List>
{history.map((item: DownloadHistoryItem) => (
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={item.title}
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.sourceUrl && (
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
{item.sourceUrl}
</Typography>
)}
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>
{item.error && (
<Typography variant="caption" color="error" component="span">
{item.error}
</Typography>
<>
<List>
{history
.slice((historyPage - 1) * ITEMS_PER_PAGE, historyPage * ITEMS_PER_PAGE)
.map((item: DownloadHistoryItem) => (
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={item.title}
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.sourceUrl && (
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
{item.sourceUrl}
</Typography>
)}
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>
{item.error && (
<Typography variant="caption" color="error" component="span">
{item.error}
</Typography>
)}
</Box>
}
/>
<Box sx={{ mr: 8 }}>
{item.status === 'success' ? (
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
) : (
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
)}
</Box>
}
/>
<Box sx={{ mr: 8 }}>
{item.status === 'success' ? (
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
) : (
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
)}
</Box>
</ListItem>
</Paper>
))}
</List>
</ListItem>
</Paper>
))}
</List>
{history.length > ITEMS_PER_PAGE && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={Math.ceil(history.length / ITEMS_PER_PAGE)}
page={historyPage}
onChange={(_: React.ChangeEvent<unknown>, page: number) => setHistoryPage(page)}
color="primary"
/>
</Box>
)}
</>
)}
</CustomTabPanel>
<BatchDownloadModal
open={showBatchModal}
onClose={() => setShowBatchModal(false)}
onConfirm={handleBatchSubmit}
/>
<UploadModal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onUploadSuccess={handleUploadSuccess}
/>
<ConfirmationModal
isOpen={showScanConfirmModal}
onClose={() => setShowScanConfirmModal(false)}
onConfirm={() => {
setShowScanConfirmModal(false);
scanMutation.mutate();
}}
title={t('scanFiles') || 'Scan Files'}
message={t('scanFilesConfirmMessage') || 'The system will scan the root folder of the video path to find undocumented video files.'}
confirmText={t('continue') || 'Continue'}
cancelText={t('cancel') || 'Cancel'}
/>
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, YouTube } from '@mui/icons-material';
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, ViewSidebar, YouTube } from '@mui/icons-material';
import {
Alert,
Box,
@@ -8,7 +8,9 @@ import {
CardContent,
CardMedia,
Chip,
CircularProgress,
Collapse,
Container,
Grid,
Pagination,
@@ -16,6 +18,7 @@ import {
ToggleButtonGroup,
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import AuthorsList from '../components/AuthorsList';
import CollectionCard from '../components/CollectionCard';
@@ -27,6 +30,8 @@ import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
const API_URL = import.meta.env.VITE_API_URL;
const Home: React.FC = () => {
const { t } = useLanguage();
const {
@@ -54,6 +59,53 @@ const Home: React.FC = () => {
const saved = localStorage.getItem('homeViewMode');
return (saved as 'collections' | 'all-videos') || 'collections';
});
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [settingsLoaded, setSettingsLoaded] = useState(false);
// Fetch settings on mount
useEffect(() => {
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
if (response.data && typeof response.data.homeSidebarOpen !== 'undefined') {
setIsSidebarOpen(response.data.homeSidebarOpen);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
setSettingsLoaded(true);
}
};
fetchSettings();
}, []);
const handleSidebarToggle = async () => {
const newState = !isSidebarOpen;
setIsSidebarOpen(newState);
try {
// We need to fetch current settings first to not overwrite other settings
// Or better, the backend should support partial updates, but the current controller
// implementation replaces the whole object or merges with defaults.
// Let's fetch first to be safe, similar to how SettingsPage does it,
// but for a simple toggle, we might want a lighter endpoint.
// However, given the current backend structure, we'll fetch then save.
// Actually, the backend `updateSettings` merges with `defaultSettings` but expects the full object
// in `req.body` to be the new state.
// Wait, looking at `settingsController.ts`: `const newSettings: Settings = req.body;`
// and `storageService.saveSettings(newSettings);`
// It seems it REPLACES the settings with what's sent.
// So we MUST fetch existing settings first.
const response = await axios.get(`${API_URL}/settings`);
const currentSettings = response.data;
await axios.post(`${API_URL}/settings`, {
...currentSettings,
homeSidebarOpen: newState
});
} catch (error) {
console.error('Failed to save sidebar state:', error);
}
};
// Reset page when filters change
useEffect(() => {
@@ -72,7 +124,7 @@ const Home: React.FC = () => {
// Add default empty array to ensure videos is always an array
const videoArray = Array.isArray(videos) ? videos : [];
if (loading && videoArray.length === 0 && !isSearchMode) {
if (!settingsLoaded || (loading && videoArray.length === 0 && !isSearchMode)) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
@@ -289,29 +341,65 @@ const Home: React.FC = () => {
</Typography>
</Box>
) : (
<Grid container spacing={4}>
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
{/* Sidebar container for Collections, Authors, and Tags */}
<Grid size={{ xs: 12, md: 3 }} sx={{ display: { xs: 'none', md: 'block' } }}>
<Box sx={{ position: 'sticky', top: 80 }}>
<Collections collections={collections} />
<Box sx={{ mt: 2 }}>
<TagsList
availableTags={availableTags}
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
<Collapse in={isSidebarOpen} orientation="horizontal" timeout={300} sx={{ height: '100%', '& .MuiCollapse-wrapper': { height: '100%' }, '& .MuiCollapse-wrapperInner': { height: '100%' } }}>
<Box sx={{ width: 280, mr: 4, flexShrink: 0, height: '100%', position: 'relative' }}>
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<Box sx={{
position: 'sticky',
maxHeight: 'calc(100% - 80px)',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.1)',
borderRadius: '3px',
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.2)',
},
}}>
<Collections collections={collections} />
<Box sx={{ mt: 2 }}>
<TagsList
availableTags={availableTags}
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</Box>
<Box sx={{ mt: 2 }}>
<AuthorsList videos={videoArray} />
</Box>
</Box>
</Box>
</Box>
<Box sx={{ mt: 2 }}>
<AuthorsList videos={videoArray} />
</Box>
</Box>
</Grid>
</Collapse>
</Box>
{/* Videos grid */}
<Grid size={{ xs: 12, md: 9 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* View mode toggle */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" fontWeight="bold">
<Typography variant="h5" fontWeight="bold" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
onClick={handleSidebarToggle}
variant="outlined"
sx={{
minWidth: 'auto',
p: 1,
display: { xs: 'none', md: 'inline-flex' },
color: 'text.primary',
borderColor: 'text.primary',
}}
>
<ViewSidebar sx={{ transform: 'rotate(180deg)' }} />
</Button>
{t('videos')}
</Typography>
<ToggleButtonGroup
@@ -332,10 +420,14 @@ const Home: React.FC = () => {
</Box>
<Grid container spacing={3}>
{displayedVideos.map(video => {
const gridProps = isSidebarOpen
? { xs: 12, sm: 6, lg: 4, xl: 3 }
: { xs: 12, sm: 6, md: 4, lg: 3, xl: 2 };
// In all-videos mode, ALWAYS render as VideoCard
if (viewMode === 'all-videos') {
return (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
<Grid size={gridProps} key={video.id}>
<VideoCard
video={video}
collections={collections}
@@ -351,7 +443,7 @@ const Home: React.FC = () => {
// If it is, render CollectionCard
if (collection) {
return (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={`collection-${collection.id}`}>
<Grid size={gridProps} key={`collection-${collection.id}`}>
<CollectionCard
collection={collection}
videos={videoArray}
@@ -362,7 +454,7 @@ const Home: React.FC = () => {
// Otherwise render VideoCard for non-collection videos
return (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
<Grid size={gridProps} key={video.id}>
<VideoCard
video={video}
collections={collections}
@@ -372,6 +464,8 @@ const Home: React.FC = () => {
})}
</Grid>
{totalPages > 1 && (
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Pagination
@@ -385,10 +479,10 @@ const Home: React.FC = () => {
/>
</Box>
)}
</Grid>
</Grid>
</Box>
</Box >
)}
</Container>
</Container >
);
};

View File

@@ -0,0 +1,180 @@
import { Box, Container, Divider, List, ListItem, ListItemText, Paper, Typography } from '@mui/material';
import React from 'react';
import Disclaimer from '../components/Disclaimer';
import { useLanguage } from '../contexts/LanguageContext';
const InstructionPage: React.FC = () => {
const { t } = useLanguage();
const renderInstructions = () => (
<Paper elevation={0} sx={{ p: 3, bgcolor: 'transparent' }}>
{/* Section 1 */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{t('instructionSection1Title')}
</Typography>
<Typography variant="body1" paragraph color="text.secondary">
{t('instructionSection1Desc')}
</Typography>
<Box sx={{ ml: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection1Sub1')}
</Typography>
<List dense>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item1Label')}</b> {t('instructionSection1Item1Text')}</>}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item2Label')}</b> {t('instructionSection1Item2Text')}</>}
/>
</ListItem>
</List>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection1Sub2')}
</Typography>
<List dense>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item3Label')}</b> {t('instructionSection1Item3Text')}</>}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item4Label')}</b> {t('instructionSection1Item4Text')}</>}
/>
</ListItem>
</List>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection1Sub3')}
</Typography>
<List dense>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item5Label')}</b> {t('instructionSection1Item5Text')}</>}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item6Label')}</b> {t('instructionSection1Item6Text')}</>}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection1Item7Label')}</b> {t('instructionSection1Item7Text')}</>}
/>
</ListItem>
</List>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Section 2 */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{t('instructionSection2Title')}
</Typography>
<Typography variant="body1" paragraph color="text.secondary">
{t('instructionSection2Desc')}
</Typography>
<Box sx={{ ml: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection2Sub1')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, ml: 2 }}>
{t('instructionSection2Text1')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection2Sub2')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, ml: 2 }}>
{t('instructionSection2Text2')}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Section 3 */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{t('instructionSection3Title')}
</Typography>
<Typography variant="body1" paragraph color="text.secondary">
{t('instructionSection3Desc')}
</Typography>
<Box sx={{ ml: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection3Sub1')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, ml: 2 }}>
{t('instructionSection3Text1')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection3Sub2')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, ml: 2 }}>
{t('instructionSection3Text2')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection3Sub3')}
</Typography>
<List dense>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection3Item1Label')}</b> {t('instructionSection3Item1Text')}</>}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection3Item2Label')}</b> {t('instructionSection3Item2Text')}</>}
/>
</ListItem>
</List>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 2 }}>
{t('instructionSection3Sub4')}
</Typography>
<List dense>
<ListItem>
<ListItemText
primary={<><b>{t('instructionSection3Item3Label')}</b> {t('instructionSection3Item3Text')}</>}
/>
</ListItem>
</List>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
<Disclaimer />
</Paper>
);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ fontWeight: 'bold' }}>
{t('instruction')}
</Typography>
</Box>
<Box>
{renderInstructions()}
</Box>
</Container>
);
};
export default InstructionPage;

View File

@@ -2,6 +2,7 @@ import {
ArrowBack,
Check,
Close,
Delete,
Edit,
Folder,
@@ -34,6 +35,7 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
@@ -53,6 +55,7 @@ const ManagePage: React.FC = () => {
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
// Editing state
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState<string>('');
@@ -227,22 +230,29 @@ const ManagePage: React.FC = () => {
return video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
};
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
{t('manageContent')}
</Typography>
<Button
component={Link}
to="/"
variant="outlined"
startIcon={<ArrowBack />}
>
{t('backToHome')}
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
component={Link}
to="/"
variant="outlined"
startIcon={<ArrowBack />}
>
{t('backToHome')}
</Button>
</Box>
</Box>
<DeleteCollectionModal
isOpen={!!collectionToDelete}
onClose={() => !isDeletingCollection && setCollectionToDelete(null)}

View File

@@ -48,6 +48,7 @@ interface Settings {
openListApiUrl: string;
openListToken: string;
cloudDrivePath: string;
homeSidebarOpen?: boolean;
}
const SettingsPage: React.FC = () => {
@@ -127,29 +128,7 @@ const SettingsPage: React.FC = () => {
saveMutation.mutate(settings);
};
// Scan files mutation
const scanMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/scan-files`);
return res.data;
},
onSuccess: (data) => {
setInfoModal({
isOpen: true,
title: t('success'),
message: t('scanFilesSuccess').replace('{count}', data.addedCount.toString()),
type: 'success'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
// Migrate data mutation
const migrateMutation = useMutation({
@@ -289,7 +268,7 @@ const SettingsPage: React.FC = () => {
setSettings(prev => ({ ...prev, tags: updatedTags }));
};
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
const isSaving = saveMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
@@ -534,15 +513,7 @@ const SettingsPage: React.FC = () => {
{t('migrateDataButton')}
</Button>
<Button
variant="outlined"
color="primary"
onClick={() => scanMutation.mutate()}
disabled={isSaving}
sx={{ ml: 2 }}
>
{t('scanFiles')}
</Button>
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>{t('removeLegacyData')}</Typography>

View File

@@ -0,0 +1,155 @@
import { Delete } from '@mui/icons-material';
import {
Button,
Container,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
interface Subscription {
id: string;
author: string;
authorUrl: string;
interval: number;
lastVideoLink?: string;
lastCheck?: number;
downloadCount: number;
createdAt: number;
platform: string;
}
const SubscriptionsPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
useEffect(() => {
fetchSubscriptions();
}, []);
const fetchSubscriptions = async () => {
try {
const response = await axios.get(`${API_URL}/subscriptions`);
setSubscriptions(response.data);
} catch (error) {
console.error('Error fetching subscriptions:', error);
showSnackbar(t('error'));
}
};
const handleUnsubscribeClick = (id: string, author: string) => {
setSelectedSubscription({ id, author });
setIsUnsubscribeModalOpen(true);
};
const handleConfirmUnsubscribe = async () => {
if (!selectedSubscription) return;
try {
await axios.delete(`${API_URL}/subscriptions/${selectedSubscription.id}`);
showSnackbar(t('unsubscribedSuccessfully'));
fetchSubscriptions();
} catch (error) {
console.error('Error unsubscribing:', error);
showSnackbar(t('error'));
} finally {
setIsUnsubscribeModalOpen(false);
setSelectedSubscription(null);
}
};
const formatDate = (timestamp?: number) => {
if (!timestamp) return t('never');
return new Date(timestamp).toLocaleString();
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
{t('subscriptions')}
</Typography>
<TableContainer component={Paper} sx={{ mt: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('author')}</TableCell>
<TableCell>{t('platform')}</TableCell>
<TableCell>{t('interval')}</TableCell>
<TableCell>{t('lastCheck')}</TableCell>
<TableCell>{t('downloads')}</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
</Typography>
</TableCell>
</TableRow>
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell>
<Button
href={sub.authorUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', justifyContent: 'flex-start', p: 0 }}
>
{sub.author}
</Button>
</TableCell>
<TableCell>{sub.platform}</TableCell>
<TableCell>{sub.interval} {t('minutes')}</TableCell>
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
<TableCell>{sub.downloadCount}</TableCell>
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleUnsubscribeClick(sub.id, sub.author)}
title={t('unsubscribe')}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<ConfirmationModal
isOpen={isUnsubscribeModalOpen}
onClose={() => setIsUnsubscribeModalOpen(false)}
onConfirm={handleConfirmUnsubscribe}
title={t('unsubscribe')}
message={t('confirmUnsubscribe', { author: selectedSubscription?.author || '' })}
confirmText={t('unsubscribe')}
isDanger
/>
</Container >
);
};
export default SubscriptionsPage;

View File

@@ -388,12 +388,14 @@ const VideoPlayer: React.FC = () => {
onTagsUpdate={handleUpdateTags}
/>
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
{(video.source === 'youtube' || video.source === 'bilibili') && (
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
)}
</Box>
</Grid>
@@ -435,8 +437,8 @@ const VideoPlayer: React.FC = () => {
/>
)}
</Box>
<CardContent sx={{ flex: '1 0 auto', p: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
<CardContent sx={{ flex: '1 1 auto', minWidth: 0, p: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
{relatedVideo.title}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
@@ -445,11 +447,9 @@ const VideoPlayer: React.FC = () => {
<Typography variant="caption" display="block" color="text.secondary">
{formatDate(relatedVideo.date)}
</Typography>
{relatedVideo.viewCount !== undefined && (
<Typography variant="caption" display="block" color="text.secondary">
{relatedVideo.viewCount} {t('views')}
</Typography>
)}
<Typography variant="caption" display="block" color="text.secondary">
{relatedVideo.viewCount || 0} {t('views')}
</Typography>
</CardContent>
</Card>
))}

View File

@@ -59,8 +59,10 @@ export const ar = {
migrateDataButton: "نقل البيانات من JSON",
scanFiles: "فحص الملفات",
scanFilesSuccess: "اكتمل الفحص. تمت إضافة {count} فيديوهات جديدة.",
scanFilesFailed: "فشل الفحص",
migrateConfirmation: "هل أنت متأكد أنك تريد نقل البيانات؟ قد يستغرق هذا بضع لحظات.",
scanFilesFailed: "فشل المسح",
scanFilesConfirmMessage: "سيقوم النظام بفحص المجلد الجذر لمسار الفيديو للعثور على ملفات الفيديو غير الموثقة.",
scanning: "جارٍ المسح...",
migrateConfirmation: "هل أنت متأكد أنك تريد ترحيل البيانات؟ قد يستغرق هذا بضع لحظات.",
migrationResults: "نتائج النقل",
migrationReport: "تقرير النقل",
migrationSuccess: "اكتمل النقل. انظر التفاصيل في التنبيه.",
@@ -212,6 +214,7 @@ export const ar = {
save: "حفظ",
on: "تشغيل",
off: "إيقاف",
continue: "متابعة",
// Video Card
unknownDate: "تاريخ غير معروف",
@@ -265,4 +268,72 @@ export const ar = {
speed: "السرعة",
finishedAt: "انتهى في",
failed: "فشل",
// Batch Download
batchDownload: "تحميل مجمع",
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
urls: "الروابط",
addToQueue: "إضافة إلى قائمة الانتظار",
batchTasksAdded: "تمت إضافة {count} مهمة",
addBatchTasks: "إضافة مهام مجمعة",
// Subscriptions
subscribeToAuthor: "الاشتراك في المؤلف",
subscribeConfirmationMessage: "هل تريد الاشتراك في {author}؟",
subscribeDescription: "سيقوم النظام تلقائيًا بالتحقق من مقاطع الفيديو الجديدة لهذا المؤلف وتنزيلها.",
checkIntervalMinutes: "فاصل التحقق (دقائق)",
subscribe: "اشتراك",
subscriptions: "الاشتراكات",
interval: "الفاصل الزمني",
lastCheck: "آخر تحقق",
platform: "المنصة",
unsubscribe: "إلغاء الاشتراك",
confirmUnsubscribe: "هل أنت متأكد أنك تريد إلغاء الاشتراك من {author}؟",
subscribedSuccessfully: "تم الاشتراك بنجاح",
unsubscribedSuccessfully: "تم إلغاء الاشتراك بنجاح",
subscriptionAlreadyExists: "أنت مشترك بالفعل في هذا المؤلف.",
minutes: "دقائق",
never: "أبداً",
// Instruction Page
instructionSection1Title: "1. التنزيل وإدارة المهام",
instructionSection1Desc: "تتضمن هذه الوحدة وظائف الحصول على الفيديو والمهام المجمعة واستيراد الملفات.",
instructionSection1Sub1: "تنزيل الرابط:",
instructionSection1Item1Label: "تنزيل أساسي:",
instructionSection1Item1Text: "الصق روابط من مواقع فيديو مختلفة في مربع الإدخال للتنزيل مباشرة.",
instructionSection1Item2Label: "الأذونات:",
instructionSection1Item2Text: "بالنسبة للمواقع التي تتطلب عضوية أو تسجيل دخول، يرجى تسجيل الدخول إلى الحساب المقابل في علامة تبويب متصفح جديدة أولاً للحصول على أذونات التنزيل.",
instructionSection1Sub2: "التعرف الذكي:",
instructionSection1Item3Label: "اشتراك مؤلف YouTube:",
instructionSection1Item3Text: "عندما يكون الرابط الذي تم لصقه هو قناة مؤلف، سيسأل النظام عما إذا كنت تريد الاشتراك. بعد الاشتراك، يمكن للنظام مسح وتنزيل تحديثات المؤلف تلقائيًا على فترات زمنية محددة.",
instructionSection1Item4Label: "تنزيل مجموعة Bilibili:",
instructionSection1Item4Text: "عندما يكون الرابط الذي تم لصقه مفضلة/مجموعة Bilibili، سيسأل النظام عما إذا كنت تريد تنزيل محتوى المجموعة بالكامل.",
instructionSection1Sub3: "أدوات متقدمة (صفحة إدارة التنزيل):",
instructionSection1Item5Label: "إضافة مهام مجمعة:",
instructionSection1Item5Text: "يدعم لصق روابط تنزيل متعددة في وقت واحد (واحد في كل سطر) للإضافة المجمعة.",
instructionSection1Item6Label: "مسح الملفات:",
instructionSection1Item6Text: "يبحث تلقائيًا عن جميع الملفات في الدليل الجذر لتخزين الفيديو والمجلدات من المستوى الأول. هذه الوظيفة مناسبة لمزامنة الملفات مع النظام بعد أن يقوم المسؤولون بإيداعها يدويًا في الواجهة الخلفية للخادم.",
instructionSection1Item7Label: "تحميل فيديو:",
instructionSection1Item7Text: "يدعم تحميل ملفات الفيديو المحلية مباشرة من العميل إلى الخادم.",
instructionSection2Title: "2. إدارة مكتبة الفيديو",
instructionSection2Desc: "صيانة وتحرير موارد الفيديو التي تم تنزيلها أو استيرادها.",
instructionSection2Sub1: "حذف المجموعة/الفيديو:",
instructionSection2Text1: "عند حذف مجموعة في صفحة الإدارة، يقدم النظام خيارين: حذف عنصر قائمة المجموعة فقط (الاحتفاظ بالملفات)، أو حذف الملفات المادية داخل المجموعة تمامًا.",
instructionSection2Sub2: "إصلاح الصورة المصغرة:",
instructionSection2Text2: "إذا لم يكن للفيديو غلاف بعد التنزيل، فانقر فوق زر التحديث على الصورة المصغرة للفيديو، وسيقوم النظام بإعادة التقاط الإطار الأول للفيديو كصورة مصغرة جديدة.",
instructionSection3Title: "3. إعدادات النظام",
instructionSection3Desc: "تكوين معلمات النظام وصيانة البيانات وتوسيع الوظائف.",
instructionSection3Sub1: "إعدادات الأمان:",
instructionSection3Text1: "قم بتعيين كلمة مرور تسجيل الدخول للنظام (كلمة المرور الأولية الافتراضية هي 123، يوصى بتغييرها بعد تسجيل الدخول الأول).",
instructionSection3Sub2: "إدارة العلامات:",
instructionSection3Text2: "يدعم إضافة أو حذف علامات تصنيف الفيديو. ملاحظة: يجب النقر فوق الزر \"حفظ\" في أسفل الصفحة لتصبح التغييرات سارية المفعول.",
instructionSection3Sub3: "صيانة النظام:",
instructionSection3Item1Label: "تنظيف الملفات المؤقتة:",
instructionSection3Item1Text: "يستخدم لمسح ملفات التنزيل المؤقتة المتبقية الناتجة عن فشل الواجهة الخلفية العرضي لتحرير المساحة.",
instructionSection3Item2Label: "ترحيل قاعدة البيانات:",
instructionSection3Item2Text: "مصمم لمستخدمي الإصدارات المبكرة. استخدم هذه الوظيفة لترحيل البيانات من JSON إلى قاعدة بيانات SQLite الجديدة. بعد الترحيل الناجح، انقر فوق زر الحذف لتنظيف بيانات السجل القديمة.",
instructionSection3Sub4: "الخدمات الموسعة:",
instructionSection3Item3Label: "OpenList Cloud Drive:",
instructionSection3Item3Text: "(قيد التطوير) يدعم الاتصال بخدمات OpenList التي ينشرها المستخدم. أضف التكوين هنا لتمكين تكامل محرك الأقراص السحابية.",
};

View File

@@ -23,6 +23,8 @@ export const de = {
database: "Datenbank", migrateDataDescription: "Daten von Legacy-JSON-Dateien zur neuen SQLite-Datenbank migrieren. Diese Aktion kann sicher mehrmals ausgeführt werden (Duplikate werden übersprungen).",
migrateDataButton: "Daten aus JSON migrieren", scanFiles: "Dateien Scannen",
scanFilesSuccess: "Scan abgeschlossen. {count} neue Videos hinzugefügt.", scanFilesFailed: "Scan fehlgeschlagen",
scanFilesConfirmMessage: "Das System scannt den Stammordner des Videopfads, um nicht dokumentierte Videodateien zu finden.",
scanning: "Scannen...",
migrateConfirmation: "Sind Sie sicher, dass Sie Daten migrieren möchten? Dies kann einige Momente dauern.",
migrationResults: "Migrationsergebnisse", migrationReport: "Migrationsbericht",
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.", migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
@@ -93,7 +95,8 @@ export const de = {
deleteCollectionTitle: "Sammlung Löschen", deleteCollectionConfirmation: "Sind Sie sicher, dass Sie die Sammlung löschen möchten",
collectionContains: "Diese Sammlung enthält", deleteCollectionOnly: "Nur Sammlung Löschen",
deleteCollectionAndVideos: "Sammlung und Alle Videos Löschen", loading: "Laden...", error: "Fehler",
success: "Erfolg", cancel: "Abbrechen", confirm: "Bestätigen", save: "Speichern", on: "Ein", off: "Aus",
success: "Erfolg", cancel: "Abbrechen", confirm: "Bestätigen", save: "Speichern", on: "Ein", off: "Aus",
continue: "Weiter",
unknownDate: "Unbekanntes Datum", part: "Teil", collection: "Sammlung", selectVideoFile: "Videodatei Auswählen",
pleaseSelectVideo: "Bitte wählen Sie eine Videodatei aus", uploadFailed: "Upload fehlgeschlagen",
failedToUpload: "Fehler beim Hochladen des Videos", uploading: "Hochladen...", upload: "Hochladen",
@@ -136,4 +139,72 @@ export const de = {
speed: "Geschwindigkeit",
finishedAt: "Beendet am",
failed: "Fehlgeschlagen",
// Batch Download
batchDownload: "Stapel-Download",
batchDownloadDescription: "Fügen Sie unten mehrere URLs ein, eine pro Zeile.",
urls: "URLs",
addToQueue: "Zur Warteschlange hinzufügen",
batchTasksAdded: "{count} Aufgaben hinzugefügt",
addBatchTasks: "Stapelaufgaben hinzufügen",
// Subscriptions
subscribeToAuthor: "Autor abonnieren",
subscribeConfirmationMessage: "Möchten Sie {author} abonnieren?",
subscribeDescription: "Das System prüft automatisch auf neue Videos dieses Autors und lädt sie herunter.",
checkIntervalMinutes: "Prüfintervall (Minuten)",
subscribe: "Abonnieren",
subscriptions: "Abonnements",
interval: "Intervall",
lastCheck: "Letzte Prüfung",
platform: "Plattform",
unsubscribe: "Deabonnieren",
confirmUnsubscribe: "Sind Sie sicher, dass Sie {author} deabonnieren möchten?",
subscribedSuccessfully: "Erfolgreich abonniert",
unsubscribedSuccessfully: "Erfolgreich deabonniert",
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
minutes: "Minuten",
never: "Nie",
// Instruction Page
instructionSection1Title: "1. Download & Aufgabenverwaltung",
instructionSection1Desc: "Dieses Modul umfasst Videoerfassung, Batch-Aufgaben und Dateiimportfunktionen.",
instructionSection1Sub1: "Link-Download:",
instructionSection1Item1Label: "Basis-Download:",
instructionSection1Item1Text: "Fügen Sie Links von verschiedenen Videoseiten in das Eingabefeld ein, um direkt herunterzuladen.",
instructionSection1Item2Label: "Berechtigungen:",
instructionSection1Item2Text: "Für Seiten, die eine Mitgliedschaft oder Anmeldung erfordern, melden Sie sich bitte zuerst in einem neuen Browser-Tab im entsprechenden Konto an, um Download-Berechtigungen zu erhalten.",
instructionSection1Sub2: "Intelligente Erkennung:",
instructionSection1Item3Label: "YouTube-Autoren-Abonnement:",
instructionSection1Item3Text: "Wenn der eingefügte Link der Kanal eines Autors ist, fragt das System, ob Sie abonnieren möchten. Nach dem Abonnieren kann das System automatisch nach Updates des Autors in festgelegten Intervallen suchen und diese herunterladen.",
instructionSection1Item4Label: "Bilibili-Sammlungs-Download:",
instructionSection1Item4Text: "Wenn der eingefügte Link ein Bilibili-Favorit/Sammlung ist, fragt das System, ob Sie den gesamten Inhalt der Sammlung herunterladen möchten.",
instructionSection1Sub3: "Erweiterte Tools (Download-Verwaltungsseite):",
instructionSection1Item5Label: "Batch-Aufgaben hinzufügen:",
instructionSection1Item5Text: "Unterstützt das Einfügen mehrerer Download-Links auf einmal (einer pro Zeile) für das Batch-Hinzufügen.",
instructionSection1Item6Label: "Dateien scannen:",
instructionSection1Item6Text: "Sucht automatisch nach allen Dateien im Videospeicher-Stammverzeichnis und Ordnern der ersten Ebene. Diese Funktion eignet sich zum Synchronisieren von Dateien mit dem System, nachdem Administratoren sie manuell im Server-Backend abgelegt haben.",
instructionSection1Item7Label: "Video hochladen:",
instructionSection1Item7Text: "Unterstützt das Hochladen lokaler Videodateien direkt vom Client auf den Server.",
instructionSection2Title: "2. Videobibliotheksverwaltung",
instructionSection2Desc: "Verwalten und bearbeiten Sie heruntergeladene oder importierte Videoressourcen.",
instructionSection2Sub1: "Sammlungs-/Video-Löschung:",
instructionSection2Text1: "Beim Löschen einer Sammlung auf der Verwaltungsseite bietet das System zwei Optionen: nur das Sammlungslistenelement löschen (Dateien behalten) oder die physischen Dateien innerhalb der Sammlung vollständig löschen.",
instructionSection2Sub2: "Thumbnail-Reparatur:",
instructionSection2Text2: "Wenn ein Video nach dem Herunterladen kein Cover hat, klicken Sie auf die Aktualisieren-Schaltfläche auf dem Video-Thumbnail, und das System erfasst den ersten Frame des Videos erneut als neues Thumbnail.",
instructionSection3Title: "3. Systemeinstellungen",
instructionSection3Desc: "Systemparameter konfigurieren, Daten verwalten und Funktionen erweitern.",
instructionSection3Sub1: "Sicherheitseinstellungen:",
instructionSection3Text1: "Legen Sie das System-Login-Passwort fest (das Standard-Initialpasswort ist 123, es wird empfohlen, es nach dem ersten Login zu ändern).",
instructionSection3Sub2: "Tag-Verwaltung:",
instructionSection3Text2: "Unterstützt das Hinzufügen oder Löschen von Videoklassifizierungs-Tags. Hinweis: Sie müssen auf die Schaltfläche \"Speichern\" unten auf der Seite klicken, damit Änderungen wirksam werden.",
instructionSection3Sub3: "Systemwartung:",
instructionSection3Item1Label: "Temporäre Dateien bereinigen:",
instructionSection3Item1Text: "Wird verwendet, um restliche temporäre Download-Dateien zu löschen, die durch gelegentliche Backend-Fehler verursacht wurden, um Speicherplatz freizugeben.",
instructionSection3Item2Label: "Datenbankmigration:",
instructionSection3Item2Text: "Entwickelt für Benutzer früherer Versionen. Verwenden Sie diese Funktion, um Daten von JSON in die neue SQLite-Datenbank zu migrieren. Klicken Sie nach erfolgreicher Migration auf die Löschen-Schaltfläche, um alte Verlaufsdaten zu bereinigen.",
instructionSection3Sub4: "Erweiterte Dienste:",
instructionSection3Item3Label: "OpenList Cloud Drive:",
instructionSection3Item3Text: "(In Entwicklung) Unterstützt die Verbindung zu benutzerbereitgestellten OpenList-Diensten. Fügen Sie hier eine Konfiguration hinzu, um die Cloud-Laufwerksintegration zu aktivieren.",
};

View File

@@ -9,6 +9,7 @@ export const en = {
uploadVideo: "Upload Video",
enterUrlOrSearchTerm: "Enter YouTube/Bilibili URL or search term",
manageVideos: "Manage Videos",
instruction: "Instruction",
// Home
pasteUrl: "Paste video or collection URL",
@@ -60,6 +61,8 @@ export const en = {
scanFiles: "Scan Files",
scanFilesSuccess: "Scan complete. Added {count} new videos.",
scanFilesFailed: "Scan failed",
scanFilesConfirmMessage: "The system will scan the root folder of the video path to find undocumented video files.",
scanning: "Scanning...",
migrateConfirmation: "Are you sure you want to migrate data? This may take a few moments.",
migrationResults: "Migration Results",
migrationReport: "Migration Report",
@@ -203,6 +206,7 @@ export const en = {
save: "Save",
on: "On",
off: "Off",
continue: "Continue",
// Video Card
unknownDate: "Unknown date",
@@ -266,4 +270,76 @@ export const en = {
videoRemovedFromCollection: "Video removed from collection",
collectionDeletedSuccessfully: "Collection deleted successfully",
failedToDeleteCollection: "Failed to delete collection",
// Batch Download
batchDownload: "Batch Download",
batchDownloadDescription: "Paste multiple URLs below, one per line.",
urls: "URLs",
addToQueue: "Add to Queue",
batchTasksAdded: "{count} tasks added",
addBatchTasks: "Add batch tasks",
// Subscriptions
subscribeToAuthor: "Subscribe to Author",
subscribeConfirmationMessage: "Do you want to subscribe to {author}?",
subscribeDescription: "The system will automatically check for new videos from this author and download them.",
checkIntervalMinutes: "Check Interval (minutes)",
subscribe: "Subscribe",
subscriptions: "Subscriptions",
interval: "Interval",
lastCheck: "Last Check",
platform: "Platform",
unsubscribe: "Unsubscribe",
confirmUnsubscribe: "Are you sure you want to unsubscribe from {author}?",
subscribedSuccessfully: "Subscribed successfully",
unsubscribedSuccessfully: "Unsubscribed successfully",
subscriptionAlreadyExists: "You are already subscribed to this author.",
minutes: "minutes",
never: "Never",
// Instruction Page
instructionSection1Title: "1. Download & Task Management",
instructionSection1Desc: "This module includes video acquisition, batch tasks, and file import functions.",
instructionSection1Sub1: "Link Download:",
instructionSection1Item1Label: "Basic Download:",
instructionSection1Item1Text: "Paste links from various video sites into the input box to download directly.",
instructionSection1Item2Label: "Permissions:",
instructionSection1Item2Text: "For sites requiring membership or login, please log in to the corresponding account in a new browser tab first to acquire download permissions.",
instructionSection1Sub2: "Smart Recognition:",
instructionSection1Item3Label: "YouTube Author Subscription:",
instructionSection1Item3Text: "When the pasted link is an author's channel, the system will ask if you want to subscribe. After subscribing, the system can automatically scan and download the author's updates at set intervals.",
instructionSection1Item4Label: "Bilibili Collection Download:",
instructionSection1Item4Text: "When the pasted link is a Bilibili favorite/collection, the system will ask if you want to download the entire collection content.",
instructionSection1Sub3: "Advanced Tools (Download Management Page):",
instructionSection1Item5Label: "Batch Add Tasks:",
instructionSection1Item5Text: "Supports pasting multiple download links at once (one per line) for batch addition.",
instructionSection1Item6Label: "Scan Files:",
instructionSection1Item6Text: "Automatically searches for all files in the video storage root directory and first-level folders. This function is suitable for syncing files to the system after administrators manually deposit them in the server backend.",
instructionSection1Item7Label: "Upload Video:",
instructionSection1Item7Text: "Supports uploading local video files directly from the client to the server.",
instructionSection2Title: "2. Video Library Management",
instructionSection2Desc: "Maintain and edit downloaded or imported video resources.",
instructionSection2Sub1: "Collection/Video Deletion:",
instructionSection2Text1: "When deleting a collection on the management page, the system offers two options: delete only the collection list item (keep files), or completely delete the physical files within the collection.",
instructionSection2Sub2: "Thumbnail Repair:",
instructionSection2Text2: "If a video has no cover after downloading, click the refresh button on the video thumbnail, and the system will re-capture the first frame of the video as the new thumbnail.",
instructionSection3Title: "3. System Settings",
instructionSection3Desc: "Configure system parameters, maintain data, and extend functions.",
instructionSection3Sub1: "Security Settings:",
instructionSection3Text1: "Set the system login password (default initial password is 123, recommended to change after first login).",
instructionSection3Sub2: "Tag Management:",
instructionSection3Text2: "Supports adding or deleting video classification tags. Note: You must click the \"Save\" button at the bottom of the page for changes to take effect.",
instructionSection3Sub3: "System Maintenance:",
instructionSection3Item1Label: "Clean Up Temp Files:",
instructionSection3Item1Text: "Used to clear residual temporary download files caused by occasional backend failures to free up space.",
instructionSection3Item2Label: "Database Migration:",
instructionSection3Item2Text: "Designed for early version users. Use this function to migrate data from JSON to the new SQLite database. After successful migration, click the delete button to clean up old history data.",
instructionSection3Sub4: "Extended Services:",
instructionSection3Item3Label: "OpenList Cloud Drive:",
instructionSection3Item3Text: "(In Development) Supports connecting to user-deployed OpenList services. Add configuration here to enable cloud drive integration.",
// Disclaimer
disclaimerTitle: "Disclaimer",
disclaimerText: "1. Purpose and Restrictions\nThis software (including code and documentation) is intended solely for personal learning, research, and technical exchange. It is strictly prohibited to use this software for any commercial purposes or for any illegal activities that violate local laws and regulations.\n\n2. Liability\nThe developer is unaware of and has no control over how users utilize this software. Any legal liabilities, disputes, or damages arising from the illegal or improper use of this software (including but not limited to copyright infringement) shall be borne solely by the user. The developer assumes no direct, indirect, or joint liability.\n\n3. Modifications and Distribution\nThis project is open-source. Any individual or organization modifying or forking this code must comply with the open-source license. Important: If a third party modifies the code to bypass or remove the original user authentication/security mechanisms and distributes such versions, the modifier/distributor bears full responsibility for any consequences. We strongly discourage bypassing or tampering with any security verification mechanisms.\n\n4. Non-Profit Statement\nThis is a completely free open-source project. The developer does not accept donations and has never published any donation pages. The software itself allows no charges and offers no paid services. Please be vigilant and beware of any scams or misleading information claiming to collect fees on behalf of this project.",
};

View File

@@ -22,7 +22,9 @@ export const es = {
tagsManagementNote: "Recuerde hacer clic en \"Guardar Configuración\" después de agregar o eliminar etiquetas para aplicar los cambios.",
database: "Base de Datos", migrateDataDescription: "Migrar datos de archivos JSON heredados a la nueva base de datos SQLite. Esta acción es segura para ejecutar varias veces (se omitirán duplicados).",
migrateDataButton: "Migrar Datos desde JSON", scanFiles: "Escanear Archivos",
scanFilesSuccess: "Escaneo completo. Se agregaron {count} nuevos videos.", scanFilesFailed: "Escaneo fallido",
scanFilesSuccess: "Escaneo completo. Se agregaron {count} nuevos videos.", scanFilesFailed: "Escaneo fallido",
scanFilesConfirmMessage: "El sistema escaneará la carpeta raíz de la ruta de video para encontrar archivos de video no documentados.",
scanning: "Escaneando...",
migrateConfirmation: "¿Está seguro de que desea migrar los datos? Esto puede tardar unos momentos.",
migrationResults: "Resultados de Migración", migrationReport: "Informe de Migración",
migrationSuccess: "Migración completada. Ver detalles en la alerta.", migrationNoData: "Migración finalizada pero no se encontraron datos.",
@@ -134,4 +136,72 @@ export const es = {
speed: "Velocidad",
finishedAt: "Finalizado en",
failed: "Fallido",
// Batch Download
batchDownload: "Descarga por lotes",
batchDownloadDescription: "Pegue varias URL a continuación, una por línea.",
urls: "URLs",
addToQueue: "Añadir a la cola",
batchTasksAdded: "{count} tareas añadidas",
addBatchTasks: "Añadir tareas por lotes",
// Subscriptions
subscribeToAuthor: "Suscribirse al autor",
subscribeConfirmationMessage: "¿Quieres suscribirte a {author}?",
subscribeDescription: "El sistema comprobará automáticamente si hay nuevos vídeos de este autor y los descargará.",
checkIntervalMinutes: "Intervalo de comprobación (minutos)",
subscribe: "Suscribirse",
subscriptions: "Suscripciones",
interval: "Intervalo",
lastCheck: "Última comprobación",
platform: "Plataforma",
unsubscribe: "Darse de baja",
confirmUnsubscribe: "¿Estás seguro de que quieres darte de baja de {author}?",
subscribedSuccessfully: "Suscrito con éxito",
unsubscribedSuccessfully: "Dado de baja con éxito",
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
minutes: "minutos",
never: "Nunca",
// Instruction Page
instructionSection1Title: "1. Descarga y Gestión de Tareas",
instructionSection1Desc: "Este módulo incluye adquisición de video, tareas por lotes y funciones de importación de archivos.",
instructionSection1Sub1: "Descarga de Enlace:",
instructionSection1Item1Label: "Descarga Básica:",
instructionSection1Item1Text: "Pegue enlaces de varios sitios de video en el cuadro de entrada para descargar directamente.",
instructionSection1Item2Label: "Permisos:",
instructionSection1Item2Text: "Para sitios que requieren membresía o inicio de sesión, inicie sesión en la cuenta correspondiente en una nueva pestaña del navegador primero para adquirir permisos de descarga.",
instructionSection1Sub2: "Reconocimiento Inteligente:",
instructionSection1Item3Label: "Suscripción de Autor de YouTube:",
instructionSection1Item3Text: "Cuando el enlace pegado es el canal de un autor, el sistema preguntará si desea suscribirse. Después de suscribirse, el sistema puede escanear y descargar automáticamente las actualizaciones del autor en intervalos establecidos.",
instructionSection1Item4Label: "Descarga de Colección de Bilibili:",
instructionSection1Item4Text: "Cuando el enlace pegado es un favorito/colección de Bilibili, el sistema preguntará si desea descargar todo el contenido de la colección.",
instructionSection1Sub3: "Herramientas Avanzadas (Página de Gestión de Descargas):",
instructionSection1Item5Label: "Añadir Tareas por Lotes:",
instructionSection1Item5Text: "Admite pegar múltiples enlaces de descarga a la vez (uno por línea) para la adición por lotes.",
instructionSection1Item6Label: "Escanear Archivos:",
instructionSection1Item6Text: "Busca automáticamente todos los archivos en el directorio raíz de almacenamiento de video y carpetas de primer nivel. Esta función es adecuada para sincronizar archivos con el sistema después de que los administradores los depositen manualmente en el backend del servidor.",
instructionSection1Item7Label: "Subir Video:",
instructionSection1Item7Text: "Admite subir archivos de video locales directamente desde el cliente al servidor.",
instructionSection2Title: "2. Gestión de Biblioteca de Video",
instructionSection2Desc: "Mantener y editar recursos de video descargados o importados.",
instructionSection2Sub1: "Eliminación de Colección/Video:",
instructionSection2Text1: "Al eliminar una colección en la página de gestión, el sistema ofrece dos opciones: eliminar solo el elemento de la lista de colección (mantener archivos), o eliminar completamente los archivos físicos dentro de la colección.",
instructionSection2Sub2: "Reparación de Miniatura:",
instructionSection2Text2: "Si un video no tiene portada después de la descarga, haga clic en el botón de actualización en la miniatura del video, y el sistema volverá a capturar el primer fotograma del video como la nueva miniatura.",
instructionSection3Title: "3. Configuración del Sistema",
instructionSection3Desc: "Configurar parámetros del sistema, mantener datos y extender funciones.",
instructionSection3Sub1: "Configuración de Seguridad:",
instructionSection3Text1: "Establezca la contraseña de inicio de sesión del sistema (la contraseña inicial predeterminada es 123, se recomienda cambiar después del primer inicio de sesión).",
instructionSection3Sub2: "Gestión de Etiquetas:",
instructionSection3Text2: "Admite agregar o eliminar etiquetas de clasificación de video. Nota: Debe hacer clic en el botón \"Guardar\" en la parte inferior de la página para que los cambios surtan efecto.",
instructionSection3Sub3: "Mantenimiento del Sistema:",
instructionSection3Item1Label: "Limpiar Archivos Temporales:",
instructionSection3Item1Text: "Se utiliza para borrar archivos de descarga temporales residuales causados por fallas ocasionales del backend para liberar espacio.",
instructionSection3Item2Label: "Migración de Base de Datos:",
instructionSection3Item2Text: "Diseñado para usuarios de versiones anteriores. Use esta función para migrar datos de JSON a la nueva base de datos SQLite. Después de una migración exitosa, haga clic en el botón de eliminar para limpiar los datos históricos antiguos.",
instructionSection3Sub4: "Servicios Extendidos:",
instructionSection3Item3Label: "Nube OpenList:",
instructionSection3Item3Text: "(En Desarrollo) Admite conectar servicios OpenList implementados por el usuario. Agregue configuración aquí para habilitar la integración de la unidad en la nube.",
};

View File

@@ -60,6 +60,8 @@ export const fr = {
scanFiles: "Scanner les fichiers",
scanFilesSuccess: "Scan terminé. {count} nouvelles vidéos ajoutées.",
scanFilesFailed: "Échec du scan",
scanFilesConfirmMessage: "Le système analysera le dossier racine du chemin vidéo pour trouver des fichiers vidéo non documentés.",
scanning: "Analyse en cours...",
migrateConfirmation: "Êtes-vous sûr de vouloir migrer les données ? Cela peut prendre quelques instants.",
migrationResults: "Résultats de la migration",
migrationReport: "Rapport de migration",
@@ -264,4 +266,72 @@ export const fr = {
speed: "Vitesse",
finishedAt: "Terminé à",
failed: "Échoué",
// Batch Download
batchDownload: "Téléchargement par lot",
batchDownloadDescription: "Collez plusieurs URL ci-dessous, une par ligne.",
urls: "URLs",
addToQueue: "Ajouter à la file d'attente",
batchTasksAdded: "{count} tâches ajoutées",
addBatchTasks: "Ajouter des tâches par lot",
// Subscriptions
subscribeToAuthor: "S'abonner à l'auteur",
subscribeConfirmationMessage: "Voulez-vous vous abonner à {author} ?",
subscribeDescription: "Le système vérifiera automatiquement les nouvelles vidéos de cet auteur et les téléchargera.",
checkIntervalMinutes: "Intervalle de vérification (minutes)",
subscribe: "S'abonner",
subscriptions: "Abonnements",
interval: "Intervalle",
lastCheck: "Dernière vérification",
platform: "Plateforme",
unsubscribe: "Se désabonner",
confirmUnsubscribe: "Êtes-vous sûr de vouloir vous désabonner de {author} ?",
subscribedSuccessfully: "Abonné avec succès",
unsubscribedSuccessfully: "Désabonné avec succès",
subscriptionAlreadyExists: "Vous êtes déjà abonné à cet auteur.",
minutes: "minutes",
never: "Jamais",
// Instruction Page
instructionSection1Title: "1. Téléchargement et Gestion des Tâches",
instructionSection1Desc: "Ce module comprend l'acquisition de vidéos, les tâches par lots et les fonctions d'importation de fichiers.",
instructionSection1Sub1: "Téléchargement de Lien :",
instructionSection1Item1Label: "Téléchargement de Base :",
instructionSection1Item1Text: "Collez des liens de divers sites vidéo dans la zone de saisie pour télécharger directement.",
instructionSection1Item2Label: "Permissions :",
instructionSection1Item2Text: "Pour les sites nécessitant une adhésion ou une connexion, veuillez d'abord vous connecter au compte correspondant dans un nouvel onglet du navigateur pour acquérir les permissions de téléchargement.",
instructionSection1Sub2: "Reconnaissance Intelligente :",
instructionSection1Item3Label: "Abonnement Auteur YouTube :",
instructionSection1Item3Text: "Lorsque le lien collé est la chaîne d'un auteur, le système demandera si vous souhaitez vous abonner. Après l'abonnement, le système peut scanner et télécharger automatiquement les mises à jour de l'auteur à des intervalles définis.",
instructionSection1Item4Label: "Téléchargement de Collection Bilibili :",
instructionSection1Item4Text: "Lorsque le lien collé est un favori/collection Bilibili, le système demandera si vous souhaitez télécharger tout le contenu de la collection.",
instructionSection1Sub3: "Outils Avancés (Page de Gestion des Téléchargements) :",
instructionSection1Item5Label: "Ajouter des Tâches par Lots :",
instructionSection1Item5Text: "Prend en charge le collage de plusieurs liens de téléchargement à la fois (un par ligne) pour l'ajout par lots.",
instructionSection1Item6Label: "Scanner les Fichiers :",
instructionSection1Item6Text: "Recherche automatiquement tous les fichiers dans le répertoire racine de stockage vidéo et les dossiers de premier niveau. Cette fonction convient pour synchroniser les fichiers avec le système après que les administrateurs les ont déposés manuellement dans le backend du serveur.",
instructionSection1Item7Label: "Télécharger une Vidéo :",
instructionSection1Item7Text: "Prend en charge le téléchargement de fichiers vidéo locaux directement du client vers le serveur.",
instructionSection2Title: "2. Gestion de la Bibliothèque Vidéo",
instructionSection2Desc: "Maintenir et éditer les ressources vidéo téléchargées ou importées.",
instructionSection2Sub1: "Suppression de Collection/Vidéo :",
instructionSection2Text1: "Lors de la suppression d'une collection sur la page de gestion, le système offre deux options : supprimer uniquement l'élément de la liste de collection (conserver les fichiers), ou supprimer complètement les fichiers physiques dans la collection.",
instructionSection2Sub2: "Réparation de Miniature :",
instructionSection2Text2: "Si une vidéo n'a pas de couverture après le téléchargement, cliquez sur le bouton d'actualisation sur la miniature de la vidéo, et le système recapturera la première image de la vidéo comme nouvelle miniature.",
instructionSection3Title: "3. Paramètres du Système",
instructionSection3Desc: "Configurer les paramètres du système, maintenir les données et étendre les fonctions.",
instructionSection3Sub1: "Paramètres de Sécurité :",
instructionSection3Text1: "Définissez le mot de passe de connexion au système (le mot de passe initial par défaut est 123, il est recommandé de le changer après la première connexion).",
instructionSection3Sub2: "Gestion des Étiquettes :",
instructionSection3Text2: "Prend en charge l'ajout ou la suppression d'étiquettes de classification vidéo. Remarque : Vous devez cliquer sur le bouton \"Enregistrer\" en bas de la page pour que les modifications prennent effet.",
instructionSection3Sub3: "Maintenance du Système :",
instructionSection3Item1Label: "Nettoyer les Fichiers Temporaires :",
instructionSection3Item1Text: "Utilisé pour effacer les fichiers de téléchargement temporaires résiduels causés par des pannes occasionnelles du backend pour libérer de l'espace.",
instructionSection3Item2Label: "Migration de Base de Données :",
instructionSection3Item2Text: "Conçu pour les utilisateurs des premières versions. Utilisez cette fonction pour migrer les données de JSON vers la nouvelle base de données SQLite. Après une migration réussie, cliquez sur le bouton de suppression pour nettoyer les anciennes données historiques.",
instructionSection3Sub4: "Services Étendus :",
instructionSection3Item3Label: "OpenList Cloud Drive :",
instructionSection3Item3Text: "(En Développement) Prend en charge la connexion aux services OpenList déployés par l'utilisateur. Ajoutez la configuration ici pour activer l'intégration du lecteur cloud.",
};

View File

@@ -60,6 +60,8 @@ export const ja = {
scanFiles: "ファイルをスキャン",
scanFilesSuccess: "スキャンが完了しました。{count}個の新しい動画を追加しました。",
scanFilesFailed: "スキャンに失敗しました",
scanFilesConfirmMessage: "システムはビデオパスのルートフォルダをスキャンして、未登録のビデオファイルを検索します。",
scanning: "スキャン中...",
migrateConfirmation: "データを移行してもよろしいですか?これには時間がかかる場合があります。",
migrationResults: "移行結果",
migrationReport: "移行レポート",
@@ -212,6 +214,7 @@ export const ja = {
save: "保存",
on: "オン",
off: "オフ",
continue: "続行",
// Video Card
unknownDate: "不明な日付",
@@ -265,4 +268,72 @@ export const ja = {
speed: "速度",
finishedAt: "完了日時",
failed: "失敗",
// Batch Download
batchDownload: "一括ダウンロード",
batchDownloadDescription: "以下に複数のURLを1行に1つずつ貼り付けてください。",
urls: "URL",
addToQueue: "キューに追加",
batchTasksAdded: "{count} 件のタスクを追加しました",
addBatchTasks: "一括タスクを追加",
// Subscriptions
subscribeToAuthor: "著者を購読する",
subscribeConfirmationMessage: "{author} を購読しますか?",
subscribeDescription: "システムはこの著者の新しい動画を自動的にチェックしてダウンロードします。",
checkIntervalMinutes: "チェック間隔(分)",
subscribe: "購読",
subscriptions: "購読",
interval: "間隔",
lastCheck: "前回のチェック",
platform: "プラットフォーム",
unsubscribe: "購読解除",
confirmUnsubscribe: "{author} の購読を解除してもよろしいですか?",
subscribedSuccessfully: "購読しました",
unsubscribedSuccessfully: "購読を解除しました",
subscriptionAlreadyExists: "この著者はすでに購読しています。",
minutes: "分",
never: "なし",
// Instruction Page
instructionSection1Title: "1. ダウンロードとタスク管理",
instructionSection1Desc: "このモジュールには、ビデオ取得、バッチタスク、およびファイルインポート機能が含まれています。",
instructionSection1Sub1: "リンクダウンロード:",
instructionSection1Item1Label: "基本ダウンロード:",
instructionSection1Item1Text: "さまざまなビデオサイトのリンクを入力ボックスに貼り付けて直接ダウンロードします。",
instructionSection1Item2Label: "権限:",
instructionSection1Item2Text: "メンバーシップまたはログインが必要なサイトの場合、ダウンロード権限を取得するために、まず新しいブラウザタブで対応するアカウントにログインしてください。",
instructionSection1Sub2: "スマート認識:",
instructionSection1Item3Label: "YouTube 著者登録:",
instructionSection1Item3Text: "貼り付けられたリンクが著者のチャンネルである場合、システムは登録するかどうかを尋ねます。登録後、システムは設定された間隔で著者の更新を自動的にスキャンしてダウンロードできます。",
instructionSection1Item4Label: "Bilibili コレクションダウンロード:",
instructionSection1Item4Text: "貼り付けられたリンクが Bilibili のお気に入り/コレクションである場合、システムはコレクションの内容全体をダウンロードするかどうかを尋ねます。",
instructionSection1Sub3: "高度なツール(ダウンロード管理ページ):",
instructionSection1Item5Label: "バッチタスクの追加:",
instructionSection1Item5Text: "バッチ追加のために、複数のダウンロードリンクを一度に1行に1つ貼り付けることをサポートします。",
instructionSection1Item6Label: "ファイルのスキャン:",
instructionSection1Item6Text: "ビデオストレージのルートディレクトリと第1レベルのフォルダ内のすべてのファイルを自動的に検索します。この機能は、管理者がサーバーバックエンドに手動でファイルを配置した後、システムにファイルを同期するのに適しています。",
instructionSection1Item7Label: "ビデオのアップロード:",
instructionSection1Item7Text: "クライアントからサーバーにローカルビデオファイルを直接アップロードすることをサポートします。",
instructionSection2Title: "2. ビデオライブラリ管理",
instructionSection2Desc: "ダウンロードまたはインポートされたビデオリソースを維持および編集します。",
instructionSection2Sub1: "コレクション/ビデオの削除:",
instructionSection2Text1: "管理ページでコレクションを削除する場合、システムには2つのオプションがあります。コレクションリスト項目のみを削除するファイルを保持するか、コレクション内の物理ファイルを完全に削除するかです。",
instructionSection2Sub2: "サムネイルの修復:",
instructionSection2Text2: "ダウンロード後にビデオにカバーがない場合は、ビデオサムネイルの更新ボタンをクリックすると、システムはビデオの最初のフレームを新しいサムネイルとして再キャプチャします。",
instructionSection3Title: "3. システム設定",
instructionSection3Desc: "システムパラメータの構成、データの維持、および機能の拡張。",
instructionSection3Sub1: "セキュリティ設定:",
instructionSection3Text1: "システムログインパスワードを設定しますデフォルトの初期パスワードは123です。初回ログイン後に変更することをお勧めします。",
instructionSection3Sub2: "タグ管理:",
instructionSection3Text2: "ビデオ分類タグの追加または削除をサポートします。注:変更を有効にするには、ページ下部の「保存」ボタンをクリックする必要があります。",
instructionSection3Sub3: "システムメンテナンス:",
instructionSection3Item1Label: "一時ファイルのクリーンアップ:",
instructionSection3Item1Text: "スペースを解放するために、時折発生するバックエンドの障害によって引き起こされる残留一時ダウンロードファイルをクリアするために使用されます。",
instructionSection3Item2Label: "データベース移行:",
instructionSection3Item2Text: "初期バージョンのユーザー向けに設計されています。この機能を使用して、JSON から新しい SQLite データベースにデータを移行します。移行が成功したら、削除ボタンをクリックして古い履歴データをクリーンアップします。",
instructionSection3Sub4: "拡張サービス:",
instructionSection3Item3Label: "OpenList クラウドドライブ:",
instructionSection3Item3Text: "(開発中)ユーザーがデプロイした OpenList サービスへの接続をサポートします。ここで構成を追加して、クラウドドライブ統合を有効にします。",
};

View File

@@ -60,6 +60,8 @@ export const ko = {
scanFiles: "파일 스캔",
scanFilesSuccess: "스캔 완료. {count}개의 새 동영상이 추가되었습니다.",
scanFilesFailed: "스캔 실패",
scanFilesConfirmMessage: "시스템이 비디오 경로의 루트 폴더를 스캔하여 문서화되지 않은 비디오 파일을 찾습니다.",
scanning: "스캔 중...",
migrateConfirmation: "데이터를 마이그레이션하시겠습니까? 잠시 시간이 걸릴 수 있습니다.",
migrationResults: "마이그레이션 결과",
migrationReport: "마이그레이션 보고서",
@@ -212,6 +214,7 @@ export const ko = {
save: "저장",
on: "켜기",
off: "끄기",
continue: "계속",
// Video Card
unknownDate: "알 수 없는 날짜",
@@ -265,4 +268,72 @@ export const ko = {
speed: "속도",
finishedAt: "완료 시간",
failed: "실패",
// Batch Download
batchDownload: "일괄 다운로드",
batchDownloadDescription: "아래에 여러 URL을 한 줄에 하나씩 붙여넣으세요.",
urls: "URL",
addToQueue: "대기열에 추가",
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
addBatchTasks: "일괄 작업 추가",
// Subscriptions
subscribeToAuthor: "작가 구독",
subscribeConfirmationMessage: "{author}님을 구독하시겠습니까?",
subscribeDescription: "시스템이 자동으로 이 작가의 새 동영상을 확인하고 다운로드합니다.",
checkIntervalMinutes: "확인 간격 (분)",
subscribe: "구독",
subscriptions: "구독",
interval: "간격",
lastCheck: "마지막 확인",
platform: "플랫폼",
unsubscribe: "구독 취소",
confirmUnsubscribe: "{author}님의 구독을 취소하시겠습니까?",
subscribedSuccessfully: "구독 성공",
unsubscribedSuccessfully: "구독 취소 성공",
subscriptionAlreadyExists: "이미 구독 중인 작가입니다.",
minutes: "분",
never: "없음",
// Instruction Page
instructionSection1Title: "1. 다운로드 및 작업 관리",
instructionSection1Desc: "이 모듈에는 비디오 획득, 일괄 작업 및 파일 가져오기 기능이 포함되어 있습니다.",
instructionSection1Sub1: "링크 다운로드:",
instructionSection1Item1Label: "기본 다운로드:",
instructionSection1Item1Text: "다양한 비디오 사이트의 링크를 입력 상자에 붙여넣어 직접 다운로드하십시오.",
instructionSection1Item2Label: "권한:",
instructionSection1Item2Text: "멤버십 또는 로그인이 필요한 사이트의 경우, 다운로드 권한을 얻으려면 먼저 새 브라우저 탭에서 해당 계정에 로그인하십시오.",
instructionSection1Sub2: "스마트 인식:",
instructionSection1Item3Label: "YouTube 작성자 구독:",
instructionSection1Item3Text: "붙여넣은 링크가 작성자의 채널인 경우 시스템에서 구독 여부를 묻습니다. 구독 후 시스템은 설정된 간격으로 작성자의 업데이트를 자동으로 스캔하고 다운로드할 수 있습니다.",
instructionSection1Item4Label: "Bilibili 컬렉션 다운로드:",
instructionSection1Item4Text: "붙여넣은 링크가 Bilibili 즐겨찾기/컬렉션인 경우 시스템에서 전체 컬렉션 콘텐츠를 다운로드할지 묻습니다.",
instructionSection1Sub3: "고급 도구 (다운로드 관리 페이지):",
instructionSection1Item5Label: "일괄 작업 추가:",
instructionSection1Item5Text: "일괄 추가를 위해 한 번에 여러 다운로드 링크(한 줄에 하나씩)를 붙여넣는 것을 지원합니다.",
instructionSection1Item6Label: "파일 스캔:",
instructionSection1Item6Text: "비디오 저장소 루트 디렉터리 및 1단계 폴더의 모든 파일을 자동으로 검색합니다. 이 기능은 관리자가 서버 백엔드에 수동으로 파일을 입금한 후 시스템에 파일을 동기화하는 데 적합합니다.",
instructionSection1Item7Label: "비디오 업로드:",
instructionSection1Item7Text: "클라이언트에서 서버로 로컬 비디오 파일을 직접 업로드하는 것을 지원합니다.",
instructionSection2Title: "2. 비디오 라이브러리 관리",
instructionSection2Desc: "다운로드하거나 가져온 비디오 리소스를 유지 관리하고 편집합니다.",
instructionSection2Sub1: "컬렉션/비디오 삭제:",
instructionSection2Text1: "관리 페이지에서 컬렉션을 삭제할 때 시스템은 두 가지 옵션을 제공합니다. 컬렉션 목록 항목만 삭제(파일 유지)하거나 컬렉션 내의 물리적 파일을 완전히 삭제하는 것입니다.",
instructionSection2Sub2: "썸네일 복구:",
instructionSection2Text2: "다운로드 후 비디오에 표지가 없는 경우 비디오 썸네일의 새로 고침 버튼을 클릭하면 시스템이 비디오의 첫 번째 프레임을 새 썸네일로 다시 캡처합니다.",
instructionSection3Title: "3. 시스템 설정",
instructionSection3Desc: "시스템 매개변수 구성, 데이터 유지 관리 및 기능 확장.",
instructionSection3Sub1: "보안 설정:",
instructionSection3Text1: "시스템 로그인 비밀번호를 설정하십시오(기본 초기 비밀번호는 123이며, 첫 로그인 후 변경하는 것이 좋습니다).",
instructionSection3Sub2: "태그 관리:",
instructionSection3Text2: "비디오 분류 태그 추가 또는 삭제를 지원합니다. 참고: 변경 사항을 적용하려면 페이지 하단의 \"저장\" 버튼을 클릭해야 합니다.",
instructionSection3Sub3: "시스템 유지 관리:",
instructionSection3Item1Label: "임시 파일 정리:",
instructionSection3Item1Text: "공간을 확보하기 위해 가끔 발생하는 백엔드 오류로 인한 잔여 임시 다운로드 파일을 지우는 데 사용됩니다.",
instructionSection3Item2Label: "데이터베이스 마이그레이션:",
instructionSection3Item2Text: "초기 버전 사용자를 위해 설계되었습니다. 이 기능을 사용하여 JSON에서 새 SQLite 데이터베이스로 데이터를 마이그레이션하십시오. 마이그레이션이 성공하면 삭제 버튼을 클릭하여 오래된 기록 데이터를 정리하십시오.",
instructionSection3Sub4: "확장 서비스:",
instructionSection3Item3Label: "OpenList 클라우드 드라이브:",
instructionSection3Item3Text: "(개발 중) 사용자 배포 OpenList 서비스 연결을 지원합니다. 클라우드 드라이브 통합을 활성화하려면 여기에 구성을 추가하십시오.",
};

View File

@@ -59,7 +59,9 @@ export const pt = {
migrateDataButton: "Migrar Dados do JSON",
scanFiles: "Escanear Arquivos",
scanFilesSuccess: "Escaneamento completo. {count} novos vídeos adicionados.",
scanFilesFailed: "Falha no escaneamento",
scanFilesFailed: "A verificação falhou",
scanFilesConfirmMessage: "O sistema verificará a pasta raiz do caminho do vídeo para encontrar arquivos de vídeo não documentados.",
scanning: "Verificando...",
migrateConfirmation: "Tem certeza de que deseja migrar os dados? Isso pode levar alguns instantes.",
migrationResults: "Resultados da Migração",
migrationReport: "Relatório de Migração",
@@ -211,6 +213,7 @@ export const pt = {
save: "Salvar",
on: "Ligado",
off: "Desligado",
continue: "Continuar",
// Video Card
unknownDate: "Data desconhecida",
@@ -264,4 +267,72 @@ export const pt = {
speed: "Velocidade",
finishedAt: "Terminado em",
failed: "Falhou",
// Batch Download
batchDownload: "Download em lote",
batchDownloadDescription: "Cole vários URLs abaixo, um por linha.",
urls: "URLs",
addToQueue: "Adicionar à fila",
batchTasksAdded: "{count} tarefas adicionadas",
addBatchTasks: "Adicionar tarefas em lote",
// Subscriptions
subscribeToAuthor: "Inscrever-se no autor",
subscribeConfirmationMessage: "Deseja se inscrever em {author}?",
subscribeDescription: "O sistema verificará automaticamente novos vídeos deste autor e os baixará.",
checkIntervalMinutes: "Intervalo de verificação (minutos)",
subscribe: "Inscrever-se",
subscriptions: "Inscrições",
interval: "Intervalo",
lastCheck: "Última verificação",
platform: "Plataforma",
unsubscribe: "Cancelar inscrição",
confirmUnsubscribe: "Tem certeza de que deseja cancelar a inscrição de {author}?",
subscribedSuccessfully: "Inscrito com sucesso",
unsubscribedSuccessfully: "Inscrição cancelada com sucesso",
subscriptionAlreadyExists: "Você já está inscrito neste autor.",
minutes: "minutos",
never: "Nunca",
// Instruction Page
instructionSection1Title: "1. Download e Gerenciamento de Tarefas",
instructionSection1Desc: "Este módulo inclui aquisição de vídeo, tarefas em lote e funções de importação de arquivos.",
instructionSection1Sub1: "Download de Link:",
instructionSection1Item1Label: "Download Básico:",
instructionSection1Item1Text: "Cole links de vários sites de vídeo na caixa de entrada para baixar diretamente.",
instructionSection1Item2Label: "Permissões:",
instructionSection1Item2Text: "Para sites que exigem associação ou login, faça login na conta correspondente em uma nova guia do navegador primeiro para adquirir permissões de download.",
instructionSection1Sub2: "Reconhecimento Inteligente:",
instructionSection1Item3Label: "Assinatura de Autor do YouTube:",
instructionSection1Item3Text: "Quando o link colado for o canal de um autor, o sistema perguntará se você deseja se inscrever. Após a inscrição, o sistema pode verificar e baixar automaticamente as atualizações do autor em intervalos definidos.",
instructionSection1Item4Label: "Download de Coleção Bilibili:",
instructionSection1Item4Text: "Quando o link colado for um favorito/coleção Bilibili, o sistema perguntará se você deseja baixar todo o conteúdo da coleção.",
instructionSection1Sub3: "Ferramentas Avançadas (Página de Gerenciamento de Download):",
instructionSection1Item5Label: "Adicionar Tarefas em Lote:",
instructionSection1Item5Text: "Suporta colar vários links de download de uma vez (um por linha) para adição em lote.",
instructionSection1Item6Label: "Verificar Arquivos:",
instructionSection1Item6Text: "Pesquisa automaticamente todos os arquivos no diretório raiz de armazenamento de vídeo e pastas de primeiro nível. Esta função é adequada para sincronizar arquivos com o sistema depois que os administradores os depositam manualmente no backend do servidor.",
instructionSection1Item7Label: "Enviar Vídeo:",
instructionSection1Item7Text: "Suporta o envio de arquivos de vídeo locais diretamente do cliente para o servidor.",
instructionSection2Title: "2. Gerenciamento da Biblioteca de Vídeo",
instructionSection2Desc: "Manter e editar recursos de vídeo baixados ou importados.",
instructionSection2Sub1: "Exclusão de Coleção/Vídeo:",
instructionSection2Text1: "Ao excluir uma coleção na página de gerenciamento, o sistema oferece duas opções: excluir apenas o item da lista de coleção (manter arquivos) ou excluir completamente os arquivos físicos dentro da coleção.",
instructionSection2Sub2: "Reparo de Miniatura:",
instructionSection2Text2: "Se um vídeo não tiver capa após o download, clique no botão de atualização na miniatura do vídeo e o sistema recapturará o primeiro quadro do vídeo como a nova miniatura.",
instructionSection3Title: "3. Configurações do Sistema",
instructionSection3Desc: "Configurar parâmetros do sistema, manter dados e estender funções.",
instructionSection3Sub1: "Configurações de Segurança:",
instructionSection3Text1: "Defina a senha de login do sistema (a senha inicial padrão é 123, recomenda-se alterar após o primeiro login).",
instructionSection3Sub2: "Gerenciamento de Tags:",
instructionSection3Text2: "Suporta adicionar ou excluir tags de classificação de vídeo. Nota: Você deve clicar no botão \"Salvar\" na parte inferior da página para que as alterações entrem em vigor.",
instructionSection3Sub3: "Manutenção do Sistema:",
instructionSection3Item1Label: "Limpar Arquivos Temporários:",
instructionSection3Item1Text: "Usado para limpar arquivos de download temporários residuais causados por falhas ocasionais de backend para liberar espaço.",
instructionSection3Item2Label: "Migração de Banco de Dados:",
instructionSection3Item2Text: "Projetado para usuários de versões anteriores. Use esta função para migrar dados de JSON para o novo banco de dados SQLite. Após a migração bem-sucedida, clique no botão excluir para limpar dados históricos antigos.",
instructionSection3Sub4: "Serviços Estendidos:",
instructionSection3Item3Label: "OpenList Cloud Drive:",
instructionSection3Item3Text: "(Em Desenvolvimento) Suporta conexão com serviços OpenList implantados pelo usuário. Adicione a configuração aqui para habilitar a integração da unidade de nuvem.",
};

View File

@@ -59,7 +59,9 @@ export const ru = {
migrateDataButton: "Перенести данные из JSON",
scanFiles: "Сканировать файлы",
scanFilesSuccess: "Сканирование завершено. Добавлено {count} новых видео.",
scanFilesFailed: "Ошибка сканирования",
scanFilesFailed: "Сканирование не удалось",
scanFilesConfirmMessage: "Система просканирует корневую папку с видео, чтобы найти недовкументированные видеофайлы.",
scanning: "Сканирование...",
migrateConfirmation: "Вы уверены, что хотите перенести данные? Это может занять некоторое время.",
migrationResults: "Результаты миграции",
migrationReport: "Отчет о миграции",
@@ -211,7 +213,8 @@ export const ru = {
confirm: "Подтвердить",
save: "Сохранить",
on: "Вкл.",
off: "Выкл.",
off: "Выкл",
continue: "Продолжить",
// Video Card
unknownDate: "Неизвестная дата",
@@ -265,4 +268,72 @@ export const ru = {
speed: "Скорость",
finishedAt: "Завершено в",
failed: "Ошибка",
// Batch Download
batchDownload: "Пакетная загрузка",
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
urls: "URL",
addToQueue: "Добавить в очередь",
batchTasksAdded: "Добавлено задач: {count}",
addBatchTasks: "Добавить пакетные задачи",
// Subscriptions
subscribeToAuthor: "Подписаться на автора",
subscribeConfirmationMessage: "Вы хотите подписаться на {author}?",
subscribeDescription: "Система будет автоматически проверять новые видео от этого автора и скачивать их.",
checkIntervalMinutes: "Интервал проверки (минуты)",
subscribe: "Подписаться",
subscriptions: "Подписки",
interval: "Интервал",
lastCheck: "Последняя проверка",
platform: "Платформа",
unsubscribe: "Отписаться",
confirmUnsubscribe: "Вы уверены, что хотите отписаться от {author}?",
subscribedSuccessfully: "Успешно подписаны",
unsubscribedSuccessfully: "Успешно отписаны",
subscriptionAlreadyExists: "Вы уже подписаны на этого автора.",
minutes: "минуты",
never: "Никогда",
// Instruction Page
instructionSection1Title: "1. Загрузка и управление задачами",
instructionSection1Desc: "Этот модуль включает функции получения видео, пакетных задач и импорта файлов.",
instructionSection1Sub1: "Загрузка по ссылке:",
instructionSection1Item1Label: "Базовая загрузка:",
instructionSection1Item1Text: "Вставьте ссылки с различных видеосайтов в поле ввода для прямой загрузки.",
instructionSection1Item2Label: "Разрешения:",
instructionSection1Item2Text: "Для сайтов, требующих членства или входа в систему, пожалуйста, сначала войдите в соответствующую учетную запись на новой вкладке браузера, чтобы получить разрешения на загрузку.",
instructionSection1Sub2: "Умное распознавание:",
instructionSection1Item3Label: "Подписка на автора YouTube:",
instructionSection1Item3Text: "Когда вставленная ссылка является каналом автора, система спросит, хотите ли вы подписаться. После подписки система может автоматически сканировать и загружать обновления автора через заданные интервалы.",
instructionSection1Item4Label: "Загрузка коллекции Bilibili:",
instructionSection1Item4Text: "Когда вставленная ссылка является избранным/коллекцией Bilibili, система спросит, хотите ли вы загрузить все содержимое коллекции.",
instructionSection1Sub3: "Расширенные инструменты (Страница управления загрузками):",
instructionSection1Item5Label: "Пакетное добавление задач:",
instructionSection1Item5Text: "Поддерживает вставку нескольких ссылок для загрузки одновременно (по одной в строке) для пакетного добавления.",
instructionSection1Item6Label: "Сканировать файлы:",
instructionSection1Item6Text: "Автоматически ищет все файлы в корневом каталоге хранения видео и папках первого уровня. Эта функция подходит для синхронизации файлов с системой после того, как администраторы вручную поместили их на сервер.",
instructionSection1Item7Label: "Загрузить видео:",
instructionSection1Item7Text: "Поддерживает загрузку локальных видеофайлов непосредственно с клиента на сервер.",
instructionSection2Title: "2. Управление видеотекой",
instructionSection2Desc: "Обслуживание и редактирование загруженных или импортированных видеоресурсов.",
instructionSection2Sub1: "Удаление коллекции/видео:",
instructionSection2Text1: "При удалении коллекции на странице управления система предлагает два варианта: удалить только элемент списка коллекции (сохранить файлы) или полностью удалить физические файлы внутри коллекции.",
instructionSection2Sub2: "Восстановление миниатюры:",
instructionSection2Text2: "Если у видео нет обложки после загрузки, нажмите кнопку обновления на миниатюре видео, и система повторно захватит первый кадр видео в качестве новой миниатюры.",
instructionSection3Title: "3. Настройки системы",
instructionSection3Desc: "Настройка параметров системы, обслуживание данных и расширение функций.",
instructionSection3Sub1: "Настройки безопасности:",
instructionSection3Text1: "Установите пароль для входа в систему (начальный пароль по умолчанию — 123, рекомендуется изменить после первого входа).",
instructionSection3Sub2: "Управление тегами:",
instructionSection3Text2: "Поддерживает добавление или удаление тегов классификации видео. Примечание: Вы должны нажать кнопку «Сохранить» внизу страницы, чтобы изменения вступили в силу.",
instructionSection3Sub3: "Обслуживание системы:",
instructionSection3Item1Label: "Очистить временные файлы:",
instructionSection3Item1Text: "Используется для очистки остаточных временных файлов загрузки, вызванных случайными сбоями бэкенда, для освобождения места.",
instructionSection3Item2Label: "Миграция базы данных:",
instructionSection3Item2Text: "Предназначено для пользователей ранних версий. Используйте эту функцию для миграции данных из JSON в новую базу данных SQLite. После успешной миграции нажмите кнопку удаления, чтобы очистить старые исторические данные.",
instructionSection3Sub4: "Расширенные сервисы:",
instructionSection3Item3Label: "Облачный диск OpenList:",
instructionSection3Item3Text: "(В разработке) Поддерживает подключение к развернутым пользователем сервисам OpenList. Добавьте конфигурацию здесь, чтобы включить интеграцию с облачным диском.",
};

View File

@@ -9,6 +9,7 @@ export const zh = {
uploadVideo: "上传视频",
enterUrlOrSearchTerm: "输入 YouTube/Bilibili 链接或搜索关键词",
manageVideos: "管理视频",
instruction: "使用说明",
// Home
pasteUrl: "粘贴视频或合集链接",
@@ -60,7 +61,9 @@ export const zh = {
scanFiles: "扫描文件",
scanFilesSuccess: "扫描完成。添加了 {count} 个新视频。",
scanFilesFailed: "扫描失败",
migrateConfirmation: "确定要迁移数据吗?这可能需要一些时间。",
scanFilesConfirmMessage: "系统将扫描视频路径的根文件夹以查找未记录的视频文件。",
scanning: "扫描中...",
migrateConfirmation: "您确定要迁移数据吗?这可能需要一些时间。",
migrationResults: "迁移结果",
migrationReport: "迁移报告",
migrationSuccess: "迁移完成。请查看警报中的详细信息。",
@@ -211,7 +214,8 @@ export const zh = {
confirm: "确认",
save: "保存",
on: "开启",
off: "关",
off: "关",
continue: "继续",
// Video Card
unknownDate: "未知日期",
@@ -266,4 +270,72 @@ export const zh = {
speed: "速度",
finishedAt: "完成时间",
failed: "失败",
// Batch Download
batchDownload: "批量下载",
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
urls: "链接",
addToQueue: "添加到队列",
batchTasksAdded: "已添加 {count} 个任务",
addBatchTasks: "添加批量任务",
// Subscriptions
subscribeToAuthor: "订阅作者",
subscribeConfirmationMessage: "您确定要订阅 {author} 吗?",
subscribeDescription: "系统将自动检查此作者的新视频并下载。",
checkIntervalMinutes: "检查间隔(分钟)",
subscribe: "订阅",
subscriptions: "订阅",
interval: "间隔",
lastCheck: "上次检查",
platform: "平台",
unsubscribe: "取消订阅",
confirmUnsubscribe: "您确定要取消订阅 {author} 吗?",
subscribedSuccessfully: "订阅成功",
unsubscribedSuccessfully: "取消订阅成功",
subscriptionAlreadyExists: "您已订阅此作者。",
minutes: "分钟",
never: "从未",
// Instruction Page
instructionSection1Title: "1. 下载与任务管理",
instructionSection1Desc: "本模块包含视频获取、批量任务及文件导入等功能。",
instructionSection1Sub1: "链接下载:",
instructionSection1Item1Label: "基础下载:",
instructionSection1Item1Text: "在链接文本框中粘贴各类视频网站的链接即可直接下载。",
instructionSection1Item2Label: "权限说明:",
instructionSection1Item2Text: "部分需要会员或登录才能观看的网站,请先在浏览器内另开标签页登录对应账号,以获取下载权限。",
instructionSection1Sub2: "智能识别:",
instructionSection1Item3Label: "YouTube 作者订阅:",
instructionSection1Item3Text: "当粘贴链接为作者个人空间时,系统将询问是否订阅。订阅后,系统可设定时间间隔,自动扫描并下载该作者的更新。",
instructionSection1Item4Label: "Bilibili 合集下载:",
instructionSection1Item4Text: "当粘贴链接为 Bilibili 收藏夹/合集时,系统将询问是否下载整个合集内容。",
instructionSection1Sub3: "高级工具(下载管理页):",
instructionSection1Item5Label: "批量添加任务:",
instructionSection1Item5Text: "支持一次性粘贴多个下载链接(请按行区分),进行批量添加。",
instructionSection1Item6Label: "扫描文件:",
instructionSection1Item6Text: "自动搜索视频储存根目录及一级文件夹下的所有文件。此功能适用于管理员在服务器后台直接存入文件后,将其批量同步至系统。",
instructionSection1Item7Label: "上传视频:",
instructionSection1Item7Text: "支持直接从客户端单独上传本地视频文件到服务器。",
instructionSection2Title: "2. 视频库管理",
instructionSection2Desc: "对已下载或导入的视频资源进行维护和编辑。",
instructionSection2Sub1: "合集/视频删除:",
instructionSection2Text1: "在管理页面删除合集时,系统提供两种选择:仅删除合集列表项(保留文件),或连同合集内的物理文件一并彻底删除。",
instructionSection2Sub2: "缩略图修复:",
instructionSection2Text2: "若遇到下载后视频无封面的情况,可点击视频缩略图上的刷新按钮,系统将重新抓取视频首帧作为新的缩略图。",
instructionSection3Title: "3. 系统设置",
instructionSection3Desc: "配置系统参数、维护数据及扩展功能。",
instructionSection3Sub1: "安全设定:",
instructionSection3Text1: "设置系统登录密码(默认初始密码为 123建议首次登录后修改。",
instructionSection3Sub2: "标签管理:",
instructionSection3Text2: "支持添加或删除视频分类标签。注意: 所有操作完成后,必须点击页面底端的“保存”按钮方可生效。",
instructionSection3Sub3: "系统维护:",
instructionSection3Item1Label: "清理临时文件:",
instructionSection3Item1Text: "用于清除因后端偶发故障而残留的临时下载文件,释放空间。",
instructionSection3Item2Label: "数据库迁移:",
instructionSection3Item2Text: "专为早期版本用户设计。使用此功能可将数据从 JSON 迁移至新的 SQLite 数据库。迁移成功后,可点击删除按钮清理旧的历史数据。",
instructionSection3Sub4: "扩展服务:",
instructionSection3Item3Label: "OpenList 云盘:",
instructionSection3Item3Text: "(开发中)支持连接用户自行部署的 OpenList 服务,在此处添加配置后可实现云盘联动。",
};

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mytube",
"version": "1.3.1",
"version": "1.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.3.1",
"version": "1.3.5",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"

View File

@@ -1,6 +1,6 @@
{
"name": "mytube",
"version": "1.3.2",
"version": "1.3.7",
"description": "YouTube video downloader and player application",
"main": "index.js",
"scripts": {