16 Commits

Author SHA1 Message Date
Peifan Li
85d900f5f7 chore(release): v1.2.4 2025-11-27 18:00:22 -05:00
Peifan Li
6621be19fc feat: Add support for multilingual snackbar messages 2025-11-27 18:00:11 -05:00
Peifan Li
10d5423c99 fix: Update package versions to 1.2.3 2025-11-27 15:15:46 -05:00
Peifan Li
067273a44b chore(release): v1.2.3 2025-11-27 15:13:44 -05:00
Peifan Li
0009f7bb96 feat: Add last played timestamp to video data 2025-11-27 15:13:30 -05:00
Peifan Li
591e85c814 feat: Add file size to video metadata 2025-11-27 14:54:34 -05:00
Peifan Li
610bc614b1 Add image to README-zh.md and enhance layout
Updated README-zh.md to include an image and improve formatting.
2025-11-27 00:51:33 -05:00
Peifan Li
70defde9c2 Add image to README and enhance demo section
Updated README to include an image and improve formatting.
2025-11-27 00:51:17 -05:00
Peifan Li
d9bce6df02 fix: Update package versions to 1.2.2 2025-11-27 00:36:14 -05:00
Peifan Li
b301a563d9 chore(release): v1.2.2 2025-11-27 00:34:19 -05:00
Peifan Li
8c33d29832 feat: Add new features and optimizations 2025-11-27 00:34:09 -05:00
Peifan Li
3ad06c00ba fix: Update package versions to 1.2.1 2025-11-26 22:35:34 -05:00
Peifan Li
9c7771b232 chore(release): v1.2.1 2025-11-26 22:28:58 -05:00
Peifan Li
f418024418 feat: Introduce AuthProvider for authentication 2025-11-26 22:28:44 -05:00
Peifan Li
350cacb1f0 feat: refactor with Tanstack Query 2025-11-26 22:05:36 -05:00
Peifan Li
1fbec80917 fix: Update package versions to 1.2.0 2025-11-26 16:08:41 -05:00
47 changed files with 1973 additions and 1370 deletions

View File

@@ -4,24 +4,33 @@
[English](README.md)
## 在线演示
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
## 功能特点
- **视频下载**:通过简单的 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)。

View File

@@ -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)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
@@ -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).

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.2.0",
"version": "1.2.4",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",

View File

@@ -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" });

View File

@@ -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', {

View File

@@ -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));

View File

@@ -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);

View File

@@ -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);
}
};
}

View File

@@ -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,

View File

@@ -1,10 +1,10 @@
import axios from "axios";
import * as cheerio from "cheerio";
import { spawn } from "child_process";
import fs from "fs-extra";
import path from "path";
import puppeteer from "puppeteer";
import youtubedl from "youtube-dl-exec";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
@@ -57,6 +57,10 @@ export class MissAVDownloader {
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Ensure directories exist
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
@@ -65,10 +69,11 @@ export class MissAVDownloader {
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let thumbnailUrl: string | null = null;
let thumbnailSaved = false;
let m3u8Url: string | null = null;
try {
// 1. Fetch the page content using Puppeteer to bypass Cloudflare
console.log("Fetching MissAV page content with Puppeteer...");
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
console.log("Launching Puppeteer to capture m3u8 URL...");
const browser = await puppeteer.launch({
headless: true,
@@ -78,8 +83,21 @@ export class MissAVDownloader {
const page = await browser.newPage();
// Set a real user agent
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
await page.setUserAgent(userAgent);
// Setup request listener to find m3u8
page.on('request', (request) => {
const reqUrl = request.url();
if (reqUrl.includes('.m3u8') && !reqUrl.includes('preview')) {
console.log("Found m3u8 URL via network interception:", reqUrl);
if (!m3u8Url) {
m3u8Url = reqUrl;
}
}
});
console.log("Navigating to:", url);
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const html = await page.content();
@@ -99,44 +117,38 @@ export class MissAVDownloader {
console.log("Extracted metadata:", { title: videoTitle, thumbnail: thumbnailUrl });
// 3. Extract the m3u8 URL
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
// 3. If m3u8 URL was not found via network, try regex extraction as fallback
if (!m3u8Url) {
console.log("m3u8 URL not found via network, trying regex extraction...");
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
// Look for the obfuscated string pattern
// The pattern seems to be: m3u8|...|playlist|source
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
if (m3u8Match) {
const matchString = m3u8Match[0];
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
const urlWords = cleanString.split("|");
if (!m3u8Match) {
throw new Error("Could not find m3u8 URL pattern in page source");
const videoIndex = urlWords.indexOf("video");
if (videoIndex !== -1) {
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL via regex:", m3u8Url);
}
}
}
const matchString = m3u8Match[0];
// Remove "m3u8|" from start and "|playlist|source" from end
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
const urlWords = cleanString.split("|");
// Find "video" index
const videoIndex = urlWords.indexOf("video");
if (videoIndex === -1) {
throw new Error("Could not parse m3u8 URL structure");
if (!m3u8Url) {
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
fs.writeFileSync(debugFile, html);
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
throw new Error("Could not find m3u8 URL in page source or network requests");
}
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
// Reconstruct parts
// m3u8_url_path = "-".join((url_words[0:5])[::-1])
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
// base_url_path = ".".join((url_words[5:video_index-1])[::-1])
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
// formatted_url = "{0}://{1}/{2}/{3}/{4}.m3u8".format(protocol, base_url_path, m3u8_url_path, video_format, url_words[video_index])
const m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL:", m3u8Url);
// 4. Download the video using yt-dlp
// 4. Download the video using ffmpeg directly
console.log("Downloading video stream to:", videoPath);
if (downloadId) {
@@ -146,70 +158,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(),
};

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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 });

View File

@@ -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>

View File

@@ -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;
}

View 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;
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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",

View File

@@ -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",
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "エラー",

View File

@@ -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: "오류",

View File

@@ -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",

View File

@@ -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

View File

@@ -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: "未知",

View 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
View File

@@ -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"

View File

@@ -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": {