refactor: optimize download manage page

This commit is contained in:
Peifan Li
2025-12-09 23:57:48 -05:00
parent 9dffd2b72b
commit 429403806e
11 changed files with 552 additions and 1006 deletions

View File

@@ -7,8 +7,6 @@ WORKDIR /app
COPY package*.json ./
# Skip Puppeteer download during build as we only need to compile TS
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# Skip Python check for youtube-dl-exec during build
ENV YOUTUBE_DL_SKIP_PYTHON_CHECK=1
RUN npm ci
COPY . .
@@ -57,7 +55,7 @@ COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/bgutil-ytdlp-pot-provider /app/bgutil-ytdlp-pot-provider
# Create necessary directories
RUN mkdir -p uploads/videos uploads/images data
RUN mkdir -p uploads/videos uploads/images uploads/subtitles data
EXPOSE 5551

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,7 @@
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
"uuid": "^13.0.0",
"youtube-dl-exec": "^2.4.17"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -1,85 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import youtubedl from 'youtube-dl-exec';
import { getComments } from '../../services/commentService';
import * as storageService from '../../services/storageService';
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getComments } from "../../services/commentService";
import * as storageService from "../../services/storageService";
import * as ytDlpUtils from "../../utils/ytDlpUtils";
vi.mock('../../services/storageService');
vi.mock('youtube-dl-exec');
vi.mock("../../services/storageService");
vi.mock("../../utils/ytDlpUtils");
describe('CommentService', () => {
describe("CommentService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getComments', () => {
it('should return comments when video exists and youtube-dl succeeds', async () => {
describe("getComments", () => {
it("should return comments when video exists and youtube-dl succeeds", async () => {
const mockVideo = {
id: 'video1',
sourceUrl: 'https://youtube.com/watch?v=123',
id: "video1",
sourceUrl: "https://youtube.com/watch?v=123",
};
(storageService.getVideoById as any).mockReturnValue(mockVideo);
const mockOutput = {
comments: [
{
id: 'c1',
author: 'User1',
text: 'Great video!',
id: "c1",
author: "User1",
text: "Great video!",
timestamp: 1600000000,
},
{
id: 'c2',
author: '@User2',
text: 'Nice!',
id: "c2",
author: "@User2",
text: "Nice!",
timestamp: 1600000000,
},
],
};
(youtubedl as any).mockResolvedValue(mockOutput);
(ytDlpUtils.executeYtDlpJson as any).mockResolvedValue(mockOutput);
const comments = await getComments('video1');
const comments = await getComments("video1");
expect(comments).toHaveLength(2);
expect(comments[0]).toEqual({
id: 'c1',
author: 'User1',
content: 'Great video!',
id: "c1",
author: "User1",
content: "Great video!",
date: expect.any(String),
});
expect(comments[1].author).toBe('User2'); // Check @ removal
expect(comments[1].author).toBe("User2"); // Check @ removal
});
it('should return empty array if video not found', async () => {
it("should return empty array if video not found", async () => {
(storageService.getVideoById as any).mockReturnValue(null);
const comments = await getComments('non-existent');
const comments = await getComments("non-existent");
expect(comments).toEqual([]);
expect(youtubedl).not.toHaveBeenCalled();
expect(ytDlpUtils.executeYtDlpJson).not.toHaveBeenCalled();
});
it('should return empty array if youtube-dl fails', async () => {
it("should return empty array if youtube-dl fails", async () => {
const mockVideo = {
id: 'video1',
sourceUrl: 'https://youtube.com/watch?v=123',
id: "video1",
sourceUrl: "https://youtube.com/watch?v=123",
};
(storageService.getVideoById as any).mockReturnValue(mockVideo);
(youtubedl as any).mockRejectedValue(new Error('Download failed'));
(ytDlpUtils.executeYtDlpJson as any).mockRejectedValue(
new Error("Download failed")
);
const comments = await getComments('video1');
const comments = await getComments("video1");
expect(comments).toEqual([]);
});
it('should return empty array if no comments in output', async () => {
it("should return empty array if no comments in output", async () => {
const mockVideo = {
id: 'video1',
sourceUrl: 'https://youtube.com/watch?v=123',
id: "video1",
sourceUrl: "https://youtube.com/watch?v=123",
};
(storageService.getVideoById as any).mockReturnValue(mockVideo);
(youtubedl as any).mockResolvedValue({});
(ytDlpUtils.executeYtDlpJson as any).mockResolvedValue({});
const comments = await getComments('video1');
const comments = await getComments("video1");
expect(comments).toEqual([]);
});

View File

@@ -7,7 +7,7 @@ import express from "express";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "./config/paths";
import { runMigrations } from "./db/migrate";
import apiRoutes from "./routes/api";
import settingsRoutes from './routes/settingsRoutes';
import settingsRoutes from "./routes/settingsRoutes";
import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService";
import { VERSION } from "./version";
@@ -38,27 +38,56 @@ const startServer = async () => {
// Initialize download manager (restore queued tasks)
downloadManager.initialize();
// Serve static files
app.use("/videos", express.static(VIDEOS_DIR));
// Serve static files with proper MIME types
app.use(
"/videos",
express.static(VIDEOS_DIR, {
setHeaders: (res, path) => {
if (path.endsWith(".mp4")) {
res.setHeader("Content-Type", "video/mp4");
} else if (path.endsWith(".webm")) {
res.setHeader("Content-Type", "video/webm");
}
},
})
);
app.use("/images", express.static(IMAGES_DIR));
app.use("/subtitles", express.static(SUBTITLES_DIR));
app.use(
"/subtitles",
express.static(SUBTITLES_DIR, {
setHeaders: (res, path) => {
if (path.endsWith(".vtt")) {
res.setHeader("Content-Type", "text/vtt");
res.setHeader("Access-Control-Allow-Origin", "*");
}
},
})
);
// API Routes
app.use("/api", apiRoutes);
app.use('/api/settings', settingsRoutes);
app.use("/api/settings", settingsRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
// Start subscription scheduler
import("./services/subscriptionService").then(({ subscriptionService }) => {
subscriptionService.startScheduler();
}).catch(err => console.error("Failed to start subscription service:", err));
import("./services/subscriptionService")
.then(({ subscriptionService }) => {
subscriptionService.startScheduler();
})
.catch((err) =>
console.error("Failed to start subscription service:", err)
);
// Run duration backfill in background
import("./services/metadataService").then(service => {
service.backfillDurations();
}).catch(err => console.error("Failed to start metadata service:", err));
import("./services/metadataService")
.then((service) => {
service.backfillDurations();
})
.catch((err) =>
console.error("Failed to start metadata service:", err)
);
});
} catch (error) {
console.error("Failed to start server:", error);
@@ -67,4 +96,3 @@ const startServer = async () => {
};
startServer();

View File

@@ -1,4 +1,4 @@
import youtubedl from "youtube-dl-exec";
import { executeYtDlpJson } from "../utils/ytDlpUtils";
import * as storageService from "./storageService";
export interface Comment {
@@ -17,44 +17,43 @@ export const getComments = async (videoId: string): Promise<Comment[]> => {
throw new Error("Video not found");
}
// Use youtube-dl for both Bilibili and YouTube as it's more reliable
return await getCommentsWithYoutubeDl(video.sourceUrl);
// Use yt-dlp for both Bilibili and YouTube as it's more reliable
return await getCommentsWithYtDlp(video.sourceUrl);
} catch (error) {
console.error("Error fetching comments:", error);
return [];
}
};
// Fetch comments using youtube-dl (works for YouTube and Bilibili)
const getCommentsWithYoutubeDl = async (url: string): Promise<Comment[]> => {
// Fetch comments using yt-dlp (works for YouTube and Bilibili)
const getCommentsWithYtDlp = async (url: string): Promise<Comment[]> => {
try {
console.log(`[CommentService] Fetching comments using youtube-dl for: ${url}`);
const output = await youtubedl(url, {
getComments: true,
dumpSingleJson: true,
console.log(`[CommentService] Fetching comments using yt-dlp for: ${url}`);
const info = await executeYtDlpJson(url, {
writeComments: true, // Include comments in JSON output
noWarnings: true,
playlistEnd: 1, // Ensure we only process one video
extractorArgs: "youtube:max_comments=20,all_comments=false",
} as any);
});
const info = output as any;
if (info.comments) {
// Sort by date (newest first) and take top 10
// Note: youtube-dl comments structure might vary
return info.comments
.slice(0, 10)
.map((comment: any) => ({
id: comment.id,
author: comment.author.startsWith('@') ? comment.author.substring(1) : comment.author,
content: comment.text,
date: comment.timestamp ? new Date(comment.timestamp * 1000).toISOString().split('T')[0] : 'Unknown',
}));
// Sort by date (newest first) and take top 10
// Note: yt-dlp comments structure might vary
return info.comments.slice(0, 10).map((comment: any) => ({
id: comment.id || comment.comment_id || String(Math.random()),
author: comment.author?.startsWith("@")
? comment.author.substring(1)
: comment.author || "Unknown",
content: comment.text || comment.content || "",
date: comment.timestamp
? new Date(comment.timestamp * 1000).toISOString().split("T")[0]
: comment.time || "Unknown",
}));
}
return [];
} catch (error) {
console.error("Error fetching comments with youtube-dl:", error);
console.error("Error fetching comments with yt-dlp:", error);
return [];
}
};

View File

@@ -719,7 +719,45 @@ export class BilibiliDownloader {
createdAt: new Date().toISOString(),
};
// Save the video using storage service
// Check if video with same sourceUrl already exists
const existingVideo = storageService.getVideoBySourceUrl(url);
if (existingVideo) {
// Update existing video with new subtitle information and file paths
console.log(
"Video with same sourceUrl exists, updating subtitle information"
);
// Use existing video's ID and preserve other fields
videoData.id = existingVideo.id;
videoData.addedAt = existingVideo.addedAt;
videoData.createdAt = existingVideo.createdAt;
const updatedVideo = storageService.updateVideo(existingVideo.id, {
subtitles: subtitles.length > 0 ? subtitles : undefined,
videoFilename: finalVideoFilename,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailFilename: thumbnailSaved
? finalThumbnailFilename
: existingVideo.thumbnailFilename,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: existingVideo.thumbnailPath,
duration: duration,
fileSize: fileSize,
title: videoData.title, // Update title in case it changed
description: videoData.description, // Update description in case it changed
});
if (updatedVideo) {
console.log(
`Part ${partNumber}/${totalParts} updated in database with new subtitles`
);
return { success: true, videoData: updatedVideo };
}
}
// Save the video (new video)
storageService.saveVideo(videoData);
console.log(`Part ${partNumber}/${totalParts} added to database`);

View File

@@ -486,7 +486,43 @@ export class YtDlpDownloader {
console.error("Failed to get file size:", e);
}
// Save the video
// Check if video with same sourceUrl already exists
const existingVideo = storageService.getVideoBySourceUrl(videoUrl);
if (existingVideo) {
// Update existing video with new subtitle information and file paths
console.log(
"Video with same sourceUrl exists, updating subtitle information"
);
// Use existing video's ID and preserve other fields
videoData.id = existingVideo.id;
videoData.addedAt = existingVideo.addedAt;
videoData.createdAt = existingVideo.createdAt;
const updatedVideo = storageService.updateVideo(existingVideo.id, {
subtitles: subtitles.length > 0 ? subtitles : undefined,
videoFilename: finalVideoFilename,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailFilename: thumbnailSaved
? finalThumbnailFilename
: existingVideo.thumbnailFilename,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: existingVideo.thumbnailPath,
duration: videoData.duration,
fileSize: videoData.fileSize,
title: videoData.title, // Update title in case it changed
description: videoData.description, // Update description in case it changed
});
if (updatedVideo) {
console.log("Video updated in database with new subtitles");
return updatedVideo;
}
}
// Save the video (new video)
storageService.saveVideo(videoData);
console.log("Video added to database");

View File

@@ -350,16 +350,22 @@ export function initializeStorage(): void {
// Backfill video_id in download_history for existing records
try {
const result = sqlite.prepare(`
const result = sqlite
.prepare(
`
UPDATE download_history
SET video_id = (SELECT id FROM videos WHERE videos.source_url = download_history.source_url)
WHERE video_id IS NULL AND status = 'success' AND source_url IS NOT NULL
`).run();
if (result.changes > 0) {
console.log(`Backfilled video_id for ${result.changes} download history items.`);
}
`
)
.run();
if (result.changes > 0) {
console.log(
`Backfilled video_id for ${result.changes} download history items.`
);
}
} catch (error) {
console.error("Error backfilling video_id in download history:", error);
console.error("Error backfilling video_id in download history:", error);
}
} catch (error) {
console.error(
@@ -487,10 +493,7 @@ export function getDownloadStatus(): DownloadStatus {
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
db.delete(downloads)
.where(
and(
lt(downloads.timestamp, oneDayAgo),
eq(downloads.status, "active")
)
and(lt(downloads.timestamp, oneDayAgo), eq(downloads.status, "active"))
)
.run();
@@ -818,6 +821,28 @@ export function getVideos(): Video[] {
}
}
export function getVideoBySourceUrl(sourceUrl: string): Video | undefined {
try {
const result = db
.select()
.from(videos)
.where(eq(videos.sourceUrl, sourceUrl))
.get();
if (result) {
return {
...result,
tags: result.tags ? JSON.parse(result.tags) : [],
subtitles: result.subtitles ? JSON.parse(result.subtitles) : undefined,
} as Video;
}
return undefined;
} catch (error) {
console.error("Error getting video by sourceUrl:", error);
return undefined;
}
}
export function getVideoById(id: string): Video | undefined {
try {
const video = db.select().from(videos).where(eq(videos.id, id)).get();
@@ -838,217 +863,279 @@ export function getVideoById(id: string): Video | undefined {
/**
* Format legacy filenames to the new standard format: Title-Author-YYYY
*/
export function formatLegacyFilenames(): {
processed: number;
renamed: number;
errors: number;
details: string[]
export function formatLegacyFilenames(): {
processed: number;
renamed: number;
errors: number;
details: string[];
} {
const results = {
processed: 0,
renamed: 0,
errors: 0,
details: [] as string[]
};
const results = {
processed: 0,
renamed: 0,
errors: 0,
details: [] as string[],
};
try {
const allVideos = getVideos();
console.log(`Starting legacy filename formatting for ${allVideos.length} videos...`);
try {
const allVideos = getVideos();
console.log(
`Starting legacy filename formatting for ${allVideos.length} videos...`
);
for (const video of allVideos) {
results.processed++;
try {
// Generate new filename
const newBaseFilename = formatVideoFilename(video.title, video.author || "Unknown", video.date);
// preserve subdirectory if it exists (e.g. for collections)
// We rely on videoPath because videoFilename is usually just the basename
let subdirectory = "";
if (video.videoPath) {
// videoPath is like "/videos/SubDir/file.mp4" or "/videos/file.mp4"
const relPath = video.videoPath.replace(/^\/videos\//, "");
const dir = path.dirname(relPath);
if (dir && dir !== ".") {
subdirectory = dir;
}
}
for (const video of allVideos) {
results.processed++;
// New filename (basename only)
const newVideoFilename = `${newBaseFilename}.mp4`;
const newThumbnailFilename = `${newBaseFilename}.jpg`;
try {
// Generate new filename
const newBaseFilename = formatVideoFilename(
video.title,
video.author || "Unknown",
video.date
);
// Calculate full paths for checks
// For the check we need to know if the resulting full path is different
// But the check "video.videoFilename === newVideoFilename" only checks basename.
// If basename matches, we might still want to rename if we were normalizing something else,
// but usually if format matches, we skip.
if (video.videoFilename === newVideoFilename) {
continue;
}
console.log(`Renaming video ${video.id}: ${video.videoFilename} -> ${newVideoFilename} (Subdir: ${subdirectory})`);
// Paths
// Old path must be constructed using the subdirectory derived from videoPath
const oldVideoPath = path.join(VIDEOS_DIR, subdirectory, video.videoFilename || "");
const newVideoPath = path.join(VIDEOS_DIR, subdirectory, newVideoFilename);
// Handle thumbnail subdirectory
let thumbSubdir = "";
if (video.thumbnailPath) {
const relPath = video.thumbnailPath.replace(/^\/images\//, "");
const dir = path.dirname(relPath);
if (dir && dir !== ".") {
thumbSubdir = dir;
}
}
const oldThumbnailPath = video.thumbnailFilename ? path.join(IMAGES_DIR, thumbSubdir, video.thumbnailFilename) : null;
const newThumbnailPath = path.join(IMAGES_DIR, thumbSubdir, newThumbnailFilename);
// Rename video file
if (fs.existsSync(oldVideoPath)) {
if (fs.existsSync(newVideoPath) && oldVideoPath !== newVideoPath) {
// Destination exists, append timestamp to avoid collision
const uniqueSuffix = `_${Date.now()}`;
const uniqueBase = `${newBaseFilename}${uniqueSuffix}`;
const uniqueVideoBase = `${uniqueBase}.mp4`;
const uniqueThumbBase = `${uniqueBase}.jpg`;
// Full paths for rename
const uniqueVideoPath = path.join(VIDEOS_DIR, subdirectory, uniqueVideoBase);
const uniqueThumbPath = path.join(IMAGES_DIR, thumbSubdir, uniqueThumbBase); // Use thumbSubdir
console.log(`Destination exists, using unique suffix: ${uniqueVideoBase}`);
fs.renameSync(oldVideoPath, uniqueVideoPath);
if (oldThumbnailPath && fs.existsSync(oldThumbnailPath)) {
fs.renameSync(oldThumbnailPath, uniqueThumbPath);
}
// Handle subtitles (Keep in their original folder, assuming root or derived from path if available)
if (video.subtitles && video.subtitles.length > 0) {
const newSubtitles = [];
for (const subtitle of video.subtitles) {
// Subtitles usually in SUBTITLES_DIR root, checking...
const oldSubPath = path.join(SUBTITLES_DIR, subtitle.filename);
// If we ever supported subdirs for subtitles, we'd need to parse subtitle.path here too
// For now assuming existing structure matches simple join
if (fs.existsSync(oldSubPath)) {
const newSubFilename = `${uniqueBase}.${subtitle.language}.vtt`;
const newSubPath = path.join(SUBTITLES_DIR, newSubFilename);
fs.renameSync(oldSubPath, newSubPath);
newSubtitles.push({
...subtitle,
filename: newSubFilename,
path: `/subtitles/${newSubFilename}`
});
} else {
newSubtitles.push(subtitle);
}
}
// Update video record with unique names
// videoFilename should be BASENAME only
// videoPath should be FULL WEB PATH including subdir
db.update(videos)
.set({
videoFilename: uniqueVideoBase,
thumbnailFilename: video.thumbnailFilename ? uniqueThumbBase : undefined,
videoPath: `/videos/${subdirectory ? subdirectory + '/' : ''}${uniqueVideoBase}`,
thumbnailPath: video.thumbnailFilename ? `/images/${thumbSubdir ? thumbSubdir + '/' : ''}${uniqueThumbBase}` : null,
subtitles: JSON.stringify(newSubtitles)
})
.where(eq(videos.id, video.id))
.run();
} else {
// Update video record with unique names
db.update(videos)
.set({
videoFilename: uniqueVideoBase,
thumbnailFilename: video.thumbnailFilename ? uniqueThumbBase : undefined,
videoPath: `/videos/${subdirectory ? subdirectory + '/' : ''}${uniqueVideoBase}`,
thumbnailPath: video.thumbnailFilename ? `/images/${thumbSubdir ? thumbSubdir + '/' : ''}${uniqueThumbBase}` : null,
})
.where(eq(videos.id, video.id))
.run();
}
results.renamed++;
results.details.push(`Renamed (unique): ${video.title}`);
} else {
// Rename normally
fs.renameSync(oldVideoPath, newVideoPath);
if (oldThumbnailPath && fs.existsSync(oldThumbnailPath)) {
// Check if new thumbnail path exists (it shouldn't if specific to this video, but safety check)
if (fs.existsSync(newThumbnailPath) && oldThumbnailPath !== newThumbnailPath) {
fs.unlinkSync(newThumbnailPath);
}
fs.renameSync(oldThumbnailPath, newThumbnailPath);
}
// Handle subtitles
const updatedSubtitles = [];
if (video.subtitles && video.subtitles.length > 0) {
for (const subtitle of video.subtitles) {
const oldSubPath = path.join(SUBTITLES_DIR, subtitle.filename);
if (fs.existsSync(oldSubPath)) {
// Keep subtitles in their current location (usually root SUBTITLES_DIR)
const newSubFilename = `${newBaseFilename}.${subtitle.language}.vtt`;
const newSubPath = path.join(SUBTITLES_DIR, newSubFilename);
// Remove dest if exists
if (fs.existsSync(newSubPath)) fs.unlinkSync(newSubPath);
fs.renameSync(oldSubPath, newSubPath);
updatedSubtitles.push({
...subtitle,
filename: newSubFilename,
path: `/subtitles/${newSubFilename}`
});
} else {
updatedSubtitles.push(subtitle);
}
}
}
// Update DB
db.update(videos)
.set({
videoFilename: newVideoFilename,
thumbnailFilename: video.thumbnailFilename ? newThumbnailFilename : undefined,
videoPath: `/videos/${subdirectory ? subdirectory + '/' : ''}${newVideoFilename}`,
thumbnailPath: video.thumbnailFilename ? `/images/${thumbSubdir ? thumbSubdir + '/' : ''}${newThumbnailFilename}` : null,
subtitles: updatedSubtitles.length > 0 ? JSON.stringify(updatedSubtitles) : (video.subtitles ? JSON.stringify(video.subtitles) : undefined)
})
.where(eq(videos.id, video.id))
.run();
results.renamed++;
}
} else {
results.details.push(`Skipped (file missing): ${video.title}`);
// results.errors++; // Not necessarily an error, maybe just missing file
}
} catch (err: any) {
console.error(`Error renaming video ${video.id}:`, err);
results.errors++;
results.details.push(`Error: ${video.title} - ${err.message}`);
}
// preserve subdirectory if it exists (e.g. for collections)
// We rely on videoPath because videoFilename is usually just the basename
let subdirectory = "";
if (video.videoPath) {
// videoPath is like "/videos/SubDir/file.mp4" or "/videos/file.mp4"
const relPath = video.videoPath.replace(/^\/videos\//, "");
const dir = path.dirname(relPath);
if (dir && dir !== ".") {
subdirectory = dir;
}
}
return results;
// New filename (basename only)
const newVideoFilename = `${newBaseFilename}.mp4`;
const newThumbnailFilename = `${newBaseFilename}.jpg`;
} catch (error: any) {
console.error("Error in formatLegacyFilenames:", error);
throw error;
// Calculate full paths for checks
// For the check we need to know if the resulting full path is different
// But the check "video.videoFilename === newVideoFilename" only checks basename.
// If basename matches, we might still want to rename if we were normalizing something else,
// but usually if format matches, we skip.
if (video.videoFilename === newVideoFilename) {
continue;
}
console.log(
`Renaming video ${video.id}: ${video.videoFilename} -> ${newVideoFilename} (Subdir: ${subdirectory})`
);
// Paths
// Old path must be constructed using the subdirectory derived from videoPath
const oldVideoPath = path.join(
VIDEOS_DIR,
subdirectory,
video.videoFilename || ""
);
const newVideoPath = path.join(
VIDEOS_DIR,
subdirectory,
newVideoFilename
);
// Handle thumbnail subdirectory
let thumbSubdir = "";
if (video.thumbnailPath) {
const relPath = video.thumbnailPath.replace(/^\/images\//, "");
const dir = path.dirname(relPath);
if (dir && dir !== ".") {
thumbSubdir = dir;
}
}
const oldThumbnailPath = video.thumbnailFilename
? path.join(IMAGES_DIR, thumbSubdir, video.thumbnailFilename)
: null;
const newThumbnailPath = path.join(
IMAGES_DIR,
thumbSubdir,
newThumbnailFilename
);
// Rename video file
if (fs.existsSync(oldVideoPath)) {
if (fs.existsSync(newVideoPath) && oldVideoPath !== newVideoPath) {
// Destination exists, append timestamp to avoid collision
const uniqueSuffix = `_${Date.now()}`;
const uniqueBase = `${newBaseFilename}${uniqueSuffix}`;
const uniqueVideoBase = `${uniqueBase}.mp4`;
const uniqueThumbBase = `${uniqueBase}.jpg`;
// Full paths for rename
const uniqueVideoPath = path.join(
VIDEOS_DIR,
subdirectory,
uniqueVideoBase
);
const uniqueThumbPath = path.join(
IMAGES_DIR,
thumbSubdir,
uniqueThumbBase
); // Use thumbSubdir
console.log(
`Destination exists, using unique suffix: ${uniqueVideoBase}`
);
fs.renameSync(oldVideoPath, uniqueVideoPath);
if (oldThumbnailPath && fs.existsSync(oldThumbnailPath)) {
fs.renameSync(oldThumbnailPath, uniqueThumbPath);
}
// Handle subtitles (Keep in their original folder, assuming root or derived from path if available)
if (video.subtitles && video.subtitles.length > 0) {
const newSubtitles = [];
for (const subtitle of video.subtitles) {
// Subtitles usually in SUBTITLES_DIR root, checking...
const oldSubPath = path.join(SUBTITLES_DIR, subtitle.filename);
// If we ever supported subdirs for subtitles, we'd need to parse subtitle.path here too
// For now assuming existing structure matches simple join
if (fs.existsSync(oldSubPath)) {
const newSubFilename = `${uniqueBase}.${subtitle.language}.vtt`;
const newSubPath = path.join(SUBTITLES_DIR, newSubFilename);
fs.renameSync(oldSubPath, newSubPath);
newSubtitles.push({
...subtitle,
filename: newSubFilename,
path: `/subtitles/${newSubFilename}`,
});
} else {
newSubtitles.push(subtitle);
}
}
// Update video record with unique names
// videoFilename should be BASENAME only
// videoPath should be FULL WEB PATH including subdir
db.update(videos)
.set({
videoFilename: uniqueVideoBase,
thumbnailFilename: video.thumbnailFilename
? uniqueThumbBase
: undefined,
videoPath: `/videos/${
subdirectory ? subdirectory + "/" : ""
}${uniqueVideoBase}`,
thumbnailPath: video.thumbnailFilename
? `/images/${
thumbSubdir ? thumbSubdir + "/" : ""
}${uniqueThumbBase}`
: null,
subtitles: JSON.stringify(newSubtitles),
})
.where(eq(videos.id, video.id))
.run();
} else {
// Update video record with unique names
db.update(videos)
.set({
videoFilename: uniqueVideoBase,
thumbnailFilename: video.thumbnailFilename
? uniqueThumbBase
: undefined,
videoPath: `/videos/${
subdirectory ? subdirectory + "/" : ""
}${uniqueVideoBase}`,
thumbnailPath: video.thumbnailFilename
? `/images/${
thumbSubdir ? thumbSubdir + "/" : ""
}${uniqueThumbBase}`
: null,
})
.where(eq(videos.id, video.id))
.run();
}
results.renamed++;
results.details.push(`Renamed (unique): ${video.title}`);
} else {
// Rename normally
fs.renameSync(oldVideoPath, newVideoPath);
if (oldThumbnailPath && fs.existsSync(oldThumbnailPath)) {
// Check if new thumbnail path exists (it shouldn't if specific to this video, but safety check)
if (
fs.existsSync(newThumbnailPath) &&
oldThumbnailPath !== newThumbnailPath
) {
fs.unlinkSync(newThumbnailPath);
}
fs.renameSync(oldThumbnailPath, newThumbnailPath);
}
// Handle subtitles
const updatedSubtitles = [];
if (video.subtitles && video.subtitles.length > 0) {
for (const subtitle of video.subtitles) {
const oldSubPath = path.join(SUBTITLES_DIR, subtitle.filename);
if (fs.existsSync(oldSubPath)) {
// Keep subtitles in their current location (usually root SUBTITLES_DIR)
const newSubFilename = `${newBaseFilename}.${subtitle.language}.vtt`;
const newSubPath = path.join(SUBTITLES_DIR, newSubFilename);
// Remove dest if exists
if (fs.existsSync(newSubPath)) fs.unlinkSync(newSubPath);
fs.renameSync(oldSubPath, newSubPath);
updatedSubtitles.push({
...subtitle,
filename: newSubFilename,
path: `/subtitles/${newSubFilename}`,
});
} else {
updatedSubtitles.push(subtitle);
}
}
}
// Update DB
db.update(videos)
.set({
videoFilename: newVideoFilename,
thumbnailFilename: video.thumbnailFilename
? newThumbnailFilename
: undefined,
videoPath: `/videos/${
subdirectory ? subdirectory + "/" : ""
}${newVideoFilename}`,
thumbnailPath: video.thumbnailFilename
? `/images/${
thumbSubdir ? thumbSubdir + "/" : ""
}${newThumbnailFilename}`
: null,
subtitles:
updatedSubtitles.length > 0
? JSON.stringify(updatedSubtitles)
: video.subtitles
? JSON.stringify(video.subtitles)
: undefined,
})
.where(eq(videos.id, video.id))
.run();
results.renamed++;
}
} else {
results.details.push(`Skipped (file missing): ${video.title}`);
// results.errors++; // Not necessarily an error, maybe just missing file
}
} catch (err: any) {
console.error(`Error renaming video ${video.id}:`, err);
results.errors++;
results.details.push(`Error: ${video.title} - ${err.message}`);
}
}
return results;
} catch (error: any) {
console.error("Error in formatLegacyFilenames:", error);
throw error;
}
}
export function saveVideo(videoData: Video): Video {
@@ -1079,8 +1166,16 @@ export function updateVideo(id: string, updates: Partial<Video>): Video | null {
const updatesToSave = {
...updates,
// Only include tags/subtitles if they are explicitly in the updates object
...(updates.tags !== undefined ? { tags: updates.tags ? JSON.stringify(updates.tags) : undefined } : {}),
...(updates.subtitles !== undefined ? { subtitles: updates.subtitles ? JSON.stringify(updates.subtitles) : undefined } : {}),
...(updates.tags !== undefined
? { tags: updates.tags ? JSON.stringify(updates.tags) : undefined }
: {}),
...(updates.subtitles !== undefined
? {
subtitles: updates.subtitles
? JSON.stringify(updates.subtitles)
: undefined,
}
: {}),
};
// If tags is explicitly empty array, we might want to save it as '[]' or null.
// JSON.stringify([]) is '[]', which is fine.

View File

@@ -30,6 +30,16 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /subtitles {
proxy_pass http://backend:5551/subtitles;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass through CORS headers from backend
proxy_pass_header Access-Control-Allow-Origin;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {

View File

@@ -95,6 +95,7 @@ const DownloadPage: React.FC = () => {
const [queuePage, setQueuePage] = useState(1);
const [historyPage, setHistoryPage] = useState(1);
const [downloadingItems, setDownloadingItems] = useState<Set<string>>(new Set());
// Scan files mutation
@@ -232,10 +233,37 @@ const DownloadPage: React.FC = () => {
removeFromHistoryMutation.mutate(id);
};
// Helper function to check if a sourceUrl is already in active or queued downloads
const isDownloadInProgress = (sourceUrl: string): boolean => {
if (!sourceUrl) return false;
// Check active downloads (sourceUrl is available but not in type definition)
const inActive = activeDownloads.some((d: any) => d.sourceUrl === sourceUrl);
if (inActive) return true;
// Check queued downloads (sourceUrl is available but not in type definition)
const inQueue = queuedDownloads.some((d: any) => d.sourceUrl === sourceUrl);
if (inQueue) return true;
// Check if currently being processed
if (downloadingItems.has(sourceUrl)) return true;
return false;
};
// Re-download deleted video
const handleReDownload = async (sourceUrl: string) => {
if (!sourceUrl) return;
// Prevent duplicate downloads - check if already in active/queue or currently processing
if (isDownloadInProgress(sourceUrl)) {
showSnackbar('Download already in progress or queued');
return;
}
// Mark as downloading
setDownloadingItems(prev => new Set(prev).add(sourceUrl));
try {
// Call download with forceDownload flag
const response = await axios.post(`${API_URL}/download`, {
@@ -250,6 +278,15 @@ const DownloadPage: React.FC = () => {
} catch (error: any) {
console.error('Error re-downloading video:', error);
showSnackbar(t('error') || 'Error');
} finally {
// Remove from downloading set after a short delay to prevent rapid re-clicks
setTimeout(() => {
setDownloadingItems(prev => {
const next = new Set(prev);
next.delete(sourceUrl);
return next;
});
}, 1000);
}
};
@@ -528,7 +565,14 @@ const DownloadPage: React.FC = () => {
color="primary"
size="small"
startIcon={<ReplayIcon />}
onClick={() => handleVideoSubmit(item.sourceUrl!)}
onClick={() => {
if (!isDownloadInProgress(item.sourceUrl!)) {
handleVideoSubmit(item.sourceUrl!);
} else {
showSnackbar('Download already in progress or queued');
}
}}
disabled={isDownloadInProgress(item.sourceUrl)}
sx={{ minWidth: '100px' }}
>
{t('retry') || 'Retry'}
@@ -565,6 +609,7 @@ const DownloadPage: React.FC = () => {
size="small"
startIcon={<ReplayIcon />}
onClick={() => handleReDownload(item.sourceUrl!)}
disabled={isDownloadInProgress(item.sourceUrl)}
>
{t('downloadAgain') || 'Download Again'}
</Button>