25 Commits

Author SHA1 Message Date
Peifan Li
c2d6215b44 chore(release): v1.3.1 2025-11-29 10:52:04 -05:00
Peifan Li
f2b5af0912 refactor: Remove unnecessary youtubedl call arguments 2025-11-29 10:52:00 -05:00
Peifan Li
56557da2cf feat: Update versions and add support for more sites 2025-11-28 21:05:18 -05:00
Peifan Li
1d45692374 chore(release): v1.3.0 2025-11-28 20:50:17 -05:00
Peifan Li
fc070da102 refactor: Update YouTubeDownloader to YtDlpDownloader 2025-11-28 20:50:04 -05:00
Peifan Li
d1ceef9698 fix: Update backend and frontend package versions to 1.2.5 2025-11-27 20:57:31 -05:00
Peifan Li
bc9564f9bc chore(release): v1.2.5 2025-11-27 20:54:46 -05:00
Peifan Li
710e85ad5e style: Improve speed calculation and add version in footer 2025-11-27 20:54:44 -05:00
Peifan Li
bc3ab6f9ef fix: Update package versions to 1.2.4 2025-11-27 18:02:25 -05:00
Peifan Li
85d900f5f7 chore(release): v1.2.4 2025-11-27 18:00:22 -05:00
Peifan Li
6621be19fc feat: Add support for multilingual snackbar messages 2025-11-27 18:00:11 -05:00
Peifan Li
10d5423c99 fix: Update package versions to 1.2.3 2025-11-27 15:15:46 -05:00
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
54 changed files with 2150 additions and 1488 deletions

View File

@@ -1,27 +1,36 @@
# MyTube
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##)包括微博小红书x.com等。
[English](README.md)
## 在线演示
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![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

@@ -1,9 +1,13 @@
# MyTube
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
[中文](README-zh.md)
## Demo
🌐 **Try the live demo (read only): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![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.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.0.1",
"version": "1.3.0",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",

View File

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

View File

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

View File

@@ -9,12 +9,12 @@ import * as downloadService from "../services/downloadService";
import { getVideoDuration } from "../services/metadataService";
import * as storageService from "../services/storageService";
import {
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
} from "../utils/helpers";
// Configure Multer for file uploads
@@ -86,13 +86,12 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
console.log("Resolved shortened URL to:", videoUrl);
}
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be") || isBilibiliUrl(videoUrl) || videoUrl.includes("missav")) {
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
// Try to fetch video info for all URLs
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
} catch (err) {
console.warn("Failed to fetch video info for title, using default:", err);
@@ -236,8 +235,16 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
}
};
// Determine type
let type = 'youtube';
if (videoUrl.includes("missav")) {
type = 'missav';
} else if (isBilibiliUrl(videoUrl)) {
type = 'bilibili';
}
// Add to download manager
downloadManager.addDownload(downloadTask, downloadId, initialTitle)
downloadManager.addDownload(downloadTask, downloadId, initialTitle, videoUrl, type)
.then((result: any) => {
console.log("Download completed successfully:", result);
})
@@ -451,6 +458,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 +481,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 +664,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 +689,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

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

View File

