feat: add delete history detect

This commit is contained in:
Peifan Li
2025-12-08 14:54:57 -05:00
parent 48e3821ed3
commit caf34816e4
13 changed files with 1185 additions and 368 deletions

View File

@@ -0,0 +1,17 @@
CREATE TABLE `video_downloads` (
`id` text PRIMARY KEY NOT NULL,
`source_video_id` text NOT NULL,
`source_url` text NOT NULL,
`platform` text NOT NULL,
`video_id` text,
`title` text,
`author` text,
`status` text DEFAULT 'exists' NOT NULL,
`downloaded_at` integer NOT NULL,
`deleted_at` integer
);
--> statement-breakpoint
CREATE INDEX `video_downloads_source_video_id_idx` ON `video_downloads` (`source_video_id`);
--> statement-breakpoint
CREATE INDEX `video_downloads_source_url_idx` ON `video_downloads` (`source_url`);

View File

@@ -29,6 +29,13 @@
"when": 1764631012929,
"tag": "0003_puzzling_energizer",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1733644800000,
"tag": "0004_video_downloads",
"breakpoints": true
}
]
}

View File

@@ -10,6 +10,7 @@ import { getVideoDuration } from "../services/metadataService";
import * as storageService from "../services/storageService";
import {
extractBilibiliVideoId,
extractSourceVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
@@ -54,6 +55,83 @@ export const searchVideos = async (
}
};
// Check video download status
export const checkVideoDownloadStatus = async (
req: Request,
res: Response
): Promise<any> => {
try {
const { url } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({ error: "URL is required" });
}
let videoUrl = extractUrlFromText(url);
// Resolve shortened URLs
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
}
// Extract source video ID
const { id: sourceVideoId, platform } = extractSourceVideoId(videoUrl);
if (!sourceVideoId) {
return res.status(200).json({ found: false });
}
// Check if video was previously downloaded
const downloadCheck = storageService.checkVideoDownloadBySourceId(sourceVideoId);
if (downloadCheck.found) {
// If status is "exists", verify the video still exists in the database
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
const existingVideo = storageService.getVideoById(downloadCheck.videoId);
if (!existingVideo) {
// Video was deleted but not marked in download history, update it
storageService.markVideoDownloadDeleted(downloadCheck.videoId);
return res.status(200).json({
found: true,
status: "deleted",
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
});
}
return res.status(200).json({
found: true,
status: "exists",
videoId: downloadCheck.videoId,
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
downloadedAt: downloadCheck.downloadedAt,
videoPath: existingVideo.videoPath,
thumbnailPath: existingVideo.thumbnailPath,
});
}
return res.status(200).json({
found: true,
status: downloadCheck.status,
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
});
}
return res.status(200).json({ found: false });
} catch (error: any) {
console.error("Error checking video download status:", error);
res.status(500).json({
error: "Failed to check video download status",
details: error.message,
});
}
};
// Download video
export const downloadVideo = async (
req: Request,
@@ -66,6 +144,7 @@ export const downloadVideo = async (
collectionName,
downloadCollection,
collectionInfo,
forceDownload, // Allow re-download of deleted videos
} = req.body;
let videoUrl = youtubeUrl;
@@ -89,6 +168,79 @@ export const downloadVideo = async (
});
}
// Resolve shortened URLs first to get the real URL for checking
let resolvedUrl = videoUrl;
if (videoUrl.includes("b23.tv")) {
resolvedUrl = await resolveShortUrl(videoUrl);
console.log("Resolved shortened URL to:", resolvedUrl);
}
// Extract source video ID for checking download history
const { id: sourceVideoId, platform } = extractSourceVideoId(resolvedUrl);
// Check if video was previously downloaded (skip for collections/multi-part)
if (sourceVideoId && !downloadAllParts && !downloadCollection) {
const downloadCheck = storageService.checkVideoDownloadBySourceId(sourceVideoId);
if (downloadCheck.found) {
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
// Verify the video still exists
const existingVideo = storageService.getVideoById(downloadCheck.videoId);
if (existingVideo) {
// Video exists, add to download history as "skipped" and return success
storageService.addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
sourceUrl: resolvedUrl,
finishedAt: Date.now(),
status: "skipped",
videoPath: existingVideo.videoPath,
thumbnailPath: existingVideo.thumbnailPath,
videoId: existingVideo.id,
});
return res.status(200).json({
success: true,
skipped: true,
videoId: downloadCheck.videoId,
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
videoPath: existingVideo.videoPath,
message: "Video already exists, skipped download",
});
}
// Video was deleted but not marked, update the record
storageService.markVideoDownloadDeleted(downloadCheck.videoId);
}
if (downloadCheck.status === "deleted" && !forceDownload) {
// Video was previously downloaded but deleted - add to history and skip
storageService.addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || "Unknown Title",
author: downloadCheck.author,
sourceUrl: resolvedUrl,
finishedAt: Date.now(),
status: "deleted",
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
});
return res.status(200).json({
success: true,
skipped: true,
previouslyDeleted: true,
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
message: "Video was previously downloaded but deleted, skipped download",
});
}
}
}
// Determine initial title for the download task
let initialTitle = "Video";
try {

View File

@@ -100,11 +100,14 @@ export const downloadHistory = sqliteTable('download_history', {
author: text('author'),
sourceUrl: text('source_url'),
finishedAt: integer('finished_at').notNull(), // Timestamp
status: text('status').notNull(), // 'success' or 'failed'
status: text('status').notNull(), // 'success', 'failed', 'skipped', or 'deleted'
error: text('error'), // Error message if failed
videoPath: text('video_path'), // Path to video file if successful
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
totalSize: text('total_size'),
videoId: text('video_id'), // Reference to video for skipped items
downloadedAt: integer('downloaded_at'), // Original download timestamp for deleted items
deletedAt: integer('deleted_at'), // Deletion timestamp for deleted items
});
export const subscriptions = sqliteTable('subscriptions', {
@@ -118,3 +121,17 @@ export const subscriptions = sqliteTable('subscriptions', {
createdAt: integer('created_at').notNull(),
platform: text('platform').default('YouTube'),
});
// Track downloaded video IDs to prevent re-downloading
export const videoDownloads = sqliteTable('video_downloads', {
id: text('id').primaryKey(), // Unique identifier
sourceVideoId: text('source_video_id').notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.)
sourceUrl: text('source_url').notNull(), // Original source URL
platform: text('platform').notNull(), // YouTube, Bilibili, MissAV, etc.
videoId: text('video_id'), // Reference to local video ID (null if deleted)
title: text('title'), // Video title for display
author: text('author'), // Video author
status: text('status').notNull().default('exists'), // 'exists' or 'deleted'
downloadedAt: integer('downloaded_at').notNull(), // Timestamp of first download
deletedAt: integer('deleted_at'), // Timestamp when video was deleted (nullable)
});

View File

@@ -25,6 +25,7 @@ router.post("/scan-files", scanController.scanFiles);
router.post("/cleanup-temp-files", cleanupController.cleanupTempFiles);
router.get("/download-status", videoController.getDownloadStatus);
router.get("/check-video-download", videoController.checkVideoDownloadStatus);
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);

View File

@@ -1,6 +1,7 @@
import { CloudStorageService } from "./CloudStorageService";
import { createDownloadTask } from "./downloadService";
import * as storageService from "./storageService";
import { extractSourceVideoId } from "../utils/helpers";
interface DownloadTask {
downloadFn: (registerCancel: (cancel: () => void) => void) => Promise<any>;
@@ -282,6 +283,35 @@ class DownloadManager {
sourceUrl: videoData.sourceUrl || task.sourceUrl,
author: videoData.author,
});
// Record video download for future duplicate detection
const sourceUrl = videoData.sourceUrl || task.sourceUrl;
if (sourceUrl && videoData.id) {
const { id: sourceVideoId, platform } = extractSourceVideoId(sourceUrl);
if (sourceVideoId) {
// Check if this is a re-download of previously deleted video
const existingRecord = storageService.checkVideoDownloadBySourceId(sourceVideoId);
if (existingRecord.found && existingRecord.status === "deleted") {
// Update existing record
storageService.updateVideoDownloadRecord(
sourceVideoId,
videoData.id,
finalTitle || task.title,
videoData.author
);
} else if (!existingRecord.found) {
// New download, create record
storageService.recordVideoDownload(
sourceVideoId,
sourceUrl,
platform,
videoData.id,
finalTitle || task.title,
videoData.author
);
}
}
}
}
// Trigger Cloud Upload (Async, don't await to block queue processing?)

