54 Commits

Author SHA1 Message Date
Peifan Li
d0d60691d2 chore(release): v1.3.12 2025-12-03 21:15:54 -05:00
Peifan Li
a370f93ba0 feat: Improve cookie handling and server initialization 2025-12-03 21:15:49 -05:00
Peifan Li
fb9498410b fix: Update versions to 1.3.11 in package-lock.json 2025-12-03 16:55:20 -05:00
Peifan Li
a60da5a1c5 chore(release): v1.3.11 2025-12-03 16:53:17 -05:00
Peifan Li
c7ea7b15e3 refactor: Add columns to database tables 2025-12-03 16:52:54 -05:00
Peifan Li
06478cffb5 feat: Add cookie upload functionality 2025-12-03 16:25:09 -05:00
Peifan Li
d6d6824b5f style: Update video preview image link in README files 2025-12-02 23:07:40 -05:00
Peifan Li
fec1d6c180 fix: Update package versions to 1.3.10 2025-12-02 23:01:13 -05:00
Peifan Li
ce21fab280 chore(release): v1.3.10 2025-12-02 22:59:16 -05:00
Peifan Li
e96b4e47b4 feat: Add logic to organize videos into collections 2025-12-02 22:59:10 -05:00
Peifan Li
10d6933cbd docs: Update deployment instructions in README 2025-12-02 21:31:51 -05:00
Peifan Li
eed24589d4 feat: Add documentation for API endpoints and directory structure 2025-12-02 20:36:08 -05:00
Peifan Li
63914a70a0 fix: Update package versions to 1.3.9 in lock files 2025-12-02 20:07:20 -05:00
Peifan Li
81dc0b08a5 chore(release): v1.3.9 2025-12-02 16:06:38 -05:00
Peifan Li
a6920ef4c1 feat: Add subtitles support and rescan for existing subtitles 2025-12-02 15:29:51 -05:00
Peifan Li
12858c503d fix: Update backend and frontend package versions to 1.3.8 2025-12-02 13:35:46 -05:00
Peifan Li
b74b6578af chore(release): v1.3.8 2025-12-02 13:33:05 -05:00
Peifan Li
75b6f89066 refactor: Update download history logic to exclude cancelled tasks 2025-12-02 13:33:00 -05:00
Peifan Li
0cf2947c23 fix: Update route path for collection in App component 2025-12-02 13:27:39 -05:00
Peifan Li
9c48b5c007 fix: Update backend and frontend versions to 1.3.7 2025-12-02 13:18:48 -05:00
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
Peifan Li
88e452fc61 chore(release): v1.3.2 2025-11-30 17:07:22 -05:00
Peifan Li
cffe2319c2 feat: Add Cloud Storage Service and settings for OpenList 2025-11-30 17:07:10 -05:00
Peifan Li
19383ad582 fix: Update package versions to 1.3.1 2025-11-29 10:55:20 -05:00
Peifan Li
c2d6215b44 chore(release): v1.3.1 2025-11-29 10:52:04 -05:00
Peifan Li
f2b5af0912 refactor: Remove unnecessary youtubedl call arguments 2025-11-29 10:52:00 -05:00
Peifan Li
56557da2cf feat: Update versions and add support for more sites 2025-11-28 21:05:18 -05:00
Peifan Li
1d45692374 chore(release): v1.3.0 2025-11-28 20:50:17 -05:00
Peifan Li
fc070da102 refactor: Update YouTubeDownloader to YtDlpDownloader 2025-11-28 20:50:04 -05:00
Peifan Li
d1ceef9698 fix: Update backend and frontend package versions to 1.2.5 2025-11-27 20:57:31 -05:00
Peifan Li
bc9564f9bc chore(release): v1.2.5 2025-11-27 20:54:46 -05:00
Peifan Li
710e85ad5e style: Improve speed calculation and add version in footer 2025-11-27 20:54:44 -05:00
Peifan Li
bc3ab6f9ef fix: Update package versions to 1.2.4 2025-11-27 18:02:25 -05:00
Peifan Li
85d900f5f7 chore(release): v1.2.4 2025-11-27 18:00:22 -05:00
Peifan Li
6621be19fc feat: Add support for multilingual snackbar messages 2025-11-27 18:00:11 -05:00
Peifan Li
10d5423c99 fix: Update package versions to 1.2.3 2025-11-27 15:15:46 -05:00
80 changed files with 5976 additions and 1438 deletions

1
.gitignore vendored
View File

