refactor: optimize download manage page
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
720
backend/package-lock.json
generated
720
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
import("./services/subscriptionService")
|
||||
.then(({ subscriptionService }) => {
|
||||
subscriptionService.startScheduler();
|
||||
}).catch(err => console.error("Failed to start subscription service:", err));
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("Failed to start subscription service:", err)
|
||||
);
|
||||
|
||||
// Run duration backfill in background
|
||||
import("./services/metadataService").then(service => {
|
||||
import("./services/metadataService")
|
||||
.then((service) => {
|
||||
service.backfillDurations();
|
||||
}).catch(err => console.error("Failed to start metadata service:", err));
|
||||
})
|
||||
.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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
// 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 [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -350,13 +350,19 @@ 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();
|
||||
`
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
console.log(`Backfilled video_id for ${result.changes} download history items.`);
|
||||
console.log(
|
||||
`Backfilled video_id for ${result.changes} download history items.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error backfilling video_id in download history:", 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();
|
||||
@@ -842,25 +867,31 @@ export function formatLegacyFilenames(): {
|
||||
processed: number;
|
||||
renamed: number;
|
||||
errors: number;
|
||||
details: string[]
|
||||
details: string[];
|
||||
} {
|
||||
const results = {
|
||||
processed: 0,
|
||||
renamed: 0,
|
||||
errors: 0,
|
||||
details: [] as string[]
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
try {
|
||||
const allVideos = getVideos();
|
||||
console.log(`Starting legacy filename formatting for ${allVideos.length} videos...`);
|
||||
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);
|
||||
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
|
||||
@@ -887,12 +918,22 @@ export function formatLegacyFilenames(): {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Renaming video ${video.id}: ${video.videoFilename} -> ${newVideoFilename} (Subdir: ${subdirectory})`);
|
||||
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);
|
||||
const oldVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
subdirectory,
|
||||
video.videoFilename || ""
|
||||
);
|
||||
const newVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
subdirectory,
|
||||
newVideoFilename
|
||||
);
|
||||
|
||||
// Handle thumbnail subdirectory
|
||||
let thumbSubdir = "";
|
||||
@@ -904,8 +945,14 @@ export function formatLegacyFilenames(): {
|
||||
}
|
||||
}
|
||||
|
||||
const oldThumbnailPath = video.thumbnailFilename ? path.join(IMAGES_DIR, thumbSubdir, video.thumbnailFilename) : null;
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, thumbSubdir, newThumbnailFilename);
|
||||
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)) {
|
||||
@@ -918,10 +965,20 @@ export function formatLegacyFilenames(): {
|
||||
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
|
||||
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}`);
|
||||
console.log(
|
||||
`Destination exists, using unique suffix: ${uniqueVideoBase}`
|
||||
);
|
||||
|
||||
fs.renameSync(oldVideoPath, uniqueVideoPath);
|
||||
|
||||
@@ -946,7 +1003,7 @@ export function formatLegacyFilenames(): {
|
||||
newSubtitles.push({
|
||||
...subtitle,
|
||||
filename: newSubFilename,
|
||||
path: `/subtitles/${newSubFilename}`
|
||||
path: `/subtitles/${newSubFilename}`,
|
||||
});
|
||||
} else {
|
||||
newSubtitles.push(subtitle);
|
||||
@@ -958,10 +1015,18 @@ export function formatLegacyFilenames(): {
|
||||
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)
|
||||
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();
|
||||
@@ -970,9 +1035,17 @@ export function formatLegacyFilenames(): {
|
||||
db.update(videos)
|
||||
.set({
|
||||
videoFilename: uniqueVideoBase,
|
||||
thumbnailFilename: video.thumbnailFilename ? uniqueThumbBase : undefined,
|
||||
videoPath: `/videos/${subdirectory ? subdirectory + '/' : ''}${uniqueVideoBase}`,
|
||||
thumbnailPath: video.thumbnailFilename ? `/images/${thumbSubdir ? thumbSubdir + '/' : ''}${uniqueThumbBase}` : null,
|
||||
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();
|
||||
@@ -986,7 +1059,10 @@ export function formatLegacyFilenames(): {
|
||||
|
||||
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) {
|
||||
if (
|
||||
fs.existsSync(newThumbnailPath) &&
|
||||
oldThumbnailPath !== newThumbnailPath
|
||||
) {
|
||||
fs.unlinkSync(newThumbnailPath);
|
||||
}
|
||||
fs.renameSync(oldThumbnailPath, newThumbnailPath);
|
||||
@@ -1009,7 +1085,7 @@ export function formatLegacyFilenames(): {
|
||||
updatedSubtitles.push({
|
||||
...subtitle,
|
||||
filename: newSubFilename,
|
||||
path: `/subtitles/${newSubFilename}`
|
||||
path: `/subtitles/${newSubFilename}`,
|
||||
});
|
||||
} else {
|
||||
updatedSubtitles.push(subtitle);
|
||||
@@ -1021,10 +1097,23 @@ export function formatLegacyFilenames(): {
|
||||
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)
|
||||
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();
|
||||
@@ -1035,7 +1124,6 @@ export function formatLegacyFilenames(): {
|
||||
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++;
|
||||
@@ -1044,7 +1132,6 @@ export function formatLegacyFilenames(): {
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in formatLegacyFilenames:", error);
|
||||
throw error;
|
||||
@@ -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.
|
||||
|
||||
@@ -31,6 +31,16 @@ server {
|
||||
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)$ {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user