Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e452fc61 | ||
|
|
cffe2319c2 | ||
|
|
19383ad582 | ||
|
|
c2d6215b44 | ||
|
|
f2b5af0912 | ||
|
|
56557da2cf | ||
|
|
1d45692374 | ||
|
|
fc070da102 | ||
|
|
d1ceef9698 | ||
|
|
bc9564f9bc | ||
|
|
710e85ad5e | ||
|
|
bc3ab6f9ef | ||
|
|
85d900f5f7 | ||
|
|
6621be19fc | ||
|
|
10d5423c99 | ||
|
|
067273a44b | ||
|
|
0009f7bb96 | ||
|
|
591e85c814 | ||
|
|
610bc614b1 | ||
|
|
70defde9c2 | ||
|
|
d9bce6df02 | ||
|
|
b301a563d9 | ||
|
|
8c33d29832 | ||
|
|
3ad06c00ba | ||
|
|
9c7771b232 | ||
|
|
f418024418 | ||
|
|
350cacb1f0 | ||
|
|
1fbec80917 | ||
|
|
f35b65158e | ||
|
|
0f36b4b050 | ||
|
|
cac5338fef | ||
|
|
3933db62b8 | ||
|
|
c5d9eaaa13 | ||
|
|
f22e1034f2 | ||
|
|
5684c023ee | ||
|
|
ecc17875ef | ||
|
|
f021fd4655 |
48
README-zh.md
48
README-zh.md
@@ -1,27 +1,36 @@
|
||||
# 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)
|
||||
|
||||
## 在线演示
|
||||
|
||||
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
|
||||
|
||||