@@ -55,3 +55,4 @@ backend/data/*.db
backend/data/*.db-journal
backend/data/status.json
backend/data/settings.json
backend/data/cookies.txt

View File

@@ -1,182 +0,0 @@
# Deployment Guide for MyTube
This guide explains how to deploy MyTube to a server or QNAP Container Station.
## Prerequisites
- Docker Hub account
- Server with Docker and Docker Compose installed, or QNAP NAS with Container Station installed
- Docker installed on your development machine
## Docker Images
The application is containerized into two Docker images:
1. Frontend: `franklioxygen/mytube:frontend-latest`
2. Backend: `franklioxygen/mytube:backend-latest`
## Deployment Process
### 1. Build and Push Docker Images
You can customize the build configuration by setting environment variables before running the build script:
```bash
# Optional: Set custom API URLs for the build (defaults to localhost if not set)
export VITE_API_URL="http://your-build-server:5551/api"
export VITE_BACKEND_URL="http://your-build-server:5551"
# Make the script executable
chmod +x build-and-push.sh
# Run the script
./build-and-push.sh
```
The script will:
- Build the backend and frontend Docker images optimized for amd64 architecture
- Apply the specified environment variables during build time (or use localhost defaults)
- Push the images to Docker Hub under your account (franklioxygen)
### 2. Deploy on Server or QNAP Container Station
#### For Standard Docker Environment:
By default, the docker-compose.yml is configured to use Docker's service discovery for container communication:
```bash
docker-compose up -d
```
#### For QNAP Container Station or Environments with Networking Limitations:
If you're deploying to QNAP or another environment where container-to-container communication via service names doesn't work properly, you'll need to specify the host IP:
1. Create a `.env` file with your server's IP:
```
API_HOST=your-server-ip
API_PORT=5551
```
2. Place this file in the same directory as your docker-compose.yml
3. Deploy using Container Station or docker-compose:
```bash
docker-compose up -d
```
#### Volume Paths on QNAP
The docker-compose file is configured to use the following specific paths on your QNAP:
```yaml
volumes:
- /share/CACHEDEV2_DATA/Medias/MyTube/uploads:/app/uploads
- /share/CACHEDEV2_DATA/Medias/MyTube/data:/app/data
```
Ensure these directories exist on your server or QNAP before deployment. If they don't exist, create them:
```bash
mkdir -p /share/CACHEDEV2_DATA/Medias/MyTube/uploads
mkdir -p /share/CACHEDEV2_DATA/Medias/MyTube/data
```
If deploying to a different server (not QNAP), you may want to modify these paths in the docker-compose.yml file.
### 3. Access the Application
Once deployed:
- Frontend will be accessible at: http://your-server-ip:5556
- Backend API will be accessible at: http://your-server-ip:5551/api
## Docker Networking and Environment Variables
### Container Networking Options
The application provides two ways for containers to communicate with each other:
#### 1. Docker Service Discovery (Default)
In standard Docker environments, containers can communicate using service names. This is the default configuration:
```yaml
environment:
- VITE_API_URL=http://backend:5551/api
- VITE_BACKEND_URL=http://backend:5551
```
This works for most Docker environments including Docker Desktop, Docker Engine on Linux, and many managed container services.
#### 2. Custom Host Configuration (For QNAP and Special Cases)
For environments where service discovery doesn't work properly, you can specify a custom host:
```
# In .env file:
API_HOST=192.168.1.105
API_PORT=5551
```
The entrypoint script will detect these variables and configure the frontend to use the specified host and port.
### How Environment Variables Work
This application handles environment variables in three stages:
1. **Build-time configuration** (via ARG in Dockerfile):
- Default values are set to `http://localhost:5551/api` and `http://localhost:5551`
- These values are compiled into the frontend application
2. **Container start-time configuration** (via entrypoint.sh):
- The entrypoint script replaces the build-time URLs with runtime values
- Uses either service name (backend) or custom host (API_HOST) as configured
- This happens every time the container starts, so no rebuild is needed
3. **Priority order**:
- If API_HOST is provided → Use that explicitly
- If not, use VITE_API_URL from docker-compose → Service discovery with "backend"
- If neither is available → Fall back to default localhost values
## Volume Persistence
The Docker Compose setup includes a volume mount for the backend to store downloaded videos:
```yaml
volumes:
backend-data:
driver: local
```
This ensures that your downloaded videos are persistent even if the container is restarted.
## Network Configuration
The services are connected through a dedicated bridge network called `mytube-network`, which enables service discovery by name.
## Troubleshooting
If you encounter issues:
1. **Network Errors**:
- If you're using Docker service discovery and get connection errors, try using the custom host method
- Create a .env file with API_HOST=your-server-ip and API_PORT=5551
- Check if both containers are running: `docker ps`
- Verify they're on the same network: `docker network inspect mytube-network`
- Check logs for both containers: `docker logs mytube-frontend` and `docker logs mytube-backend`
2. **Checking the Applied Configuration**:
- You can verify what URLs the frontend is using with: `docker logs mytube-frontend`
- The entrypoint script will show "Configuring frontend with the following settings:"
3. **General Troubleshooting**:
- Ensure ports 5551 and 5556 are not being used by other services
- Check for any deployment errors with `docker-compose logs`
- If backend fails with Python-related errors, verify that the container has Python installed

View File

@@ -1,6 +1,6 @@
# MyTube
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,支持频道订阅与自动下载,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##)包括微博小红书x.com等。
[English](README.md)
@@ -8,7 +8,7 @@
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
[![Watch the video](https://img.youtube.com/vi/O5rMqYffXpg/maxresdefault.jpg)](https://youtu.be/O5rMqYffXpg)
## 功能特点
@@ -17,11 +17,14 @@
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
- **Bilibili 支持**支持下载单个视频、多P视频以及整个合集/系列。
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
- **批量下载**:一次性添加多个视频链接到下载队列。
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
- **字幕**:自动下载 YouTube 默认语言字幕。
- **搜索功能**:支持在本地库中搜索视频,或在线搜索 YouTube 视频。
- **收藏夹**:创建自定义收藏夹以整理您的视频。
- **订阅功能**:订阅您喜爱的频道,并在新视频发布时自动下载。
- **现代化 UI**:响应式深色主题界面,包含“返回主页”功能和玻璃拟态效果。
- **主题支持**:支持在明亮和深色模式之间切换,支持平滑过渡。
- **登录保护**:通过密码登录页面保护您的应用。
@@ -34,163 +37,15 @@
## 目录结构
```
mytube/
├── backend/ # Express.js 后端 (TypeScript)
│ ├── src/ # 源代码
│ │ ├── config/ # 配置文件
│ │ ├── controllers/ # 路由控制器
│ │ ├── db/ # 数据库迁移和设置
│ │ ├── routes/ # API 路由
│ │ ├── services/ # 业务逻辑服务
│ │ ├── utils/ # 工具函数
│ │ └── server.ts # 主服务器文件
│ ├── uploads/ # 上传文件目录
│ │ ├── videos/ # 下载的视频
│ │ └── images/ # 下载的缩略图
│ └── package.json # 后端依赖
├── frontend/ # React.js 前端 (Vite + TypeScript)
│ ├── src/ # 源代码
│ │ ├── assets/ # 图片和样式
│ │ ├── components/ # React 组件
│ │ ├── contexts/ # React 上下文
│ │ ├── pages/ # 页面组件
│ │ ├── utils/ # 工具和多语言文件
│ │ └── theme.ts # 主题配置
│ └── package.json # 前端依赖
├── build-and-push.sh # Docker 构建脚本
├── docker-compose.yml # Docker Compose 配置
├── DEPLOYMENT.md # 部署指南
├── CONTRIBUTING.md # 贡献指南
└── package.json # 运行两个应用的根 package.json
```
有关项目结构的详细说明,请参阅 [目录结构](documents/zh/directory-structure.md)。
## 开始使用
### 前提条件
- Node.js (v14 或更高版本)
- npm (v6 或更高版本)
- Docker (可选,用于容器化部署)
### 安装
1. 克隆仓库:
```bash
git clone <repository-url>
cd mytube
```
2. 安装依赖:
您可以使用一条命令安装根目录、前端和后端的所有依赖:
```bash
npm run install:all
```
或者手动安装:
```bash
npm install
cd frontend && npm install
cd ../backend && npm install
```
#### 使用 npm 脚本
您可以在根目录下使用 npm 脚本:
```bash
npm run dev # 以开发模式启动前端和后端
```
其他可用脚本:
```bash
npm run start # 以生产模式启动前端和后端
npm run build # 为生产环境构建前端
npm run lint # 运行前端代码检查
npm run lint:fix # 修复前端代码检查错误
```
### 访问应用
- 前端http://localhost:5556
- 后端 APIhttp://localhost:5551
有关安装和设置说明,请参阅 [开始使用](documents/zh/getting-started.md)。
## API 端点
### 视频
- `POST /api/download` - 下载视频 (YouTube 或 Bilibili)
- `POST /api/upload` - 上传本地视频文件
- `GET /api/videos` - 获取所有已下载的视频
- `GET /api/videos/:id` - 获取特定视频
- `PUT /api/videos/:id` - 更新视频详情
- `DELETE /api/videos/:id` - 删除视频
- `GET /api/videos/:id/comments` - 获取视频评论
- `POST /api/videos/:id/rate` - 评价视频
- `POST /api/videos/:id/refresh-thumbnail` - 刷新视频缩略图
- `POST /api/videos/:id/view` - 增加观看次数
- `PUT /api/videos/:id/progress` - 更新播放进度
- `GET /api/search` - 在线搜索视频
- `GET /api/download-status` - 获取当前下载状态
- `GET /api/check-bilibili-parts` - 检查 Bilibili 视频是否包含多个分P
- `GET /api/check-bilibili-collection` - 检查 Bilibili URL 是否为合集/系列
### 下载管理
- `POST /api/downloads/cancel/:id` - 取消下载
- `DELETE /api/downloads/queue/:id` - 从队列中移除
- `DELETE /api/downloads/queue` - 清空队列
- `GET /api/downloads/history` - 获取下载历史
- `DELETE /api/downloads/history/:id` - 从历史中移除
- `DELETE /api/downloads/history` - 清空历史
### 收藏夹
- `GET /api/collections` - 获取所有收藏夹
- `POST /api/collections` - 创建新收藏夹
- `PUT /api/collections/:id` - 更新收藏夹 (添加/移除视频)
- `DELETE /api/collections/:id` - 删除收藏夹
### 设置与系统
- `GET /api/settings` - 获取应用设置
- `POST /api/settings` - 更新应用设置
- `POST /api/settings/verify-password` - 验证登录密码
- `POST /api/settings/migrate` - 从 JSON 迁移数据到 SQLite
- `POST /api/settings/delete-legacy` - 删除旧的 JSON 数据
- `POST /api/scan-files` - 扫描现有文件
- `POST /api/cleanup-temp-files` - 清理临时下载文件
## 收藏夹功能
MyTube 允许您将视频整理到收藏夹中:
- **创建收藏夹**:创建自定义收藏夹以对视频进行分类。
- **添加到收藏夹**:直接从视频播放器或管理页面将视频添加到一个或多个收藏夹。
- **从收藏夹中移除**:轻松从收藏夹中移除视频。
- **浏览收藏夹**:在侧边栏查看所有收藏夹,并按收藏夹浏览视频。
- **删除选项**:选择仅删除收藏夹分组,或连同所有视频文件一起从磁盘删除。
## 数据迁移
MyTube 现在使用 SQLite 数据库以获得更好的性能和可靠性。如果您是从使用 JSON 文件的旧版本升级:
1. 进入 **设置**。
2. 向下滚动到 **数据库** 部分。
3. 点击 **从 JSON 迁移数据**。
4. 该工具将把您现有的视频、收藏夹和下载历史导入到新数据库中。
## 用户界面
该应用具有现代化、高级感的 UI包括
- **深色/明亮模式**:根据您的喜好切换主题。
- **响应式设计**:在桌面和移动设备上无缝运行,并针对移动端进行了优化。
- **视频网格**:便于浏览的视频库网格布局。
- **确认模态框**:带有自定义确认对话框的安全删除功能。
- **搜索**:集成的搜索栏,用于查找本地和在线内容。
- **Snackbar 通知**:为添加/移除视频等操作提供视觉反馈。
有关可用 API 端点的列表,请参阅 [API 端点](documents/zh/api-endpoints.md)。
## 环境变量
@@ -221,12 +76,22 @@ MAX_FILE_SIZE=500000000
## 部署
有关如何使用 Docker 或在 QNAP Container Station 上部署 MyTube 的详细说明,请参阅 [DEPLOYMENT.md](DEPLOYMENT.md)
有关如何使用 Docker 部署 MyTube 的详细说明,请参阅 [Docker 部署指南](documents/zh/docker-guide.md).
## Star History
## 星标历史
[![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

173
README.md
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.
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)
@@ -8,7 +8,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
🌐 **Try the live demo (read only): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
[![Watch the video](https://img.youtube.com/vi/O5rMqYffXpg/maxresdefault.jpg)](https://youtu.be/O5rMqYffXpg)
## Features
@@ -17,9 +17,11 @@ 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.
- **Auto Subtitles**: Automatically download YouTube default language subtitles.
- **Search**: Search for videos locally in your library or online via YouTube.
- **Collections**: Organize videos into custom collections for easy access.
- **Modern UI**: Responsive, dark-themed interface with a "Back to Home" feature and glassmorphism effects.
@@ -27,6 +29,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.
@@ -34,163 +37,15 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
## Directory Structure
```
mytube/
├── backend/ # Express.js backend (TypeScript)
│ ├── src/ # Source code
│ │ ├── config/ # Configuration files
│ │ ├── controllers/ # Route controllers
│ │ ├── db/ # Database migrations and setup
│ │ ├── routes/ # API routes
│ │ ├── services/ # Business logic services
│ │ ├── utils/ # Utility functions
│ │ └── server.ts # Main server file
│ ├── uploads/ # Uploaded files directory
│ │ ├── videos/ # Downloaded videos
│ │ └── images/ # Downloaded thumbnails
│ └── package.json # Backend dependencies
├── frontend/ # React.js frontend (Vite + TypeScript)
│ ├── src/ # Source code
│ │ ├── assets/ # Images and styles
│ │ ├── components/ # React components
│ │ ├── contexts/ # React contexts
│ │ ├── pages/ # Page components
│ │ ├── utils/ # Utilities and locales
│ │ └── theme.ts # Theme configuration
│ └── package.json # Frontend dependencies
├── build-and-push.sh # Docker build script
├── docker-compose.yml # Docker Compose configuration
├── DEPLOYMENT.md # Deployment guide
├── CONTRIBUTING.md # Contributing guidelines
└── package.json # Root package.json for running both apps
```
For a detailed breakdown of the project structure, please refer to [Directory Structure](documents/en/directory-structure.md).
## Getting Started
### Prerequisites
- Node.js (v14 or higher)
- npm (v6 or higher)
- Docker (optional, for containerized deployment)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd mytube
```
2. Install dependencies:
You can install all dependencies for the root, frontend, and backend with a single command:
```bash
npm run install:all
```
Or manually:
```bash
npm install
cd frontend && npm install
cd ../backend && npm install
```
#### Using npm Scripts
You can use npm scripts from the root directory:
```bash
npm run dev # Start both frontend and backend in development mode
```
Other available scripts:
```bash
npm run start # Start both frontend and backend in production mode
npm run build # Build the frontend for production
npm run lint # Run linting for frontend
npm run lint:fix # Fix linting errors for frontend
```
### Accessing the Application
- Frontend: http://localhost:5556
- Backend API: http://localhost:5551
For installation and setup instructions, please refer to [Getting Started](documents/en/getting-started.md).
## API Endpoints
### Videos
- `POST /api/download` - Download a video (YouTube or Bilibili)
- `POST /api/upload` - Upload a local video file
- `GET /api/videos` - Get all downloaded videos
- `GET /api/videos/:id` - Get a specific video
- `PUT /api/videos/:id` - Update video details
- `DELETE /api/videos/:id` - Delete a video
- `GET /api/videos/:id/comments` - Get video comments
- `POST /api/videos/:id/rate` - Rate a video
- `POST /api/videos/:id/refresh-thumbnail` - Refresh video thumbnail
- `POST /api/videos/:id/view` - Increment view count
- `PUT /api/videos/:id/progress` - Update playback progress
- `GET /api/search` - Search for videos online
- `GET /api/download-status` - Get status of active downloads
- `GET /api/check-bilibili-parts` - Check if a Bilibili video has multiple parts
- `GET /api/check-bilibili-collection` - Check if a Bilibili URL is a collection/series
### Download Management
- `POST /api/downloads/cancel/:id` - Cancel a download
- `DELETE /api/downloads/queue/:id` - Remove from queue
- `DELETE /api/downloads/queue` - Clear queue
- `GET /api/downloads/history` - Get download history
- `DELETE /api/downloads/history/:id` - Remove from history
- `DELETE /api/downloads/history` - Clear history
### Collections
- `GET /api/collections` - Get all collections
- `POST /api/collections` - Create a new collection
- `PUT /api/collections/:id` - Update a collection (add/remove videos)
- `DELETE /api/collections/:id` - Delete a collection
### Settings & System
- `GET /api/settings` - Get application settings
- `POST /api/settings` - Update application settings
- `POST /api/settings/verify-password` - Verify login password
- `POST /api/settings/migrate` - Migrate data from JSON to SQLite
- `POST /api/settings/delete-legacy` - Delete legacy JSON data
- `POST /api/scan-files` - Scan for existing files
- `POST /api/cleanup-temp-files` - Cleanup temporary download files
## Collections Feature
MyTube allows you to organize your videos into collections:
- **Create Collections**: Create custom collections to categorize your videos.
- **Add to Collections**: Add videos to one or more collections directly from the video player or manage page.
- **Remove from Collections**: Remove videos from collections easily.
- **Browse Collections**: View all your collections in the sidebar and browse videos by collection.
- **Delete Options**: Choose to delete just the collection grouping or delete the collection along with all its video files from the disk.
## Data Migration
MyTube now uses a SQLite database for better performance and reliability. If you are upgrading from an older version that used JSON files:
1. Go to **Settings**.
2. Scroll down to the **Database** section.
3. Click **Migrate Data from JSON**.
4. The tool will import your existing videos, collections, and download history into the new database.
## User Interface
The application features a modern, premium UI with:
- **Dark/Light Mode**: Toggle between themes to suit your preference.
- **Responsive Design**: Works seamlessly on desktop and mobile devices, with mobile-specific optimizations.
- **Video Grid**: Easy-to-browse grid layout for your video library.
- **Confirmation Modals**: Safe deletion with custom confirmation dialogs.
- **Search**: Integrated search bar for finding local and online content.
- **Snackbar Notifications**: Visual feedback for actions like adding/removing videos.
For a list of available API endpoints, please refer to [API Endpoints](documents/en/api-endpoints.md).
## Environment Variables
@@ -221,12 +76,22 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for deta
## Deployment
For detailed instructions on how to deploy MyTube using Docker or on QNAP Container Station, please refer to [DEPLOYMENT.md](DEPLOYMENT.md).
For detailed instructions on how to deploy MyTube using Docker, please refer to [Docker Deployment Guide](documents/en/docker-guide.md).
## Star History
[![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,6 @@
ALTER TABLE `downloads` ADD `source_url` text;--> statement-breakpoint
ALTER TABLE `downloads` ADD `type` text;--> statement-breakpoint
ALTER TABLE `videos` ADD `tags` text;--> statement-breakpoint
ALTER TABLE `videos` ADD `progress` integer;--> statement-breakpoint
ALTER TABLE `videos` ADD `last_played_at` integer;--> statement-breakpoint
ALTER TABLE `videos` ADD `subtitles` text;

View File

@@ -0,0 +1,546 @@
{
"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'"
}
},
"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
},
"file_size": {
"name": "file_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,588 @@
{
"version": "6",
"dialect": "sqlite",
"id": "99422252-1f8e-47dc-993c-07653d092ac9",
"prevId": "e34144d1-add0-4bb0-b9d3-852c5fa0384e",
"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
},
"subtitles": {
"name": "subtitles",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.2.2",
"version": "1.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.2.2",
"version": "1.3.11",
"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.2.3",
"version": "1.3.12",
"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": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
},
"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

@@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as downloadService from '../../services/downloadService';
import { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
import { MissAVDownloader } from '../../services/downloaders/MissAVDownloader';
import { YouTubeDownloader } from '../../services/downloaders/YouTubeDownloader';
import { YtDlpDownloader } from '../../services/downloaders/YtDlpDownloader';
vi.mock('../../services/downloaders/BilibiliDownloader');
vi.mock('../../services/downloaders/YouTubeDownloader');
vi.mock('../../services/downloaders/YtDlpDownloader');
vi.mock('../../services/downloaders/MissAVDownloader');
describe('DownloadService', () => {
@@ -56,22 +56,22 @@ describe('DownloadService', () => {
});
});
describe('YouTube', () => {
it('should call YouTubeDownloader.search', async () => {
describe('YouTube/Generic', () => {
it('should call YtDlpDownloader.search', async () => {
await downloadService.searchYouTube('query');
expect(YouTubeDownloader.search).toHaveBeenCalledWith('query');
expect(YtDlpDownloader.search).toHaveBeenCalledWith('query');
});
it('should call YouTubeDownloader.downloadVideo', async () => {
it('should call YtDlpDownloader.downloadVideo', async () => {
await downloadService.downloadYouTubeVideo('url', 'id');
expect(YouTubeDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
expect(YtDlpDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
});
});
describe('MissAV', () => {
it('should call MissAVDownloader.downloadVideo', async () => {
await downloadService.downloadMissAVVideo('url', 'id');
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
});
});
});

View File

@@ -6,6 +6,7 @@ export const ROOT_DIR: string = process.cwd();
export const UPLOADS_DIR: string = path.join(ROOT_DIR, "uploads");
export const VIDEOS_DIR: string = path.join(UPLOADS_DIR, "videos");
export const IMAGES_DIR: string = path.join(UPLOADS_DIR, "images");
export const SUBTITLES_DIR: string = path.join(UPLOADS_DIR, "subtitles");
export const DATA_DIR: string = path.join(ROOT_DIR, "data");
export const VIDEOS_DATA_PATH: string = path.join(DATA_DIR, "videos.json");

View File

@@ -158,6 +158,40 @@ export const scanFiles = async (_req: Request, res: Response): Promise<any> => {
storageService.saveVideo(newVideo);
addedCount++;
// Check if video is in a subfolder
const dirName = path.dirname(relativePath);
console.log(`DEBUG: relativePath='${relativePath}', dirName='${dirName}'`);
if (dirName !== '.') {
const collectionName = dirName.split(path.sep)[0];
// Find existing collection by name
let collectionId: string | undefined;
const allCollections = storageService.getCollections();
const existingCollection = allCollections.find(c => (c.title === collectionName || c.name === collectionName));
if (existingCollection) {
collectionId = existingCollection.id;
} else {
// Create new collection
collectionId = (Date.now() + Math.floor(Math.random() * 10000)).toString();
const newCollection = {
id: collectionId,
title: collectionName,
name: collectionName,
videos: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
storageService.saveCollection(newCollection);
console.log(`Created new collection from folder: ${collectionName}`);
}
if (collectionId) {
storageService.addVideoToCollection(collectionId, newVideo.id);
console.log(`Added video ${newVideo.title} to collection ${collectionName}`);
}
}
}
console.log(`Scan complete. Added ${addedCount} new videos.`);

View File

@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
import { Request, Response } from 'express';
import fs from 'fs-extra';
import path from 'path';
import { COLLECTIONS_DATA_PATH, STATUS_DATA_PATH, VIDEOS_DATA_PATH } from '../config/paths';
import { COLLECTIONS_DATA_PATH, DATA_DIR, STATUS_DATA_PATH, VIDEOS_DATA_PATH } from '../config/paths';
import downloadManager from '../services/downloadManager';
import * as storageService from '../services/storageService';
@@ -14,6 +14,12 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags?: string[];
cloudDriveEnabled?: boolean;
openListApiUrl?: string;
openListToken?: string;
cloudDrivePath?: string;
homeSidebarOpen?: boolean;
subtitlesEnabled?: boolean;
}
const defaultSettings: Settings = {
@@ -22,7 +28,13 @@ const defaultSettings: Settings = {
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en'
language: 'en',
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: '',
homeSidebarOpen: true,
subtitlesEnabled: true
};
export const getSettings = async (_req: Request, res: Response) => {
@@ -174,3 +186,49 @@ export const verifyPassword = async (req: Request, res: Response) => {
res.status(500).json({ error: 'Failed to verify password' });
}
};
export const uploadCookies = async (req: Request, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
if (!req.file.originalname.endsWith('.txt')) {
// Clean up the uploaded file if it's not a txt file
if (req.file.path) fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Only .txt files are allowed' });
}
const COOKIES_PATH = path.join(DATA_DIR, 'cookies.txt');
// Read the uploaded file
let content = await fs.readFile(req.file.path, 'utf8');
// Convert CRLF to LF
content = content.replace(/\r\n/g, '\n');
// Ensure Netscape header exists
if (!content.startsWith('# Netscape HTTP Cookie File') && !content.startsWith('# HTTP Cookie File')) {
content = '# Netscape HTTP Cookie File\n\n' + content;
}
// Write sanitized content to data/cookies.txt
await fs.writeFile(COOKIES_PATH, content, 'utf8');
// Clean up temp file
await fs.unlink(req.file.path);
res.json({ success: true, message: 'Cookies uploaded successfully' });
} catch (error: any) {
console.error('Error uploading cookies:', error);
// Try to clean up temp file if it exists
if (req.file?.path && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch (e) {
console.error('Failed to cleanup temp file:', e);
}
}
res.status(500).json({ error: 'Failed to upload cookies', details: error.message });
}
};

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

@@ -86,13 +86,12 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
console.log("Resolved shortened URL to:", videoUrl);
}
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be") || isBilibiliUrl(videoUrl) || videoUrl.includes("missav")) {
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
// Try to fetch video info for all URLs
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
} catch (err) {
console.warn("Failed to fetch video info for title, using default:", err);
@@ -236,8 +235,16 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
}
};
// Determine type
let type = 'youtube';
if (videoUrl.includes("missav")) {
type = 'missav';
} else if (isBilibiliUrl(videoUrl)) {
type = 'bilibili';
}
// Add to download manager
downloadManager.addDownload(downloadTask, downloadId, initialTitle)
downloadManager.addDownload(downloadTask, downloadId, initialTitle, videoUrl, type)
.then((result: any) => {
console.log("Download completed successfully:", result);
})

View File

@@ -28,6 +28,7 @@ export const videos = sqliteTable('videos', {
progress: integer('progress'), // Playback progress in seconds
fileSize: text('file_size'),
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
subtitles: text('subtitles'), // JSON stringified array of subtitle objects
});
export const collections = sqliteTable('collections', {
@@ -89,6 +90,8 @@ export const downloads = sqliteTable('downloads', {
progress: integer('progress'), // Using integer for percentage (0-100) or similar
speed: text('speed'),
status: text('status').notNull().default('active'), // 'active' or 'queued'
sourceUrl: text('source_url'),
type: text('type'),
});
export const downloadHistory = sqliteTable('download_history', {
@@ -103,3 +106,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

@@ -1,12 +1,15 @@
import express from 'express';
import { deleteLegacyData, getSettings, migrateData, updateSettings, verifyPassword } from '../controllers/settingsController';
import multer from 'multer';
import { deleteLegacyData, getSettings, migrateData, updateSettings, uploadCookies, verifyPassword } from '../controllers/settingsController';
const router = express.Router();
const upload = multer({ dest: 'uploads/temp/' });
router.get('/', getSettings);
router.post('/', updateSettings);
router.post('/verify-password', verifyPassword);
router.post('/migrate', migrateData);
router.post('/delete-legacy', deleteLegacyData);
router.post('/upload-cookies', upload.single('file'), uploadCookies);
export default router;

View File

@@ -0,0 +1,55 @@
import fs from "fs-extra";
import path from "path";
import { SUBTITLES_DIR } from "../config/paths";
/**
* Clean existing VTT files by removing alignment tags that force left-alignment
*/
async function cleanVttFiles() {
console.log("Starting VTT file cleanup...");
try {
if (!fs.existsSync(SUBTITLES_DIR)) {
console.log("Subtitles directory doesn't exist");
return;
}
const vttFiles = fs.readdirSync(SUBTITLES_DIR).filter((file) => file.endsWith(".vtt"));
console.log(`Found ${vttFiles.length} VTT files to clean`);
let cleanedCount = 0;
for (const vttFile of vttFiles) {
const filePath = path.join(SUBTITLES_DIR, vttFile);
// Read VTT file
let vttContent = fs.readFileSync(filePath, 'utf-8');
// Check if it has alignment tags
if (vttContent.includes('align:start') || vttContent.includes('position:0%')) {
// Replace align:start with align:middle for centered subtitles (Safari needs this)
// Remove position:0% which forces left positioning
vttContent = vttContent.replace(/ align:start/g, ' align:middle');
vttContent = vttContent.replace(/ position:0%/g, '');
// Write cleaned content back
fs.writeFileSync(filePath, vttContent, 'utf-8');
console.log(`Cleaned: ${vttFile}`);
cleanedCount++;
}
}
console.log(`VTT cleanup complete. Cleaned ${cleanedCount} files.`);
} catch (error) {
console.error("Error during VTT cleanup:", error);
}
}
// Run the script
cleanVttFiles().then(() => {
console.log("Done");
process.exit(0);
}).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import fs from "fs-extra";
import { SUBTITLES_DIR } from "../config/paths";
import * as storageService from "../services/storageService";
/**
* Scan subtitle directory and update video records with subtitle metadata
*/
async function rescanSubtitles() {
console.log("Starting subtitle rescan...");
try {
// Get all videos
const videos = storageService.getVideos();
console.log(`Found ${videos.length} videos to check`);
// Get all subtitle files
if (!fs.existsSync(SUBTITLES_DIR)) {
console.log("Subtitles directory doesn't exist");
return;
}
const subtitleFiles = fs.readdirSync(SUBTITLES_DIR).filter((file) => file.endsWith(".vtt"));
console.log(`Found ${subtitleFiles.length} subtitle files`);
let updatedCount = 0;
for (const video of videos) {
// Skip if video already has subtitles
if (video.subtitles && video.subtitles.length > 0) {
continue;
}
// Look for subtitle files matching this video
const videoTimestamp = video.id;
const matchingSubtitles = subtitleFiles.filter((file) => file.includes(videoTimestamp));
if (matchingSubtitles.length > 0) {
console.log(`Found ${matchingSubtitles.length} subtitles for video: ${video.title}`);
const subtitles = matchingSubtitles.map((filename) => {
// Parse language from filename (e.g., video_123.en.vtt -> en)
const match = filename.match(/\.([a-z]{2}(?:-[A-Z]{2})?)\.vtt$/);
const language = match ? match[1] : "unknown";
return {
language,
filename,
path: `/subtitles/${filename}`,
};
});
// Update video record
storageService.updateVideo(video.id, { subtitles });
console.log(`Updated video ${video.id} with ${subtitles.length} subtitles`);
updatedCount++;
}
}
console.log(`Subtitle rescan complete. Updated ${updatedCount} videos.`);
} catch (error) {
console.error("Error during subtitle rescan:", error);
}
}
// Run the script
rescanSubtitles().then(() => {
console.log("Done");
process.exit(0);
}).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -4,9 +4,10 @@ dotenv.config();
import cors from "cors";
import express from "express";
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "./config/paths";
import apiRoutes from "./routes/api";
import settingsRoutes from './routes/settingsRoutes';
import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService";
import { VERSION } from "./version";
@@ -21,28 +22,50 @@ app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Initialize storage (create directories, etc.)
// Initialize storage (create directories, etc.)
storageService.initializeStorage();
// Run database migrations
import { runMigrations } from "./db/migrate";
runMigrations();
// Serve static files
app.use("/videos", express.static(VIDEOS_DIR));
app.use("/images", express.static(IMAGES_DIR));
const startServer = async () => {
try {
// Run migrations before starting anything else
await runMigrations();
// API Routes
app.use("/api", apiRoutes);
app.use('/api/settings', settingsRoutes);
// Initialize download manager (restore queued tasks)
// This must happen AFTER migrations to ensure tables exist
downloadManager.initialize();
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
// Run duration backfill in background
import("./services/metadataService").then(service => {
service.backfillDurations();
}).catch(err => console.error("Failed to start metadata service:", err));
});
// Serve static files
app.use("/videos", express.static(VIDEOS_DIR));
app.use("/images", express.static(IMAGES_DIR));
app.use("/subtitles", express.static(SUBTITLES_DIR));
// API Routes
app.use("/api", apiRoutes);
app.use('/api/settings', settingsRoutes);
// Start the server
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();
}).catch(err => console.error("Failed to start metadata service:", err));
});
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
};
startServer();

