Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d900f5f7 | ||
|
|
6621be19fc | ||
|
|
10d5423c99 | ||
|
|
067273a44b | ||
|
|
0009f7bb96 | ||
|
|
591e85c814 | ||
|
|
610bc614b1 | ||
|
|
70defde9c2 | ||
|
|
d9bce6df02 | ||
|
|
b301a563d9 | ||
|
|
8c33d29832 | ||
|
|
3ad06c00ba | ||
|
|
9c7771b232 | ||
|
|
f418024418 | ||
|
|
350cacb1f0 | ||
|
|
1fbec80917 |
46
README-zh.md
46
README-zh.md
@@ -4,24 +4,33 @@
|
||||
|
||||
[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)。
|
||||
|
||||
45
README.md
45
README.md
@@ -4,6 +4,10 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
|
||||
|
||||
[中文](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).
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.4",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -236,8 +236,16 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
||||
}
|
||||
};
|
||||
|
||||
// Determine type
|
||||
let type = 'youtube';
|
||||
if (videoUrl.includes("missav")) {
|
||||
type = 'missav';
|
||||
} else if (isBilibiliUrl(videoUrl)) {
|
||||
type = 'bilibili';
|
||||
}
|
||||
|
||||
// Add to download manager
|
||||
downloadManager.addDownload(downloadTask, downloadId, initialTitle)
|
||||
downloadManager.addDownload(downloadTask, downloadId, initialTitle, videoUrl, type)
|
||||
.then((result: any) => {
|
||||
console.log("Download completed successfully:", result);
|
||||
})
|
||||
@@ -451,6 +459,17 @@ 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,
|
||||
@@ -463,6 +482,7 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
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(),
|
||||
@@ -645,7 +665,10 @@ export const incrementViewCount = (req: Request, res: Response): any => {
|
||||
}
|
||||
|
||||
const currentViews = video.viewCount || 0;
|
||||
const updatedVideo = storageService.updateVideo(id, { viewCount: currentViews + 1 });
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
viewCount: currentViews + 1,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -667,7 +690,10 @@ export const updateProgress = (req: Request, res: Response): any => {
|
||||
return res.status(400).json({ error: "Progress must be a number" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, { progress });
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
progress,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const videos = sqliteTable('videos', {
|
||||
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', {
|
||||
@@ -88,6 +89,8 @@ export const downloads = sqliteTable('downloads', {
|
||||
progress: integer('progress'), // Using integer for percentage (0-100) or similar
|
||||
speed: text('speed'),
|
||||
status: text('status').notNull().default('active'), // 'active' or 'queued'
|
||||
sourceUrl: text('source_url'),
|
||||
type: text('type'),
|
||||
});
|
||||
|
||||
export const downloadHistory = sqliteTable('download_history', {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDownloadTask } from "./downloadService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
interface DownloadTask {
|
||||
@@ -7,6 +8,8 @@ interface DownloadTask {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
cancelFn?: () => void;
|
||||
sourceUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
@@ -35,6 +38,58 @@ class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the download manager and restore queued tasks
|
||||
*/
|
||||
initialize(): void {
|
||||
try {
|
||||
console.log("Initializing DownloadManager...");
|
||||
const status = storageService.getDownloadStatus();
|
||||
const queuedDownloads = status.queuedDownloads;
|
||||
|
||||
if (queuedDownloads && queuedDownloads.length > 0) {
|
||||
console.log(`Restoring ${queuedDownloads.length} queued downloads...`);
|
||||
|
||||
for (const download of queuedDownloads) {
|
||||
if (download.sourceUrl && download.type) {
|
||||
console.log(`Restoring task: ${download.title} (${download.id})`);
|
||||
|
||||
// Reconstruct the download function
|
||||
const downloadFn = createDownloadTask(
|
||||
download.type,
|
||||
download.sourceUrl,
|
||||
download.id
|
||||
);
|
||||
|
||||
// Add to queue without persisting (since it's already in DB)
|
||||
// We need to manually construct the task and push to queue
|
||||
// We can't use addDownload because it returns a promise that we can't easily attach to
|
||||
// But for restored tasks, we don't have a client waiting for the promise anyway.
|
||||
|
||||
const task: DownloadTask = {
|
||||
downloadFn,
|
||||
id: download.id,
|
||||
title: download.title,
|
||||
sourceUrl: download.sourceUrl,
|
||||
type: download.type,
|
||||
resolve: (val) => console.log(`Restored task ${download.id} completed`, val),
|
||||
reject: (err) => console.error(`Restored task ${download.id} failed`, err),
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
} else {
|
||||
console.warn(`Skipping restoration of task ${download.id} due to missing sourceUrl or type`);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger processing
|
||||
this.processQueue();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing DownloadManager:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum number of concurrent downloads
|
||||
* @param limit - Maximum number of concurrent downloads
|
||||
@@ -49,19 +104,16 @@ class DownloadManager {
|
||||
* @param downloadFn - Async function that performs the download
|
||||
* @param id - Unique ID for the download
|
||||
* @param title - Title of the video being downloaded
|
||||
* @returns - Resolves when the download is complete
|
||||
*/
|
||||
/**
|
||||
* Add a download task to the manager
|
||||
* @param downloadFn - Async function that performs the download
|
||||
* @param id - Unique ID for the download
|
||||
* @param title - Title of the video being downloaded
|
||||
* @param sourceUrl - Source URL of the video
|
||||
* @param type - Type of the download (youtube, bilibili, missav)
|
||||
* @returns - Resolves when the download is complete
|
||||
*/
|
||||
async addDownload(
|
||||
downloadFn: (registerCancel: (cancel: () => void) => void) => Promise<any>,
|
||||
id: string,
|
||||
title: string
|
||||
title: string,
|
||||
sourceUrl?: string,
|
||||
type?: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task: DownloadTask = {
|
||||
@@ -70,6 +122,8 @@ class DownloadManager {
|
||||
title,
|
||||
resolve,
|
||||
reject,
|
||||
sourceUrl,
|
||||
type,
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
@@ -107,6 +161,7 @@ class DownloadManager {
|
||||
finishedAt: Date.now(),
|
||||
status: 'failed',
|
||||
error: 'Download cancelled by user',
|
||||
sourceUrl: task.sourceUrl,
|
||||
});
|
||||
|
||||
// Clean up internal state
|
||||
@@ -152,7 +207,9 @@ class DownloadManager {
|
||||
const queuedDownloads = this.queue.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
sourceUrl: task.sourceUrl,
|
||||
type: task.type,
|
||||
}));
|
||||
storageService.setQueuedDownloads(queuedDownloads);
|
||||
}
|
||||
@@ -177,6 +234,13 @@ class DownloadManager {
|
||||
|
||||
// Update status in storage
|
||||
storageService.addActiveDownload(task.id, task.title);
|
||||
// Update with extra info if available
|
||||
if (task.sourceUrl || task.type) {
|
||||
storageService.updateActiveDownload(task.id, {
|
||||
sourceUrl: task.sourceUrl,
|
||||
type: task.type
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Starting download: ${task.title} (${task.id})`);
|
||||
@@ -211,7 +275,7 @@ class DownloadManager {
|
||||
status: 'success',
|
||||
videoPath: videoData.videoPath,
|
||||
thumbnailPath: videoData.thumbnailPath,
|
||||
sourceUrl: videoData.sourceUrl,
|
||||
sourceUrl: videoData.sourceUrl || task.sourceUrl,
|
||||
author: videoData.author,
|
||||
});
|
||||
|
||||
@@ -229,6 +293,7 @@ class DownloadManager {
|
||||
finishedAt: Date.now(),
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
sourceUrl: task.sourceUrl,
|
||||
});
|
||||
|
||||
task.reject(error);
|
||||
|
||||
@@ -113,3 +113,23 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
// 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 YouTube
|
||||
return YouTubeDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -507,6 +507,17 @@ export class BilibiliDownloader {
|
||||
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(),
|
||||
@@ -523,6 +534,7 @@ export class BilibiliDownloader {
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import puppeteer from "puppeteer";
|
||||
import youtubedl from "youtube-dl-exec";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { sanitizeFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
@@ -57,6 +57,10 @@ export class MissAVDownloader {
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Ensure directories exist
|
||||
fs.ensureDirSync(VIDEOS_DIR);
|
||||
fs.ensureDirSync(IMAGES_DIR);
|
||||
|
||||
const videoPath = path.join(VIDEOS_DIR, videoFilename);
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
@@ -65,10 +69,11 @@ export class MissAVDownloader {
|
||||
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
let thumbnailUrl: string | null = null;
|
||||
let thumbnailSaved = false;
|
||||
let m3u8Url: string | null = null;
|
||||
|
||||
try {
|
||||
// 1. Fetch the page content using Puppeteer to bypass Cloudflare
|
||||
console.log("Fetching MissAV page content with Puppeteer...");
|
||||
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
|
||||
console.log("Launching Puppeteer to capture m3u8 URL...");
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
@@ -78,8 +83,21 @@ export class MissAVDownloader {
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a real user agent
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
await page.setUserAgent(userAgent);
|
||||
|
||||
// Setup request listener to find m3u8
|
||||
page.on('request', (request) => {
|
||||
const reqUrl = request.url();
|
||||
if (reqUrl.includes('.m3u8') && !reqUrl.includes('preview')) {
|
||||
console.log("Found m3u8 URL via network interception:", reqUrl);
|
||||
if (!m3u8Url) {
|
||||
m3u8Url = reqUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Navigating to:", url);
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
const html = await page.content();
|
||||
@@ -99,44 +117,38 @@ export class MissAVDownloader {
|
||||
|
||||
console.log("Extracted metadata:", { title: videoTitle, thumbnail: thumbnailUrl });
|
||||
|
||||
// 3. Extract the m3u8 URL
|
||||
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
|
||||
// 3. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
if (!m3u8Url) {
|
||||
console.log("m3u8 URL not found via network, trying regex extraction...");
|
||||
|
||||
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
|
||||
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
|
||||
|
||||
// Look for the obfuscated string pattern
|
||||
// The pattern seems to be: m3u8|...|playlist|source
|
||||
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
|
||||
if (m3u8Match) {
|
||||
const matchString = m3u8Match[0];
|
||||
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
|
||||
const urlWords = cleanString.split("|");
|
||||
|
||||
if (!m3u8Match) {
|
||||
throw new Error("Could not find m3u8 URL pattern in page source");
|
||||
const videoIndex = urlWords.indexOf("video");
|
||||
if (videoIndex !== -1) {
|
||||
const protocol = urlWords[videoIndex - 1];
|
||||
const videoFormat = urlWords[videoIndex + 1];
|
||||
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
|
||||
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
|
||||
m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
|
||||
console.log("Reconstructed m3u8 URL via regex:", m3u8Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchString = m3u8Match[0];
|
||||
// Remove "m3u8|" from start and "|playlist|source" from end
|
||||
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
|
||||
const urlWords = cleanString.split("|");
|
||||
|
||||
// Find "video" index
|
||||
const videoIndex = urlWords.indexOf("video");
|
||||
if (videoIndex === -1) {
|
||||
throw new Error("Could not parse m3u8 URL structure");
|
||||
if (!m3u8Url) {
|
||||
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
|
||||
fs.writeFileSync(debugFile, html);
|
||||
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
|
||||
throw new Error("Could not find m3u8 URL in page source or network requests");
|
||||
}
|
||||
|
||||
const protocol = urlWords[videoIndex - 1];
|
||||
const videoFormat = urlWords[videoIndex + 1];
|
||||
|
||||
// Reconstruct parts
|
||||
// m3u8_url_path = "-".join((url_words[0:5])[::-1])
|
||||
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
|
||||
|
||||
// base_url_path = ".".join((url_words[5:video_index-1])[::-1])
|
||||
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
|
||||
|
||||
// formatted_url = "{0}://{1}/{2}/{3}/{4}.m3u8".format(protocol, base_url_path, m3u8_url_path, video_format, url_words[video_index])
|
||||
const m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
|
||||
|
||||
console.log("Reconstructed m3u8 URL:", m3u8Url);
|
||||
|
||||
// 4. Download the video using yt-dlp
|
||||
// 4. Download the video using ffmpeg directly
|
||||
console.log("Downloading video stream to:", videoPath);
|
||||
|
||||
if (downloadId) {
|
||||
@@ -146,70 +158,116 @@ export class MissAVDownloader {
|
||||
});
|
||||
}
|
||||
|
||||
const subprocess = youtubedl.exec(m3u8Url, {
|
||||
output: videoPath,
|
||||
format: "mp4",
|
||||
noCheckCertificates: true,
|
||||
// Add headers to mimic browser
|
||||
addHeader: [
|
||||
'Referer:https://missav.ai/',
|
||||
'User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
]
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ffmpegArgs = [
|
||||
'-user_agent', userAgent,
|
||||
'-headers', 'Referer: https://missav.ai/',
|
||||
'-i', m3u8Url!,
|
||||
'-c', 'copy',
|
||||
'-bsf:a', 'aac_adtstoasc',
|
||||
'-y', // Overwrite output file
|
||||
videoPath
|
||||
];
|
||||
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
console.log("Killing subprocess for download:", downloadId);
|
||||
subprocess.kill();
|
||||
|
||||
// Clean up partial files
|
||||
console.log("Cleaning up partial files...");
|
||||
try {
|
||||
// youtube-dl creates .part files during download
|
||||
const partVideoPath = `${videoPath}.part`;
|
||||
const partThumbnailPath = `${thumbnailPath}.part`;
|
||||
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
|
||||
let totalDurationSec = 0;
|
||||
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
console.log("Killing ffmpeg process for download:", downloadId);
|
||||
ffmpeg.kill('SIGKILL');
|
||||
|
||||
if (fs.existsSync(partVideoPath)) {
|
||||
fs.unlinkSync(partVideoPath);
|
||||
console.log("Deleted partial video file:", partVideoPath);
|
||||
// Cleanup
|
||||
try {
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.unlinkSync(videoPath);
|
||||
console.log("Deleted partial video file:", videoPath);
|
||||
}
|
||||
if (fs.existsSync(thumbnailPath)) {
|
||||
fs.unlinkSync(thumbnailPath);
|
||||
console.log("Deleted partial thumbnail file:", thumbnailPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error cleaning up partial files:", e);
|
||||
}
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.unlinkSync(videoPath);
|
||||
console.log("Deleted partial video file:", videoPath);
|
||||
}
|
||||
if (fs.existsSync(partThumbnailPath)) {
|
||||
fs.unlinkSync(partThumbnailPath);
|
||||
console.log("Deleted partial thumbnail file:", partThumbnailPath);
|
||||
}
|
||||
if (fs.existsSync(thumbnailPath)) {
|
||||
fs.unlinkSync(thumbnailPath);
|
||||
console.log("Deleted partial thumbnail file:", thumbnailPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up partial files:", cleanupError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subprocess.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
|
||||
const progressMatch = output.match(/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/);
|
||||
|
||||
if (progressMatch && downloadId) {
|
||||
const percentage = parseFloat(progressMatch[1]);
|
||||
const totalSize = progressMatch[2];
|
||||
const speed = progressMatch[3];
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
speed: speed
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await subprocess;
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
// console.log("ffmpeg stderr:", output); // Uncomment for verbose debug
|
||||
|
||||
// Try to parse duration if not set
|
||||
if (totalDurationSec === 0) {
|
||||
const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||
if (durationMatch) {
|
||||
const hours = parseInt(durationMatch[1]);
|
||||
const minutes = parseInt(durationMatch[2]);
|
||||
const seconds = parseInt(durationMatch[3]);
|
||||
totalDurationSec = hours * 3600 + minutes * 60 + seconds;
|
||||
console.log("Detected total duration:", totalDurationSec);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse progress
|
||||
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
|
||||
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
|
||||
const speedMatch = output.match(/speed=\s*(\d+\.?\d*)x/);
|
||||
|
||||
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 = "0x";
|
||||
if (speedMatch) {
|
||||
speedStr = `${speedMatch[1]}x`;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -272,6 +330,17 @@ export class MissAVDownloader {
|
||||
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(),
|
||||
@@ -286,6 +355,7 @@ export class MissAVDownloader {
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -284,6 +284,16 @@ export class YouTubeDownloader {
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ 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, downloadHistory, downloads, settings, videos } from "../db/schema";
|
||||
@@ -21,6 +21,7 @@ export interface Video {
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
fileSize?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,8 @@ export interface DownloadInfo {
|
||||
downloadedSize?: string;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
sourceUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
@@ -135,8 +138,56 @@ export function initializeStorage(): void {
|
||||
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 columns:", error);
|
||||
console.error("Error checking/migrating viewCount/progress/duration/fileSize columns:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,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: {
|
||||
@@ -172,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)
|
||||
@@ -205,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();
|
||||
}
|
||||
@@ -241,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
|
||||
@@ -249,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 };
|
||||
@@ -766,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;
|
||||
}
|
||||
}
|
||||
@@ -816,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.rmSync(videoCollectionDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(collectionVideoDir) && fs.readdirSync(collectionVideoDir).length === 0) {
|
||||
fs.rmdirSync(collectionVideoDir);
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir) && fs.readdirSync(imageCollectionDir).length === 0) {
|
||||
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(collectionImageDir) && fs.readdirSync(collectionImageDir).length === 0) {
|
||||
fs.rmdirSync(collectionImageDir);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing collection directories:", error);
|
||||
} catch (e) {
|
||||
console.error("Error removing collection directories:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
return deleteCollection(collectionId);
|
||||
}
|
||||
|
||||
export function deleteCollectionAndVideos(collectionId: string): boolean {
|
||||
@@ -842,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.rmSync(videoCollectionDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(collectionVideoDir)) {
|
||||
fs.rmdirSync(collectionVideoDir);
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir)) {
|
||||
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(collectionImageDir)) {
|
||||
fs.rmdirSync(collectionImageDir);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing collection directories:", error);
|
||||
} catch (e) {
|
||||
console.error("Error removing collection directories:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
return deleteCollection(collectionId);
|
||||
}
|
||||
|
||||
|
||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"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.2.0",
|
||||
"version": "1.2.4",
|
||||
"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,53 +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 (
|
||||
@@ -56,115 +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={
|
||||
<PageTransition>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<DownloadPage />
|
||||
</PageTransition>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
|
||||
|
||||
const isDownloading = activeDownloads.length > 0 || queuedDownloads.length > 0;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Header props:', { activeDownloads, queuedDownloads });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,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;
|
||||
}
|
||||
|
||||
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,3 +1,4 @@
|
||||
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';
|
||||
@@ -42,9 +43,27 @@ export const useVideo = () => {
|
||||
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { t } = useLanguage();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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[]>([]);
|
||||
@@ -56,31 +75,43 @@ 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(t('failedToLoadVideos'));
|
||||
} 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(t('videoRemovedSuccessfully'));
|
||||
await deleteVideoMutation.mutateAsync(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setLoading(false);
|
||||
return { success: false, error: t('failedToDeleteVideo') };
|
||||
}
|
||||
};
|
||||
@@ -161,25 +192,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
return { success: false, error: t('failedToSearch') };
|
||||
}
|
||||
return { success: false, error: t('searchCancelled') };
|
||||
} finally {
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tags state
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,12 +203,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch videos and tags on mount
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
// Cleanup search on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -207,38 +213,68 @@ 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 response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
|
||||
if (response.data.success) {
|
||||
setVideos(prevVideos => prevVideos.map(video =>
|
||||
video.id === id
|
||||
? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl }
|
||||
: video
|
||||
));
|
||||
showSnackbar(t('thumbnailRefreshed'));
|
||||
const result = await refreshThumbnailMutation.mutateAsync(id);
|
||||
if (result.data.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing thumbnail:', 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 response = await axios.put(`${API_URL}/videos/${id}`, updates);
|
||||
if (response.data.success) {
|
||||
setVideos(prevVideos => prevVideos.map(video =>
|
||||
video.id === id ? { ...video, ...updates } : video
|
||||
));
|
||||
showSnackbar(t('videoUpdated'));
|
||||
const result = await updateVideoMutation.mutateAsync({ id, updates });
|
||||
if (result.data.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
}
|
||||
};
|
||||
@@ -246,8 +282,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
return (
|
||||
<VideoContext.Provider value={{
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
loading: videosLoading,
|
||||
error: videosError ? (videosError as Error).message : null,
|
||||
fetchVideos,
|
||||
deleteVideo,
|
||||
updateVideo,
|
||||
|
||||
@@ -9,62 +9,33 @@ 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 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 +50,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
|
||||
@@ -141,7 +104,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}
|
||||
/>
|
||||
|
||||
@@ -13,31 +13,21 @@ import {
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 DownloadInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp: number;
|
||||
filename?: string;
|
||||
totalSize?: string;
|
||||
downloadedSize?: string;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
}
|
||||
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -80,102 +70,114 @@ function CustomTabPanel(props: TabPanelProps) {
|
||||
const DownloadPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { activeDownloads, queuedDownloads } = useDownload();
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>([]);
|
||||
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>([]);
|
||||
const [history, setHistory] = useState<DownloadHistoryItem[]>([]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
setActiveDownloads(response.data.activeDownloads);
|
||||
setQueuedDownloads(response.data.queuedDownloads);
|
||||
} catch (error) {
|
||||
console.error('Error fetching download status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
// Fetch history with polling
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ['downloadHistory'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/downloads/history`);
|
||||
setHistory(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchHistory();
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchHistory();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 2000
|
||||
});
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleCancelDownload = async (id: string) => {
|
||||
try {
|
||||
// Cancel download mutation
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
||||
fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Error cancelling download:', error);
|
||||
// DownloadContext handles active/queued updates via its own polling
|
||||
// But we might want to invalidate to be sure
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleCancelDownload = (id: string) => {
|
||||
cancelMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = async (id: string) => {
|
||||
try {
|
||||
// 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');
|
||||
fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Error removing from queue:', error);
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFromQueue = (id: string) => {
|
||||
removeFromQueueMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
try {
|
||||
// Clear queue mutation
|
||||
const clearQueueMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/downloads/queue`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('queueCleared') || 'Queue cleared');
|
||||
fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Error clearing queue:', error);
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleClearQueue = () => {
|
||||
clearQueueMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRemoveFromHistory = async (id: string) => {
|
||||
try {
|
||||
// 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');
|
||||
fetchHistory();
|
||||
} catch (error) {
|
||||
console.error('Error removing from history:', error);
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFromHistory = (id: string) => {
|
||||
removeFromHistoryMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
// Clear history mutation
|
||||
const clearHistoryMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/downloads/history`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('historyCleared') || 'History cleared');
|
||||
fetchHistory();
|
||||
} catch (error) {
|
||||
console.error('Error clearing history:', error);
|
||||
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error') || 'Error');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const formatBytes = (bytes?: string | number) => {
|
||||
if (!bytes) return '-';
|
||||
return bytes.toString(); // Simplified, ideally use a helper
|
||||
const handleClearHistory = () => {
|
||||
clearHistoryMutation.mutate();
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
@@ -203,24 +205,40 @@ const DownloadPage: React.FC = () => {
|
||||
<List>
|
||||
{activeDownloads.map((download) => (
|
||||
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem disableGutters>
|
||||
<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 }} />
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{download.progress?.toFixed(1)}% • {download.speed || '0 B/s'} • {download.downloadedSize || '0'} / {download.totalSize || '?'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" fontWeight="bold" color="primary">
|
||||
{download.progress?.toFixed(1)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{download.speed || '0 B/s'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{download.downloadedSize || '0'} / {download.totalSize || '?'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton edge="end" aria-label="cancel" onClick={() => handleCancelDownload(download.id)}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
@@ -246,16 +264,18 @@ const DownloadPage: React.FC = () => {
|
||||
<List>
|
||||
{queuedDownloads.map((download) => (
|
||||
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem disableGutters>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={download.title}
|
||||
secondary={t('queued') || 'Queued'}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
@@ -279,9 +299,16 @@ const DownloadPage: React.FC = () => {
|
||||
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{history.map((item) => (
|
||||
{history.map((item: DownloadHistoryItem) => (
|
||||
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
||||
<ListItem disableGutters>
|
||||
<ListItem
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
@@ -298,18 +325,13 @@ const DownloadPage: React.FC = () => {
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
<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>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
@@ -22,55 +22,34 @@ 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 { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
const { t } = useLanguage();
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
const [viewMode, setViewMode] = useState<'collections' | 'all-videos'>(() => {
|
||||
const saved = localStorage.getItem('homeViewMode');
|
||||
return (saved as 'collections' | 'all-videos') || 'collections';
|
||||
@@ -81,6 +60,14 @@ const Home: React.FC<HomeProps> = ({
|
||||
setPage(1);
|
||||
}, [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 : [];
|
||||
@@ -139,8 +126,6 @@ const Home: React.FC<HomeProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleViewModeChange = (mode: 'collections' | 'all-videos') => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('homeViewMode', mode);
|
||||
@@ -159,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 '';
|
||||
@@ -188,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>
|
||||
@@ -211,7 +194,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -279,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>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
@@ -33,23 +34,18 @@ 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 { refreshThumbnail, updateVideo } = useVideo();
|
||||
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);
|
||||
@@ -67,10 +63,79 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
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);
|
||||
@@ -98,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
|
||||
@@ -116,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);
|
||||
};
|
||||
@@ -124,7 +189,7 @@ 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);
|
||||
};
|
||||
@@ -214,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>
|
||||
@@ -225,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')}>
|
||||
@@ -265,7 +332,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<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..."
|
||||
@@ -291,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>
|
||||
@@ -301,12 +393,14 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<TableRow key={video.id} hover>
|
||||
<TableCell sx={{ width: 140 }}>
|
||||
<Box sx={{ position: 'relative', width: 120, height: 68 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getThumbnailSrc(video)}
|
||||
alt={video.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
<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"
|
||||
@@ -328,6 +422,9 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</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, maxWidth: 400 }}>
|
||||
{editingVideoId === video.id ? (
|
||||
@@ -385,7 +482,22 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</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,6 +23,7 @@ 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';
|
||||
@@ -46,6 +47,10 @@ interface Settings {
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, setLanguage } = useLanguage();
|
||||
const { activeDownloads } = useDownload();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
loginEnabled: false,
|
||||
password: '',
|
||||
@@ -56,7 +61,6 @@ const SettingsPage: React.FC = () => {
|
||||
tags: []
|
||||
});
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
|
||||
// Modal states
|
||||
@@ -72,51 +76,191 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
|
||||
|
||||
const { t, setLanguage } = useLanguage();
|
||||
const { activeDownloads } = useDownload();
|
||||
// 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') {
|
||||
@@ -137,6 +281,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 }}>
|
||||
@@ -314,7 +460,7 @@ const SettingsPage: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setShowCleanupTempFilesModal(true)}
|
||||
disabled={saving || activeDownloads.length > 0}
|
||||
disabled={isSaving || activeDownloads.length > 0}
|
||||
>
|
||||
{t('cleanupTempFiles')}
|
||||
</Button>
|
||||
@@ -333,7 +479,7 @@ const SettingsPage: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setShowMigrateConfirmModal(true)}
|
||||
disabled={saving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('migrateDataButton')}
|
||||
</Button>
|
||||
@@ -341,31 +487,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')}
|
||||
@@ -380,7 +503,7 @@ const SettingsPage: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteLegacyModal(true)}
|
||||
disabled={saving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('deleteLegacyDataButton')}
|
||||
</Button>
|
||||
@@ -416,9 +539,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>
|
||||
@@ -439,42 +562,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')}
|
||||
@@ -487,58 +577,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')}
|
||||
@@ -550,38 +591,9 @@ const SettingsPage: React.FC = () => {
|
||||
<ConfirmationModal
|
||||
isOpen={showCleanupTempFilesModal}
|
||||
onClose={() => setShowCleanupTempFilesModal(false)}
|
||||
onConfirm={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
|
||||
const { deletedCount, errors } = res.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'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Cleanup failed:', error);
|
||||
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'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
onConfirm={() => {
|
||||
setShowCleanupTempFilesModal(false);
|
||||
cleanupMutation.mutate();
|
||||
}}
|
||||
title={t('cleanupTempFilesConfirmTitle')}
|
||||
message={t('cleanupTempFilesConfirmMessage')}
|
||||
|
||||
@@ -11,57 +11,43 @@ import {
|
||||
Stack,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, 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,43 +224,63 @@ 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating tags:', error);
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
await tagsMutation.mutateAsync(newTags);
|
||||
};
|
||||
|
||||
const [hasViewed, setHasViewed] = useState<boolean>(false);
|
||||
@@ -342,7 +312,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
axios.post(`${API_URL}/videos/${id}/view`)
|
||||
.then(res => {
|
||||
if (res.data.success && video) {
|
||||
setVideo({ ...video, viewCount: res.data.viewCount });
|
||||
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, viewCount: res.data.viewCount } : old);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error incrementing view count:', err));
|
||||
@@ -369,13 +339,20 @@ 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]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
@@ -397,8 +374,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
deleteError={deleteError}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||
videoCollections={videoCollections}
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface Video {
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
duration?: string;
|
||||
fileSize?: string; // Size in bytes as string
|
||||
lastPlayedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -35,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 {
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ar = {
|
||||
authors: "المؤلفون",
|
||||
created: "تاريخ الإنشاء",
|
||||
name: "الاسم",
|
||||
size: "الحجم",
|
||||
actions: "إجراءات",
|
||||
deleteCollection: "حذف المجموعة",
|
||||
deleteVideo: "حذف الفيديو",
|
||||
@@ -182,6 +183,15 @@ export const ar = {
|
||||
deleteCollectionConfirmation: "هل أنت متأكد أنك تريد حذف المجموعة",
|
||||
collectionContains: "تحتوي هذه المجموعة على",
|
||||
deleteCollectionOnly: "حذف المجموعة فقط",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "جاري تنزيل الفيديو",
|
||||
downloadStartedSuccessfully: "بدأ التنزيل بنجاح",
|
||||
collectionCreatedSuccessfully: "تم إنشاء المجموعة بنجاح",
|
||||
videoAddedToCollection: "تمت إضافة الفيديو إلى المجموعة",
|
||||
videoRemovedFromCollection: "تمت إزالة الفيديو من المجموعة",
|
||||
collectionDeletedSuccessfully: "تم حذف المجموعة بنجاح",
|
||||
failedToDeleteCollection: "فشل حذف المجموعة",
|
||||
deleteCollectionAndVideos: "حذف المجموعة وكل الفيديوهات",
|
||||
|
||||
// Common
|
||||
|
||||
@@ -46,7 +46,9 @@ export const de = {
|
||||
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",
|
||||
@@ -111,6 +113,15 @@ export const de = {
|
||||
removedFromQueue: "Aus der Warteschlange entfernt",
|
||||
removedFromHistory: "Aus dem Verlauf entfernt",
|
||||
status: "Status",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Video wird heruntergeladen",
|
||||
downloadStartedSuccessfully: "Download erfolgreich gestartet",
|
||||
collectionCreatedSuccessfully: "Sammlung erfolgreich erstellt",
|
||||
videoAddedToCollection: "Video zur Sammlung hinzugefügt",
|
||||
videoRemovedFromCollection: "Video aus der Sammlung entfernt",
|
||||
collectionDeletedSuccessfully: "Sammlung erfolgreich gelöscht",
|
||||
failedToDeleteCollection: "Fehler beim Löschen der Sammlung",
|
||||
progress: "Fortschritt",
|
||||
speed: "Geschwindigkeit",
|
||||
finishedAt: "Beendet am",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const en = {
|
||||
authors: "Authors",
|
||||
created: "Created",
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
@@ -247,4 +248,13 @@ export const en = {
|
||||
speed: "Speed",
|
||||
finishedAt: "Finished At",
|
||||
failed: "Failed",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Video downloading",
|
||||
downloadStartedSuccessfully: "Download started successfully",
|
||||
collectionCreatedSuccessfully: "Collection created successfully",
|
||||
videoAddedToCollection: "Video added to collection",
|
||||
videoRemovedFromCollection: "Video removed from collection",
|
||||
collectionDeletedSuccessfully: "Collection deleted successfully",
|
||||
failedToDeleteCollection: "Failed to delete collection",
|
||||
};
|
||||
|
||||
@@ -44,17 +44,28 @@ export const es = {
|
||||
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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const fr = {
|
||||
authors: "Auteurs",
|
||||
created: "Créé",
|
||||
name: "Nom",
|
||||
size: "Taille",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Supprimer la collection",
|
||||
deleteVideo: "Supprimer la vidéo",
|
||||
@@ -130,7 +131,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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ja = {
|
||||
authors: "作成者一覧",
|
||||
created: "作成日",
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
actions: "アクション",
|
||||
deleteCollection: "コレクションを削除",
|
||||
deleteVideo: "動画を削除",
|
||||
@@ -184,6 +185,15 @@ export const ja = {
|
||||
deleteCollectionOnly: "コレクションのみ削除",
|
||||
deleteCollectionAndVideos: "コレクションとすべての動画を削除",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "動画をダウンロード中",
|
||||
downloadStartedSuccessfully: "ダウンロードが正常に開始されました",
|
||||
collectionCreatedSuccessfully: "コレクションが正常に作成されました",
|
||||
videoAddedToCollection: "動画がコレクションに追加されました",
|
||||
videoRemovedFromCollection: "動画がコレクションから削除されました",
|
||||
collectionDeletedSuccessfully: "コレクションが正常に削除されました",
|
||||
failedToDeleteCollection: "コレクションの削除に失敗しました",
|
||||
|
||||
// Common
|
||||
loading: "読み込み中...",
|
||||
error: "エラー",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ko = {
|
||||
authors: "작성자 목록",
|
||||
created: "생성일",
|
||||
name: "이름",
|
||||
size: "크기",
|
||||
actions: "작업",
|
||||
deleteCollection: "컬렉션 삭제",
|
||||
deleteVideo: "동영상 삭제",
|
||||
@@ -184,6 +185,15 @@ export const ko = {
|
||||
deleteCollectionOnly: "컬렉션만 삭제",
|
||||
deleteCollectionAndVideos: "컬렉션 및 모든 동영상 삭제",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "비디오 다운로드 중",
|
||||
downloadStartedSuccessfully: "다운로드가 성공적으로 시작되었습니다",
|
||||
collectionCreatedSuccessfully: "컬렉션이 성공적으로 생성되었습니다",
|
||||
videoAddedToCollection: "비디오가 컬렉션에 추가되었습니다",
|
||||
videoRemovedFromCollection: "비디오가 컬렉션에서 제거되었습니다",
|
||||
collectionDeletedSuccessfully: "컬렉션이 성공적으로 삭제되었습니다",
|
||||
failedToDeleteCollection: "컬렉션 삭제 실패",
|
||||
|
||||
// Common
|
||||
loading: "로드 중...",
|
||||
error: "오류",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const pt = {
|
||||
authors: "Autores",
|
||||
created: "Criado",
|
||||
name: "Nome",
|
||||
size: "Tamanho",
|
||||
actions: "Ações",
|
||||
deleteCollection: "Excluir Coleção",
|
||||
deleteVideo: "Excluir Vídeo",
|
||||
@@ -149,12 +150,14 @@ export const pt = {
|
||||
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 remover 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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ru = {
|
||||
authors: "Авторы",
|
||||
created: "Создано",
|
||||
name: "Имя",
|
||||
size: "Размер",
|
||||
actions: "Действия",
|
||||
deleteCollection: "Удалить коллекцию",
|
||||
deleteVideo: "Удалить видео",
|
||||
@@ -170,6 +171,15 @@ export const ru = {
|
||||
loadingCollection: "Загрузка коллекции...",
|
||||
collectionNotFound: "Коллекция не найдена",
|
||||
noVideosInCollection: "В этой коллекции нет видео.",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Видео скачивается",
|
||||
downloadStartedSuccessfully: "Загрузка успешно началась",
|
||||
collectionCreatedSuccessfully: "Коллекция успешно создана",
|
||||
videoAddedToCollection: "Видео добавлено в коллекцию",
|
||||
videoRemovedFromCollection: "Видео удалено из коллекции",
|
||||
collectionDeletedSuccessfully: "Коллекция успешно удалена",
|
||||
failedToDeleteCollection: "Не удалось удалить коллекцию",
|
||||
back: "Назад",
|
||||
|
||||
// Author Videos
|
||||
|
||||
@@ -104,6 +104,7 @@ export const zh = {
|
||||
authors: "作者列表",
|
||||
created: "创建时间",
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
actions: "操作",
|
||||
deleteCollection: "删除合集",
|
||||
deleteVideo: "删除视频",
|
||||
@@ -172,6 +173,15 @@ export const zh = {
|
||||
noVideosInCollection: "此合集中没有视频。",
|
||||
back: "返回",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "视频下载中",
|
||||
downloadStartedSuccessfully: "下载已成功开始",
|
||||
collectionCreatedSuccessfully: "集合创建成功",
|
||||
videoAddedToCollection: "视频已添加到集合",
|
||||
videoRemovedFromCollection: "视频已从集合中移除",
|
||||
collectionDeletedSuccessfully: "集合删除成功",
|
||||
failedToDeleteCollection: "删除集合失败",
|
||||
|
||||
// Author Videos
|
||||
loadVideosError: "加载视频失败,请稍后再试。",
|
||||
unknownAuthor: "未知",
|
||||
|
||||
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);
|
||||
};
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.4",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user