18 Commits

Author SHA1 Message Date
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
40 changed files with 1093 additions and 323 deletions

View File

@@ -4,24 +4,33 @@
[English](README.md)
## 在线演示
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
## 功能特点
- **视频下载**:通过简单的 URL 输入下载 YouTube、Bilibili 和 MissAV 视频。
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
- **Bilibili 支持**支持下载单个视频、多P视频以及整个合集/系列。
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
- **搜索功能**:支持在本地库中搜索视频,或在线搜索 YouTube 视频。
- **收藏夹**:创建自定义收藏夹以整理您的视频。
- **现代化 UI**:响应式深色主题界面,包含“返回主页”功能和玻璃拟态效果。
- **主题支持**:支持在明亮和深色模式之间切换。
- **主题支持**:支持在明亮和深色模式之间切换,支持平滑过渡
- **登录保护**:通过密码登录页面保护您的应用。
- **语言切换**:支持英语中文语言切换
- **国际化**:支持多种语言,包括英语中文、西班牙语、法语、德语、日语、韩语、阿拉伯语和葡萄牙语。
- **分页功能**:支持分页浏览,高效管理大量视频。
- **视频评分**:使用 5 星评级系统为您的视频评分。
- **移动端优化**:移动端友好的标签菜单和针对小屏幕优化的布局。
- **临时文件清理**:直接从设置中清理临时下载文件以管理存储空间。
- **视图模式**:在主页上切换收藏夹视图和视频视图。
## 目录结构
@@ -31,6 +40,7 @@ mytube/
│ ├── src/ # 源代码
│ │ ├── config/ # 配置文件
│ │ ├── controllers/ # 路由控制器
│ │ ├── db/ # 数据库迁移和设置
│ │ ├── routes/ # API 路由
│ │ ├── services/ # 业务逻辑服务
│ │ ├── utils/ # 工具函数
@@ -43,12 +53,15 @@ mytube/
│ ├── src/ # 源代码
│ │ ├── assets/ # 图片和样式
│ │ ├── components/ # React 组件
│ │ ├── contexts/ # React 上下文
│ │ ├── pages/ # 页面组件
│ │ ├── utils/ # 工具和多语言文件
│ │ └── theme.ts # 主题配置
│ └── package.json # 前端依赖
├── build-and-push.sh # Docker 构建脚本
├── docker-compose.yml # Docker Compose 配置
├── DEPLOYMENT.md # 部署指南
├── CONTRIBUTING.md # 贡献指南
└── package.json # 运行两个应用的根 package.json
```
@@ -98,6 +111,8 @@ npm run dev # 以开发模式启动前端和后端
```bash
npm run start # 以生产模式启动前端和后端
npm run build # 为生产环境构建前端
npm run lint # 运行前端代码检查
npm run lint:fix # 修复前端代码检查错误
```
### 访问应用
@@ -112,18 +127,41 @@ npm run build # 为生产环境构建前端
- `POST /api/upload` - 上传本地视频文件
- `GET /api/videos` - 获取所有已下载的视频
- `GET /api/videos/:id` - 获取特定视频
- `PUT /api/videos/:id` - 更新视频详情
- `DELETE /api/videos/:id` - 删除视频
- `GET /api/videos/:id/comments` - 获取视频评论
- `POST /api/videos/:id/rate` - 评价视频
- `POST /api/videos/:id/refresh-thumbnail` - 刷新视频缩略图
- `POST /api/videos/:id/view` - 增加观看次数
- `PUT /api/videos/:id/progress` - 更新播放进度
- `GET /api/search` - 在线搜索视频
- `GET /api/download-status` - 获取当前下载状态
- `GET /api/check-bilibili-parts` - 检查 Bilibili 视频是否包含多个分P
- `GET /api/check-bilibili-collection` - 检查 Bilibili URL 是否为合集/系列
### 下载管理
- `POST /api/downloads/cancel/:id` - 取消下载
- `DELETE /api/downloads/queue/:id` - 从队列中移除
- `DELETE /api/downloads/queue` - 清空队列
- `GET /api/downloads/history` - 获取下载历史
- `DELETE /api/downloads/history/:id` - 从历史中移除
- `DELETE /api/downloads/history` - 清空历史
### 收藏夹
- `GET /api/collections` - 获取所有收藏夹
- `POST /api/collections` - 创建新收藏夹
- `PUT /api/collections/:id` - 更新收藏夹 (添加/移除视频)
- `DELETE /api/collections/:id` - 删除收藏夹
### 设置与系统
- `GET /api/settings` - 获取应用设置
- `POST /api/settings` - 更新应用设置
- `POST /api/settings/verify-password` - 验证登录密码
- `POST /api/settings/migrate` - 从 JSON 迁移数据到 SQLite
- `POST /api/settings/delete-legacy` - 删除旧的 JSON 数据
- `POST /api/scan-files` - 扫描现有文件
- `POST /api/cleanup-temp-files` - 清理临时下载文件
## 收藏夹功能
MyTube 允许您将视频整理到收藏夹中:
@@ -177,6 +215,10 @@ MAX_FILE_SIZE=500000000
复制前端和后端目录中的 `.env.example` 文件以创建您自己的 `.env` 文件。
## 贡献
我们欢迎贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解如何开始、我们的开发工作流程以及代码质量指南。
## 部署
有关如何使用 Docker 或在 QNAP Container Station 上部署 MyTube 的详细说明,请参阅 [DEPLOYMENT.md](DEPLOYMENT.md)。

View File

@@ -4,6 +4,10 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
[中文](README-zh.md)
## Demo
🌐 **Try the live demo (read only): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
![Nov-23-2025 21-19-25](https://github.com/user-attachments/assets/0f8761c9-893d-48df-8add-47f3f19357df)
@@ -13,16 +17,20 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.
- **Search**: Search for videos locally in your library or online via YouTube.
- **Collections**: Organize videos into custom collections for easy access.
- **Modern UI**: Responsive, dark-themed interface with a "Back to Home" feature and glassmorphism effects.
- **Theme Support**: Toggle between Light and Dark modes.
- **Theme Support**: Toggle between Light and Dark modes with smooth transitions.
- **Login Protection**: Secure your application with a password login page.
- **Language Switching**: Support for English and Chinese languages.
- **Internationalization**: Support for multiple languages including English, Chinese, Spanish, French, German, Japanese, Korean, Arabic, and Portuguese.
- **Pagination**: Efficiently browse large libraries with pagination support.
- **Video Rating**: Rate your videos with a 5-star system.
- **Mobile Optimizations**: Mobile-friendly tags menu and optimized layout for smaller screens.
- **Temp Files Cleanup**: Manage storage by cleaning up temporary download files directly from settings.
- **View Modes**: Toggle between Collection View and Video View on the home page.
## Directory Structure
@@ -32,6 +40,7 @@ mytube/
│ ├── src/ # Source code
│ │ ├── config/ # Configuration files
│ │ ├── controllers/ # Route controllers
│ │ ├── db/ # Database migrations and setup
│ │ ├── routes/ # API routes
│ │ ├── services/ # Business logic services
│ │ ├── utils/ # Utility functions
@@ -44,12 +53,15 @@ mytube/
│ ├── src/ # Source code
│ │ ├── assets/ # Images and styles
│ │ ├── components/ # React components
│ │ ├── contexts/ # React contexts
│ │ ├── pages/ # Page components
│ │ ├── utils/ # Utilities and locales
│ │ └── theme.ts # Theme configuration
│ └── package.json # Frontend dependencies
├── build-and-push.sh # Docker build script
├── docker-compose.yml # Docker Compose configuration
├── DEPLOYMENT.md # Deployment guide
├── CONTRIBUTING.md # Contributing guidelines
└── package.json # Root package.json for running both apps
```
@@ -99,6 +111,8 @@ Other available scripts:
```bash
npm run start # Start both frontend and backend in production mode
npm run build # Build the frontend for production
npm run lint # Run linting for frontend
npm run lint:fix # Fix linting errors for frontend
```
### Accessing the Application
@@ -113,18 +127,41 @@ npm run build # Build the frontend for production
- `POST /api/upload` - Upload a local video file
- `GET /api/videos` - Get all downloaded videos
- `GET /api/videos/:id` - Get a specific video
- `PUT /api/videos/:id` - Update video details
- `DELETE /api/videos/:id` - Delete a video
- `GET /api/videos/:id/comments` - Get video comments
- `POST /api/videos/:id/rate` - Rate a video
- `POST /api/videos/:id/refresh-thumbnail` - Refresh video thumbnail
- `POST /api/videos/:id/view` - Increment view count
- `PUT /api/videos/:id/progress` - Update playback progress
- `GET /api/search` - Search for videos online
- `GET /api/download-status` - Get status of active downloads
- `GET /api/check-bilibili-parts` - Check if a Bilibili video has multiple parts
- `GET /api/check-bilibili-collection` - Check if a Bilibili URL is a collection/series
### Download Management
- `POST /api/downloads/cancel/:id` - Cancel a download
- `DELETE /api/downloads/queue/:id` - Remove from queue
- `DELETE /api/downloads/queue` - Clear queue
- `GET /api/downloads/history` - Get download history
- `DELETE /api/downloads/history/:id` - Remove from history
- `DELETE /api/downloads/history` - Clear history
### Collections
- `GET /api/collections` - Get all collections
- `POST /api/collections` - Create a new collection
- `PUT /api/collections/:id` - Update a collection (add/remove videos)
- `DELETE /api/collections/:id` - Delete a collection
### Settings & System
- `GET /api/settings` - Get application settings
- `POST /api/settings` - Update application settings
- `POST /api/settings/verify-password` - Verify login password
- `POST /api/settings/migrate` - Migrate data from JSON to SQLite
- `POST /api/settings/delete-legacy` - Delete legacy JSON data
- `POST /api/scan-files` - Scan for existing files
- `POST /api/cleanup-temp-files` - Cleanup temporary download files
## Collections Feature
MyTube allows you to organize your videos into collections:
@@ -178,6 +215,10 @@ MAX_FILE_SIZE=500000000
Copy the `.env.example` files in both frontend and backend directories to create your own `.env` files.
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started, our development workflow, and code quality guidelines.
## Deployment
For detailed instructions on how to deploy MyTube using Docker or on QNAP Container Station, please refer to [DEPLOYMENT.md](DEPLOYMENT.md).

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.2.0",
"version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.2.0",
"version": "1.2.5",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.2.1",
"version": "1.3.0",
"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,12 +7,48 @@ 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,
@@ -33,7 +69,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",
}));
@@ -52,29 +88,29 @@ export class YouTubeDownloader {
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 +120,41 @@ 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 +171,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 +181,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 +209,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 +254,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 +286,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 +298,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 +313,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 +325,16 @@ export class YouTubeDownloader {
console.error("Failed to extract duration from downloaded file:", e);
}
// Get file size
try {
if (fs.existsSync(finalVideoPath)) {
const stats = fs.statSync(finalVideoPath);
videoData.fileSize = stats.size.toString();
}
} catch (e) {
console.error("Failed to get file size:", e);
}
// Save the video
storageService.saveVideo(videoData);

View File

@@ -2,11 +2,11 @@ import { desc, eq, lt } from "drizzle-orm";
import fs from "fs-extra";
import path from "path";
import {
DATA_DIR,
IMAGES_DIR,
STATUS_DATA_PATH,
UPLOADS_DIR,
VIDEOS_DIR,
DATA_DIR,
IMAGES_DIR,
STATUS_DATA_PATH,
UPLOADS_DIR,
VIDEOS_DIR,
} from "../config/paths";
import { db, sqlite } from "../db";
import { collections, collectionVideos, downloadHistory, downloads, settings, videos } from "../db/schema";
@@ -21,6 +21,7 @@ export interface Video {
tags?: string[];
viewCount?: number;
progress?: number;
fileSize?: string;
[key: string]: any;
}
@@ -42,6 +43,8 @@ export interface DownloadInfo {
downloadedSize?: string;
progress?: number;
speed?: string;
sourceUrl?: string;
type?: string;
}
export interface DownloadHistoryItem {
@@ -135,8 +138,56 @@ export function initializeStorage(): void {
sqlite.prepare("ALTER TABLE videos ADD COLUMN duration TEXT").run();
console.log("Migration successful: duration added.");
}
if (!columns.includes('file_size')) {
console.log("Migrating database: Adding file_size column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN file_size TEXT").run();
console.log("Migration successful: file_size added.");
}
if (!columns.includes('last_played_at')) {
console.log("Migrating database: Adding last_played_at column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN last_played_at INTEGER").run();
console.log("Migration successful: last_played_at added.");
}
// Check downloads table columns
const downloadsTableInfo = sqlite.prepare("PRAGMA table_info(downloads)").all();
const downloadsColumns = (downloadsTableInfo as any[]).map((col: any) => col.name);
if (!downloadsColumns.includes('source_url')) {
console.log("Migrating database: Adding source_url column to downloads table...");
sqlite.prepare("ALTER TABLE downloads ADD COLUMN source_url TEXT").run();
console.log("Migration successful: source_url added.");
}
if (!downloadsColumns.includes('type')) {
console.log("Migrating database: Adding type column to downloads table...");
sqlite.prepare("ALTER TABLE downloads ADD COLUMN type TEXT").run();
console.log("Migration successful: type added.");
}
// Populate fileSize for existing videos
const allVideos = db.select().from(videos).all();
let updatedCount = 0;
for (const video of allVideos) {
if (!video.fileSize && video.videoFilename) {
const videoPath = findVideoFile(video.videoFilename);
if (videoPath && fs.existsSync(videoPath)) {
const stats = fs.statSync(videoPath);
db.update(videos)
.set({ fileSize: stats.size.toString() })
.where(eq(videos.id, video.id))
.run();
updatedCount++;
}
}
}
if (updatedCount > 0) {
console.log(`Populated fileSize for ${updatedCount} videos.`);
}
} catch (error) {
console.error("Error checking/migrating viewCount/progress/duration columns:", error);
console.error("Error checking/migrating viewCount/progress/duration/fileSize columns:", error);
}
}
@@ -151,6 +202,11 @@ export function addActiveDownload(id: string, title: string): void {
title,
timestamp: now,
status: 'active',
// We might want to pass sourceUrl and type here too if available,
// but addActiveDownload signature currently only has id and title.
// We will update the signature in a separate step or let updateActiveDownload handle it.
// Actually, let's update the signature now to be safe, but that breaks callers.
// For now, let's just insert what we have.
}).onConflictDoUpdate({
target: downloads.id,
set: {
@@ -172,6 +228,8 @@ export function updateActiveDownload(id: string, updates: Partial<DownloadInfo>)
// Map fields to DB columns if necessary (though they match mostly)
if (updates.totalSize) updateData.totalSize = updates.totalSize;
if (updates.downloadedSize) updateData.downloadedSize = updates.downloadedSize;
if (updates.sourceUrl) updateData.sourceUrl = updates.sourceUrl;
if (updates.type) updateData.type = updates.type;
db.update(downloads)
.set(updateData)
@@ -205,12 +263,16 @@ export function setQueuedDownloads(queuedDownloads: DownloadInfo[]): void {
title: download.title,
timestamp: download.timestamp,
status: 'queued',
sourceUrl: download.sourceUrl,
type: download.type,
}).onConflictDoUpdate({
target: downloads.id,
set: {
title: download.title,
timestamp: download.timestamp,
status: 'queued'
status: 'queued',
sourceUrl: download.sourceUrl,
type: download.type,
}
}).run();
}
@@ -241,6 +303,8 @@ export function getDownloadStatus(): DownloadStatus {
downloadedSize: d.downloadedSize || undefined,
progress: d.progress || undefined,
speed: d.speed || undefined,
sourceUrl: d.sourceUrl || undefined,
type: d.type || undefined,
}));
const queuedDownloads = allDownloads
@@ -249,6 +313,8 @@ export function getDownloadStatus(): DownloadStatus {
id: d.id,
title: d.title,
timestamp: d.timestamp || 0,
sourceUrl: d.sourceUrl || undefined,
type: d.type || undefined,
}));
return { activeDownloads, queuedDownloads };
@@ -766,45 +832,28 @@ export function deleteCollectionWithFiles(collectionId: string): boolean {
collection.videos.forEach(videoId => {
const video = getVideoById(videoId);
if (video) {
const allCollections = getCollections();
const otherCollection = allCollections.find(c => c.videos.includes(videoId) && c.id !== collectionId);
let targetVideoDir = VIDEOS_DIR;
let targetImageDir = IMAGES_DIR;
let videoPathPrefix = '/videos';
let imagePathPrefix = '/images';
if (otherCollection) {
const otherName = otherCollection.name || otherCollection.title;
if (otherName) {
targetVideoDir = path.join(VIDEOS_DIR, otherName);
targetImageDir = path.join(IMAGES_DIR, otherName);
videoPathPrefix = `/videos/${otherName}`;
imagePathPrefix = `/images/${otherName}`;
}
}
// Move files back to root
const updates: Partial<Video> = {};
let updated = false;
if (video.videoFilename) {
const currentVideoPath = findVideoFile(video.videoFilename);
const targetVideoPath = path.join(targetVideoDir, video.videoFilename);
const targetVideoPath = path.join(VIDEOS_DIR, video.videoFilename);
if (currentVideoPath && currentVideoPath !== targetVideoPath) {
moveFile(currentVideoPath, targetVideoPath);
updates.videoPath = `${videoPathPrefix}/${video.videoFilename}`;
updates.videoPath = `/videos/${video.videoFilename}`;
updated = true;
}
}
if (video.thumbnailFilename) {
const currentImagePath = findImageFile(video.thumbnailFilename);
const targetImagePath = path.join(targetImageDir, video.thumbnailFilename);
const targetImagePath = path.join(IMAGES_DIR, video.thumbnailFilename);
if (currentImagePath && currentImagePath !== targetImagePath) {
moveFile(currentImagePath, targetImagePath);
updates.thumbnailPath = `${imagePathPrefix}/${video.thumbnailFilename}`;
updates.thumbnailPath = `/images/${video.thumbnailFilename}`;
updated = true;
}
}
@@ -816,25 +865,24 @@ export function deleteCollectionWithFiles(collectionId: string): boolean {
});
}
const success = deleteCollection(collectionId);
if (success && collectionName) {
// Delete collection directory if exists and empty
if (collectionName) {
const collectionVideoDir = path.join(VIDEOS_DIR, collectionName);
const collectionImageDir = path.join(IMAGES_DIR, collectionName);
try {
const videoCollectionDir = path.join(VIDEOS_DIR, collectionName);
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
if (fs.existsSync(videoCollectionDir) && fs.readdirSync(videoCollectionDir).length === 0) {
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionVideoDir) && fs.readdirSync(collectionVideoDir).length === 0) {
fs.rmdirSync(collectionVideoDir);
}
if (fs.existsSync(imageCollectionDir) && fs.readdirSync(imageCollectionDir).length === 0) {
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionImageDir) && fs.readdirSync(collectionImageDir).length === 0) {
fs.rmdirSync(collectionImageDir);
}
} catch (error) {
console.error("Error removing collection directories:", error);
} catch (e) {
console.error("Error removing collection directories:", e);
}
}
return success;
return deleteCollection(collectionId);
}
export function deleteCollectionAndVideos(collectionId: string): boolean {
@@ -842,32 +890,30 @@ export function deleteCollectionAndVideos(collectionId: string): boolean {
if (!collection) return false;
const collectionName = collection.name || collection.title;
// Delete all videos in the collection
if (collection.videos && collection.videos.length > 0) {
const videosToDelete = [...collection.videos];
videosToDelete.forEach(videoId => {
collection.videos.forEach(videoId => {
deleteVideo(videoId);
});
}
const success = deleteCollection(collectionId);
if (success && collectionName) {
// Delete collection directory if exists
if (collectionName) {
const collectionVideoDir = path.join(VIDEOS_DIR, collectionName);
const collectionImageDir = path.join(IMAGES_DIR, collectionName);
try {
const videoCollectionDir = path.join(VIDEOS_DIR, collectionName);
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
if (fs.existsSync(videoCollectionDir)) {
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionVideoDir)) {
fs.rmdirSync(collectionVideoDir);
}
if (fs.existsSync(imageCollectionDir)) {
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
if (fs.existsSync(collectionImageDir)) {
fs.rmdirSync(collectionImageDir);
}
} catch (error) {
console.error("Error removing collection directories:", error);
} catch (e) {
console.error("Error removing collection directories:", e);
}
}
return success;
return deleteCollection(collectionId);
}

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",

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

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

@@ -1,9 +1,6 @@
import {
Delete,
Folder,
Movie,
OndemandVideo,
YouTube
Folder
} from '@mui/icons-material';
import {
Box,
@@ -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

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

View File

@@ -3,6 +3,7 @@ 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';
@@ -64,6 +65,7 @@ const getStoredDownloadStatus = () => {
export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const { fetchVideos, handleSearch, setVideos } = useVideo();
const { fetchCollections } = useCollection();
const queryClient = useQueryClient();
@@ -207,7 +209,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setVideos(prevVideos => [response.data.video, ...prevVideos]);
}
showSnackbar('Video downloading');
showSnackbar(t('videoDownloading'));
return { success: true };
} catch (err: any) {
console.error('Error downloading video:', err);
@@ -247,7 +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);

View File

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

View File

@@ -25,6 +25,7 @@ import {
TableContainer,
TableHead,
TableRow,
TableSortLabel,
TextField,
Tooltip,
Typography
@@ -62,10 +63,79 @@ const ManagePage: React.FC = () => {
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);
@@ -209,6 +279,7 @@ const ManagePage: React.FC = () => {
<TableRow>
<TableCell>{t('name')}</TableCell>
<TableCell>{t('videos')}</TableCell>
<TableCell>{t('size')}</TableCell>
<TableCell>{t('created')}</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
</TableRow>
@@ -220,6 +291,7 @@ const ManagePage: React.FC = () => {
{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')}>
@@ -260,7 +332,7 @@ const ManagePage: React.FC = () => {
<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..."
@@ -286,8 +358,33 @@ const ManagePage: React.FC = () => {
<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>
@@ -296,12 +393,14 @@ const ManagePage: React.FC = () => {
<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"
@@ -323,6 +422,9 @@ const ManagePage: React.FC = () => {
</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 ? (
@@ -380,7 +482,22 @@ const ManagePage: React.FC = () => {
</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

@@ -13,7 +13,7 @@ import {
} 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';
@@ -25,6 +25,7 @@ import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
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;
@@ -343,8 +344,15 @@ const VideoPlayer: React.FC = () => {
);
}
// 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 }}>

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

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.2.0",
"version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.2.0",
"version": "1.2.5",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"

View File

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