View File

@@ -0,0 +1,173 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { getSettings } from './storageService';
interface CloudDriveConfig {
enabled: boolean;
apiUrl: string;
token: string;
uploadPath: string;
}
export class CloudStorageService {
private static getConfig(): CloudDriveConfig {
const settings = getSettings();
return {
enabled: settings.cloudDriveEnabled || false,
apiUrl: settings.openListApiUrl || '',
token: settings.openListToken || '',
uploadPath: settings.cloudDrivePath || '/'
};
}
static async uploadVideo(videoData: any): Promise<void> {
const config = this.getConfig();
if (!config.enabled || !config.apiUrl || !config.token) {
return;
}
console.log(`[CloudStorage] Starting upload for video: ${videoData.title}`);
try {
// Upload Video File
if (videoData.videoPath) {
// videoPath is relative, e.g. /videos/filename.mp4
// We need absolute path. Assuming backend runs in project root or we can resolve it.
// Based on storageService, VIDEOS_DIR is likely imported from config/paths.
// But here we might need to resolve it.
// Let's try to resolve relative to process.cwd() or use absolute path if available.
// Actually, storageService stores relative paths for frontend usage.
// We should probably look up the file using the same logic as storageService or just assume standard location.
// For now, let's try to construct the path.
// Better approach: Use the absolute path if we can get it, or resolve from common dirs.
// Since I don't have direct access to config/paths here easily without importing,
// I'll assume the videoData might have enough info or I'll import paths.
const absoluteVideoPath = this.resolveAbsolutePath(videoData.videoPath);
if (absoluteVideoPath && fs.existsSync(absoluteVideoPath)) {
await this.uploadFile(absoluteVideoPath, config);
} else {
console.error(`[CloudStorage] Video file not found: ${videoData.videoPath}`);
}
}
// Upload Thumbnail
if (videoData.thumbnailPath) {
const absoluteThumbPath = this.resolveAbsolutePath(videoData.thumbnailPath);
if (absoluteThumbPath && fs.existsSync(absoluteThumbPath)) {
await this.uploadFile(absoluteThumbPath, config);
}
}
// Upload Metadata (JSON)
const metadata = {
title: videoData.title,
description: videoData.description,
author: videoData.author,
sourceUrl: videoData.sourceUrl,
tags: videoData.tags,
createdAt: videoData.createdAt,
...videoData
};
const metadataFileName = `${this.sanitizeFilename(videoData.title)}.json`;
const metadataPath = path.join(process.cwd(), 'temp_metadata', metadataFileName);
fs.ensureDirSync(path.dirname(metadataPath));
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
await this.uploadFile(metadataPath, config);
// Cleanup temp metadata
fs.unlinkSync(metadataPath);
console.log(`[CloudStorage] Upload completed for: ${videoData.title}`);
} catch (error) {
console.error(`[CloudStorage] Upload failed for ${videoData.title}:`, error);
}
}
private static resolveAbsolutePath(relativePath: string): string | null {
// This is a heuristic. In a real app we should import the constants.
// Assuming the app runs from 'backend' or root.
// relativePath starts with /videos or /images
// Try to find the 'data' directory.
// If we are in backend/src/services, data is likely ../../../data
// Let's try to use the absolute path if we can find the data dir.
// Or just check common locations.
const possibleRoots = [
path.join(process.cwd(), 'data'),
path.join(process.cwd(), '..', 'data'), // if running from backend
path.join(__dirname, '..', '..', '..', 'data') // if compiled
];
for (const root of possibleRoots) {
if (fs.existsSync(root)) {
// Remove leading slash from relative path
const cleanRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
const fullPath = path.join(root, cleanRelative);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return null;
}
private static async uploadFile(filePath: string, config: CloudDriveConfig): Promise<void> {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const fileStream = fs.createReadStream(filePath);
console.log(`[CloudStorage] Uploading ${fileName} (${fileSize} bytes)...`);
// Generic upload implementation
// Assuming a simple PUT or POST with file content
// Many cloud drives (like Alist/WebDAV) use PUT with the path.
// Construct URL: apiUrl + uploadPath + fileName
// Ensure slashes are handled correctly
const baseUrl = config.apiUrl.endsWith('/') ? config.apiUrl.slice(0, -1) : config.apiUrl;
const uploadDir = config.uploadPath.startsWith('/') ? config.uploadPath : '/' + config.uploadPath;
const finalDir = uploadDir.endsWith('/') ? uploadDir : uploadDir + '/';
// Encode filename for URL
const encodedFileName = encodeURIComponent(fileName);
const url = `${baseUrl}${finalDir}${encodedFileName}`;
try {
await axios.put(url, fileStream, {
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': fileSize
},
maxContentLength: Infinity,
maxBodyLength: Infinity
});
console.log(`[CloudStorage] Successfully uploaded ${fileName}`);
} catch (error: any) {
// Try POST if PUT fails, some APIs might differ
console.warn(`[CloudStorage] PUT failed, trying POST... Error: ${error.message}`);
try {
// For POST, we might need FormData, but let's try raw body first or check if it's a specific API.
// If it's Alist/WebDAV, PUT is standard.
// If it's a custom API, it might expect FormData.
// Let's stick to PUT for now as it's common for "Save to Cloud" generic interfaces.
throw error;
} catch (retryError) {
throw retryError;
}
}
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
}
}

View File

@@ -1,3 +1,5 @@
import { CloudStorageService } from "./CloudStorageService";
import { createDownloadTask } from "./downloadService";
import * as storageService from "./storageService";
interface DownloadTask {
@@ -7,6 +9,9 @@ interface DownloadTask {
resolve: (value: any) => void;
reject: (reason?: any) => void;
cancelFn?: () => void;
sourceUrl?: string;
type?: string;
cancelled?: boolean;
}
class DownloadManager {
@@ -35,6 +40,58 @@ class DownloadManager {
}
}
/**
* Initialize the download manager and restore queued tasks
*/
initialize(): void {
try {
console.log("Initializing DownloadManager...");
const status = storageService.getDownloadStatus();
const queuedDownloads = status.queuedDownloads;
if (queuedDownloads && queuedDownloads.length > 0) {
console.log(`Restoring ${queuedDownloads.length} queued downloads...`);
for (const download of queuedDownloads) {
if (download.sourceUrl && download.type) {
console.log(`Restoring task: ${download.title} (${download.id})`);
// Reconstruct the download function
const downloadFn = createDownloadTask(
download.type,
download.sourceUrl,
download.id
);
// Add to queue without persisting (since it's already in DB)
// We need to manually construct the task and push to queue
// We can't use addDownload because it returns a promise that we can't easily attach to
// But for restored tasks, we don't have a client waiting for the promise anyway.
const task: DownloadTask = {
downloadFn,
id: download.id,
title: download.title,
sourceUrl: download.sourceUrl,
type: download.type,
resolve: (val) => console.log(`Restored task ${download.id} completed`, val),
reject: (err) => console.error(`Restored task ${download.id} failed`, err),
};
this.queue.push(task);
} else {
console.warn(`Skipping restoration of task ${download.id} due to missing sourceUrl or type`);
}
}
// Trigger processing
this.processQueue();
}
} catch (error) {
console.error("Error initializing DownloadManager:", error);
}
}
/**
* Set the maximum number of concurrent downloads
* @param limit - Maximum number of concurrent downloads
@@ -49,19 +106,16 @@ class DownloadManager {
* @param downloadFn - Async function that performs the download
* @param id - Unique ID for the download
* @param title - Title of the video being downloaded
* @returns - Resolves when the download is complete
*/
/**
* Add a download task to the manager
* @param downloadFn - Async function that performs the download
* @param id - Unique ID for the download
* @param title - Title of the video being downloaded
* @param sourceUrl - Source URL of the video
* @param type - Type of the download (youtube, bilibili, missav)
* @returns - Resolves when the download is complete
*/
async addDownload(
downloadFn: (registerCancel: (cancel: () => void) => void) => Promise<any>,
id: string,
title: string
title: string,
sourceUrl?: string,
type?: string
): Promise<any> {
return new Promise((resolve, reject) => {
const task: DownloadTask = {
@@ -70,6 +124,8 @@ class DownloadManager {
title,
resolve,
reject,
sourceUrl,
type,
};
this.queue.push(task);
@@ -86,6 +142,7 @@ class DownloadManager {
const task = this.activeTasks.get(id);
if (task) {
console.log(`Cancelling active download: ${task.title} (${id})`);
task.cancelled = true;
// Call the cancel function if available
if (task.cancelFn) {
@@ -107,6 +164,7 @@ class DownloadManager {
finishedAt: Date.now(),
status: 'failed',
error: 'Download cancelled by user',
sourceUrl: task.sourceUrl,
});
// Clean up internal state
@@ -152,7 +210,9 @@ class DownloadManager {
const queuedDownloads = this.queue.map(task => ({
id: task.id,
title: task.title,
timestamp: Date.now()
timestamp: Date.now(),
sourceUrl: task.sourceUrl,
type: task.type,
}));
storageService.setQueuedDownloads(queuedDownloads);
}
@@ -177,6 +237,13 @@ class DownloadManager {
// Update status in storage
storageService.addActiveDownload(task.id, task.title);
// Update with extra info if available
if (task.sourceUrl || task.type) {
storageService.updateActiveDownload(task.id, {
sourceUrl: task.sourceUrl,
type: task.type
});
}
try {
console.log(`Starting download: ${task.title} (${task.id})`);
@@ -204,16 +271,28 @@ class DownloadManager {
}
// Add to history
storageService.addDownloadHistoryItem({
id: task.id,
if (!task.cancelled) {
storageService.addDownloadHistoryItem({
id: task.id,
title: finalTitle || task.title,
finishedAt: Date.now(),
status: 'success',
videoPath: videoData.videoPath,
thumbnailPath: videoData.thumbnailPath,
sourceUrl: videoData.sourceUrl || task.sourceUrl,
author: videoData.author,
});
}
// Trigger Cloud Upload (Async, don't await to block queue processing?)
// Actually, we might want to await it if we want to ensure it's done before resolving,
// but that would block the download queue.
// Let's run it in background but log it.
CloudStorageService.uploadVideo({
...videoData,
title: finalTitle || task.title,
finishedAt: Date.now(),
status: 'success',
videoPath: videoData.videoPath,
thumbnailPath: videoData.thumbnailPath,
sourceUrl: videoData.sourceUrl,
author: videoData.author,
});
sourceUrl: task.sourceUrl
}).catch(err => console.error("Background cloud upload failed:", err));
task.resolve(result);
} catch (error) {
@@ -223,13 +302,16 @@ class DownloadManager {
storageService.removeActiveDownload(task.id);
// Add to history
storageService.addDownloadHistoryItem({
id: task.id,
title: task.title,
finishedAt: Date.now(),
status: 'failed',
error: error instanceof Error ? error.message : String(error),
});
if (!task.cancelled) {
storageService.addDownloadHistoryItem({
id: task.id,
title: task.title,
finishedAt: Date.now(),
status: 'failed',
error: error instanceof Error ? error.message : String(error),
sourceUrl: task.sourceUrl,
});
}
task.reject(error);
} finally {

View File

@@ -9,7 +9,7 @@ import {
DownloadResult
} from "./downloaders/BilibiliDownloader";
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
import { YouTubeDownloader } from "./downloaders/YouTubeDownloader";
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
import { Video } from "./storageService";
// Re-export types for compatibility
@@ -77,14 +77,14 @@ export async function downloadRemainingBilibiliParts(
return BilibiliDownloader.downloadRemainingParts(baseUrl, startPart, totalParts, seriesTitle, collectionId, downloadId);
}
// Search for videos on YouTube
// Search for videos on YouTube (using yt-dlp)
export async function searchYouTube(query: string): Promise<any[]> {
return YouTubeDownloader.search(query);
return YtDlpDownloader.search(query);
}
// Download YouTube video
// Download generic video (using yt-dlp)
export async function downloadYouTubeVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
return YouTubeDownloader.downloadVideo(videoUrl, downloadId, onStart);
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
}
// Helper function to download MissAV video
@@ -99,17 +99,30 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
if (videoId) {
return BilibiliDownloader.getVideoInfo(videoId);
}
} else if (url.includes("youtube.com") || url.includes("youtu.be")) {
return YouTubeDownloader.getVideoInfo(url);
} else if (url.includes("missav")) {
return MissAVDownloader.getVideoInfo(url);
}
// Default fallback
return {
title: "Video",
author: "Unknown",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
// Default fallback to yt-dlp for everything else
return YtDlpDownloader.getVideoInfo(url);
}
// Factory function to create a download task
export function createDownloadTask(
type: string,
url: string,
downloadId: string
): (registerCancel: (cancel: () => void) => void) => Promise<any> {
return async (registerCancel: (cancel: () => void) => void) => {
if (type === 'missav') {
return MissAVDownloader.downloadVideo(url, downloadId, registerCancel);
} else if (type === 'bilibili') {
// For restored tasks, we assume single video download for now
// Complex collection handling would require persisting more state
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
} else {
// Default to yt-dlp
return YtDlpDownloader.downloadVideo(url, downloadId, registerCancel);
}
};
}

View File

@@ -1,10 +1,10 @@
import axios from "axios";
import * as cheerio from "cheerio";
import { spawn } from "child_process";
import fs from "fs-extra";
import path from "path";
import puppeteer from "puppeteer";
import youtubedl from "youtube-dl-exec";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
@@ -57,6 +57,10 @@ export class MissAVDownloader {
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Ensure directories exist
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
@@ -65,10 +69,11 @@ export class MissAVDownloader {
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let thumbnailUrl: string | null = null;
let thumbnailSaved = false;
let m3u8Url: string | null = null;
try {
// 1. Fetch the page content using Puppeteer to bypass Cloudflare
console.log("Fetching MissAV page content with Puppeteer...");
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
console.log("Launching Puppeteer to capture m3u8 URL...");
const browser = await puppeteer.launch({
headless: true,
@@ -78,8 +83,21 @@ export class MissAVDownloader {
const page = await browser.newPage();
// Set a real user agent
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
await page.setUserAgent(userAgent);
// Setup request listener to find m3u8
page.on('request', (request) => {
const reqUrl = request.url();
if (reqUrl.includes('.m3u8') && !reqUrl.includes('preview')) {
console.log("Found m3u8 URL via network interception:", reqUrl);
if (!m3u8Url) {
m3u8Url = reqUrl;
}
}
});
console.log("Navigating to:", url);
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const html = await page.content();
@@ -99,44 +117,38 @@ export class MissAVDownloader {
console.log("Extracted metadata:", { title: videoTitle, thumbnail: thumbnailUrl });
// 3. Extract the m3u8 URL
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
// 3. If m3u8 URL was not found via network, try regex extraction as fallback
if (!m3u8Url) {
console.log("m3u8 URL not found via network, trying regex extraction...");
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
// Look for the obfuscated string pattern
// The pattern seems to be: m3u8|...|playlist|source
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
if (m3u8Match) {
const matchString = m3u8Match[0];
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
const urlWords = cleanString.split("|");
if (!m3u8Match) {
throw new Error("Could not find m3u8 URL pattern in page source");
const videoIndex = urlWords.indexOf("video");
if (videoIndex !== -1) {
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL via regex:", m3u8Url);
}
}
}
const matchString = m3u8Match[0];
// Remove "m3u8|" from start and "|playlist|source" from end
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
const urlWords = cleanString.split("|");
// Find "video" index
const videoIndex = urlWords.indexOf("video");
if (videoIndex === -1) {
throw new Error("Could not parse m3u8 URL structure");
if (!m3u8Url) {
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
fs.writeFileSync(debugFile, html);
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
throw new Error("Could not find m3u8 URL in page source or network requests");
}
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
// Reconstruct parts
// m3u8_url_path = "-".join((url_words[0:5])[::-1])
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
// base_url_path = ".".join((url_words[5:video_index-1])[::-1])
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
// formatted_url = "{0}://{1}/{2}/{3}/{4}.m3u8".format(protocol, base_url_path, m3u8_url_path, video_format, url_words[video_index])
const m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL:", m3u8Url);
// 4. Download the video using yt-dlp
// 4. Download the video using ffmpeg directly
console.log("Downloading video stream to:", videoPath);
if (downloadId) {
@@ -146,70 +158,124 @@ export class MissAVDownloader {
});
}
const subprocess = youtubedl.exec(m3u8Url, {
output: videoPath,
format: "mp4",
noCheckCertificates: true,
// Add headers to mimic browser
addHeader: [
'Referer:https://missav.ai/',
'User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
]
});
await new Promise<void>((resolve, reject) => {
const ffmpegArgs = [
'-user_agent', userAgent,
'-headers', 'Referer: https://missav.ai/',
'-i', m3u8Url!,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y', // Overwrite output file
videoPath
];
if (onStart) {
onStart(() => {
console.log("Killing subprocess for download:", downloadId);
subprocess.kill();
// Clean up partial files
console.log("Cleaning up partial files...");
try {
// youtube-dl creates .part files during download
const partVideoPath = `${videoPath}.part`;
const partThumbnailPath = `${thumbnailPath}.part`;
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
let totalDurationSec = 0;
if (onStart) {
onStart(() => {
console.log("Killing ffmpeg process for download:", downloadId);
ffmpeg.kill('SIGKILL');
if (fs.existsSync(partVideoPath)) {
fs.unlinkSync(partVideoPath);
console.log("Deleted partial video file:", partVideoPath);
// Cleanup
try {
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
console.log("Deleted partial video file:", videoPath);
}
if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath);
console.log("Deleted partial thumbnail file:", thumbnailPath);
}
} catch (e) {
console.error("Error cleaning up partial files:", e);
}
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
console.log("Deleted partial video file:", videoPath);
}
if (fs.existsSync(partThumbnailPath)) {
fs.unlinkSync(partThumbnailPath);
console.log("Deleted partial thumbnail file:", partThumbnailPath);
}
if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath);
console.log("Deleted partial thumbnail file:", thumbnailPath);
}
} catch (cleanupError) {
console.error("Error cleaning up partial files:", cleanupError);
}
});
}
subprocess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
const progressMatch = output.match(/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/);
if (progressMatch && downloadId) {
const percentage = parseFloat(progressMatch[1]);
const totalSize = progressMatch[2];
const speed = progressMatch[3];
storageService.updateActiveDownload(downloadId, {
progress: percentage,
totalSize: totalSize,
speed: speed
});
}
});
await subprocess;
ffmpeg.stderr.on('data', (data) => {
const output = data.toString();
// console.log("ffmpeg stderr:", output); // Uncomment for verbose debug
// Try to parse duration if not set
if (totalDurationSec === 0) {
const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (durationMatch) {
const hours = parseInt(durationMatch[1]);
const minutes = parseInt(durationMatch[2]);
const seconds = parseInt(durationMatch[3]);
totalDurationSec = hours * 3600 + minutes * 60 + seconds;
console.log("Detected total duration:", totalDurationSec);
}
}
// Parse progress
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
const bitrateMatch = output.match(/bitrate=\s*(\d+\.?\d*)kbits\/s/);
if (timeMatch && downloadId) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const currentTimeSec = hours * 3600 + minutes * 60 + seconds;
let percentage = 0;
if (totalDurationSec > 0) {
percentage = Math.min(100, (currentTimeSec / totalDurationSec) * 100);
}
let totalSizeStr = "0B";
if (sizeMatch) {
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
}
let speedStr = "0 B/s";
if (bitrateMatch) {
const bitrateKbps = parseFloat(bitrateMatch[1]);
// Convert kbits/s to KB/s (approximate, usually bitrate is bits, so /8)
// But ffmpeg reports kbits/s. 1 byte = 8 bits.
const speedKBps = bitrateKbps / 8;
if (speedKBps > 1024) {
speedStr = `${(speedKBps / 1024).toFixed(2)} MB/s`;
} else {
speedStr = `${speedKBps.toFixed(2)} KB/s`;
}
}
storageService.updateActiveDownload(downloadId, {
progress: parseFloat(percentage.toFixed(1)),
totalSize: totalSizeStr,
speed: speedStr
});
}
});
ffmpeg.on('close', (code) => {
if (code === 0) {
console.log("ffmpeg process finished successfully");
resolve();
} else {
console.error(`ffmpeg process exited with code ${code}`);
// If killed (null code) or error
if (code === null) {
// Likely killed by user, reject? Or resolve if handled?
// If killed by onStart callback, we might want to reject to stop flow
reject(new Error("Download cancelled"));
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
}
});
ffmpeg.on('error', (err) => {
console.error("Failed to start ffmpeg:", err);
reject(err);
});
});
console.log("Video download complete");

View File

@@ -1,304 +0,0 @@
import axios from "axios";
import fs from "fs-extra";
import path from "path";
import youtubedl from "youtube-dl-exec";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
export class YouTubeDownloader {
// Search for videos on YouTube
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
} as any);
if (!searchResults || !(searchResults as any).entries) {
return [];
}
// Format the search results
const formattedResults = (searchResults as any).entries.map((entry: any) => ({
id: entry.id,
title: entry.title,
author: entry.uploader,
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
source: "youtube",
}));
console.log(
`Found ${formattedResults.length} search results for "${query}"`
);
return formattedResults;
}
// Get video info without downloading
static async getVideoInfo(url: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
try {
const info = await youtubedl(url, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
} as any);
return {
title: info.title || "YouTube Video",
author: info.uploader || "YouTube User",
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: info.thumbnail,
};
} catch (error) {
console.error("Error fetching YouTube video info:", error);
return {
title: "YouTube Video",
author: "YouTube User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
}
// Download YouTube video
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected YouTube URL");
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
try {
// Get YouTube video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
} as any);
console.log("YouTube video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
// Download the YouTube video
console.log("Downloading YouTube video to:", newVideoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0
});
}
// Use exec to capture stdout for progress
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
// avc1 is the H.264 variant that Safari supports best
// Use Android client to avoid SABR streaming issues and JS runtime requirements
const subprocess = youtubedl.exec(videoUrl, {
output: newVideoPath,
format: "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best",
mergeOutputFormat: "mp4",
'extractor-args': "youtube:player_client=android",
addHeader: [
'Referer:https://www.youtube.com/',
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
]
} as any);
if (onStart) {
onStart(() => {
console.log("Killing subprocess for download:", downloadId);
subprocess.kill();
// Clean up partial files
console.log("Cleaning up partial files...");
try {
// youtube-dl creates .part files during download
const partVideoPath = `${newVideoPath}.part`;
const partThumbnailPath = `${newThumbnailPath}.part`;
if (fs.existsSync(partVideoPath)) {
fs.unlinkSync(partVideoPath);
console.log("Deleted partial video file:", partVideoPath);
}
if (fs.existsSync(newVideoPath)) {
fs.unlinkSync(newVideoPath);
console.log("Deleted partial video file:", newVideoPath);
}
if (fs.existsSync(partThumbnailPath)) {
fs.unlinkSync(partThumbnailPath);
console.log("Deleted partial thumbnail file:", partThumbnailPath);
}
if (fs.existsSync(newThumbnailPath)) {
fs.unlinkSync(newThumbnailPath);
console.log("Deleted partial thumbnail file:", newThumbnailPath);
}
} catch (cleanupError) {
console.error("Error cleaning up partial files:", cleanupError);
}
});
}
subprocess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
const progressMatch = output.match(/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/);
if (progressMatch && downloadId) {
const percentage = parseFloat(progressMatch[1]);
const totalSize = progressMatch[2];
const speed = progressMatch[3];
storageService.updateActiveDownload(downloadId, {
progress: percentage,
totalSize: totalSize,
speed: speed
});
}
});
await subprocess;
console.log("YouTube video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", newThumbnailPath);
} catch (thumbnailError) {
console.error("Error downloading thumbnail:", thumbnailError);
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
throw youtubeError;
}
// Create metadata for the video
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle || "Video",
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: "youtube",
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
duration: undefined, // Will be populated below
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
// If duration is missing from info, try to extract it from file
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
// But wait, finalVideoFilename is available.
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
try {
// Dynamic import to avoid circular dependency if any, though here it's fine
const { getVideoDuration } = await import("../../services/metadataService");
const duration = await getVideoDuration(finalVideoPath);
if (duration) {
videoData.duration = duration.toString();
}
} catch (e) {
console.error("Failed to extract duration from downloaded file:", e);
}
// Get file size
try {
if (fs.existsSync(finalVideoPath)) {
const stats = fs.statSync(finalVideoPath);
videoData.fileSize = stats.size.toString();
}
} catch (e) {
console.error("Failed to get file size:", e);
}
// Save the video
storageService.saveVideo(videoData);
console.log("Video added to database");
return videoData;
}
}

View File

@@ -0,0 +1,472 @@
import axios from "axios";
import fs from "fs-extra";
import path from "path";
import youtubedl from "youtube-dl-exec";
import { DATA_DIR, IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
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");
const COOKIES_PATH = path.join(DATA_DIR, "cookies.txt");
// Helper to get cookie arguments if cookies file exists
function getCookieArgs(): any {
if (fs.existsSync(COOKIES_PATH)) {
console.log("Using cookies from:", COOKIES_PATH);
return { cookies: COOKIES_PATH };
}
return {};
}
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
try {
console.log("Attempting to extract XiaoHongShu author from webpage...");
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000
});
const html = response.data;
// Try to find author name in the JSON data embedded in the page
// XiaoHongShu embeds data in window.__INITIAL_STATE__
const match = html.match(/"nickname":"([^"]+)"/);
if (match && match[1]) {
console.log("Found XiaoHongShu author:", match[1]);
return match[1];
}
// Alternative: try to find in user info
const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
if (userMatch && userMatch[1]) {
console.log("Found XiaoHongShu author (user):", userMatch[1]);
return userMatch[1];
}
console.log("Could not extract XiaoHongShu author from webpage");
return null;
} catch (error) {
console.error("Error extracting XiaoHongShu author:", error);
return null;
}
}
export class YtDlpDownloader {
// Search for videos (primarily for YouTube, but could be adapted)
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
// Use ytsearch for searching
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
...getCookieArgs(),
} as any, { execPath: YT_DLP_PATH } as any);
if (!searchResults || !(searchResults as any).entries) {
return [];
}
// Format the search results
const formattedResults = (searchResults as any).entries.map((entry: any) => ({
id: entry.id,
title: entry.title,
author: entry.uploader,
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
source: "youtube",
}));
console.log(
`Found ${formattedResults.length} search results for "${query}"`
);
return formattedResults;
}
// Get video info without downloading
static async getVideoInfo(url: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
try {
const info = await youtubedl(url, {
dumpSingleJson: true,
noWarnings: true,
preferFreeFormats: true,
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
...getCookieArgs(),
} as any, { execPath: YT_DLP_PATH } as any);
return {
title: info.title || "Video",
author: info.uploader || "Unknown",
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: info.thumbnail,
};
} catch (error) {
console.error("Error fetching video info:", error);
return {
title: "Video",
author: "Unknown",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
}
// 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}`,
...getCookieArgs(),
} 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);
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
let subtitles: Array<{ language: string; filename: string; path: string }> = [];
try {
// Get video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
preferFreeFormats: true,
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
...getCookieArgs(),
} as any, { execPath: YT_DLP_PATH } as any);
console.log("Video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
extractor: info.extractor,
});
videoTitle = info.title || "Video";
videoAuthor = info.uploader || "Unknown";
// If author is unknown and it's a XiaoHongShu video, try custom extraction
if ((!info.uploader || info.uploader === "Unknown") && info.extractor === "XiaoHongShu") {
const customAuthor = await extractXiaoHongShuAuthor(videoUrl);
if (customAuthor) {
videoAuthor = customAuthor;
}
}
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
source = info.extractor || "generic";
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
// Download the video
console.log("Downloading video to:", newVideoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0
});
}
// Prepare flags
const flags: any = {
output: newVideoPath,
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
mergeOutputFormat: "mp4",
writeSubs: true,
writeAutoSubs: true,
convertSubs: "vtt",
};
// Add YouTube specific flags if it's a YouTube URL
// Add YouTube specific flags if it's a YouTube URL
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
// Use a more generic format selection that works well with cookies
flags.format = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best";
flags.addHeader = [
'Referer:https://www.youtube.com/',
'User-Agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
}
// Add PO Token provider args
flags.extractorArgs = `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`;
// Add cookies if available
const cookieArgs = getCookieArgs();
if (cookieArgs.cookies) {
flags.cookies = cookieArgs.cookies;
}
// Use exec to capture stdout for progress
const subprocess = youtubedl.exec(videoUrl, flags, { execPath: YT_DLP_PATH } as any);
if (onStart) {
onStart(() => {
console.log("Killing subprocess for download:", downloadId);
subprocess.kill();
// Clean up partial files
console.log("Cleaning up partial files...");
try {
const partVideoPath = `${newVideoPath}.part`;
const partThumbnailPath = `${newThumbnailPath}.part`;
if (fs.existsSync(partVideoPath)) {
fs.unlinkSync(partVideoPath);
console.log("Deleted partial video file:", partVideoPath);
}
if (fs.existsSync(newVideoPath)) {
fs.unlinkSync(newVideoPath);
console.log("Deleted partial video file:", newVideoPath);
}
if (fs.existsSync(partThumbnailPath)) {
fs.unlinkSync(partThumbnailPath);
console.log("Deleted partial thumbnail file:", partThumbnailPath);
}
if (fs.existsSync(newThumbnailPath)) {
fs.unlinkSync(newThumbnailPath);
console.log("Deleted partial thumbnail file:", newThumbnailPath);
}
} catch (cleanupError) {
console.error("Error cleaning up partial files:", cleanupError);
}
});
}
subprocess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
const progressMatch = output.match(/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/);
if (progressMatch && downloadId) {
const percentage = parseFloat(progressMatch[1]);
const totalSize = progressMatch[2];
const speed = progressMatch[3];
storageService.updateActiveDownload(downloadId, {
progress: percentage,
totalSize: totalSize,
speed: speed
});
}
});
await subprocess;
console.log("Video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", newThumbnailPath);
} catch (thumbnailError) {
console.error("Error downloading thumbnail:", thumbnailError);
// Continue even if thumbnail download fails
}
}
// Scan for subtitle files
try {
const baseFilename = newSafeBaseFilename;
const subtitleFiles = fs.readdirSync(VIDEOS_DIR).filter((file: string) =>
file.startsWith(baseFilename) && file.endsWith(".vtt")
);
console.log(`Found ${subtitleFiles.length} subtitle files`);
for (const subtitleFile of subtitleFiles) {
// Parse language from filename (e.g., video_123.en.vtt -> en)
const match = subtitleFile.match(/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.vtt$/);
const language = match ? match[1] : "unknown";
// Move subtitle to subtitles directory
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
const destSubFilename = `${baseFilename}.${language}.vtt`;
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
// Read VTT file and fix alignment for centering
let vttContent = fs.readFileSync(sourceSubPath, 'utf-8');
// Replace align:start with align:middle for centered subtitles
// Also remove position:0% which forces left positioning
vttContent = vttContent.replace(/ align:start/g, ' align:middle');
vttContent = vttContent.replace(/ position:0%/g, '');
// Write cleaned VTT to destination
fs.writeFileSync(destSubPath, vttContent, 'utf-8');
// Remove original file
fs.unlinkSync(sourceSubPath);
console.log(`Processed and moved subtitle ${subtitleFile} to ${destSubPath}`);
subtitles.push({
language,
filename: destSubFilename,
path: `/subtitles/${destSubFilename}`,
});
}
} catch (subtitleError) {
console.error("Error processing subtitle files:", subtitleError);
}
} catch (error) {
console.error("Error in download process:", error);
throw error;
}
// Create metadata for the video
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle || "Video",
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: source, // Use extracted source
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
subtitles: subtitles.length > 0 ? subtitles : undefined,
duration: undefined, // Will be populated below
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
// If duration is missing from info, try to extract it from file
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
try {
const { getVideoDuration } = await import("../../services/metadataService");
const duration = await getVideoDuration(finalVideoPath);
if (duration) {
videoData.duration = duration.toString();
}
} catch (e) {
console.error("Failed to extract duration from downloaded file:", e);
}
// Get file size
try {
if (fs.existsSync(finalVideoPath)) {
const stats = fs.statSync(finalVideoPath);
videoData.fileSize = stats.size.toString();
}
} catch (e) {
console.error("Failed to get file size:", e);
}
// Save the video
storageService.saveVideo(videoData);
console.log("Video added to database");
return videoData;
}
}