@@ -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,124 @@ export class MissAVDownloader {
});
}
const subprocess = youtubedl.exec(m3u8Url, {
output: videoPath,
format: "mp4",
noCheckCertificates: true,
// Add headers to mimic browser
addHeader: [
'Referer:https://missav.ai/',
'User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
]
});
await new Promise<void>((resolve, reject) => {
const ffmpegArgs = [
'-user_agent', userAgent,
'-headers', 'Referer: https://missav.ai/',
'-i', m3u8Url!,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y', // Overwrite output file
videoPath
];
if (onStart) {
onStart(() => {
console.log("Killing subprocess for download:", downloadId);
subprocess.kill();
// Clean up partial files
console.log("Cleaning up partial files...");
try {
// youtube-dl creates .part files during download
const partVideoPath = `${videoPath}.part`;
const partThumbnailPath = `${thumbnailPath}.part`;
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
let totalDurationSec = 0;
if (onStart) {
onStart(() => {
console.log("Killing ffmpeg process for download:", downloadId);
ffmpeg.kill('SIGKILL');
if (fs.existsSync(partVideoPath)) {
fs.unlinkSync(partVideoPath);
console.log("Deleted partial video file:", partVideoPath);
// Cleanup
try {
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
console.log("Deleted partial video file:", videoPath);
}
if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath);
console.log("Deleted partial thumbnail file:", thumbnailPath);
}
} catch (e) {
console.error("Error cleaning up partial files:", e);
}
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
console.log("Deleted partial video file:", videoPath);
}
if (fs.existsSync(partThumbnailPath)) {
fs.unlinkSync(partThumbnailPath);
console.log("Deleted partial thumbnail file:", partThumbnailPath);
}
if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath);
console.log("Deleted partial thumbnail file:", thumbnailPath);
}
} catch (cleanupError) {
console.error("Error cleaning up partial files:", cleanupError);
}
});
}
subprocess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
const progressMatch = output.match(/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/);
if (progressMatch && downloadId) {
const percentage = parseFloat(progressMatch[1]);
const totalSize = progressMatch[2];
const speed = progressMatch[3];
storageService.updateActiveDownload(downloadId, {
progress: percentage,
totalSize: totalSize,
speed: speed
});
}
});
await subprocess;
ffmpeg.stderr.on('data', (data) => {
const output = data.toString();
// console.log("ffmpeg stderr:", output); // Uncomment for verbose debug
// Try to parse duration if not set
if (totalDurationSec === 0) {
const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (durationMatch) {
const hours = parseInt(durationMatch[1]);
const minutes = parseInt(durationMatch[2]);
const seconds = parseInt(durationMatch[3]);
totalDurationSec = hours * 3600 + minutes * 60 + seconds;
console.log("Detected total duration:", totalDurationSec);
}
}
// Parse progress
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
const bitrateMatch = output.match(/bitrate=\s*(\d+\.?\d*)kbits\/s/);
if (timeMatch && downloadId) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const currentTimeSec = hours * 3600 + minutes * 60 + seconds;
let percentage = 0;
if (totalDurationSec > 0) {
percentage = Math.min(100, (currentTimeSec / totalDurationSec) * 100);
}
let totalSizeStr = "0B";
if (sizeMatch) {
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
}
let speedStr = "0 B/s";
if (bitrateMatch) {
const bitrateKbps = parseFloat(bitrateMatch[1]);
// Convert kbits/s to KB/s (approximate, usually bitrate is bits, so /8)
// But ffmpeg reports kbits/s. 1 byte = 8 bits.
const speedKBps = bitrateKbps / 8;
if (speedKBps > 1024) {
speedStr = `${(speedKBps / 1024).toFixed(2)} MB/s`;
} else {
speedStr = `${speedKBps.toFixed(2)} KB/s`;
}
}
storageService.updateActiveDownload(downloadId, {
progress: parseFloat(percentage.toFixed(1)),
totalSize: totalSizeStr,
speed: speedStr
});
}
});
ffmpeg.on('close', (code) => {
if (code === 0) {
console.log("ffmpeg process finished successfully");
resolve();
} else {
console.error(`ffmpeg process exited with code ${code}`);
// If killed (null code) or error
if (code === null) {
// Likely killed by user, reject? Or resolve if handled?
// If killed by onStart callback, we might want to reject to stop flow
reject(new Error("Download cancelled"));
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
}
});
ffmpeg.on('error', (err) => {
console.error("Failed to start ffmpeg:", err);
reject(err);
});
});
console.log("Video download complete");
@@ -272,6 +338,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 +363,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

