refact: decouple components

This commit is contained in:
Peifan Li
2025-11-25 17:56:55 -05:00
parent 12213fdf0d
commit 8e46e28288
16 changed files with 2812 additions and 2327 deletions

View File

@@ -13,6 +13,7 @@ interface Settings {
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
language: string;
tags?: string[];
}
const defaultSettings: Settings = {
@@ -111,6 +112,28 @@ export const updateSettings = async (req: Request, res: Response) => {
newSettings.password = existingSettings.password;
}
// Check for deleted tags and remove them from all videos
const existingSettings = storageService.getSettings();
const oldTags: string[] = existingSettings.tags || [];
const newTagsList: string[] = newSettings.tags || [];
const deletedTags = oldTags.filter(tag => !newTagsList.includes(tag));
if (deletedTags.length > 0) {
console.log('Tags deleted:', deletedTags);
const allVideos = storageService.getVideos();
let videosUpdatedCount = 0;
for (const video of allVideos) {
if (video.tags && video.tags.some(tag => deletedTags.includes(tag))) {
const updatedTags = video.tags.filter(tag => !deletedTags.includes(tag));
storageService.updateVideo(video.id, { tags: updatedTags });
videosUpdatedCount++;
}
}
console.log(`Removed deleted tags from ${videosUpdatedCount} videos`);
}
storageService.saveSettings(newSettings);
// Apply settings immediately where possible

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,700 @@
import axios from "axios";
import fs from "fs-extra";
import path from "path";
// @ts-ignore
import { downloadByVedioPath } from "bilibili-save-nodejs";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import {
extractBilibiliVideoId,
sanitizeFilename
} from "../../utils/helpers";
import * as storageService from "../storageService";
import { Collection, Video } from "../storageService";
export interface BilibiliVideoInfo {
title: string;
author: string;
date: string;
thumbnailUrl: string | null;
thumbnailSaved: boolean;
error?: string;
}
export interface BilibiliPartsCheckResult {
success: boolean;
videosNumber: number;
title?: string;
}
export interface BilibiliCollectionCheckResult {
success: boolean;
type: 'collection' | 'series' | 'none';
id?: number;
title?: string;
count?: number;
mid?: number;
}
export interface BilibiliVideoItem {
bvid: string;
title: string;
aid: number;
}
export interface BilibiliVideosResult {
success: boolean;
videos: BilibiliVideoItem[];
}
export interface DownloadResult {
success: boolean;
videoData?: Video;
error?: string;
}
export interface CollectionDownloadResult {
success: boolean;
collectionId?: string;
videosDownloaded?: number;
error?: string;
}
export class BilibiliDownloader {
// Helper function to download Bilibili video
static async downloadVideo(
url: string,
videoPath: string,
thumbnailPath: string
): Promise<BilibiliVideoInfo> {
const tempDir = path.join(VIDEOS_DIR, `temp_${Date.now()}_${Math.floor(Math.random() * 10000)}`);
try {
// Create a unique temporary directory for the download
fs.ensureDirSync(tempDir);
console.log("Downloading Bilibili video to temp directory:", tempDir);
// Download the video using the package
await downloadByVedioPath({
url: url,
type: "mp4",
folder: tempDir,
});
console.log("Download completed, checking for video file");
// Find the downloaded file
const files = fs.readdirSync(tempDir);
console.log("Files in temp directory:", files);
const videoFile = files.find((file: string) => file.endsWith(".mp4"));
if (!videoFile) {
throw new Error("Downloaded video file not found");
}
console.log("Found video file:", videoFile);
// Move the file to the desired location
const tempVideoPath = path.join(tempDir, videoFile);
fs.moveSync(tempVideoPath, videoPath, { overwrite: true });
console.log("Moved video file to:", videoPath);
// Clean up temp directory
fs.removeSync(tempDir);
// Extract video title from filename (remove extension)
const videoTitle = videoFile.replace(".mp4", "") || "Bilibili Video";
// Try to get thumbnail from Bilibili
let thumbnailSaved = false;
let thumbnailUrl: string | null = null;
const videoId = extractBilibiliVideoId(url);
console.log("Extracted video ID:", videoId);
if (videoId) {
try {
// Try to get video info from Bilibili API
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Fetching video info from API:", apiUrl);
const response = await axios.get(apiUrl);
if (response.data && response.data.data) {
const videoInfo = response.data.data;
thumbnailUrl = videoInfo.pic;
console.log("Got video info from API:", {
title: videoInfo.title,
author: videoInfo.owner?.name,
thumbnailUrl: thumbnailUrl,
});
if (thumbnailUrl) {
// Download thumbnail
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", thumbnailPath);
return {
title: videoInfo.title || videoTitle,
author: videoInfo.owner?.name || "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: thumbnailUrl,
thumbnailSaved,
};
}
}
} catch (thumbnailError) {
console.error("Error downloading Bilibili thumbnail:", thumbnailError);
}
}
console.log("Using basic video info");
// Return basic info if we couldn't get detailed info
return {
title: videoTitle,
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
};
} catch (error: any) {
console.error("Error in downloadBilibiliVideo:", error);
// Make sure we clean up the temp directory if it exists
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
// Return a default object to prevent undefined errors
return {
title: "Bilibili Video",
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
error: error.message,
};
}
}
// Helper function to check if a Bilibili video has multiple parts
static async checkVideoParts(videoId: string): Promise<BilibiliPartsCheckResult> {
try {
// Try to get video info from Bilibili API
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Fetching video info from API to check parts:", apiUrl);
const response = await axios.get(apiUrl);
if (response.data && response.data.data) {
const videoInfo = response.data.data;
const videosNumber = videoInfo.videos || 1;
console.log(`Bilibili video has ${videosNumber} parts`);
return {
success: true,
videosNumber,
title: videoInfo.title || "Bilibili Video",
};
}
return { success: false, videosNumber: 1 };
} catch (error) {
console.error("Error checking Bilibili video parts:", error);
return { success: false, videosNumber: 1 };
}
}
// Helper function to check if a Bilibili video belongs to a collection or series
static async checkCollectionOrSeries(videoId: string): Promise<BilibiliCollectionCheckResult> {
try {
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Checking if video belongs to collection/series:", apiUrl);
const response = await axios.get(apiUrl, {
headers: {
'Referer': 'https://www.bilibili.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
if (response.data && response.data.data) {
const videoInfo = response.data.data;
const mid = videoInfo.owner?.mid;
// Check for collection (ugc_season)
if (videoInfo.ugc_season) {
const season = videoInfo.ugc_season;
console.log(`Video belongs to collection: ${season.title}`);
return {
success: true,
type: 'collection',
id: season.id,
title: season.title,
count: season.ep_count || 0,
mid: mid
};
}
// If no collection found, return none
return { success: true, type: 'none' };
}
return { success: false, type: 'none' };
} catch (error) {
console.error("Error checking collection/series:", error);
return { success: false, type: 'none' };
}
}
// Helper function to get all videos from a Bilibili collection
static async getCollectionVideos(mid: number, seasonId: number): Promise<BilibiliVideosResult> {
try {
const allVideos: BilibiliVideoItem[] = [];
let pageNum = 1;
const pageSize = 30;
let hasMore = true;
console.log(`Fetching collection videos for mid=${mid}, season_id=${seasonId}`);
while (hasMore) {
const apiUrl = `https://api.bilibili.com/x/polymer/web-space/seasons_archives_list`;
const params = {
mid: mid,
season_id: seasonId,
page_num: pageNum,
page_size: pageSize,
sort_reverse: false
};
console.log(`Fetching page ${pageNum} of collection...`);
const response = await axios.get(apiUrl, {
params,
headers: {
'Referer': 'https://www.bilibili.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
if (response.data && response.data.data) {
const data = response.data.data;
const archives = data.archives || [];
console.log(`Got ${archives.length} videos from page ${pageNum}`);
archives.forEach((video: any) => {
allVideos.push({
bvid: video.bvid,
title: video.title,
aid: video.aid
});
});
// Check if there are more pages
const total = data.page?.total || 0;
hasMore = allVideos.length < total;
pageNum++;
} else {
hasMore = false;
}
}
console.log(`Total videos in collection: ${allVideos.length}`);
return { success: true, videos: allVideos };
} catch (error) {
console.error("Error fetching collection videos:", error);
return { success: false, videos: [] };
}
}
// Helper function to get all videos from a Bilibili series
static async getSeriesVideos(mid: number, seriesId: number): Promise<BilibiliVideosResult> {
try {
const allVideos: BilibiliVideoItem[] = [];
let pageNum = 1;
const pageSize = 30;
let hasMore = true;
console.log(`Fetching series videos for mid=${mid}, series_id=${seriesId}`);
while (hasMore) {
const apiUrl = `https://api.bilibili.com/x/series/archives`;
const params = {
mid: mid,
series_id: seriesId,
pn: pageNum,
ps: pageSize
};
console.log(`Fetching page ${pageNum} of series...`);
const response = await axios.get(apiUrl, {
params,
headers: {
'Referer': 'https://www.bilibili.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
if (response.data && response.data.data) {
const data = response.data.data;
const archives = data.archives || [];
console.log(`Got ${archives.length} videos from page ${pageNum}`);
archives.forEach((video: any) => {
allVideos.push({
bvid: video.bvid,
title: video.title,
aid: video.aid
});
});
// Check if there are more pages
const page = data.page || {};
hasMore = archives.length === pageSize && allVideos.length < (page.total || 0);
pageNum++;
} else {
hasMore = false;
}
}
console.log(`Total videos in series: ${allVideos.length}`);
return { success: true, videos: allVideos };
} catch (error) {
console.error("Error fetching series videos:", error);
return { success: false, videos: [] };
}
}
// Helper function to download a single Bilibili part
static async downloadSinglePart(
url: string,
partNumber: number,
totalParts: number,
seriesTitle: string
): Promise<DownloadResult> {
try {
console.log(
`Downloading Bilibili part ${partNumber}/${totalParts}: ${url}`
);
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
// Download Bilibili video
const bilibiliInfo = await BilibiliDownloader.downloadVideo(
url,
videoPath,
thumbnailPath
);
if (!bilibiliInfo) {
throw new Error("Failed to get Bilibili video info");
}
console.log("Bilibili download info:", bilibiliInfo);
// For multi-part videos, include the part number in the title
videoTitle =
totalParts > 1
? `${seriesTitle} - Part ${partNumber}/${totalParts}`
: bilibiliInfo.title || "Bilibili Video";
videoAuthor = bilibiliInfo.author || "Bilibili User";
videoDate =
bilibiliInfo.date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = bilibiliInfo.thumbnailUrl;
thumbnailSaved = bilibiliInfo.thumbnailSaved;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(videoTitle)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Rename the files
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
console.log("Renamed video file to:", newVideoFilename);
finalVideoFilename = newVideoFilename;
} else {
console.log("Video file not found at:", videoPath);
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
console.log("Renamed thumbnail file to:", newThumbnailFilename);
finalThumbnailFilename = newThumbnailFilename;
}
// Create metadata for the video
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle,
author: videoAuthor,
date: videoDate,
source: "bilibili",
sourceUrl: url,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
addedAt: new Date().toISOString(),
partNumber: partNumber,
totalParts: totalParts,
seriesTitle: seriesTitle,
createdAt: new Date().toISOString(),
};
// Save the video using storage service
storageService.saveVideo(videoData);
console.log(`Part ${partNumber}/${totalParts} added to database`);
return { success: true, videoData };
} catch (error: any) {
console.error(
`Error downloading Bilibili part ${partNumber}/${totalParts}:`,
error
);
return { success: false, error: error.message };
}
}
// Helper function to download all videos from a Bilibili collection or series
static async downloadCollection(
collectionInfo: BilibiliCollectionCheckResult,
collectionName: string,
downloadId: string
): Promise<CollectionDownloadResult> {
try {
const { type, id, mid, title, count } = collectionInfo;
console.log(`Starting download of ${type}: ${title} (${count} videos)`);
// Add to active downloads
if (downloadId) {
storageService.addActiveDownload(
downloadId,
`Downloading ${type}: ${title}`
);
}
// Fetch all videos from the collection/series
let videosResult: BilibiliVideosResult;
if (type === 'collection' && mid && id) {
videosResult = await BilibiliDownloader.getCollectionVideos(mid, id);
} else if (type === 'series' && mid && id) {
videosResult = await BilibiliDownloader.getSeriesVideos(mid, id);
} else {
throw new Error(`Unknown type: ${type}`);
}
if (!videosResult.success || videosResult.videos.length === 0) {
throw new Error(`Failed to fetch videos from ${type}`);
}
const videos = videosResult.videos;
console.log(`Found ${videos.length} videos to download`);
// Create a MyTube collection for these videos
const mytubeCollection: Collection = {
id: Date.now().toString(),
name: collectionName || title || "Collection",
videos: [],
createdAt: new Date().toISOString(),
title: collectionName || title || "Collection",
};
storageService.saveCollection(mytubeCollection);
const mytubeCollectionId = mytubeCollection.id;
console.log(`Created MyTube collection: ${mytubeCollection.name}`);
// Download each video sequentially
for (let i = 0; i < videos.length; i++) {
const video = videos[i];
const videoNumber = i + 1;
// Update status
if (downloadId) {
storageService.addActiveDownload(
downloadId,
`Downloading ${videoNumber}/${videos.length}: ${video.title}`
);
}
console.log(`Downloading video ${videoNumber}/${videos.length}: ${video.title}`);
// Construct video URL
const videoUrl = `https://www.bilibili.com/video/${video.bvid}`;
try {
// Download this video
const result = await BilibiliDownloader.downloadSinglePart(
videoUrl,
videoNumber,
videos.length,
title || "Collection"
);
// If download was successful, add to collection
if (result.success && result.videoData) {
storageService.atomicUpdateCollection(mytubeCollectionId, (collection) => {
collection.videos.push(result.videoData!.id);
return collection;
});
console.log(`Added video ${videoNumber}/${videos.length} to collection`);
} else {
console.error(`Failed to download video ${videoNumber}/${videos.length}: ${video.title}`);
}
} catch (videoError) {
console.error(`Error downloading video ${videoNumber}/${videos.length}:`, videoError);
// Continue with next video even if one fails
}
// Small delay between downloads to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 2000));
}
// All videos downloaded, remove from active downloads
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
console.log(`Finished downloading ${type}: ${title}`);
return {
success: true,
collectionId: mytubeCollectionId,
videosDownloaded: videos.length
};
} catch (error: any) {
console.error(`Error downloading ${collectionInfo.type}:`, error);
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
return {
success: false,
error: error.message
};
}
}
// Helper function to download remaining Bilibili parts in sequence
static async downloadRemainingParts(
baseUrl: string,
startPart: number,
totalParts: number,
seriesTitle: string,
collectionId: string,
downloadId: string
): Promise<void> {
try {
// Add to active downloads if ID is provided
if (downloadId) {
storageService.addActiveDownload(downloadId, `Downloading ${seriesTitle}`);
}
for (let part = startPart; part <= totalParts; part++) {
// Update status to show which part is being downloaded
if (downloadId) {
storageService.addActiveDownload(
downloadId,
`Downloading part ${part}/${totalParts}: ${seriesTitle}`
);
}
// Construct URL for this part
const partUrl = `${baseUrl}?p=${part}`;
// Download this part
const result = await BilibiliDownloader.downloadSinglePart(
partUrl,
part,
totalParts,
seriesTitle
);
// If download was successful and we have a collection ID, add to collection
if (result.success && collectionId && result.videoData) {
try {
storageService.atomicUpdateCollection(collectionId, (collection) => {
collection.videos.push(result.videoData!.id);
return collection;
});
console.log(
`Added part ${part}/${totalParts} to collection ${collectionId}`
);
} catch (collectionError) {
console.error(
`Error adding part ${part}/${totalParts} to collection:`,
collectionError
);
}
}
// Small delay between downloads to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// All parts downloaded, remove from active downloads
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
console.log(
`All ${totalParts} parts of "${seriesTitle}" downloaded successfully`
);
} catch (error) {
console.error("Error downloading remaining Bilibili parts:", error);
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
}
}
}

View File

@@ -0,0 +1,220 @@
import axios from "axios";
import * as cheerio from "cheerio";
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 { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
export class MissAVDownloader {
// Helper function to download MissAV video
static async downloadVideo(url: string, downloadId?: string): Promise<Video> {
console.log("Detected MissAV URL:", url);
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle = "MissAV Video";
let videoAuthor = "MissAV";
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let thumbnailUrl: string | null = null;
let thumbnailSaved = false;
try {
// 1. Fetch the page content using Puppeteer to bypass Cloudflare
console.log("Fetching MissAV page content with Puppeteer...");
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
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');
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const html = await page.content();
await browser.close();
// 2. Extract metadata using cheerio
const $ = cheerio.load(html);
const pageTitle = $('meta[property="og:title"]').attr('content');
if (pageTitle) {
videoTitle = pageTitle;
}
const ogImage = $('meta[property="og:image"]').attr('content');
if (ogImage) {
thumbnailUrl = ogImage;
}
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
// Look for the obfuscated string pattern
// The pattern seems to be: m3u8|...|playlist|source
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
if (!m3u8Match) {
throw new Error("Could not find m3u8 URL pattern in page source");
}
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");
}
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
console.log("Downloading video stream to:", videoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0
});
}
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'
]
});
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;
console.log("Video download complete");
// 5. Download thumbnail
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved");
} catch (err) {
console.error("Error downloading thumbnail:", err);
}
}
// 6. Rename files with title
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
const newSafeBaseFilename = `${sanitizeFilename(videoTitle)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
finalVideoFilename = newVideoFilename;
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
finalThumbnailFilename = newThumbnailFilename;
}
// 7. Save metadata
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle,
author: videoAuthor,
date: videoDate,
source: "missav",
sourceUrl: url,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
storageService.saveVideo(videoData);
console.log("MissAV video saved to database");
return videoData;
} catch (error: any) {
console.error("Error in downloadMissAVVideo:", error);
// Cleanup
if (fs.existsSync(videoPath)) fs.removeSync(videoPath);
if (fs.existsSync(thumbnailPath)) fs.removeSync(thumbnailPath);
throw error;
}
}
}

View File

@@ -0,0 +1,215 @@
import axios from "axios";
import fs from "fs-extra";
import path from "path";
import youtubedl from "youtube-dl-exec";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
export class YouTubeDownloader {
// Search for videos on YouTube
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
} as any);
if (!searchResults || !(searchResults as any).entries) {
return [];
}
// Format the search results
const formattedResults = (searchResults as any).entries.map((entry: any) => ({
id: entry.id,
title: entry.title,
author: entry.uploader,
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
source: "youtube",
}));
console.log(
`Found ${formattedResults.length} search results for "${query}"`
);
return formattedResults;
}
// Download YouTube video
static async downloadVideo(videoUrl: string, downloadId?: string): Promise<Video> {
console.log("Detected YouTube URL");
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
try {
// Get YouTube 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:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
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);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0
});
}
// 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, {
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",
mergeOutputFormat: "mp4",
'extractor-args': "youtube:player_client=android",
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);
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;
console.log("YouTube video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", newThumbnailPath);
} catch (thumbnailError) {
console.error("Error downloading thumbnail:", thumbnailError);
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
throw youtubeError;
}
// Create metadata for the video
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle || "Video",
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: "youtube",
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
// Save the video
storageService.saveVideo(videoData);
console.log("Video added to database");
return videoData;
}
}

View File

@@ -1,74 +1,57 @@
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
import axios from 'axios';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import './App.css';
import AnimatedRoutes from './components/AnimatedRoutes';
import BilibiliPartsModal from './components/BilibiliPartsModal';
import Footer from './components/Footer';
import Header from './components/Header';
import { CollectionProvider, useCollection } from './contexts/CollectionContext';
import { DownloadProvider, useDownload } from './contexts/DownloadContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { useSnackbar } from './contexts/SnackbarContext';
import { SnackbarProvider } from './contexts/SnackbarContext';
import { VideoProvider, useVideo } from './contexts/VideoContext';
import LoginPage from './pages/LoginPage';
import getTheme from './theme';
import { Collection, DownloadInfo, Video } from './types';
const API_URL = import.meta.env.VITE_API_URL;
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
// Helper function to get download status from localStorage
const getStoredDownloadStatus = () => {
try {
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
if (!savedStatus) return null;
function AppContent() {
const {
videos,
loading,
error,
deleteVideo,
isSearchMode,
searchTerm,
searchResults,
localSearchResults,
youtubeLoading,
handleSearch,
resetSearch,
setIsSearchMode
} = useVideo();
const parsedStatus = JSON.parse(savedStatus);
const {
collections,
createCollection,
addToCollection,
removeFromCollection,
deleteCollection
} = useCollection();
// Check if the saved status is too old (stale)
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
return parsedStatus;
} catch (error) {
console.error('Error parsing download status from localStorage:', error);
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
};
interface BilibiliPartsInfo {
videosNumber: number;
title: string;
url: string;
type: 'parts' | 'collection' | 'series';
collectionInfo: any;
}
function App() {
const { showSnackbar } = useSnackbar();
const [videos, setVideos] = useState<Video[]>([]);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [localSearchResults, setLocalSearchResults] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [collections, setCollections] = useState<Collection[]>([]);
// Bilibili multi-part video state
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState<BilibiliPartsInfo>({
videosNumber: 0,
title: '',
url: '',
type: 'parts', // 'parts', 'collection', or 'series'
collectionInfo: null // For collection/series, stores the API response
});
const [isCheckingParts, setIsCheckingParts] = useState<boolean>(false);
const {
activeDownloads,
queuedDownloads,
handleVideoSubmit,
showBilibiliPartsModal,
setShowBilibiliPartsModal,
bilibiliPartsInfo,
isCheckingParts,
handleDownloadAllBilibiliParts,
handleDownloadCurrentBilibiliPart
} = useDownload();
// Theme state
const [themeMode, setThemeMode] = useState<'light' | 'dark'>(() => {
@@ -92,117 +75,6 @@ function App() {
setThemeMode(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
// Reference to the current search request's abort controller
const searchAbortController = useRef<AbortController | null>(null);
// Get initial download status from localStorage
const initialStatus = getStoredDownloadStatus();
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
initialStatus ? initialStatus.activeDownloads || [] : []
);
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>(
initialStatus ? initialStatus.queuedDownloads || [] : []
);
// Fetch collections from the server
const fetchCollections = async () => {
try {
const response = await axios.get(`${API_URL}/collections`);
setCollections(response.data);
} catch (error) {
console.error('Error fetching collections:', error);
}
};
// Reference to track current download IDs for detecting completion
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
// Add a function to check download status from the backend
const checkBackendDownloadStatus = async () => {
try {
const response = await axios.get(`${API_URL}/download-status`);
const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data;
const newActive = backendActive || [];
const newQueued = backendQueued || [];
// Create a set of all current download IDs from the backend
const newIds = new Set<string>([
...newActive.map((d: DownloadInfo) => d.id),
...newQueued.map((d: DownloadInfo) => d.id)
]);
// Check if any ID from the previous check is missing in the new check
// This implies it finished (or failed), so we should refresh the video list
let hasCompleted = false;
if (currentDownloadIdsRef.current.size > 0) {
for (const id of currentDownloadIdsRef.current) {
if (!newIds.has(id)) {
hasCompleted = true;
break;
}
}
}
// Update the ref for the next check
currentDownloadIdsRef.current = newIds;
if (hasCompleted) {
console.log('Download completed, refreshing videos');
fetchVideos();
}
if (newActive.length > 0 || newQueued.length > 0) {
// If backend has active or queued downloads, update the local status
setActiveDownloads(newActive);
setQueuedDownloads(newQueued);
// Save to localStorage for persistence
const statusData = {
activeDownloads: newActive,
queuedDownloads: newQueued,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
// If backend says no downloads are in progress, clear the status
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
console.log('Backend says downloads are complete, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setActiveDownloads([]);
setQueuedDownloads([]);
// Refresh videos list when downloads complete (fallback)
fetchVideos();
}
}
} catch (error) {
console.error('Error checking backend download status:', error);
}
};
// Check backend download status periodically
useEffect(() => {
// Check immediately on mount
checkBackendDownloadStatus();
// Then check every 2 seconds (faster polling for better UX)
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
return () => {
clearInterval(statusCheckInterval);
};
}, [activeDownloads.length, queuedDownloads.length]); // Depend on lengths to trigger refresh when downloads finish
// Fetch collections on component mount
useEffect(() => {
fetchCollections();
}, []);
// Fetch videos on component mount
useEffect(() => {
fetchVideos();
}, []);
// Check login settings and authentication status
useEffect(() => {
const checkAuth = async () => {
@@ -242,309 +114,9 @@ function App() {
sessionStorage.setItem('mytube_authenticated', 'true');
};
// Set up localStorage and event listeners
useEffect(() => {
console.log('Setting up localStorage and event listeners');
// Set up event listener for storage changes (for multi-tab support)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === DOWNLOAD_STATUS_KEY) {
try {
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [], queuedDownloads: [] };
console.log('Storage changed, new status:', newStatus);
setActiveDownloads(newStatus.activeDownloads || []);
setQueuedDownloads(newStatus.queuedDownloads || []);
} catch (error) {
console.error('Error handling storage change:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// Set up periodic check for stale download status
const checkDownloadStatus = () => {
const status = getStoredDownloadStatus();
if (!status && (activeDownloads.length > 0 || queuedDownloads.length > 0)) {
console.log('Clearing stale download status');
setActiveDownloads([]);
setQueuedDownloads([]);
}
};
// Check every minute
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(statusCheckInterval);
};
}, [activeDownloads, queuedDownloads]);
// Update localStorage whenever activeDownloads or queuedDownloads changes
useEffect(() => {
console.log('Downloads state changed:', { active: activeDownloads.length, queued: queuedDownloads.length });
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
const statusData = {
activeDownloads,
queuedDownloads,
timestamp: Date.now()
};
console.log('Saving to localStorage:', statusData);
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
console.log('Removing from localStorage');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [activeDownloads, queuedDownloads]);
const fetchVideos = async () => {
try {
setLoading(true);
const response = await axios.get(`${API_URL}/videos`);
setVideos(response.data);
setError(null);
// Check if we need to clear a stale download status
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
const status = getStoredDownloadStatus();
if (!status) {
console.log('Clearing download status after fetching videos');
setActiveDownloads([]);
setQueuedDownloads([]);
}
}
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
try {
// Check if it's a Bilibili URL
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
setIsCheckingParts(true);
try {
// Only check for collection/series if not explicitly skipped
if (!skipCollectionCheck) {
// First, check if it's a collection or series
const collectionResponse = await axios.get(`${API_URL}/check-bilibili-collection`, {
params: { url: videoUrl }
});
if (collectionResponse.data.success && collectionResponse.data.type !== 'none') {
// It's a collection or series
const { type, title, count, id, mid } = collectionResponse.data;
console.log(`Detected Bilibili ${type}:`, title, `with ${count} videos`);
setBilibiliPartsInfo({
videosNumber: count,
title: title,
url: videoUrl,
type: type,
collectionInfo: { type, id, mid, title, count }
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
}
// If not a collection/series (or check was skipped), check if it has multiple parts
const partsResponse = await axios.get(`${API_URL}/check-bilibili-parts`, {
params: { url: videoUrl }
});
if (partsResponse.data.success && partsResponse.data.videosNumber > 1) {
// Show modal to ask user if they want to download all parts
setBilibiliPartsInfo({
videosNumber: partsResponse.data.videosNumber,
title: partsResponse.data.title,
url: videoUrl,
type: 'parts',
collectionInfo: null
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
} catch (err) {
console.error('Error checking Bilibili parts/collection:', err);
// Continue with normal download if check fails
} finally {
setIsCheckingParts(false);
}
}
// Normal download flow
setLoading(true);
// We don't set activeDownloads here immediately because the backend will queue it
// and we'll pick it up via polling
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
// If the response contains a downloadId, it means it was queued/started
if (response.data.downloadId) {
// Trigger an immediate status check
checkBackendDownloadStatus();
} else if (response.data.video) {
// If it returned a video immediately (shouldn't happen with new logic but safe to keep)
setVideos(prevVideos => [response.data.video, ...prevVideos]);
}
setIsSearchMode(false);
showSnackbar('Video downloading');
return { success: true };
} catch (err: any) {
console.error('Error downloading video:', err);
// Check if the error is because the input is a search term
if (err.response?.data?.isSearchTerm) {
// Handle as search term
return await handleSearch(err.response.data.searchTerm);
}
return {
success: false,
error: err.response?.data?.error || 'Failed to download video. Please try again.'
};
} finally {
setLoading(false);
}
};
const searchLocalVideos = (query: string) => {
if (!query || !videos.length) return [];
const searchTermLower = query.toLowerCase();
return videos.filter(video =>
video.title.toLowerCase().includes(searchTermLower) ||
video.author.toLowerCase().includes(searchTermLower)
);
};
const handleSearch = async (query: string): Promise<any> => {
// Don't enter search mode if the query is empty
if (!query || query.trim() === '') {
resetSearch();
return { success: false, error: 'Please enter a search term' };
}
try {
// Cancel any previous search request
if (searchAbortController.current) {
searchAbortController.current.abort();
}
// Create a new abort controller for this request
searchAbortController.current = new AbortController();
const signal = searchAbortController.current.signal;
// Set search mode and term immediately
setIsSearchMode(true);
setSearchTerm(query);
// Search local videos first (synchronously)
const localResults = searchLocalVideos(query);
setLocalSearchResults(localResults);
// Set loading state only for YouTube results
setYoutubeLoading(true);
// Then search YouTube asynchronously
try {
const response = await axios.get(`${API_URL}/search`, {
params: { query },
signal: signal // Pass the abort signal to axios
});
// Only update results if the request wasn't aborted
if (!signal.aborted) {
setSearchResults(response.data.results);
}
} catch (youtubeErr: any) {
// Don't handle if it's an abort error
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
// Don't set overall error if only YouTube search fails
} finally {
// Only update loading state if the request wasn't aborted
if (!signal.aborted) {
setYoutubeLoading(false);
}
}
return { success: true };
} catch (err: any) {
// Don't handle if it's an abort error
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
console.error('Error in search process:', err);
// Even if there's an error in the overall process,
// we still want to show local results if available
const localResults = searchLocalVideos(query);
if (localResults.length > 0) {
setLocalSearchResults(localResults);
setIsSearchMode(true);
setSearchTerm(query);
return { success: true };
}
return {
success: false,
error: 'Failed to search. Please try again.'
};
}
return { success: false, error: 'Search was cancelled' };
} finally {
// Only update loading state if the request wasn't aborted
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
}
}
};
// Delete a video
const handleDeleteVideo = async (id: string) => {
try {
setLoading(true);
// First, remove the video from any collections
await handleRemoveFromCollection(id);
// Then delete the video
await axios.delete(`${API_URL}/videos/${id}`);
// Update the videos state
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
setLoading(false);
showSnackbar('Video removed successfully');
return { success: true };
} catch (error) {
console.error('Error deleting video:', error);
setError('Failed to delete video');
setLoading(false);
return { success: false, error: 'Failed to delete video' };
}
};
const handleDownloadFromSearch = async (videoUrl: string) => {
try {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
// We need to stop the search mode
setIsSearchMode(false);
const result = await handleVideoSubmit(videoUrl);
@@ -555,254 +127,85 @@ function App() {
}
};
// For debugging
useEffect(() => {
console.log('Current download status:', {
activeDownloads,
queuedDownloads,
count: activeDownloads.length + queuedDownloads.length,
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
});
}, [activeDownloads, queuedDownloads]);
// Cleanup effect to abort any pending search requests when unmounting
useEffect(() => {
return () => {
// Abort any ongoing search request when component unmounts
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
};
}, []);
// Update the resetSearch function to abort any ongoing search
const resetSearch = () => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
// Reset search-related state
setIsSearchMode(false);
setSearchTerm('');
setSearchResults([]);
setLocalSearchResults([]);
setYoutubeLoading(false);
};
// Create a new collection
const handleCreateCollection = async (name: string, videoId: string) => {
try {
const response = await axios.post(`${API_URL}/collections`, {
name,
videoId
});
// Update the collections state with the new collection from the server
setCollections(prevCollections => [...prevCollections, response.data]);
showSnackbar('Collection created successfully');
return response.data;
} catch (error) {
console.error('Error creating collection:', error);
return null;
}
};
// Add a video to a collection
const handleAddToCollection = async (collectionId: string, videoId: string) => {
try {
// If videoId is provided, remove it from any other collections first
// This is handled on the server side now
// Add the video to the selected collection
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
videoId,
action: "add"
});
// Update the collections state with the updated collection from the server
setCollections(prevCollections => prevCollections.map(collection =>
collection.id === collectionId ? response.data : collection
));
showSnackbar('Video added to collection');
return response.data;
} catch (error) {
console.error('Error adding video to collection:', error);
return null;
}
};
// Remove a video from a collection
const handleRemoveFromCollection = async (videoId: string) => {
try {
// Get all collections
const collectionsWithVideo = collections.filter(collection =>
collection.videos.includes(videoId)
);
// For each collection that contains the video, remove it
for (const collection of collectionsWithVideo) {
await axios.put(`${API_URL}/collections/${collection.id}`, {
videoId,
action: "remove"
});
}
// Update the collections state
setCollections(prevCollections => prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(v => v !== videoId)
})));
showSnackbar('Video removed from collection');
return true;
} catch (error) {
console.error('Error removing video from collection:', error);
return false;
}
};
// Delete a collection
const handleDeleteCollection = async (collectionId: string, deleteVideos = false) => {
try {
// Delete the collection with optional video deletion
await axios.delete(`${API_URL}/collections/${collectionId}`, {
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
});
// Update the collections state
setCollections(prevCollections =>
prevCollections.filter(collection => collection.id !== collectionId)
);
// If videos were deleted, refresh the videos list
if (deleteVideos) {
await fetchVideos();
}
showSnackbar('Collection deleted successfully');
return { success: true };
} catch (error) {
console.error('Error deleting collection:', error);
showSnackbar('Failed to delete collection', 'error');
return { success: false, error: 'Failed to delete collection' };
}
};
// Handle downloading all parts of a Bilibili video OR all videos from a collection/series
const handleDownloadAllBilibiliParts = async (collectionName: string) => {
try {
setLoading(true);
setShowBilibiliPartsModal(false);
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: bilibiliPartsInfo.url,
downloadAllParts: !isCollection, // Only set this for multi-part videos
downloadCollection: isCollection, // Set this for collections/series
collectionInfo: isCollection ? bilibiliPartsInfo.collectionInfo : null,
collectionName
});
// Trigger immediate status check
checkBackendDownloadStatus();
// If a collection was created, refresh collections
if (response.data.collectionId) {
await fetchCollections();
}
setIsSearchMode(false);
showSnackbar('Download started successfully');
return { success: true };
} catch (err: any) {
console.error('Error downloading Bilibili parts/collection:', err);
return {
success: false,
error: err.response?.data?.error || 'Failed to download. Please try again.'
};
} finally {
setLoading(false);
}
};
// Handle downloading only the current part of a Bilibili video
const handleDownloadCurrentBilibiliPart = async () => {
setShowBilibiliPartsModal(false);
// Pass true to skip collection/series check since we already know about it
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
};
return (
<LanguageProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
{!isAuthenticated && loginRequired ? (
checkingAuth ? (
<div className="loading">Loading...</div>
) : (
<LoginPage onLoginSuccess={handleLoginSuccess} />
)
<ThemeProvider theme={theme}>
<CssBaseline />
{!isAuthenticated && loginRequired ? (
checkingAuth ? (
<div className="loading">Loading...</div>
) : (
<Router>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
queuedDownloads={queuedDownloads}
<LoginPage onLoginSuccess={handleLoginSuccess} />
)
) : (
<Router>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
queuedDownloads={queuedDownloads}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
theme={themeMode}
toggleTheme={toggleTheme}
collections={collections}
videos={videos}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
<AnimatedRoutes
videos={videos}
loading={loading}
error={error}
onDeleteVideo={deleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
theme={themeMode}
toggleTheme={toggleTheme}
collections={collections}
videos={videos}
onAddToCollection={addToCollection}
onCreateCollection={createCollection}
onRemoveFromCollection={removeFromCollection}
onDeleteCollection={deleteCollection}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
<AnimatedRoutes
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
onDeleteCollection={handleDeleteCollection}
/>
</Box>
<Footer />
</Box>
</Router>
)}
</ThemeProvider>
<Footer />
</Box>
</Router>
)}
</ThemeProvider>
);
}
function App() {
return (
<LanguageProvider>
<SnackbarProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
<AppContent />
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</SnackbarProvider>
</LanguageProvider>
);
}

View File

@@ -0,0 +1,143 @@
import {
Alert,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Collection } from '../../types';
interface CollectionModalProps {
open: boolean;
onClose: () => void;
videoCollections: Collection[];
collections: Collection[];
onAddToCollection: (collectionId: string) => Promise<void>;
onCreateCollection: (name: string) => Promise<void>;
onRemoveFromCollection: () => void;
}
const CollectionModal: React.FC<CollectionModalProps> = ({
open,
onClose,
videoCollections,
collections,
onAddToCollection,
onCreateCollection,
onRemoveFromCollection
}) => {
const { t } = useLanguage();
const [newCollectionName, setNewCollectionName] = useState<string>('');
const [selectedCollection, setSelectedCollection] = useState<string>('');
const handleClose = () => {
setNewCollectionName('');
setSelectedCollection('');
onClose();
};
const handleCreate = async () => {
if (!newCollectionName.trim()) return;
await onCreateCollection(newCollectionName);
handleClose();
};
const handleAdd = async () => {
if (!selectedCollection) return;
await onAddToCollection(selectedCollection);
handleClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('addToCollection')}</DialogTitle>
<DialogContent dividers>
{videoCollections.length > 0 && (
<Alert severity="info" sx={{ mb: 3 }} action={
<Button color="error" size="small" onClick={() => {
onRemoveFromCollection();
handleClose();
}}>
{t('remove')}
</Button>
}>
{t('currentlyIn')} <strong>{videoCollections[0].name}</strong>
<Typography variant="caption" display="block">
{t('collectionWarning')}
</Typography>
</Alert>
)}
{collections && collections.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle2" gutterBottom>{t('addToExistingCollection')}</Typography>
<Stack direction="row" spacing={2}>
<FormControl fullWidth size="small">
<InputLabel>{t('selectCollection')}</InputLabel>
<Select
value={selectedCollection}
label={t('selectCollection')}
onChange={(e) => setSelectedCollection(e.target.value)}
>
{collections.map(collection => (
<MenuItem
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
>
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? t('current') : ''}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleAdd}
disabled={!selectedCollection}
>
{t('add')}
</Button>
</Stack>
</Box>
)}
<Box>
<Typography variant="subtitle2" gutterBottom>{t('createNewCollection')}</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
size="small"
label={t('collectionName')}
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreate()}
/>
<Button
variant="contained"
onClick={handleCreate}
disabled={!newCollectionName.trim()}
>
{t('create')}
</Button>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="inherit">{t('cancel')}</Button>
</DialogActions>
</Dialog>
);
};
export default CollectionModal;

View File

@@ -0,0 +1,83 @@
import {
Avatar,
Box,
Button,
CircularProgress,
Stack,
Typography
} from '@mui/material';
import React from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Comment } from '../../types';
interface CommentsSectionProps {
comments: Comment[];
loading: boolean;
showComments: boolean;
onToggleComments: () => void;
}
const CommentsSection: React.FC<CommentsSectionProps> = ({
comments,
loading,
showComments,
onToggleComments
}) => {
const { t } = useLanguage();
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="bold">
{t('latestComments')}
</Typography>
<Button
variant="outlined"
onClick={onToggleComments}
size="small"
>
{showComments ? "Hide Comments" : "Show Comments"}
</Button>
</Box>
{showComments && (
<>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : comments.length > 0 ? (
<Stack spacing={2}>
{comments.map((comment) => (
<Box key={comment.id} sx={{ display: 'flex', gap: 2 }}>
<Avatar src={comment.avatar} alt={comment.author}>
{comment.author.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold">
{comment.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{comment.date}
</Typography>
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.content}
</Typography>
</Box>
</Box>
))}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
{t('noComments')}
</Typography>
)}
</>
)}
</Box>
);
};
export default CommentsSection;

View File

@@ -0,0 +1,201 @@
import {
FastForward,
FastRewind,
Forward10,
Fullscreen,
FullscreenExit,
Loop,
Pause,
PlayArrow,
Replay10
} from '@mui/icons-material';
import {
Box,
Button,
Stack,
Tooltip,
useMediaQuery,
useTheme
} from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
interface VideoControlsProps {
src: string;
autoPlay?: boolean;
autoLoop?: boolean;
}
const VideoControls: React.FC<VideoControlsProps> = ({
src,
autoPlay = false,
autoLoop = false
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useLanguage();
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isLooping, setIsLooping] = useState<boolean>(autoLoop);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
useEffect(() => {
if (videoRef.current) {
if (autoPlay) {
videoRef.current.autoplay = true;
// We don't set isPlaying(true) here immediately because autoplay might be blocked
// The onPlay event handler will handle the state update
}
if (autoLoop) {
videoRef.current.loop = true;
setIsLooping(true);
}
}
}, [autoPlay, autoLoop]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !isLooping;
setIsLooping(!isLooping);
}
};
const handleToggleFullscreen = () => {
const videoContainer = videoRef.current?.parentElement;
if (!videoContainer) return;
if (!document.fullscreenElement) {
videoContainer.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
document.exitFullscreen();
}
};
const handleSeek = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime += seconds;
}
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
controls={false} // We use custom controls, but maybe we should keep native controls as fallback or overlay? The original had controls={true} AND custom controls.
// The original code had `controls` attribute on the video tag, which enables native controls.
// But it also rendered custom controls below it.
// Let's keep it consistent with original: native controls enabled.
src={src}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
playsInline
>
Your browser does not support the video tag.
</video>
{/* Custom Controls Area */}
<Box sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
opacity: isFullscreen ? 0.3 : 1,
transition: 'opacity 0.3s',
'&:hover': { opacity: 1 }
}}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="center"
spacing={{ xs: 2, sm: 2 }}
>
{/* Row 1 on Mobile: Play/Pause and Loop */}
<Stack direction="row" spacing={2} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title={isPlaying ? t('paused') : t('playing')}>
<Button
variant="contained"
color={isPlaying ? "warning" : "primary"}
onClick={handlePlayPause}
fullWidth={isMobile}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</Button>
</Tooltip>
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`}>
<Button
variant={isLooping ? "contained" : "outlined"}
color="secondary"
onClick={handleToggleLoop}
fullWidth={isMobile}
>
<Loop />
</Button>
</Tooltip>
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}>
<Button
variant="outlined"
onClick={handleToggleFullscreen}
fullWidth={isMobile}
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</Tooltip>
</Stack>
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<FastRewind />
</Button>
</Tooltip>
<Tooltip title="-10s">
<Button variant="outlined" onClick={() => handleSeek(-10)}>
<Replay10 />
</Button>
</Tooltip>
<Tooltip title="+10s">
<Button variant="outlined" onClick={() => handleSeek(10)}>
<Forward10 />
</Button>
</Tooltip>
<Tooltip title="+1m">
<Button variant="outlined" onClick={() => handleSeek(60)}>
<FastForward />
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>
</Box>
);
};
export default VideoControls;

View File

@@ -0,0 +1,315 @@
import {
Add,
CalendarToday,
Check,
Close,
Delete,
Download,
Edit,
Folder,
Link as LinkIcon,
LocalOffer,
VideoLibrary
} from '@mui/icons-material';
import {
Alert,
Autocomplete,
Avatar,
Box,
Button,
Chip,
Divider,
Rating,
Stack,
TextField,
Tooltip,
Typography,
useTheme
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useSnackbar } from '../../contexts/SnackbarContext';
import { Collection, Video } from '../../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoInfoProps {
video: Video;
onTitleSave: (newTitle: string) => Promise<void>;
onRatingChange: (newRating: number) => Promise<void>;
onAuthorClick: () => void;
onAddToCollection: () => void;
onDelete: () => void;
isDeleting: boolean;
deleteError: string | null;
videoCollections: Collection[];
onCollectionClick: (id: string) => void;
availableTags: string[];
onTagsUpdate: (tags: string[]) => Promise<void>;
}
const VideoInfo: React.FC<VideoInfoProps> = ({
video,
onTitleSave,
onRatingChange,
onAuthorClick,
onAddToCollection,
onDelete,
isDeleting,
deleteError,
videoCollections,
onCollectionClick,
availableTags,
onTagsUpdate
}) => {
const theme = useTheme();
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
const [editedTitle, setEditedTitle] = useState<string>('');
const handleStartEditingTitle = () => {
setEditedTitle(video.title);
setIsEditingTitle(true);
};
const handleCancelEditingTitle = () => {
setIsEditingTitle(false);
setEditedTitle('');
};
const handleSaveTitle = async () => {
if (!editedTitle.trim()) return;
await onTitleSave(editedTitle);
setIsEditingTitle(false);
};
const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => {
if (newValue) {
onRatingChange(newValue);
}
};
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString?: string) => {
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
return (
<Box sx={{ mt: 2 }}>
{isEditingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<TextField
fullWidth
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
variant="outlined"
size="small"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
}
}}
/>
<Button
variant="contained"
color="primary"
onClick={handleSaveTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Check />
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancelEditingTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Close />
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="h5" component="h1" fontWeight="bold" sx={{ mr: 1 }}>
{video.title}
</Typography>
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Rating
value={video.rating || 0}
onChange={handleRatingChangeInternal}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
{video.rating ? `(${video.rating})` : t('rateThisVideo')}
</Typography>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', sm: 'center' }}
spacing={2}
sx={{ mb: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={onAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
>
{video.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
startIcon={<Add />}
onClick={onAddToCollection}
>
{t('addToCollection')}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting ? t('deleting') : t('delete')}
</Button>
</Stack>
</Stack>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }}>
{deleteError}
</Alert>
)}
<Divider sx={{ my: 2 }} />
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
{video.sourceUrl && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<LinkIcon fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('originalLink')}</strong>
</a>
</Typography>
)}
{video.videoPath && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={`${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<Download fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('download')}</strong>
</a>
</Typography>
)}
<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')}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarToday fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('addedDate')}</strong> {new Date(video.addedAt).toLocaleDateString()}
</Typography>
)}
</Stack>
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{videoCollections.map(c => (
<Chip
key={c.id}
icon={<Folder />}
label={c.name}
onClick={() => onCollectionClick(c.id)}
color="secondary"
variant="outlined"
clickable
sx={{ mb: 1 }}
/>
))}
</Stack>
</Box>
)}
</Box>
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => onTagsUpdate(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
InputProps={{ ...params.InputProps, disableUnderline: true }}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
variant="outlined"
label={option}
size="small"
{...tagProps}
/>
);
})
}
sx={{ flexGrow: 1 }}
/>
</Box>
</Box>
);
};
export default VideoInfo;

View File

@@ -0,0 +1,140 @@
import axios from 'axios';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Collection } from '../types';
import { useSnackbar } from './SnackbarContext';
import { useVideo } from './VideoContext';
const API_URL = import.meta.env.VITE_API_URL;
interface CollectionContextType {
collections: Collection[];
fetchCollections: () => Promise<void>;
createCollection: (name: string, videoId: string) => Promise<Collection | null>;
addToCollection: (collectionId: string, videoId: string) => Promise<Collection | null>;
removeFromCollection: (videoId: string) => Promise<boolean>;
deleteCollection: (collectionId: string, deleteVideos?: boolean) => Promise<{ success: boolean; error?: string }>;
}
const CollectionContext = createContext<CollectionContextType | undefined>(undefined);
export const useCollection = () => {
const context = useContext(CollectionContext);
if (!context) {
throw new Error('useCollection must be used within a CollectionProvider');
}
return context;
};
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { fetchVideos } = useVideo();
const [collections, setCollections] = useState<Collection[]>([]);
const fetchCollections = async () => {
try {
const response = await axios.get(`${API_URL}/collections`);
setCollections(response.data);
} catch (error) {
console.error('Error fetching collections:', error);
}
};
const createCollection = async (name: string, videoId: string) => {
try {
const response = await axios.post(`${API_URL}/collections`, {
name,
videoId
});
setCollections(prevCollections => [...prevCollections, response.data]);
showSnackbar('Collection created successfully');
return response.data;
} catch (error) {
console.error('Error creating collection:', error);
return null;
}
};
const addToCollection = async (collectionId: string, videoId: string) => {
try {
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
videoId,
action: "add"
});
setCollections(prevCollections => prevCollections.map(collection =>
collection.id === collectionId ? response.data : collection
));
showSnackbar('Video added to collection');
return response.data;
} catch (error) {
console.error('Error adding video to collection:', error);
return null;
}
};
const removeFromCollection = async (videoId: string) => {
try {
const collectionsWithVideo = collections.filter(collection =>
collection.videos.includes(videoId)
);
for (const collection of collectionsWithVideo) {
await axios.put(`${API_URL}/collections/${collection.id}`, {
videoId,
action: "remove"
});
}
setCollections(prevCollections => prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(v => v !== videoId)
})));
showSnackbar('Video removed from collection');
return true;
} catch (error) {
console.error('Error removing video from collection:', error);
return false;
}
};
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
try {
await axios.delete(`${API_URL}/collections/${collectionId}`, {
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
});
setCollections(prevCollections =>
prevCollections.filter(collection => collection.id !== collectionId)
);
if (deleteVideos) {
await fetchVideos();
}
showSnackbar('Collection deleted successfully');
return { success: true };
} catch (error) {
console.error('Error deleting collection:', error);
showSnackbar('Failed to delete collection', 'error');
return { success: false, error: 'Failed to delete collection' };
}
};
// Fetch collections on mount
useEffect(() => {
fetchCollections();
}, []);
return (
<CollectionContext.Provider value={{
collections,
fetchCollections,
createCollection,
addToCollection,
removeFromCollection,
deleteCollection
}}>
{children}
</CollectionContext.Provider>
);
};

View File

@@ -0,0 +1,374 @@
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { DownloadInfo } from '../types';
import { useCollection } from './CollectionContext';
import { useSnackbar } from './SnackbarContext';
import { useVideo } from './VideoContext';
const API_URL = import.meta.env.VITE_API_URL;
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
interface BilibiliPartsInfo {
videosNumber: number;
title: string;
url: string;
type: 'parts' | 'collection' | 'series';
collectionInfo: any;
}
interface DownloadContextType {
activeDownloads: DownloadInfo[];
queuedDownloads: DownloadInfo[];
handleVideoSubmit: (videoUrl: string, skipCollectionCheck?: boolean) => Promise<any>;
showBilibiliPartsModal: boolean;
setShowBilibiliPartsModal: (show: boolean) => void;
bilibiliPartsInfo: BilibiliPartsInfo;
isCheckingParts: boolean;
handleDownloadAllBilibiliParts: (collectionName: string) => Promise<{ success: boolean; error?: string }>;
handleDownloadCurrentBilibiliPart: () => Promise<any>;
}
const DownloadContext = createContext<DownloadContextType | undefined>(undefined);
export const useDownload = () => {
const context = useContext(DownloadContext);
if (!context) {
throw new Error('useDownload must be used within a DownloadProvider');
}
return context;
};
// Helper function to get download status from localStorage
const getStoredDownloadStatus = () => {
try {
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
if (!savedStatus) return null;
const parsedStatus = JSON.parse(savedStatus);
// Check if the saved status is too old (stale)
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
return parsedStatus;
} catch (error) {
console.error('Error parsing download status from localStorage:', error);
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
};
export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { fetchVideos, handleSearch, setVideos } = useVideo();
const { fetchCollections } = useCollection();
// Get initial download status from localStorage
const initialStatus = getStoredDownloadStatus();
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
initialStatus ? initialStatus.activeDownloads || [] : []
);
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>(
initialStatus ? initialStatus.queuedDownloads || [] : []
);
// Bilibili multi-part video state
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState<BilibiliPartsInfo>({
videosNumber: 0,
title: '',
url: '',
type: 'parts', // 'parts', 'collection', or 'series'
collectionInfo: null // For collection/series, stores the API response
});
const [isCheckingParts, setIsCheckingParts] = useState<boolean>(false);
// Reference to track current download IDs for detecting completion
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
// Add a function to check download status from the backend
const checkBackendDownloadStatus = async () => {
try {
const response = await axios.get(`${API_URL}/download-status`);
const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data;
const newActive = backendActive || [];
const newQueued = backendQueued || [];
// Create a set of all current download IDs from the backend
const newIds = new Set<string>([
...newActive.map((d: DownloadInfo) => d.id),
...newQueued.map((d: DownloadInfo) => d.id)
]);
// Check if any ID from the previous check is missing in the new check
// This implies it finished (or failed), so we should refresh the video list
let hasCompleted = false;
if (currentDownloadIdsRef.current.size > 0) {
for (const id of currentDownloadIdsRef.current) {
if (!newIds.has(id)) {
hasCompleted = true;
break;
}
}
}
// Update the ref for the next check
currentDownloadIdsRef.current = newIds;
if (hasCompleted) {
console.log('Download completed, refreshing videos');
fetchVideos();
}
if (newActive.length > 0 || newQueued.length > 0) {
// If backend has active or queued downloads, update the local status
setActiveDownloads(newActive);
setQueuedDownloads(newQueued);
// Save to localStorage for persistence
const statusData = {
activeDownloads: newActive,
queuedDownloads: newQueued,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
// If backend says no downloads are in progress, clear the status
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
console.log('Backend says downloads are complete, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setActiveDownloads([]);
setQueuedDownloads([]);
// Refresh videos list when downloads complete (fallback)
fetchVideos();
}
}
} catch (error) {
console.error('Error checking backend download status:', error);
}
};
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
try {
// Check if it's a Bilibili URL
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
setIsCheckingParts(true);
try {
// Only check for collection/series if not explicitly skipped
if (!skipCollectionCheck) {
// First, check if it's a collection or series
const collectionResponse = await axios.get(`${API_URL}/check-bilibili-collection`, {
params: { url: videoUrl }
});
if (collectionResponse.data.success && collectionResponse.data.type !== 'none') {
// It's a collection or series
const { type, title, count, id, mid } = collectionResponse.data;
console.log(`Detected Bilibili ${type}:`, title, `with ${count} videos`);
setBilibiliPartsInfo({
videosNumber: count,
title: title,
url: videoUrl,
type: type,
collectionInfo: { type, id, mid, title, count }
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
}
// If not a collection/series (or check was skipped), check if it has multiple parts
const partsResponse = await axios.get(`${API_URL}/check-bilibili-parts`, {
params: { url: videoUrl }
});
if (partsResponse.data.success && partsResponse.data.videosNumber > 1) {
// Show modal to ask user if they want to download all parts
setBilibiliPartsInfo({
videosNumber: partsResponse.data.videosNumber,
title: partsResponse.data.title,
url: videoUrl,
type: 'parts',
collectionInfo: null
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
} catch (err) {
console.error('Error checking Bilibili parts/collection:', err);
// Continue with normal download if check fails
} finally {
setIsCheckingParts(false);
}
}
// Normal download flow
// We don't set activeDownloads here immediately because the backend will queue it
// and we'll pick it up via polling
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
// If the response contains a downloadId, it means it was queued/started
if (response.data.downloadId) {
// Trigger an immediate status check
checkBackendDownloadStatus();
} else if (response.data.video) {
// If it returned a video immediately (shouldn't happen with new logic but safe to keep)
setVideos(prevVideos => [response.data.video, ...prevVideos]);
}
// Use the setIsSearchMode from VideoContext but we need to expose it there first
// For now, we can assume the caller handles UI state or we add it to VideoContext
// Actually, let's just use the resetSearch from VideoContext which handles search mode
// But wait, resetSearch clears everything. We just want to exit search mode.
// Let's update VideoContext to expose setIsSearchMode or handle it better.
// For now, let's assume VideoContext handles it via resetSearch if needed, or we just ignore it here
// and let the component handle UI.
// Wait, the original code called setIsSearchMode(false).
// I should add setIsSearchMode to VideoContext interface.
showSnackbar('Video downloading');
return { success: true };
} catch (err: any) {
console.error('Error downloading video:', err);
// Check if the error is because the input is a search term
if (err.response?.data?.isSearchTerm) {
// Handle as search term
return await handleSearch(err.response.data.searchTerm);
}
return {
success: false,
error: err.response?.data?.error || 'Failed to download video. Please try again.'
};
}
};
const handleDownloadAllBilibiliParts = async (collectionName: string) => {
try {
setShowBilibiliPartsModal(false);
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: bilibiliPartsInfo.url,
downloadAllParts: !isCollection, // Only set this for multi-part videos
downloadCollection: isCollection, // Set this for collections/series
collectionInfo: isCollection ? bilibiliPartsInfo.collectionInfo : null,
collectionName
});
// Trigger immediate status check
checkBackendDownloadStatus();
// If a collection was created, refresh collections
if (response.data.collectionId) {
await fetchCollections();
}
showSnackbar('Download started successfully');
return { success: true };
} catch (err: any) {
console.error('Error downloading Bilibili parts/collection:', err);
return {
success: false,
error: err.response?.data?.error || 'Failed to download. Please try again.'
};
}
};
const handleDownloadCurrentBilibiliPart = async () => {
setShowBilibiliPartsModal(false);
// Pass true to skip collection/series check since we already know about it
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
};
// Check backend download status periodically
useEffect(() => {
// Check immediately on mount
checkBackendDownloadStatus();
// Then check every 2 seconds (faster polling for better UX)
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
return () => {
clearInterval(statusCheckInterval);
};
}, [activeDownloads.length, queuedDownloads.length]);
// Set up localStorage and event listeners
useEffect(() => {
// Set up event listener for storage changes (for multi-tab support)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === DOWNLOAD_STATUS_KEY) {
try {
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [], queuedDownloads: [] };
setActiveDownloads(newStatus.activeDownloads || []);
setQueuedDownloads(newStatus.queuedDownloads || []);
} catch (error) {
console.error('Error handling storage change:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// Set up periodic check for stale download status
const checkDownloadStatus = () => {
const status = getStoredDownloadStatus();
if (!status && (activeDownloads.length > 0 || queuedDownloads.length > 0)) {
console.log('Clearing stale download status');
setActiveDownloads([]);
setQueuedDownloads([]);
}
};
// Check every minute
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(statusCheckInterval);
};
}, [activeDownloads, queuedDownloads]);
// Update localStorage whenever activeDownloads or queuedDownloads changes
useEffect(() => {
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
const statusData = {
activeDownloads,
queuedDownloads,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [activeDownloads, queuedDownloads]);
return (
<DownloadContext.Provider value={{
activeDownloads,
queuedDownloads,
handleVideoSubmit,
showBilibiliPartsModal,
setShowBilibiliPartsModal,
bilibiliPartsInfo,
isCheckingParts,
handleDownloadAllBilibiliParts,
handleDownloadCurrentBilibiliPart
}}>
{children}
</DownloadContext.Provider>
);
};

View File

@@ -0,0 +1,200 @@
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { Video } from '../types';
import { useSnackbar } from './SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
interface VideoContextType {
videos: Video[];
loading: boolean;
error: string | null;
fetchVideos: () => Promise<void>;
deleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
searchLocalVideos: (query: string) => Video[];
searchResults: any[];
localSearchResults: Video[];
isSearchMode: boolean;
searchTerm: string;
youtubeLoading: boolean;
handleSearch: (query: string) => Promise<any>;
resetSearch: () => void;
setVideos: React.Dispatch<React.SetStateAction<Video[]>>;
setIsSearchMode: React.Dispatch<React.SetStateAction<boolean>>;
}
const VideoContext = createContext<VideoContextType | undefined>(undefined);
export const useVideo = () => {
const context = useContext(VideoContext);
if (!context) {
throw new Error('useVideo must be used within a VideoProvider');
}
return context;
};
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Search state
const [searchResults, setSearchResults] = useState<any[]>([]);
const [localSearchResults, setLocalSearchResults] = useState<Video[]>([]);
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
// Reference to the current search request's abort controller
const searchAbortController = useRef<AbortController | null>(null);
const fetchVideos = async () => {
try {
setLoading(true);
const response = await axios.get(`${API_URL}/videos`);
setVideos(response.data);
setError(null);
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
const deleteVideo = async (id: string) => {
try {
setLoading(true);
await axios.delete(`${API_URL}/videos/${id}`);
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
setLoading(false);
showSnackbar('Video removed successfully');
return { success: true };
} catch (error) {
console.error('Error deleting video:', error);
setLoading(false);
return { success: false, error: 'Failed to delete video' };
}
};
const searchLocalVideos = (query: string) => {
if (!query || !videos.length) return [];
const searchTermLower = query.toLowerCase();
return videos.filter(video =>
video.title.toLowerCase().includes(searchTermLower) ||
video.author.toLowerCase().includes(searchTermLower)
);
};
const resetSearch = () => {
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
setIsSearchMode(false);
setSearchTerm('');
setSearchResults([]);
setLocalSearchResults([]);
setYoutubeLoading(false);
};
const handleSearch = async (query: string): Promise<any> => {
if (!query || query.trim() === '') {
resetSearch();
return { success: false, error: 'Please enter a search term' };
}
try {
if (searchAbortController.current) {
searchAbortController.current.abort();
}
searchAbortController.current = new AbortController();
const signal = searchAbortController.current.signal;
setIsSearchMode(true);
setSearchTerm(query);
const localResults = searchLocalVideos(query);
setLocalSearchResults(localResults);
setYoutubeLoading(true);
try {
const response = await axios.get(`${API_URL}/search`, {
params: { query },
signal: signal
});
if (!signal.aborted) {
setSearchResults(response.data.results);
}
} catch (youtubeErr: any) {
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
} finally {
if (!signal.aborted) {
setYoutubeLoading(false);
}
}
return { success: true };
} catch (err: any) {
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
console.error('Error in search process:', err);
const localResults = searchLocalVideos(query);
if (localResults.length > 0) {
setLocalSearchResults(localResults);
setIsSearchMode(true);
setSearchTerm(query);
return { success: true };
}
return { success: false, error: 'Failed to search. Please try again.' };
}
return { success: false, error: 'Search was cancelled' };
} finally {
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
}
}
};
// Fetch videos on mount
useEffect(() => {
fetchVideos();
}, []);
// Cleanup search on unmount
useEffect(() => {
return () => {
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
};
}, []);
return (
<VideoContext.Provider value={{
videos,
loading,
error,
fetchVideos,
deleteVideo,
searchLocalVideos,
searchResults,
localSearchResults,
isSearchMode,
searchTerm,
youtubeLoading,
handleSearch,
resetSearch,
setVideos,
setIsSearchMode
}}>
{children}
</VideoContext.Provider>
);
};

View File

@@ -238,6 +238,9 @@ const SettingsPage: React.FC = () => {
{/* Tags Management */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('tagsManagement') || 'Tags Management'}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t('tagsManagementNote') || 'Please remember to click "Save Settings" after adding or removing tags to apply changes.'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{settings.tags && settings.tags.map((tag) => (
<Chip

View File

@@ -1,59 +1,24 @@
import {
Add,
CalendarToday,
Check,
Close,
Delete,
Download,
Edit,
FastForward,
FastRewind,
Folder,
Forward10,
Fullscreen,
FullscreenExit,
Link as LinkIcon,
LocalOffer,
Loop,
Pause,
PlayArrow,
Replay10,
VideoLibrary
} from '@mui/icons-material';
import {
Alert,
Autocomplete,
Avatar,
Box,
Button,
Card,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Rating,
Select,
Stack,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import CollectionModal from '../components/VideoPlayer/CollectionModal';
import CommentsSection from '../components/VideoPlayer/CommentsSection';
import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { Collection, Comment, Video } from '../types';
@@ -80,8 +45,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}) => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
@@ -91,22 +54,14 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
const [newCollectionName, setNewCollectionName] = useState<string>('');
const [selectedCollection, setSelectedCollection] = useState<string>('');
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [loadingComments, setLoadingComments] = useState<boolean>(false);
const [showComments, setShowComments] = useState<boolean>(false);
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isLooping, setIsLooping] = useState<boolean>(false);
// Title editing state
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
const [editedTitle, setEditedTitle] = useState<string>('');
const [autoPlay, setAutoPlay] = useState<boolean>(false);
const [autoLoop, setAutoLoop] = useState<boolean>(false);
// Confirmation Modal State
const [confirmationModal, setConfirmationModal] = useState({
@@ -119,56 +74,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
isDanger: false
});
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !isLooping;
setIsLooping(!isLooping);
}
};
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const handleToggleFullscreen = () => {
const videoContainer = videoRef.current?.parentElement;
if (!videoContainer) return;
if (!document.fullscreenElement) {
videoContainer.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
document.exitFullscreen();
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const handleSeek = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime += seconds;
}
};
useEffect(() => {
// Don't try to fetch the video if it's being deleted
if (isDeleting) {
@@ -206,7 +111,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
};
fetchVideo();
fetchVideo();
}, [id, videos, navigate, isDeleting]);
// Fetch settings and apply defaults
@@ -216,20 +120,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const response = await axios.get(`${API_URL}/settings`);
const { defaultAutoPlay, defaultAutoLoop } = response.data;
if (videoRef.current) {
if (defaultAutoPlay) {
videoRef.current.autoplay = true;
setIsPlaying(true);
}
if (defaultAutoLoop) {
videoRef.current.loop = true;
setIsLooping(true);
}
}
setAutoPlay(!!defaultAutoPlay);
setAutoLoop(!!defaultAutoLoop);
console.log('Fetched settings in VideoPlayer:', response.data);
setAvailableTags(response.data.tags || []);
console.log('Setting available tags:', response.data.tags || []);
} catch (error) {
console.error('Error fetching settings:', error);
}
@@ -338,31 +232,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleCloseModal = () => {
setShowCollectionModal(false);
setNewCollectionName('');
setSelectedCollection('');
};
const handleCreateCollection = async () => {
if (!newCollectionName.trim() || !id) {
return;
}
const handleCreateCollection = async (name: string) => {
if (!id) return;
try {
await onCreateCollection(newCollectionName, id);
handleCloseModal();
await onCreateCollection(name, id);
} catch (error) {
console.error('Error creating collection:', error);
}
};
const handleAddToExistingCollection = async () => {
if (!selectedCollection || !id) {
return;
}
const handleAddToExistingCollection = async (collectionId: string) => {
if (!id) return;
try {
await onAddToCollection(selectedCollection, id);
handleCloseModal();
await onAddToCollection(collectionId, id);
} catch (error) {
console.error('Error adding to collection:', error);
}
@@ -373,7 +257,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
try {
await onRemoveFromCollection(id);
handleCloseModal();
} catch (error) {
console.error('Error removing from collection:', error);
}
@@ -391,8 +274,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
});
};
const handleRatingChange = async (_: React.SyntheticEvent, newValue: number | null) => {
if (!newValue || !id) return;
const handleRatingChange = async (newValue: number) => {
if (!id) return;
try {
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
@@ -402,26 +285,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}
};
const handleStartEditingTitle = () => {
if (video) {
setEditedTitle(video.title);
setIsEditingTitle(true);
}
};
const handleCancelEditingTitle = () => {
setIsEditingTitle(false);
setEditedTitle('');
};
const handleSaveTitle = async () => {
if (!id || !editedTitle.trim()) return;
const handleSaveTitle = async (newTitle: string) => {
if (!id) return;
try {
const response = await axios.put(`${API_URL}/videos/${id}`, { title: editedTitle });
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
if (response.data.success) {
setVideo(prev => prev ? { ...prev, title: editedTitle } : null);
setIsEditingTitle(false);
setVideo(prev => prev ? { ...prev, title: newTitle } : null);
showSnackbar(t('titleUpdated'));
}
} catch (error) {
@@ -468,352 +338,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Grid container spacing={4}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4 }}>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
controls
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
playsInline
>
Your browser does not support the video tag.
</video>
<VideoControls
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
autoPlay={autoPlay}
autoLoop={autoLoop}
/>
{/* Custom Controls Area */}
<Box sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
opacity: isFullscreen ? 0.3 : 1,
transition: 'opacity 0.3s',
'&:hover': { opacity: 1 }
}}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="center"
spacing={{ xs: 2, sm: 2 }}
>
{/* Row 1 on Mobile: Play/Pause and Loop */}
<Stack direction="row" spacing={2} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title={isPlaying ? t('paused') : t('playing')}>
<Button
variant="contained"
color={isPlaying ? "warning" : "primary"}
onClick={handlePlayPause}
fullWidth={isMobile}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</Button>
</Tooltip>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={isDeleting}
deleteError={deleteError}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
/>
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`}>
<Button
variant={isLooping ? "contained" : "outlined"}
color="secondary"
onClick={handleToggleLoop}
fullWidth={isMobile}
>
<Loop />
</Button>
</Tooltip>
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}>
<Button
variant="outlined"
onClick={handleToggleFullscreen}
fullWidth={isMobile}
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</Tooltip>
</Stack>
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<FastRewind />
</Button>
</Tooltip>
<Tooltip title="-10s">
<Button variant="outlined" onClick={() => handleSeek(-10)}>
<Replay10 />
</Button>
</Tooltip>
<Tooltip title="+10s">
<Button variant="outlined" onClick={() => handleSeek(10)}>
<Forward10 />
</Button>
</Tooltip>
<Tooltip title="+1m">
<Button variant="outlined" onClick={() => handleSeek(60)}>
<FastForward />
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>
</Box>
{/* Info Column */}
<Box sx={{ mt: 2 }}>
{isEditingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<TextField
fullWidth
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
variant="outlined"
size="small"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
}
}}
/>
<Button
variant="contained"
color="primary"
onClick={handleSaveTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Check />
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancelEditingTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Close />
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="h5" component="h1" fontWeight="bold" sx={{ mr: 1 }}>
{video.title}
</Typography>
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Rating
value={video.rating || 0}
onChange={handleRatingChange}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
{video.rating ? `(${video.rating})` : t('rateThisVideo')}
</Typography>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', sm: 'center' }}
spacing={2}
sx={{ mb: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={handleAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
>
{video.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
startIcon={<Add />}
onClick={handleAddToCollection}
>
{t('addToCollection')}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? t('deleting') : t('delete')}
</Button>
</Stack>
</Stack>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }}>
{deleteError}
</Alert>
)}
<Divider sx={{ my: 2 }} />
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
{video.sourceUrl && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<LinkIcon fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('originalLink')}</strong>
</a>
</Typography>
)}
{video.videoPath && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={`${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<Download fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('download')}</strong>
</a>
</Typography>
)}
<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')}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarToday fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('addedDate')}</strong> {new Date(video.addedAt).toLocaleDateString()}
</Typography>
)}
</Stack>
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{videoCollections.map(c => (
<Chip
key={c.id}
icon={<Folder />}
label={c.name}
onClick={() => handleCollectionClick(c.id)}
color="secondary"
variant="outlined"
clickable
sx={{ mb: 1 }}
/>
))}
</Stack>
</Box>
)}
</Box>
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => handleUpdateTags(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
InputProps={{ ...params.InputProps, disableUnderline: true }}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
variant="outlined"
label={option}
size="small"
{...tagProps}
/>
);
})
}
sx={{ flexGrow: 1 }}
/>
</Box>
{/* Comments Section */}
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="bold">
{t('latestComments')}
</Typography>
<Button
variant="outlined"
onClick={handleToggleComments}
size="small"
>
{showComments ? "Hide Comments" : "Show Comments"}
</Button>
</Box>
{showComments && (
<>
{loadingComments ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : comments.length > 0 ? (
<Stack spacing={2}>
{comments.map((comment) => (
<Box key={comment.id} sx={{ display: 'flex', gap: 2 }}>
<Avatar src={comment.avatar} alt={comment.author}>
{comment.author.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold">
{comment.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{comment.date}
</Typography>
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.content}
</Typography>
</Box>
</Box>
))}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
{t('noComments')}
</Typography>
)}
</>
)}
</Box>
</Box>
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
</Grid>
{/* Sidebar Column - Up Next */}
@@ -874,81 +425,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
</Grid>
</Grid>
{/* Collection Modal */}
<Dialog open={showCollectionModal} onClose={handleCloseModal} maxWidth="sm" fullWidth>
<DialogTitle>{t('addToCollection')}</DialogTitle>
<DialogContent dividers>
{videoCollections.length > 0 && (
<Alert severity="info" sx={{ mb: 3 }} action={
<Button color="error" size="small" onClick={handleRemoveFromCollection}>
{t('remove')}
</Button>
}>
{t('currentlyIn')} <strong>{videoCollections[0].name}</strong>
<Typography variant="caption" display="block">
{t('collectionWarning')}
</Typography>
</Alert>
)}
{collections && collections.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle2" gutterBottom>{t('addToExistingCollection')}</Typography>
<Stack direction="row" spacing={2}>
<FormControl fullWidth size="small">
<InputLabel>{t('selectCollection')}</InputLabel>
<Select
value={selectedCollection}
label={t('selectCollection')}
onChange={(e) => setSelectedCollection(e.target.value)}
>
{collections.map(collection => (
<MenuItem
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
>
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? t('current') : ''}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
{t('add')}
</Button>
</Stack>
</Box>
)}
<Box>
<Typography variant="subtitle2" gutterBottom>{t('createNewCollection')}</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
size="small"
label={t('collectionName')}
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
/>
<Button
variant="contained"
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
{t('create')}
</Button>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseModal} color="inherit">{t('cancel')}</Button>
</DialogActions>
</Dialog>
<CollectionModal
open={showCollectionModal}
onClose={handleCloseModal}
videoCollections={videoCollections}
collections={collections}
onAddToCollection={handleAddToExistingCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
<ConfirmationModal
isOpen={confirmationModal.isOpen}

View File

@@ -52,6 +52,7 @@ export const translations = {
tagsManagement: "Tags Management",
newTag: "New Tag",
tags: "Tags",
tagsManagementNote: "Please remember to click \"Save Settings\" after adding or removing tags to apply changes.",
// Database
database: "Database",
@@ -260,6 +261,7 @@ export const translations = {
tagsManagement: "标签管理",
newTag: "新标签",
tags: "标签",
tagsManagementNote: "添加或删除标签后,请记得点击“保存设置”以应用更改。",
// Database
database: "数据库",