View File

@@ -64,9 +64,10 @@ export const backfillDurations = async () => {
const duration = await getVideoDuration(fsPath);
if (duration !== null) {
await db.update(videos)
db.update(videos)
.set({ duration: duration.toString() })
.where(eq(videos.id, video.id));
.where(eq(videos.id, video.id))
.run();
console.log(`Updated duration for ${video.title}: ${duration}s`);
updatedCount++;
}

View File

@@ -68,7 +68,7 @@ export async function runMigration() {
description: video.description,
viewCount: video.viewCount,
duration: video.duration,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
results.videos.count++;
} catch (error: any) {
console.error(`Error migrating video ${video.id}:`, error);
@@ -96,7 +96,7 @@ export async function runMigration() {
title: collection.title,
createdAt: collection.createdAt || new Date().toISOString(),
updatedAt: collection.updatedAt,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
results.collections.count++;
// Insert Collection Videos
@@ -106,7 +106,7 @@ export async function runMigration() {
await db.insert(collectionVideos).values({
collectionId: collection.id,
videoId: videoId,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
} catch (err: any) {
console.error(`Error linking video ${videoId} to collection ${collection.id}:`, err);
results.errors.push(`Link ${videoId}->${collection.id}: ${err.message}`);
@@ -137,7 +137,7 @@ export async function runMigration() {
}).onConflictDoUpdate({
target: settings.key,
set: { value: JSON.stringify(value) },
});
}).run();
results.settings.count++;
}
} catch (error: any) {
@@ -178,7 +178,7 @@ export async function runMigration() {
speed: download.speed,
status: 'active',
}
});
}).run();
results.downloads.count++;
}
}
@@ -198,7 +198,7 @@ export async function runMigration() {
timestamp: download.timestamp,
status: 'queued',
}
});
}).run();
results.downloads.count++;
}
}

View File