@@ -7,16 +7,51 @@ import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
export class YouTubeDownloader {
// Search for videos on YouTube
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
try {
console.log("Attempting to extract XiaoHongShu author from webpage...");
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000
});
const html = response.data;
// Try to find author name in the JSON data embedded in the page
// XiaoHongShu embeds data in window.__INITIAL_STATE__
const match = html.match(/"nickname":"([^"]+)"/);
if (match && match[1]) {
console.log("Found XiaoHongShu author:", match[1]);
return match[1];
}
// Alternative: try to find in user info
const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
if (userMatch && userMatch[1]) {
console.log("Found XiaoHongShu author (user):", userMatch[1]);
return userMatch[1];
}
console.log("Could not extract XiaoHongShu author from webpage");
return null;
} catch (error) {
console.error("Error extracting XiaoHongShu author:", error);
return null;
}
}
export class YtDlpDownloader {
// Search for videos (primarily for YouTube, but could be adapted)
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
// Use ytsearch for searching
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
} as any);
@@ -33,7 +68,7 @@ export class YouTubeDownloader {
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
source: "youtube",
}));
@@ -50,31 +85,30 @@ export class YouTubeDownloader {
const info = await youtubedl(url, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
} as any);
return {
title: info.title || "YouTube Video",
author: info.uploader || "YouTube User",
title: info.title || "Video",
author: info.uploader || "Unknown",
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: info.thumbnail,
};
} catch (error) {
console.error("Error fetching YouTube video info:", error);
console.error("Error fetching video info:", error);
return {
title: "YouTube Video",
author: "YouTube User",
title: "Video",
author: "Unknown",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
}
// Download YouTube video
// Download video
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected YouTube URL");
console.log("Detected URL:", videoUrl);
// Create a safe base filename (without extension)
const timestamp = Date.now();
@@ -84,36 +118,40 @@ export class YouTubeDownloader {
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
try {
// Get YouTube video info first
// Get video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
} as any);
console.log("YouTube video info:", {
console.log("Video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
extractor: info.extractor,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoTitle = info.title || "Video";
videoAuthor = info.uploader || "Unknown";
// If author is unknown and it's a XiaoHongShu video, try custom extraction
if ((!info.uploader || info.uploader === "Unknown") && info.extractor === "XiaoHongShu") {
const customAuthor = await extractXiaoHongShuAuthor(videoUrl);
if (customAuthor) {
videoAuthor = customAuthor;
}
}
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
source = info.extractor || "generic";
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
@@ -130,8 +168,8 @@ export class YouTubeDownloader {
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
// Download the YouTube video
console.log("Downloading YouTube video to:", newVideoPath);
// Download the video
console.log("Downloading video to:", newVideoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
@@ -140,20 +178,25 @@ export class YouTubeDownloader {
});
}
// Use exec to capture stdout for progress
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
// avc1 is the H.264 variant that Safari supports best
// Use Android client to avoid SABR streaming issues and JS runtime requirements
const subprocess = youtubedl.exec(videoUrl, {
// Prepare flags
const flags: any = {
output: newVideoPath,
format: "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best",
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
mergeOutputFormat: "mp4",
'extractor-args': "youtube:player_client=android",
addHeader: [
};
// Add YouTube specific flags if it's a YouTube URL
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
flags.format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best";
flags['extractor-args'] = "youtube:player_client=android";
flags.addHeader = [
'Referer:https://www.youtube.com/',
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
]
} as any);
];
}
// Use exec to capture stdout for progress
const subprocess = youtubedl.exec(videoUrl, flags);
if (onStart) {
onStart(() => {
@@ -163,7 +206,6 @@ export class YouTubeDownloader {
// Clean up partial files
console.log("Cleaning up partial files...");
try {
// youtube-dl creates .part files during download
const partVideoPath = `${newVideoPath}.part`;
const partThumbnailPath = `${newThumbnailPath}.part`;
@@ -209,12 +251,11 @@ export class YouTubeDownloader {
await subprocess;
console.log("YouTube video downloaded successfully");
console.log("Video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
@@ -242,9 +283,9 @@ export class YouTubeDownloader {
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
throw youtubeError;
} catch (error) {
console.error("Error in download process:", error);
throw error;
}
// Create metadata for the video
@@ -254,7 +295,7 @@ export class YouTubeDownloader {
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: "youtube",
source: source, // Use extracted source
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
@@ -269,12 +310,9 @@ export class YouTubeDownloader {
};
// If duration is missing from info, try to extract it from file
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
// But wait, finalVideoFilename is available.
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
try {
// Dynamic import to avoid circular dependency if any, though here it's fine
const { getVideoDuration } = await import("../../services/metadataService");
const duration = await getVideoDuration(finalVideoPath);
if (duration) {
@@ -284,6 +322,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

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

View File

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

View File

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

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

View File

@@ -1,17 +1,19 @@
{
"name": "frontend",
"version": "1.0.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.0.1",
"version": "1.3.0",
"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.3.1",
"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

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

View File

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

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

@@ -1,9 +1,6 @@
import {
Delete,
Folder,
Movie,
OndemandVideo,
YouTube
Folder
} from '@mui/icons-material';
import {
Box,
@@ -28,7 +25,7 @@ const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoCardProps {
video: Video;
collections?: Collection[];
onDeleteVideo?: (id: string) => Promise<void>;
onDeleteVideo?: (id: string) => Promise<any>;
showDeleteButton?: boolean;
disableCollectionGrouping?: boolean;
}
@@ -140,17 +137,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}
};
// Get source icon
const getSourceIcon = () => {
if (video.source === 'bilibili') {
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
} else if (video.source === 'local') {
return <Folder sx={{ color: '#4caf50' }} />; // Local green (using Folder as generic local icon, or maybe VideoFile if available)
} else if (video.source === 'missav') {
return <Movie sx={{ color: '#ff4081' }} />; // Pink for MissAV
}
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
};
return (
<>
@@ -192,9 +179,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}}
/>
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{getSourceIcon()}
</Box>
{video.partNumber && video.totalParts && video.totalParts > 1 && (
<Chip
@@ -276,7 +261,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
sx={{
position: 'absolute',
top: 8,
right: 40, // Positioned to the left of the source icon
right: 8,
bgcolor: 'rgba(0,0,0,0.6)',
color: 'white',
opacity: 0, // Hidden by default, shown on hover

View File

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

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,139 @@ 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}`);
},
onMutate: async (id: string) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['downloadStatus'] });
// Snapshot the previous value
const previousStatus = queryClient.getQueryData(['downloadStatus']);
// Optimistically update to the new value
queryClient.setQueryData(['downloadStatus'], (old: any) => {
if (!old) return old;
return {
...old,
activeDownloads: old.activeDownloads.filter((d: any) => d.id !== id),
queuedDownloads: old.queuedDownloads.filter((d: any) => d.id !== id),
};
});
// Return a context object with the snapshotted value
return { previousStatus };
},
onError: (_err, _id, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousStatus) {
queryClient.setQueryData(['downloadStatus'], context.previousStatus);
}
showSnackbar(t('error') || 'Error');
},
onSettled: () => {
// Always refetch after error or success:
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
},
onSuccess: () => {
showSnackbar(t('downloadCancelled') || 'Download cancelled');
fetchStatus();
} catch (error) {
console.error('Error cancelling download:', error);
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 +230,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 +289,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 +324,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 +350,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);
};

View File

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

4
package-lock.json generated
View File

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

View File

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