Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d6215b44 | ||
|
|
f2b5af0912 | ||
|
|
56557da2cf | ||
|
|
1d45692374 | ||
|
|
fc070da102 | ||
|
|
d1ceef9698 | ||
|
|
bc9564f9bc | ||
|
|
710e85ad5e | ||
|
|
bc3ab6f9ef | ||
|
|
85d900f5f7 | ||
|
|
6621be19fc | ||
|
|
10d5423c99 | ||
|
|
067273a44b | ||
|
|
0009f7bb96 | ||
|
|
591e85c814 | ||
|
|
610bc614b1 | ||
|
|
70defde9c2 | ||
|
|
d9bce6df02 |
@@ -1,15 +1,16 @@
|
||||
# MyTube
|
||||
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##),包括微博,小红书,x.com等。
|
||||
|
||||
[English](README.md)
|
||||
|
||||

|
||||
|
||||
## 在线演示
|
||||
|
||||
🌐 **访问在线演示(只读): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
|
||||
|
||||

|
||||
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **视频下载**:通过简单的 URL 输入下载 YouTube、Bilibili 和 MissAV 视频。
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# MyTube
|
||||
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
|
||||
|
||||
[中文](README-zh.md)
|
||||
|
||||

|
||||
|
||||
## Demo
|
||||
|
||||
🌐 **Try the live demo (read only): [https://mytube-demo.vercel.app](https://mytube-demo.vercel.app)**
|
||||
|
||||

|
||||
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.1",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const videos = sqliteTable('videos', {
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
fileSize: text('file_size'),
|
||||
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
@@ -88,6 +89,8 @@ export const downloads = sqliteTable('downloads', {
|
||||
progress: integer('progress'), // Using integer for percentage (0-100) or similar
|
||||
speed: text('speed'),
|
||||
status: text('status').notNull().default('active'), // 'active' or 'queued'
|
||||
sourceUrl: text('source_url'),
|
||||
type: text('type'),
|
||||
});
|
||||
|
||||
export const downloadHistory = sqliteTable('download_history', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import express from "express";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
|
||||
import apiRoutes from "./routes/api";
|
||||
import settingsRoutes from './routes/settingsRoutes';
|
||||
import downloadManager from "./services/downloadManager";
|
||||
import * as storageService from "./services/storageService";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
@@ -28,6 +29,9 @@ storageService.initializeStorage();
|
||||
import { runMigrations } from "./db/migrate";
|
||||
runMigrations();
|
||||
|
||||
// Initialize download manager (restore queued tasks)
|
||||
downloadManager.initialize();
|
||||
|
||||
// Serve static files
|
||||
app.use("/videos", express.static(VIDEOS_DIR));
|
||||
app.use("/images", express.static(IMAGES_DIR));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDownloadTask } from "./downloadService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
interface DownloadTask {
|
||||
@@ -7,6 +8,8 @@ interface DownloadTask {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
cancelFn?: () => void;
|
||||
sourceUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
@@ -35,6 +38,58 @@ class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the download manager and restore queued tasks
|
||||
*/
|
||||
initialize(): void {
|
||||
try {
|
||||
console.log("Initializing DownloadManager...");
|
||||
const status = storageService.getDownloadStatus();
|
||||
const queuedDownloads = status.queuedDownloads;
|
||||
|
||||
if (queuedDownloads && queuedDownloads.length > 0) {
|
||||
console.log(`Restoring ${queuedDownloads.length} queued downloads...`);
|
||||
|
||||
for (const download of queuedDownloads) {
|
||||
if (download.sourceUrl && download.type) {
|
||||
console.log(`Restoring task: ${download.title} (${download.id})`);
|
||||
|
||||
// Reconstruct the download function
|
||||
const downloadFn = createDownloadTask(
|
||||
download.type,
|
||||
download.sourceUrl,
|
||||
download.id
|
||||
);
|
||||
|
||||
// Add to queue without persisting (since it's already in DB)
|
||||
// We need to manually construct the task and push to queue
|
||||
// We can't use addDownload because it returns a promise that we can't easily attach to
|
||||
// But for restored tasks, we don't have a client waiting for the promise anyway.
|
||||
|
||||
const task: DownloadTask = {
|
||||
downloadFn,
|
||||
id: download.id,
|
||||
title: download.title,
|
||||
sourceUrl: download.sourceUrl,
|
||||
type: download.type,
|
||||
resolve: (val) => console.log(`Restored task ${download.id} completed`, val),
|
||||
reject: (err) => console.error(`Restored task ${download.id} failed`, err),
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
} else {
|
||||
console.warn(`Skipping restoration of task ${download.id} due to missing sourceUrl or type`);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger processing
|
||||
this.processQueue();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing DownloadManager:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum number of concurrent downloads
|
||||
* @param limit - Maximum number of concurrent downloads
|
||||
@@ -49,19 +104,16 @@ class DownloadManager {
|
||||
* @param downloadFn - Async function that performs the download
|
||||
* @param id - Unique ID for the download
|
||||
* @param title - Title of the video being downloaded
|
||||
* @returns - Resolves when the download is complete
|
||||
*/
|
||||
/**
|
||||
* Add a download task to the manager
|
||||
* @param downloadFn - Async function that performs the download
|
||||
* @param id - Unique ID for the download
|
||||
* @param title - Title of the video being downloaded
|
||||
* @param sourceUrl - Source URL of the video
|
||||
* @param type - Type of the download (youtube, bilibili, missav)
|
||||
* @returns - Resolves when the download is complete
|
||||
*/
|
||||
async addDownload(
|
||||
downloadFn: (registerCancel: (cancel: () => void) => void) => Promise<any>,
|
||||
id: string,
|
||||
title: string
|
||||
title: string,
|
||||
sourceUrl?: string,
|
||||
type?: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task: DownloadTask = {
|
||||
@@ -70,6 +122,8 @@ class DownloadManager {
|
||||
title,
|
||||
resolve,
|
||||
reject,
|
||||
sourceUrl,
|
||||
type,
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
@@ -107,6 +161,7 @@ class DownloadManager {
|
||||
finishedAt: Date.now(),
|
||||
status: 'failed',
|
||||
error: 'Download cancelled by user',
|
||||
sourceUrl: task.sourceUrl,
|
||||
});
|
||||
|
||||
// Clean up internal state
|
||||
@@ -152,7 +207,9 @@ class DownloadManager {
|
||||
const queuedDownloads = this.queue.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
sourceUrl: task.sourceUrl,
|
||||
type: task.type,
|
||||
}));
|
||||
storageService.setQueuedDownloads(queuedDownloads);
|
||||
}
|
||||
@@ -177,6 +234,13 @@ class DownloadManager {
|
||||
|
||||
// Update status in storage
|
||||
storageService.addActiveDownload(task.id, task.title);
|
||||
// Update with extra info if available
|
||||
if (task.sourceUrl || task.type) {
|
||||
storageService.updateActiveDownload(task.id, {
|
||||
sourceUrl: task.sourceUrl,
|
||||
type: task.type
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Starting download: ${task.title} (${task.id})`);
|
||||
@@ -211,7 +275,7 @@ class DownloadManager {
|
||||
status: 'success',
|
||||
videoPath: videoData.videoPath,
|
||||
thumbnailPath: videoData.thumbnailPath,
|
||||
sourceUrl: videoData.sourceUrl,
|
||||
sourceUrl: videoData.sourceUrl || task.sourceUrl,
|
||||
author: videoData.author,
|
||||
});
|
||||
|
||||
@@ -229,6 +293,7 @@ class DownloadManager {
|
||||
finishedAt: Date.now(),
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
sourceUrl: task.sourceUrl,
|
||||
});
|
||||
|
||||
task.reject(error);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -507,6 +507,17 @@ export class BilibiliDownloader {
|
||||
console.error("Failed to extract duration from Bilibili video:", e);
|
||||
}
|
||||
|
||||
// Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
const stats = fs.statSync(newVideoPath);
|
||||
fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
@@ -523,6 +534,7 @@ export class BilibiliDownloader {
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
addedAt: new Date().toISOString(),
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import puppeteer from "puppeteer";
|
||||
import youtubedl from "youtube-dl-exec";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { sanitizeFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
@@ -57,6 +57,10 @@ export class MissAVDownloader {
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Ensure directories exist
|
||||
fs.ensureDirSync(VIDEOS_DIR);
|
||||
fs.ensureDirSync(IMAGES_DIR);
|
||||
|
||||
const videoPath = path.join(VIDEOS_DIR, videoFilename);
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
@@ -65,10 +69,11 @@ export class MissAVDownloader {
|
||||
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
let thumbnailUrl: string | null = null;
|
||||
let thumbnailSaved = false;
|
||||
let m3u8Url: string | null = null;
|
||||
|
||||
try {
|
||||
// 1. Fetch the page content using Puppeteer to bypass Cloudflare
|
||||
console.log("Fetching MissAV page content with Puppeteer...");
|
||||
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
|
||||
console.log("Launching Puppeteer to capture m3u8 URL...");
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
@@ -78,8 +83,21 @@ export class MissAVDownloader {
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a real user agent
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
await page.setUserAgent(userAgent);
|
||||
|
||||
// Setup request listener to find m3u8
|
||||
page.on('request', (request) => {
|
||||
const reqUrl = request.url();
|
||||
if (reqUrl.includes('.m3u8') && !reqUrl.includes('preview')) {
|
||||
console.log("Found m3u8 URL via network interception:", reqUrl);
|
||||
if (!m3u8Url) {
|
||||
m3u8Url = reqUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Navigating to:", url);
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
const html = await page.content();
|
||||
@@ -99,44 +117,38 @@ export class MissAVDownloader {
|
||||
|
||||
console.log("Extracted metadata:", { title: videoTitle, thumbnail: thumbnailUrl });
|
||||
|
||||
// 3. Extract the m3u8 URL
|
||||
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
|
||||
// 3. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
if (!m3u8Url) {
|
||||
console.log("m3u8 URL not found via network, trying regex extraction...");
|
||||
|
||||
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
|
||||
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
|
||||
|
||||
// Look for the obfuscated string pattern
|
||||
// The pattern seems to be: m3u8|...|playlist|source
|
||||
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
|
||||
if (m3u8Match) {
|
||||
const matchString = m3u8Match[0];
|
||||
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
|
||||
const urlWords = cleanString.split("|");
|
||||
|
||||
if (!m3u8Match) {
|
||||
throw new Error("Could not find m3u8 URL pattern in page source");
|
||||
const videoIndex = urlWords.indexOf("video");
|
||||
if (videoIndex !== -1) {
|
||||
const protocol = urlWords[videoIndex - 1];
|
||||
const videoFormat = urlWords[videoIndex + 1];
|
||||
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
|
||||
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
|
||||
m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
|
||||
console.log("Reconstructed m3u8 URL via regex:", m3u8Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchString = m3u8Match[0];
|
||||
// Remove "m3u8|" from start and "|playlist|source" from end
|
||||
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
|
||||
const urlWords = cleanString.split("|");
|
||||
|
||||
// Find "video" index
|
||||
const videoIndex = urlWords.indexOf("video");
|
||||
if (videoIndex === -1) {
|
||||
throw new Error("Could not parse m3u8 URL structure");
|
||||
if (!m3u8Url) {
|
||||
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
|
||||
fs.writeFileSync(debugFile, html);
|
||||
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
|
||||
throw new Error("Could not find m3u8 URL in page source or network requests");
|
||||
}
|
||||
|
||||
const protocol = urlWords[videoIndex - 1];
|
||||
const videoFormat = urlWords[videoIndex + 1];
|
||||
|
||||
// Reconstruct parts
|
||||
// m3u8_url_path = "-".join((url_words[0:5])[::-1])
|
||||
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
|
||||
|
||||
// base_url_path = ".".join((url_words[5:video_index-1])[::-1])
|
||||
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
|
||||
|
||||
// formatted_url = "{0}://{1}/{2}/{3}/{4}.m3u8".format(protocol, base_url_path, m3u8_url_path, video_format, url_words[video_index])
|
||||
const m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
|
||||
|
||||
console.log("Reconstructed m3u8 URL:", m3u8Url);
|
||||
|
||||
// 4. Download the video using yt-dlp
|
||||
// 4. Download the video using ffmpeg directly
|
||||
console.log("Downloading video stream to:", videoPath);
|
||||
|
||||
if (downloadId) {
|
||||
@@ -146,70 +158,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(),
|
||||
};
|
||||
|
||||
@@ -7,16 +7,51 @@ import { sanitizeFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
|
||||
export class YouTubeDownloader {
|
||||
// Search for videos on YouTube
|
||||
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
|
||||
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
|
||||
try {
|
||||
console.log("Attempting to extract XiaoHongShu author from webpage...");
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
|
||||
// Try to find author name in the JSON data embedded in the page
|
||||
// XiaoHongShu embeds data in window.__INITIAL_STATE__
|
||||
const match = html.match(/"nickname":"([^"]+)"/);
|
||||
if (match && match[1]) {
|
||||
console.log("Found XiaoHongShu author:", match[1]);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// Alternative: try to find in user info
|
||||
const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
|
||||
if (userMatch && userMatch[1]) {
|
||||
console.log("Found XiaoHongShu author (user):", userMatch[1]);
|
||||
return userMatch[1];
|
||||
}
|
||||
|
||||
console.log("Could not extract XiaoHongShu author from webpage");
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error extracting XiaoHongShu author:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class YtDlpDownloader {
|
||||
// Search for videos (primarily for YouTube, but could be adapted)
|
||||
static async search(query: string): Promise<any[]> {
|
||||
console.log("Processing search request for query:", query);
|
||||
|
||||
// Use youtube-dl to search for videos
|
||||
// Use ytsearch for searching
|
||||
const searchResults = await youtubedl(`ytsearch5:${query}`, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
noCallHome: true,
|
||||
skipDownload: true,
|
||||
playlistEnd: 5, // Limit to 5 results
|
||||
} as any);
|
||||
@@ -33,7 +68,7 @@ export class YouTubeDownloader {
|
||||
thumbnailUrl: entry.thumbnail,
|
||||
duration: entry.duration,
|
||||
viewCount: entry.view_count,
|
||||
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
|
||||
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
|
||||
source: "youtube",
|
||||
}));
|
||||
|
||||
@@ -50,31 +85,30 @@ export class YouTubeDownloader {
|
||||
const info = await youtubedl(url, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
youtubeSkipDashManifest: true,
|
||||
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
|
||||
} as any);
|
||||
|
||||
return {
|
||||
title: info.title || "YouTube Video",
|
||||
author: info.uploader || "YouTube User",
|
||||
title: info.title || "Video",
|
||||
author: info.uploader || "Unknown",
|
||||
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: info.thumbnail,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching YouTube video info:", error);
|
||||
console.error("Error fetching video info:", error);
|
||||
return {
|
||||
title: "YouTube Video",
|
||||
author: "YouTube User",
|
||||
title: "Video",
|
||||
author: "Unknown",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Download YouTube video
|
||||
// Download video
|
||||
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
console.log("Detected YouTube URL");
|
||||
console.log("Detected URL:", videoUrl);
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -84,36 +118,40 @@ export class YouTubeDownloader {
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
|
||||
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
try {
|
||||
// Get YouTube video info first
|
||||
// Get video info first
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
youtubeSkipDashManifest: true,
|
||||
} as any);
|
||||
|
||||
console.log("YouTube video info:", {
|
||||
console.log("Video info:", {
|
||||
title: info.title,
|
||||
uploader: info.uploader,
|
||||
upload_date: info.upload_date,
|
||||
extractor: info.extractor,
|
||||
});
|
||||
|
||||
videoTitle = info.title || "YouTube Video";
|
||||
videoAuthor = info.uploader || "YouTube User";
|
||||
videoTitle = info.title || "Video";
|
||||
videoAuthor = info.uploader || "Unknown";
|
||||
|
||||
// If author is unknown and it's a XiaoHongShu video, try custom extraction
|
||||
if ((!info.uploader || info.uploader === "Unknown") && info.extractor === "XiaoHongShu") {
|
||||
const customAuthor = await extractXiaoHongShuAuthor(videoUrl);
|
||||
if (customAuthor) {
|
||||
videoAuthor = customAuthor;
|
||||
}
|
||||
}
|
||||
videoDate =
|
||||
info.upload_date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = info.thumbnail;
|
||||
source = info.extractor || "generic";
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
@@ -130,8 +168,8 @@ export class YouTubeDownloader {
|
||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
|
||||
// Download the YouTube video
|
||||
console.log("Downloading YouTube video to:", newVideoPath);
|
||||
// Download the video
|
||||
console.log("Downloading video to:", newVideoPath);
|
||||
|
||||
if (downloadId) {
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
@@ -140,20 +178,25 @@ export class YouTubeDownloader {
|
||||
});
|
||||
}
|
||||
|
||||
// Use exec to capture stdout for progress
|
||||
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
|
||||
// avc1 is the H.264 variant that Safari supports best
|
||||
// Use Android client to avoid SABR streaming issues and JS runtime requirements
|
||||
const subprocess = youtubedl.exec(videoUrl, {
|
||||
// Prepare flags
|
||||
const flags: any = {
|
||||
output: newVideoPath,
|
||||
format: "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||
mergeOutputFormat: "mp4",
|
||||
'extractor-args': "youtube:player_client=android",
|
||||
addHeader: [
|
||||
};
|
||||
|
||||
// Add YouTube specific flags if it's a YouTube URL
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||
flags.format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best";
|
||||
flags['extractor-args'] = "youtube:player_client=android";
|
||||
flags.addHeader = [
|
||||
'Referer:https://www.youtube.com/',
|
||||
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||
]
|
||||
} as any);
|
||||
];
|
||||
}
|
||||
|
||||
// Use exec to capture stdout for progress
|
||||
const subprocess = youtubedl.exec(videoUrl, flags);
|
||||
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
@@ -163,7 +206,6 @@ export class YouTubeDownloader {
|
||||
// Clean up partial files
|
||||
console.log("Cleaning up partial files...");
|
||||
try {
|
||||
// youtube-dl creates .part files during download
|
||||
const partVideoPath = `${newVideoPath}.part`;
|
||||
const partThumbnailPath = `${newThumbnailPath}.part`;
|
||||
|
||||
@@ -209,12 +251,11 @@ export class YouTubeDownloader {
|
||||
|
||||
await subprocess;
|
||||
|
||||
console.log("YouTube video downloaded successfully");
|
||||
console.log("Video downloaded successfully");
|
||||
|
||||
// Download and save the thumbnail
|
||||
thumbnailSaved = false;
|
||||
|
||||
// Download the thumbnail image
|
||||
if (thumbnailUrl) {
|
||||
try {
|
||||
console.log("Downloading thumbnail from:", thumbnailUrl);
|
||||
@@ -242,9 +283,9 @@ export class YouTubeDownloader {
|
||||
// Continue even if thumbnail download fails
|
||||
}
|
||||
}
|
||||
} catch (youtubeError) {
|
||||
console.error("Error in YouTube download process:", youtubeError);
|
||||
throw youtubeError;
|
||||
} catch (error) {
|
||||
console.error("Error in download process:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
@@ -254,7 +295,7 @@ export class YouTubeDownloader {
|
||||
author: videoAuthor || "Unknown",
|
||||
date:
|
||||
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
source: "youtube",
|
||||
source: source, // Use extracted source
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
@@ -269,12 +310,9 @@ export class YouTubeDownloader {
|
||||
};
|
||||
|
||||
// If duration is missing from info, try to extract it from file
|
||||
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
|
||||
// But wait, finalVideoFilename is available.
|
||||
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency if any, though here it's fine
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const duration = await getVideoDuration(finalVideoPath);
|
||||
if (duration) {
|
||||
@@ -284,6 +322,16 @@ export class YouTubeDownloader {
|
||||
console.error("Failed to extract duration from downloaded file:", e);
|
||||
}
|
||||
|
||||
// Get file size
|
||||
try {
|
||||
if (fs.existsSync(finalVideoPath)) {
|
||||
const stats = fs.statSync(finalVideoPath);
|
||||
videoData.fileSize = stats.size.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// Save the video
|
||||
storageService.saveVideo(videoData);
|
||||
|
||||
@@ -64,9 +64,10 @@ export const backfillDurations = async () => {
|
||||
const duration = await getVideoDuration(fsPath);
|
||||
|
||||
if (duration !== null) {
|
||||
await db.update(videos)
|
||||
db.update(videos)
|
||||
.set({ duration: duration.toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
.where(eq(videos.id, video.id))
|
||||
.run();
|
||||
console.log(`Updated duration for ${video.title}: ${duration}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function runMigration() {
|
||||
description: video.description,
|
||||
viewCount: video.viewCount,
|
||||
duration: video.duration,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.videos.count++;
|
||||
} catch (error: any) {
|
||||
console.error(`Error migrating video ${video.id}:`, error);
|
||||
@@ -96,7 +96,7 @@ export async function runMigration() {
|
||||
title: collection.title,
|
||||
createdAt: collection.createdAt || new Date().toISOString(),
|
||||
updatedAt: collection.updatedAt,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.collections.count++;
|
||||
|
||||
// Insert Collection Videos
|
||||
@@ -106,7 +106,7 @@ export async function runMigration() {
|
||||
await db.insert(collectionVideos).values({
|
||||
collectionId: collection.id,
|
||||
videoId: videoId,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
} catch (err: any) {
|
||||
console.error(`Error linking video ${videoId} to collection ${collection.id}:`, err);
|
||||
results.errors.push(`Link ${videoId}->${collection.id}: ${err.message}`);
|
||||
@@ -137,7 +137,7 @@ export async function runMigration() {
|
||||
}).onConflictDoUpdate({
|
||||
target: settings.key,
|
||||
set: { value: JSON.stringify(value) },
|
||||
});
|
||||
}).run();
|
||||
results.settings.count++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -178,7 +178,7 @@ export async function runMigration() {
|
||||
speed: download.speed,
|
||||
status: 'active',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export async function runMigration() {
|
||||
timestamp: download.timestamp,
|
||||
status: 'queued',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,9 +108,13 @@ export function sanitizeFilename(filename: string): string {
|
||||
|
||||
// Replace only unsafe characters for filesystems
|
||||
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
|
||||
return withoutHashtags
|
||||
const sanitized = withoutHashtags
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
|
||||
.replace(/\s+/g, "_"); // Replace spaces with underscores
|
||||
|
||||
// Truncate to 200 characters to avoid ENAMETOOLONG errors (filesystem limit is usually 255 bytes)
|
||||
// We use 200 to leave room for timestamp suffix and extension
|
||||
return sanitized.slice(0, 200);
|
||||
}
|
||||
|
||||
// Helper function to extract user mid from Bilibili URL
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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: "حذف الفيديو",
|
||||
@@ -182,6 +183,15 @@ export const ar = {
|
||||
deleteCollectionConfirmation: "هل أنت متأكد أنك تريد حذف المجموعة",
|
||||
collectionContains: "تحتوي هذه المجموعة على",
|
||||
deleteCollectionOnly: "حذف المجموعة فقط",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "جاري تنزيل الفيديو",
|
||||
downloadStartedSuccessfully: "بدأ التنزيل بنجاح",
|
||||
collectionCreatedSuccessfully: "تم إنشاء المجموعة بنجاح",
|
||||
videoAddedToCollection: "تمت إضافة الفيديو إلى المجموعة",
|
||||
videoRemovedFromCollection: "تمت إزالة الفيديو من المجموعة",
|
||||
collectionDeletedSuccessfully: "تم حذف المجموعة بنجاح",
|
||||
failedToDeleteCollection: "فشل حذف المجموعة",
|
||||
deleteCollectionAndVideos: "حذف المجموعة وكل الفيديوهات",
|
||||
|
||||
// Common
|
||||
|
||||
@@ -46,7 +46,9 @@ export const de = {
|
||||
delete: "Löschen", backToHome: "Zurück zur Startseite", confirmDelete: "Sind Sie sicher, dass Sie dies löschen möchten?",
|
||||
deleteSuccess: "Erfolgreich gelöscht", deleteFailed: "Löschen fehlgeschlagen", noVideos: "Keine Videos gefunden",
|
||||
noCollections: "Keine Sammlungen gefunden", searchVideos: "Videos suchen...", thumbnail: "Miniaturansicht",
|
||||
title: "Titel", author: "Autor", authors: "Autoren", created: "Erstellt", name: "Name", actions: "Aktionen",
|
||||
title: "Titel", author: "Autor", authors: "Autoren", created: "Erstellt", name: "Name",
|
||||
size: "Größe",
|
||||
actions: "Aktionen",
|
||||
deleteCollection: "Sammlung Löschen", deleteVideo: "Video Löschen", noVideosFoundMatching: "Keine Videos gefunden, die Ihrer Suche entsprechen.",
|
||||
playing: "Abspielen", paused: "Pause", next: "Weiter", previous: "Zurück", loop: "Schleife",
|
||||
autoPlayOn: "Automatische Wiedergabe Ein", autoPlayOff: "Automatische Wiedergabe Aus",
|
||||
@@ -111,6 +113,15 @@ export const de = {
|
||||
removedFromQueue: "Aus der Warteschlange entfernt",
|
||||
removedFromHistory: "Aus dem Verlauf entfernt",
|
||||
status: "Status",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Video wird heruntergeladen",
|
||||
downloadStartedSuccessfully: "Download erfolgreich gestartet",
|
||||
collectionCreatedSuccessfully: "Sammlung erfolgreich erstellt",
|
||||
videoAddedToCollection: "Video zur Sammlung hinzugefügt",
|
||||
videoRemovedFromCollection: "Video aus der Sammlung entfernt",
|
||||
collectionDeletedSuccessfully: "Sammlung erfolgreich gelöscht",
|
||||
failedToDeleteCollection: "Fehler beim Löschen der Sammlung",
|
||||
progress: "Fortschritt",
|
||||
speed: "Geschwindigkeit",
|
||||
finishedAt: "Beendet am",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const en = {
|
||||
authors: "Authors",
|
||||
created: "Created",
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
@@ -247,4 +248,13 @@ export const en = {
|
||||
speed: "Speed",
|
||||
finishedAt: "Finished At",
|
||||
failed: "Failed",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Video downloading",
|
||||
downloadStartedSuccessfully: "Download started successfully",
|
||||
collectionCreatedSuccessfully: "Collection created successfully",
|
||||
videoAddedToCollection: "Video added to collection",
|
||||
videoRemovedFromCollection: "Video removed from collection",
|
||||
collectionDeletedSuccessfully: "Collection deleted successfully",
|
||||
failedToDeleteCollection: "Failed to delete collection",
|
||||
};
|
||||
|
||||
@@ -44,17 +44,28 @@ export const es = {
|
||||
delete: "Eliminar", backToHome: "Volver a Inicio", confirmDelete: "¿Está seguro de que desea eliminar esto?",
|
||||
deleteSuccess: "Eliminado exitosamente", deleteFailed: "Error al eliminar", noVideos: "No se encontraron videos",
|
||||
noCollections: "No se encontraron colecciones", searchVideos: "Buscar videos...", thumbnail: "Miniatura",
|
||||
title: "Título", author: "Autor", authors: "Autores", created: "Creado", name: "Nombre", actions: "Acciones",
|
||||
title: "Título", author: "Autor", authors: "Autores", created: "Creado", name: "Nombre",
|
||||
size: "Tamaño",
|
||||
actions: "Acciones",
|
||||
deleteCollection: "Eliminar Colección", deleteVideo: "Eliminar Video", noVideosFoundMatching: "No se encontraron videos que coincidan con su búsqueda.",
|
||||
playing: "Reproducir", paused: "Pausar", next: "Siguiente", previous: "Anterior", loop: "Repetir",
|
||||
autoPlayOn: "Reproducción Automática Activada", autoPlayOff: "Reproducción Automática Desactivada",
|
||||
videoNotFound: "Video no encontrado", videoNotFoundOrLoaded: "Video no encontrado o no se pudo cargar.",
|
||||
deleting: "Eliminando...", addToCollection: "Agregar a Colección", originalLink: "Enlace Original",
|
||||
source: "Fuente:", addedDate: "Fecha de Agregado:", latestComments: "Últimos Comentarios",
|
||||
noComments: "No hay comentarios disponibles.", upNext: "A Continuación", noOtherVideos: "No hay otros videos disponibles",
|
||||
noComments: "No hay comentarios disponibles.", upNext: "A Continuación", noOtherVideos: "No hay otros videos disponibles",
|
||||
currentlyIn: "Actualmente en:", collectionWarning: "Agregar a una colección diferente lo eliminará de la actual.",
|
||||
addToExistingCollection: "Agregar a colección existente:", selectCollection: "Seleccionar una colección",
|
||||
add: "Agregar", createNewCollection: "Crear nueva colección:", collectionName: "Nombre de la colección",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Descargando video",
|
||||
downloadStartedSuccessfully: "Descarga iniciada exitosamente",
|
||||
collectionCreatedSuccessfully: "Colección creada exitosamente",
|
||||
videoAddedToCollection: "Video agregado a la colección",
|
||||
videoRemovedFromCollection: "Video eliminado de la colección",
|
||||
collectionDeletedSuccessfully: "Colección eliminada exitosamente",
|
||||
failedToDeleteCollection: "Error al eliminar la colección",
|
||||
create: "Crear", removeFromCollection: "Eliminar de la Colección",
|
||||
confirmRemoveFromCollection: "¿Está seguro de que desea eliminar este video de la colección?", remove: "Eliminar",
|
||||
loadingVideo: "Cargando video...", current: "(Actual)", rateThisVideo: "Calificar este video",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const fr = {
|
||||
authors: "Auteurs",
|
||||
created: "Créé",
|
||||
name: "Nom",
|
||||
size: "Taille",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Supprimer la collection",
|
||||
deleteVideo: "Supprimer la vidéo",
|
||||
@@ -130,7 +131,15 @@ export const fr = {
|
||||
upNext: "À suivre",
|
||||
noOtherVideos: "Aucune autre vidéo disponible",
|
||||
currentlyIn: "Actuellement dans :",
|
||||
collectionWarning: "L'ajout à une autre collection la supprimera de la collection actuelle.",
|
||||
collectionWarning: "L'ajout à une autre collection la supprimera de la collection.",
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Téléchargement de la vidéo",
|
||||
downloadStartedSuccessfully: "Le téléchargement a commencé avec succès",
|
||||
collectionCreatedSuccessfully: "Collection créée avec succès",
|
||||
videoAddedToCollection: "Vidéo ajoutée à la collection",
|
||||
videoRemovedFromCollection: "Vidéo retirée de la collection",
|
||||
collectionDeletedSuccessfully: "Collection supprimée avec succès",
|
||||
failedToDeleteCollection: "Échec de la suppression de la collection",
|
||||
addToExistingCollection: "Ajouter à une collection existante :",
|
||||
selectCollection: "Sélectionner une collection",
|
||||
add: "Ajouter",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ja = {
|
||||
authors: "作成者一覧",
|
||||
created: "作成日",
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
actions: "アクション",
|
||||
deleteCollection: "コレクションを削除",
|
||||
deleteVideo: "動画を削除",
|
||||
@@ -184,6 +185,15 @@ export const ja = {
|
||||
deleteCollectionOnly: "コレクションのみ削除",
|
||||
deleteCollectionAndVideos: "コレクションとすべての動画を削除",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "動画をダウンロード中",
|
||||
downloadStartedSuccessfully: "ダウンロードが正常に開始されました",
|
||||
collectionCreatedSuccessfully: "コレクションが正常に作成されました",
|
||||
videoAddedToCollection: "動画がコレクションに追加されました",
|
||||
videoRemovedFromCollection: "動画がコレクションから削除されました",
|
||||
collectionDeletedSuccessfully: "コレクションが正常に削除されました",
|
||||
failedToDeleteCollection: "コレクションの削除に失敗しました",
|
||||
|
||||
// Common
|
||||
loading: "読み込み中...",
|
||||
error: "エラー",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ko = {
|
||||
authors: "작성자 목록",
|
||||
created: "생성일",
|
||||
name: "이름",
|
||||
size: "크기",
|
||||
actions: "작업",
|
||||
deleteCollection: "컬렉션 삭제",
|
||||
deleteVideo: "동영상 삭제",
|
||||
@@ -184,6 +185,15 @@ export const ko = {
|
||||
deleteCollectionOnly: "컬렉션만 삭제",
|
||||
deleteCollectionAndVideos: "컬렉션 및 모든 동영상 삭제",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "비디오 다운로드 중",
|
||||
downloadStartedSuccessfully: "다운로드가 성공적으로 시작되었습니다",
|
||||
collectionCreatedSuccessfully: "컬렉션이 성공적으로 생성되었습니다",
|
||||
videoAddedToCollection: "비디오가 컬렉션에 추가되었습니다",
|
||||
videoRemovedFromCollection: "비디오가 컬렉션에서 제거되었습니다",
|
||||
collectionDeletedSuccessfully: "컬렉션이 성공적으로 삭제되었습니다",
|
||||
failedToDeleteCollection: "컬렉션 삭제 실패",
|
||||
|
||||
// Common
|
||||
loading: "로드 중...",
|
||||
error: "오류",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const pt = {
|
||||
authors: "Autores",
|
||||
created: "Criado",
|
||||
name: "Nome",
|
||||
size: "Tamanho",
|
||||
actions: "Ações",
|
||||
deleteCollection: "Excluir Coleção",
|
||||
deleteVideo: "Excluir Vídeo",
|
||||
@@ -149,12 +150,14 @@ export const pt = {
|
||||
titleUpdateFailed: "Falha ao atualizar título",
|
||||
refreshThumbnail: "Atualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura atualizada com sucesso",
|
||||
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
|
||||
videoUpdated: "Vídeo atualizado com sucesso",
|
||||
videoUpdateFailed: "Falha ao atualizar vídeo",
|
||||
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
|
||||
videoRemovedSuccessfully: "Vídeo removido com sucesso",
|
||||
failedToDeleteVideo: "Falha ao remover vídeo",
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Baixando vídeo",
|
||||
downloadStartedSuccessfully: "Download iniciado com sucesso",
|
||||
collectionCreatedSuccessfully: "Coleção criada com sucesso",
|
||||
videoAddedToCollection: "Vídeo adicionado à coleção",
|
||||
videoRemovedFromCollection: "Vídeo removido da coleção",
|
||||
collectionDeletedSuccessfully: "Coleção excluída com sucesso",
|
||||
failedToDeleteCollection: "Falha ao excluir coleção",
|
||||
pleaseEnterSearchTerm: "Por favor, insira um termo de pesquisa",
|
||||
failedToSearch: "Falha na pesquisa. Por favor, tente novamente.",
|
||||
searchCancelled: "Pesquisa cancelada",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ru = {
|
||||
authors: "Авторы",
|
||||
created: "Создано",
|
||||
name: "Имя",
|
||||
size: "Размер",
|
||||
actions: "Действия",
|
||||
deleteCollection: "Удалить коллекцию",
|
||||
deleteVideo: "Удалить видео",
|
||||
@@ -170,6 +171,15 @@ export const ru = {
|
||||
loadingCollection: "Загрузка коллекции...",
|
||||
collectionNotFound: "Коллекция не найдена",
|
||||
noVideosInCollection: "В этой коллекции нет видео.",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Видео скачивается",
|
||||
downloadStartedSuccessfully: "Загрузка успешно началась",
|
||||
collectionCreatedSuccessfully: "Коллекция успешно создана",
|
||||
videoAddedToCollection: "Видео добавлено в коллекцию",
|
||||
videoRemovedFromCollection: "Видео удалено из коллекции",
|
||||
collectionDeletedSuccessfully: "Коллекция успешно удалена",
|
||||
failedToDeleteCollection: "Не удалось удалить коллекцию",
|
||||
back: "Назад",
|
||||
|
||||
// Author Videos
|
||||
|
||||
@@ -104,6 +104,7 @@ export const zh = {
|
||||
authors: "作者列表",
|
||||
created: "创建时间",
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
actions: "操作",
|
||||
deleteCollection: "删除合集",
|
||||
deleteVideo: "删除视频",
|
||||
@@ -172,6 +173,15 @@ export const zh = {
|
||||
noVideosInCollection: "此合集中没有视频。",
|
||||
back: "返回",
|
||||
|
||||
// Snackbar Messages
|
||||
videoDownloading: "视频下载中",
|
||||
downloadStartedSuccessfully: "下载已成功开始",
|
||||
collectionCreatedSuccessfully: "集合创建成功",
|
||||
videoAddedToCollection: "视频已添加到集合",
|
||||
videoRemovedFromCollection: "视频已从集合中移除",
|
||||
collectionDeletedSuccessfully: "集合删除成功",
|
||||
failedToDeleteCollection: "删除集合失败",
|
||||
|
||||
// Author Videos
|
||||
loadVideosError: "加载视频失败,请稍后再试。",
|
||||
unknownAuthor: "未知",
|
||||
|
||||
135
frontend/src/utils/recommendations.ts
Normal file
135
frontend/src/utils/recommendations.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
export interface RecommendationWeights {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
collection: number;
|
||||
tags: number;
|
||||
author: number;
|
||||
filename: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: RecommendationWeights = {
|
||||
recency: 0.2,
|
||||
frequency: 0.1,
|
||||
collection: 0.4,
|
||||
tags: 0.2,
|
||||
author: 0.1,
|
||||
filename: 0.0, // Used as tie-breaker mostly
|
||||
sequence: 0.5, // Boost for the immediate next file
|
||||
};
|
||||
|
||||
export interface RecommendationContext {
|
||||
currentVideo: Video;
|
||||
allVideos: Video[];
|
||||
collections: Collection[];
|
||||
weights?: Partial<RecommendationWeights>;
|
||||
}
|
||||
|
||||
export const getRecommendations = (context: RecommendationContext): Video[] => {
|
||||
const { currentVideo, allVideos, collections, weights } = context;
|
||||
const finalWeights = { ...DEFAULT_WEIGHTS, ...weights };
|
||||
|
||||
// Filter out current video
|
||||
const candidates = allVideos.filter(v => v.id !== currentVideo.id);
|
||||
|
||||
// Pre-calculate collection membership for current video
|
||||
const currentVideoCollections = collections.filter(c => c.videos.includes(currentVideo.id)).map(c => c.id);
|
||||
|
||||
// Calculate max values for normalization
|
||||
const maxViewCount = Math.max(...allVideos.map(v => v.viewCount || 0), 1);
|
||||
const now = Date.now();
|
||||
// Normalize recency: 1.0 for now, 0.0 for very old (e.g. 1 year ago)
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Determine natural sequence
|
||||
// Sort all videos by filename/title to find the "next" one naturally
|
||||
const sortedAllVideos = [...allVideos].sort((a, b) => {
|
||||
const nameA = a.videoFilename || a.title;
|
||||
const nameB = b.videoFilename || b.title;
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
const currentIndex = sortedAllVideos.findIndex(v => v.id === currentVideo.id);
|
||||
const nextInSequenceId = currentIndex !== -1 && currentIndex < sortedAllVideos.length - 1
|
||||
? sortedAllVideos[currentIndex + 1].id
|
||||
: null;
|
||||
|
||||
const scoredCandidates = candidates.map(video => {
|
||||
let score = 0;
|
||||
|
||||
// 1. Recency (lastPlayedAt)
|
||||
// Higher score for more recently played.
|
||||
// If never played, score is 0.
|
||||
if (video.lastPlayedAt) {
|
||||
const age = Math.max(0, now - video.lastPlayedAt);
|
||||
const recencyScore = Math.max(0, 1 - (age / ONE_YEAR_MS));
|
||||
score += recencyScore * finalWeights.recency;
|
||||
}
|
||||
|
||||
// 2. Frequency (viewCount)
|
||||
const frequencyScore = (video.viewCount || 0) / maxViewCount;
|
||||
score += frequencyScore * finalWeights.frequency;
|
||||
|
||||
// 3. Collection/Series
|
||||
// Check if video is in the same collection as current video
|
||||
const videoCollections = collections.filter(c => c.videos.includes(video.id)).map(c => c.id);
|
||||
const inSameCollection = currentVideoCollections.some(id => videoCollections.includes(id));
|
||||
|
||||
// Also check seriesTitle if available
|
||||
const sameSeriesTitle = currentVideo.seriesTitle && video.seriesTitle && currentVideo.seriesTitle === video.seriesTitle;
|
||||
|
||||
if (inSameCollection || sameSeriesTitle) {
|
||||
score += 1.0 * finalWeights.collection;
|
||||
}
|
||||
|
||||
// 4. Tags
|
||||
// Jaccard index or simple overlap
|
||||
const currentTags = currentVideo.tags || [];
|
||||
const videoTags = video.tags || [];
|
||||
if (currentTags.length > 0 && videoTags.length > 0) {
|
||||
const intersection = currentTags.filter(t => videoTags.includes(t));
|
||||
const union = new Set([...currentTags, ...videoTags]);
|
||||
const tagScore = intersection.length / union.size;
|
||||
score += tagScore * finalWeights.tags;
|
||||
}
|
||||
|
||||
// 5. Author
|
||||
if (currentVideo.author && video.author && currentVideo.author === video.author) {
|
||||
score += 1.0 * finalWeights.author;
|
||||
}
|
||||
|
||||
// 6. Sequence (Natural Order)
|
||||
if (video.id === nextInSequenceId) {
|
||||
score += 1.0 * finalWeights.sequence;
|
||||
}
|
||||
|
||||
return {
|
||||
video,
|
||||
score,
|
||||
inSameCollection
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scoredCandidates.sort((a, b) => {
|
||||
if (Math.abs(a.score - b.score) > 0.001) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
// Tie-breakers
|
||||
|
||||
// 1. Same collection
|
||||
if (a.inSameCollection !== b.inSameCollection) {
|
||||
return a.inSameCollection ? -1 : 1;
|
||||
}
|
||||
|
||||
// 2. Filename natural order
|
||||
const nameA = a.video.videoFilename || a.video.title;
|
||||
const nameB = b.video.videoFilename || b.video.title;
|
||||
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return scoredCandidates.map(item => item.video);
|
||||
};
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.1",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user