@@ -2,13 +2,14 @@ import { desc, eq, lt } from "drizzle-orm";
import fs from "fs-extra";
import path from "path";
import {
DATA_DIR,
IMAGES_DIR,
STATUS_DATA_PATH,
UPLOADS_DIR,
VIDEOS_DIR,
DATA_DIR,
IMAGES_DIR,
STATUS_DATA_PATH,
SUBTITLES_DIR,
UPLOADS_DIR,
VIDEOS_DIR,
} from "../config/paths";
import { db, sqlite } from "../db";
import { db } from "../db";
import { collections, collectionVideos, downloadHistory, downloads, settings, videos } from "../db/schema";
export interface Video {
@@ -17,6 +18,7 @@ export interface Video {
sourceUrl: string;
videoFilename?: string;
thumbnailFilename?: string;
subtitles?: Array<{ language: string; filename: string; path: string }>;
createdAt: string;
tags?: string[];
viewCount?: number;
@@ -43,6 +45,8 @@ export interface DownloadInfo {
downloadedSize?: string;
progress?: number;
speed?: string;
sourceUrl?: string;
type?: string;
}
export interface DownloadHistoryItem {
@@ -68,6 +72,7 @@ export function initializeStorage(): void {
fs.ensureDirSync(UPLOADS_DIR);
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
fs.ensureDirSync(SUBTITLES_DIR);
fs.ensureDirSync(DATA_DIR);
// Initialize status.json if it doesn't exist
@@ -100,77 +105,9 @@ export function initializeStorage(): void {
console.error("Error clearing active downloads from database:", error);
}
// Check and migrate tags column if needed
try {
const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all();
const hasTags = (tableInfo as any[]).some((col: any) => col.name === 'tags');
if (!hasTags) {
console.log("Migrating database: Adding tags column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN tags TEXT").run();
console.log("Migration successful.");
}
} catch (error) {
console.error("Error checking/migrating tags column:", error);
}
// Check and migrate viewCount and progress columns if needed
try {
const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all();
const columns = (tableInfo as any[]).map((col: any) => col.name);
if (!columns.includes('view_count')) {
console.log("Migrating database: Adding view_count column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN view_count INTEGER DEFAULT 0").run();
console.log("Migration successful: view_count added.");
}
if (!columns.includes('progress')) {
console.log("Migrating database: Adding progress column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN progress INTEGER DEFAULT 0").run();
console.log("Migration successful: progress added.");
}
if (!columns.includes('duration')) {
console.log("Migrating database: Adding duration column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN duration TEXT").run();
console.log("Migration successful: duration added.");
}
if (!columns.includes('file_size')) {
console.log("Migrating database: Adding file_size column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN file_size TEXT").run();
console.log("Migration successful: file_size added.");
}
if (!columns.includes('last_played_at')) {
console.log("Migrating database: Adding last_played_at column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN last_played_at INTEGER").run();
console.log("Migration successful: last_played_at added.");
}
// Populate fileSize for existing videos
const allVideos = db.select().from(videos).all();
let updatedCount = 0;
for (const video of allVideos) {
if (!video.fileSize && video.videoFilename) {
const videoPath = findVideoFile(video.videoFilename);
if (videoPath && fs.existsSync(videoPath)) {
const stats = fs.statSync(videoPath);
db.update(videos)
.set({ fileSize: stats.size.toString() })
.where(eq(videos.id, video.id))
.run();
updatedCount++;
}
}
}
if (updatedCount > 0) {
console.log(`Populated fileSize for ${updatedCount} videos.`);
}
} catch (error) {
console.error("Error checking/migrating viewCount/progress/duration/fileSize columns:", error);
}
}
@@ -184,6 +121,11 @@ export function addActiveDownload(id: string, title: string): void {
title,
timestamp: now,
status: 'active',
// We might want to pass sourceUrl and type here too if available,
// but addActiveDownload signature currently only has id and title.
// We will update the signature in a separate step or let updateActiveDownload handle it.
// Actually, let's update the signature now to be safe, but that breaks callers.
// For now, let's just insert what we have.
}).onConflictDoUpdate({
target: downloads.id,
set: {
@@ -205,6 +147,8 @@ export function updateActiveDownload(id: string, updates: Partial<DownloadInfo>)
// Map fields to DB columns if necessary (though they match mostly)
if (updates.totalSize) updateData.totalSize = updates.totalSize;
if (updates.downloadedSize) updateData.downloadedSize = updates.downloadedSize;
if (updates.sourceUrl) updateData.sourceUrl = updates.sourceUrl;
if (updates.type) updateData.type = updates.type;
db.update(downloads)
.set(updateData)
@@ -238,12 +182,16 @@ export function setQueuedDownloads(queuedDownloads: DownloadInfo[]): void {
title: download.title,
timestamp: download.timestamp,
status: 'queued',
sourceUrl: download.sourceUrl,
type: download.type,
}).onConflictDoUpdate({
target: downloads.id,
set: {
title: download.title,
timestamp: download.timestamp,
status: 'queued'
status: 'queued',
sourceUrl: download.sourceUrl,
type: download.type,
}
}).run();
}
@@ -274,6 +222,8 @@ export function getDownloadStatus(): DownloadStatus {
downloadedSize: d.downloadedSize || undefined,
progress: d.progress || undefined,
speed: d.speed || undefined,
sourceUrl: d.sourceUrl || undefined,
type: d.type || undefined,
}));
const queuedDownloads = allDownloads
@@ -282,6 +232,8 @@ export function getDownloadStatus(): DownloadStatus {
id: d.id,
title: d.title,
timestamp: d.timestamp || 0,
sourceUrl: d.sourceUrl || undefined,
type: d.type || undefined,
}));
return { activeDownloads, queuedDownloads };
@@ -396,6 +348,7 @@ export function getVideos(): Video[] {
return allVideos.map(v => ({
...v,
tags: v.tags ? JSON.parse(v.tags) : [],
subtitles: v.subtitles ? JSON.parse(v.subtitles) : undefined,
})) as Video[];
} catch (error) {
console.error("Error getting videos:", error);
@@ -410,6 +363,7 @@ export function getVideoById(id: string): Video | undefined {
return {
...video,
tags: video.tags ? JSON.parse(video.tags) : [],
subtitles: video.subtitles ? JSON.parse(video.subtitles) : undefined,
} as Video;
}
return undefined;
@@ -424,6 +378,7 @@ export function saveVideo(videoData: Video): Video {
const videoToSave = {
...videoData,
tags: videoData.tags ? JSON.stringify(videoData.tags) : undefined,
subtitles: videoData.subtitles ? JSON.stringify(videoData.subtitles) : undefined,
};
db.insert(videos).values(videoToSave as any).onConflictDoUpdate({
target: videos.id,
@@ -441,6 +396,7 @@ export function updateVideo(id: string, updates: Partial<Video>): Video | null {
const updatesToSave = {
...updates,
tags: updates.tags ? JSON.stringify(updates.tags) : undefined,
subtitles: updates.subtitles ? JSON.stringify(updates.subtitles) : undefined,
};
// If tags is explicitly empty array, we might want to save it as '[]' or null.
// JSON.stringify([]) is '[]', which is fine.
@@ -451,6 +407,7 @@ export function updateVideo(id: string, updates: Partial<Video>): Video | null {
return {
...result,
tags: result.tags ? JSON.parse(result.tags) : [],
subtitles: result.subtitles ? JSON.parse(result.subtitles) : undefined,
} as Video;
}
return null;
@@ -465,7 +422,7 @@ export function deleteVideo(id: string): boolean {
const videoToDelete = getVideoById(id);
if (!videoToDelete) return false;
// Remove files
// Remove video file
if (videoToDelete.videoFilename) {
const actualPath = findVideoFile(videoToDelete.videoFilename);
if (actualPath && fs.existsSync(actualPath)) {
@@ -473,6 +430,7 @@ export function deleteVideo(id: string): boolean {
}
}
// Remove thumbnail file
if (videoToDelete.thumbnailFilename) {
const actualPath = findImageFile(videoToDelete.thumbnailFilename);
if (actualPath && fs.existsSync(actualPath)) {
@@ -480,6 +438,17 @@ export function deleteVideo(id: string): boolean {
}
}
// Remove subtitle files
if (videoToDelete.subtitles && videoToDelete.subtitles.length > 0) {
for (const subtitle of videoToDelete.subtitles) {
const subtitlePath = path.join(SUBTITLES_DIR, subtitle.filename);
if (fs.existsSync(subtitlePath)) {
fs.unlinkSync(subtitlePath);
console.log(`Deleted subtitle file: ${subtitle.filename}`);
}
}
}
// Delete from DB
db.delete(videos).where(eq(videos.id, id)).run();
return true;
@@ -799,45 +768,28 @@ export function deleteCollectionWithFiles(collectionId: string): boolean {
collection.videos.forEach(videoId => {
const video = getVideoById(videoId);
if (video) {
const allCollections = getCollections();
const otherCollection = allCollections.find(c => c.videos.includes(videoId) && c.id !== collectionId);
let targetVideoDir = VIDEOS_DIR;
let targetImageDir = IMAGES_DIR;
let videoPathPrefix = '/videos';
let imagePathPrefix = '/images';
if (otherCollection) {
const otherName = otherCollection.name || otherCollection.title;
if (otherName) {
targetVideoDir = path.join(VIDEOS_DIR, otherName);
targetImageDir = path.join(IMAGES_DIR, otherName);
videoPathPrefix = `/videos/${otherName}`;
imagePathPrefix = `/images/${otherName}`;
}
}
// Move files back to root
const updates: Partial<Video> = {};
let updated = false;
if (video.videoFilename) {
const currentVideoPath = findVideoFile(video.videoFilename);
const targetVideoPath = path.join(targetVideoDir, video.videoFilename);
const targetVideoPath = path.join(VIDEOS_DIR, video.videoFilename);
if (currentVideoPath && currentVideoPath !== targetVideoPath) {
moveFile(currentVideoPath, targetVideoPath);
updates.videoPath = `${videoPathPrefix}/${video.videoFilename}`;
updates.videoPath = `/videos/${video.videoFilename}`;
updated = true;
}
}
if (video.thumbnailFilename) {
const currentImagePath = findImageFile(video.thumbnailFilename);
const targetImagePath = path.join(targetImageDir, video.thumbnailFilename);
const targetImagePath = path.join(IMAGES_DIR, video.thumbnailFilename);
if (currentImagePath && currentImagePath !== targetImagePath) {
moveFile(currentImagePath, targetImagePath);
updates.thumbnailPath = `${imagePathPrefix}/${video.thumbnailFilename}`;
updates.thumbnailPath = `/images/${video.thumbnailFilename}`;
updated = true;
}
}
@@ -849,25 +801,24 @@ export function deleteCollectionWithFiles(collectionId: string): boolean {
});
}
const success = deleteCollection(collectionId);
if (success && collectionName) {
// Delete collection directory if exists and empty
if (collectionName) {
const collectionVideoDir = path.join(VIDEOS_DIR, collectionName);
const collectionImageDir = path.join(IMAGES_DIR, collectionName);
try {
const videoCollectionDir = path.join(VIDEOS_DIR, collectionName);
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
if (fs.existsSync(videoCollectionDir) && fs.readdirSync(videoCollectionDir).length === 0) {
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionVideoDir) && fs.readdirSync(collectionVideoDir).length === 0) {
fs.rmdirSync(collectionVideoDir);
}
if (fs.existsSync(imageCollectionDir) && fs.readdirSync(imageCollectionDir).length === 0) {
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionImageDir) && fs.readdirSync(collectionImageDir).length === 0) {
fs.rmdirSync(collectionImageDir);
}
} catch (error) {
console.error("Error removing collection directories:", error);
} catch (e) {
console.error("Error removing collection directories:", e);
}
}
return success;
return deleteCollection(collectionId);
}
export function deleteCollectionAndVideos(collectionId: string): boolean {
@@ -875,32 +826,30 @@ export function deleteCollectionAndVideos(collectionId: string): boolean {
if (!collection) return false;
const collectionName = collection.name || collection.title;
// Delete all videos in the collection
if (collection.videos && collection.videos.length > 0) {
const videosToDelete = [...collection.videos];
videosToDelete.forEach(videoId => {
collection.videos.forEach(videoId => {
deleteVideo(videoId);
});
}
const success = deleteCollection(collectionId);
if (success && collectionName) {
// Delete collection directory if exists
if (collectionName) {
const collectionVideoDir = path.join(VIDEOS_DIR, collectionName);
const collectionImageDir = path.join(IMAGES_DIR, collectionName);
try {
const videoCollectionDir = path.join(VIDEOS_DIR, collectionName);
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
if (fs.existsSync(videoCollectionDir)) {
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionVideoDir)) {
fs.rmdirSync(collectionVideoDir);
}
if (fs.existsSync(imageCollectionDir)) {
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionImageDir)) {
fs.rmdirSync(collectionImageDir);
}
} catch (error) {
console.error("Error removing collection directories:", error);
} catch (e) {
console.error("Error removing collection directories:", e);
}
}
return success;
return deleteCollection(collectionId);
}

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

@@ -108,9 +108,13 @@ export function sanitizeFilename(filename: string): string {
// Replace only unsafe characters for filesystems
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
return withoutHashtags
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
const sanitized = withoutHashtags
.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)
// We use 200 to leave room for timestamp suffix and extension
return sanitized.slice(0, 200);
}
// Helper function to extract user mid from Bilibili URL

View File

@@ -0,0 +1,46 @@
# API Endpoints
## Videos
- `POST /api/download` - Download a video (YouTube or Bilibili)
- `POST /api/upload` - Upload a local video file
- `GET /api/videos` - Get all downloaded videos
- `GET /api/videos/:id` - Get a specific video
- `PUT /api/videos/:id` - Update video details
- `DELETE /api/videos/:id` - Delete a video
- `GET /api/videos/:id/comments` - Get video comments
- `POST /api/videos/:id/rate` - Rate a video
- `POST /api/videos/:id/refresh-thumbnail` - Refresh video thumbnail
- `POST /api/videos/:id/view` - Increment view count
- `PUT /api/videos/:id/progress` - Update playback progress
- `GET /api/search` - Search for videos online
- `GET /api/download-status` - Get status of active downloads
- `GET /api/check-bilibili-parts` - Check if a Bilibili video has multiple parts
- `GET /api/check-bilibili-collection` - Check if a Bilibili URL is a collection/series
## Download Management
- `POST /api/downloads/cancel/:id` - Cancel a download
- `DELETE /api/downloads/queue/:id` - Remove from queue
- `DELETE /api/downloads/queue` - Clear queue
- `GET /api/downloads/history` - Get download history
- `DELETE /api/downloads/history/:id` - Remove from history
- `DELETE /api/downloads/history` - Clear history
## Collections
- `GET /api/collections` - Get all collections
- `POST /api/collections` - Create a new collection
- `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
- `POST /api/settings/verify-password` - Verify login password
- `POST /api/settings/migrate` - Migrate data from JSON to SQLite
- `POST /api/settings/delete-legacy` - Delete legacy JSON data
- `POST /api/scan-files` - Scan for existing files
- `POST /api/cleanup-temp-files` - Cleanup temporary download files

View File

@@ -0,0 +1,32 @@
# Directory Structure
```
mytube/
├── backend/ # Express.js backend (TypeScript)
│ ├── src/ # Source code
│ │ ├── config/ # Configuration files
│ │ ├── controllers/ # Route controllers
│ │ ├── db/ # Database migrations and setup
│ │ ├── routes/ # API routes
│ │ ├── services/ # Business logic services
│ │ ├── utils/ # Utility functions
│ │ └── server.ts # Main server file
│ ├── uploads/ # Uploaded files directory
│ │ ├── videos/ # Downloaded videos
│ │ └── images/ # Downloaded thumbnails
│ └── package.json # Backend dependencies
├── frontend/ # React.js frontend (Vite + TypeScript)
│ ├── src/ # Source code
│ │ ├── assets/ # Images and styles
│ │ ├── components/ # React components
│ │ ├── contexts/ # React contexts
│ │ ├── pages/ # Page components
│ │ ├── utils/ # Utilities and locales
│ │ └── theme.ts # Theme configuration
│ └── package.json # Frontend dependencies
├── build-and-push.sh # Docker build script
├── docker-compose.yml # Docker Compose configuration
├── DEPLOYMENT.md # Deployment guide
├── CONTRIBUTING.md # Contributing guidelines
└── package.json # Root package.json for running both apps
```

View File

@@ -0,0 +1,190 @@
# Docker Deployment Guide for MyTube
This guide provides step-by-step instructions to deploy [MyTube](https://github.com/franklioxygen/MyTube "null") using Docker and Docker Compose. This setup is designed for standard environments (Linux, macOS, Windows) and modifies the original QNAP-specific configurations for general use.
## 🚀 Quick Start (Pre-built Images)
The easiest way to run MyTube is using the official pre-built images.
### 1. Create a Project Directory
Create a folder for your project and navigate into it:
```
mkdir mytube-deploy
cd mytube-deploy
```
### 2. Create the `docker-compose.yml`
Create a file named `docker-compose.yml` inside your folder and paste the following content.
**Note:** This version uses standard relative paths (`./data`, `./uploads`) instead of the QNAP-specific paths found in the original repository.
```
version: '3.8'
services:
backend:
image: franklioxygen/mytube:backend-latest
container_name: mytube-backend
pull_policy: always
restart: unless-stopped
ports:
- "5551:5551"
environment:
- PORT=5551
# Optional: Set a custom upload directory inside container if needed
# - VIDEO_DIR=/app/uploads/videos
volumes:
- ./uploads:/app/uploads
- ./data:/app/data
networks:
- mytube-network
frontend:
image: franklioxygen/mytube:frontend-latest
container_name: mytube-frontend
pull_policy: always
restart: unless-stopped
ports:
- "5556:5556"
environment:
# Internal Docker networking URLs (Browser -> Frontend -> Backend)
# In most setups, these defaults work fine.
- VITE_API_URL=/api
- VITE_BACKEND_URL=
depends_on:
- backend
networks:
- mytube-network
networks:
mytube-network:
driver: bridge
```
### 3. Start the Application
Run the following command to start the services in the background:
```
docker-compose up -d
```
### 4. Access MyTube
Once the containers are running, access the application in your browser:
- **Frontend UI:** `http://localhost:5556`
- **Backend API:** `http://localhost:5551`
## ⚙️ Configuration & Data Persistence
### Volumes (Data Storage)
The `docker-compose.yml` above creates two folders in your current directory to persist data:
- `./uploads`: Stores downloaded videos and thumbnails.
- `./data`: Stores the SQLite database and logs.
**Important:** If you move the `docker-compose.yml` file, you must move these folders with it to keep your data.
### Environment Variables
You can customize the deployment by adding a `.env` file or modifying the `environment` section in `docker-compose.yml`.
|Variable|Service|Description|Default|
|---|---|---|---|
|`PORT`|Backend|Port the backend listens on internally|`5551`|
|`VITE_API_URL`|Frontend|API endpoint path|`/api`|
|`API_HOST`|Frontend|**Advanced:** Force a specific backend IP|_(Auto-detected)_|
|`API_PORT`|Frontend|**Advanced:** Force a specific backend Port|`5551`|
## 🛠️ Advanced Networking (Remote/NAS Deployment)
If you are deploying this on a remote server (e.g., a VPS or NAS) and accessing it from a different computer, the default relative API paths usually work fine.
However, if you experience connection issues where the frontend cannot reach the backend, you may need to explicitly tell the frontend where the API is located.
1. Create a `.env` file in the same directory as `docker-compose.yml`:
```
API_HOST=192.168.1.100 # Replace with your server's LAN/WAN IP
API_PORT=5551
```
2. Restart the containers:
```
docker-compose down
docker-compose up -d
```
## 🏗️ Building from Source (Optional)
If you prefer to build the images yourself (e.g., to modify code), follow these steps:
1. **Clone the Repository:**
```
git clone [https://github.com/franklioxygen/MyTube.git](https://github.com/franklioxygen/MyTube.git)
cd MyTube
```
2. **Build and Run:** You can use the same `docker-compose.yml` structure, but replace `image: ...` with `build: ...`.
Modify `docker-compose.yml`:
```
services:
backend:
build: ./backend
# ... other settings
frontend:
build: ./frontend
# ... other settings
```
3. **Start:**
```
docker-compose up -d --build
```
## ❓ Troubleshooting
### 1. "Network Error" or API connection failed
- **Cause:** The browser cannot reach the backend API.
- **Fix:** Ensure port `5551` is open on your firewall. If running on a remote server, try setting the `API_HOST` in a `.env` file as described in the "Advanced Networking" section.
### 2. Permission Denied for `./uploads`
- **Cause:** The Docker container user doesn't have write permissions to the host directory.
- **Fix:** Adjust permissions on your host machine:
```
chmod -R 777 ./uploads ./data
```
### 3. Container Name Conflicts
- **Cause:** You have another instance of MyTube running or an old container wasn't removed.
- **Fix:** Remove old containers before starting:
```
docker rm -f mytube-backend mytube-frontend
docker-compose up -d
```

View File

@@ -0,0 +1,66 @@
# Getting Started
## Prerequisites
- 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
1. Clone the repository:
```bash
git clone <repository-url>
cd mytube
```
2. Install dependencies:
You can install all dependencies for the root, frontend, and backend with a single command:
```bash
npm run install:all
```
Or manually:
```bash
npm install
cd frontend && npm install
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:
```bash
npm run dev # Start both frontend and backend in development mode
```
Other available scripts:
```bash
npm run start # Start both frontend and backend in production mode
npm run build # Build the frontend for production
npm run lint # Run linting for frontend
npm run lint:fix # Fix linting errors for frontend
```
## Accessing the Application
- Frontend: http://localhost:5556
- Backend API: http://localhost:5551

View File

@@ -0,0 +1,46 @@
# API 端点
## 视频
- `POST /api/download` - 下载视频 (YouTube 或 Bilibili)
- `POST /api/upload` - 上传本地视频文件
- `GET /api/videos` - 获取所有已下载的视频
- `GET /api/videos/:id` - 获取特定视频
- `PUT /api/videos/:id` - 更新视频详情
- `DELETE /api/videos/:id` - 删除视频
- `GET /api/videos/:id/comments` - 获取视频评论
- `POST /api/videos/:id/rate` - 评价视频
- `POST /api/videos/:id/refresh-thumbnail` - 刷新视频缩略图
- `POST /api/videos/:id/view` - 增加观看次数
- `PUT /api/videos/:id/progress` - 更新播放进度
- `GET /api/search` - 在线搜索视频
- `GET /api/download-status` - 获取当前下载状态
- `GET /api/check-bilibili-parts` - 检查 Bilibili 视频是否包含多个分P
- `GET /api/check-bilibili-collection` - 检查 Bilibili URL 是否为合集/系列
## 下载管理
- `POST /api/downloads/cancel/:id` - 取消下载
- `DELETE /api/downloads/queue/:id` - 从队列中移除
- `DELETE /api/downloads/queue` - 清空队列
- `GET /api/downloads/history` - 获取下载历史
- `DELETE /api/downloads/history/:id` - 从历史中移除
- `DELETE /api/downloads/history` - 清空历史
## 收藏夹
- `GET /api/collections` - 获取所有收藏夹
- `POST /api/collections` - 创建新收藏夹
- `PUT /api/collections/:id` - 更新收藏夹 (添加/移除视频)
- `DELETE /api/collections/:id` - 删除收藏夹
## 订阅
- `GET /api/subscriptions` - 获取所有订阅
- `POST /api/subscriptions` - 创建新订阅
- `DELETE /api/subscriptions/:id` - 删除订阅
## 设置与系统
- `GET /api/settings` - 获取应用设置
- `POST /api/settings` - 更新应用设置
- `POST /api/settings/verify-password` - 验证登录密码
- `POST /api/settings/migrate` - 从 JSON 迁移数据到 SQLite
- `POST /api/settings/delete-legacy` - 删除旧的 JSON 数据
- `POST /api/scan-files` - 扫描现有文件
- `POST /api/cleanup-temp-files` - 清理临时下载文件

View File

@@ -0,0 +1,32 @@
# 目录结构
```
mytube/
├── backend/ # Express.js 后端 (TypeScript)
│ ├── src/ # 源代码
│ │ ├── config/ # 配置文件
│ │ ├── controllers/ # 路由控制器
│ │ ├── db/ # 数据库迁移和设置
│ │ ├── routes/ # API 路由
│ │ ├── services/ # 业务逻辑服务
│ │ ├── utils/ # 工具函数
│ │ └── server.ts # 主服务器文件
│ ├── uploads/ # 上传文件目录
│ │ ├── videos/ # 下载的视频
│ │ └── images/ # 下载的缩略图
│ └── package.json # 后端依赖
├── frontend/ # React.js 前端 (Vite + TypeScript)
│ ├── src/ # 源代码
│ │ ├── assets/ # 图片和样式
│ │ ├── components/ # React 组件
│ │ ├── contexts/ # React 上下文
│ │ ├── pages/ # 页面组件
│ │ ├── utils/ # 工具和多语言文件
│ │ └── theme.ts # 主题配置
│ └── package.json # 前端依赖
├── build-and-push.sh # Docker 构建脚本
├── docker-compose.yml # Docker Compose 配置
├── DEPLOYMENT.md # 部署指南
├── CONTRIBUTING.md # 贡献指南
└── package.json # 运行两个应用的根 package.json
```

View File

@@ -0,0 +1,190 @@
# MyTube Docker 部署指南
本指南提供了使用 Docker 和 Docker Compose 部署 [MyTube](https://github.com/franklioxygen/MyTube "null") 的详细步骤。此设置适用于标准环境Linux, macOS, Windows并针对通用用途修改了原本专用于 QNAP 的配置。
## 🚀 快速开始 (使用预构建镜像)
运行 MyTube 最简单的方法是使用官方预构建的镜像。
### 1. 创建项目目录
为您的项目创建一个文件夹并进入该目录:
```
mkdir mytube-deploy
cd mytube-deploy
```
### 2. 创建 `docker-compose.yml` 文件
在文件夹中创建一个名为 `docker-compose.yml` 的文件,并粘贴以下内容。
**注意:** 此版本使用标准的相对路径(`./data`, `./uploads`),而不是原始仓库中特定于 QNAP 的路径。
```
version: '3.8'
services:
backend:
image: franklioxygen/mytube:backend-latest
container_name: mytube-backend
pull_policy: always
restart: unless-stopped
ports:
- "5551:5551"
environment:
- PORT=5551
# 可选:如果需要,在容器内设置自定义上传目录
# - VIDEO_DIR=/app/uploads/videos
volumes:
- ./uploads:/app/uploads
- ./data:/app/data
networks:
- mytube-network
frontend:
image: franklioxygen/mytube:frontend-latest
container_name: mytube-frontend
pull_policy: always
restart: unless-stopped
ports:
- "5556:5556"
environment:
# 内部 Docker 网络 URL浏览器 -> 前端 -> 后端)
# 在大多数设置中,这些默认值都可以正常工作。
- VITE_API_URL=/api
- VITE_BACKEND_URL=
depends_on:
- backend
networks:
- mytube-network
networks:
mytube-network:
driver: bridge
```
### 3. 启动应用
运行以下命令在后台启动服务:
```
docker-compose up -d
```
### 4. 访问 MyTube
容器运行后,请在浏览器中访问应用程序:
- **前端 UI:** `http://localhost:5556`
- **后端 API:** `http://localhost:5551`
## ⚙️ 配置与数据持久化
### 卷 (数据存储)
上面的 `docker-compose.yml` 在当前目录中创建了两个文件夹来持久保存数据:
- `./uploads`: 存储下载的视频和缩略图。
- `./data`: 存储 SQLite 数据库和日志。
**重要提示:** 如果您移动 `docker-compose.yml` 文件,必须同时移动这些文件夹以保留您的数据。
### 环境变量
您可以通过添加 `.env` 文件或修改 `docker-compose.yml` 中的 `environment` 部分来自定义部署。
|变量|服务|描述|默认值|
|---|---|---|---|
|`PORT`|Backend|后端内部监听端口|`5551`|
|`VITE_API_URL`|Frontend|API 端点路径|`/api`|
|`API_HOST`|Frontend|**高级:** 强制指定后端 IP|_(自动检测)_|
|`API_PORT`|Frontend|**高级:** 强制指定后端端口|`5551`|
## 🛠️ 高级网络 (远程/NAS 部署)
如果您在远程服务器(例如 VPS 或 NAS上部署并从另一台计算机访问它默认的相对 API 路径通常可以正常工作。
但是,如果您遇到连接问题(前端无法连接到后端),您可能需要明确告诉前端 API 的位置。
1. 在与 `docker-compose.yml` 相同的目录中创建一个 `.env` 文件:
```
API_HOST=192.168.1.100 # 替换为您的服务器局域网/公网 IP
API_PORT=5551
```
2. 重启容器:
```
docker-compose down
docker-compose up -d
```
## 🏗️ 从源码构建 (可选)
如果您更喜欢自己构建镜像(例如,为了修改代码),请按照以下步骤操作:
1. **克隆仓库:**
```
git clone [https://github.com/franklioxygen/MyTube.git](https://github.com/franklioxygen/MyTube.git)
cd MyTube
```
2. **构建并运行:** 您可以使用相同的 `docker-compose.yml` 结构,但将 `image: ...` 替换为 `build: ...`。
修改 `docker-compose.yml`
```
services:
backend:
build: ./backend
# ... 其他设置
frontend:
build: ./frontend
# ... 其他设置
```
3. **启动:**
```
docker-compose up -d --build
```
## ❓ 故障排除 (Troubleshooting)
### 1. "Network Error" 或 API 连接失败
- **原因:** 浏览器无法访问后端 API。
- **解决方法:** 确保端口 `5551` 在您的防火墙上已打开。如果在远程服务器上运行,请尝试按照“高级网络”部分的说明在 `.env` 文件中设置 `API_HOST`。
### 2. `./uploads` 权限被拒绝 (Permission Denied)
- **原因:** Docker 容器用户没有主机目录的写入权限。
- **解决方法:** 调整主机上的权限:
```
chmod -R 777 ./uploads ./data
```
### 3. 容器名称冲突 (Container Name Conflicts)
- **原因:** 您有另一个 MyTube 实例正在运行,或者旧容器未被删除。
- **解决方法:** 在启动前删除旧容器:
```
docker rm -f mytube-backend mytube-frontend
docker-compose up -d
```

View File

@@ -0,0 +1,54 @@
# 开始使用
## 前提条件
- Node.js (v14 或更高版本)
- npm (v6 或更高版本)
- Docker (可选,用于容器化部署)
## 安装
1. 克隆仓库:
```bash
git clone <repository-url>
cd mytube
```
2. 安装依赖:
您可以使用一条命令安装根目录、前端和后端的所有依赖:
```bash
npm run install:all
```
或者手动安装:
```bash
npm install
cd frontend && npm install
cd ../backend && npm install
```
#### 使用 npm 脚本
您可以在根目录下使用 npm 脚本:
```bash
npm run dev # 以开发模式启动前端和后端
```
其他可用脚本:
```bash
npm run start # 以生产模式启动前端和后端
npm run build # 为生产环境构建前端
npm run lint # 运行前端代码检查
npm run lint:fix # 修复前端代码检查错误
```
## 访问应用
- 前端http://localhost:5556
- 后端 APIhttp://localhost:5551

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.3",
"version": "1.3.12",
"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="/collection/:id" element={<CollectionPage />} />
<Route path="/author/:authorName" 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

@@ -22,6 +22,9 @@ const Footer = () => {
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'center', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
v{import.meta.env.VITE_APP_VERSION}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
Created by franklioxygen
</Typography>

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();
@@ -123,10 +123,9 @@ const Header: React.FC<HeaderProps> = ({
return;
}
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
const missavRegex = /^(https?:\/\/)?(www\.)?(missav\.(ai|ws|com))\/.+$/;
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl) || missavRegex.test(videoUrl);
// Generic URL check
const urlRegex = /^(https?:\/\/[^\s]+)/;
const isUrl = urlRegex.test(videoUrl);
setError('');
setIsSubmitting(true);
@@ -167,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>
{(
<>
@@ -202,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,
@@ -225,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%' }}>
@@ -285,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>
</>
)}
@@ -319,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',
@@ -336,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>
);
@@ -485,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

@@ -1,9 +1,6 @@
import {
Delete,
Folder,
Movie,
OndemandVideo,
YouTube
Folder
} from '@mui/icons-material';
import {
Box,
@@ -17,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';
@@ -46,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) => {
@@ -140,17 +169,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}
};
// Get source icon
const getSourceIcon = () => {
if (video.source === 'bilibili') {
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
} else if (video.source === 'local') {
return <Folder sx={{ color: '#4caf50' }} />; // Local green (using Folder as generic local icon, or maybe VideoFile if available)
} else if (video.source === 'missav') {
return <Movie sx={{ color: '#ff4081' }} />; // Pink for MissAV
}
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
};
return (
<>
@@ -171,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'}
@@ -183,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;
@@ -192,9 +258,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}}
/>
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{getSourceIcon()}
</Box>
{video.partNumber && video.totalParts && video.totalParts > 1 && (
<Chip
@@ -276,7 +340,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
sx={{
position: 'absolute',
top: 8,
right: 40, // Positioned to the left of the source icon
right: 8,
bgcolor: 'rgba(0,0,0,0.6)',
color: 'white',
opacity: 0, // Hidden by default, shown on hover

View File

@@ -4,10 +4,14 @@ import {
Forward10,
Fullscreen,
FullscreenExit,
KeyboardDoubleArrowLeft,
KeyboardDoubleArrowRight,
Loop,
Pause,
PlayArrow,
Replay10
Replay10,
Subtitles,
SubtitlesOff
} from '@mui/icons-material';
import {
Box,
@@ -27,6 +31,10 @@ interface VideoControlsProps {
onTimeUpdate?: (currentTime: number) => void;
onLoadedMetadata?: (duration: number) => void;
startTime?: number;
subtitles?: Array<{ language: string; filename: string; path: string }>;
subtitlesEnabled?: boolean;
onSubtitlesToggle?: (enabled: boolean) => void;
onLoopToggle?: (enabled: boolean) => void;
}
const VideoControls: React.FC<VideoControlsProps> = ({
@@ -35,7 +43,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
autoLoop = false,
onTimeUpdate,
onLoadedMetadata,
startTime = 0
startTime = 0,
subtitles = [],
subtitlesEnabled: initialSubtitlesEnabled = true,
onSubtitlesToggle,
onLoopToggle
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -45,6 +57,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isLooping, setIsLooping] = useState<boolean>(autoLoop);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [subtitlesEnabled, setSubtitlesEnabled] = useState<boolean>(initialSubtitlesEnabled && subtitles.length > 0);
useEffect(() => {
if (videoRef.current) {
@@ -90,6 +103,40 @@ 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);
};
}, []);
// Sync subtitle tracks when preference changes or subtitles become available
useEffect(() => {
if (videoRef.current && subtitles.length > 0) {
const tracks = videoRef.current.textTracks;
const newState = initialSubtitlesEnabled && subtitles.length > 0;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = newState ? 'showing' : 'hidden';
}
setSubtitlesEnabled(newState);
}
}, [initialSubtitlesEnabled, subtitles]);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
@@ -103,8 +150,14 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !isLooping;
setIsLooping(!isLooping);
const newState = !isLooping;
videoRef.current.loop = newState;
setIsLooping(newState);
// Call the callback to save preference to database
if (onLoopToggle) {
onLoopToggle(newState);
}
}
};
@@ -138,8 +191,52 @@ const VideoControls: React.FC<VideoControlsProps> = ({
}
};
const handleToggleSubtitles = () => {
if (videoRef.current) {
const tracks = videoRef.current.textTracks;
const newState = !subtitlesEnabled;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = newState ? 'showing' : 'hidden';
}
setSubtitlesEnabled(newState);
// Call the callback to save preference to database
if (onSubtitlesToggle) {
onSubtitlesToggle(newState);
}
}
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
{/* Global style for centering subtitles */}
<style>
{`
video::cue {
text-align: center !important;
line-height: 1.5;
background-color: rgba(0, 0, 0, 0.8);
}
video::-webkit-media-text-track-display {
text-align: center !important;
}
video::-webkit-media-text-track-container {
text-align: center !important;
display: flex;
justify-content: center;
align-items: flex-end;
}
video::cue-region {
text-align: center !important;
}
`}
</style>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
@@ -159,9 +256,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
if (onLoadedMetadata) {
onLoadedMetadata(e.currentTarget.duration);
}
// Initialize subtitle tracks based on preference
const tracks = e.currentTarget.textTracks;
const shouldShow = initialSubtitlesEnabled && subtitles.length > 0;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = shouldShow ? 'showing' : 'hidden';
}
}}
playsInline
crossOrigin="anonymous"
>
{subtitles && subtitles.map((subtitle) => (
<track
key={subtitle.language}
kind="subtitles"
src={`${import.meta.env.VITE_BACKEND_URL}${subtitle.path}`}
srcLang={subtitle.language}
label={subtitle.language.toUpperCase()}
/>
))}
Your browser does not support the video tag.
</video>
@@ -212,10 +326,27 @@ const VideoControls: React.FC<VideoControlsProps> = ({
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</Tooltip>
{subtitles && subtitles.length > 0 && (
<Tooltip title={subtitlesEnabled ? 'Hide Subtitles' : 'Show Subtitles'}>
<Button
variant={subtitlesEnabled ? "contained" : "outlined"}
onClick={handleToggleSubtitles}
fullWidth={isMobile}
>
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
</Button>
</Tooltip>
)}
</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 />
</Button>
</Tooltip>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<FastRewind />
@@ -236,6 +367,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
<FastForward />
</Button>
</Tooltip>
<Tooltip title="+10m">
<Button variant="outlined" onClick={() => handleSeek(600)}>
<KeyboardDoubleArrowRight />
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>

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' }}>
@@ -242,7 +269,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
)}
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('source')}</strong> {video.source === 'bilibili' ? 'Bilibili' : (video.source === 'local' ? 'Local Upload' : 'YouTube')}
<strong>{t('source')}</strong> {video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ 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

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext } from 'react';
import { Collection } from '../types';
import { useLanguage } from './LanguageContext';
import { useSnackbar } from './SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
@@ -27,6 +28,7 @@ export const useCollection = () => {
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const queryClient = useQueryClient();
const { data: collections = [], refetch: fetchCollectionsQuery } = useQuery({
@@ -51,7 +53,7 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Collection created successfully');
showSnackbar(t('collectionCreatedSuccessfully'));
},
onError: (error) => {
console.error('Error creating collection:', error);
@@ -76,7 +78,7 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Video added to collection');
showSnackbar(t('videoAddedToCollection'));
},
onError: (error) => {
console.error('Error adding video to collection:', error);
@@ -105,7 +107,7 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
));
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Video removed from collection');
showSnackbar(t('videoRemovedFromCollection'));
return true;
} catch (error) {
console.error('Error removing video from collection:', error);
@@ -125,11 +127,11 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
if (deleteVideos) {
queryClient.invalidateQueries({ queryKey: ['videos'] });
}
showSnackbar('Collection deleted successfully');
showSnackbar(t('collectionDeletedSuccessfully'));
},
onError: (error) => {
console.error('Error deleting collection:', error);
showSnackbar('Failed to delete collection', 'error');
showSnackbar(t('failedToDeleteCollection'), 'error');
}
});

View File

@@ -1,8 +1,11 @@
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';
import { useSnackbar } from './SnackbarContext';
import { useVideo } from './VideoContext';
@@ -64,6 +67,7 @@ const getStoredDownloadStatus = () => {
export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const { fetchVideos, handleSearch, setVideos } = useVideo();
const { fetchCollections } = useCollection();
const queryClient = useQueryClient();
@@ -139,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);
@@ -207,7 +220,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setVideos(prevVideos => [response.data.video, ...prevVideos]);
}
showSnackbar('Video downloading');
showSnackbar(t('videoDownloading'));
return { success: true };
} catch (err: any) {
console.error('Error downloading video:', err);
@@ -247,7 +260,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
await fetchCollections();
}
showSnackbar('Download started successfully');
showSnackbar(t('downloadStartedSuccessfully'));
return { success: true };
} catch (err: any) {
console.error('Error downloading Bilibili parts/collection:', err);
@@ -265,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,
@@ -278,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

@@ -19,7 +19,8 @@ import { Video } from '../types';
const AuthorVideos: React.FC = () => {
const { t } = useLanguage();
const { author } = useParams<{ author: string }>();
const { authorName } = useParams<{ authorName: string }>();
const author = authorName;
const navigate = useNavigate();
const { videos, loading, deleteVideo } = useVideo();
const { collections } = useCollection();
@@ -79,7 +80,7 @@ const AuthorVideos: React.FC = () => {
</Avatar>
<Box>
<Typography variant="h4" component="h1" fontWeight="bold">
{author ? decodeURIComponent(author) : t('unknownAuthor')}
{author || t('unknownAuthor')}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{authorVideos.length} {t('videos')}

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({
@@ -93,15 +140,40 @@ const DownloadPage: React.FC = () => {
mutationFn: async (id: string) => {
await axios.post(`${API_URL}/downloads/cancel/${id}`);
},
onSuccess: () => {
showSnackbar(t('downloadCancelled') || 'Download cancelled');
// DownloadContext handles active/queued updates via its own polling
// But we might want to invalidate to be sure
onMutate: async (id: string) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['downloadStatus'] });
// Snapshot the previous value
const previousStatus = queryClient.getQueryData(['downloadStatus']);
// Optimistically update to the new value
queryClient.setQueryData(['downloadStatus'], (old: any) => {
if (!old) return old;
return {
...old,
activeDownloads: old.activeDownloads.filter((d: any) => d.id !== id),
queuedDownloads: old.queuedDownloads.filter((d: any) => d.id !== id),
};
});
// Return a context object with the snapshotted value
return { previousStatus };
},
onError: (_err, _id, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousStatus) {
queryClient.setQueryData(['downloadStatus'], context.previousStatus);
}
showSnackbar(t('error') || 'Error');
},
onSettled: () => {
// Always refetch after error or success:
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
},
onError: () => {
showSnackbar(t('error') || 'Error');
}
onSuccess: () => {
showSnackbar(t('downloadCancelled') || 'Download cancelled');
},
});
const handleCancelDownload = (id: string) => {
@@ -186,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'} />
@@ -219,9 +328,23 @@ const DownloadPage: React.FC = () => {
secondary={
<Box sx={{ mt: 1 }}>
<LinearProgress variant="determinate" value={download.progress || 0} sx={{ mb: 1 }} />
<Typography variant="caption" color="textSecondary">
{download.progress?.toFixed(1)}% {download.speed || '0 B/s'} {download.downloadedSize || '0'} / {download.totalSize || '?'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" fontWeight="bold" color="primary">
{download.progress?.toFixed(1)}%
</Typography>
<Typography variant="caption" color="textSecondary">
</Typography>
<Typography variant="caption" color="textSecondary">
{download.speed || '0 B/s'}
</Typography>
<Typography variant="caption" color="textSecondary">
</Typography>
<Typography variant="caption" color="textSecondary">
{download.downloadedSize || '0'} / {download.totalSize || '?'}
</Typography>
</Box>
</Box>
}
/>
@@ -247,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>
@@ -284,46 +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 }}>
<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,66 @@ 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)',
minHeight: 'calc(100vh - 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 +421,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 +444,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 +455,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 +465,8 @@ const Home: React.FC = () => {
})}
</Grid>
{totalPages > 1 && (
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Pagination
@@ -385,10 +480,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

@@ -44,6 +44,11 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags: string[];
cloudDriveEnabled: boolean;
openListApiUrl: string;
openListToken: string;
cloudDrivePath: string;
homeSidebarOpen?: boolean;
}
const SettingsPage: React.FC = () => {
@@ -58,7 +63,11 @@ const SettingsPage: React.FC = () => {
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en',
tags: []
tags: [],
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
});
const [newTag, setNewTag] = useState('');
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -119,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({
@@ -281,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 }}>
@@ -365,6 +352,48 @@ const SettingsPage: React.FC = () => {
<Grid size={12}><Divider /></Grid>
{/* Cookie Upload Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('cookieUpload') || 'Cookie Upload'}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t('cookieUploadDescription') || 'Upload a cookies.txt file to authenticate yt-dlp. This is required for some sites to avoid bot detection.'}
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="outlined"
component="label"
>
{t('selectFile') || 'Select File'}
<input
type="file"
hidden
accept=".txt"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
axios.post(`${API_URL}/settings/upload-cookies`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(() => {
setMessage({ text: t('cookieUploadSuccess') || 'Cookies uploaded successfully', type: 'success' });
})
.catch((error) => {
setMessage({ text: error.response?.data?.error || t('cookieUploadFailed') || 'Failed to upload cookies', type: 'error' });
});
}
}}
/>
</Button>
</Box>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Video Defaults */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('videoDefaults')}</Typography>
@@ -378,15 +407,6 @@ const SettingsPage: React.FC = () => {
}
label={t('autoPlay')}
/>
<FormControlLabel
control={
<Switch
checked={settings.defaultAutoLoop}
onChange={(e) => handleChange('defaultAutoLoop', e.target.checked)}
/>
}
label={t('autoLoop')}
/>
</Box>
</Grid>
@@ -469,6 +489,48 @@ const SettingsPage: React.FC = () => {
<Grid size={12}><Divider /></Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('cloudDriveSettings')}</Typography>
<FormControlLabel
control={
<Switch
checked={settings.cloudDriveEnabled || false}
onChange={(e) => handleChange('cloudDriveEnabled', e.target.checked)}
/>
}
label={t('enableAutoSave')}
/>
{settings.cloudDriveEnabled && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 600 }}>
<TextField
label={t('apiUrl')}
value={settings.openListApiUrl || ''}
onChange={(e) => handleChange('openListApiUrl', e.target.value)}
helperText={t('apiUrlHelper')}
fullWidth
/>
<TextField
label={t('token')}
value={settings.openListToken || ''}
onChange={(e) => handleChange('openListToken', e.target.value)}
type="password"
fullWidth
/>
<TextField
label={t('uploadPath')}
value={settings.cloudDrivePath || ''}
onChange={(e) => handleChange('cloudDrivePath', e.target.value)}
helperText={t('cloudDrivePathHelper')}
fullWidth
/>
</Box>
)}
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Database Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('database')}</Typography>
@@ -484,15 +546,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