View File

@@ -16,6 +16,7 @@ import {
downloadHistory,
downloads,
settings,
videoDownloads,
videos,
} from "../db/schema";
@@ -62,11 +63,37 @@ export interface DownloadHistoryItem {
author?: string;
sourceUrl?: string;
finishedAt: number;
status: "success" | "failed";
status: "success" | "failed" | "skipped" | "deleted";
error?: string;
videoPath?: string;
thumbnailPath?: string;
totalSize?: string;
videoId?: string; // Reference to the video for skipped items
downloadedAt?: number; // Original download timestamp for deleted items
deletedAt?: number; // Deletion timestamp for deleted items
}
export interface VideoDownloadRecord {
id: string;
sourceVideoId: string;
sourceUrl: string;
platform: string;
videoId?: string;
title?: string;
author?: string;
status: "exists" | "deleted";
downloadedAt: number;
deletedAt?: number;
}
export interface VideoDownloadCheckResult {
found: boolean;
status?: "exists" | "deleted";
videoId?: string;
title?: string;
author?: string;
downloadedAt?: number;
deletedAt?: number;
}
export interface DownloadStatus {
@@ -211,6 +238,86 @@ export function initializeStorage(): void {
console.log("Migration successful: type added.");
}
// Create video_downloads table if it doesn't exist
sqlite
.prepare(
`
CREATE TABLE IF NOT EXISTS video_downloads (
id TEXT PRIMARY KEY NOT NULL,
source_video_id TEXT NOT NULL,
source_url TEXT NOT NULL,
platform TEXT NOT NULL,
video_id TEXT,
title TEXT,
author TEXT,
status TEXT DEFAULT 'exists' NOT NULL,
downloaded_at INTEGER NOT NULL,
deleted_at INTEGER
)
`
)
.run();
// Create indexes for video_downloads
try {
sqlite
.prepare(
`CREATE INDEX IF NOT EXISTS video_downloads_source_video_id_idx ON video_downloads (source_video_id)`
)
.run();
sqlite
.prepare(
`CREATE INDEX IF NOT EXISTS video_downloads_source_url_idx ON video_downloads (source_url)`
)
.run();
} catch (indexError) {
// Indexes might already exist, ignore error
}
// Check download_history table for video_id, downloaded_at, deleted_at columns
const downloadHistoryTableInfo = sqlite
.prepare("PRAGMA table_info(download_history)")
.all();
const downloadHistoryColumns = (downloadHistoryTableInfo as any[]).map(
(col: any) => col.name
);
if (!downloadHistoryColumns.includes("video_id")) {
console.log(
"Migrating database: Adding video_id column to download_history table..."
);
sqlite
.prepare("ALTER TABLE download_history ADD COLUMN video_id TEXT")
.run();
console.log("Migration successful: video_id added to download_history.");
}
if (!downloadHistoryColumns.includes("downloaded_at")) {
console.log(
"Migrating database: Adding downloaded_at column to download_history table..."
);
sqlite
.prepare(
"ALTER TABLE download_history ADD COLUMN downloaded_at INTEGER"
)
.run();
console.log(
"Migration successful: downloaded_at added to download_history."
);
}
if (!downloadHistoryColumns.includes("deleted_at")) {
console.log(
"Migrating database: Adding deleted_at column to download_history table..."
);
sqlite
.prepare("ALTER TABLE download_history ADD COLUMN deleted_at INTEGER")
.run();
console.log(
"Migration successful: deleted_at added to download_history."
);
}
// Populate fileSize for existing videos
const allVideos = db.select().from(videos).all();
let updatedCount = 0;
@@ -409,6 +516,9 @@ export function addDownloadHistoryItem(item: DownloadHistoryItem): void {
videoPath: item.videoPath,
thumbnailPath: item.thumbnailPath,
totalSize: item.totalSize,
videoId: item.videoId,
downloadedAt: item.downloadedAt,
deletedAt: item.deletedAt,
})
.run();
} catch (error) {
@@ -425,13 +535,16 @@ export function getDownloadHistory(): DownloadHistoryItem[] {
.all();
return history.map((h) => ({
...h,
status: h.status as "success" | "failed",
status: h.status as "success" | "failed" | "skipped" | "deleted",
author: h.author || undefined,
sourceUrl: h.sourceUrl || undefined,
error: h.error || undefined,
videoPath: h.videoPath || undefined,
thumbnailPath: h.thumbnailPath || undefined,
totalSize: h.totalSize || undefined,
videoId: h.videoId || undefined,
downloadedAt: h.downloadedAt || undefined,
deletedAt: h.deletedAt || undefined,
}));
} catch (error) {
console.error("Error getting download history:", error);
@@ -455,6 +568,161 @@ export function clearDownloadHistory(): void {
}
}
// --- Video Download Tracking ---
/**
* Check if a video has been downloaded before by its source video ID
*/
export function checkVideoDownloadBySourceId(
sourceVideoId: string
): VideoDownloadCheckResult {
try {
const record = db
.select()
.from(videoDownloads)
.where(eq(videoDownloads.sourceVideoId, sourceVideoId))
.get();
if (record) {
return {
found: true,
status: record.status as "exists" | "deleted",
videoId: record.videoId || undefined,
title: record.title || undefined,
author: record.author || undefined,
downloadedAt: record.downloadedAt,
deletedAt: record.deletedAt || undefined,
};
}
return { found: false };
} catch (error) {
console.error("Error checking video download by source ID:", error);
return { found: false };
}
}
/**
* Check if a video has been downloaded before by its source URL
*/
export function checkVideoDownloadByUrl(
sourceUrl: string
): VideoDownloadCheckResult {
try {
const record = db
.select()
.from(videoDownloads)
.where(eq(videoDownloads.sourceUrl, sourceUrl))
.get();
if (record) {
return {
found: true,
status: record.status as "exists" | "deleted",
videoId: record.videoId || undefined,
title: record.title || undefined,
author: record.author || undefined,
downloadedAt: record.downloadedAt,
deletedAt: record.deletedAt || undefined,
};
}
return { found: false };
} catch (error) {
console.error("Error checking video download by URL:", error);
return { found: false };
}
}
/**
* Record a new video download
*/
export function recordVideoDownload(
sourceVideoId: string,
sourceUrl: string,
platform: string,
videoId: string,
title?: string,
author?: string
): void {
try {
const id = `${platform}-${sourceVideoId}-${Date.now()}`;
db.insert(videoDownloads)
.values({
id,
sourceVideoId,
sourceUrl,
platform,
videoId,
title,
author,
status: "exists",
downloadedAt: Date.now(),
})
.onConflictDoUpdate({
target: videoDownloads.id,
set: {
videoId,
title,
author,
status: "exists",
deletedAt: null,
},
})
.run();
console.log(
`Recorded video download: ${title || sourceVideoId} (${platform})`
);
} catch (error) {
console.error("Error recording video download:", error);
}
}
/**
* Mark a video as deleted in the download history
*/
export function markVideoDownloadDeleted(videoId: string): void {
try {
db.update(videoDownloads)
.set({
status: "deleted",
deletedAt: Date.now(),
videoId: null,
})
.where(eq(videoDownloads.videoId, videoId))
.run();
console.log(`Marked video download as deleted: ${videoId}`);
} catch (error) {
console.error("Error marking video download as deleted:", error);
}
}
/**
* Update video download record when re-downloading a previously deleted video
*/
export function updateVideoDownloadRecord(
sourceVideoId: string,
newVideoId: string,
title?: string,
author?: string
): void {
try {
db.update(videoDownloads)
.set({
videoId: newVideoId,
title,
author,
status: "exists",
deletedAt: null,
})
.where(eq(videoDownloads.sourceVideoId, sourceVideoId))
.run();
console.log(`Updated video download record: ${title || sourceVideoId}`);
} catch (error) {
console.error("Error updating video download record:", error);
}
}
// --- Settings ---
export function getSettings(): Record<string, any> {
@@ -624,6 +892,9 @@ export function deleteVideo(id: string): boolean {
}
}
// Mark video as deleted in download history
markVideoDownloadDeleted(id);
// Delete from DB
db.delete(videos).where(eq(videos.id, id)).run();
return true;

View File

@@ -101,6 +101,64 @@ export function extractBilibiliVideoId(url: string): string | null {
return null;
}
// Helper function to extract video ID from YouTube URL
export function extractYouTubeVideoId(url: string): string | null {
// Standard YouTube URL: youtube.com/watch?v=VIDEO_ID
const watchMatch = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
if (watchMatch && watchMatch[1]) {
return watchMatch[1];
}
// Short YouTube URL: youtu.be/VIDEO_ID
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
if (shortMatch && shortMatch[1]) {
return shortMatch[1];
}
// Embed URL: youtube.com/embed/VIDEO_ID
const embedMatch = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (embedMatch && embedMatch[1]) {
return embedMatch[1];
}
// Shorts URL: youtube.com/shorts/VIDEO_ID
const shortsMatch = url.match(/\/shorts\/([a-zA-Z0-9_-]{11})/);
if (shortsMatch && shortsMatch[1]) {
return shortsMatch[1];
}
return null;
}
// Helper function to extract video ID from MissAV/123AV URL
export function extractMissAVVideoId(url: string): string | null {
// Extract video ID from MissAV URL pattern like /v/VIDEO_ID or /dm*/VIDEO_ID
const vidMatch = url.match(/\/(?:v|dm\d*)\/([a-zA-Z0-9-]+)/);
if (vidMatch && vidMatch[1]) {
return vidMatch[1];
}
return null;
}
// Helper function to extract source video ID from any supported URL
export function extractSourceVideoId(url: string): { id: string | null; platform: string } {
if (isBilibiliUrl(url)) {
return { id: extractBilibiliVideoId(url), platform: "bilibili" };
}
if (url.includes("youtube.com") || url.includes("youtu.be")) {
return { id: extractYouTubeVideoId(url), platform: "youtube" };
}
if (url.includes("missav") || url.includes("123av")) {
return { id: extractMissAVVideoId(url), platform: "missav" };
}
// For other URLs, use the full URL as ID (normalized)
return { id: url, platform: "other" };
}
// Helper function to create a safe filename that preserves non-Latin characters
export function sanitizeFilename(filename: string): string {
// Remove hashtags (e.g. #tag)

View File

@@ -0,0 +1,123 @@
import {
Delete as DeleteIcon,
Download as DownloadIcon
} from '@mui/icons-material';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography
} from '@mui/material';
import React from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface ExistingVideoInfo {
type: 'deleted';
title?: string;
author?: string;
downloadedAt?: number;
deletedAt?: number;
}
interface ExistingVideoModalProps {
open: boolean;
onClose: () => void;
videoInfo: ExistingVideoInfo | null;
onDownloadAgain: () => void;
}
const ExistingVideoModal: React.FC<ExistingVideoModalProps> = ({
open,
onClose,
videoInfo,
onDownloadAgain
}) => {
const { t } = useLanguage();
if (!videoInfo) return null;
const formatDate = (timestamp?: number) => {
if (!timestamp) return '';
return new Date(timestamp).toLocaleString();
};
const handleDownloadAgain = () => {
onDownloadAgain();
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: { borderRadius: 2 }
}
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<DeleteIcon color="warning" />
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{t('previouslyDeletedVideo')}
</Typography>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Title and Author */}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
{videoInfo.title || 'Unknown Title'}
</Typography>
{videoInfo.author && (
<Typography variant="body2" color="text.secondary">
{videoInfo.author}
</Typography>
)}
</Box>
{/* Status Message */}
<Typography variant="body1" color="text.primary">
{t('videoWasDeleted')}
</Typography>
{/* Timestamps */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{videoInfo.downloadedAt && (
<Typography variant="caption" color="text.secondary">
{t('downloadedOn')}: {formatDate(videoInfo.downloadedAt)}
</Typography>
)}
{videoInfo.deletedAt && (
<Typography variant="caption" color="text.secondary">
{t('deletedOn')}: {formatDate(videoInfo.deletedAt)}
</Typography>
)}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button onClick={onClose} color="inherit">
{t('cancel')}
</Button>
<Button
onClick={handleDownloadAgain}
variant="contained"
color="primary"
startIcon={<DownloadIcon />}
autoFocus
>
{t('downloadAgain')}
</Button>
</DialogActions>
</Dialog>
);
};
export default ExistingVideoModal;