|
||||
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **视频下载**:通过简单的 URL 输入下载 YouTube、Bilibili 和 MissAV 视频。
|
||||
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
|
||||
- **Bilibili 支持**:支持下载单个视频、多P视频以及整个合集/系列。
|
||||
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
|
||||
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
|
||||
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
|
||||
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
|
||||
- **搜索功能**:支持在本地库中搜索视频,或在线搜索 YouTube 视频。
|
||||
- **收藏夹**:创建自定义收藏夹以整理您的视频。
|
||||
- **现代化 UI**:响应式深色主题界面,包含“返回主页”功能和玻璃拟态效果。
|
||||
- **主题支持**:支持在明亮和深色模式之间切换。
|
||||
- **主题支持**:支持在明亮和深色模式之间切换,支持平滑过渡。
|
||||
- **登录保护**:通过密码登录页面保护您的应用。
|
||||
- **语言切换**:支持英语和中文语言切换。
|
||||
- **国际化**:支持多种语言,包括英语、中文、西班牙语、法语、德语、日语、韩语、阿拉伯语和葡萄牙语。
|
||||
- **分页功能**:支持分页浏览,高效管理大量视频。
|
||||
- **视频评分**:使用 5 星评级系统为您的视频评分。
|
||||
- **移动端优化**:移动端友好的标签菜单和针对小屏幕优化的布局。
|
||||
- **临时文件清理**:直接从设置中清理临时下载文件以管理存储空间。
|
||||
- **视图模式**:在主页上切换收藏夹视图和视频视图。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -31,6 +40,7 @@ mytube/
|
||||
│ ├── src/ # 源代码
|
||||
│ │ ├── config/ # 配置文件
|
||||
│ │ ├── controllers/ # 路由控制器
|
||||
│ │ ├── db/ # 数据库迁移和设置
|
||||
│ │ ├── routes/ # API 路由
|
||||
│ │ ├── services/ # 业务逻辑服务
|
||||
│ │ ├── utils/ # 工具函数
|
||||
@@ -43,12 +53,15 @@ mytube/
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
@@ -98,6 +111,8 @@ npm run dev # 以开发模式启动前端和后端
|
||||
```bash
|
||||
npm run start # 以生产模式启动前端和后端
|
||||
npm run build # 为生产环境构建前端
|
||||
npm run lint # 运行前端代码检查
|
||||
npm run lint:fix # 修复前端代码检查错误
|
||||
```
|
||||
|
||||
### 访问应用
|
||||
@@ -112,18 +127,41 @@ npm run build # 为生产环境构建前端
|
||||
- `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 允许您将视频整理到收藏夹中:
|
||||
@@ -177,6 +215,10 @@ MAX_FILE_SIZE=500000000
|
||||
|
||||
复制前端和后端目录中的 `.env.example` 文件以创建您自己的 `.env` 文件。
|
||||
|
||||
## 贡献
|
||||
|
||||
我们欢迎贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解如何开始、我们的开发工作流程以及代码质量指南。
|
||||
|
||||
## 部署
|
||||
|
||||
有关如何使用 Docker 或在 QNAP Container Station 上部署 MyTube 的详细说明,请参阅 [DEPLOYMENT.md](DEPLOYMENT.md)。
|
||||
|
||||
47
README.md
47
README.md
@@ -1,9 +1,13 @@
|
||||
# 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 application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
|
||||
|
||||
[中文](README-zh.md)
|
||||
|
||||
## Demo
|
||||
|
||||
🌐 **Try the live demo (read only): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -13,16 +17,20 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Theme Support**: Toggle between Light and Dark modes.
|
||||
- **Theme Support**: Toggle between Light and Dark modes with smooth transitions.
|
||||
- **Login Protection**: Secure your application with a password login page.
|
||||
- **Language Switching**: Support for English and Chinese languages.
|
||||
- **Internationalization**: Support for multiple languages including English, Chinese, Spanish, French, German, Japanese, Korean, Arabic, and Portuguese.
|
||||
- **Pagination**: Efficiently browse large libraries with pagination support.
|
||||
- **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.
|
||||
- **View Modes**: Toggle between Collection View and Video View on the home page.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -32,6 +40,7 @@ mytube/
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── config/ # Configuration files
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
│ │ ├── db/ # Database migrations and setup
|
||||
│ │ ├── routes/ # API routes
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── utils/ # Utility functions
|
||||
@@ -44,12 +53,15 @@ mytube/
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
@@ -99,6 +111,8 @@ 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
|
||||
@@ -113,18 +127,41 @@ npm run build # Build the frontend for production
|
||||
- `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:
|
||||
@@ -178,6 +215,10 @@ MAX_FILE_SIZE=500000000
|
||||
|
||||
Copy the `.env.example` files in both frontend and backend directories to create your own `.env` files.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started, our development workflow, and code quality guidelines.
|
||||
|
||||
## Deployment
|
||||
|
||||
For detailed instructions on how to deploy MyTube using Docker or on QNAP Container Station, please refer to [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
12
backend/drizzle/0001_worthless_blur.sql
Normal file
12
backend/drizzle/0001_worthless_blur.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `download_history` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`author` text,
|
||||
`source_url` text,
|
||||
`finished_at` integer NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`error` text,
|
||||
`video_path` text,
|
||||
`thumbnail_path` text,
|
||||
`total_size` text
|
||||
);
|
||||
1
backend/drizzle/0002_romantic_colossus.sql
Normal file
1
backend/drizzle/0002_romantic_colossus.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `videos` ADD `file_size` text;
|
||||
478
backend/drizzle/meta/0001_snapshot.json
Normal file
478
backend/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,478 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "4ad1e3d4-fd4b-431a-b4ed-8e74842fa726",
|
||||
"prevId": "458257d1-ceab-4f29-ac3f-6ac2576d37f5",
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
485
backend/drizzle/meta/0002_snapshot.json
Normal file
485
backend/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,485 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a4f15b55-7d41-46eb-a976-c89e80c42797",
|
||||
"prevId": "4ad1e3d4-fd4b-431a-b4ed-8e74842fa726",
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1764043254513,
|
||||
"tag": "0000_known_guardsmen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1764182291372,
|
||||
"tag": "0001_worthless_blur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1764190450949,
|
||||
"tag": "0002_romantic_colossus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.2",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -37,9 +37,35 @@ async function migrate() {
|
||||
seriesTitle: video.seriesTitle,
|
||||
rating: video.rating,
|
||||
description: video.description,
|
||||
viewCount: video.viewCount,
|
||||
viewCount: video.viewCount || 0,
|
||||
progress: video.progress || 0,
|
||||
duration: video.duration,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoUpdate({
|
||||
target: videos.id,
|
||||
set: {
|
||||
title: video.title,
|
||||
author: video.author,
|
||||
date: video.date,
|
||||
source: video.source,
|
||||
sourceUrl: video.sourceUrl,
|
||||
videoFilename: video.videoFilename,
|
||||
thumbnailFilename: video.thumbnailFilename,
|
||||
videoPath: video.videoPath,
|
||||
thumbnailPath: video.thumbnailPath,
|
||||
thumbnailUrl: video.thumbnailUrl,
|
||||
addedAt: video.addedAt,
|
||||
createdAt: video.createdAt,
|
||||
updatedAt: video.updatedAt,
|
||||
partNumber: video.partNumber,
|
||||
totalParts: video.totalParts,
|
||||
seriesTitle: video.seriesTitle,
|
||||
rating: video.rating,
|
||||
description: video.description,
|
||||
viewCount: video.viewCount || 0,
|
||||
progress: video.progress || 0,
|
||||
duration: video.duration,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error migrating video ${video.id}:`, error);
|
||||
}
|
||||
|
||||
48
backend/scripts/test-duration.ts
Normal file
48
backend/scripts/test-duration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { exec } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getVideoDuration } from "../src/services/metadataService";
|
||||
|
||||
const TEST_VIDEO_PATH = path.join(__dirname, "test_video.mp4");
|
||||
|
||||
async function createTestVideo() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Create a 5-second black video
|
||||
exec(`ffmpeg -f lavfi -i color=c=black:s=320x240:d=5 -c:v libx264 "${TEST_VIDEO_PATH}" -y`, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
try {
|
||||
console.log("Creating test video...");
|
||||
await createTestVideo();
|
||||
console.log("Test video created.");
|
||||
|
||||
console.log("Getting duration...");
|
||||
const duration = await getVideoDuration(TEST_VIDEO_PATH);
|
||||
console.log(`Duration: ${duration}`);
|
||||
|
||||
if (duration === 5) {
|
||||
console.log("SUCCESS: Duration is correct.");
|
||||
} else {
|
||||
console.error(`FAILURE: Expected duration 5, got ${duration}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (fs.existsSync(TEST_VIDEO_PATH)) {
|
||||
fs.unlinkSync(TEST_VIDEO_PATH);
|
||||
console.log("Test video deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runTest();
|
||||
79
backend/scripts/update-durations.ts
Normal file
79
backend/scripts/update-durations.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { exec } from 'child_process';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { VIDEOS_DIR } from '../src/config/paths';
|
||||
import { db } from '../src/db';
|
||||
import { videos } from '../src/db/schema';
|
||||
|
||||
async function updateDurations() {
|
||||
console.log('Starting duration update...');
|
||||
|
||||
// Get all videos with missing duration
|
||||
// Note: We can't easily filter by isNull(videos.duration) if the column was just added and defaults to null,
|
||||
// but let's try to get all videos and check in JS if needed, or just update all.
|
||||
// Updating all is safer to ensure correctness.
|
||||
|
||||
const allVideos = await db.select().from(videos).all();
|
||||
console.log(`Found ${allVideos.length} videos.`);
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const video of allVideos) {
|
||||
if (video.duration) {
|
||||
// Skip if already has duration (optional: remove this check to force update)
|
||||
continue;
|
||||
}
|
||||
|
||||
let videoPath = video.videoPath;
|
||||
if (!videoPath) continue;
|
||||
|
||||
// Resolve absolute path
|
||||
// videoPath in DB is web path like "/videos/subdir/file.mp4"
|
||||
// We need filesystem path.
|
||||
// Assuming /videos maps to VIDEOS_DIR
|
||||
|
||||
let fsPath = '';
|
||||
if (videoPath.startsWith('/videos/')) {
|
||||
const relativePath = videoPath.replace('/videos/', '');
|
||||
fsPath = path.join(VIDEOS_DIR, relativePath);
|
||||
} else {
|
||||
// Fallback or other path structure
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fsPath)) {
|
||||
console.warn(`File not found: ${fsPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (duration) {
|
||||
const durationSec = parseFloat(duration);
|
||||
if (!isNaN(durationSec)) {
|
||||
await db.update(videos)
|
||||
.set({ duration: Math.round(durationSec).toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
console.log(`Updated duration for ${video.title}: ${Math.round(durationSec)}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting duration for ${video.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Finished. Updated ${updatedCount} videos.`);
|
||||
}
|
||||
|
||||
updateDurations().catch(console.error);
|
||||
@@ -35,7 +35,7 @@ describe('ScanController', () => {
|
||||
isDirectory: () => false,
|
||||
birthtime: new Date(),
|
||||
});
|
||||
(exec as any).mockImplementation((cmd: string, cb: (error: Error | null) => void) => cb(null));
|
||||
(exec as any).mockImplementation((_cmd: string, cb: (error: Error | null) => void) => cb(null));
|
||||
|
||||
await scanFiles(req as Request, res as Response);
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('VideoController', () => {
|
||||
(downloadService.downloadSingleBilibiliPart as any).mockResolvedValue({ success: true, videoData: { id: 'v1' } });
|
||||
(downloadService.downloadRemainingBilibiliParts as any).mockImplementation(() => {});
|
||||
(storageService.saveCollection as any).mockImplementation(() => {});
|
||||
(storageService.atomicUpdateCollection as any).mockImplementation((id: string, fn: Function) => fn({ videos: [] }));
|
||||
(storageService.atomicUpdateCollection as any).mockImplementation((_id: string, fn: Function) => fn({ videos: [] }));
|
||||
|
||||
await downloadVideo(req as Request, res as Response);
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('VideoController', () => {
|
||||
(fs.existsSync as any).mockReturnValue(true);
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
(exec as any).mockImplementation((cmd: any, cb: any) => cb(null));
|
||||
(exec as any).mockImplementation((_cmd: any, cb: any) => cb(null));
|
||||
|
||||
await import('../../controllers/videoController').then(m => m.uploadVideo(req as Request, res as Response));
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('DownloadManager', () => {
|
||||
(fsMock.pathExists as any).mockResolvedValue(false);
|
||||
|
||||
// Should not throw
|
||||
const dm = (await import('../../services/downloadManager')).default;
|
||||
(await import('../../services/downloadManager'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('DownloadManager', () => {
|
||||
(fsMock.readJson as any).mockRejectedValue(new Error('JSON parse error'));
|
||||
|
||||
// Should not throw
|
||||
const dm = (await import('../../services/downloadManager')).default;
|
||||
(await import('../../services/downloadManager'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,7 +436,7 @@ describe('StorageService', () => {
|
||||
|
||||
describe('deleteCollection', () => {
|
||||
it('should delete collection', () => {
|
||||
const mockRun = vi.fn();
|
||||
|
||||
(db.delete as any).mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 1 }),
|
||||
@@ -463,7 +463,7 @@ describe('StorageService', () => {
|
||||
});
|
||||
|
||||
// Mock getVideoById
|
||||
const mockVideo = { id: 'v1', videoFilename: 'vid.mp4' };
|
||||
|
||||
// We need to handle multiple select calls differently or just return compatible mocks
|
||||
// Since we already mocked select for collection, we need to be careful.
|
||||
// But vi.fn() returns the same mock object unless we use mockImplementation.
|
||||
@@ -601,7 +601,7 @@ describe('StorageService', () => {
|
||||
(db.transaction as any).mockImplementation((cb: Function) => cb());
|
||||
|
||||
const mockCollection = { id: '1', title: 'Col 1', videos: [] };
|
||||
const mockVideo = { id: 'v1', videoFilename: 'vid.mp4', thumbnailFilename: 'thumb.jpg' };
|
||||
|
||||
|
||||
// This test requires complex mocking of multiple db.select calls
|
||||
// For now, we'll just verify the function completes without error
|
||||
@@ -682,7 +682,7 @@ describe('StorageService', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = storageService.removeVideoFromCollection('1', 'v1');
|
||||
storageService.removeVideoFromCollection('1', 'v1');
|
||||
|
||||
// Just verify function completes without error
|
||||
// Complex mocking makes specific assertions unreliable
|
||||
|
||||
72
backend/src/controllers/cleanupController.ts
Normal file
72
backend/src/controllers/cleanupController.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { VIDEOS_DIR } from "../config/paths";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Clean up temporary download files (.ytdl, .part)
|
||||
*/
|
||||
export const cleanupTempFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// Check if there are active downloads
|
||||
const downloadStatus = storageService.getDownloadStatus();
|
||||
if (downloadStatus.activeDownloads.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Cannot clean up while downloads are active",
|
||||
activeDownloads: downloadStatus.activeDownloads.length,
|
||||
});
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Recursively find and delete .ytdl and .part files
|
||||
const cleanupDirectory = async (dir: string) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively clean subdirectories
|
||||
await cleanupDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Check if file has .ytdl or .part extension
|
||||
if (entry.name.endsWith('.ytdl') || entry.name.endsWith('.part')) {
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
deletedCount++;
|
||||
console.log(`Deleted temp file: ${fullPath}`);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to delete ${fullPath}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.error(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to read directory ${dir}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.error(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Start cleanup from VIDEOS_DIR
|
||||
await cleanupDirectory(VIDEOS_DIR);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
deletedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error cleaning up temp files:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to clean up temporary files",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import * as storageService from "../services/storageService";
|
||||
import { Collection } from "../services/storageService";
|
||||
|
||||
// Get all collections
|
||||
export const getCollections = (req: Request, res: Response): void => {
|
||||
export const getCollections = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const collections = storageService.getCollections();
|
||||
res.json(collections);
|
||||
|
||||
72
backend/src/controllers/downloadController.ts
Normal file
72
backend/src/controllers/downloadController.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Request, Response } from "express";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
// Cancel a download
|
||||
export const cancelDownload = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
downloadManager.cancelDownload(id);
|
||||
res.status(200).json({ success: true, message: "Download cancelled" });
|
||||
} catch (error: any) {
|
||||
console.error("Error cancelling download:", error);
|
||||
res.status(500).json({ error: "Failed to cancel download", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Remove from queue
|
||||
export const removeFromQueue = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
downloadManager.removeFromQueue(id);
|
||||
res.status(200).json({ success: true, message: "Removed from queue" });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing from queue:", error);
|
||||
res.status(500).json({ error: "Failed to remove from queue", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear queue
|
||||
export const clearQueue = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
downloadManager.clearQueue();
|
||||
res.status(200).json({ success: true, message: "Queue cleared" });
|
||||
} catch (error: any) {
|
||||
console.error("Error clearing queue:", error);
|
||||
res.status(500).json({ error: "Failed to clear queue", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Get download history
|
||||
export const getDownloadHistory = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
const history = storageService.getDownloadHistory();
|
||||
res.status(200).json(history);
|
||||
} catch (error: any) {
|
||||
console.error("Error getting download history:", error);
|
||||
res.status(500).json({ error: "Failed to get download history", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Remove from history
|
||||
export const removeDownloadHistory = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
storageService.removeDownloadHistoryItem(id);
|
||||
res.status(200).json({ success: true, message: "Removed from history" });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing from history:", error);
|
||||
res.status(500).json({ error: "Failed to remove from history", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear history
|
||||
export const clearDownloadHistory = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
storageService.clearDownloadHistory();
|
||||
res.status(200).json({ success: true, message: "History cleared" });
|
||||
} catch (error: any) {
|
||||
console.error("Error clearing history:", error);
|
||||
res.status(500).json({ error: "Failed to clear history", details: error.message });
|
||||
}
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const getFilesRecursively = (dir: string): string[] => {
|
||||
return results;
|
||||
};
|
||||
|
||||
export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
export const scanFiles = async (_req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
console.log("Starting file scan...");
|
||||
|
||||
@@ -117,6 +117,28 @@ export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get duration
|
||||
let duration = undefined;
|
||||
try {
|
||||
const durationOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
if (durationOutput) {
|
||||
const durationSec = parseFloat(durationOutput);
|
||||
if (!isNaN(durationSec)) {
|
||||
duration = Math.round(durationSec).toString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error getting duration:", err);
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: path.parse(filename).name,
|
||||
@@ -131,6 +153,7 @@ export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
createdAt: createdDate.toISOString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
date: createdDate.toISOString().split('T')[0].replace(/-/g, ''),
|
||||
duration: duration,
|
||||
};
|
||||
|
||||
storageService.saveVideo(newVideo);
|
||||
|
||||
@@ -14,6 +14,10 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags?: string[];
|
||||
cloudDriveEnabled?: boolean;
|
||||
openListApiUrl?: string;
|
||||
openListToken?: string;
|
||||
cloudDrivePath?: string;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -22,10 +26,14 @@ const defaultSettings: Settings = {
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
language: 'en',
|
||||
cloudDriveEnabled: false,
|
||||
openListApiUrl: '',
|
||||
openListToken: '',
|
||||
cloudDrivePath: ''
|
||||
};
|
||||
|
||||
export const getSettings = async (req: Request, res: Response) => {
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
|
||||
@@ -47,7 +55,7 @@ export const getSettings = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const migrateData = async (req: Request, res: Response) => {
|
||||
export const migrateData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { runMigration } = await import('../services/migrationService');
|
||||
const results = await runMigration();
|
||||
@@ -58,7 +66,7 @@ export const migrateData = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteLegacyData = async (req: Request, res: Response) => {
|
||||
export const deleteLegacyData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const SETTINGS_DATA_PATH = path.join(path.dirname(VIDEOS_DATA_PATH), 'settings.json');
|
||||
const filesToDelete = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as downloadService from "../services/downloadService";
|
||||
import { getVideoDuration } from "../services/metadataService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
@@ -18,11 +19,11 @@ import {
|
||||
|
||||
// Configure Multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
destination: (_req, _file, cb) => {
|
||||
fs.ensureDirSync(VIDEOS_DIR);
|
||||
cb(null, VIDEOS_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
@@ -78,25 +79,34 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
||||
|
||||
// Determine initial title for the download task
|
||||
let initialTitle = "Video";
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||
initialTitle = "YouTube Video";
|
||||
} else if (isBilibiliUrl(videoUrl)) {
|
||||
initialTitle = "Bilibili Video";
|
||||
} else if (videoUrl.includes("missav")) {
|
||||
initialTitle = "MissAV Video";
|
||||
try {
|
||||
// Resolve shortened URLs (like b23.tv) first to get correct info
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||
initialTitle = "YouTube Video";
|
||||
} else if (isBilibiliUrl(videoUrl)) {
|
||||
initialTitle = "Bilibili Video";
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique ID for this download task
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
// Define the download task function
|
||||
const downloadTask = async () => {
|
||||
// Resolve shortened URLs (like b23.tv)
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
}
|
||||
|
||||
const downloadTask = async (registerCancel: (cancel: () => void) => void) => {
|
||||
// Trim Bilibili URL if needed
|
||||
if (isBilibiliUrl(videoUrl)) {
|
||||
videoUrl = trimBilibiliUrl(videoUrl);
|
||||
@@ -216,17 +226,25 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
||||
}
|
||||
} else if (videoUrl.includes("missav")) {
|
||||
// MissAV download
|
||||
const videoData = await downloadService.downloadMissAVVideo(videoUrl, downloadId);
|
||||
const videoData = await downloadService.downloadMissAVVideo(videoUrl, downloadId, registerCancel);
|
||||
return { success: true, video: videoData };
|
||||
} else {
|
||||
// YouTube download
|
||||
const videoData = await downloadService.downloadYouTubeVideo(videoUrl, downloadId);
|
||||
const videoData = await downloadService.downloadYouTubeVideo(videoUrl, downloadId, registerCancel);
|
||||
return { success: true, video: videoData };
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
})
|
||||
@@ -250,7 +268,7 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
||||
};
|
||||
|
||||
// Get all videos
|
||||
export const getVideos = (req: Request, res: Response): void => {
|
||||
export const getVideos = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const videos = storageService.getVideos();
|
||||
res.status(200).json(videos);
|
||||
@@ -297,7 +315,7 @@ export const deleteVideo = (req: Request, res: Response): any => {
|
||||
};
|
||||
|
||||
// Get download status
|
||||
export const getDownloadStatus = (req: Request, res: Response): void => {
|
||||
export const getDownloadStatus = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const status = storageService.getDownloadStatus();
|
||||
res.status(200).json(status);
|
||||
@@ -425,7 +443,7 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
// Generate thumbnail
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
await new Promise<void>((resolve, _reject) => {
|
||||
exec(`ffmpeg -i "${videoPath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
@@ -437,6 +455,20 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
});
|
||||
});
|
||||
|
||||
// Get video duration
|
||||
const duration = await getVideoDuration(videoPath);
|
||||
|
||||
// Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(videoPath)) {
|
||||
const stats = fs.statSync(videoPath);
|
||||
fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: title || req.file.originalname,
|
||||
@@ -448,6 +480,8 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
videoPath: `/videos/${videoFilename}`,
|
||||
thumbnailPath: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
thumbnailUrl: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
duration: duration ? duration.toString() : undefined,
|
||||
fileSize: fileSize,
|
||||
createdAt: new Date().toISOString(),
|
||||
date: new Date().toISOString().split('T')[0].replace(/-/g, ''),
|
||||
addedAt: new Date().toISOString(),
|
||||
@@ -529,3 +563,147 @@ export const updateVideoDetails = (req: Request, res: Response): any => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Refresh video thumbnail
|
||||
export const refreshThumbnail = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const video = storageService.getVideoById(id);
|
||||
|
||||
if (!video) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
// Construct paths
|
||||
let videoFilePath: string;
|
||||
if (video.videoPath && video.videoPath.startsWith('/videos/')) {
|
||||
const relativePath = video.videoPath.replace(/^\/videos\//, '');
|
||||
// Split by / to handle the web path separators and join with system separator
|
||||
videoFilePath = path.join(VIDEOS_DIR, ...relativePath.split('/'));
|
||||
} else if (video.videoFilename) {
|
||||
videoFilePath = path.join(VIDEOS_DIR, video.videoFilename);
|
||||
} else {
|
||||
return res.status(400).json({ error: "Video file path not found in record" });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(videoFilePath)) {
|
||||
return res.status(404).json({ error: "Video file not found on disk" });
|
||||
}
|
||||
|
||||
// Determine thumbnail path on disk
|
||||
let thumbnailAbsolutePath: string;
|
||||
let needsDbUpdate = false;
|
||||
let newThumbnailFilename = video.thumbnailFilename;
|
||||
let newThumbnailPath = video.thumbnailPath;
|
||||
|
||||
if (video.thumbnailPath && video.thumbnailPath.startsWith('/images/')) {
|
||||
// Local file exists (or should exist) - preserve the existing path (e.g. inside a collection folder)
|
||||
const relativePath = video.thumbnailPath.replace(/^\/images\//, '');
|
||||
thumbnailAbsolutePath = path.join(IMAGES_DIR, ...relativePath.split('/'));
|
||||
} else {
|
||||
// Remote URL or missing - create a new local file in the root images directory
|
||||
if (!newThumbnailFilename) {
|
||||
const videoName = path.parse(path.basename(videoFilePath)).name;
|
||||
newThumbnailFilename = `${videoName}.jpg`;
|
||||
}
|
||||
thumbnailAbsolutePath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
newThumbnailPath = `/images/${newThumbnailFilename}`;
|
||||
needsDbUpdate = true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
fs.ensureDirSync(path.dirname(thumbnailAbsolutePath));
|
||||
|
||||
// Generate thumbnail
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// -y to overwrite existing file
|
||||
exec(`ffmpeg -i "${videoFilePath}" -ss 00:00:00 -vframes 1 "${thumbnailAbsolutePath}" -y`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update video record if needed (switching from remote to local, or creating new)
|
||||
if (needsDbUpdate) {
|
||||
const updates: any = {
|
||||
thumbnailFilename: newThumbnailFilename,
|
||||
thumbnailPath: newThumbnailPath,
|
||||
thumbnailUrl: newThumbnailPath
|
||||
};
|
||||
storageService.updateVideo(id, updates);
|
||||
}
|
||||
|
||||
// Return success with timestamp to bust cache
|
||||
const thumbnailUrl = `${newThumbnailPath}?t=${Date.now()}`;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Thumbnail refreshed successfully",
|
||||
thumbnailUrl: thumbnailUrl
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error refreshing thumbnail:", error);
|
||||
res.status(500).json({
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Increment view count
|
||||
export const incrementViewCount = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const video = storageService.getVideoById(id);
|
||||
|
||||
if (!video) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
const currentViews = video.viewCount || 0;
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
viewCount: currentViews + 1,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
viewCount: updatedVideo?.viewCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error incrementing view count:", error);
|
||||
res.status(500).json({ error: "Failed to increment view count" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update progress
|
||||
export const updateProgress = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { progress } = req.body;
|
||||
|
||||
if (typeof progress !== 'number') {
|
||||
return res.status(400).json({ error: "Progress must be a number" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
progress,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
progress: updatedVideo.progress
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating progress:", error);
|
||||
res.status(500).json({ error: "Failed to update progress" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ export const videos = sqliteTable('videos', {
|
||||
viewCount: integer('view_count'),
|
||||
duration: text('duration'),
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
fileSize: text('file_size'),
|
||||
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
@@ -86,4 +89,19 @@ 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', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
author: text('author'),
|
||||
sourceUrl: text('source_url'),
|
||||
finishedAt: integer('finished_at').notNull(), // Timestamp
|
||||
status: text('status').notNull(), // 'success' or 'failed'
|
||||
error: text('error'), // Error message if failed
|
||||
videoPath: text('video_path'), // Path to video file if successful
|
||||
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
|
||||
totalSize: text('total_size'),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from "express";
|
||||
import * as cleanupController from "../controllers/cleanupController";
|
||||
import * as collectionController from "../controllers/collectionController";
|
||||
import * as downloadController from "../controllers/downloadController";
|
||||
import * as scanController from "../controllers/scanController";
|
||||
import * as videoController from "../controllers/videoController";
|
||||
|
||||
@@ -15,13 +17,25 @@ router.put("/videos/:id", videoController.updateVideoDetails);
|
||||
router.delete("/videos/:id", videoController.deleteVideo);
|
||||
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||
router.post("/videos/:id/rate", videoController.rateVideo);
|
||||
router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail);
|
||||
router.post("/videos/:id/view", videoController.incrementViewCount);
|
||||
router.put("/videos/:id/progress", videoController.updateProgress);
|
||||
|
||||
router.post("/scan-files", scanController.scanFiles);
|
||||
router.post("/cleanup-temp-files", cleanupController.cleanupTempFiles);
|
||||
|
||||
router.get("/download-status", videoController.getDownloadStatus);
|
||||
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
||||
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);
|
||||
|
||||
// Download management
|
||||
router.post("/downloads/cancel/:id", downloadController.cancelDownload);
|
||||
router.delete("/downloads/queue/:id", downloadController.removeFromQueue);
|
||||
router.delete("/downloads/queue", downloadController.clearQueue);
|
||||
router.get("/downloads/history", downloadController.getDownloadHistory);
|
||||
router.delete("/downloads/history/:id", downloadController.removeDownloadHistory);
|
||||
router.delete("/downloads/history", downloadController.clearDownloadHistory);
|
||||
|
||||
// Collection routes
|
||||
router.get("/collections", collectionController.getCollections);
|
||||
router.post("/collections", collectionController.createCollection);
|
||||
|
||||
@@ -7,6 +7,7 @@ import express from "express";
|
||||
import { IMAGES_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";
|
||||
|
||||
@@ -28,6 +29,9 @@ storageService.initializeStorage();
|
||||
import { runMigrations } from "./db/migrate";
|
||||
runMigrations();
|
||||
|
||||
// Initialize download manager (restore queued tasks)
|
||||
downloadManager.initialize();
|
||||
|
||||
// Serve static files
|
||||
app.use("/videos", express.static(VIDEOS_DIR));
|
||||
app.use("/images", express.static(IMAGES_DIR));
|
||||
@@ -39,4 +43,10 @@ app.use('/api/settings', settingsRoutes);
|
||||
// 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));
|
||||
});
|
||||
|
||||
|
||||
173
backend/src/services/CloudStorageService.ts
Normal file
173
backend/src/services/CloudStorageService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { CloudStorageService } from "./CloudStorageService";
|
||||
import { createDownloadTask } from "./downloadService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
const SETTINGS_FILE = path.join(__dirname, "../../data/settings.json");
|
||||
|
||||
interface DownloadTask {
|
||||
downloadFn: () => Promise<any>;
|
||||
downloadFn: (registerCancel: (cancel: () => void) => void) => Promise<any>;
|
||||
id: string;
|
||||
title: string;
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
cancelFn?: () => void;
|
||||
sourceUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
private queue: DownloadTask[];
|
||||
private activeTasks: Map<string, DownloadTask>;
|
||||
private activeDownloads: number;
|
||||
private maxConcurrentDownloads: number;
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.activeTasks = new Map();
|
||||
this.activeDownloads = 0;
|
||||
this.maxConcurrentDownloads = 3; // Default
|
||||
this.loadSettings();
|
||||
@@ -26,18 +29,68 @@ class DownloadManager {
|
||||
|
||||
private async loadSettings() {
|
||||
try {
|
||||
if (await fs.pathExists(SETTINGS_FILE)) {
|
||||
const settings = await fs.readJson(SETTINGS_FILE);
|
||||
if (settings.maxConcurrentDownloads) {
|
||||
this.maxConcurrentDownloads = settings.maxConcurrentDownloads;
|
||||
console.log(`Loaded maxConcurrentDownloads: ${this.maxConcurrentDownloads}`);
|
||||
}
|
||||
const settings = storageService.getSettings();
|
||||
if (settings.maxConcurrentDownloads) {
|
||||
this.maxConcurrentDownloads = settings.maxConcurrentDownloads;
|
||||
console.log(`Loaded maxConcurrentDownloads from database: ${this.maxConcurrentDownloads}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading settings in DownloadManager:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -52,19 +105,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: () => Promise<any>,
|
||||
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 = {
|
||||
@@ -73,6 +123,8 @@ class DownloadManager {
|
||||
title,
|
||||
resolve,
|
||||
reject,
|
||||
sourceUrl,
|
||||
type,
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
@@ -81,6 +133,74 @@ class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active download
|
||||
* @param id - ID of the download to cancel
|
||||
*/
|
||||
cancelDownload(id: string): void {
|
||||
const task = this.activeTasks.get(id);
|
||||
if (task) {
|
||||
console.log(`Cancelling active download: ${task.title} (${id})`);
|
||||
|
||||
// Call the cancel function if available
|
||||
if (task.cancelFn) {
|
||||
try {
|
||||
task.cancelFn();
|
||||
} catch (error) {
|
||||
console.error(`Error calling cancel function for ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicitly remove from database and clean up state
|
||||
// This ensures cleanup happens even if cancelFn doesn't properly reject
|
||||
storageService.removeActiveDownload(id);
|
||||
|
||||
// Add to history as cancelled/failed
|
||||
storageService.addDownloadHistoryItem({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
finishedAt: Date.now(),
|
||||
status: 'failed',
|
||||
error: 'Download cancelled by user',
|
||||
sourceUrl: task.sourceUrl,
|
||||
});
|
||||
|
||||
// Clean up internal state
|
||||
this.activeTasks.delete(id);
|
||||
this.activeDownloads--;
|
||||
|
||||
// Reject the promise
|
||||
task.reject(new Error('Download cancelled by user'));
|
||||
|
||||
// Process next item in queue
|
||||
this.processQueue();
|
||||
} else {
|
||||
// Check if it's in the queue and remove it
|
||||
const inQueue = this.queue.some(t => t.id === id);
|
||||
if (inQueue) {
|
||||
console.log(`Removing queued download: ${id}`);
|
||||
this.removeFromQueue(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a download from the queue
|
||||
* @param id - ID of the download to remove
|
||||
*/
|
||||
removeFromQueue(id: string): void {
|
||||
this.queue = this.queue.filter(task => task.id !== id);
|
||||
this.updateQueuedDownloads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the download queue
|
||||
*/
|
||||
clearQueue(): void {
|
||||
this.queue = [];
|
||||
this.updateQueuedDownloads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the queued downloads in storage
|
||||
*/
|
||||
@@ -88,7 +208,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);
|
||||
}
|
||||
@@ -109,26 +231,89 @@ class DownloadManager {
|
||||
|
||||
this.updateQueuedDownloads();
|
||||
this.activeDownloads++;
|
||||
this.activeTasks.set(task.id, task);
|
||||
|
||||
// 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})`);
|
||||
const result = await task.downloadFn();
|
||||
const result = await task.downloadFn((cancel) => {
|
||||
task.cancelFn = cancel;
|
||||
});
|
||||
|
||||
// Download complete
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
|
||||
// Extract video data from result
|
||||
// videoController returns { success: true, video: ... }
|
||||
// But some downloaders might return the video object directly or different structure
|
||||
const videoData = result.video || result;
|
||||
|
||||
console.log(`Download finished for ${task.title}. Result title: ${videoData.title}`);
|
||||
|
||||
// Determine best title
|
||||
let finalTitle = videoData.title;
|
||||
const genericTitles = ["YouTube Video", "Bilibili Video", "MissAV Video", "Video"];
|
||||
if (!finalTitle || genericTitles.includes(finalTitle)) {
|
||||
if (task.title && !genericTitles.includes(task.title)) {
|
||||
finalTitle = task.title;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to history
|
||||
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,
|
||||
sourceUrl: task.sourceUrl
|
||||
}).catch(err => console.error("Background cloud upload failed:", err));
|
||||
|
||||
task.resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`Error downloading ${task.title}:`, error);
|
||||
|
||||
// Download failed
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
|
||||
// Add to history
|
||||
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 {
|
||||
// Only clean up if the task wasn't already cleaned up by cancelDownload
|
||||
if (this.activeTasks.has(task.id)) {
|
||||
this.activeTasks.delete(task.id);
|
||||
this.activeDownloads--;
|
||||
}
|
||||
// Process next item in queue
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractBilibiliVideoId, isBilibiliUrl } from "../utils/helpers";
|
||||
import {
|
||||
BilibiliCollectionCheckResult,
|
||||
BilibiliDownloader,
|
||||
@@ -8,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
|
||||
@@ -76,17 +77,52 @@ 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
|
||||
export async function downloadYouTubeVideo(videoUrl: string, downloadId?: string): Promise<Video> {
|
||||
return YouTubeDownloader.downloadVideo(videoUrl, downloadId);
|
||||
// Download generic video (using yt-dlp)
|
||||
export async function downloadYouTubeVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video
|
||||
export async function downloadMissAVVideo(url: string, downloadId?: string): Promise<Video> {
|
||||
return MissAVDownloader.downloadVideo(url, downloadId);
|
||||
export async function downloadMissAVVideo(url: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
return MissAVDownloader.downloadVideo(url, downloadId, onStart);
|
||||
}
|
||||
|
||||
// Helper function to get video info without downloading
|
||||
export async function getVideoInfo(url: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
|
||||
if (isBilibiliUrl(url)) {
|
||||
const videoId = extractBilibiliVideoId(url);
|
||||
if (videoId) {
|
||||
return BilibiliDownloader.getVideoInfo(videoId);
|
||||
}
|
||||
} else if (url.includes("missav")) {
|
||||
return MissAVDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,33 @@ export interface CollectionDownloadResult {
|
||||
}
|
||||
|
||||
export class BilibiliDownloader {
|
||||
// Get video info without downloading
|
||||
static async getVideoInfo(videoId: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
|
||||
try {
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
const response = await axios.get(apiUrl);
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const videoInfo = response.data.data;
|
||||
return {
|
||||
title: videoInfo.title || "Bilibili Video",
|
||||
author: videoInfo.owner?.name || "Bilibili User",
|
||||
date: new Date(videoInfo.pubdate * 1000).toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: videoInfo.pic,
|
||||
};
|
||||
}
|
||||
throw new Error("No data found");
|
||||
} catch (error) {
|
||||
console.error("Error fetching Bilibili video info:", error);
|
||||
return {
|
||||
title: "Bilibili Video",
|
||||
author: "Bilibili User",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download Bilibili video
|
||||
static async downloadVideo(
|
||||
url: string,
|
||||
@@ -468,6 +495,29 @@ export class BilibiliDownloader {
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
|
||||
// Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const durationSec = await getVideoDuration(newVideoPath);
|
||||
if (durationSec) {
|
||||
duration = durationSec.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to extract duration from Bilibili video:", e);
|
||||
}
|
||||
|
||||
// Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
const stats = fs.statSync(newVideoPath);
|
||||
fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
@@ -483,6 +533,8 @@ export class BilibiliDownloader {
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
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";
|
||||
|
||||
export class MissAVDownloader {
|
||||
// Get video info without downloading
|
||||
static async getVideoInfo(url: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
|
||||
try {
|
||||
console.log("Fetching MissAV page content with Puppeteer...");
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
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');
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
const html = await page.content();
|
||||
await browser.close();
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const pageTitle = $('meta[property="og:title"]').attr('content');
|
||||
const ogImage = $('meta[property="og:image"]').attr('content');
|
||||
|
||||
return {
|
||||
title: pageTitle || "MissAV Video",
|
||||
author: "MissAV",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: ogImage || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching MissAV video info:", error);
|
||||
return {
|
||||
title: "MissAV Video",
|
||||
author: "MissAV",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video
|
||||
static async downloadVideo(url: string, downloadId?: string): Promise<Video> {
|
||||
static async downloadVideo(url: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
console.log("Detected MissAV URL:", url);
|
||||
|
||||
const timestamp = Date.now();
|
||||
@@ -19,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);
|
||||
|
||||
@@ -27,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,
|
||||
@@ -40,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();
|
||||
@@ -61,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) {
|
||||
@@ -108,36 +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
|
||||
];
|
||||
|
||||
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.\/]+)/);
|
||||
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
|
||||
|
||||
if (progressMatch && downloadId) {
|
||||
const percentage = parseFloat(progressMatch[1]);
|
||||
const totalSize = progressMatch[2];
|
||||
const speed = progressMatch[3];
|
||||
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
|
||||
let totalDurationSec = 0;
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
speed: speed
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
console.log("Killing ffmpeg process for download:", downloadId);
|
||||
ffmpeg.kill('SIGKILL');
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -188,6 +326,29 @@ export class MissAVDownloader {
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
|
||||
// Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const durationSec = await getVideoDuration(newVideoPath);
|
||||
if (durationSec) {
|
||||
duration = durationSec.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to extract duration from MissAV video:", e);
|
||||
}
|
||||
|
||||
// Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
const stats = fs.statSync(newVideoPath);
|
||||
fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// 7. Save metadata
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
@@ -201,6 +362,8 @@ export class MissAVDownloader {
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,215 +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;
|
||||
}
|
||||
|
||||
// Download YouTube video
|
||||
static async downloadVideo(videoUrl: string, downloadId?: string): 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
|
||||
const videoPath = path.join(VIDEOS_DIR, videoFilename);
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save the video
|
||||
storageService.saveVideo(videoData);
|
||||
|
||||
console.log("Video added to database");
|
||||
|
||||
return videoData;
|
||||
}
|
||||
}
|
||||
342
backend/src/services/downloaders/YtDlpDownloader.ts
Normal file
342
backend/src/services/downloaders/YtDlpDownloader.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
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";
|
||||
|
||||
// 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
|
||||
} 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
|
||||
} 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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
try {
|
||||
// Get video info first
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
preferFreeFormats: true,
|
||||
} 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",
|
||||
};
|
||||
|
||||
// Add YouTube specific flags if it's a YouTube URL
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||
flags.format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best";
|
||||
flags['extractor-args'] = "youtube:player_client=android";
|
||||
flags.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'
|
||||
];
|
||||
}
|
||||
|
||||
// Use exec to capture stdout for progress
|
||||
const subprocess = youtubedl.exec(videoUrl, flags);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
84
backend/src/services/metadataService.ts
Normal file
84
backend/src/services/metadataService.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { exec } from 'child_process';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { VIDEOS_DIR } from '../config/paths';
|
||||
import { db } from '../db';
|
||||
import { videos } from '../db/schema';
|
||||
|
||||
export const getVideoDuration = async (filePath: string): Promise<number | null> => {
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (duration) {
|
||||
const durationSec = parseFloat(duration);
|
||||
if (!isNaN(durationSec)) {
|
||||
return Math.round(durationSec);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting duration for ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const backfillDurations = async () => {
|
||||
console.log('Starting duration backfill...');
|
||||
|
||||
try {
|
||||
const allVideos = await db.select().from(videos).all();
|
||||
console.log(`Found ${allVideos.length} videos to check for duration.`);
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const video of allVideos) {
|
||||
if (video.duration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let videoPath = video.videoPath;
|
||||
if (!videoPath) continue;
|
||||
|
||||
let fsPath = '';
|
||||
if (videoPath.startsWith('/videos/')) {
|
||||
const relativePath = videoPath.replace('/videos/', '');
|
||||
fsPath = path.join(VIDEOS_DIR, relativePath);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fsPath)) {
|
||||
// console.warn(`File not found: ${fsPath}`); // Reduce noise
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = await getVideoDuration(fsPath);
|
||||
|
||||
if (duration !== null) {
|
||||
db.update(videos)
|
||||
.set({ duration: duration.toString() })
|
||||
.where(eq(videos.id, video.id))
|
||||
.run();
|
||||
console.log(`Updated duration for ${video.title}: ${duration}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
console.log(`Duration backfill finished. Updated ${updatedCount} videos.`);
|
||||
} else {
|
||||
console.log('Duration backfill finished. No videos needed update.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during duration backfill:", error);
|
||||
}
|
||||
};
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +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,
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DIR,
|
||||
} from "../config/paths";
|
||||
import { db, sqlite } from "../db";
|
||||
import { collections, collectionVideos, downloads, settings, videos } from "../db/schema";
|
||||
import { collections, collectionVideos, downloadHistory, downloads, settings, videos } from "../db/schema";
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
@@ -19,6 +19,9 @@ export interface Video {
|
||||
thumbnailFilename?: string;
|
||||
createdAt: string;
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
fileSize?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -40,6 +43,21 @@ export interface DownloadInfo {
|
||||
downloadedSize?: string;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
sourceUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
sourceUrl?: string;
|
||||
finishedAt: number;
|
||||
status: 'success' | 'failed';
|
||||
error?: string;
|
||||
videoPath?: string;
|
||||
thumbnailPath?: string;
|
||||
totalSize?: string;
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
@@ -76,6 +94,14 @@ export function initializeStorage(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up active downloads from database on startup
|
||||
try {
|
||||
db.delete(downloads).where(eq(downloads.status, 'active')).run();
|
||||
console.log("Cleared active downloads from database on startup");
|
||||
} catch (error) {
|
||||
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();
|
||||
@@ -89,6 +115,80 @@ export function initializeStorage(): void {
|
||||
} 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.");
|
||||
}
|
||||
|
||||
// Check downloads table columns
|
||||
const downloadsTableInfo = sqlite.prepare("PRAGMA table_info(downloads)").all();
|
||||
const downloadsColumns = (downloadsTableInfo as any[]).map((col: any) => col.name);
|
||||
|
||||
if (!downloadsColumns.includes('source_url')) {
|
||||
console.log("Migrating database: Adding source_url column to downloads table...");
|
||||
sqlite.prepare("ALTER TABLE downloads ADD COLUMN source_url TEXT").run();
|
||||
console.log("Migration successful: source_url added.");
|
||||
}
|
||||
|
||||
if (!downloadsColumns.includes('type')) {
|
||||
console.log("Migrating database: Adding type column to downloads table...");
|
||||
sqlite.prepare("ALTER TABLE downloads ADD COLUMN type TEXT").run();
|
||||
console.log("Migration successful: type 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +202,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: {
|
||||
@@ -123,6 +228,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)
|
||||
@@ -156,12 +263,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();
|
||||
}
|
||||
@@ -192,6 +303,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
|
||||
@@ -200,6 +313,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 };
|
||||
@@ -209,6 +324,62 @@ export function getDownloadStatus(): DownloadStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download History ---
|
||||
|
||||
export function addDownloadHistoryItem(item: DownloadHistoryItem): void {
|
||||
try {
|
||||
db.insert(downloadHistory).values({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
sourceUrl: item.sourceUrl,
|
||||
finishedAt: item.finishedAt,
|
||||
status: item.status,
|
||||
error: item.error,
|
||||
videoPath: item.videoPath,
|
||||
thumbnailPath: item.thumbnailPath,
|
||||
totalSize: item.totalSize,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.error("Error adding download history item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadHistory(): DownloadHistoryItem[] {
|
||||
try {
|
||||
const history = db.select().from(downloadHistory).orderBy(desc(downloadHistory.finishedAt)).all();
|
||||
return history.map(h => ({
|
||||
...h,
|
||||
status: h.status as 'success' | 'failed',
|
||||
author: h.author || undefined,
|
||||
sourceUrl: h.sourceUrl || undefined,
|
||||
error: h.error || undefined,
|
||||
videoPath: h.videoPath || undefined,
|
||||
thumbnailPath: h.thumbnailPath || undefined,
|
||||
totalSize: h.totalSize || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error getting download history:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDownloadHistoryItem(id: string): void {
|
||||
try {
|
||||
db.delete(downloadHistory).where(eq(downloadHistory.id, id)).run();
|
||||
} catch (error) {
|
||||
console.error("Error removing download history item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDownloadHistory(): void {
|
||||
try {
|
||||
db.delete(downloadHistory).run();
|
||||
} catch (error) {
|
||||
console.error("Error clearing download history:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export function getSettings(): Record<string, any> {
|
||||
@@ -661,45 +832,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;
|
||||
}
|
||||
}
|
||||
@@ -711,25 +865,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.rmdirSync(videoCollectionDir);
|
||||
if (fs.existsSync(collectionVideoDir) && fs.readdirSync(collectionVideoDir).length === 0) {
|
||||
fs.rmdirSync(collectionVideoDir);
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir) && fs.readdirSync(imageCollectionDir).length === 0) {
|
||||
fs.rmdirSync(imageCollectionDir);
|
||||
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 {
|
||||
@@ -737,32 +890,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.rmdirSync(videoCollectionDir);
|
||||
if (fs.existsSync(collectionVideoDir)) {
|
||||
fs.rmdirSync(collectionVideoDir);
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir)) {
|
||||
fs.rmdirSync(imageCollectionDir);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
const sanitized = withoutHashtags
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem 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
|
||||
|
||||
0
data/mytube.db
Normal file
0
data/mytube.db
Normal file
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.8.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"framer-motion": "^12.23.24",
|
||||
@@ -1669,6 +1671,59 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
|
||||
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
|
||||
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.91.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,6 +14,8 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.8.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"framer-motion": "^12.23.24",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import './App.css';
|
||||
@@ -7,6 +6,7 @@ import AnimatedRoutes from './components/AnimatedRoutes';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Footer from './components/Footer';
|
||||
import Header from './components/Header';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { CollectionProvider, useCollection } from './contexts/CollectionContext';
|
||||
import { DownloadProvider, useDownload } from './contexts/DownloadContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
@@ -15,31 +15,17 @@ import { VideoProvider, useVideo } from './contexts/VideoContext';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import getTheme from './theme';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
function AppContent() {
|
||||
const {
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
deleteVideo,
|
||||
isSearchMode,
|
||||
searchTerm,
|
||||
searchResults,
|
||||
localSearchResults,
|
||||
youtubeLoading,
|
||||
handleSearch,
|
||||
resetSearch,
|
||||
setIsSearchMode
|
||||
resetSearch
|
||||
} = useVideo();
|
||||
|
||||
const {
|
||||
collections,
|
||||
createCollection,
|
||||
addToCollection,
|
||||
removeFromCollection,
|
||||
deleteCollection
|
||||
} = useCollection();
|
||||
const { collections } = useCollection();
|
||||
|
||||
const {
|
||||
activeDownloads,
|
||||
@@ -53,16 +39,13 @@ function AppContent() {
|
||||
handleDownloadCurrentBilibiliPart
|
||||
} = useDownload();
|
||||
|
||||
const { isAuthenticated, loginRequired, checkingAuth } = useAuth();
|
||||
|
||||
// Theme state
|
||||
const [themeMode, setThemeMode] = useState<'light' | 'dark'>(() => {
|
||||
return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark';
|
||||
});
|
||||
|
||||
// Login state
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
|
||||
const [checkingAuth, setCheckingAuth] = useState<boolean>(true);
|
||||
|
||||
const theme = useMemo(() => getTheme(themeMode), [themeMode]);
|
||||
|
||||
// Apply theme to body
|
||||
@@ -75,58 +58,6 @@ function AppContent() {
|
||||
setThemeMode(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// Check login settings and authentication status
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// Check if login is enabled in settings
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const { loginEnabled, isPasswordSet } = response.data;
|
||||
|
||||
// Login is required only if enabled AND a password is set
|
||||
if (!loginEnabled || !isPasswordSet) {
|
||||
setLoginRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setLoginRequired(true);
|
||||
// Check if already authenticated in this session
|
||||
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
|
||||
if (sessionAuth === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth settings:', error);
|
||||
// If error, default to requiring login for security, but maybe allow if backend is down?
|
||||
// Better to fail safe.
|
||||
} finally {
|
||||
setCheckingAuth(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('mytube_authenticated', 'true');
|
||||
};
|
||||
|
||||
const handleDownloadFromSearch = async (videoUrl: string) => {
|
||||
try {
|
||||
// We need to stop the search mode
|
||||
setIsSearchMode(false);
|
||||
|
||||
const result = await handleVideoSubmit(videoUrl);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleDownloadFromSearch:', error);
|
||||
return { success: false, error: 'Failed to download video' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
@@ -134,7 +65,7 @@ function AppContent() {
|
||||
checkingAuth ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
<LoginPage />
|
||||
)
|
||||
) : (
|
||||
<Router>
|
||||
@@ -165,25 +96,8 @@ function AppContent() {
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
|
||||
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
|
||||
<AnimatedRoutes
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={deleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onResetSearch={resetSearch}
|
||||
onAddToCollection={addToCollection}
|
||||
onCreateCollection={createCollection}
|
||||
onRemoveFromCollection={removeFromCollection}
|
||||
onDeleteCollection={deleteCollection}
|
||||
/>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
|
||||
<AnimatedRoutes />
|
||||
</Box>
|
||||
|
||||
<Footer />
|
||||
@@ -194,19 +108,27 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<SnackbarProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</SnackbarProvider>
|
||||
</LanguageProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<SnackbarProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,17 @@
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import AuthorVideos from '../pages/AuthorVideos';
|
||||
import CollectionPage from '../pages/CollectionPage';
|
||||
import DownloadPage from '../pages/DownloadPage';
|
||||
import Home from '../pages/Home';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
import ManagePage from '../pages/ManagePage';
|
||||
import SearchResults from '../pages/SearchResults';
|
||||
import SettingsPage from '../pages/SettingsPage';
|
||||
import VideoPlayer from '../pages/VideoPlayer';
|
||||
import { Collection, Video } from '../types';
|
||||
import PageTransition from './PageTransition';
|
||||
|
||||
interface AnimatedRoutesProps {
|
||||
videos: Video[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
collections: Collection[];
|
||||
isSearchMode: boolean;
|
||||
searchTerm: string;
|
||||
localSearchResults: Video[];
|
||||
youtubeLoading: boolean;
|
||||
searchResults: any[];
|
||||
onDownload: (videoUrl: string) => Promise<any>;
|
||||
onResetSearch: () => void;
|
||||
onAddToCollection: (collectionId: string, videoId: string) => Promise<any>;
|
||||
onCreateCollection: (name: string, videoId: string) => Promise<any>;
|
||||
onRemoveFromCollection: (videoId: string) => Promise<boolean>;
|
||||
onDeleteCollection: (collectionId: string, deleteVideos?: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
const AnimatedRoutes = ({
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
onDeleteVideo,
|
||||
collections,
|
||||
isSearchMode,
|
||||
searchTerm,
|
||||
localSearchResults,
|
||||
youtubeLoading,
|
||||
searchResults,
|
||||
onDownload,
|
||||
onResetSearch,
|
||||
onAddToCollection,
|
||||
onCreateCollection,
|
||||
onRemoveFromCollection,
|
||||
onDeleteCollection
|
||||
}: AnimatedRoutesProps) => {
|
||||
const AnimatedRoutes: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
@@ -55,107 +20,120 @@ const AnimatedRoutes = ({
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PageTransition>
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={onDownload}
|
||||
onResetSearch={onResetSearch}
|
||||
/>
|
||||
</PageTransition>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<PageTransition>
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={onAddToCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onRemoveFromCollection={onRemoveFromCollection}
|
||||
/>
|
||||
</PageTransition>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<PageTransition>
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
</PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Home />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<PageTransition>
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
/>
|
||||
</PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<CollectionPage />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
path="/video/:id"
|
||||
element={
|
||||
<PageTransition>
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
loading={loading}
|
||||
onDownload={onDownload}
|
||||
onResetSearch={onResetSearch}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
collections={collections}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<VideoPlayer />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
path="/author/:authorName"
|
||||
element={
|
||||
<PageTransition>
|
||||
<ManagePage
|
||||
videos={videos}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
collections={collections}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
/>
|
||||
</PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<AuthorVideos />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<DownloadPage />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SettingsPage />
|
||||
</PageTransition>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<ManagePage />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SearchResults />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LoginPage />
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
{/* Redirect /login to home if already authenticated (or login disabled) */}
|
||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||
{/* Catch all - redirect to home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
@@ -97,8 +97,10 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -48,7 +48,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
|
||||
@@ -40,12 +40,14 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
onClose={onClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
maxWidth: 500,
|
||||
backgroundImage: 'none'
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
maxWidth: 500,
|
||||
backgroundImage: 'none'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -38,8 +38,10 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box, Container, Link, Typography, useTheme } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
const theme = useTheme();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
CircularProgress,
|
||||
ClickAwayListener,
|
||||
Collapse,
|
||||
Divider,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Menu,
|
||||
@@ -33,9 +34,11 @@ import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import logo from '../assets/logo.svg';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import AuthorsList from './AuthorsList';
|
||||
import Collections from './Collections';
|
||||
import TagsList from './TagsList';
|
||||
import UploadModal from './UploadModal';
|
||||
|
||||
interface DownloadInfo {
|
||||
@@ -87,9 +90,10 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { t } = useLanguage();
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
|
||||
|
||||
|
||||
const isDownloading = activeDownloads.length > 0 || queuedDownloads.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Header props:', { activeDownloads, queuedDownloads });
|
||||
@@ -119,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);
|
||||
@@ -179,7 +182,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{isDownloading && (
|
||||
{(
|
||||
<>
|
||||
<IconButton color="inherit" onClick={handleDownloadsClick}>
|
||||
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
|
||||
@@ -190,32 +193,34 @@ const Header: React.FC<HeaderProps> = ({
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleDownloadsClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
@@ -253,34 +258,38 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{queuedDownloads.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight="bold">
|
||||
{t('queued')} ({queuedDownloads.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
{queuedDownloads.map((download) => (
|
||||
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5, opacity: 0.7 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<CircularProgress
|
||||
variant="indeterminate"
|
||||
size={16}
|
||||
sx={{ mr: 2, flexShrink: 0, color: 'text.disabled' }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{download.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('waitingInQueue')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{queuedDownloads.length > 0 && [
|
||||
<Box key="queued-header" sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight="bold">
|
||||
{t('queued')} ({queuedDownloads.length})
|
||||
</Typography>
|
||||
</Box>,
|
||||
...queuedDownloads.map((download) => (
|
||||
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5, opacity: 0.7 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<CircularProgress
|
||||
variant="indeterminate"
|
||||
size={16}
|
||||
sx={{ mr: 2, flexShrink: 0, color: 'text.disabled' }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{download.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('waitingInQueue')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -302,25 +311,27 @@ const Header: React.FC<HeaderProps> = ({
|
||||
anchorEl={manageAnchorEl}
|
||||
open={Boolean(manageAnchorEl)}
|
||||
onClose={handleManageClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
@@ -347,25 +358,27 @@ const Header: React.FC<HeaderProps> = ({
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
size="small"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{isSearchMode && searchTerm && (
|
||||
<IconButton onClick={onResetSearch} edge="end" size="small" sx={{ mr: 0.5 }}>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { pr: 0, borderRadius: 2 }
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{isSearchMode && searchTerm && (
|
||||
<IconButton onClick={onResetSearch} edge="end" size="small" sx={{ mr: 0.5 }}>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { pr: 0, borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -451,6 +464,13 @@ const Header: React.FC<HeaderProps> = ({
|
||||
collections={collections}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList
|
||||
videos={videos}
|
||||
@@ -469,11 +489,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
</AppBar>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -29,7 +30,6 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [author, setAuthor] = useState<string>('Admin');
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
@@ -43,22 +43,8 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
setError(t('pleaseSelectVideo'));
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
formData.append('title', title);
|
||||
formData.append('author', author);
|
||||
|
||||
try {
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (formData: FormData) => {
|
||||
await axios.post(`${API_URL}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
@@ -68,15 +54,32 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
setProgress(percentCompleted);
|
||||
},
|
||||
});
|
||||
|
||||
},
|
||||
onSuccess: () => {
|
||||
onUploadSuccess();
|
||||
handleClose();
|
||||
} catch (err: any) {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('Upload failed:', err);
|
||||
setError(err.response?.data?.error || t('failedToUpload'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!file) {
|
||||
setError(t('pleaseSelectVideo'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
formData.append('title', title);
|
||||
formData.append('author', author);
|
||||
|
||||
uploadMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -89,7 +92,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={!uploading ? handleClose : undefined} maxWidth="sm" fullWidth>
|
||||
<Dialog open={open} onClose={!uploadMutation.isPending ? handleClose : undefined} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('uploadVideo')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||
@@ -114,7 +117,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
fullWidth
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={uploading}
|
||||
disabled={uploadMutation.isPending}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -122,7 +125,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
fullWidth
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
disabled={uploading}
|
||||
disabled={uploadMutation.isPending}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
@@ -131,7 +134,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
{uploadMutation.isPending && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}>
|
||||
@@ -142,13 +145,13 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={uploading}>{t('cancel')}</Button>
|
||||
<Button onClick={handleClose} disabled={uploadMutation.isPending}>{t('cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
variant="contained"
|
||||
disabled={!file || uploading}
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
>
|
||||
{uploading ? <CircularProgress size={24} /> : t('upload')}
|
||||
{uploadMutation.isPending ? <CircularProgress size={24} /> : t('upload')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
Delete,
|
||||
Folder,
|
||||
Movie,
|
||||
OndemandVideo,
|
||||
YouTube
|
||||
Folder
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
@@ -28,7 +25,7 @@ const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
collections?: Collection[];
|
||||
onDeleteVideo?: (id: string) => Promise<void>;
|
||||
onDeleteVideo?: (id: string) => Promise<any>;
|
||||
showDeleteButton?: boolean;
|
||||
disableCollectionGrouping?: boolean;
|
||||
}
|
||||
@@ -60,6 +57,24 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Format duration (seconds or MM:SS)
|
||||
const formatDuration = (duration: string | number | undefined) => {
|
||||
if (!duration) return null;
|
||||
|
||||
// If it's already a string with colon, assume it's formatted
|
||||
if (typeof duration === 'string' && duration.includes(':')) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Otherwise treat as seconds
|
||||
const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration;
|
||||
if (isNaN(seconds)) return null;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Use local thumbnail if available, otherwise fall back to the original URL
|
||||
const thumbnailSrc = video.thumbnailPath
|
||||
? `${BACKEND_URL}${video.thumbnailPath}`
|
||||
@@ -122,17 +137,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 (
|
||||
<>
|
||||
@@ -142,7 +147,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
@@ -174,16 +179,30 @@ 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
|
||||
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8 }}
|
||||
sx={{ position: 'absolute', bottom: 36, right: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{video.duration && (
|
||||
<Chip
|
||||
label={formatDuration(video.duration)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
height: 20,
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: 'rgba(0,0,0,0.8)',
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -226,6 +245,9 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
@@ -239,7 +261,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
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Forward10,
|
||||
Fullscreen,
|
||||
FullscreenExit,
|
||||
KeyboardDoubleArrowLeft,
|
||||
KeyboardDoubleArrowRight,
|
||||
Loop,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
@@ -24,12 +26,18 @@ interface VideoControlsProps {
|
||||
src: string;
|
||||
autoPlay?: boolean;
|
||||
autoLoop?: boolean;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onLoadedMetadata?: (duration: number) => void;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
src,
|
||||
autoPlay = false,
|
||||
autoLoop = false
|
||||
autoLoop = false,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
startTime = 0
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -59,9 +67,28 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
const handleWebkitBeginFullscreen = () => {
|
||||
setIsFullscreen(true);
|
||||
};
|
||||
|
||||
const handleWebkitEndFullscreen = () => {
|
||||
setIsFullscreen(false);
|
||||
};
|
||||
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (videoElement) {
|
||||
videoElement.addEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen);
|
||||
videoElement.addEventListener('webkitendfullscreen', handleWebkitEndFullscreen);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen);
|
||||
videoElement.removeEventListener('webkitendfullscreen', handleWebkitEndFullscreen);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -85,14 +112,25 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
|
||||
const handleToggleFullscreen = () => {
|
||||
const videoContainer = videoRef.current?.parentElement;
|
||||
if (!videoContainer) return;
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
if (!videoContainer || !videoElement) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
videoContainer.requestFullscreen().catch(err => {
|
||||
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||
});
|
||||
// Try standard fullscreen first (for Desktop, Android, iPad)
|
||||
if (videoContainer.requestFullscreen) {
|
||||
videoContainer.requestFullscreen().catch(err => {
|
||||
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||
});
|
||||
}
|
||||
// Fallback for iPhone Safari
|
||||
else if ((videoElement as any).webkitEnterFullscreen) {
|
||||
(videoElement as any).webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,11 +141,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
};
|
||||
|
||||
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' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
|
||||
controls={false} // We use custom controls, but maybe we should keep native controls as fallback or overlay? The original had controls={true} AND custom controls.
|
||||
controls={true} // Enable native controls as requested
|
||||
// The original code had `controls` attribute on the video tag, which enables native controls.
|
||||
// But it also rendered custom controls below it.
|
||||
// Let's keep it consistent with original: native controls enabled.
|
||||
@@ -115,6 +153,15 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
src={src}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={(e) => onTimeUpdate && onTimeUpdate(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => {
|
||||
if (startTime > 0) {
|
||||
e.currentTarget.currentTime = startTime;
|
||||
}
|
||||
if (onLoadedMetadata) {
|
||||
onLoadedMetadata(e.currentTarget.duration);
|
||||
}
|
||||
}}
|
||||
playsInline
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
@@ -125,7 +172,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
p: 1,
|
||||
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
|
||||
opacity: isFullscreen ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
transition: 'opacity 0.3s, background-color 0.3s',
|
||||
'&:hover': { opacity: 1 }
|
||||
}}>
|
||||
<Stack
|
||||
@@ -171,6 +218,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
|
||||
{/* Row 2 on Mobile: Seek Controls */}
|
||||
<Stack direction="row" spacing={1} 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 />
|
||||
@@ -191,6 +243,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
<FastForward />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="+10m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(600)}>
|
||||
<KeyboardDoubleArrowRight />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../contexts/SnackbarContext';
|
||||
import { Collection, Video } from '../../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -64,7 +63,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
|
||||
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
@@ -163,6 +162,9 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.rating ? `(${video.rating})` : t('rateThisVideo')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
@@ -240,7 +242,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' }}>
|
||||
@@ -282,29 +284,20 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
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 }}
|
||||
InputProps={{ ...params.InputProps, disableUnderline: true }}
|
||||
slotProps={{
|
||||
input: { ...params.InputProps, disableUnderline: true }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
variant="outlined"
|
||||
label={option}
|
||||
size="small"
|
||||
{...tagProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
77
frontend/src/contexts/AuthContext.tsx
Normal file
77
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
loginRequired: boolean;
|
||||
checkingAuth: boolean;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Check login settings and authentication status
|
||||
const { isLoading: checkingAuth } = useQuery({
|
||||
queryKey: ['authSettings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Check if login is enabled in settings
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const { loginEnabled, isPasswordSet } = response.data;
|
||||
|
||||
// Login is required only if enabled AND a password is set
|
||||
if (!loginEnabled || !isPasswordSet) {
|
||||
setLoginRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setLoginRequired(true);
|
||||
// Check if already authenticated in this session
|
||||
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
|
||||
if (sessionAuth === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking auth settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const login = () => {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('mytube_authenticated', 'true');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem('mytube_authenticated');
|
||||
queryClient.invalidateQueries({ queryKey: ['authSettings'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { Collection } from '../types';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
import { useVideo } from './VideoContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -27,46 +28,67 @@ export const useCollection = () => {
|
||||
|
||||
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { fetchVideos } = useVideo();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: collections = [], refetch: fetchCollectionsQuery } = useQuery({
|
||||
queryKey: ['collections'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/collections`);
|
||||
return response.data as Collection[];
|
||||
}
|
||||
});
|
||||
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/collections`);
|
||||
setCollections(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
}
|
||||
await fetchCollectionsQuery();
|
||||
};
|
||||
|
||||
const createCollection = async (name: string, videoId: string) => {
|
||||
try {
|
||||
const createCollectionMutation = useMutation({
|
||||
mutationFn: async ({ name, videoId }: { name: string, videoId: string }) => {
|
||||
const response = await axios.post(`${API_URL}/collections`, {
|
||||
name,
|
||||
videoId
|
||||
});
|
||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
||||
showSnackbar('Collection created successfully');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
showSnackbar(t('collectionCreatedSuccessfully'));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error creating collection:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const createCollection = async (name: string, videoId: string) => {
|
||||
try {
|
||||
return await createCollectionMutation.mutateAsync({ name, videoId });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const addToCollection = async (collectionId: string, videoId: string) => {
|
||||
try {
|
||||
const addToCollectionMutation = useMutation({
|
||||
mutationFn: async ({ collectionId, videoId }: { collectionId: string, videoId: string }) => {
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||
videoId,
|
||||
action: "add"
|
||||
});
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? response.data : collection
|
||||
));
|
||||
showSnackbar('Video added to collection');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
showSnackbar(t('videoAddedToCollection'));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error adding video to collection:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const addToCollection = async (collectionId: string, videoId: string) => {
|
||||
try {
|
||||
return await addToCollectionMutation.mutateAsync({ collectionId, videoId });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -77,19 +99,15 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
collection.videos.includes(videoId)
|
||||
);
|
||||
|
||||
for (const collection of collectionsWithVideo) {
|
||||
await axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||
await Promise.all(collectionsWithVideo.map(collection =>
|
||||
axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||
videoId,
|
||||
action: "remove"
|
||||
});
|
||||
}
|
||||
})
|
||||
));
|
||||
|
||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
||||
...collection,
|
||||
videos: collection.videos.filter(v => v !== videoId)
|
||||
})));
|
||||
|
||||
showSnackbar('Video removed from collection');
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
showSnackbar(t('videoRemovedFromCollection'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing video from collection:', error);
|
||||
@@ -97,34 +115,35 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
|
||||
try {
|
||||
const deleteCollectionMutation = useMutation({
|
||||
mutationFn: async ({ collectionId, deleteVideos }: { collectionId: string, deleteVideos: boolean }) => {
|
||||
await axios.delete(`${API_URL}/collections/${collectionId}`, {
|
||||
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
|
||||
});
|
||||
|
||||
setCollections(prevCollections =>
|
||||
prevCollections.filter(collection => collection.id !== collectionId)
|
||||
);
|
||||
|
||||
return { collectionId, deleteVideos };
|
||||
},
|
||||
onSuccess: ({ deleteVideos }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||
if (deleteVideos) {
|
||||
await fetchVideos();
|
||||
queryClient.invalidateQueries({ queryKey: ['videos'] });
|
||||
}
|
||||
|
||||
showSnackbar('Collection deleted successfully');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
showSnackbar(t('collectionDeletedSuccessfully'));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error deleting collection:', error);
|
||||
showSnackbar('Failed to delete collection', 'error');
|
||||
showSnackbar(t('failedToDeleteCollection'), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
|
||||
try {
|
||||
await deleteCollectionMutation.mutateAsync({ collectionId, deleteVideos });
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: 'Failed to delete collection' };
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch collections on mount
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CollectionContext.Provider value={{
|
||||
collections,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { DownloadInfo } from '../types';
|
||||
import { useCollection } from './CollectionContext';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
import { useVideo } from './VideoContext';
|
||||
|
||||
@@ -63,17 +65,26 @@ 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();
|
||||
|
||||
// Get initial download status from localStorage
|
||||
const initialStatus = getStoredDownloadStatus();
|
||||
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
|
||||
initialStatus ? initialStatus.activeDownloads || [] : []
|
||||
);
|
||||
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>(
|
||||
initialStatus ? initialStatus.queuedDownloads || [] : []
|
||||
);
|
||||
|
||||
const { data: downloadStatus } = useQuery({
|
||||
queryKey: ['downloadStatus'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 2000,
|
||||
initialData: initialStatus || { activeDownloads: [], queuedDownloads: [] }
|
||||
});
|
||||
|
||||
const activeDownloads = downloadStatus.activeDownloads || [];
|
||||
const queuedDownloads = downloadStatus.queuedDownloads || [];
|
||||
|
||||
// Bilibili multi-part video state
|
||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
|
||||
@@ -89,67 +100,43 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
// Reference to track current download IDs for detecting completion
|
||||
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Add a function to check download status from the backend
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data;
|
||||
useEffect(() => {
|
||||
const newIds = new Set<string>([
|
||||
...activeDownloads.map((d: DownloadInfo) => d.id),
|
||||
...queuedDownloads.map((d: DownloadInfo) => d.id)
|
||||
]);
|
||||
|
||||
const newActive = backendActive || [];
|
||||
const newQueued = backendQueued || [];
|
||||
|
||||
// Create a set of all current download IDs from the backend
|
||||
const newIds = new Set<string>([
|
||||
...newActive.map((d: DownloadInfo) => d.id),
|
||||
...newQueued.map((d: DownloadInfo) => d.id)
|
||||
]);
|
||||
|
||||
// Check if any ID from the previous check is missing in the new check
|
||||
// This implies it finished (or failed), so we should refresh the video list
|
||||
let hasCompleted = false;
|
||||
if (currentDownloadIdsRef.current.size > 0) {
|
||||
for (const id of currentDownloadIdsRef.current) {
|
||||
if (!newIds.has(id)) {
|
||||
hasCompleted = true;
|
||||
break;
|
||||
}
|
||||
let hasCompleted = false;
|
||||
if (currentDownloadIdsRef.current.size > 0) {
|
||||
for (const id of currentDownloadIdsRef.current) {
|
||||
if (!newIds.has(id)) {
|
||||
hasCompleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the ref for the next check
|
||||
currentDownloadIdsRef.current = newIds;
|
||||
|
||||
if (hasCompleted) {
|
||||
console.log('Download completed, refreshing videos');
|
||||
fetchVideos();
|
||||
}
|
||||
|
||||
if (newActive.length > 0 || newQueued.length > 0) {
|
||||
// If backend has active or queued downloads, update the local status
|
||||
setActiveDownloads(newActive);
|
||||
setQueuedDownloads(newQueued);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
const statusData = {
|
||||
activeDownloads: newActive,
|
||||
queuedDownloads: newQueued,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
// If backend says no downloads are in progress, clear the status
|
||||
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
||||
console.log('Backend says downloads are complete, clearing status');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
setActiveDownloads([]);
|
||||
setQueuedDownloads([]);
|
||||
// Refresh videos list when downloads complete (fallback)
|
||||
fetchVideos();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking backend download status:', error);
|
||||
}
|
||||
|
||||
currentDownloadIdsRef.current = newIds;
|
||||
|
||||
if (hasCompleted) {
|
||||
console.log('Download completed, refreshing videos');
|
||||
fetchVideos();
|
||||
}
|
||||
|
||||
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
||||
const statusData = {
|
||||
activeDownloads,
|
||||
queuedDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [activeDownloads, queuedDownloads, fetchVideos]);
|
||||
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
|
||||
@@ -211,9 +198,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
}
|
||||
|
||||
// Normal download flow
|
||||
// We don't set activeDownloads here immediately because the backend will queue it
|
||||
// and we'll pick it up via polling
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||
|
||||
// If the response contains a downloadId, it means it was queued/started
|
||||
@@ -225,17 +209,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
}
|
||||
|
||||
// Use the setIsSearchMode from VideoContext but we need to expose it there first
|
||||
// For now, we can assume the caller handles UI state or we add it to VideoContext
|
||||
// Actually, let's just use the resetSearch from VideoContext which handles search mode
|
||||
// But wait, resetSearch clears everything. We just want to exit search mode.
|
||||
// Let's update VideoContext to expose setIsSearchMode or handle it better.
|
||||
// For now, let's assume VideoContext handles it via resetSearch if needed, or we just ignore it here
|
||||
// and let the component handle UI.
|
||||
// Wait, the original code called setIsSearchMode(false).
|
||||
// I should add setIsSearchMode to VideoContext interface.
|
||||
|
||||
showSnackbar('Video downloading');
|
||||
showSnackbar(t('videoDownloading'));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error downloading video:', err);
|
||||
@@ -275,7 +249,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);
|
||||
@@ -293,69 +267,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
||||
};
|
||||
|
||||
// Check backend download status periodically
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// Then check every 2 seconds (faster polling for better UX)
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads.length, queuedDownloads.length]);
|
||||
|
||||
// Set up localStorage and event listeners
|
||||
useEffect(() => {
|
||||
// Set up event listener for storage changes (for multi-tab support)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
||||
try {
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [], queuedDownloads: [] };
|
||||
setActiveDownloads(newStatus.activeDownloads || []);
|
||||
setQueuedDownloads(newStatus.queuedDownloads || []);
|
||||
} catch (error) {
|
||||
console.error('Error handling storage change:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Set up periodic check for stale download status
|
||||
const checkDownloadStatus = () => {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status && (activeDownloads.length > 0 || queuedDownloads.length > 0)) {
|
||||
console.log('Clearing stale download status');
|
||||
setActiveDownloads([]);
|
||||
setQueuedDownloads([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads, queuedDownloads]);
|
||||
|
||||
// Update localStorage whenever activeDownloads or queuedDownloads changes
|
||||
useEffect(() => {
|
||||
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
||||
const statusData = {
|
||||
activeDownloads,
|
||||
queuedDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [activeDownloads, queuedDownloads]);
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={{
|
||||
activeDownloads,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Video } from '../types';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -11,6 +13,8 @@ interface VideoContextType {
|
||||
error: string | null;
|
||||
fetchVideos: () => Promise<void>;
|
||||
deleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
updateVideo: (id: string, updates: Partial<Video>) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshThumbnail: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
searchLocalVideos: (query: string) => Video[];
|
||||
searchResults: any[];
|
||||
localSearchResults: Video[];
|
||||
@@ -21,6 +25,9 @@ interface VideoContextType {
|
||||
resetSearch: () => void;
|
||||
setVideos: React.Dispatch<React.SetStateAction<Video[]>>;
|
||||
setIsSearchMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
availableTags: string[];
|
||||
selectedTags: string[];
|
||||
handleTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextType | undefined>(undefined);
|
||||
@@ -35,9 +42,28 @@ export const useVideo = () => {
|
||||
|
||||
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Videos Query
|
||||
const { data: videos = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
|
||||
queryKey: ['videos'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
return response.data as Video[];
|
||||
},
|
||||
});
|
||||
|
||||
// Tags Query
|
||||
const { data: availableTags = [] } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data.tags || [];
|
||||
},
|
||||
});
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
// Search state
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
@@ -49,32 +75,44 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef<AbortController | null>(null);
|
||||
|
||||
// Wrapper for refetch to match interface
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
setVideos(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await refetchVideos();
|
||||
};
|
||||
|
||||
// Emulate setVideos for compatibility
|
||||
const setVideos: React.Dispatch<React.SetStateAction<Video[]>> = (updater) => {
|
||||
queryClient.setQueryData(['videos'], (oldVideos: Video[] | undefined) => {
|
||||
const currentVideos = oldVideos || [];
|
||||
if (typeof updater === 'function') {
|
||||
return updater(currentVideos);
|
||||
}
|
||||
return updater;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteVideoMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||
old ? old.filter(video => video.id !== id) : []
|
||||
);
|
||||
showSnackbar(t('videoRemovedSuccessfully'));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error deleting video:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteVideo = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
setLoading(false);
|
||||
showSnackbar('Video removed successfully');
|
||||
await deleteVideoMutation.mutateAsync(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setLoading(false);
|
||||
return { success: false, error: 'Failed to delete video' };
|
||||
return { success: false, error: t('failedToDeleteVideo') };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,7 +140,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const handleSearch = async (query: string): Promise<any> => {
|
||||
if (!query || query.trim() === '') {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
return { success: false, error: t('pleaseEnterSearchTerm') };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -151,20 +189,19 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to search. Please try again.' };
|
||||
}
|
||||
return { success: false, error: 'Search was cancelled' };
|
||||
} finally {
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
return { success: false, error: t('failedToSearch') };
|
||||
}
|
||||
return { success: false, error: t('searchCancelled') };
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch videos on mount
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
? prev.filter(t => t !== tag)
|
||||
: [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
// Cleanup search on unmount
|
||||
useEffect(() => {
|
||||
@@ -176,13 +213,81 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshThumbnailMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
|
||||
return { id, data: response.data };
|
||||
},
|
||||
onSuccess: ({ id, data }) => {
|
||||
if (data.success) {
|
||||
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||
old ? old.map(video =>
|
||||
video.id === id
|
||||
? { ...video, thumbnailUrl: data.thumbnailUrl, thumbnailPath: data.thumbnailUrl }
|
||||
: video
|
||||
) : []
|
||||
);
|
||||
showSnackbar(t('thumbnailRefreshed'));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error refreshing thumbnail:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const refreshThumbnail = async (id: string) => {
|
||||
try {
|
||||
const result = await refreshThumbnailMutation.mutateAsync(id);
|
||||
if (result.data.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||
} catch (error) {
|
||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||
}
|
||||
};
|
||||
|
||||
const updateVideoMutation = useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Video> }) => {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
|
||||
return { id, updates, data: response.data };
|
||||
},
|
||||
onSuccess: ({ id, updates, data }) => {
|
||||
if (data.success) {
|
||||
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||
old ? old.map(video =>
|
||||
video.id === id ? { ...video, ...updates } : video
|
||||
) : []
|
||||
);
|
||||
showSnackbar(t('videoUpdated'));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating video:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const updateVideo = async (id: string, updates: Partial<Video>) => {
|
||||
try {
|
||||
const result = await updateVideoMutation.mutateAsync({ id, updates });
|
||||
if (result.data.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
} catch (error) {
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
loading: videosLoading,
|
||||
error: videosError ? (videosError as Error).message : null,
|
||||
fetchVideos,
|
||||
deleteVideo,
|
||||
updateVideo,
|
||||
refreshThumbnail,
|
||||
searchLocalVideos,
|
||||
searchResults,
|
||||
localSearchResults,
|
||||
@@ -192,7 +297,10 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
handleSearch,
|
||||
resetSearch,
|
||||
setVideos,
|
||||
setIsSearchMode
|
||||
setIsSearchMode,
|
||||
availableTags,
|
||||
selectedTags,
|
||||
handleTagToggle
|
||||
}}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
|
||||
@@ -35,4 +35,11 @@ html {
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Smooth theme transition */
|
||||
*, *::before, *::after {
|
||||
transition-property: background-color, color, border-color, fill, stroke, box-shadow;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
@@ -9,62 +9,34 @@ import {
|
||||
Grid,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface AuthorVideosProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
||||
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();
|
||||
|
||||
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!author) return;
|
||||
|
||||
// If videos are passed as props, filter them
|
||||
if (allVideos && allVideos.length > 0) {
|
||||
const filteredVideos = allVideos.filter(
|
||||
if (videos) {
|
||||
const filteredVideos = videos.filter(
|
||||
video => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch from API
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
// Filter videos by author
|
||||
const filteredVideos = response.data.filter(
|
||||
(video: Video) => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, [author, allVideos]);
|
||||
}, [author, videos]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
@@ -79,14 +51,6 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('loadVideosError')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = authorVideos.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
@@ -116,7 +80,7 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
</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')}
|
||||
@@ -141,7 +105,7 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -4,59 +4,34 @@ import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Grid,
|
||||
Pagination,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
interface CollectionPageProps {
|
||||
collections: Collection[];
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
|
||||
}
|
||||
|
||||
const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, onDeleteVideo, onDeleteCollection }) => {
|
||||
const CollectionPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [collection, setCollection] = useState<Collection | null>(null);
|
||||
const [collectionVideos, setCollectionVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { collections, deleteCollection } = useCollection();
|
||||
const { videos, deleteVideo } = useVideo();
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
useEffect(() => {
|
||||
if (collections && collections.length > 0 && id) {
|
||||
const foundCollection = collections.find(c => c.id === id);
|
||||
|
||||
if (foundCollection) {
|
||||
setCollection(foundCollection);
|
||||
|
||||
// Find all videos that are in this collection
|
||||
const videosInCollection = videos.filter(video =>
|
||||
foundCollection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
setCollectionVideos(videosInCollection);
|
||||
} else {
|
||||
// Collection not found, redirect to home
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
}, [id, collections, videos, navigate]);
|
||||
const collection = collections.find(c => c.id === id);
|
||||
const collectionVideos = collection
|
||||
? videos.filter(video => collection.videos.includes(video.id))
|
||||
: [];
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(collectionVideos.length / ITEMS_PER_PAGE);
|
||||
@@ -80,8 +55,8 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
|
||||
const handleDeleteCollectionOnly = async () => {
|
||||
if (!id) return;
|
||||
const success = await onDeleteCollection(id, false);
|
||||
if (success) {
|
||||
const result = await deleteCollection(id, false);
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
@@ -89,26 +64,25 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
|
||||
const handleDeleteCollectionAndVideos = async () => {
|
||||
if (!id) return;
|
||||
const success = await onDeleteCollection(id, true);
|
||||
if (success) {
|
||||
const result = await deleteCollection(id, true);
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>{t('loadingCollection')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('collectionNotFound')}</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleBack}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('back')}
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +122,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
disableCollectionGrouping={true}
|
||||
/>
|
||||
|
||||
375
frontend/src/pages/DownloadPage.tsx
Normal file
375
frontend/src/pages/DownloadPage.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
Cancel as CancelIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ClearAll as ClearAllIcon,
|
||||
Delete as DeleteIcon,
|
||||
Error as ErrorIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
sourceUrl?: string;
|
||||
finishedAt: number;
|
||||
status: 'success' | 'failed';
|
||||
error?: string;
|
||||
videoPath?: string;
|
||||
thumbnailPath?: string;
|
||||
totalSize?: string;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function CustomTabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DownloadPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { activeDownloads, queuedDownloads } = useDownload();
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Fetch history with polling
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ['downloadHistory'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/downloads/history`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 2000
|
||||
});
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
// Cancel download mutation
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
||||
},
|
||||
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'] });
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelDownload = (id: string) => {
|
||||
cancelMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Remove from queue mutation
|
||||
const removeFromQueueMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.delete(`${API_URL}/downloads/queue/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('removedFromQueue') || 'Removed from queue');
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFromQueue = (id: string) => {
|
||||
removeFromQueueMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Clear queue mutation
|
||||
const clearQueueMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/downloads/queue`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('queueCleared') || 'Queue cleared');
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleClearQueue = () => {
|
||||
clearQueueMutation.mutate();
|
||||
};
|
||||
|
||||
// Remove from history mutation
|
||||
const removeFromHistoryMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.delete(`${API_URL}/downloads/history/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('removedFromHistory') || 'Removed from history');
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFromHistory = (id: string) => {
|
||||
removeFromHistoryMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Clear history mutation
|
||||
const clearHistoryMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/downloads/history`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('historyCleared') || 'History cleared');
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleClearHistory = () => {
|
||||
clearHistoryMutation.mutate();
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', p: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('downloads') || 'Downloads'}
|
||||
</Typography>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="download tabs">
|
||||
<Tab label={t('activeDownloads') || 'Active Downloads'} />
|
||||
<Tab label={t('queuedDownloads') || 'Queue'} />
|
||||
<Tab label={t('downloadHistory') || 'History'} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Active Downloads */}
|
||||
<CustomTabPanel value={tabValue} index={0}>
|
||||
{activeDownloads.length === 0 ? (
|
||||
<Typography color="textSecondary">{t('noActiveDownloads') || 'No active downloads'}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{activeDownloads.map((download) => (
|
||||
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="cancel" onClick={() => handleCancelDownload(download.id)}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={download.title}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={download.progress || 0} sx={{ mb: 1 }} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
|
||||
{/* Queue */}
|
||||
<CustomTabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ClearAllIcon />}
|
||||
onClick={handleClearQueue}
|
||||
disabled={queuedDownloads.length === 0}
|
||||
>
|
||||
{t('clearQueue') || 'Clear Queue'}
|
||||
</Button>
|
||||
</Box>
|
||||
{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>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
|
||||
{/* History */}
|
||||
<CustomTabPanel value={tabValue} index={2}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ClearAllIcon />}
|
||||
onClick={handleClearHistory}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
{t('clearHistory') || 'Clear History'}
|
||||
</Button>
|
||||
</Box>
|
||||
{history.length === 0 ? (
|
||||
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{history.map((item: DownloadHistoryItem) => (
|
||||
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{item.sourceUrl && (
|
||||
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
{item.sourceUrl}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" component="span">
|
||||
{formatDate(item.finishedAt)}
|
||||
</Typography>
|
||||
{item.error && (
|
||||
<Typography variant="caption" color="error" component="span">
|
||||
{item.error}
|
||||
</Typography>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadPage;
|
||||
@@ -16,87 +16,58 @@ 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';
|
||||
import Collections from '../components/Collections';
|
||||
import TagsList from '../components/TagsList';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
}
|
||||
const Home: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
deleteVideo,
|
||||
availableTags,
|
||||
selectedTags,
|
||||
handleTagToggle,
|
||||
isSearchMode,
|
||||
searchTerm,
|
||||
localSearchResults,
|
||||
searchResults,
|
||||
youtubeLoading,
|
||||
setIsSearchMode,
|
||||
resetSearch
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
|
||||
interface HomeProps {
|
||||
videos: Video[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
isSearchMode: boolean;
|
||||
searchTerm: string;
|
||||
localSearchResults: Video[];
|
||||
youtubeLoading: boolean;
|
||||
searchResults: SearchResult[];
|
||||
onDownload: (url: string, title?: string) => void;
|
||||
onResetSearch: () => void;
|
||||
}
|
||||
|
||||
const Home: React.FC<HomeProps> = ({
|
||||
videos = [],
|
||||
loading,
|
||||
error,
|
||||
onDeleteVideo,
|
||||
collections = [],
|
||||
isSearchMode = false,
|
||||
searchTerm = '',
|
||||
localSearchResults = [],
|
||||
youtubeLoading = false,
|
||||
searchResults = [],
|
||||
onDownload,
|
||||
onResetSearch
|
||||
}) => {
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
const { t } = useLanguage();
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [viewMode, setViewMode] = useState<'collections' | 'all-videos'>(() => {
|
||||
const saved = localStorage.getItem('homeViewMode');
|
||||
return (saved as 'collections' | 'all-videos') || 'collections';
|
||||
});
|
||||
|
||||
// Fetch tags
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data.tags) {
|
||||
setAvailableTags(response.data.tags);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
// Reset page when filters change (though currently no filters other than search which is separate)
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [videos, collections]);
|
||||
}, [videos, collections, selectedTags]);
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
setIsSearchMode(false);
|
||||
await handleVideoSubmit(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
@@ -155,15 +126,6 @@ const Home: React.FC<HomeProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
? prev.filter(t => t !== tag)
|
||||
: [...prev, tag]
|
||||
);
|
||||
setPage(1); // Reset to first page when filter changes
|
||||
};
|
||||
|
||||
const handleViewModeChange = (mode: 'collections' | 'all-videos') => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('homeViewMode', mode);
|
||||
@@ -182,8 +144,6 @@ const Home: React.FC<HomeProps> = ({
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
@@ -211,11 +171,11 @@ const Home: React.FC<HomeProps> = ({
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('searchResultsFor')} "{searchTerm}"
|
||||
</Typography>
|
||||
{onResetSearch && (
|
||||
{resetSearch && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={onResetSearch}
|
||||
onClick={resetSearch}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
@@ -234,7 +194,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -302,7 +262,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => onDownload(result.sourceUrl, result.title)}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
{t('download')}
|
||||
</Button>
|
||||
|
||||
@@ -10,48 +10,50 @@ import {
|
||||
ThemeProvider,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import getTheme from '../theme';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface LoginPageProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
const LoginPage: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
const { login } = useAuth();
|
||||
|
||||
// Use dark theme for login page to match app style
|
||||
const theme = getTheme('dark');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (password: string) => {
|
||||
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
if (response.data.success) {
|
||||
onLoginSuccess();
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
login();
|
||||
} else {
|
||||
setError(t('incorrectPassword'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('Login error:', err);
|
||||
if (err.response && err.response.status === 401) {
|
||||
setError(t('incorrectPassword'));
|
||||
} else {
|
||||
setError(t('loginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
loginMutation.mutate(password);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,9 +99,9 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loading ? t('verifying') : t('signIn')}
|
||||
{loginMutation.isPending ? t('verifying') : t('signIn')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
Check,
|
||||
Close,
|
||||
Delete,
|
||||
Edit,
|
||||
Folder,
|
||||
Refresh,
|
||||
Search,
|
||||
VideoLibrary
|
||||
} from '@mui/icons-material';
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
@@ -29,36 +34,108 @@ 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';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
interface ManagePageProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
|
||||
}
|
||||
|
||||
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
|
||||
const ManagePage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { t } = useLanguage();
|
||||
const { videos, deleteVideo, refreshThumbnail, updateVideo } = useVideo();
|
||||
const { collections, deleteCollection } = useCollection();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
|
||||
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>('');
|
||||
const [isSavingTitle, setIsSavingTitle] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [collectionPage, setCollectionPage] = useState(1);
|
||||
const [videoPage, setVideoPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// Sorting state
|
||||
const [orderBy, setOrderBy] = useState<keyof Video | 'fileSize'>('addedAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const handleRequestSort = (property: keyof Video | 'fileSize') => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: string | undefined) => {
|
||||
if (!duration) return '';
|
||||
const seconds = parseInt(duration, 10);
|
||||
if (isNaN(seconds)) return duration;
|
||||
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
video.author.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
).sort((a, b) => {
|
||||
let aValue: any = a[orderBy as keyof Video];
|
||||
let bValue: any = b[orderBy as keyof Video];
|
||||
|
||||
if (orderBy === 'fileSize') {
|
||||
aValue = a.fileSize ? parseInt(a.fileSize, 10) : 0;
|
||||
bValue = b.fileSize ? parseInt(b.fileSize, 10) : 0;
|
||||
}
|
||||
|
||||
if (bValue < aValue) {
|
||||
return order === 'asc' ? 1 : -1;
|
||||
}
|
||||
if (bValue > aValue) {
|
||||
return order === 'asc' ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const formatSize = (bytes: string | number | undefined) => {
|
||||
if (!bytes) return '0 B';
|
||||
const size = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(size)) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const totalSize = filteredVideos.reduce((acc, video) => {
|
||||
const size = video.fileSize ? parseInt(video.fileSize, 10) : 0;
|
||||
return acc + (isNaN(size) ? 0 : size);
|
||||
}, 0);
|
||||
|
||||
const getCollectionSize = (collectionVideoIds: string[]) => {
|
||||
const totalBytes = collectionVideoIds.reduce((acc, videoId) => {
|
||||
const video = videos.find(v => v.id === videoId);
|
||||
if (video && video.fileSize) {
|
||||
const size = parseInt(video.fileSize, 10);
|
||||
return acc + (isNaN(size) ? 0 : size);
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
return formatSize(totalBytes);
|
||||
};
|
||||
|
||||
// Pagination logic
|
||||
const totalCollectionPages = Math.ceil(collections.length / ITEMS_PER_PAGE);
|
||||
@@ -86,7 +163,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
if (!videoToDelete) return;
|
||||
|
||||
setDeletingId(videoToDelete);
|
||||
await onDeleteVideo(videoToDelete);
|
||||
await deleteVideo(videoToDelete);
|
||||
setDeletingId(null);
|
||||
setVideoToDelete(null);
|
||||
setShowVideoDeleteModal(false); // Close the modal after deletion
|
||||
@@ -104,7 +181,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
const handleCollectionDeleteOnly = async () => {
|
||||
if (!collectionToDelete) return;
|
||||
setIsDeletingCollection(true);
|
||||
await onDeleteCollection(collectionToDelete.id, false);
|
||||
await deleteCollection(collectionToDelete.id, false);
|
||||
setIsDeletingCollection(false);
|
||||
setCollectionToDelete(null);
|
||||
};
|
||||
@@ -112,11 +189,37 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
const handleCollectionDeleteAll = async () => {
|
||||
if (!collectionToDelete) return;
|
||||
setIsDeletingCollection(true);
|
||||
await onDeleteCollection(collectionToDelete.id, true);
|
||||
await deleteCollection(collectionToDelete.id, true);
|
||||
setIsDeletingCollection(false);
|
||||
setCollectionToDelete(null);
|
||||
};
|
||||
|
||||
const handleRefreshThumbnail = async (id: string) => {
|
||||
setRefreshingId(id);
|
||||
await refreshThumbnail(id);
|
||||
setRefreshingId(null);
|
||||
};
|
||||
|
||||
const handleEditClick = (video: Video) => {
|
||||
setEditingVideoId(video.id);
|
||||
setEditTitle(video.title);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingVideoId(null);
|
||||
setEditTitle('');
|
||||
};
|
||||
|
||||
const handleSaveTitle = async (id: string) => {
|
||||
if (!editTitle.trim()) return;
|
||||
|
||||
setIsSavingTitle(true);
|
||||
await updateVideo(id, { title: editTitle });
|
||||
setIsSavingTitle(false);
|
||||
setEditingVideoId(null);
|
||||
setEditTitle('');
|
||||
};
|
||||
|
||||
const getThumbnailSrc = (video: Video) => {
|
||||
if (video.thumbnailPath) {
|
||||
return `${BACKEND_URL}${video.thumbnailPath}`;
|
||||
@@ -176,6 +279,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<TableRow>
|
||||
<TableCell>{t('name')}</TableCell>
|
||||
<TableCell>{t('videos')}</TableCell>
|
||||
<TableCell>{t('size')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
@@ -187,6 +291,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
{collection.name}
|
||||
</TableCell>
|
||||
<TableCell>{collection.videos.length} videos</TableCell>
|
||||
<TableCell>{getCollectionSize(collection.videos)}</TableCell>
|
||||
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteCollection')}>
|
||||
@@ -223,22 +328,25 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{/* Videos List */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<VideoLibrary sx={{ mr: 1, color: 'primary.main' }} />
|
||||
{t('videos')} ({filteredVideos.length})
|
||||
{t('videos')} ({filteredVideos.length}) - {formatSize(totalSize)}
|
||||
</Typography>
|
||||
<TextField
|
||||
placeholder="Search videos..."
|
||||
size="small"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}
|
||||
}}
|
||||
sx={{ width: 300 }}
|
||||
/>
|
||||
@@ -250,8 +358,33 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('thumbnail')}</TableCell>
|
||||
<TableCell>{t('title')}</TableCell>
|
||||
<TableCell>{t('author')}</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'title'}
|
||||
direction={orderBy === 'title' ? order : 'asc'}
|
||||
onClick={() => handleRequestSort('title')}
|
||||
>
|
||||
{t('title')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'author'}
|
||||
direction={orderBy === 'author' ? order : 'asc'}
|
||||
onClick={() => handleRequestSort('author')}
|
||||
>
|
||||
{t('author')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'fileSize'}
|
||||
direction={orderBy === 'fileSize' ? order : 'asc'}
|
||||
onClick={() => handleRequestSort('fileSize')}
|
||||
>
|
||||
{t('size')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -259,17 +392,112 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
{displayedVideos.map(video => (
|
||||
<TableRow key={video.id} hover>
|
||||
<TableCell sx={{ width: 140 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getThumbnailSrc(video)}
|
||||
alt={video.title}
|
||||
sx={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
<Box sx={{ position: 'relative', width: 120, height: 68 }}>
|
||||
<Link to={`/video/${video.id}`} style={{ display: 'block', width: '100%', height: '100%' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getThumbnailSrc(video)}
|
||||
alt={video.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
</Link>
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefreshThumbnail(video.id)}
|
||||
disabled={refreshingId === video.id}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
p: 0.5,
|
||||
width: 24,
|
||||
height: 24
|
||||
}}
|
||||
>
|
||||
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.5, color: 'text.secondary', textAlign: 'center' }}>
|
||||
{formatDuration(video.duration)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 500 }}>
|
||||
{video.title}
|
||||
<TableCell sx={{ fontWeight: 500, maxWidth: 400 }}>
|
||||
{editingVideoId === video.id ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<TextField
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveTitle(video.id);
|
||||
if (e.key === 'Escape') handleCancelEdit();
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleSaveTitle(video.id)}
|
||||
disabled={isSavingTitle}
|
||||
>
|
||||
{isSavingTitle ? <CircularProgress size={20} /> : <Check />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isSavingTitle}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{video.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{video.author}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/author/${encodeURIComponent(video.author)}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
'&:hover': { textDecoration: 'underline', color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
{video.author}
|
||||
</Typography>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(video.fileSize)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteVideo')}>
|
||||
<IconButton
|
||||
|
||||
@@ -16,56 +16,45 @@ import {
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult[];
|
||||
localResults: Video[];
|
||||
searchTerm: string;
|
||||
loading: boolean;
|
||||
youtubeLoading: boolean;
|
||||
onDownload: (url: string, title?: string) => void;
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
onResetSearch: () => void;
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
results,
|
||||
localResults,
|
||||
searchTerm,
|
||||
loading,
|
||||
youtubeLoading,
|
||||
onDownload,
|
||||
onDeleteVideo,
|
||||
onResetSearch,
|
||||
collections = []
|
||||
}) => {
|
||||
const SearchResults: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
searchResults,
|
||||
localSearchResults,
|
||||
searchTerm,
|
||||
loading,
|
||||
youtubeLoading,
|
||||
deleteVideo,
|
||||
resetSearch,
|
||||
setIsSearchMode
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
|
||||
// If search term is empty, reset search and go back to home
|
||||
useEffect(() => {
|
||||
if (!searchTerm || searchTerm.trim() === '') {
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
if (resetSearch) {
|
||||
resetSearch();
|
||||
}
|
||||
}
|
||||
}, [searchTerm, onResetSearch]);
|
||||
}, [searchTerm, resetSearch]);
|
||||
|
||||
const handleDownload = async (videoUrl: string, title: string) => {
|
||||
const handleDownload = async (videoUrl: string) => {
|
||||
try {
|
||||
await onDownload(videoUrl, title);
|
||||
// We need to stop the search mode before downloading?
|
||||
// Actually App.tsx implementation was:
|
||||
// setIsSearchMode(false);
|
||||
// await handleVideoSubmit(videoUrl);
|
||||
// Let's replicate that behavior if we want to exit search on download
|
||||
// Or maybe just download and stay on search results?
|
||||
// The original implementation in App.tsx exited search mode.
|
||||
setIsSearchMode(false);
|
||||
await handleVideoSubmit(videoUrl);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search results:', error);
|
||||
}
|
||||
@@ -73,8 +62,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
|
||||
const handleBackClick = () => {
|
||||
// Call the onResetSearch function to reset search mode
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
if (resetSearch) {
|
||||
resetSearch();
|
||||
} else {
|
||||
// Fallback to navigate if onResetSearch is not provided
|
||||
navigate('/');
|
||||
@@ -96,8 +85,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasLocalResults = localResults && localResults.length > 0;
|
||||
const hasYouTubeResults = results && results.length > 0;
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
@@ -158,11 +147,11 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
</Typography>
|
||||
{hasLocalResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{localResults.map((video) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
||||
{localSearchResults.map((video) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -186,7 +175,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{results.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||
<CardMedia
|
||||
@@ -229,7 +218,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(result.sourceUrl, result.title)}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import ConsoleManager from '../utils/consoleManager';
|
||||
import { Language } from '../utils/translations';
|
||||
@@ -42,9 +44,17 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags: string[];
|
||||
cloudDriveEnabled: boolean;
|
||||
openListApiUrl: string;
|
||||
openListToken: string;
|
||||
cloudDrivePath: string;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, setLanguage } = useLanguage();
|
||||
const { activeDownloads } = useDownload();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
loginEnabled: false,
|
||||
password: '',
|
||||
@@ -52,15 +62,19 @@ const SettingsPage: React.FC = () => {
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en',
|
||||
tags: []
|
||||
tags: [],
|
||||
cloudDriveEnabled: false,
|
||||
openListApiUrl: '',
|
||||
openListToken: '',
|
||||
cloudDrivePath: ''
|
||||
});
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
|
||||
const [showMigrateConfirmModal, setShowMigrateConfirmModal] = useState(false);
|
||||
const [showCleanupTempFilesModal, setShowCleanupTempFilesModal] = useState(false);
|
||||
const [infoModal, setInfoModal] = useState<{ isOpen: boolean; title: string; message: string; type: 'success' | 'error' | 'info' | 'warning' }>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
@@ -70,50 +84,191 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
|
||||
|
||||
const { t, setLanguage } = useLanguage();
|
||||
// Fetch settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (settingsData) {
|
||||
setSettings({
|
||||
...response.data,
|
||||
tags: response.data.tags || []
|
||||
...settingsData,
|
||||
tags: settingsData.tags || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
} finally {
|
||||
// Loading finished
|
||||
}
|
||||
};
|
||||
}, [settingsData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save settings mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newSettings: Settings) => {
|
||||
// Only send password if it has been changed (is not empty)
|
||||
const settingsToSend = { ...settings };
|
||||
const settingsToSend = { ...newSettings };
|
||||
if (!settingsToSend.password) {
|
||||
delete settingsToSend.password;
|
||||
}
|
||||
|
||||
console.log('Saving settings:', settingsToSend);
|
||||
await axios.post(`${API_URL}/settings`, settingsToSend);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
setMessage({ text: t('settingsSaved'), type: 'success' });
|
||||
|
||||
// Clear password field after save
|
||||
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
},
|
||||
onError: () => {
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
const res = await axios.post(`${API_URL}/settings/migrate`);
|
||||
return res.data.results;
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
let msg = `${t('migrationReport')}:\n`;
|
||||
let hasData = false;
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
||||
categories.forEach(cat => {
|
||||
const data = results[cat];
|
||||
if (data) {
|
||||
if (data.found) {
|
||||
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
||||
hasData = true;
|
||||
} else {
|
||||
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (results.errors && results.errors.length > 0) {
|
||||
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
||||
}
|
||||
|
||||
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
||||
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: hasData ? t('migrationResults') : t('migrationNoData'),
|
||||
message: msg,
|
||||
type: hasData ? 'success' : 'warning'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup temp files mutation
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const { deletedCount, errors } = data;
|
||||
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
|
||||
if (errors && errors.length > 0) {
|
||||
msg += `\n\nErrors:\n${errors.join('\n')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: msg,
|
||||
type: errors && errors.length > 0 ? 'warning' : 'success'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
|
||||
? t('cleanupTempFilesActiveDownloads')
|
||||
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: errorMsg,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete legacy data mutation
|
||||
const deleteLegacyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
|
||||
return res.data.results;
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
let msg = `${t('legacyDataDeleted')}\n`;
|
||||
if (results.deleted.length > 0) {
|
||||
msg += `\nDeleted: ${results.deleted.join(', ')}`;
|
||||
}
|
||||
if (results.failed.length > 0) {
|
||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: msg,
|
||||
type: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (field: keyof Settings, value: string | boolean | number) => {
|
||||
setSettings(prev => ({ ...prev, [field]: value }));
|
||||
if (field === 'language') {
|
||||
@@ -134,6 +289,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
||||
};
|
||||
|
||||
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
@@ -296,6 +453,68 @@ const SettingsPage: React.FC = () => {
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>{t('cleanupTempFiles')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{t('cleanupTempFilesDescription')}
|
||||
</Typography>
|
||||
{activeDownloads.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 2, maxWidth: 600 }}>
|
||||
{t('cleanupTempFilesActiveDownloads')}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setShowCleanupTempFilesModal(true)}
|
||||
disabled={isSaving || activeDownloads.length > 0}
|
||||
>
|
||||
{t('cleanupTempFiles')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<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>
|
||||
@@ -310,7 +529,7 @@ const SettingsPage: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setShowMigrateConfirmModal(true)}
|
||||
disabled={saving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('migrateDataButton')}
|
||||
</Button>
|
||||
@@ -318,31 +537,8 @@ const SettingsPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/scan-files`);
|
||||
const { addedCount } = res.data;
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: t('scanFilesSuccess').replace('{count}', addedCount.toString()),
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Scan failed:', error);
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={isSaving}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
{t('scanFiles')}
|
||||
@@ -357,7 +553,7 @@ const SettingsPage: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteLegacyModal(true)}
|
||||
disabled={saving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('deleteLegacyDataButton')}
|
||||
</Button>
|
||||
@@ -393,9 +589,9 @@ const SettingsPage: React.FC = () => {
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{saving ? t('saving') : t('saveSettings')}
|
||||
{isSaving ? t('saving') : t('saveSettings')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -416,42 +612,9 @@ const SettingsPage: React.FC = () => {
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteLegacyModal}
|
||||
onClose={() => setShowDeleteLegacyModal(false)}
|
||||
onConfirm={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
|
||||
const results = res.data.results;
|
||||
console.log('Delete legacy results:', results);
|
||||
|
||||
let msg = `${t('legacyDataDeleted')}\n`;
|
||||
if (results.deleted.length > 0) {
|
||||
msg += `\nDeleted: ${results.deleted.join(', ')}`;
|
||||
}
|
||||
if (results.failed.length > 0) {
|
||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
if (results.failed.length > 0) {
|
||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: msg,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete legacy data:', error);
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
onConfirm={() => {
|
||||
setShowDeleteLegacyModal(false);
|
||||
deleteLegacyMutation.mutate();
|
||||
}}
|
||||
title={t('removeLegacyDataConfirmTitle')}
|
||||
message={t('removeLegacyDataConfirmMessage')}
|
||||
@@ -464,58 +627,9 @@ const SettingsPage: React.FC = () => {
|
||||
<ConfirmationModal
|
||||
isOpen={showMigrateConfirmModal}
|
||||
onClose={() => setShowMigrateConfirmModal(false)}
|
||||
onConfirm={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/settings/migrate`);
|
||||
const results = res.data.results;
|
||||
console.log('Migration results:', results);
|
||||
|
||||
let msg = `${t('migrationReport')}:\n`;
|
||||
let hasData = false;
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
||||
categories.forEach(cat => {
|
||||
const data = results[cat];
|
||||
if (data) {
|
||||
if (data.found) {
|
||||
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
||||
hasData = true;
|
||||
} else {
|
||||
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (results.errors && results.errors.length > 0) {
|
||||
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
||||
}
|
||||
|
||||
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
||||
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: hasData ? t('migrationResults') : t('migrationNoData'),
|
||||
message: msg,
|
||||
type: hasData ? 'success' : 'warning'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Migration failed:', error);
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
onConfirm={() => {
|
||||
setShowMigrateConfirmModal(false);
|
||||
migrateMutation.mutate();
|
||||
}}
|
||||
title={t('migrateDataButton')}
|
||||
message={t('migrateConfirmation')}
|
||||
@@ -523,6 +637,21 @@ const SettingsPage: React.FC = () => {
|
||||
cancelText={t('cancel')}
|
||||
/>
|
||||
|
||||
{/* Cleanup Temp Files Modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={showCleanupTempFilesModal}
|
||||
onClose={() => setShowCleanupTempFilesModal(false)}
|
||||
onConfirm={() => {
|
||||
setShowCleanupTempFilesModal(false);
|
||||
cleanupMutation.mutate();
|
||||
}}
|
||||
title={t('cleanupTempFilesConfirmTitle')}
|
||||
message={t('cleanupTempFilesConfirmMessage')}
|
||||
confirmText={t('confirm')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Info/Result Modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={infoModal.isOpen}
|
||||
@@ -534,7 +663,7 @@ const SettingsPage: React.FC = () => {
|
||||
showCancel={false}
|
||||
isDanger={infoModal.type === 'error'}
|
||||
/>
|
||||
</Container>
|
||||
</Container >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,57 +11,43 @@ import {
|
||||
Stack,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import CollectionModal from '../components/VideoPlayer/CollectionModal';
|
||||
import CommentsSection from '../components/VideoPlayer/CommentsSection';
|
||||
import VideoControls from '../components/VideoPlayer/VideoControls';
|
||||
import VideoInfo from '../components/VideoPlayer/VideoInfo';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { Collection, Comment, Video } from '../types';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { getRecommendations } from '../utils/recommendations';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
collections: Collection[];
|
||||
onAddToCollection: (collectionId: string, videoId: string) => Promise<void>;
|
||||
onCreateCollection: (name: string, videoId: string) => Promise<void>;
|
||||
onRemoveFromCollection: (videoId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videos,
|
||||
onDeleteVideo,
|
||||
collections,
|
||||
onAddToCollection,
|
||||
onCreateCollection,
|
||||
onRemoveFromCollection
|
||||
}) => {
|
||||
const VideoPlayer: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { videos, deleteVideo } = useVideo();
|
||||
const {
|
||||
collections,
|
||||
addToCollection,
|
||||
createCollection,
|
||||
removeFromCollection
|
||||
} = useCollection();
|
||||
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
|
||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loadingComments, setLoadingComments] = useState<boolean>(false);
|
||||
const [showComments, setShowComments] = useState<boolean>(false);
|
||||
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [autoPlay, setAutoPlay] = useState<boolean>(false);
|
||||
const [autoLoop, setAutoLoop] = useState<boolean>(false);
|
||||
|
||||
// Confirmation Modal State
|
||||
const [confirmationModal, setConfirmationModal] = useState({
|
||||
@@ -74,84 +60,54 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
isDanger: false
|
||||
});
|
||||
|
||||
// Fetch video details
|
||||
const { data: video, isLoading: loading, error } = useQuery({
|
||||
queryKey: ['video', id],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
initialData: () => {
|
||||
return videos.find(v => v.id === id);
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Handle error redirect
|
||||
useEffect(() => {
|
||||
// Don't try to fetch the video if it's being deleted
|
||||
if (isDeleting) {
|
||||
return;
|
||||
if (error) {
|
||||
const timer = setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, navigate]);
|
||||
|
||||
const fetchVideo = async () => {
|
||||
if (!id) return;
|
||||
// Fetch settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
// First check if the video is in the videos prop
|
||||
const foundVideo = videos.find(v => v.id === id);
|
||||
const autoPlay = settings?.defaultAutoPlay || false;
|
||||
const autoLoop = settings?.defaultAutoLoop || false;
|
||||
const availableTags = settings?.tags || [];
|
||||
|
||||
if (foundVideo) {
|
||||
setVideo(foundVideo);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found in props, try to fetch from API
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||
setVideo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError(t('videoNotFoundOrLoaded'));
|
||||
|
||||
// Redirect to home after 3 seconds if video not found
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideo();
|
||||
}, [id, videos, navigate, isDeleting]);
|
||||
|
||||
// Fetch settings and apply defaults
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const { defaultAutoPlay, defaultAutoLoop } = response.data;
|
||||
|
||||
setAutoPlay(!!defaultAutoPlay);
|
||||
setAutoLoop(!!defaultAutoLoop);
|
||||
|
||||
setAvailableTags(response.data.tags || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, [id]); // Re-run when video changes
|
||||
|
||||
const fetchComments = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setLoadingComments(true);
|
||||
try {
|
||||
// Fetch comments
|
||||
const { data: comments = [], isLoading: loadingComments } = useQuery({
|
||||
queryKey: ['comments', id],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
|
||||
setComments(response.data);
|
||||
setCommentsLoaded(true);
|
||||
} catch (err) {
|
||||
console.error('Error fetching comments:', err);
|
||||
// We don't set a global error here as comments are secondary
|
||||
} finally {
|
||||
setLoadingComments(false);
|
||||
}
|
||||
};
|
||||
return response.data;
|
||||
},
|
||||
enabled: showComments && !!id
|
||||
});
|
||||
|
||||
const handleToggleComments = () => {
|
||||
if (!showComments && !commentsLoaded) {
|
||||
fetchComments();
|
||||
}
|
||||
setShowComments(!showComments);
|
||||
};
|
||||
|
||||
@@ -191,27 +147,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
navigate(`/collection/${collectionId}`);
|
||||
};
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (videoId: string) => {
|
||||
return await deleteVideo(videoId);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const executeDelete = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
const result = await onDeleteVideo(id);
|
||||
|
||||
if (result.success) {
|
||||
// Navigate to home immediately after successful deletion
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setDeleteError(result.error || t('deleteFailed'));
|
||||
setIsDeleting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError(t('unexpectedErrorOccurred'));
|
||||
console.error(err);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
await deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -237,7 +187,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleCreateCollection = async (name: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await onCreateCollection(name, id);
|
||||
await createCollection(name, id);
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
}
|
||||
@@ -246,7 +196,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleAddToExistingCollection = async (collectionId: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await onAddToCollection(collectionId, id);
|
||||
await addToCollection(collectionId, id);
|
||||
} catch (error) {
|
||||
console.error('Error adding to collection:', error);
|
||||
}
|
||||
@@ -256,7 +206,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await onRemoveFromCollection(id);
|
||||
await removeFromCollection(id);
|
||||
} catch (error) {
|
||||
console.error('Error removing from collection:', error);
|
||||
}
|
||||
@@ -274,42 +224,106 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Rating mutation
|
||||
const ratingMutation = useMutation({
|
||||
mutationFn: async (newValue: number) => {
|
||||
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
|
||||
return newValue;
|
||||
},
|
||||
onSuccess: (newValue) => {
|
||||
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, rating: newValue } : old);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingChange = async (newValue: number) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
|
||||
setVideo(prev => prev ? { ...prev, rating: newValue } : null);
|
||||
} catch (error) {
|
||||
console.error('Error updating rating:', error);
|
||||
}
|
||||
await ratingMutation.mutateAsync(newValue);
|
||||
};
|
||||
|
||||
// Title mutation
|
||||
const titleMutation = useMutation({
|
||||
mutationFn: async (newTitle: string) => {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, newTitle) => {
|
||||
if (data.success) {
|
||||
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, title: newTitle } : old);
|
||||
showSnackbar(t('titleUpdated'));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('titleUpdateFailed'), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveTitle = async (newTitle: string) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
|
||||
if (response.data.success) {
|
||||
setVideo(prev => prev ? { ...prev, title: newTitle } : null);
|
||||
showSnackbar(t('titleUpdated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating title:', error);
|
||||
showSnackbar(t('titleUpdateFailed'), 'error');
|
||||
}
|
||||
await titleMutation.mutateAsync(newTitle);
|
||||
};
|
||||
|
||||
// Tags mutation
|
||||
const tagsMutation = useMutation({
|
||||
mutationFn: async (newTags: string[]) => {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, newTags) => {
|
||||
if (data.success) {
|
||||
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, tags: newTags } : old);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleUpdateTags = async (newTags: string[]) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
|
||||
if (response.data.success) {
|
||||
setVideo(prev => prev ? { ...prev, tags: newTags } : null);
|
||||
await tagsMutation.mutateAsync(newTags);
|
||||
};
|
||||
|
||||
const [hasViewed, setHasViewed] = useState<boolean>(false);
|
||||
const lastProgressSave = useRef<number>(0);
|
||||
const currentTimeRef = useRef<number>(0);
|
||||
|
||||
// Reset hasViewed when video changes
|
||||
useEffect(() => {
|
||||
setHasViewed(false);
|
||||
currentTimeRef.current = 0;
|
||||
}, [id]);
|
||||
|
||||
// Save progress on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (id && currentTimeRef.current > 0) {
|
||||
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTimeRef.current) })
|
||||
.catch(err => console.error('Error saving progress on unmount:', err));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating tags:', error);
|
||||
showSnackbar(t('error'), 'error');
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const handleTimeUpdate = (currentTime: number) => {
|
||||
currentTimeRef.current = currentTime;
|
||||
|
||||
// Increment view count after 10 seconds
|
||||
if (currentTime > 10 && !hasViewed && id) {
|
||||
setHasViewed(true);
|
||||
axios.post(`${API_URL}/videos/${id}/view`)
|
||||
.then(res => {
|
||||
if (res.data.success && video) {
|
||||
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, viewCount: res.data.viewCount } : old);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error incrementing view count:', err));
|
||||
}
|
||||
|
||||
// Save progress every 5 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastProgressSave.current > 5000 && id) {
|
||||
lastProgressSave.current = now;
|
||||
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTime) })
|
||||
.catch(err => console.error('Error saving progress:', err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,50 +339,66 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
if (error || !video) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error || t('videoNotFound')}</Alert>
|
||||
<Alert severity="error">{t('videoNotFoundOrLoaded')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Get related videos (exclude current video)
|
||||
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
|
||||
// Get related videos using recommendation algorithm
|
||||
const relatedVideos = useMemo(() => {
|
||||
if (!video) return [];
|
||||
return getRecommendations({
|
||||
currentVideo: video,
|
||||
allVideos: videos,
|
||||
collections: collections
|
||||
}).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
|
||||
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
|
||||
autoPlay={autoPlay}
|
||||
autoLoop={autoLoop}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
startTime={video.progress || 0}
|
||||
/>
|
||||
|
||||
<VideoInfo
|
||||
video={video}
|
||||
onTitleSave={handleSaveTitle}
|
||||
onRatingChange={handleRatingChange}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
deleteError={deleteError}
|
||||
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}
|
||||
/>
|
||||
<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 => (
|
||||
@@ -415,6 +445,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface Video {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
source: 'youtube' | 'bilibili' | 'local';
|
||||
source: 'youtube' | 'bilibili' | 'local' | 'missav';
|
||||
sourceUrl: string;
|
||||
videoFilename?: string;
|
||||
thumbnailFilename?: string;
|
||||
@@ -16,6 +16,11 @@ export interface Video {
|
||||
seriesTitle?: string;
|
||||
rating?: number;
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
duration?: string;
|
||||
fileSize?: string; // Size in bytes as string
|
||||
lastPlayedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -32,6 +37,11 @@ export interface DownloadInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp?: number;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
totalSize?: string;
|
||||
downloadedSize?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
|
||||
@@ -77,6 +77,22 @@ export const ar = {
|
||||
removeLegacyDataConfirmMessage: "هل أنت متأكد أنك تريد حذف ملفات بيانات JSON القديمة؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
legacyDataDeleted: "تم حذف البيانات القديمة بنجاح.",
|
||||
deleteLegacyDataButton: "حذف البيانات القديمة",
|
||||
cleanupTempFiles: "تنظيف الملفات المؤقتة",
|
||||
cleanupTempFilesDescription: "إزالة جميع ملفات التنزيل المؤقتة (.ytdl، .part) من دليل التحميلات. يساعد هذا في تحرير مساحة القرص من التنزيلات غير المكتملة أو الملغاة.",
|
||||
cleanupTempFilesConfirmTitle: "تنظيف الملفات المؤقتة؟",
|
||||
cleanupTempFilesConfirmMessage: "سيؤدي هذا إلى حذف جميع ملفات .ytdl و .part في دليل التحميلات نهائيًا. تأكد من عدم وجود تنزيلات نشطة قبل المتابعة.",
|
||||
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: "إدارة المحتوى",
|
||||
@@ -97,6 +113,7 @@ export const ar = {
|
||||
authors: "المؤلفون",
|
||||
created: "تاريخ الإنشاء",
|
||||
name: "الاسم",
|
||||
size: "الحجم",
|
||||
actions: "إجراءات",
|
||||
deleteCollection: "حذف المجموعة",
|
||||
deleteVideo: "حذف الفيديو",
|
||||
@@ -140,6 +157,17 @@ export const ar = {
|
||||
editTitle: "تعديل العنوان",
|
||||
titleUpdated: "تم تحديث العنوان بنجاح",
|
||||
titleUpdateFailed: "فشل تحديث العنوان",
|
||||
refreshThumbnail: "تحديث الصورة المصغرة",
|
||||
thumbnailRefreshed: "تم تحديث الصورة المصغرة بنجاح",
|
||||
thumbnailRefreshFailed: "فشل تحديث الصورة المصغرة",
|
||||
videoUpdated: "تم تحديث الفيديو بنجاح",
|
||||
videoUpdateFailed: "فشل تحديث الفيديو",
|
||||
failedToLoadVideos: "فشل تحميل مقاطع الفيديو. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
videoRemovedSuccessfully: "تم حذف الفيديو بنجاح",
|
||||
failedToDeleteVideo: "فشل حذف الفيديو",
|
||||
pleaseEnterSearchTerm: "الرجاء إدخال مصطلح البحث",
|
||||
failedToSearch: "فشل البحث. يرجى المحاولة مرة أخرى.",
|
||||
searchCancelled: "تم إلغاء البحث",
|
||||
|
||||
// Login
|
||||
signIn: "تسجيل الدخول",
|
||||
@@ -164,6 +192,15 @@ export const ar = {
|
||||
deleteCollectionConfirmation: "هل أنت متأكد أنك تريد حذف المجموعة",
|
||||
collectionContains: "تحتوي هذه المجموعة على",
|
||||
deleteCollectionOnly: "حذف المجموعة فقط",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "جاري تنزيل الفيديو",
|
||||
downloadStartedSuccessfully: "بدأ التنزيل بنجاح",
|
||||
collectionCreatedSuccessfully: "تم إنشاء المجموعة بنجاح",
|
||||
videoAddedToCollection: "تمت إضافة الفيديو إلى المجموعة",
|
||||
videoRemovedFromCollection: "تمت إزالة الفيديو من المجموعة",
|
||||
collectionDeletedSuccessfully: "تم حذف المجموعة بنجاح",
|
||||
failedToDeleteCollection: "فشل حذف المجموعة",
|
||||
deleteCollectionAndVideos: "حذف المجموعة وكل الفيديوهات",
|
||||
|
||||
// Common
|
||||
@@ -206,5 +243,26 @@ export const ar = {
|
||||
allPartsAddedToCollection: "سيتم إضافة جميع الأجزاء إلى هذه المجموعة",
|
||||
allVideosAddedToCollection: "سيتم إضافة جميع الفيديوهات إلى هذه المجموعة",
|
||||
queued: "في الانتظار",
|
||||
waitingInQueue: "في قائمة الانتظار"
|
||||
waitingInQueue: "في قائمة الانتظار",
|
||||
// Downloads
|
||||
downloads: "التنزيلات",
|
||||
activeDownloads: "التنزيلات النشطة",
|
||||
manageDownloads: "إدارة التنزيلات",
|
||||
queuedDownloads: "التنزيلات في الانتظار",
|
||||
downloadHistory: "سجل التنزيلات",
|
||||
clearQueue: "مسح قائمة الانتظار",
|
||||
clearHistory: "مسح السجل",
|
||||
noActiveDownloads: "لا توجد تنزيلات نشطة",
|
||||
noQueuedDownloads: "لا توجد تنزيلات في قائمة الانتظار",
|
||||
noDownloadHistory: "لا يوجد سجل تنزيلات",
|
||||
downloadCancelled: "تم إلغاء التنزيل",
|
||||
queueCleared: "تم مسح قائمة الانتظار",
|
||||
historyCleared: "تم مسح السجل",
|
||||
removedFromQueue: "تمت الإزالة من قائمة الانتظار",
|
||||
removedFromHistory: "تمت الإزالة من السجل",
|
||||
status: "الحالة",
|
||||
progress: "التقدم",
|
||||
speed: "السرعة",
|
||||
finishedAt: "انتهى في",
|
||||
failed: "فشل",
|
||||
};
|
||||
|
||||
@@ -26,17 +26,39 @@ export const de = {
|
||||
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.",
|
||||
migrationFailed: "Migration fehlgeschlagen", migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
|
||||
migrationFailed: "Migration fehlgeschlagen",
|
||||
|
||||
migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
|
||||
itemsMigrated: "Elemente migriert", fileNotFound: "Datei nicht gefunden unter",
|
||||
noDataFilesFound: "Keine Datendateien zum Migrieren gefunden. Bitte überprüfen Sie Ihre Volume-Zuordnungen.",
|
||||
removeLegacyData: "Legacy-Daten Entfernen", removeLegacyDataDescription: "Löschen Sie die alten JSON-Dateien, um Speicherplatz freizugeben. Tun Sie dies nur, nachdem Sie überprüft haben, dass Ihre Daten erfolgreich migriert wurden.",
|
||||
removeLegacyDataConfirmTitle: "Legacy-Daten löschen?", removeLegacyDataConfirmMessage: "Sind Sie sicher, dass Sie die Legacy-JSON-Datendateien löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
legacyDataDeleted: "Legacy-Daten erfolgreich gelöscht.", deleteLegacyDataButton: "Legacy-Daten Löschen",
|
||||
legacyDataDeleted: "Legacy-Daten erfolgreich gelöscht.",
|
||||
deleteLegacyDataButton: "Legacy-Daten Löschen",
|
||||
cleanupTempFiles: "Temporäre Dateien bereinigen",
|
||||
cleanupTempFilesDescription: "Alle temporären Download-Dateien (.ytdl, .part) aus dem Upload-Verzeichnis entfernen. Dies hilft, Speicherplatz von unvollständigen oder abgebrochenen Downloads freizugeben.",
|
||||
cleanupTempFilesConfirmTitle: "Temporäre Dateien bereinigen?",
|
||||
cleanupTempFilesConfirmMessage: "Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine aktiven Downloads vorhanden sind, bevor Sie fortfahren.",
|
||||
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",
|
||||
noCollections: "Keine Sammlungen gefunden", searchVideos: "Videos suchen...", thumbnail: "Miniaturansicht",
|
||||
title: "Titel", author: "Autor", authors: "Autoren", created: "Erstellt", name: "Name", actions: "Aktionen",
|
||||
title: "Titel", author: "Autor", authors: "Autoren", created: "Erstellt", name: "Name",
|
||||
size: "Größe",
|
||||
actions: "Aktionen",
|
||||
deleteCollection: "Sammlung Löschen", deleteVideo: "Video Löschen", noVideosFoundMatching: "Keine Videos gefunden, die Ihrer Suche entsprechen.",
|
||||
playing: "Abspielen", paused: "Pause", next: "Weiter", previous: "Zurück", loop: "Schleife",
|
||||
autoPlayOn: "Automatische Wiedergabe Ein", autoPlayOff: "Automatische Wiedergabe Aus",
|
||||
@@ -52,6 +74,17 @@ export const de = {
|
||||
loadingVideo: "Video wird geladen...", current: "(Aktuell)", rateThisVideo: "Dieses Video bewerten",
|
||||
enterFullscreen: "Vollbild", exitFullscreen: "Vollbild Verlassen", editTitle: "Titel Bearbeiten",
|
||||
titleUpdated: "Titel erfolgreich aktualisiert", titleUpdateFailed: "Fehler beim Aktualisieren des Titels",
|
||||
refreshThumbnail: "Vorschaubild aktualisieren",
|
||||
thumbnailRefreshed: "Vorschaubild erfolgreich aktualisiert",
|
||||
thumbnailRefreshFailed: "Aktualisierung des Vorschaubilds fehlgeschlagen",
|
||||
videoUpdated: "Video erfolgreich aktualisiert",
|
||||
videoUpdateFailed: "Videoaktualisierung fehlgeschlagen",
|
||||
failedToLoadVideos: "Videos konnten nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
||||
videoRemovedSuccessfully: "Video erfolgreich entfernt",
|
||||
failedToDeleteVideo: "Löschen des Videos fehlgeschlagen",
|
||||
pleaseEnterSearchTerm: "Bitte geben Sie einen Suchbegriff ein",
|
||||
failedToSearch: "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
searchCancelled: "Suche abgebrochen",
|
||||
signIn: "Anmelden", verifying: "Überprüfen...", incorrectPassword: "Falsches Passwort",
|
||||
loginFailed: "Fehler beim Überprüfen des Passworts", defaultPasswordHint: "Standardpasswort: 123",
|
||||
loadingCollection: "Sammlung wird geladen...", collectionNotFound: "Sammlung nicht gefunden",
|
||||
@@ -72,5 +105,35 @@ export const de = {
|
||||
processing: "Verarbeiten...", wouldYouLikeToDownloadAllParts: "Möchten Sie alle Teile herunterladen?",
|
||||
wouldYouLikeToDownloadAllVideos: "Möchten Sie alle Videos herunterladen?",
|
||||
allPartsAddedToCollection: "Alle Teile werden dieser Sammlung hinzugefügt",
|
||||
allVideosAddedToCollection: "Alle Videos werden dieser Sammlung hinzugefügt", queued: "In Warteschlange", waitingInQueue: "Warten in Warteschlange"
|
||||
allVideosAddedToCollection: "Alle Videos werden dieser Sammlung hinzugefügt", queued: "In Warteschlange", waitingInQueue: "Warten in Warteschlange",
|
||||
// Downloads
|
||||
downloads: "Downloads",
|
||||
activeDownloads: "Aktive Downloads",
|
||||
manageDownloads: "Downloads Verwalten",
|
||||
queuedDownloads: "Warteschlange",
|
||||
downloadHistory: "Download-Verlauf",
|
||||
clearQueue: "Warteschlange Leeren",
|
||||
clearHistory: "Verlauf Löschen",
|
||||
noActiveDownloads: "Keine aktiven Downloads",
|
||||
noQueuedDownloads: "Keine Downloads in der Warteschlange",
|
||||
noDownloadHistory: "Kein Download-Verlauf",
|
||||
downloadCancelled: "Download abgebrochen",
|
||||
queueCleared: "Warteschlange geleert",
|
||||
historyCleared: "Verlauf gelöscht",
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const en = {
|
||||
removeLegacyDataConfirmMessage: "Are you sure you want to delete the legacy JSON data files? This action cannot be undone.",
|
||||
legacyDataDeleted: "Legacy data deleted successfully.",
|
||||
deleteLegacyDataButton: "Delete Legacy Data",
|
||||
cleanupTempFiles: "Clean Up Temp Files",
|
||||
cleanupTempFilesDescription: "Remove all temporary download files (.ytdl, .part) from the uploads directory. This helps free up disk space from incomplete or cancelled downloads.",
|
||||
cleanupTempFilesConfirmTitle: "Clean Up Temporary Files?",
|
||||
cleanupTempFilesConfirmMessage: "This will permanently delete all .ytdl and .part files in the uploads directory. Make sure there are no active downloads before proceeding.",
|
||||
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",
|
||||
@@ -97,10 +113,12 @@ export const en = {
|
||||
authors: "Authors",
|
||||
created: "Created",
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
noVideosFoundMatching: "No videos found matching your search.",
|
||||
refreshThumbnail: "Refresh Thumbnail",
|
||||
|
||||
// Video Player
|
||||
playing: "Play",
|
||||
@@ -140,6 +158,16 @@ export const en = {
|
||||
editTitle: "Edit Title",
|
||||
titleUpdated: "Title updated successfully",
|
||||
titleUpdateFailed: "Failed to update title",
|
||||
thumbnailRefreshed: "Thumbnail refreshed successfully",
|
||||
thumbnailRefreshFailed: "Failed to refresh thumbnail",
|
||||
videoUpdated: "Video updated successfully",
|
||||
videoUpdateFailed: "Failed to update video",
|
||||
failedToLoadVideos: "Failed to load videos. Please try again later.",
|
||||
videoRemovedSuccessfully: "Video removed successfully",
|
||||
failedToDeleteVideo: "Failed to delete video",
|
||||
pleaseEnterSearchTerm: "Please enter a search term",
|
||||
failedToSearch: "Failed to search. Please try again.",
|
||||
searchCancelled: "Search was cancelled",
|
||||
|
||||
// Login
|
||||
signIn: "Sign in",
|
||||
@@ -206,5 +234,36 @@ export const en = {
|
||||
allPartsAddedToCollection: "All parts will be added to this collection",
|
||||
allVideosAddedToCollection: "All videos will be added to this collection",
|
||||
queued: "Queued",
|
||||
waitingInQueue: "Waiting in queue"
|
||||
waitingInQueue: "Waiting in queue",
|
||||
|
||||
// Downloads
|
||||
downloads: "Downloads",
|
||||
activeDownloads: "Active Downloads",
|
||||
manageDownloads: "Manage Downloads",
|
||||
queuedDownloads: "Queued Downloads",
|
||||
downloadHistory: "Download History",
|
||||
clearQueue: "Clear Queue",
|
||||
clearHistory: "Clear History",
|
||||
noActiveDownloads: "No active downloads",
|
||||
noQueuedDownloads: "No queued downloads",
|
||||
noDownloadHistory: "No download history",
|
||||
downloadCancelled: "Download cancelled",
|
||||
queueCleared: "Queue cleared",
|
||||
historyCleared: "History cleared",
|
||||
removedFromQueue: "Removed from queue",
|
||||
removedFromHistory: "Removed from history",
|
||||
status: "Status",
|
||||
progress: "Progress",
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -31,27 +31,67 @@ export const es = {
|
||||
noDataFilesFound: "No se encontraron archivos de datos para migrar. Por favor, verifique sus asignaciones de volumen.",
|
||||
removeLegacyData: "Eliminar Datos Heredados", removeLegacyDataDescription: "Eliminar los archivos JSON antiguos para liberar espacio en disco. Solo haga esto después de verificar que sus datos se hayan migrado exitosamente.",
|
||||
removeLegacyDataConfirmTitle: "¿Eliminar Datos Heredados?", removeLegacyDataConfirmMessage: "¿Está seguro de que desea eliminar los archivos de datos JSON heredados? Esta acción no se puede deshacer.",
|
||||
legacyDataDeleted: "Datos heredados eliminados exitosamente.", deleteLegacyDataButton: "Eliminar Datos Heredados",
|
||||
legacyDataDeleted: "Datos heredados eliminados exitosamente.",
|
||||
deleteLegacyDataButton: "Eliminar Datos Heredados",
|
||||
cleanupTempFiles: "Limpiar Archivos Temporales",
|
||||
cleanupTempFilesDescription: "Eliminar todos los archivos temporales de descarga (.ytdl, .part) del directorio de cargas. Esto ayuda a liberar espacio en disco de descargas incompletas o canceladas.",
|
||||
cleanupTempFilesConfirmTitle: "¿Limpiar Archivos Temporales?",
|
||||
cleanupTempFilesConfirmMessage: "Esto eliminará permanentemente todos los archivos .ytdl y .part en el directorio de cargas. Asegúrate de que no haya descargas activas antes de continuar.",
|
||||
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",
|
||||
noCollections: "No se encontraron colecciones", searchVideos: "Buscar videos...", thumbnail: "Miniatura",
|
||||
title: "Título", author: "Autor", authors: "Autores", created: "Creado", name: "Nombre", actions: "Acciones",
|
||||
title: "Título", author: "Autor", authors: "Autores", created: "Creado", name: "Nombre",
|
||||
size: "Tamaño",
|
||||
actions: "Acciones",
|
||||
deleteCollection: "Eliminar Colección", deleteVideo: "Eliminar Video", noVideosFoundMatching: "No se encontraron videos que coincidan con su búsqueda.",
|
||||
playing: "Reproducir", paused: "Pausar", next: "Siguiente", previous: "Anterior", loop: "Repetir",
|
||||
autoPlayOn: "Reproducción Automática Activada", autoPlayOff: "Reproducción Automática Desactivada",
|
||||
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",
|
||||
enterFullscreen: "Pantalla Completa", exitFullscreen: "Salir de Pantalla Completa", editTitle: "Editar Título",
|
||||
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
|
||||
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
|
||||
refreshThumbnail: "Actualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura actualizada con éxito",
|
||||
thumbnailRefreshFailed: "Error al actualizar la miniatura",
|
||||
videoUpdated: "Video actualizado con éxito",
|
||||
videoUpdateFailed: "Error al actualizar el video",
|
||||
failedToLoadVideos: "Error al cargar videos. Por favor, inténtelo de nuevo más tarde.",
|
||||
videoRemovedSuccessfully: "Video eliminado con éxito",
|
||||
failedToDeleteVideo: "Error al eliminar el video",
|
||||
pleaseEnterSearchTerm: "Por favor, introduzca un término de búsqueda",
|
||||
failedToSearch: "Error en la búsqueda. Por favor, inténtelo de nuevo.",
|
||||
searchCancelled: "Búsqueda cancelada",
|
||||
signIn: "Iniciar Sesión", verifying: "Verificando...", incorrectPassword: "Contraseña incorrecta",
|
||||
loginFailed: "Error al verificar la contraseña", defaultPasswordHint: "Contraseña predeterminada: 123",
|
||||
loadingCollection: "Cargando colección...", collectionNotFound: "Colección no encontrada",
|
||||
@@ -72,5 +112,26 @@ export const es = {
|
||||
processing: "Procesando...", wouldYouLikeToDownloadAllParts: "¿Le gustaría descargar todas las partes?",
|
||||
wouldYouLikeToDownloadAllVideos: "¿Le gustaría descargar todos los videos?",
|
||||
allPartsAddedToCollection: "Todas las partes se agregarán a esta colección",
|
||||
allVideosAddedToCollection: "Todos los videos se agregarán a esta colección", queued: "En cola", waitingInQueue: "Esperando en cola"
|
||||
allVideosAddedToCollection: "Todos los videos se agregarán a esta colección", queued: "En cola", waitingInQueue: "Esperando en cola",
|
||||
// Downloads
|
||||
downloads: "Descargas",
|
||||
activeDownloads: "Descargas Activas",
|
||||
manageDownloads: "Gestionar Descargas",
|
||||
queuedDownloads: "Descargas en Cola",
|
||||
downloadHistory: "Historial de Descargas",
|
||||
clearQueue: "Limpiar Cola",
|
||||
clearHistory: "Limpiar Historial",
|
||||
noActiveDownloads: "No hay descargas activas",
|
||||
noQueuedDownloads: "No hay descargas en cola",
|
||||
noDownloadHistory: "No hay historial de descargas",
|
||||
downloadCancelled: "Descarga cancelada",
|
||||
queueCleared: "Cola limpiada",
|
||||
historyCleared: "Historial limpiado",
|
||||
removedFromQueue: "Eliminado de la cola",
|
||||
removedFromHistory: "Eliminado del historial",
|
||||
status: "Estado",
|
||||
progress: "Progreso",
|
||||
speed: "Velocidad",
|
||||
finishedAt: "Finalizado en",
|
||||
failed: "Fallido",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const fr = {
|
||||
removeLegacyDataConfirmMessage: "Êtes-vous sûr de vouloir supprimer les fichiers de données JSON hérités ? Cette action est irréversible.",
|
||||
legacyDataDeleted: "Données héritées supprimées avec succès.",
|
||||
deleteLegacyDataButton: "Supprimer les données héritées",
|
||||
cleanupTempFiles: "Nettoyer les fichiers temporaires",
|
||||
cleanupTempFilesDescription: "Supprimer tous les fichiers de téléchargement temporaires (.ytdl, .part) du répertoire des téléversements. Cela aide à libérer de l'espace disque des téléchargements incomplets ou annulés.",
|
||||
cleanupTempFilesConfirmTitle: "Nettoyer les fichiers temporaires?",
|
||||
cleanupTempFilesConfirmMessage: "Cela supprimera définitivement tous les fichiers .ytdl et .part dans le répertoire des téléversements. Assurez-vous qu'il n'y a pas de téléchargements actifs avant de continuer.",
|
||||
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",
|
||||
@@ -97,10 +113,12 @@ export const fr = {
|
||||
authors: "Auteurs",
|
||||
created: "Créé",
|
||||
name: "Nom",
|
||||
size: "Taille",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Supprimer la collection",
|
||||
deleteVideo: "Supprimer la vidéo",
|
||||
noVideosFoundMatching: "Aucune vidéo ne correspond à votre recherche.",
|
||||
refreshThumbnail: "Actualiser la miniature",
|
||||
|
||||
// Video Player
|
||||
playing: "Lecture",
|
||||
@@ -122,7 +140,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",
|
||||
@@ -140,6 +166,16 @@ export const fr = {
|
||||
editTitle: "Modifier le titre",
|
||||
titleUpdated: "Titre mis à jour avec succès",
|
||||
titleUpdateFailed: "Échec de la mise à jour du titre",
|
||||
thumbnailRefreshed: "Miniature actualisée avec succès",
|
||||
thumbnailRefreshFailed: "Échec de l'actualisation de la miniature",
|
||||
videoUpdated: "Vidéo mise à jour avec succès",
|
||||
videoUpdateFailed: "Échec de la mise à jour de la vidéo",
|
||||
failedToLoadVideos: "Échec du chargement des vidéos. Veuillez réessayer plus tard.",
|
||||
videoRemovedSuccessfully: "Vidéo supprimée avec succès",
|
||||
failedToDeleteVideo: "Échec de la suppression de la vidéo",
|
||||
pleaseEnterSearchTerm: "Veuillez entrer un terme de recherche",
|
||||
failedToSearch: "Échec de la recherche. Veuillez réessayer.",
|
||||
searchCancelled: "Recherche annulée",
|
||||
|
||||
// Login
|
||||
signIn: "Se connecter",
|
||||
@@ -206,5 +242,26 @@ export const fr = {
|
||||
allPartsAddedToCollection: "Toutes les parties seront ajoutées à cette collection",
|
||||
allVideosAddedToCollection: "Toutes les vidéos seront ajoutées à cette collection",
|
||||
queued: "En file d'attente",
|
||||
waitingInQueue: "En attente dans la file"
|
||||
waitingInQueue: "En attente dans la file",
|
||||
// Downloads
|
||||
downloads: "Téléchargements",
|
||||
activeDownloads: "Téléchargements Actifs",
|
||||
manageDownloads: "Gérer les Téléchargements",
|
||||
queuedDownloads: "Téléchargements en File d'Attente",
|
||||
downloadHistory: "Historique des Téléchargements",
|
||||
clearQueue: "Vider la File d'Attente",
|
||||
clearHistory: "Effacer l'Historique",
|
||||
noActiveDownloads: "Aucun téléchargement actif",
|
||||
noQueuedDownloads: "Aucun téléchargement en file d'attente",
|
||||
noDownloadHistory: "Aucun historique de téléchargement",
|
||||
downloadCancelled: "Téléchargement annulé",
|
||||
queueCleared: "File d'attente vidée",
|
||||
historyCleared: "Historique effacé",
|
||||
removedFromQueue: "Retiré de la file d'attente",
|
||||
removedFromHistory: "Retiré de l'historique",
|
||||
status: "Statut",
|
||||
progress: "Progression",
|
||||
speed: "Vitesse",
|
||||
finishedAt: "Terminé à",
|
||||
failed: "Échoué",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const ja = {
|
||||
removeLegacyDataConfirmMessage: "レガシーJSONデータファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
legacyDataDeleted: "レガシーデータが正常に削除されました。",
|
||||
deleteLegacyDataButton: "レガシーデータを削除",
|
||||
cleanupTempFiles: "一時ファイルをクリーンアップ",
|
||||
cleanupTempFilesDescription: "アップロードディレクトリからすべての一時ダウンロードファイル(.ytdl、.part)を削除します。不完全またはキャンセルされたダウンロードのディスク容量を解放するのに役立ちます。",
|
||||
cleanupTempFilesConfirmTitle: "一時ファイルをクリーンアップしますか?",
|
||||
cleanupTempFilesConfirmMessage: "これにより、アップロードディレクトリ内のすべての.ytdlおよび.partファイルが永久に削除されます。続行する前に、アクティブなダウンロードがないことを確認してください。",
|
||||
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: "コンテンツの管理",
|
||||
@@ -97,6 +113,7 @@ export const ja = {
|
||||
authors: "作成者一覧",
|
||||
created: "作成日",
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
actions: "アクション",
|
||||
deleteCollection: "コレクションを削除",
|
||||
deleteVideo: "動画を削除",
|
||||
@@ -140,6 +157,17 @@ export const ja = {
|
||||
editTitle: "タイトルを編集",
|
||||
titleUpdated: "タイトルが正常に更新されました",
|
||||
titleUpdateFailed: "タイトルの更新に失敗しました",
|
||||
refreshThumbnail: "サムネイルを更新",
|
||||
thumbnailRefreshed: "サムネイルを更新しました",
|
||||
thumbnailRefreshFailed: "サムネイルの更新に失敗しました",
|
||||
videoUpdated: "動画を更新しました",
|
||||
videoUpdateFailed: "動画の更新に失敗しました",
|
||||
failedToLoadVideos: "動画の読み込みに失敗しました。後でもう一度お試しください。",
|
||||
videoRemovedSuccessfully: "動画を削除しました",
|
||||
failedToDeleteVideo: "動画の削除に失敗しました",
|
||||
pleaseEnterSearchTerm: "検索語を入力してください",
|
||||
failedToSearch: "検索に失敗しました。もう一度お試しください。",
|
||||
searchCancelled: "検索がキャンセルされました",
|
||||
|
||||
// Login
|
||||
signIn: "サインイン",
|
||||
@@ -166,6 +194,15 @@ export const ja = {
|
||||
deleteCollectionOnly: "コレクションのみ削除",
|
||||
deleteCollectionAndVideos: "コレクションとすべての動画を削除",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "動画をダウンロード中",
|
||||
downloadStartedSuccessfully: "ダウンロードが正常に開始されました",
|
||||
collectionCreatedSuccessfully: "コレクションが正常に作成されました",
|
||||
videoAddedToCollection: "動画がコレクションに追加されました",
|
||||
videoRemovedFromCollection: "動画がコレクションから削除されました",
|
||||
collectionDeletedSuccessfully: "コレクションが正常に削除されました",
|
||||
failedToDeleteCollection: "コレクションの削除に失敗しました",
|
||||
|
||||
// Common
|
||||
loading: "読み込み中...",
|
||||
error: "エラー",
|
||||
@@ -206,5 +243,26 @@ export const ja = {
|
||||
allPartsAddedToCollection: "すべてのパートがこのコレクションに追加されます",
|
||||
allVideosAddedToCollection: "すべての動画がこのコレクションに追加されます",
|
||||
queued: "キューに追加済み",
|
||||
waitingInQueue: "待機中"
|
||||
waitingInQueue: "待機中",
|
||||
// Downloads
|
||||
downloads: "ダウンロード",
|
||||
activeDownloads: "アクティブなダウンロード",
|
||||
manageDownloads: "ダウンロードの管理",
|
||||
queuedDownloads: "待機中のダウンロード",
|
||||
downloadHistory: "ダウンロード履歴",
|
||||
clearQueue: "キューをクリア",
|
||||
clearHistory: "履歴をクリア",
|
||||
noActiveDownloads: "アクティブなダウンロードはありません",
|
||||
noQueuedDownloads: "待機中のダウンロードはありません",
|
||||
noDownloadHistory: "ダウンロード履歴はありません",
|
||||
downloadCancelled: "ダウンロードがキャンセルされました",
|
||||
queueCleared: "キューがクリアされました",
|
||||
historyCleared: "履歴がクリアされました",
|
||||
removedFromQueue: "キューから削除されました",
|
||||
removedFromHistory: "履歴から削除されました",
|
||||
status: "ステータス",
|
||||
progress: "進捗",
|
||||
speed: "速度",
|
||||
finishedAt: "完了日時",
|
||||
failed: "失敗",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const ko = {
|
||||
removeLegacyDataConfirmMessage: "레거시 JSON 데이터 파일을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
legacyDataDeleted: "레거시 데이터가 성공적으로 삭제되었습니다.",
|
||||
deleteLegacyDataButton: "레거시 데이터 삭제",
|
||||
cleanupTempFiles: "임시 파일 정리",
|
||||
cleanupTempFilesDescription: "업로드 디렉토리에서 모든 임시 다운로드 파일(.ytdl, .part)을 제거합니다. 불완전하거나 취소된 다운로드의 디스크 공간을 확보하는 데 도움이 됩니다.",
|
||||
cleanupTempFilesConfirmTitle: "임시 파일을 정리하시겠습니까?",
|
||||
cleanupTempFilesConfirmMessage: "업로드 디렉토리의 모든 .ytdl 및 .part 파일이 영구적으로 삭제됩니다. 계속하기 전에 활성 다운로드가 없는지 확인하세요.",
|
||||
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: "콘텐츠 관리",
|
||||
@@ -97,6 +113,7 @@ export const ko = {
|
||||
authors: "작성자 목록",
|
||||
created: "생성일",
|
||||
name: "이름",
|
||||
size: "크기",
|
||||
actions: "작업",
|
||||
deleteCollection: "컬렉션 삭제",
|
||||
deleteVideo: "동영상 삭제",
|
||||
@@ -140,6 +157,17 @@ export const ko = {
|
||||
editTitle: "제목 편집",
|
||||
titleUpdated: "제목이 성공적으로 업데이트됨",
|
||||
titleUpdateFailed: "제목 업데이트 실패",
|
||||
refreshThumbnail: "썸네일 새로고침",
|
||||
thumbnailRefreshed: "썸네일이 새로고침되었습니다",
|
||||
thumbnailRefreshFailed: "썸네일 새로고침 실패",
|
||||
videoUpdated: "비디오가 업데이트되었습니다",
|
||||
videoUpdateFailed: "비디오 업데이트 실패",
|
||||
failedToLoadVideos: "비디오를 불러오지 못했습니다. 나중에 다시 시도해주세요.",
|
||||
videoRemovedSuccessfully: "비디오가 삭제되었습니다",
|
||||
failedToDeleteVideo: "비디오 삭제 실패",
|
||||
pleaseEnterSearchTerm: "검색어를 입력해주세요",
|
||||
failedToSearch: "검색 실패. 다시 시도해주세요.",
|
||||
searchCancelled: "검색이 취소되었습니다",
|
||||
|
||||
// Login
|
||||
signIn: "로그인",
|
||||
@@ -166,6 +194,15 @@ export const ko = {
|
||||
deleteCollectionOnly: "컬렉션만 삭제",
|
||||
deleteCollectionAndVideos: "컬렉션 및 모든 동영상 삭제",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "비디오 다운로드 중",
|
||||
downloadStartedSuccessfully: "다운로드가 성공적으로 시작되었습니다",
|
||||
collectionCreatedSuccessfully: "컬렉션이 성공적으로 생성되었습니다",
|
||||
videoAddedToCollection: "비디오가 컬렉션에 추가되었습니다",
|
||||
videoRemovedFromCollection: "비디오가 컬렉션에서 제거되었습니다",
|
||||
collectionDeletedSuccessfully: "컬렉션이 성공적으로 삭제되었습니다",
|
||||
failedToDeleteCollection: "컬렉션 삭제 실패",
|
||||
|
||||
// Common
|
||||
loading: "로드 중...",
|
||||
error: "오류",
|
||||
@@ -206,5 +243,26 @@ export const ko = {
|
||||
allPartsAddedToCollection: "모든 파트가 이 컬렉션에 추가됩니다",
|
||||
allVideosAddedToCollection: "모든 동영상이 이 컬렉션에 추가됩니다",
|
||||
queued: "대기열에 추가됨",
|
||||
waitingInQueue: "대기 중"
|
||||
waitingInQueue: "대기 중",
|
||||
// Downloads
|
||||
downloads: "다운로드",
|
||||
activeDownloads: "진행 중인 다운로드",
|
||||
manageDownloads: "다운로드 관리",
|
||||
queuedDownloads: "대기 중인 다운로드",
|
||||
downloadHistory: "다운로드 기록",
|
||||
clearQueue: "대기열 지우기",
|
||||
clearHistory: "기록 지우기",
|
||||
noActiveDownloads: "진행 중인 다운로드 없음",
|
||||
noQueuedDownloads: "대기 중인 다운로드 없음",
|
||||
noDownloadHistory: "다운로드 기록 없음",
|
||||
downloadCancelled: "다운로드 취소됨",
|
||||
queueCleared: "대기열 지워짐",
|
||||
historyCleared: "기록 지워짐",
|
||||
removedFromQueue: "대기열에서 제거됨",
|
||||
removedFromHistory: "기록에서 제거됨",
|
||||
status: "상태",
|
||||
progress: "진행률",
|
||||
speed: "속도",
|
||||
finishedAt: "완료 시간",
|
||||
failed: "실패",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const pt = {
|
||||
removeLegacyDataConfirmMessage: "Tem certeza de que deseja excluir os arquivos de dados JSON legados? Esta ação não pode ser desfeita.",
|
||||
legacyDataDeleted: "Dados legados excluídos com sucesso.",
|
||||
deleteLegacyDataButton: "Excluir Dados Legados",
|
||||
cleanupTempFiles: "Limpar Arquivos Temporários",
|
||||
cleanupTempFilesDescription: "Remover todos os arquivos temporários de download (.ytdl, .part) do diretório de uploads. Isto ajuda a liberar espaço em disco de downloads incompletos ou cancelados.",
|
||||
cleanupTempFilesConfirmTitle: "Limpar Arquivos Temporários?",
|
||||
cleanupTempFilesConfirmMessage: "Isto excluirá permanentemente todos os arquivos .ytdl e .part no diretório de uploads. Certifique-se de que não há downloads ativos antes de continuar.",
|
||||
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",
|
||||
@@ -97,6 +113,7 @@ export const pt = {
|
||||
authors: "Autores",
|
||||
created: "Criado",
|
||||
name: "Nome",
|
||||
size: "Tamanho",
|
||||
actions: "Ações",
|
||||
deleteCollection: "Excluir Coleção",
|
||||
deleteVideo: "Excluir Vídeo",
|
||||
@@ -140,6 +157,25 @@ export const pt = {
|
||||
editTitle: "Editar Título",
|
||||
titleUpdated: "Título atualizado com sucesso",
|
||||
titleUpdateFailed: "Falha ao atualizar título",
|
||||
refreshThumbnail: "Atualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura atualizada com sucesso",
|
||||
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
|
||||
videoUpdated: "Vídeo atualizado com sucesso",
|
||||
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 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",
|
||||
|
||||
// Login
|
||||
signIn: "Entrar",
|
||||
@@ -206,5 +242,26 @@ export const pt = {
|
||||
allPartsAddedToCollection: "Todas as partes serão adicionadas a esta coleção",
|
||||
allVideosAddedToCollection: "Todos os vídeos serão adicionados a esta coleção",
|
||||
queued: "Na fila",
|
||||
waitingInQueue: "Aguardando na fila"
|
||||
waitingInQueue: "Aguardando na fila",
|
||||
// Downloads
|
||||
downloads: "Downloads",
|
||||
activeDownloads: "Downloads Ativos",
|
||||
manageDownloads: "Gerenciar Downloads",
|
||||
queuedDownloads: "Downloads na Fila",
|
||||
downloadHistory: "Histórico de Downloads",
|
||||
clearQueue: "Limpar Fila",
|
||||
clearHistory: "Limpar Histórico",
|
||||
noActiveDownloads: "Nenhum download ativo",
|
||||
noQueuedDownloads: "Nenhum download na fila",
|
||||
noDownloadHistory: "Nenhum histórico de download",
|
||||
downloadCancelled: "Download cancelado",
|
||||
queueCleared: "Fila limpa",
|
||||
historyCleared: "Histórico limpo",
|
||||
removedFromQueue: "Removido da fila",
|
||||
removedFromHistory: "Removido do histórico",
|
||||
status: "Status",
|
||||
progress: "Progresso",
|
||||
speed: "Velocidade",
|
||||
finishedAt: "Terminado em",
|
||||
failed: "Falhou",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const ru = {
|
||||
removeLegacyDataConfirmMessage: "Вы уверены, что хотите удалить устаревшие файлы данных JSON? Это действие нельзя отменить.",
|
||||
legacyDataDeleted: "Устаревшие данные успешно удалены.",
|
||||
deleteLegacyDataButton: "Удалить устаревшие данные",
|
||||
cleanupTempFiles: "Очистить временные файлы",
|
||||
cleanupTempFilesDescription: "Удалить все временные файлы загрузки (.ytdl, .part) из каталога загрузок. Это помогает освободить место на диске от незавершенных или отмененных загрузок.",
|
||||
cleanupTempFilesConfirmTitle: "Очистить временные файлы?",
|
||||
cleanupTempFilesConfirmMessage: "Это навсегда удалит все файлы .ytdl и .part в каталоге загрузок. Убедитесь, что нет активных загрузок перед продолжением.",
|
||||
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: "Управление контентом",
|
||||
@@ -97,6 +113,7 @@ export const ru = {
|
||||
authors: "Авторы",
|
||||
created: "Создано",
|
||||
name: "Имя",
|
||||
size: "Размер",
|
||||
actions: "Действия",
|
||||
deleteCollection: "Удалить коллекцию",
|
||||
deleteVideo: "Удалить видео",
|
||||
@@ -140,6 +157,17 @@ export const ru = {
|
||||
editTitle: "Редактировать название",
|
||||
titleUpdated: "Название успешно обновлено",
|
||||
titleUpdateFailed: "Не удалось обновить название",
|
||||
refreshThumbnail: "Обновить миниатюру",
|
||||
thumbnailRefreshed: "Миниатюра успешно обновлена",
|
||||
thumbnailRefreshFailed: "Не удалось обновить миниатюру",
|
||||
videoUpdated: "Видео успешно обновлено",
|
||||
videoUpdateFailed: "Не удалось обновить видео",
|
||||
failedToLoadVideos: "Не удалось загрузить видео. Пожалуйста, попробуйте позже.",
|
||||
videoRemovedSuccessfully: "Видео успешно удалено",
|
||||
failedToDeleteVideo: "Не удалось удалить видео",
|
||||
pleaseEnterSearchTerm: "Пожалуйста, введите поисковый запрос",
|
||||
failedToSearch: "Поиск не удался. Пожалуйста, попробуйте снова.",
|
||||
searchCancelled: "Поиск отменен",
|
||||
|
||||
// Login
|
||||
signIn: "Войти",
|
||||
@@ -152,6 +180,15 @@ export const ru = {
|
||||
loadingCollection: "Загрузка коллекции...",
|
||||
collectionNotFound: "Коллекция не найдена",
|
||||
noVideosInCollection: "В этой коллекции нет видео.",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Видео скачивается",
|
||||
downloadStartedSuccessfully: "Загрузка успешно началась",
|
||||
collectionCreatedSuccessfully: "Коллекция успешно создана",
|
||||
videoAddedToCollection: "Видео добавлено в коллекцию",
|
||||
videoRemovedFromCollection: "Видео удалено из коллекции",
|
||||
collectionDeletedSuccessfully: "Коллекция успешно удалена",
|
||||
failedToDeleteCollection: "Не удалось удалить коллекцию",
|
||||
back: "Назад",
|
||||
|
||||
// Author Videos
|
||||
@@ -206,5 +243,26 @@ export const ru = {
|
||||
allPartsAddedToCollection: "Все части будут добавлены в эту коллекцию",
|
||||
allVideosAddedToCollection: "Все видео будут добавлены в эту коллекцию",
|
||||
queued: "В очереди",
|
||||
waitingInQueue: "Ожидание в очереди"
|
||||
waitingInQueue: "Ожидание в очереди",
|
||||
// Downloads
|
||||
downloads: "Загрузки",
|
||||
activeDownloads: "Активные загрузки",
|
||||
manageDownloads: "Управление загрузками",
|
||||
queuedDownloads: "Загрузки в очереди",
|
||||
downloadHistory: "История загрузок",
|
||||
clearQueue: "Очистить очередь",
|
||||
clearHistory: "Очистить историю",
|
||||
noActiveDownloads: "Нет активных загрузок",
|
||||
noQueuedDownloads: "Нет загрузок в очереди",
|
||||
noDownloadHistory: "История загрузок пуста",
|
||||
downloadCancelled: "Загрузка отменена",
|
||||
queueCleared: "Очередь очищена",
|
||||
historyCleared: "История очищена",
|
||||
removedFromQueue: "Удалено из очереди",
|
||||
removedFromHistory: "Удалено из истории",
|
||||
status: "Статус",
|
||||
progress: "Прогресс",
|
||||
speed: "Скорость",
|
||||
finishedAt: "Завершено в",
|
||||
failed: "Ошибка",
|
||||
};
|
||||
|
||||
@@ -77,6 +77,22 @@ export const zh = {
|
||||
removeLegacyDataConfirmMessage: "确定要删除旧的 JSON 数据文件吗?此操作无法撤销。",
|
||||
legacyDataDeleted: "旧数据删除成功。",
|
||||
deleteLegacyDataButton: "删除旧数据",
|
||||
cleanupTempFiles: "清理临时文件",
|
||||
cleanupTempFilesDescription: "从上传目录中删除所有临时下载文件(.ytdl、.part)。这有助于释放未完成或已取消下载占用的磁盘空间。",
|
||||
cleanupTempFilesConfirmTitle: "清理临时文件?",
|
||||
cleanupTempFilesConfirmMessage: "这将永久删除上传目录中的所有.ytdl和.part文件。请确保没有正在进行的下载。",
|
||||
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: "内容管理",
|
||||
@@ -97,6 +113,7 @@ export const zh = {
|
||||
authors: "作者列表",
|
||||
created: "创建时间",
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
actions: "操作",
|
||||
deleteCollection: "删除合集",
|
||||
deleteVideo: "删除视频",
|
||||
@@ -140,6 +157,17 @@ export const zh = {
|
||||
editTitle: "编辑标题",
|
||||
titleUpdated: "标题更新成功",
|
||||
titleUpdateFailed: "更新标题失败",
|
||||
refreshThumbnail: "刷新缩略图",
|
||||
thumbnailRefreshed: "缩略图刷新成功",
|
||||
thumbnailRefreshFailed: "刷新缩略图失败",
|
||||
videoUpdated: "视频更新成功",
|
||||
videoUpdateFailed: "更新视频失败",
|
||||
failedToLoadVideos: "加载视频失败。请稍后再试。",
|
||||
videoRemovedSuccessfully: "视频删除成功",
|
||||
failedToDeleteVideo: "删除视频失败",
|
||||
pleaseEnterSearchTerm: "请输入搜索词",
|
||||
failedToSearch: "搜索失败。请稍后再试。",
|
||||
searchCancelled: "搜索已取消",
|
||||
|
||||
// Login
|
||||
signIn: "登录",
|
||||
@@ -154,6 +182,15 @@ export const zh = {
|
||||
noVideosInCollection: "此合集中没有视频。",
|
||||
back: "返回",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "视频下载中",
|
||||
downloadStartedSuccessfully: "下载已成功开始",
|
||||
collectionCreatedSuccessfully: "集合创建成功",
|
||||
videoAddedToCollection: "视频已添加到集合",
|
||||
videoRemovedFromCollection: "视频已从集合中移除",
|
||||
collectionDeletedSuccessfully: "集合删除成功",
|
||||
failedToDeleteCollection: "删除集合失败",
|
||||
|
||||
// Author Videos
|
||||
loadVideosError: "加载视频失败,请稍后再试。",
|
||||
unknownAuthor: "未知",
|
||||
@@ -206,5 +243,27 @@ export const zh = {
|
||||
allPartsAddedToCollection: "所有分P将被添加到此合集",
|
||||
allVideosAddedToCollection: "所有视频将被添加到此合集",
|
||||
queued: "已排队",
|
||||
waitingInQueue: "等待中"
|
||||
waitingInQueue: "等待中",
|
||||
|
||||
// Downloads
|
||||
downloads: "下载",
|
||||
activeDownloads: "进行中的下载",
|
||||
manageDownloads: "管理下载",
|
||||
queuedDownloads: "排队中的下载",
|
||||
downloadHistory: "下载历史",
|
||||
clearQueue: "清空队列",
|
||||
clearHistory: "清空历史",
|
||||
noActiveDownloads: "暂无进行中的下载",
|
||||
noQueuedDownloads: "暂无排队的下载",
|
||||
noDownloadHistory: "暂无下载历史",
|
||||
downloadCancelled: "下载已取消",
|
||||
queueCleared: "队列已清空",
|
||||
historyCleared: "历史已清空",
|
||||
removedFromQueue: "已从队列移除",
|
||||
removedFromHistory: "已从历史移除",
|
||||
status: "状态",
|
||||
progress: "进度",
|
||||
speed: "速度",
|
||||
finishedAt: "完成时间",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
135
frontend/src/utils/recommendations.ts
Normal file
135
frontend/src/utils/recommendations.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
export interface RecommendationWeights {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
collection: number;
|
||||
tags: number;
|
||||
author: number;
|
||||
filename: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: RecommendationWeights = {
|
||||
recency: 0.2,
|
||||
frequency: 0.1,
|
||||
collection: 0.4,
|
||||
tags: 0.2,
|
||||
author: 0.1,
|
||||
filename: 0.0, // Used as tie-breaker mostly
|
||||
sequence: 0.5, // Boost for the immediate next file
|
||||
};
|
||||
|
||||
export interface RecommendationContext {
|
||||
currentVideo: Video;
|
||||
allVideos: Video[];
|
||||
collections: Collection[];
|
||||
weights?: Partial<RecommendationWeights>;
|
||||
}
|
||||
|
||||
export const getRecommendations = (context: RecommendationContext): Video[] => {
|
||||
const { currentVideo, allVideos, collections, weights } = context;
|
||||
const finalWeights = { ...DEFAULT_WEIGHTS, ...weights };
|
||||
|
||||
// Filter out current video
|
||||
const candidates = allVideos.filter(v => v.id !== currentVideo.id);
|
||||
|
||||
// Pre-calculate collection membership for current video
|
||||
const currentVideoCollections = collections.filter(c => c.videos.includes(currentVideo.id)).map(c => c.id);
|
||||
|
||||
// Calculate max values for normalization
|
||||
const maxViewCount = Math.max(...allVideos.map(v => v.viewCount || 0), 1);
|
||||
const now = Date.now();
|
||||
// Normalize recency: 1.0 for now, 0.0 for very old (e.g. 1 year ago)
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Determine natural sequence
|
||||
// Sort all videos by filename/title to find the "next" one naturally
|
||||
const sortedAllVideos = [...allVideos].sort((a, b) => {
|
||||
const nameA = a.videoFilename || a.title;
|
||||
const nameB = b.videoFilename || b.title;
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
const currentIndex = sortedAllVideos.findIndex(v => v.id === currentVideo.id);
|
||||
const nextInSequenceId = currentIndex !== -1 && currentIndex < sortedAllVideos.length - 1
|
||||
? sortedAllVideos[currentIndex + 1].id
|
||||
: null;
|
||||
|
||||
const scoredCandidates = candidates.map(video => {
|
||||
let score = 0;
|
||||
|
||||
// 1. Recency (lastPlayedAt)
|
||||
// Higher score for more recently played.
|
||||
// If never played, score is 0.
|
||||
if (video.lastPlayedAt) {
|
||||
const age = Math.max(0, now - video.lastPlayedAt);
|
||||
const recencyScore = Math.max(0, 1 - (age / ONE_YEAR_MS));
|
||||
score += recencyScore * finalWeights.recency;
|
||||
}
|
||||
|
||||
// 2. Frequency (viewCount)
|
||||
const frequencyScore = (video.viewCount || 0) / maxViewCount;
|
||||
score += frequencyScore * finalWeights.frequency;
|
||||
|
||||
// 3. Collection/Series
|
||||
// Check if video is in the same collection as current video
|
||||
const videoCollections = collections.filter(c => c.videos.includes(video.id)).map(c => c.id);
|
||||
const inSameCollection = currentVideoCollections.some(id => videoCollections.includes(id));
|
||||
|
||||
// Also check seriesTitle if available
|
||||
const sameSeriesTitle = currentVideo.seriesTitle && video.seriesTitle && currentVideo.seriesTitle === video.seriesTitle;
|
||||
|
||||
if (inSameCollection || sameSeriesTitle) {
|
||||
score += 1.0 * finalWeights.collection;
|
||||
}
|
||||
|
||||
// 4. Tags
|
||||
// Jaccard index or simple overlap
|
||||
const currentTags = currentVideo.tags || [];
|
||||
const videoTags = video.tags || [];
|
||||
if (currentTags.length > 0 && videoTags.length > 0) {
|
||||
const intersection = currentTags.filter(t => videoTags.includes(t));
|
||||
const union = new Set([...currentTags, ...videoTags]);
|
||||
const tagScore = intersection.length / union.size;
|
||||
score += tagScore * finalWeights.tags;
|
||||
}
|
||||
|
||||
// 5. Author
|
||||
if (currentVideo.author && video.author && currentVideo.author === video.author) {
|
||||
score += 1.0 * finalWeights.author;
|
||||
}
|
||||
|
||||
// 6. Sequence (Natural Order)
|
||||
if (video.id === nextInSequenceId) {
|
||||
score += 1.0 * finalWeights.sequence;
|
||||
}
|
||||
|
||||
return {
|
||||
video,
|
||||
score,
|
||||
inSameCollection
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scoredCandidates.sort((a, b) => {
|
||||
if (Math.abs(a.score - b.score) > 0.001) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
// Tie-breakers
|
||||
|
||||
// 1. Same collection
|
||||
if (a.inSameCollection !== b.inSameCollection) {
|
||||
return a.inSameCollection ? -1 : 1;
|
||||
}
|
||||
|
||||
// 2. Filename natural order
|
||||
const nameA = a.video.videoFilename || a.video.title;
|
||||
const nameB = b.video.videoFilename || b.video.title;
|
||||
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return scoredCandidates.map(item => item.video);
|
||||
};
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.2",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user