@@ -96,6 +96,7 @@ const VideoPlayer: React.FC = () => {
const autoPlay = settings?.defaultAutoPlay || false;
const autoLoop = settings?.defaultAutoLoop || false;
const availableTags = settings?.tags || [];
const subtitlesEnabled = settings?.subtitlesEnabled ?? true;
// Fetch comments
const { data: comments = [], isLoading: loadingComments } = useQuery({
@@ -283,6 +284,46 @@ const VideoPlayer: React.FC = () => {
await tagsMutation.mutateAsync(newTags);
};
// Subtitle preference mutation
const subtitlePreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, subtitlesEnabled: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, subtitlesEnabled: data.settings.subtitlesEnabled } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleSubtitlesToggle = async (enabled: boolean) => {
await subtitlePreferenceMutation.mutateAsync(enabled);
};
// Loop preference mutation
const loopPreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, defaultAutoLoop: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, defaultAutoLoop: data.settings.defaultAutoLoop } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleLoopToggle = async (enabled: boolean) => {
await loopPreferenceMutation.mutateAsync(enabled);
};
const [hasViewed, setHasViewed] = useState<boolean>(false);
const lastProgressSave = useRef<number>(0);
const currentTimeRef = useRef<number>(0);
@@ -354,9 +395,14 @@ const VideoPlayer: React.FC = () => {
}).slice(0, 10);
}, [video, videos, collections]);
// Scroll to top when video ID changes
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Grid container spacing={4}>
<Container maxWidth={false} disableGutters sx={{ py: { xs: 0, md: 4 }, px: { xs: 0, md: 2 } }}>
<Grid container spacing={{ xs: 0, md: 4 }}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<VideoControls
@@ -365,33 +411,41 @@ const VideoPlayer: React.FC = () => {
autoLoop={autoLoop}
onTimeUpdate={handleTimeUpdate}
startTime={video.progress || 0}
subtitles={video.subtitles}
subtitlesEnabled={subtitlesEnabled}
onSubtitlesToggle={handleSubtitlesToggle}
onLoopToggle={handleLoopToggle}
/>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
/>
<Box sx={{ px: { xs: 2, md: 0 } }}>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
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>
{/* Sidebar Column - Up Next */}
<Grid size={{ xs: 12, lg: 4 }}>
<Grid size={{ xs: 12, lg: 4 }} sx={{ p: { xs: 2, md: 0 }, pt: { xs: 2, md: 0 } }}>
<Typography variant="h6" gutterBottom fontWeight="bold">{t('upNext')}</Typography>
<Stack spacing={2}>
{relatedVideos.map(relatedVideo => (
@@ -428,8 +482,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">
@@ -438,11 +492,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

@@ -21,6 +21,7 @@ export interface Video {
duration?: string;
fileSize?: string; // Size in bytes as string
lastPlayedAt?: number;
subtitles?: Array<{ language: string; filename: string; path: string }>;
[key: string]: any;
}

View File

@@ -59,8 +59,10 @@ export const ar = {
migrateDataButton: "نقل البيانات من JSON",
scanFiles: "فحص الملفات",
scanFilesSuccess: "اكتمل الفحص. تمت إضافة {count} فيديوهات جديدة.",
scanFilesFailed: "فشل الفحص",
migrateConfirmation: "هل أنت متأكد أنك تريد نقل البيانات؟ قد يستغرق هذا بضع لحظات.",
scanFilesFailed: "فشل المسح",
scanFilesConfirmMessage: "سيقوم النظام بفحص المجلد الجذر لمسار الفيديو للعثور على ملفات الفيديو غير الموثقة.",
scanning: "جارٍ المسح...",
migrateConfirmation: "هل أنت متأكد أنك تريد ترحيل البيانات؟ قد يستغرق هذا بضع لحظات.",
migrationResults: "نتائج النقل",
migrationReport: "تقرير النقل",
migrationSuccess: "اكتمل النقل. انظر التفاصيل في التنبيه.",
@@ -84,6 +86,15 @@ export const ar = {
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
// Cloud Drive
cloudDriveSettings: "التخزين السحابي (OpenList)",
enableAutoSave: "تمكين الحفظ التلقائي في السحابة",
apiUrl: "رابط API",
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
token: "الرمز المميز (Token)",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
// Manage
manageContent: "إدارة المحتوى",
@@ -183,6 +194,15 @@ export const ar = {
deleteCollectionConfirmation: "هل أنت متأكد أنك تريد حذف المجموعة",
collectionContains: "تحتوي هذه المجموعة على",
deleteCollectionOnly: "حذف المجموعة فقط",
// Snackbar Messages
videoDownloading: "جاري تنزيل الفيديو",
downloadStartedSuccessfully: "بدأ التنزيل بنجاح",
collectionCreatedSuccessfully: "تم إنشاء المجموعة بنجاح",
videoAddedToCollection: "تمت إضافة الفيديو إلى المجموعة",
videoRemovedFromCollection: "تمت إزالة الفيديو من المجموعة",
collectionDeletedSuccessfully: "تم حذف المجموعة بنجاح",
failedToDeleteCollection: "فشل حذف المجموعة",
deleteCollectionAndVideos: "حذف المجموعة وكل الفيديوهات",
// Common
@@ -194,6 +214,7 @@ export const ar = {
save: "حفظ",
on: "تشغيل",
off: "إيقاف",
continue: "متابعة",
// Video Card
unknownDate: "تاريخ غير معروف",
@@ -247,4 +268,78 @@ 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 التي ينشرها المستخدم. أضف التكوين هنا لتمكين تكامل محرك الأقراص السحابية.",
// Cookie Upload
cookieUpload: "تحميل ملفات تعريف الارتباط",
cookieUploadDescription: "قم بتحميل ملف cookies.txt للمصادقة على yt-dlp. هذا مطلوب لبعض المواقع لتجنب اكتشاف الروبوت.",
selectFile: "اختر ملف",
cookieUploadSuccess: "تم تحميل ملفات تعريف الارتباط بنجاح",
cookieUploadFailed: "فشل تحميل ملفات تعريف الارتباط",
};

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.",
@@ -42,6 +44,16 @@ export const de = {
cleanupTempFilesActiveDownloads: "Bereinigung nicht möglich, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie ab.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
// Cloud Drive
cloudDriveSettings: "Cloud-Speicher (OpenList)",
enableAutoSave: "Automatisches Speichern in der Cloud aktivieren",
apiUrl: "API-URL",
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload-Pfad",
cloudDrivePathHelper: "Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
manageContent: "Inhalte Verwalten", videos: "Videos", collections: "Sammlungen", allVideos: "Alle Videos",
delete: "Löschen", backToHome: "Zurück zur Startseite", confirmDelete: "Sind Sie sicher, dass Sie dies löschen möchten?",
deleteSuccess: "Erfolgreich gelöscht", deleteFailed: "Löschen fehlgeschlagen", noVideos: "Keine Videos gefunden",
@@ -83,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",
@@ -113,8 +126,91 @@ export const de = {
removedFromQueue: "Aus der Warteschlange entfernt",
removedFromHistory: "Aus dem Verlauf entfernt",
status: "Status",
// Snackbar Messages
videoDownloading: "Video wird heruntergeladen",
downloadStartedSuccessfully: "Download erfolgreich gestartet",
collectionCreatedSuccessfully: "Sammlung erfolgreich erstellt",
videoAddedToCollection: "Video zur Sammlung hinzugefügt",
videoRemovedFromCollection: "Video aus der Sammlung entfernt",
collectionDeletedSuccessfully: "Sammlung erfolgreich gelöscht",
failedToDeleteCollection: "Fehler beim Löschen der Sammlung",
progress: "Fortschritt",
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.",
// Cookie Upload
cookieUpload: "Cookie-Upload",
cookieUploadDescription: "Laden Sie eine cookies.txt-Datei hoch, um yt-dlp zu authentifizieren. Dies ist für einige Websites erforderlich, um die Bot-Erkennung zu vermeiden.",
selectFile: "Datei auswählen",
cookieUploadSuccess: "Cookies erfolgreich hochgeladen",
cookieUploadFailed: "Fehler beim Hochladen der Cookies",
};

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",
@@ -84,6 +87,15 @@ export const en = {
cleanupTempFilesActiveDownloads: "Cannot clean up while downloads are active. Please wait for all downloads to complete or cancel them first.",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
// Cloud Drive
cloudDriveSettings: "Cloud Drive (OpenList)",
enableAutoSave: "Enable Auto Save to Cloud",
apiUrl: "API URL",
apiUrlHelper: "e.g. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload Path",
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
// Manage
manageContent: "Manage Content",
@@ -194,6 +206,7 @@ export const en = {
save: "Save",
on: "On",
off: "Off",
continue: "Continue",
// Video Card
unknownDate: "Unknown date",
@@ -248,4 +261,92 @@ export const en = {
speed: "Speed",
finishedAt: "Finished At",
failed: "Failed",
// Snackbar Messages
videoDownloading: "Video downloading",
downloadStartedSuccessfully: "Download started successfully",
collectionCreatedSuccessfully: "Collection created successfully",
videoAddedToCollection: "Video added to collection",
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.",
// Cookie Upload
cookieUpload: "Cookie Upload",
cookieUploadDescription: "Upload a cookies.txt file to authenticate yt-dlp. This is required for some sites to avoid bot detection.",
selectFile: "Select File",
cookieUploadSuccess: "Cookies uploaded successfully",
cookieUploadFailed: "Failed to upload cookies",
};

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.",
@@ -40,6 +42,16 @@ export const es = {
cleanupTempFilesActiveDownloads: "No se puede limpiar mientras hay descargas activas. Espera a que todas las descargas terminen o cancélalas primero.",
cleanupTempFilesSuccess: "Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
// Cloud Drive
cloudDriveSettings: "Almacenamiento en la Nube (OpenList)",
enableAutoSave: "Habilitar guardado automático en la nube",
apiUrl: "URL de la API",
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
manageContent: "Gestionar Contenido", videos: "Videos", collections: "Colecciones", allVideos: "Todos los Videos",
delete: "Eliminar", backToHome: "Volver a Inicio", confirmDelete: "¿Está seguro de que desea eliminar esto?",
deleteSuccess: "Eliminado exitosamente", deleteFailed: "Error al eliminar", noVideos: "No se encontraron videos",
@@ -53,10 +65,19 @@ export const es = {
videoNotFound: "Video no encontrado", videoNotFoundOrLoaded: "Video no encontrado o no se pudo cargar.",
deleting: "Eliminando...", addToCollection: "Agregar a Colección", originalLink: "Enlace Original",
source: "Fuente:", addedDate: "Fecha de Agregado:", latestComments: "Últimos Comentarios",
noComments: "No hay comentarios disponibles.", upNext: "A Continuación", noOtherVideos: "No hay otros videos disponibles",
noComments: "No hay comentarios disponibles.", upNext: "A Continuación", noOtherVideos: "No hay otros videos disponibles",
currentlyIn: "Actualmente en:", collectionWarning: "Agregar a una colección diferente lo eliminará de la actual.",
addToExistingCollection: "Agregar a colección existente:", selectCollection: "Seleccionar una colección",
add: "Agregar", createNewCollection: "Crear nueva colección:", collectionName: "Nombre de la colección",
// Snackbar Messages
videoDownloading: "Descargando video",
downloadStartedSuccessfully: "Descarga iniciada exitosamente",
collectionCreatedSuccessfully: "Colección creada exitosamente",
videoAddedToCollection: "Video agregado a la colección",
videoRemovedFromCollection: "Video eliminado de la colección",
collectionDeletedSuccessfully: "Colección eliminada exitosamente",
failedToDeleteCollection: "Error al eliminar la colección",
create: "Crear", removeFromCollection: "Eliminar de la Colección",
confirmRemoveFromCollection: "¿Está seguro de que desea eliminar este video de la colección?", remove: "Eliminar",
loadingVideo: "Cargando video...", current: "(Actual)", rateThisVideo: "Calificar este video",
@@ -115,4 +136,78 @@ 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.",
// Cookie Upload
cookieUpload: "Subida de Cookies",
cookieUploadDescription: "Sube un archivo cookies.txt para autenticar yt-dlp. Esto es necesario para algunos sitios para evitar la detección de bots.",
selectFile: "Seleccionar Archivo",
cookieUploadSuccess: "Cookies subidas exitosamente",
cookieUploadFailed: "Error al subir cookies",
};

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",
@@ -84,6 +86,15 @@ export const fr = {
cleanupTempFilesActiveDownloads: "Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
cleanupTempFilesSuccess: "{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
// Cloud Drive
cloudDriveSettings: "Stockage Cloud (OpenList)",
enableAutoSave: "Activer la sauvegarde automatique sur le Cloud",
apiUrl: "URL de l'API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Jeton (Token)",
uploadPath: "Chemin de téléchargement",
cloudDrivePathHelper: "Chemin du répertoire dans le cloud, ex. /mytube-uploads",
// Manage
manageContent: "Gérer le contenu",
@@ -131,7 +142,15 @@ export const fr = {
upNext: "À suivre",
noOtherVideos: "Aucune autre vidéo disponible",
currentlyIn: "Actuellement dans :",
collectionWarning: "L'ajout à une autre collection la supprimera de la collection actuelle.",
collectionWarning: "L'ajout à une autre collection la supprimera de la collection.",
// Snackbar Messages
videoDownloading: "Téléchargement de la vidéo",
downloadStartedSuccessfully: "Le téléchargement a commencé avec succès",
collectionCreatedSuccessfully: "Collection créée avec succès",
videoAddedToCollection: "Vidéo ajoutée à la collection",
videoRemovedFromCollection: "Vidéo retirée de la collection",
collectionDeletedSuccessfully: "Collection supprimée avec succès",
failedToDeleteCollection: "Échec de la suppression de la collection",
addToExistingCollection: "Ajouter à une collection existante :",
selectCollection: "Sélectionner une collection",
add: "Ajouter",
@@ -247,4 +266,78 @@ 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.",
// Cookie Upload
cookieUpload: "Téléchargement de Cookies",
cookieUploadDescription: "Téléchargez un fichier cookies.txt pour authentifier yt-dlp. Cela est requis pour certains sites afin d'éviter la détection de bots.",
selectFile: "Sélectionner un fichier",
cookieUploadSuccess: "Cookies téléchargés avec succès",
cookieUploadFailed: "Échec du téléchargement des cookies",
};

View File

@@ -60,6 +60,8 @@ export const ja = {
scanFiles: "ファイルをスキャン",
scanFilesSuccess: "スキャンが完了しました。{count}個の新しい動画を追加しました。",
scanFilesFailed: "スキャンに失敗しました",
scanFilesConfirmMessage: "システムはビデオパスのルートフォルダをスキャンして、未登録のビデオファイルを検索します。",
scanning: "スキャン中...",
migrateConfirmation: "データを移行してもよろしいですか?これには時間がかかる場合があります。",
migrationResults: "移行結果",
migrationReport: "移行レポート",
@@ -84,6 +86,15 @@ export const ja = {
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
// Cloud Drive
cloudDriveSettings: "クラウドストレージ (OpenList)",
enableAutoSave: "クラウドへの自動保存を有効にする",
apiUrl: "API URL",
apiUrlHelper: "例: https://your-alist-instance.com/api/fs/put",
token: "トークン",
uploadPath: "アップロードパス",
cloudDrivePathHelper: "クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
// Manage
manageContent: "コンテンツの管理",
@@ -185,6 +196,15 @@ export const ja = {
deleteCollectionOnly: "コレクションのみ削除",
deleteCollectionAndVideos: "コレクションとすべての動画を削除",
// Snackbar Messages
videoDownloading: "動画をダウンロード中",
downloadStartedSuccessfully: "ダウンロードが正常に開始されました",
collectionCreatedSuccessfully: "コレクションが正常に作成されました",
videoAddedToCollection: "動画がコレクションに追加されました",
videoRemovedFromCollection: "動画がコレクションから削除されました",
collectionDeletedSuccessfully: "コレクションが正常に削除されました",
failedToDeleteCollection: "コレクションの削除に失敗しました",
// Common
loading: "読み込み中...",
error: "エラー",
@@ -194,6 +214,7 @@ export const ja = {
save: "保存",
on: "オン",
off: "オフ",
continue: "続行",
// Video Card
unknownDate: "不明な日付",
@@ -247,4 +268,78 @@ 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 サービスへの接続をサポートします。ここで構成を追加して、クラウドドライブ統合を有効にします。",
// Cookie Upload
cookieUpload: "Cookie アップロード",
cookieUploadDescription: "yt-dlp を認証するために cookies.txt ファイルをアップロードします。これは、一部のサイトでボット検出を回避するために必要です。",
selectFile: "ファイルを選択",
cookieUploadSuccess: "Cookie のアップロードに成功しました",
cookieUploadFailed: "Cookie のアップロードに失敗しました",
};

View File

@@ -60,6 +60,8 @@ export const ko = {
scanFiles: "파일 스캔",
scanFilesSuccess: "스캔 완료. {count}개의 새 동영상이 추가되었습니다.",
scanFilesFailed: "스캔 실패",
scanFilesConfirmMessage: "시스템이 비디오 경로의 루트 폴더를 스캔하여 문서화되지 않은 비디오 파일을 찾습니다.",
scanning: "스캔 중...",
migrateConfirmation: "데이터를 마이그레이션하시겠습니까? 잠시 시간이 걸릴 수 있습니다.",
migrationResults: "마이그레이션 결과",
migrationReport: "마이그레이션 보고서",
@@ -84,6 +86,15 @@ export const ko = {
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",
// Cloud Drive
cloudDriveSettings: "클라우드 드라이브 (OpenList)",
enableAutoSave: "클라우드 자동 저장 활성화",
apiUrl: "API URL",
apiUrlHelper: "예: https://your-alist-instance.com/api/fs/put",
token: "토큰",
uploadPath: "업로드 경로",
cloudDrivePathHelper: "클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
// Manage
manageContent: "콘텐츠 관리",
@@ -185,6 +196,15 @@ export const ko = {
deleteCollectionOnly: "컬렉션만 삭제",
deleteCollectionAndVideos: "컬렉션 및 모든 동영상 삭제",
// Snackbar Messages
videoDownloading: "비디오 다운로드 중",
downloadStartedSuccessfully: "다운로드가 성공적으로 시작되었습니다",
collectionCreatedSuccessfully: "컬렉션이 성공적으로 생성되었습니다",
videoAddedToCollection: "비디오가 컬렉션에 추가되었습니다",
videoRemovedFromCollection: "비디오가 컬렉션에서 제거되었습니다",
collectionDeletedSuccessfully: "컬렉션이 성공적으로 삭제되었습니다",
failedToDeleteCollection: "컬렉션 삭제 실패",
// Common
loading: "로드 중...",
error: "오류",
@@ -194,6 +214,7 @@ export const ko = {
save: "저장",
on: "켜기",
off: "끄기",
continue: "계속",
// Video Card
unknownDate: "알 수 없는 날짜",
@@ -247,4 +268,78 @@ 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 서비스 연결을 지원합니다. 클라우드 드라이브 통합을 활성화하려면 여기에 구성을 추가하십시오.",
// Cookie Upload
cookieUpload: "쿠키 업로드",
cookieUploadDescription: "yt-dlp 인증을 위해 cookies.txt 파일을 업로드하세요. 일부 사이트에서는 봇 감지를 피하기 위해 이 파일이 필요합니다.",
selectFile: "파일 선택",
cookieUploadSuccess: "쿠키 업로드 성공",
cookieUploadFailed: "쿠키 업로드 실패",
};

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",
@@ -84,6 +86,15 @@ export const pt = {
cleanupTempFilesActiveDownloads: "Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
cleanupTempFilesSuccess: "{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
// Cloud Drive
cloudDriveSettings: "Armazenamento em Nuvem (OpenList)",
enableAutoSave: "Ativar salvamento automático na nuvem",
apiUrl: "URL da API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Caminho de upload",
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
// Manage
manageContent: "Gerenciar Conteúdo",
@@ -155,7 +166,15 @@ export const pt = {
videoUpdateFailed: "Falha ao atualizar vídeo",
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
videoRemovedSuccessfully: "Vídeo removido com sucesso",
failedToDeleteVideo: "Falha ao remover vídeo",
failedToDeleteVideo: "Falha ao excluir vídeo",
// Snackbar Messages
videoDownloading: "Baixando vídeo",
downloadStartedSuccessfully: "Download iniciado com sucesso",
collectionCreatedSuccessfully: "Coleção criada com sucesso",
videoAddedToCollection: "Vídeo adicionado à coleção",
videoRemovedFromCollection: "Vídeo removido da coleção",
collectionDeletedSuccessfully: "Coleção excluída com sucesso",
failedToDeleteCollection: "Falha ao excluir coleção",
pleaseEnterSearchTerm: "Por favor, insira um termo de pesquisa",
failedToSearch: "Falha na pesquisa. Por favor, tente novamente.",
searchCancelled: "Pesquisa cancelada",
@@ -194,6 +213,7 @@ export const pt = {
save: "Salvar",
on: "Ligado",
off: "Desligado",
continue: "Continuar",
// Video Card
unknownDate: "Data desconhecida",
@@ -247,4 +267,78 @@ 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.",
// Cookie Upload
cookieUpload: "Upload de Cookies",
cookieUploadDescription: "Faça o upload de um arquivo cookies.txt para autenticar o yt-dlp. Isso é necessário para alguns sites para evitar a detecção de bots.",
selectFile: "Selecionar Arquivo",
cookieUploadSuccess: "Cookies enviados com sucesso",
cookieUploadFailed: "Falha ao enviar cookies",
};

View File

@@ -59,7 +59,9 @@ export const ru = {
migrateDataButton: "Перенести данные из JSON",
scanFiles: "Сканировать файлы",
scanFilesSuccess: "Сканирование завершено. Добавлено {count} новых видео.",
scanFilesFailed: "Ошибка сканирования",
scanFilesFailed: "Сканирование не удалось",
scanFilesConfirmMessage: "Система просканирует корневую папку с видео, чтобы найти недовкументированные видеофайлы.",
scanning: "Сканирование...",
migrateConfirmation: "Вы уверены, что хотите перенести данные? Это может занять некоторое время.",
migrationResults: "Результаты миграции",
migrationReport: "Отчет о миграции",
@@ -84,6 +86,15 @@ export const ru = {
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
// Cloud Drive
cloudDriveSettings: "Облачное хранилище (OpenList)",
enableAutoSave: "Включить автосохранение в облако",
apiUrl: "URL API",
apiUrlHelper: "напр. https://your-alist-instance.com/api/fs/put",
token: "Токен",
uploadPath: "Путь загрузки",
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
// Manage
manageContent: "Управление контентом",
@@ -171,6 +182,15 @@ export const ru = {
loadingCollection: "Загрузка коллекции...",
collectionNotFound: "Коллекция не найдена",
noVideosInCollection: "В этой коллекции нет видео.",
// Snackbar Messages
videoDownloading: "Видео скачивается",
downloadStartedSuccessfully: "Загрузка успешно началась",
collectionCreatedSuccessfully: "Коллекция успешно создана",
videoAddedToCollection: "Видео добавлено в коллекцию",
videoRemovedFromCollection: "Видео удалено из коллекции",
collectionDeletedSuccessfully: "Коллекция успешно удалена",
failedToDeleteCollection: "Не удалось удалить коллекцию",
back: "Назад",
// Author Videos
@@ -193,7 +213,8 @@ export const ru = {
confirm: "Подтвердить",
save: "Сохранить",
on: "Вкл.",
off: "Выкл.",
off: "Выкл",
continue: "Продолжить",
// Video Card
unknownDate: "Неизвестная дата",
@@ -247,4 +268,78 @@ 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. Добавьте конфигурацию здесь, чтобы включить интеграцию с облачным диском.",
// Cookie Upload
cookieUpload: "Загрузка Cookie",
cookieUploadDescription: "Загрузите файл cookies.txt для аутентификации yt-dlp. Это требуется для некоторых сайтов, чтобы избежать обнаружения ботов.",
selectFile: "Выбрать файл",
cookieUploadSuccess: "Cookie успешно загружены",
cookieUploadFailed: "Не удалось загрузить Cookie",
};

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: "迁移完成。请查看警报中的详细信息。",
@@ -84,6 +87,15 @@ export const zh = {
cleanupTempFilesActiveDownloads: "有活动下载时无法清理。请等待所有下载完成或取消它们。",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
// Cloud Drive
cloudDriveSettings: "云端存储 (OpenList)",
enableAutoSave: "启用自动保存到云端",
apiUrl: "API 地址",
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
// Manage
manageContent: "内容管理",
@@ -173,6 +185,15 @@ export const zh = {
noVideosInCollection: "此合集中没有视频。",
back: "返回",
// Snackbar Messages
videoDownloading: "视频下载中",
downloadStartedSuccessfully: "下载已成功开始",
collectionCreatedSuccessfully: "集合创建成功",
videoAddedToCollection: "视频已添加到集合",
videoRemovedFromCollection: "视频已从集合中移除",
collectionDeletedSuccessfully: "集合删除成功",
failedToDeleteCollection: "删除集合失败",
// Author Videos
loadVideosError: "加载视频失败,请稍后再试。",
unknownAuthor: "未知",
@@ -193,7 +214,8 @@ export const zh = {
confirm: "确认",
save: "保存",
on: "开启",
off: "关",
off: "关",
continue: "继续",
// Video Card
unknownDate: "未知日期",
@@ -248,4 +270,78 @@ 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 服务,在此处添加配置后可实现云盘联动。",
// Cookie Upload
cookieUpload: "Cookie 上传",
cookieUploadDescription: "上传 cookies.txt 文件以验证 yt-dlp。这是某些站点避免被检测为机器人所必需的。",
selectFile: "选择文件",
cookieUploadSuccess: "Cookie 上传成功",
cookieUploadFailed: "Cookie 上传失败",
};

View File

@@ -1,5 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import packageJson from './package.json';
// https://vite.dev/config/
export default defineConfig({
@@ -7,4 +8,7 @@ export default defineConfig({
server: {
port: 5556,
},
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)
}
});

4
package-lock.json generated
View File

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

View File

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