refact: decouple components
This commit is contained in:
@@ -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
700
backend/src/services/downloaders/BilibiliDownloader.ts
Normal file
700
backend/src/services/downloaders/BilibiliDownloader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
backend/src/services/downloaders/MissAVDownloader.ts
Normal file
220
backend/src/services/downloaders/MissAVDownloader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
215
backend/src/services/downloaders/YouTubeDownloader.ts
Normal file
215
backend/src/services/downloaders/YouTubeDownloader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
143
frontend/src/components/VideoPlayer/CollectionModal.tsx
Normal file
143
frontend/src/components/VideoPlayer/CollectionModal.tsx
Normal 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;
|
||||
83
frontend/src/components/VideoPlayer/CommentsSection.tsx
Normal file
83
frontend/src/components/VideoPlayer/CommentsSection.tsx
Normal 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;
|
||||
201
frontend/src/components/VideoPlayer/VideoControls.tsx
Normal file
201
frontend/src/components/VideoPlayer/VideoControls.tsx
Normal 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;
|
||||
315
frontend/src/components/VideoPlayer/VideoInfo.tsx
Normal file
315
frontend/src/components/VideoPlayer/VideoInfo.tsx
Normal 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;
|
||||
140
frontend/src/contexts/CollectionContext.tsx
Normal file
140
frontend/src/contexts/CollectionContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
374
frontend/src/contexts/DownloadContext.tsx
Normal file
374
frontend/src/contexts/DownloadContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
200
frontend/src/contexts/VideoContext.tsx
Normal file
200
frontend/src/contexts/VideoContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "数据库",
|
||||
|
||||
Reference in New Issue
Block a user