Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
067273a44b | ||
|
|
0009f7bb96 | ||
|
|
591e85c814 | ||
|
|
610bc614b1 | ||
|
|
70defde9c2 | ||
|
|
d9bce6df02 | ||
|
|
b301a563d9 | ||
|
|
8c33d29832 | ||
|
|
3ad06c00ba |
46
README-zh.md
46
README-zh.md
@@ -4,24 +4,33 @@
|
||||
|
||||
[English](README.md)
|
||||
|
||||
## 在线演示
|
||||
|
||||
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
|
||||
|
||||

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

|
||||
|
||||
|
||||
@@ -13,16 +17,20 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
|
||||
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
|
||||
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
|
||||
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
|
||||
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
|
||||
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
|
||||
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.
|
||||
- **Search**: Search for videos locally in your library or online via YouTube.
|
||||
- **Collections**: Organize videos into custom collections for easy access.
|
||||
- **Modern UI**: Responsive, dark-themed interface with a "Back to Home" feature and glassmorphism effects.
|
||||
- **Theme Support**: Toggle between Light and Dark modes.
|
||||
- **Theme Support**: Toggle between Light and Dark modes with smooth transitions.
|
||||
- **Login Protection**: Secure your application with a password login page.
|
||||
- **Language Switching**: Support for English and Chinese languages.
|
||||
- **Internationalization**: Support for multiple languages including English, Chinese, Spanish, French, German, Japanese, Korean, Arabic, and Portuguese.
|
||||
- **Pagination**: Efficiently browse large libraries with pagination support.
|
||||
- **Video Rating**: Rate your videos with a 5-star system.
|
||||
- **Mobile Optimizations**: Mobile-friendly tags menu and optimized layout for smaller screens.
|
||||
- **Temp Files Cleanup**: Manage storage by cleaning up temporary download files directly from settings.
|
||||
- **View Modes**: Toggle between Collection View and Video View on the home page.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -32,6 +40,7 @@ mytube/
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── config/ # Configuration files
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
│ │ ├── db/ # Database migrations and setup
|
||||
│ │ ├── routes/ # API routes
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── utils/ # Utility functions
|
||||
@@ -44,12 +53,15 @@ mytube/
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── assets/ # Images and styles
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── contexts/ # React contexts
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── utils/ # Utilities and locales
|
||||
│ │ └── theme.ts # Theme configuration
|
||||
│ └── package.json # Frontend dependencies
|
||||
├── build-and-push.sh # Docker build script
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── DEPLOYMENT.md # Deployment guide
|
||||
├── CONTRIBUTING.md # Contributing guidelines
|
||||
└── package.json # Root package.json for running both apps
|
||||
```
|
||||
|
||||
@@ -99,6 +111,8 @@ Other available scripts:
|
||||
```bash
|
||||
npm run start # Start both frontend and backend in production mode
|
||||
npm run build # Build the frontend for production
|
||||
npm run lint # Run linting for frontend
|
||||
npm run lint:fix # Fix linting errors for frontend
|
||||
```
|
||||
|
||||
### Accessing the Application
|
||||
@@ -113,18 +127,41 @@ npm run build # Build the frontend for production
|
||||
- `POST /api/upload` - Upload a local video file
|
||||
- `GET /api/videos` - Get all downloaded videos
|
||||
- `GET /api/videos/:id` - Get a specific video
|
||||
- `PUT /api/videos/:id` - Update video details
|
||||
- `DELETE /api/videos/:id` - Delete a video
|
||||
- `GET /api/videos/:id/comments` - Get video comments
|
||||
- `POST /api/videos/:id/rate` - Rate a video
|
||||
- `POST /api/videos/:id/refresh-thumbnail` - Refresh video thumbnail
|
||||
- `POST /api/videos/:id/view` - Increment view count
|
||||
- `PUT /api/videos/:id/progress` - Update playback progress
|
||||
- `GET /api/search` - Search for videos online
|
||||
- `GET /api/download-status` - Get status of active downloads
|
||||
- `GET /api/check-bilibili-parts` - Check if a Bilibili video has multiple parts
|
||||
- `GET /api/check-bilibili-collection` - Check if a Bilibili URL is a collection/series
|
||||
|
||||
### Download Management
|
||||
- `POST /api/downloads/cancel/:id` - Cancel a download
|
||||
- `DELETE /api/downloads/queue/:id` - Remove from queue
|
||||
- `DELETE /api/downloads/queue` - Clear queue
|
||||
- `GET /api/downloads/history` - Get download history
|
||||
- `DELETE /api/downloads/history/:id` - Remove from history
|
||||
- `DELETE /api/downloads/history` - Clear history
|
||||
|
||||
### Collections
|
||||
- `GET /api/collections` - Get all collections
|
||||
- `POST /api/collections` - Create a new collection
|
||||
- `PUT /api/collections/:id` - Update a collection (add/remove videos)
|
||||
- `DELETE /api/collections/:id` - Delete a collection
|
||||
|
||||
### Settings & System
|
||||
- `GET /api/settings` - Get application settings
|
||||
- `POST /api/settings` - Update application settings
|
||||
- `POST /api/settings/verify-password` - Verify login password
|
||||
- `POST /api/settings/migrate` - Migrate data from JSON to SQLite
|
||||
- `POST /api/settings/delete-legacy` - Delete legacy JSON data
|
||||
- `POST /api/scan-files` - Scan for existing files
|
||||
- `POST /api/cleanup-temp-files` - Cleanup temporary download files
|
||||
|
||||
## Collections Feature
|
||||
|
||||
MyTube allows you to organize your videos into collections:
|
||||
@@ -178,6 +215,10 @@ MAX_FILE_SIZE=500000000
|
||||
|
||||
Copy the `.env.example` files in both frontend and backend directories to create your own `.env` files.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started, our development workflow, and code quality guidelines.
|
||||
|
||||
## Deployment
|
||||
|
||||
For detailed instructions on how to deploy MyTube using Docker or on QNAP Container Station, please refer to [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.3",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -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
|
||||
@@ -451,6 +451,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 +474,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 +657,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 +682,10 @@ export const updateProgress = (req: Request, res: Response): any => {
|
||||
return res.status(400).json({ error: "Progress must be a number" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, { progress });
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
progress,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const videos = sqliteTable('videos', {
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
fileSize: text('file_size'),
|
||||
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -272,6 +272,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 +297,7 @@ export class MissAVDownloader {
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -284,6 +284,16 @@ export class YouTubeDownloader {
|
||||
console.error("Failed to extract duration from downloaded file:", e);
|
||||
}
|
||||
|
||||
// Get file size
|
||||
try {
|
||||
if (fs.existsSync(finalVideoPath)) {
|
||||
const stats = fs.statSync(finalVideoPath);
|
||||
videoData.fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// Save the video
|
||||
storageService.saveVideo(videoData);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Video {
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
fileSize?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -135,8 +136,40 @@ 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.");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ar = {
|
||||
authors: "المؤلفون",
|
||||
created: "تاريخ الإنشاء",
|
||||
name: "الاسم",
|
||||
size: "الحجم",
|
||||
actions: "إجراءات",
|
||||
deleteCollection: "حذف المجموعة",
|
||||
deleteVideo: "حذف الفيديو",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const en = {
|
||||
authors: "Authors",
|
||||
created: "Created",
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
|
||||
@@ -44,7 +44,9 @@ 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ja = {
|
||||
authors: "作成者一覧",
|
||||
created: "作成日",
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
actions: "アクション",
|
||||
deleteCollection: "コレクションを削除",
|
||||
deleteVideo: "動画を削除",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ko = {
|
||||
authors: "작성자 목록",
|
||||
created: "생성일",
|
||||
name: "이름",
|
||||
size: "크기",
|
||||
actions: "작업",
|
||||
deleteCollection: "컬렉션 삭제",
|
||||
deleteVideo: "동영상 삭제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ru = {
|
||||
authors: "Авторы",
|
||||
created: "Создано",
|
||||
name: "Имя",
|
||||
size: "Размер",
|
||||
actions: "Действия",
|
||||
deleteCollection: "Удалить коллекцию",
|
||||
deleteVideo: "Удалить видео",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const zh = {
|
||||
authors: "作者列表",
|
||||
created: "创建时间",
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
actions: "操作",
|
||||
deleteCollection: "删除合集",
|
||||
deleteVideo: "删除视频",
|
||||
|
||||
135
frontend/src/utils/recommendations.ts
Normal file
135
frontend/src/utils/recommendations.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
export interface RecommendationWeights {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
collection: number;
|
||||
tags: number;
|
||||
author: number;
|
||||
filename: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: RecommendationWeights = {
|
||||
recency: 0.2,
|
||||
frequency: 0.1,
|
||||
collection: 0.4,
|
||||
tags: 0.2,
|
||||
author: 0.1,
|
||||
filename: 0.0, // Used as tie-breaker mostly
|
||||
sequence: 0.5, // Boost for the immediate next file
|
||||
};
|
||||
|
||||
export interface RecommendationContext {
|
||||
currentVideo: Video;
|
||||
allVideos: Video[];
|
||||
collections: Collection[];
|
||||
weights?: Partial<RecommendationWeights>;
|
||||
}
|
||||
|
||||
export const getRecommendations = (context: RecommendationContext): Video[] => {
|
||||
const { currentVideo, allVideos, collections, weights } = context;
|
||||
const finalWeights = { ...DEFAULT_WEIGHTS, ...weights };
|
||||
|
||||
// Filter out current video
|
||||
const candidates = allVideos.filter(v => v.id !== currentVideo.id);
|
||||
|
||||
// Pre-calculate collection membership for current video
|
||||
const currentVideoCollections = collections.filter(c => c.videos.includes(currentVideo.id)).map(c => c.id);
|
||||
|
||||
// Calculate max values for normalization
|
||||
const maxViewCount = Math.max(...allVideos.map(v => v.viewCount || 0), 1);
|
||||
const now = Date.now();
|
||||
// Normalize recency: 1.0 for now, 0.0 for very old (e.g. 1 year ago)
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Determine natural sequence
|
||||
// Sort all videos by filename/title to find the "next" one naturally
|
||||
const sortedAllVideos = [...allVideos].sort((a, b) => {
|
||||
const nameA = a.videoFilename || a.title;
|
||||
const nameB = b.videoFilename || b.title;
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
const currentIndex = sortedAllVideos.findIndex(v => v.id === currentVideo.id);
|
||||
const nextInSequenceId = currentIndex !== -1 && currentIndex < sortedAllVideos.length - 1
|
||||
? sortedAllVideos[currentIndex + 1].id
|
||||
: null;
|
||||
|
||||
const scoredCandidates = candidates.map(video => {
|
||||
let score = 0;
|
||||
|
||||
// 1. Recency (lastPlayedAt)
|
||||
// Higher score for more recently played.
|
||||
// If never played, score is 0.
|
||||
if (video.lastPlayedAt) {
|
||||
const age = Math.max(0, now - video.lastPlayedAt);
|
||||
const recencyScore = Math.max(0, 1 - (age / ONE_YEAR_MS));
|
||||
score += recencyScore * finalWeights.recency;
|
||||
}
|
||||
|
||||
// 2. Frequency (viewCount)
|
||||
const frequencyScore = (video.viewCount || 0) / maxViewCount;
|
||||
score += frequencyScore * finalWeights.frequency;
|
||||
|
||||
// 3. Collection/Series
|
||||
// Check if video is in the same collection as current video
|
||||
const videoCollections = collections.filter(c => c.videos.includes(video.id)).map(c => c.id);
|
||||
const inSameCollection = currentVideoCollections.some(id => videoCollections.includes(id));
|
||||
|
||||
// Also check seriesTitle if available
|
||||
const sameSeriesTitle = currentVideo.seriesTitle && video.seriesTitle && currentVideo.seriesTitle === video.seriesTitle;
|
||||
|
||||
if (inSameCollection || sameSeriesTitle) {
|
||||
score += 1.0 * finalWeights.collection;
|
||||
}
|
||||
|
||||
// 4. Tags
|
||||
// Jaccard index or simple overlap
|
||||
const currentTags = currentVideo.tags || [];
|
||||
const videoTags = video.tags || [];
|
||||
if (currentTags.length > 0 && videoTags.length > 0) {
|
||||
const intersection = currentTags.filter(t => videoTags.includes(t));
|
||||
const union = new Set([...currentTags, ...videoTags]);
|
||||
const tagScore = intersection.length / union.size;
|
||||
score += tagScore * finalWeights.tags;
|
||||
}
|
||||
|
||||
// 5. Author
|
||||
if (currentVideo.author && video.author && currentVideo.author === video.author) {
|
||||
score += 1.0 * finalWeights.author;
|
||||
}
|
||||
|
||||
// 6. Sequence (Natural Order)
|
||||
if (video.id === nextInSequenceId) {
|
||||
score += 1.0 * finalWeights.sequence;
|
||||
}
|
||||
|
||||
return {
|
||||
video,
|
||||
score,
|
||||
inSameCollection
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scoredCandidates.sort((a, b) => {
|
||||
if (Math.abs(a.score - b.score) > 0.001) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
// Tie-breakers
|
||||
|
||||
// 1. Same collection
|
||||
if (a.inSameCollection !== b.inSameCollection) {
|
||||
return a.inSameCollection ? -1 : 1;
|
||||
}
|
||||
|
||||
// 2. Filename natural order
|
||||
const nameA = a.video.videoFilename || a.video.title;
|
||||
const nameB = b.video.videoFilename || b.video.title;
|
||||
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return scoredCandidates.map(item => item.video);
|
||||
};
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.3",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user