View File

@@ -21,6 +21,7 @@ interface BilibiliPartsInfo {
collectionInfo: any;
}
interface DownloadContextType {
activeDownloads: DownloadInfo[];
queuedDownloads: DownloadInfo[];
@@ -110,6 +111,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
});
const [isCheckingParts, setIsCheckingParts] = useState<boolean>(false);
// Reference to track current download IDs for detecting completion
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
@@ -152,7 +154,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
await queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
};
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false, skipPartsCheck = false): Promise<any> => {
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false, skipPartsCheck = false, forceDownload = false): Promise<any> => {
try {
// Check for YouTube channel URL
// Regex for: @username, channel/ID, user/username, c/customURL
@@ -223,7 +225,22 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
}
// Normal download flow
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: videoUrl,
forceDownload: forceDownload
});
// Check if video was skipped (already exists or previously deleted)
if (response.data.skipped) {
if (response.data.previouslyDeleted) {
showSnackbar(t('videoSkippedDeleted') || 'Video was previously deleted, skipped download');
} else {
showSnackbar(t('videoSkippedExists') || 'Video already exists, skipped download');
}
// Invalidate download history to show the skipped/deleted entry
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
return { success: true, skipped: true };
}
// If the response contains a downloadId, it means it was queued/started
if (response.data.downloadId) {
@@ -252,6 +269,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
}
};
const handleDownloadAllBilibiliParts = async (collectionName: string) => {
try {
setShowBilibiliPartsModal(false);

View File

@@ -5,8 +5,11 @@ import {
CloudUpload,
Delete as DeleteIcon,
Error as ErrorIcon,
PlayArrow as PlayArrowIcon,
PlaylistAdd as PlaylistAddIcon,
Replay as ReplayIcon
Replay as ReplayIcon,
SkipNext as SkipNextIcon,
Warning as WarningIcon
} from '@mui/icons-material';
import {
Box,
@@ -41,11 +44,14 @@ interface DownloadHistoryItem {
author?: string;
sourceUrl?: string;
finishedAt: number;
status: 'success' | 'failed';
status: 'success' | 'failed' | 'skipped' | 'deleted';
error?: string;
videoPath?: string;
thumbnailPath?: string;
totalSize?: string;
videoId?: string;
downloadedAt?: number;
deletedAt?: number;
}
interface TabPanelProps {
@@ -222,6 +228,27 @@ const DownloadPage: React.FC = () => {
removeFromHistoryMutation.mutate(id);
};
// Re-download deleted video
const handleReDownload = async (sourceUrl: string) => {
if (!sourceUrl) return;
try {
// Call download with forceDownload flag
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: sourceUrl,
forceDownload: true
});
if (response.data.downloadId) {
showSnackbar(t('videoDownloading') || 'Video downloading');
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
}
} catch (error: any) {
console.error('Error re-downloading video:', error);
showSnackbar(t('error') || 'Error');
}
};
// Clear history mutation
const clearHistoryMutation = useMutation({
mutationFn: async () => {
@@ -432,9 +459,24 @@ const DownloadPage: React.FC = () => {
{item.sourceUrl}
</Typography>
)}
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>
{item.status === 'deleted' ? (
<>
{item.downloadedAt && (
<Typography variant="caption" component="span">
{t('downloadedOn') || 'Downloaded on'}: {formatDate(item.downloadedAt)}
</Typography>
)}
{item.deletedAt && (
<Typography variant="caption" component="span">
{t('deletedOn') || 'Deleted on'}: {formatDate(item.deletedAt)}
</Typography>
)}
</>
) : (
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>
)}
{item.error && (
<Typography variant="caption" color="error" component="span">
{item.error}
@@ -456,8 +498,36 @@ const DownloadPage: React.FC = () => {
{t('retry') || 'Retry'}
</Button>
)}
{item.status === 'skipped' && item.videoId && (
<Button
variant="outlined"
color="primary"
size="small"
startIcon={<PlayArrowIcon />}
onClick={() => window.location.href = `/video/${item.videoId}`}
sx={{ minWidth: '100px' }}
>
{t('viewVideo') || 'View Video'}
</Button>
)}
{item.status === 'deleted' && item.sourceUrl && (
<Button
variant="outlined"
color="primary"
size="small"
startIcon={<ReplayIcon />}
onClick={() => handleReDownload(item.sourceUrl!)}
sx={{ minWidth: '120px' }}
>
{t('downloadAgain') || 'Download Again'}
</Button>
)}
{item.status === 'success' ? (
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
) : item.status === 'skipped' ? (
<Chip icon={<SkipNextIcon />} label={t('skipped') || 'Skipped'} color="info" size="small" />
) : item.status === 'deleted' ? (
<Chip icon={<WarningIcon />} label={t('previouslyDeleted') || 'Previously Deleted'} color="warning" size="small" />
) : (
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
)}

View File

@@ -369,4 +369,18 @@ export const en = {
disclaimerTitle: "Disclaimer",
disclaimerText: "1. Purpose and Restrictions\nThis software (including code and documentation) is intended solely for personal learning, research, and technical exchange. It is strictly prohibited to use this software for any commercial purposes or for any illegal activities that violate local laws and regulations.\n\n2. Liability\nThe developer is unaware of and has no control over how users utilize this software. Any legal liabilities, disputes, or damages arising from the illegal or improper use of this software (including but not limited to copyright infringement) shall be borne solely by the user. The developer assumes no direct, indirect, or joint liability.\n\n3. Modifications and Distribution\nThis project is open-source. Any individual or organization modifying or forking this code must comply with the open-source license. Important: If a third party modifies the code to bypass or remove the original user authentication/security mechanisms and distributes such versions, the modifier/distributor bears full responsibility for any consequences. We strongly discourage bypassing or tampering with any security verification mechanisms.\n\n4. Non-Profit Statement\nThis is a completely free open-source project. The developer does not accept donations and has never published any donation pages. The software itself allows no charges and offers no paid services. Please be vigilant and beware of any scams or misleading information claiming to collect fees on behalf of this project.",
history: 'History',
// Existing Video Detection
existingVideoDetected: "Existing Video Detected",
videoAlreadyDownloaded: "This video has already been downloaded.",
viewVideo: "View Video",
previouslyDeletedVideo: "Previously Deleted Video",
videoWasDeleted: "This video was previously downloaded but has been deleted.",
downloadAgain: "Download Again",
downloadedOn: "Downloaded on",
deletedOn: "Deleted on",
existingVideo: "Existing Video",
skipped: "Skipped",
videoSkippedExists: "Video already exists, skipped download",
videoSkippedDeleted: "Video was previously deleted, skipped download",
};

View File

@@ -1,368 +1,407 @@
export const zh = {
// Header
myTube: "MyTube",
manage: "管理",
settings: "设置",
logout: "退出",
pleaseEnterUrlOrSearchTerm: "请输入视频链接或搜索关键词",
unexpectedErrorOccurred: "发生意外错误,请重试",
uploadVideo: "上传视频",
enterUrlOrSearchTerm: "输入视频链接或搜索关键词",
manageVideos: "管理视频",
instruction: "使用说明",
// Home
pasteUrl: "粘贴视频或合集链接",
download: "下载",
search: "搜索",
recentDownloads: "最近下载",
noDownloads: "暂无下载",
downloadStarted: "开始下载",
downloadFailed: "下载失败",
loadingVideos: "加载视频中...",
searchResultsFor: "搜索结果:",
fromYourLibrary: "来自您的媒体库",
noMatchingVideos: "媒体库中未找到匹配视频",
fromYouTube: "来自 YouTube",
loadingYouTubeResults: "加载 YouTube 结果中...",
noYouTubeResults: "未找到 YouTube 结果",
noVideosYet: "暂无视频。提交视频链接以开始下载您的第一个视频!",
views: "次观看",
// Settings
general: "常规",
security: "安全",
videoDefaults: "播放器默认设置",
downloadSettings: "下载设置",
language: "语言",
enableLogin: "启用登录保护",
password: "密码",
passwordHelper: "留空以保持当前密码,或输入新密码以更改",
passwordSetHelper: "设置访问应用程序的密码",
autoPlay: "自动播放视频",
autoLoop: "自动循环播放",
maxConcurrent: "最大同时下载数",
saveSettings: "保存设置",
saving: "保存中...",
backToManage: "返回管理",
settingsSaved: "设置保存成功",
settingsFailed: "保存设置失败",
debugMode: "调试模式",
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
tagsManagement: "标签管理",
newTag: "新标签",
tags: "标签",
tagsManagementNote: "添加或删除标签后,请记得点击“保存设置”以应用更改。",
// Database
database: "数据库",
migrateDataDescription: "从旧版 JSON 文件迁移数据到新的 SQLite 数据库。此操作可以安全地多次运行(将跳过重复项)。",
migrateDataButton: "从 JSON 迁移数据",
scanFiles: "扫描文件",
scanFilesSuccess: "扫描完成。添加了 {count} 个新视频。",
scanFilesDeleted: " 移除了 {count} 个缺失文件。",
scanFilesFailed: "扫描失败",
scanFilesConfirmMessage: "系统将扫描视频路径的根文件夹。新文件将被添加,缺失的视频文件将从系统中移除。",
scanning: "扫描中...",
migrateConfirmation: "您确定要迁移数据吗?这可能需要一些时间。",
migrationResults: "迁移结果",
migrationReport: "迁移报告",
migrationSuccess: "迁移完成。请查看警报中的详细信息。",
migrationNoData: "迁移完成但未找到数据。",
migrationFailed: "迁移失败",
migrationWarnings: "警告",
migrationErrors: "错误",
itemsMigrated: "项已迁移",
fileNotFound: "未找到文件于",
noDataFilesFound: "未找到可迁移的数据文件。请检查您的卷映射。",
removeLegacyData: "删除旧数据",
removeLegacyDataDescription: "删除旧的 JSON 文件videos.json, collections.json 等)以释放磁盘空间。请仅在确认数据已成功迁移后执行此操作。",
removeLegacyDataConfirmTitle: "删除旧数据?",
removeLegacyDataConfirmMessage: "确定要删除旧的 JSON 数据文件吗?此操作无法撤销。",
legacyDataDeleted: "旧数据删除成功。",
deleteLegacyDataButton: "删除旧数据",
cleanupTempFiles: "清理临时文件",
cleanupTempFilesDescription: "从上传目录中删除所有临时下载文件(.ytdl、.part。这有助于释放未完成或已取消下载占用的磁盘空间。",
cleanupTempFilesConfirmTitle: "清理临时文件?",
cleanupTempFilesConfirmMessage: "这将永久删除上传目录中的所有.ytdl和.part文件。请确保没有正在进行的下载。",
cleanupTempFilesActiveDownloads: "有活动下载时无法清理。请等待所有下载完成或取消它们。",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
// Header
myTube: "MyTube",
manage: "管理",
settings: "设置",
logout: "退出",
pleaseEnterUrlOrSearchTerm: "请输入视频链接或搜索关键词",
unexpectedErrorOccurred: "发生意外错误,请重试",
uploadVideo: "上传视频",
enterUrlOrSearchTerm: "输入视频链接或搜索关键词",
manageVideos: "管理视频",
instruction: "使用说明",
// Cookie Settings
cookieSettings: "Cookie 设置",
cookieUploadDescription: "上传 cookies.txt 以通过 YouTube 机器人检测并启用 Bilibili 字幕下载。文件将自动重命名为 cookies.txt。(例如:使用 \"Get cookies.txt LOCALLY\" 扩展导出 cookies)",
uploadCookies: "上传 Cookie",
onlyTxtFilesAllowed: "仅允许 .txt 文件",
cookiesUploadedSuccess: "Cookie 上传成功",
cookiesUploadFailed: "上传 Cookies 失败",
cookiesFound: "已找到 cookies.txt",
cookiesNotFound: "未找到 cookies.txt",
deleteCookies: "删除 Cookies",
confirmDeleteCookies: "您确定要删除 cookies 文件吗?这将影响您下载有年龄限制或仅限会员视频的能力。",
cookiesDeletedSuccess: "Cookies 删除成功",
cookiesDeleteFailed: "删除 Cookies 失败",
// Home
pasteUrl: "粘贴视频或合集链接",
download: "下载",
search: "搜索",
recentDownloads: "最近下载",
noDownloads: "暂无下载",
downloadStarted: "开始下载",
downloadFailed: "下载失败",
loadingVideos: "加载视频中...",
searchResultsFor: "搜索结果:",
fromYourLibrary: "来自您的媒体库",
noMatchingVideos: "媒体库中未找到匹配视频",
fromYouTube: "来自 YouTube",
loadingYouTubeResults: "加载 YouTube 结果中...",
noYouTubeResults: "未找到 YouTube 结果",
noVideosYet: "暂无视频。提交视频链接以开始下载您的第一个视频!",
views: "次观看",
// Cloud Drive
cloudDriveSettings: "云端存储 (OpenList)",
enableAutoSave: "启用自动保存到云端",
apiUrl: "API 地址",
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
// Manage
manageContent: "内容管理",
videos: "视频",
collections: "合集",
allVideos: "所有视频",
delete: "删除",
backToHome: "返回首页",
confirmDelete: "确定要删除吗?",
deleteSuccess: "删除成功",
deleteFailed: "删除失败",
noVideos: "未找到视频",
noCollectionsFound: "未找到合集",
noCollections: "未找到合集",
searchVideos: "搜索视频...",
thumbnail: "缩略图",
title: "标题",
author: "作者",
authors: "作者列表",
created: "创建时间",
name: "名称",
size: "大小",
actions: "操作",
deleteCollection: "删除合集",
deleteVideo: "删除视频",
noVideosFoundMatching: "未找到匹配的视频。",
// Video Player
playing: "播放",
paused: "暂停",
next: "下一个",
previous: "上一个",
loop: "循环",
autoPlayOn: "自动播放已开启",
autoPlayOff: "自动播放已关闭",
autoPlayNext: "自动播放下一个",
videoNotFound: "未找到视频",
videoNotFoundOrLoaded: "未找到视频或无法加载。",
deleting: "删除中...",
addToCollection: "添加到合集",
originalLink: "原始链接",
source: "来源:",
addedDate: "添加日期:",
latestComments: "最新评论",
noComments: "暂无评论。",
hideComments: '隐藏评论',
showComments: '显示评论',
upNext: "接下来播放",
noOtherVideos: "没有其他视频",
currentlyIn: "当前所在:",
collectionWarning: "添加到其他合集将从当前合集中移除。",
addToExistingCollection: "添加到现有合集:",
selectCollection: "选择合集",
add: "添加",
createNewCollection: "创建新合集:",
collectionName: "合集名称",
create: "创建",
removeFromCollection: "从合集中移除",
confirmRemoveFromCollection: "确定要从合集中移除此视频吗?",
remove: "移除",
loadingVideo: "加载视频中...",
current: "(当前)",
rateThisVideo: "给视频评分",
enterFullscreen: "全屏",
exitFullscreen: "退出全屏",
share: "分享",
editTitle: "编辑标题",
titleUpdated: "标题更新成功",
titleUpdateFailed: "更新标题失败",
refreshThumbnail: "刷新缩略图",
thumbnailRefreshed: "缩略图刷新成功",
thumbnailRefreshFailed: "刷新缩略图失败",
videoUpdated: "视频更新成功",
videoUpdateFailed: "更新视频失败",
failedToLoadVideos: "加载视频失败。请稍后再试。",
videoRemovedSuccessfully: "视频删除成功",
failedToDeleteVideo: "删除视频失败",
pleaseEnterSearchTerm: "请输入搜索词",
failedToSearch: "搜索失败。请稍后再试。",
searchCancelled: "搜索已取消",
// Login
signIn: "登录",
verifying: "验证中...",
incorrectPassword: "密码错误",
loginFailed: "验证密码失败",
defaultPasswordHint: "默认密码123",
checkingConnection: "正在检查连接...",
connectionError: "连接错误",
backendConnectionFailed: "无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
retry: "重试",
// Settings
general: "常规",
security: "安全",
videoDefaults: "播放器默认设置",
downloadSettings: "下载设置",
language: "语言",
enableLogin: "启用登录保护",
password: "密码",
passwordHelper: "留空以保持当前密码,或输入新密码以更改",
passwordSetHelper: "设置访问应用程序的密码",
autoPlay: "自动播放视频",
autoLoop: "自动循环播放",
maxConcurrent: "最大同时下载数",
saveSettings: "保存设置",
saving: "保存中...",
backToManage: "返回管理",
settingsSaved: "设置保存成功",
settingsFailed: "保存设置失败",
debugMode: "调试模式",
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
tagsManagement: "标签管理",
newTag: "新标签",
tags: "标签",
tagsManagementNote: "添加或删除标签后,请记得点击“保存设置”以应用更改。",
// Collection Page
loadingCollection: "加载合集中...",
collectionNotFound: "未找到合集",
noVideosInCollection: "此合集中没有视频。",
back: "返回",
// Snackbar Messages
videoDownloading: "视频下载中",
downloadStartedSuccessfully: "下载已成功开始",
collectionCreatedSuccessfully: "集合创建成功",
videoAddedToCollection: "视频已添加到集合",
videoRemovedFromCollection: "视频已从集合中移除",
collectionDeletedSuccessfully: "集合删除成功",
failedToDeleteCollection: "删除集合失败",
// Author Videos
loadVideosError: "加载视频失败,请稍后再试。",
unknownAuthor: "未知",
noVideosForAuthor: "未找到该作者的视频。",
// Delete Collection Modal
deleteCollectionTitle: "删除合集",
deleteCollectionConfirmation: "确定要删除合集",
collectionContains: "此合集包含",
deleteCollectionOnly: "仅删除合集",
deleteCollectionAndVideos: "删除合集及所有视频",
// Common
loading: "加载中...",
error: "错误",
success: "成功",
cancel: "取消",
confirm: "确认",
save: "保存",
on: "开启",
off: "关",
continue: "继续",
expand: "展开",
collapse: "收起",
// Video Card
unknownDate: "未知日期",
part: "分P",
collection: "合集",
// Upload Modal
selectVideoFile: "选择视频文件",
pleaseSelectVideo: "请选择视频文件",
uploadFailed: "上传失败",
failedToUpload: "视频上传失败",
uploading: "上传中...",
upload: "上传",
// Bilibili Modal
bilibiliCollectionDetected: "检测到 Bilibili 合集",
bilibiliSeriesDetected: "检测到 Bilibili 系列",
multiPartVideoDetected: "检测到多P视频",
collectionHasVideos: "此合集包含 {count} 个视频。",
seriesHasVideos: "此系列包含 {count} 个视频。",
videoHasParts: "此视频包含 {count} 个分P。",
downloadAllVideos: "下载所有 {count} 个视频",
downloadAllParts: "下载所有 {count} 个分P",
downloadThisVideoOnly: "仅下载此视频",
downloadCurrentPartOnly: "仅下载当前分P",
processing: "处理中...",
wouldYouLikeToDownloadAllParts: "您想要下载所有分P吗",
wouldYouLikeToDownloadAllVideos: "您想要下载所有视频吗?",
allPartsAddedToCollection: "所有分P将被添加到此合集",
allVideosAddedToCollection: "所有视频将被添加到此合集",
queued: "已排队",
waitingInQueue: "等待中",
// Database
database: "数据库",
migrateDataDescription:
"从旧版 JSON 文件迁移数据到新的 SQLite 数据库。此操作可以安全地多次运行(将跳过重复项)。",
migrateDataButton: "从 JSON 迁移数据",
scanFiles: "扫描文件",
scanFilesSuccess: "扫描完成。添加了 {count} 个新视频。",
scanFilesDeleted: " 移除了 {count} 个缺失文件。",
scanFilesFailed: "扫描失败",
scanFilesConfirmMessage:
"系统将扫描视频路径的根文件夹。新文件将被添加,缺失的视频文件将从系统中移除。",
scanning: "扫描中...",
migrateConfirmation: "您确定要迁移数据吗?这可能需要一些时间。",
migrationResults: "迁移结果",
migrationReport: "迁移报告",
migrationSuccess: "迁移完成。请查看警报中的详细信息。",
migrationNoData: "迁移完成但未找到数据。",
migrationFailed: "迁移失败",
migrationWarnings: "警告",
migrationErrors: "错误",
itemsMigrated: "项已迁移",
fileNotFound: "未找到文件于",
noDataFilesFound: "未找到可迁移的数据文件。请检查您的卷映射。",
removeLegacyData: "删除旧数据",
removeLegacyDataDescription:
"删除旧的 JSON 文件videos.json, collections.json 等)以释放磁盘空间。请仅在确认数据已成功迁移后执行此操作。",
removeLegacyDataConfirmTitle: "删除旧数据?",
removeLegacyDataConfirmMessage:
"确定要删除旧的 JSON 数据文件吗?此操作无法撤销。",
legacyDataDeleted: "旧数据删除成功。",
deleteLegacyDataButton: "删除旧数据",
cleanupTempFiles: "清理临时文件",
cleanupTempFilesDescription:
"从上传目录中删除所有临时下载文件(.ytdl、.part。这有助于释放未完成或已取消下载占用的磁盘空间。",
cleanupTempFilesConfirmTitle: "清理临时文件?",
cleanupTempFilesConfirmMessage:
"这将永久删除上传目录中的所有.ytdl和.part文件。请确保没有正在进行的下载。",
cleanupTempFilesActiveDownloads:
"有活动下载时无法清理。请等待所有下载完成或取消它们。",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
// Downloads
downloads: "下载",
activeDownloads: "进行中的下载",
manageDownloads: "管理下载",
queuedDownloads: "排队中的下载",
downloadHistory: "下载历史",
clearQueue: "清空队列",
clearHistory: "清空历史",
noActiveDownloads: "暂无进行中的下载",
noQueuedDownloads: "暂无排队的下载",
noDownloadHistory: "暂无下载历史",
downloadCancelled: "下载已取消",
queueCleared: "队列已清空",
historyCleared: "历史已清空",
removedFromQueue: "已从队列移除",
removedFromHistory: "已从历史移除",
status: "状态",
progress: "进度",
speed: "速度",
finishedAt: "完成时间",
failed: "失败",
// Cookie Settings
cookieSettings: "Cookie 设置",
cookieUploadDescription:
'上传 cookies.txt 以通过 YouTube 机器人检测并启用 Bilibili 字幕下载。文件将自动重命名为 cookies.txt。(例如:使用 "Get cookies.txt LOCALLY" 扩展导出 cookies)',
uploadCookies: "上传 Cookie",
onlyTxtFilesAllowed: "仅允许 .txt 文件",
cookiesUploadedSuccess: "Cookie 上传成功",
cookiesUploadFailed: "上传 Cookies 失败",
cookiesFound: "已找到 cookies.txt",
cookiesNotFound: "未找到 cookies.txt",
deleteCookies: "删除 Cookies",
confirmDeleteCookies:
"您确定要删除 cookies 文件吗?这将影响您下载有年龄限制或仅限会员视频的能力。",
cookiesDeletedSuccess: "Cookies 删除成功",
cookiesDeleteFailed: "删除 Cookies 失败",
// Batch Download
batchDownload: "批量下载",
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
urls: "链接",
addToQueue: "添加到队列",
batchTasksAdded: "已添加 {count} 个任务",
addBatchTasks: "添加批量任务",
// Cloud Drive
cloudDriveSettings: "云端存储 (OpenList)",
enableAutoSave: "启用自动保存到云端",
apiUrl: "API 地址",
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
// Subscriptions
subscribeToAuthor: "订阅作者",
subscribeConfirmationMessage: "您确定要订阅 {author} 吗?",
subscribeDescription: "系统将自动检查此作者的新视频并下载。",
checkIntervalMinutes: "检查间隔(分钟)",
subscribe: "订阅",
subscriptions: "订阅",
interval: "间隔",
lastCheck: "上次检查",
platform: "平台",
unsubscribe: "取消订阅",
confirmUnsubscribe: "您确定要取消订阅 {author} 吗?",
subscribedSuccessfully: "订阅成功",
unsubscribedSuccessfully: "取消订阅成功",
subscriptionAlreadyExists: "您已订阅此作者。",
minutes: "分钟",
never: "从未",
// Instruction Page
instructionSection1Title: "1. 下载与任务管理",
instructionSection1Desc: "本模块包含视频获取、批量任务及文件导入等功能。",
instructionSection1Sub1: "链接下载:",
instructionSection1Item1Label: "基础下载:",
instructionSection1Item1Text: "在链接文本框中粘贴各类视频网站的链接即可直接下载。",
instructionSection1Item2Label: "权限说明:",
instructionSection1Item2Text: "部分需要会员或登录才能观看的网站,请先在浏览器内另开标签页登录对应账号,以获取下载权限。",
instructionSection1Sub2: "智能识别:",
instructionSection1Item3Label: "YouTube 作者订阅:",
instructionSection1Item3Text: "当粘贴链接为作者个人空间时,系统将询问是否订阅。订阅后,系统可设定时间间隔,自动扫描并下载该作者的更新。",
instructionSection1Item4Label: "Bilibili 合集下载:",
instructionSection1Item4Text: "当粘贴链接为 Bilibili 收藏夹/合集时,系统将询问是否下载整个合集内容。",
instructionSection1Sub3: "高级工具(下载管理页):",
instructionSection1Item5Label: "批量添加任务:",
instructionSection1Item5Text: "支持一次性粘贴多个下载链接(请按行区分),进行批量添加。",
instructionSection1Item6Label: "扫描文件:",
instructionSection1Item6Text: "自动搜索视频储存根目录及一级文件夹下的所有文件。此功能适用于管理员在服务器后台直接存入文件后,将其批量同步至系统。",
instructionSection1Item7Label: "上传视频:",
instructionSection1Item7Text: "支持直接从客户端单独上传本地视频文件到服务器。",
// Manage
manageContent: "内容管理",
videos: "视频",
collections: "合集",
allVideos: "所有视频",
delete: "删除",
backToHome: "返回首页",
confirmDelete: "确定要删除吗?",
deleteSuccess: "删除成功",
deleteFailed: "删除失败",
noVideos: "未找到视频",
noCollectionsFound: "未找到合集",
noCollections: "未找到合集",
searchVideos: "搜索视频...",
thumbnail: "缩略图",
title: "标题",
author: "作者",
authors: "作者列表",
created: "创建时间",
name: "名称",
size: "大小",
actions: "操作",
deleteCollection: "删除合集",
deleteVideo: "删除视频",
noVideosFoundMatching: "未找到匹配的视频。",
instructionSection2Title: "2. 视频库管理",
instructionSection2Desc: "对已下载或导入的视频资源进行维护和编辑。",
instructionSection2Sub1: "合集/视频删除:",
instructionSection2Text1: "在管理页面删除合集时,系统提供两种选择:仅删除合集列表项(保留文件),或连同合集内的物理文件一并彻底删除。",
instructionSection2Sub2: "缩略图修复:",
instructionSection2Text2: "若遇到下载后视频无封面的情况,可点击视频缩略图上的刷新按钮,系统将重新抓取视频首帧作为新的缩略图。",
// Video Player
playing: "播放",
paused: "暂停",
next: "下一个",
previous: "上一个",
loop: "循环",
autoPlayOn: "自动播放已开启",
autoPlayOff: "自动播放已关闭",
autoPlayNext: "自动播放下一个",
videoNotFound: "未找到视频",
videoNotFoundOrLoaded: "未找到视频或无法加载。",
deleting: "删除中...",
addToCollection: "添加到合集",
originalLink: "原始链接",
source: "来源:",
addedDate: "添加日期:",
latestComments: "最新评论",
noComments: "暂无评论。",
hideComments: "隐藏评论",
showComments: "显示评论",
upNext: "接下来播放",
noOtherVideos: "没有其他视频",
currentlyIn: "当前所在:",
collectionWarning: "添加到其他合集将从当前合集中移除。",
addToExistingCollection: "添加到现有合集:",
selectCollection: "选择合集",
add: "添加",
createNewCollection: "创建新合集:",
collectionName: "合集名称",
create: "创建",
removeFromCollection: "从合集中移除",
confirmRemoveFromCollection: "确定要从合集中移除此视频吗?",
remove: "移除",
loadingVideo: "加载视频中...",
current: "(当前)",
rateThisVideo: "给视频评分",
enterFullscreen: "全屏",
exitFullscreen: "退出全屏",
share: "分享",
editTitle: "编辑标题",
titleUpdated: "标题更新成功",
titleUpdateFailed: "更新标题失败",
refreshThumbnail: "刷新缩略图",
thumbnailRefreshed: "缩略图刷新成功",
thumbnailRefreshFailed: "刷新缩略图失败",
videoUpdated: "视频更新成功",
videoUpdateFailed: "更新视频失败",
failedToLoadVideos: "加载视频失败。请稍后再试。",
videoRemovedSuccessfully: "视频删除成功",
failedToDeleteVideo: "删除视频失败",
pleaseEnterSearchTerm: "请输入搜索词",
failedToSearch: "搜索失败。请稍后再试。",
searchCancelled: "搜索已取消",
instructionSection3Title: "3. 系统设置",
instructionSection3Desc: "配置系统参数、维护数据及扩展功能。",
instructionSection3Sub1: "安全设定:",
instructionSection3Text1: "设置系统登录密码(默认初始密码为 123建议首次登录后修改",
instructionSection3Sub2: "标签管理:",
instructionSection3Text2: "支持添加或删除视频分类标签。注意: 所有操作完成后,必须点击页面底端的“保存”按钮方可生效。",
instructionSection3Sub3: "系统维护:",
instructionSection3Item1Label: "清理临时文件:",
instructionSection3Item1Text: "用于清除因后端偶发故障而残留的临时下载文件,释放空间。",
instructionSection3Item2Label: "数据库迁移:",
instructionSection3Item2Text: "专为早期版本用户设计。使用此功能可将数据从 JSON 迁移至新的 SQLite 数据库。迁移成功后,可点击删除按钮清理旧的历史数据。",
instructionSection3Sub4: "扩展服务:",
instructionSection3Item3Label: "OpenList 云盘:",
instructionSection3Item3Text: "(开发中)支持连接用户自行部署的 OpenList 服务,在此处添加配置后可实现云盘联动。",
history: '历史',
// Login
signIn: "登录",
verifying: "验证中...",
incorrectPassword: "密码错误",
loginFailed: "验证密码失败",
defaultPasswordHint: "默认密码123",
checkingConnection: "正在检查连接...",
connectionError: "连接错误",
backendConnectionFailed:
"无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
retry: "重试",
// Collection Page
loadingCollection: "加载合集中...",
collectionNotFound: "未找到合集",
noVideosInCollection: "此合集中没有视频。",
back: "返回",
// Snackbar Messages
videoDownloading: "视频下载中",
downloadStartedSuccessfully: "下载已成功开始",
collectionCreatedSuccessfully: "集合创建成功",
videoAddedToCollection: "视频已添加到集合",
videoRemovedFromCollection: "视频已从集合中移除",
collectionDeletedSuccessfully: "集合删除成功",
failedToDeleteCollection: "删除集合失败",
// Author Videos
loadVideosError: "加载视频失败,请稍后再试。",
unknownAuthor: "未知",
noVideosForAuthor: "未找到该作者的视频。",
// Delete Collection Modal
deleteCollectionTitle: "删除合集",
deleteCollectionConfirmation: "确定要删除合集",
collectionContains: "此合集包含",
deleteCollectionOnly: "仅删除合集",
deleteCollectionAndVideos: "删除合集及所有视频",
// Common
loading: "加载中...",
error: "错误",
success: "成功",
cancel: "取消",
confirm: "确认",
save: "保存",
on: "开启",
off: "关",
continue: "继续",
expand: "展开",
collapse: "收起",
// Video Card
unknownDate: "未知日期",
part: "分P",
collection: "合集",
// Upload Modal
selectVideoFile: "选择视频文件",
pleaseSelectVideo: "请选择视频文件",
uploadFailed: "上传失败",
failedToUpload: "视频上传失败",
uploading: "上传中...",
upload: "上传",
// Bilibili Modal
bilibiliCollectionDetected: "检测到 Bilibili 合集",
bilibiliSeriesDetected: "检测到 Bilibili 系列",
multiPartVideoDetected: "检测到多P视频",
collectionHasVideos: "此合集包含 {count} 个视频。",
seriesHasVideos: "此系列包含 {count} 个视频。",
videoHasParts: "此视频包含 {count} 个分P。",
downloadAllVideos: "下载所有 {count} 个视频",
downloadAllParts: "下载所有 {count} 个分P",
downloadThisVideoOnly: "仅下载此视频",
downloadCurrentPartOnly: "仅下载当前分P",
processing: "处理中...",
wouldYouLikeToDownloadAllParts: "您想要下载所有分P吗",
wouldYouLikeToDownloadAllVideos: "您想要下载所有视频吗?",
allPartsAddedToCollection: "所有分P将被添加到此合集",
allVideosAddedToCollection: "所有视频将被添加到此合集",
queued: "已排队",
waitingInQueue: "等待中",
// Downloads
downloads: "下载",
activeDownloads: "进行中的下载",
manageDownloads: "管理下载",
queuedDownloads: "排队中的下载",
downloadHistory: "下载历史",
clearQueue: "清空队列",
clearHistory: "清空历史",
noActiveDownloads: "暂无进行中的下载",
noQueuedDownloads: "暂无排队的下载",
noDownloadHistory: "暂无下载历史",
downloadCancelled: "下载已取消",
queueCleared: "队列已清空",
historyCleared: "历史已清空",
removedFromQueue: "已从队列移除",
removedFromHistory: "已从历史移除",
status: "状态",
progress: "进度",
speed: "速度",
finishedAt: "完成时间",
failed: "失败",
// Batch Download
batchDownload: "批量下载",
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
urls: "链接",
addToQueue: "添加到队列",
batchTasksAdded: "已添加 {count} 个任务",
addBatchTasks: "添加批量任务",
// Subscriptions
subscribeToAuthor: "订阅作者",
subscribeConfirmationMessage: "您确定要订阅 {author} 吗?",
subscribeDescription: "系统将自动检查此作者的新视频并下载。",
checkIntervalMinutes: "检查间隔(分钟)",
subscribe: "订阅",
subscriptions: "订阅",
interval: "间隔",
lastCheck: "上次检查",
platform: "平台",
unsubscribe: "取消订阅",
confirmUnsubscribe: "您确定要取消订阅 {author} 吗?",
subscribedSuccessfully: "订阅成功",
unsubscribedSuccessfully: "取消订阅成功",
subscriptionAlreadyExists: "您已订阅此作者。",
minutes: "分钟",
never: "从未",
// Existing Video Detection
existingVideoDetected: "检测到已下载视频",
videoAlreadyDownloaded: "此视频已下载过。",
viewVideo: "查看视频",
previouslyDeletedVideo: "曾下载并删除的视频",
videoWasDeleted: "此视频曾经下载过,但已被删除。",
downloadAgain: "再次下载",
downloadedOn: "下载时间",
deletedOn: "删除时间",
existingVideo: "已存在的视频",
skipped: "已跳过",
videoSkippedExists: "视频已存在,跳过下载",
videoSkippedDeleted: "视频曾被删除,跳过下载",
history: "历史",
// Instruction Page
instructionSection1Title: "1. 下载与任务管理",
instructionSection1Desc: "本模块包含视频获取、批量任务及文件导入等功能。",
instructionSection1Sub1: "链接下载:",
instructionSection1Item1Label: "基础下载:",
instructionSection1Item1Text:
"在链接文本框中粘贴各类视频网站的链接即可直接下载。",
instructionSection1Item2Label: "权限说明:",
instructionSection1Item2Text:
"部分需要会员或登录才能观看的网站,请先在浏览器内另开标签页登录对应账号,以获取下载权限。",
instructionSection1Sub2: "智能识别:",
instructionSection1Item3Label: "YouTube 作者订阅:",
instructionSection1Item3Text:
"当粘贴链接为作者个人空间时,系统将询问是否订阅。订阅后,系统可设定时间间隔,自动扫描并下载该作者的更新。",
instructionSection1Item4Label: "Bilibili 合集下载:",
instructionSection1Item4Text:
"当粘贴链接为 Bilibili 收藏夹/合集时,系统将询问是否下载整个合集内容。",
instructionSection1Sub3: "高级工具(下载管理页):",
instructionSection1Item5Label: "批量添加任务:",
instructionSection1Item5Text:
"支持一次性粘贴多个下载链接(请按行区分),进行批量添加。",
instructionSection1Item6Label: "扫描文件:",
instructionSection1Item6Text:
"自动搜索视频储存根目录及一级文件夹下的所有文件。此功能适用于管理员在服务器后台直接存入文件后,将其批量同步至系统。",
instructionSection1Item7Label: "上传视频:",
instructionSection1Item7Text:
"支持直接从客户端单独上传本地视频文件到服务器。",
instructionSection2Title: "2. 视频库管理",
instructionSection2Desc: "对已下载或导入的视频资源进行维护和编辑。",
instructionSection2Sub1: "合集/视频删除:",
instructionSection2Text1:
"在管理页面删除合集时,系统提供两种选择:仅删除合集列表项(保留文件),或连同合集内的物理文件一并彻底删除。",
instructionSection2Sub2: "缩略图修复:",
instructionSection2Text2:
"若遇到下载后视频无封面的情况,可点击视频缩略图上的刷新按钮,系统将重新抓取视频首帧作为新的缩略图。",
instructionSection3Title: "3. 系统设置",
instructionSection3Desc: "配置系统参数、维护数据及扩展功能。",
instructionSection3Sub1: "安全设定:",
instructionSection3Text1:
"设置系统登录密码(默认初始密码为 123建议首次登录后修改。",
instructionSection3Sub2: "标签管理:",
instructionSection3Text2:
"支持添加或删除视频分类标签。注意: 所有操作完成后,必须点击页面底端的“保存”按钮方可生效。",
instructionSection3Sub3: "系统维护:",
instructionSection3Item1Label: "清理临时文件:",
instructionSection3Item1Text:
"用于清除因后端偶发故障而残留的临时下载文件,释放空间。",
instructionSection3Item2Label: "数据库迁移:",
instructionSection3Item2Text:
"专为早期版本用户设计。使用此功能可将数据从 JSON 迁移至新的 SQLite 数据库。迁移成功后,可点击删除按钮清理旧的历史数据。",
instructionSection3Sub4: "扩展服务:",
instructionSection3Item3Label: "OpenList 云盘:",
instructionSection3Item3Text:
"(开发中)支持连接用户自行部署的 OpenList 服务,在此处添加配置后可实现云盘联动。",
};