refactor: refactor controller
This commit is contained in:
@@ -29,18 +29,20 @@ describe('CollectionController', () => {
|
||||
|
||||
getCollections(req as Request, res as Response);
|
||||
|
||||
expect(json).toHaveBeenCalledWith(mockCollections);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollections });
|
||||
});
|
||||
|
||||
it('should handle errors', () => {
|
||||
it('should handle errors', async () => {
|
||||
(storageService.getCollections as any).mockImplementation(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
|
||||
getCollections(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json).toHaveBeenCalledWith({ success: false, error: 'Failed to get collections' });
|
||||
try {
|
||||
await getCollections(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.message).toBe('Error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,15 +57,22 @@ describe('CollectionController', () => {
|
||||
expect(status).toHaveBeenCalledWith(201);
|
||||
// The controller creates a new object, so we check partial match or just that it was called
|
||||
expect(storageService.saveCollection).toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: true,
|
||||
data: expect.objectContaining({ title: 'New Col' }),
|
||||
message: "Collection created"
|
||||
}));
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', () => {
|
||||
it('should throw ValidationError if name is missing', async () => {
|
||||
req.body = {};
|
||||
|
||||
createCollection(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith({ success: false, error: 'Collection name is required' });
|
||||
try {
|
||||
await createCollection(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should add video if videoId provided', () => {
|
||||
@@ -88,7 +97,7 @@ describe('CollectionController', () => {
|
||||
updateCollection(req as Request, res as Response);
|
||||
|
||||
expect(storageService.atomicUpdateCollection).toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith(mockCollection);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
|
||||
});
|
||||
|
||||
it('should add video', () => {
|
||||
@@ -100,7 +109,7 @@ describe('CollectionController', () => {
|
||||
updateCollection(req as Request, res as Response);
|
||||
|
||||
expect(storageService.addVideoToCollection).toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith(mockCollection);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
|
||||
});
|
||||
|
||||
it('should remove video', () => {
|
||||
@@ -112,17 +121,20 @@ describe('CollectionController', () => {
|
||||
updateCollection(req as Request, res as Response);
|
||||
|
||||
expect(storageService.removeVideoFromCollection).toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith(mockCollection);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
|
||||
});
|
||||
|
||||
it('should return 404 if collection not found', () => {
|
||||
it('should throw NotFoundError if collection not found', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { name: 'Update' };
|
||||
(storageService.atomicUpdateCollection as any).mockReturnValue(null);
|
||||
|
||||
updateCollection(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
try {
|
||||
await updateCollection(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,14 +161,17 @@ describe('CollectionController', () => {
|
||||
expect(json).toHaveBeenCalledWith({ success: true, message: 'Collection deleted successfully' });
|
||||
});
|
||||
|
||||
it('should return 404 if delete fails', () => {
|
||||
it('should throw NotFoundError if delete fails', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.query = {};
|
||||
(storageService.deleteCollectionWithFiles as any).mockReturnValue(false);
|
||||
|
||||
deleteCollection(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
try {
|
||||
await deleteCollection(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,10 @@ describe('ScanController', () => {
|
||||
|
||||
expect(storageService.saveVideo).toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ addedCount: 1 }));
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: true,
|
||||
data: expect.objectContaining({ addedCount: 1 })
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
@@ -49,9 +52,12 @@ describe('ScanController', () => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
|
||||
await scanFiles(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
try {
|
||||
await scanFiles(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.message).toBe('Error');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('SettingsController', () => {
|
||||
|
||||
await getSettings(req as Request, res as Response);
|
||||
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ theme: 'dark' }));
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ theme: 'dark' }) }));
|
||||
});
|
||||
|
||||
it('should save defaults if empty', async () => {
|
||||
@@ -95,7 +95,7 @@ describe('SettingsController', () => {
|
||||
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(json).toHaveBeenCalledWith({ success: true });
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: { verified: true } });
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
@@ -103,9 +103,13 @@ describe('SettingsController', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ loginEnabled: true, password: 'hashed' });
|
||||
(bcrypt.compare as any).mockResolvedValue(false);
|
||||
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
try {
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
expect(error.message).toBe('Incorrect password');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,9 +127,14 @@ describe('SettingsController', () => {
|
||||
const migrationService = await import('../../services/migrationService');
|
||||
(migrationService.runMigration as any).mockRejectedValue(new Error('Migration failed'));
|
||||
|
||||
await migrateData(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
try {
|
||||
await migrateData(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
// The controller does NOT catch generic errors, it relies on asyncHandler.
|
||||
// So here it throws.
|
||||
expect(error.message).toBe('Migration failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Request, Response } from 'express';
|
||||
import fs from 'fs-extra';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkBilibiliCollection,
|
||||
checkBilibiliParts,
|
||||
deleteVideo,
|
||||
downloadVideo,
|
||||
getVideoById,
|
||||
@@ -68,16 +70,26 @@ describe('VideoController', () => {
|
||||
|
||||
expect(downloadService.searchYouTube).toHaveBeenCalledWith('test', 8, 1);
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ results: mockResults });
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: { results: mockResults } });
|
||||
});
|
||||
|
||||
it('should return 400 if query is missing', async () => {
|
||||
req.query = {};
|
||||
|
||||
await searchVideos(req as Request, res as Response);
|
||||
req.query = {};
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Search query is required' });
|
||||
// Validation errors might return 400 or 500 depending on middleware config, but usually 400 is expected for validation
|
||||
// But since we are catching validation error in test via try/catch in middleware in real app, here we are testing controller directly.
|
||||
// Wait, searchVideos does not throw ValidationError for empty query, it explicitly returns 400?
|
||||
// Let's check controller. It throws ValidationError. Middleware catches it.
|
||||
// But in this unit test we are mocking req/res. We are NOT using middleware.
|
||||
// So calling searchVideos will THROW.
|
||||
try {
|
||||
await searchVideos(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +221,7 @@ describe('VideoController', () => {
|
||||
|
||||
expect(storageService.getVideos).toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith(mockVideos);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockVideos });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,16 +235,19 @@ describe('VideoController', () => {
|
||||
|
||||
expect(storageService.getVideoById).toHaveBeenCalledWith('1');
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith(mockVideo);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: mockVideo });
|
||||
});
|
||||
|
||||
it('should return 404 if not found', () => {
|
||||
it('should throw NotFoundError if not found', async () => {
|
||||
req.params = { id: '1' };
|
||||
(storageService.getVideoById as any).mockReturnValue(undefined);
|
||||
|
||||
getVideoById(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
try {
|
||||
await getVideoById(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,13 +262,16 @@ describe('VideoController', () => {
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 404 if delete fails', () => {
|
||||
it('should throw NotFoundError if delete fails', async () => {
|
||||
req.params = { id: '1' };
|
||||
(storageService.deleteVideo as any).mockReturnValue(false);
|
||||
|
||||
deleteVideo(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
try {
|
||||
await deleteVideo(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,26 +286,32 @@ describe('VideoController', () => {
|
||||
|
||||
expect(storageService.updateVideo).toHaveBeenCalledWith('1', { rating: 5 });
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, message: 'Video rated successfully', video: mockVideo });
|
||||
expect(json).toHaveBeenCalledWith({ success: true, message: 'Video rated successfully', data: { video: mockVideo } });
|
||||
});
|
||||
|
||||
it('should return 400 for invalid rating', () => {
|
||||
it('should throw ValidationError for invalid rating', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { rating: 6 };
|
||||
|
||||
rateVideo(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
try {
|
||||
await rateVideo(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 404 if video not found', () => {
|
||||
it('should throw NotFoundError if video not found', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { rating: 5 };
|
||||
(storageService.updateVideo as any).mockReturnValue(null);
|
||||
|
||||
rateVideo(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
try {
|
||||
await rateVideo(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,24 +339,30 @@ describe('VideoController', () => {
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 404 if video not found', () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { title: 'New Title' };
|
||||
(storageService.updateVideo as any).mockReturnValue(null);
|
||||
it('should throw NotFoundError if video not found', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { title: 'New Title' };
|
||||
(storageService.updateVideo as any).mockReturnValue(null);
|
||||
|
||||
updateVideoDetails(req as Request, res as Response);
|
||||
try {
|
||||
await updateVideoDetails(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
}
|
||||
});
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
it('should throw ValidationError if no valid updates', async () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { invalid: 'field' };
|
||||
|
||||
it('should return 400 if no valid updates', () => {
|
||||
req.params = { id: '1' };
|
||||
req.body = { invalid: 'field' };
|
||||
|
||||
updateVideoDetails(req as Request, res as Response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
try {
|
||||
await updateVideoDetails(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBilibiliParts', () => {
|
||||
@@ -340,22 +370,30 @@ describe('VideoController', () => {
|
||||
req.query = { url: 'https://www.bilibili.com/video/BV1xx' };
|
||||
(downloadService.checkBilibiliVideoParts as any).mockResolvedValue({ success: true });
|
||||
|
||||
await import('../../controllers/videoController').then(m => m.checkBilibiliParts(req as Request, res as Response));
|
||||
await checkBilibiliParts(req as Request, res as Response);
|
||||
|
||||
expect(downloadService.checkBilibiliVideoParts).toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 400 if url is missing', async () => {
|
||||
it('should throw ValidationError if url is missing', async () => {
|
||||
req.query = {};
|
||||
await import('../../controllers/videoController').then(m => m.checkBilibiliParts(req as Request, res as Response));
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
try {
|
||||
await checkBilibiliParts(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 400 if url is invalid', async () => {
|
||||
it('should throw ValidationError if url is invalid', async () => {
|
||||
req.query = { url: 'invalid' };
|
||||
await import('../../controllers/videoController').then(m => m.checkBilibiliParts(req as Request, res as Response));
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
try {
|
||||
await checkBilibiliParts(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,16 +402,20 @@ describe('VideoController', () => {
|
||||
req.query = { url: 'https://www.bilibili.com/video/BV1xx' };
|
||||
(downloadService.checkBilibiliCollectionOrSeries as any).mockResolvedValue({ success: true });
|
||||
|
||||
await import('../../controllers/videoController').then(m => m.checkBilibiliCollection(req as Request, res as Response));
|
||||
await checkBilibiliCollection(req as Request, res as Response);
|
||||
|
||||
expect(downloadService.checkBilibiliCollectionOrSeries).toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 400 if url is missing', async () => {
|
||||
it('should throw ValidationError if url is missing', async () => {
|
||||
req.query = {};
|
||||
await import('../../controllers/videoController').then(m => m.checkBilibiliCollection(req as Request, res as Response));
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
try {
|
||||
await checkBilibiliCollection(req as Request, res as Response);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.name).toBe('ValidationError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,7 +430,7 @@ describe('VideoController', () => {
|
||||
await import('../../controllers/videoController').then(m => m.getVideoComments(req as Request, res as Response));
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith([]);
|
||||
expect(json).toHaveBeenCalledWith({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,71 +2,78 @@ import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { VIDEOS_DIR } from "../config/paths";
|
||||
import { ValidationError } from "../errors/DownloadErrors";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successResponse } from "../utils/response";
|
||||
|
||||
/**
|
||||
* Clean up temporary download files (.ytdl, .part)
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const cleanupTempFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// Check if there are active downloads
|
||||
const downloadStatus = storageService.getDownloadStatus();
|
||||
if (downloadStatus.activeDownloads.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Cannot clean up while downloads are active",
|
||||
activeDownloads: downloadStatus.activeDownloads.length,
|
||||
});
|
||||
}
|
||||
export const cleanupTempFiles = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
// Check if there are active downloads
|
||||
const downloadStatus = storageService.getDownloadStatus();
|
||||
if (downloadStatus.activeDownloads.length > 0) {
|
||||
throw new ValidationError(
|
||||
`Cannot clean up while downloads are active (${downloadStatus.activeDownloads.length} active)`,
|
||||
"activeDownloads"
|
||||
);
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Recursively find and delete .ytdl and .part files
|
||||
const cleanupDirectory = async (dir: string) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
// Recursively find and delete .ytdl and .part files
|
||||
const cleanupDirectory = async (dir: string) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively clean subdirectories
|
||||
await cleanupDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Check if file has .ytdl or .part extension
|
||||
if (entry.name.endsWith('.ytdl') || entry.name.endsWith('.part')) {
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
deletedCount++;
|
||||
console.log(`Deleted temp file: ${fullPath}`);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to delete ${fullPath}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.error(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively clean subdirectories
|
||||
await cleanupDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Check if file has .ytdl or .part extension
|
||||
if (entry.name.endsWith(".ytdl") || entry.name.endsWith(".part")) {
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
deletedCount++;
|
||||
logger.debug(`Deleted temp file: ${fullPath}`);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to delete ${fullPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
logger.warn(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to read directory ${dir}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.error(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to read directory ${dir}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
logger.error(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Start cleanup from VIDEOS_DIR
|
||||
await cleanupDirectory(VIDEOS_DIR);
|
||||
// Start cleanup from VIDEOS_DIR
|
||||
await cleanupDirectory(VIDEOS_DIR);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
deletedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error cleaning up temp files:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to clean up temporary files",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
res.status(200).json(
|
||||
successResponse(
|
||||
{
|
||||
deletedCount,
|
||||
...(errors.length > 0 && { errors }),
|
||||
},
|
||||
`Cleaned up ${deletedCount} temporary files`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,133 +1,134 @@
|
||||
import { Request, Response } from "express";
|
||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { Collection } from "../services/storageService";
|
||||
import { successMessage, successResponse } from "../utils/response";
|
||||
|
||||
// Get all collections
|
||||
export const getCollections = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const collections = storageService.getCollections();
|
||||
res.json(collections);
|
||||
} catch (error) {
|
||||
console.error("Error getting collections:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: "Failed to get collections" });
|
||||
}
|
||||
/**
|
||||
* Get all collections
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getCollections = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const collections = storageService.getCollections();
|
||||
res.json(successResponse(collections));
|
||||
};
|
||||
|
||||
// Create a new collection
|
||||
export const createCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { name, videoId } = req.body;
|
||||
/**
|
||||
* Create a new collection
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const createCollection = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { name, videoId } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Collection name is required" });
|
||||
}
|
||||
|
||||
// Create a new collection
|
||||
const newCollection: Collection = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
videos: [], // Initialize with empty videos
|
||||
createdAt: new Date().toISOString(),
|
||||
title: name, // Ensure title is also set as it's required by the interface
|
||||
};
|
||||
|
||||
// Save the new collection
|
||||
storageService.saveCollection(newCollection);
|
||||
|
||||
// If videoId is provided, add it to the collection (this handles file moving)
|
||||
if (videoId) {
|
||||
const updatedCollection = storageService.addVideoToCollection(newCollection.id, videoId);
|
||||
if (updatedCollection) {
|
||||
return res.status(201).json(updatedCollection);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(newCollection);
|
||||
} catch (error) {
|
||||
console.error("Error creating collection:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: "Failed to create collection" });
|
||||
if (!name) {
|
||||
throw new ValidationError("Collection name is required", "name");
|
||||
}
|
||||
|
||||
// Create a new collection
|
||||
const newCollection: Collection = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
videos: [], // Initialize with empty videos
|
||||
createdAt: new Date().toISOString(),
|
||||
title: name, // Ensure title is also set as it's required by the interface
|
||||
};
|
||||
|
||||
// Save the new collection
|
||||
storageService.saveCollection(newCollection);
|
||||
|
||||
// If videoId is provided, add it to the collection (this handles file moving)
|
||||
if (videoId) {
|
||||
const updatedCollection = storageService.addVideoToCollection(
|
||||
newCollection.id,
|
||||
videoId
|
||||
);
|
||||
if (updatedCollection) {
|
||||
res
|
||||
.status(201)
|
||||
.json(successResponse(updatedCollection, "Collection created"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(successResponse(newCollection, "Collection created"));
|
||||
};
|
||||
|
||||
// Update a collection
|
||||
export const updateCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, videoId, action } = req.body;
|
||||
/**
|
||||
* Update a collection
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const updateCollection = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
const { name, videoId, action } = req.body;
|
||||
|
||||
let updatedCollection: Collection | null | undefined;
|
||||
let updatedCollection: Collection | null | undefined;
|
||||
|
||||
// Handle name update first
|
||||
if (name) {
|
||||
updatedCollection = storageService.atomicUpdateCollection(id, (collection) => {
|
||||
// Handle name update first
|
||||
if (name) {
|
||||
updatedCollection = storageService.atomicUpdateCollection(
|
||||
id,
|
||||
(collection) => {
|
||||
collection.name = name;
|
||||
collection.title = name;
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle video add/remove
|
||||
if (videoId) {
|
||||
if (action === "add") {
|
||||
updatedCollection = storageService.addVideoToCollection(id, videoId);
|
||||
} else if (action === "remove") {
|
||||
updatedCollection = storageService.removeVideoFromCollection(id, videoId);
|
||||
}
|
||||
}
|
||||
|
||||
// If no changes requested but id exists, return current collection
|
||||
if (!name && !videoId) {
|
||||
updatedCollection = storageService.getCollectionById(id);
|
||||
}
|
||||
|
||||
if (!updatedCollection) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Collection not found or update failed" });
|
||||
}
|
||||
|
||||
res.json(updatedCollection);
|
||||
} catch (error) {
|
||||
console.error("Error updating collection:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: "Failed to update collection" });
|
||||
);
|
||||
}
|
||||
|
||||
// Handle video add/remove
|
||||
if (videoId) {
|
||||
if (action === "add") {
|
||||
updatedCollection = storageService.addVideoToCollection(id, videoId);
|
||||
} else if (action === "remove") {
|
||||
updatedCollection = storageService.removeVideoFromCollection(id, videoId);
|
||||
}
|
||||
}
|
||||
|
||||
// If no changes requested but id exists, return current collection
|
||||
if (!name && !videoId) {
|
||||
updatedCollection = storageService.getCollectionById(id);
|
||||
}
|
||||
|
||||
if (!updatedCollection) {
|
||||
throw new NotFoundError("Collection", id);
|
||||
}
|
||||
|
||||
res.json(successResponse(updatedCollection));
|
||||
};
|
||||
|
||||
// Delete a collection
|
||||
export const deleteCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { deleteVideos } = req.query;
|
||||
/**
|
||||
* Delete a collection
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const deleteCollection = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
const { deleteVideos } = req.query;
|
||||
|
||||
let success = false;
|
||||
let success = false;
|
||||
|
||||
// If deleteVideos is true, delete all videos in the collection first
|
||||
if (deleteVideos === 'true') {
|
||||
success = storageService.deleteCollectionAndVideos(id);
|
||||
} else {
|
||||
// Default: Move files back to root/other, then delete collection
|
||||
success = storageService.deleteCollectionWithFiles(id);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Collection not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "Collection deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting collection:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: "Failed to delete collection" });
|
||||
// If deleteVideos is true, delete all videos in the collection first
|
||||
if (deleteVideos === "true") {
|
||||
success = storageService.deleteCollectionAndVideos(id);
|
||||
} else {
|
||||
// Default: Move files back to root/other, then delete collection
|
||||
success = storageService.deleteCollectionWithFiles(id);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new NotFoundError("Collection", id);
|
||||
}
|
||||
|
||||
res.json(successMessage("Collection deleted successfully"));
|
||||
};
|
||||
|
||||
@@ -1,72 +1,79 @@
|
||||
import { Request, Response } from "express";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { successMessage, successResponse } from "../utils/response";
|
||||
|
||||
// Cancel a download
|
||||
export const cancelDownload = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
downloadManager.cancelDownload(id);
|
||||
res.status(200).json({ success: true, message: "Download cancelled" });
|
||||
} catch (error: any) {
|
||||
console.error("Error cancelling download:", error);
|
||||
res.status(500).json({ error: "Failed to cancel download", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Cancel a download
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const cancelDownload = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
downloadManager.cancelDownload(id);
|
||||
res.status(200).json(successMessage("Download cancelled"));
|
||||
};
|
||||
|
||||
// Remove from queue
|
||||
export const removeFromQueue = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
downloadManager.removeFromQueue(id);
|
||||
res.status(200).json({ success: true, message: "Removed from queue" });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing from queue:", error);
|
||||
res.status(500).json({ error: "Failed to remove from queue", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Remove from queue
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const removeFromQueue = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
downloadManager.removeFromQueue(id);
|
||||
res.status(200).json(successMessage("Removed from queue"));
|
||||
};
|
||||
|
||||
// Clear queue
|
||||
export const clearQueue = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
downloadManager.clearQueue();
|
||||
res.status(200).json({ success: true, message: "Queue cleared" });
|
||||
} catch (error: any) {
|
||||
console.error("Error clearing queue:", error);
|
||||
res.status(500).json({ error: "Failed to clear queue", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Clear queue
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const clearQueue = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
downloadManager.clearQueue();
|
||||
res.status(200).json(successMessage("Queue cleared"));
|
||||
};
|
||||
|
||||
// Get download history
|
||||
export const getDownloadHistory = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
const history = storageService.getDownloadHistory();
|
||||
res.status(200).json(history);
|
||||
} catch (error: any) {
|
||||
console.error("Error getting download history:", error);
|
||||
res.status(500).json({ error: "Failed to get download history", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Get download history
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getDownloadHistory = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const history = storageService.getDownloadHistory();
|
||||
res.status(200).json(successResponse(history));
|
||||
};
|
||||
|
||||
// Remove from history
|
||||
export const removeDownloadHistory = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
storageService.removeDownloadHistoryItem(id);
|
||||
res.status(200).json({ success: true, message: "Removed from history" });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing from history:", error);
|
||||
res.status(500).json({ error: "Failed to remove from history", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Remove from history
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const removeDownloadHistory = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
storageService.removeDownloadHistoryItem(id);
|
||||
res.status(200).json(successMessage("Removed from history"));
|
||||
};
|
||||
|
||||
// Clear history
|
||||
export const clearDownloadHistory = (_req: Request, res: Response): any => {
|
||||
try {
|
||||
storageService.clearDownloadHistory();
|
||||
res.status(200).json({ success: true, message: "History cleared" });
|
||||
} catch (error: any) {
|
||||
console.error("Error clearing history:", error);
|
||||
res.status(500).json({ error: "Failed to clear history", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Clear history
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const clearDownloadHistory = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
storageService.clearDownloadHistory();
|
||||
res.status(200).json(successMessage("History cleared"));
|
||||
};
|
||||
|
||||
@@ -4,219 +4,235 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successResponse } from "../utils/response";
|
||||
|
||||
// Recursive function to get all files in a directory
|
||||
const getFilesRecursively = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
|
||||
|
||||
list.forEach((file) => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(getFilesRecursively(filePath));
|
||||
} else {
|
||||
results.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const scanFiles = async (_req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
console.log("Starting file scan...");
|
||||
|
||||
// 1. Get all existing videos from DB
|
||||
const existingVideos = storageService.getVideos();
|
||||
const existingPaths = new Set<string>();
|
||||
const existingFilenames = new Set<string>();
|
||||
|
||||
// Track deleted videos
|
||||
let deletedCount = 0;
|
||||
const videosToDelete: string[] = [];
|
||||
/**
|
||||
* Scan files in videos directory and sync with database
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const scanFiles = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
logger.info("Starting file scan...");
|
||||
|
||||
// Check for missing files
|
||||
for (const v of existingVideos) {
|
||||
if (v.videoPath) existingPaths.add(v.videoPath);
|
||||
if (v.videoFilename) {
|
||||
existingFilenames.add(v.videoFilename);
|
||||
}
|
||||
// 1. Get all existing videos from DB
|
||||
const existingVideos = storageService.getVideos();
|
||||
const existingPaths = new Set<string>();
|
||||
const existingFilenames = new Set<string>();
|
||||
|
||||
// Track deleted videos
|
||||
let deletedCount = 0;
|
||||
const videosToDelete: string[] = [];
|
||||
|
||||
// Check for missing files
|
||||
for (const v of existingVideos) {
|
||||
if (v.videoPath) existingPaths.add(v.videoPath);
|
||||
if (v.videoFilename) {
|
||||
existingFilenames.add(v.videoFilename);
|
||||
}
|
||||
|
||||
// 2. Recursively scan VIDEOS_DIR
|
||||
if (!fs.existsSync(VIDEOS_DIR)) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Videos directory does not exist",
|
||||
addedCount: 0,
|
||||
deletedCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
const allFiles = getFilesRecursively(VIDEOS_DIR);
|
||||
const videoExtensions = ['.mp4', '.mkv', '.webm', '.avi', '.mov'];
|
||||
const actualFilesOnDisk = new Set<string>(); // Stores filenames (basename)
|
||||
const actualFullPathsOnDisk = new Set<string>(); // Stores full absolute paths
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (videoExtensions.includes(ext)) {
|
||||
actualFilesOnDisk.add(path.basename(filePath));
|
||||
actualFullPathsOnDisk.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Now check for missing videos
|
||||
for (const v of existingVideos) {
|
||||
if (v.videoFilename) {
|
||||
// If the filename is not found in ANY of the scanned files, it is missing.
|
||||
if (!actualFilesOnDisk.has(v.videoFilename)) {
|
||||
console.log(`Video missing: ${v.title} (${v.videoFilename})`);
|
||||
videosToDelete.push(v.id);
|
||||
}
|
||||
} else {
|
||||
// No filename? That's a bad record.
|
||||
console.log(`Video record corrupted (no filename): ${v.title}`);
|
||||
videosToDelete.push(v.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete missing videos
|
||||
for (const id of videosToDelete) {
|
||||
if (storageService.deleteVideo(id)) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
console.log(`Deleted ${deletedCount} missing videos.`);
|
||||
|
||||
|
||||
let addedCount = 0;
|
||||
|
||||
// 3. Process each file (Add new ones)
|
||||
for (const filePath of allFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!videoExtensions.includes(ext)) continue;
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
const relativePath = path.relative(VIDEOS_DIR, filePath);
|
||||
const webPath = `/videos/${relativePath.split(path.sep).join('/')}`;
|
||||
|
||||
// Check if exists in DB
|
||||
if (existingFilenames.has(filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Found new video file: ${relativePath}`);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const createdDate = stats.birthtime;
|
||||
const videoId = (Date.now() + Math.floor(Math.random() * 10000)).toString();
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailFilename = `${path.parse(filename).name}.jpg`;
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
exec(`ffmpeg -i "${filePath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
resolve();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Get duration
|
||||
let duration = undefined;
|
||||
try {
|
||||
const durationOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
if (durationOutput) {
|
||||
const durationSec = parseFloat(durationOutput);
|
||||
if (!isNaN(durationSec)) {
|
||||
duration = Math.round(durationSec).toString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error getting duration:", err);
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: path.parse(filename).name,
|
||||
author: "Admin",
|
||||
source: "local",
|
||||
sourceUrl: "",
|
||||
videoFilename: filename,
|
||||
videoPath: webPath,
|
||||
thumbnailFilename: fs.existsSync(thumbnailPath) ? thumbnailFilename : undefined,
|
||||
thumbnailPath: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
thumbnailUrl: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
createdAt: createdDate.toISOString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
date: createdDate.toISOString().split('T')[0].replace(/-/g, ''),
|
||||
duration: duration,
|
||||
};
|
||||
|
||||
storageService.saveVideo(newVideo);
|
||||
addedCount++;
|
||||
|
||||
// Check if video is in a subfolder
|
||||
const dirName = path.dirname(relativePath);
|
||||
if (dirName !== '.') {
|
||||
const collectionName = dirName.split(path.sep)[0];
|
||||
|
||||
let collectionId: string | undefined;
|
||||
const allCollections = storageService.getCollections();
|
||||
const existingCollection = allCollections.find(c => (c.title === collectionName || c.name === collectionName));
|
||||
|
||||
if (existingCollection) {
|
||||
collectionId = existingCollection.id;
|
||||
} else {
|
||||
collectionId = (Date.now() + Math.floor(Math.random() * 10000)).toString();
|
||||
const newCollection = {
|
||||
id: collectionId,
|
||||
title: collectionName,
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
console.log(`Created new collection from folder: ${collectionName}`);
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
storageService.addVideoToCollection(collectionId, newVideo.id);
|
||||
console.log(`Added video ${newVideo.title} to collection ${collectionName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Scan complete. Added ${addedCount} new videos. Deleted ${deletedCount} missing videos.`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Scan complete. Added ${addedCount} new videos. Deleted ${deletedCount} missing videos.`,
|
||||
addedCount,
|
||||
deletedCount
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error scanning files:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to scan files",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Recursively scan VIDEOS_DIR
|
||||
if (!fs.existsSync(VIDEOS_DIR)) {
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
successResponse(
|
||||
{ addedCount: 0, deletedCount: 0 },
|
||||
"Videos directory does not exist"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = getFilesRecursively(VIDEOS_DIR);
|
||||
const videoExtensions = [".mp4", ".mkv", ".webm", ".avi", ".mov"];
|
||||
const actualFilesOnDisk = new Set<string>(); // Stores filenames (basename)
|
||||
const actualFullPathsOnDisk = new Set<string>(); // Stores full absolute paths
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (videoExtensions.includes(ext)) {
|
||||
actualFilesOnDisk.add(path.basename(filePath));
|
||||
actualFullPathsOnDisk.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Now check for missing videos
|
||||
for (const v of existingVideos) {
|
||||
if (v.videoFilename) {
|
||||
// If the filename is not found in ANY of the scanned files, it is missing.
|
||||
if (!actualFilesOnDisk.has(v.videoFilename)) {
|
||||
logger.info(`Video missing: ${v.title} (${v.videoFilename})`);
|
||||
videosToDelete.push(v.id);
|
||||
}
|
||||
} else {
|
||||
// No filename? That's a bad record.
|
||||
logger.warn(`Video record corrupted (no filename): ${v.title}`);
|
||||
videosToDelete.push(v.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete missing videos
|
||||
for (const id of videosToDelete) {
|
||||
if (storageService.deleteVideo(id)) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
logger.info(`Deleted ${deletedCount} missing videos.`);
|
||||
|
||||
let addedCount = 0;
|
||||
|
||||
// 3. Process each file (Add new ones)
|
||||
for (const filePath of allFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!videoExtensions.includes(ext)) continue;
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
const relativePath = path.relative(VIDEOS_DIR, filePath);
|
||||
const webPath = `/videos/${relativePath.split(path.sep).join("/")}`;
|
||||
|
||||
// Check if exists in DB
|
||||
if (existingFilenames.has(filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Found new video file: ${relativePath}`);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const createdDate = stats.birthtime;
|
||||
const videoId = (Date.now() + Math.floor(Math.random() * 10000)).toString();
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailFilename = `${path.parse(filename).name}.jpg`;
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
exec(
|
||||
`ffmpeg -i "${filePath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`,
|
||||
(error) => {
|
||||
if (error) {
|
||||
logger.error("Error generating thumbnail:", error);
|
||||
resolve();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Get duration
|
||||
let duration = undefined;
|
||||
try {
|
||||
const durationOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
||||
(error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
if (durationOutput) {
|
||||
const durationSec = parseFloat(durationOutput);
|
||||
if (!isNaN(durationSec)) {
|
||||
duration = Math.round(durationSec).toString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Error getting duration:", err);
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: path.parse(filename).name,
|
||||
author: "Admin",
|
||||
source: "local",
|
||||
sourceUrl: "",
|
||||
videoFilename: filename,
|
||||
videoPath: webPath,
|
||||
thumbnailFilename: fs.existsSync(thumbnailPath)
|
||||
? thumbnailFilename
|
||||
: undefined,
|
||||
thumbnailPath: fs.existsSync(thumbnailPath)
|
||||
? `/images/${thumbnailFilename}`
|
||||
: undefined,
|
||||
thumbnailUrl: fs.existsSync(thumbnailPath)
|
||||
? `/images/${thumbnailFilename}`
|
||||
: undefined,
|
||||
createdAt: createdDate.toISOString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
date: createdDate.toISOString().split("T")[0].replace(/-/g, ""),
|
||||
duration: duration,
|
||||
};
|
||||
|
||||
storageService.saveVideo(newVideo);
|
||||
addedCount++;
|
||||
|
||||
// Check if video is in a subfolder
|
||||
const dirName = path.dirname(relativePath);
|
||||
if (dirName !== ".") {
|
||||
const collectionName = dirName.split(path.sep)[0];
|
||||
|
||||
let collectionId: string | undefined;
|
||||
const allCollections = storageService.getCollections();
|
||||
const existingCollection = allCollections.find(
|
||||
(c) => c.title === collectionName || c.name === collectionName
|
||||
);
|
||||
|
||||
if (existingCollection) {
|
||||
collectionId = existingCollection.id;
|
||||
} else {
|
||||
collectionId = (
|
||||
Date.now() + Math.floor(Math.random() * 10000)
|
||||
).toString();
|
||||
const newCollection = {
|
||||
id: collectionId,
|
||||
title: collectionName,
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
logger.info(`Created new collection from folder: ${collectionName}`);
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
storageService.addVideoToCollection(collectionId, newVideo.id);
|
||||
logger.info(
|
||||
`Added video ${newVideo.title} to collection ${collectionName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = `Scan complete. Added ${addedCount} new videos. Deleted ${deletedCount} missing videos.`;
|
||||
logger.info(message);
|
||||
|
||||
res.status(200).json(successResponse({ addedCount, deletedCount }, message));
|
||||
};
|
||||
|
||||
@@ -3,12 +3,15 @@ import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
} from "../config/paths";
|
||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successMessage, successResponse } from "../utils/response";
|
||||
|
||||
interface Settings {
|
||||
loginEnabled: boolean;
|
||||
@@ -51,276 +54,302 @@ const defaultSettings: Settings = {
|
||||
showYoutubeSearch: true,
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
/**
|
||||
* Get application settings
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getSettings = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const settings = storageService.getSettings();
|
||||
|
||||
// If empty (first run), save defaults
|
||||
if (Object.keys(settings).length === 0) {
|
||||
storageService.saveSettings(defaultSettings);
|
||||
return res.json(defaultSettings);
|
||||
}
|
||||
|
||||
// Merge with defaults to ensure all fields exist
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Do not send the hashed password to the frontend
|
||||
const { password, ...safeSettings } = mergedSettings;
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password });
|
||||
} catch (error) {
|
||||
console.error("Error reading settings:", error);
|
||||
res.status(500).json({ error: "Failed to read settings" });
|
||||
// If empty (first run), save defaults
|
||||
if (Object.keys(settings).length === 0) {
|
||||
storageService.saveSettings(defaultSettings);
|
||||
res.json(successResponse(defaultSettings));
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge with defaults to ensure all fields exist
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Do not send the hashed password to the frontend
|
||||
const { password, ...safeSettings } = mergedSettings;
|
||||
res.json(successResponse({ ...safeSettings, isPasswordSet: !!password }));
|
||||
};
|
||||
|
||||
export const migrateData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { runMigration } = await import("../services/migrationService");
|
||||
const results = await runMigration();
|
||||
res.json({ success: true, results });
|
||||
} catch (error: any) {
|
||||
console.error("Error running migration:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to run migration", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Run data migration
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const migrateData = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { runMigration } = await import("../services/migrationService");
|
||||
const results = await runMigration();
|
||||
res.json(successResponse(results, "Migration completed"));
|
||||
};
|
||||
|
||||
export const deleteLegacyData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const SETTINGS_DATA_PATH = path.join(
|
||||
path.dirname(VIDEOS_DATA_PATH),
|
||||
"settings.json"
|
||||
);
|
||||
const filesToDelete = [
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
SETTINGS_DATA_PATH,
|
||||
];
|
||||
/**
|
||||
* Delete legacy data files
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const deleteLegacyData = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const SETTINGS_DATA_PATH = path.join(
|
||||
path.dirname(VIDEOS_DATA_PATH),
|
||||
"settings.json"
|
||||
);
|
||||
const filesToDelete = [
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
SETTINGS_DATA_PATH,
|
||||
];
|
||||
|
||||
const results: { deleted: string[]; failed: string[] } = {
|
||||
deleted: [],
|
||||
failed: [],
|
||||
};
|
||||
const results: { deleted: string[]; failed: string[] } = {
|
||||
deleted: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
results.deleted.push(path.basename(file));
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete ${file}:`, err);
|
||||
results.failed.push(path.basename(file));
|
||||
}
|
||||
for (const file of filesToDelete) {
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
results.deleted.push(path.basename(file));
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete ${file}:`, err);
|
||||
results.failed.push(path.basename(file));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, results });
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting legacy data:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to delete legacy data", details: error.message });
|
||||
}
|
||||
|
||||
res.json(successResponse(results, "Legacy data deletion completed"));
|
||||
};
|
||||
|
||||
export const formatFilenames = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const results = storageService.formatLegacyFilenames();
|
||||
res.json({ success: true, results });
|
||||
} catch (error: any) {
|
||||
console.error("Error formatting filenames:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to format filenames", details: error.message });
|
||||
}
|
||||
/**
|
||||
* Format legacy filenames
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const formatFilenames = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const results = storageService.formatLegacyFilenames();
|
||||
res.json(successResponse(results, "Filenames formatted"));
|
||||
};
|
||||
|
||||
export const updateSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const newSettings: Settings = req.body;
|
||||
/**
|
||||
* Update application settings
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const updateSettings = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const newSettings: Settings = req.body;
|
||||
|
||||
// Validate settings if needed
|
||||
if (newSettings.maxConcurrentDownloads < 1) {
|
||||
newSettings.maxConcurrentDownloads = 1;
|
||||
}
|
||||
// Validate settings if needed
|
||||
if (newSettings.maxConcurrentDownloads < 1) {
|
||||
newSettings.maxConcurrentDownloads = 1;
|
||||
}
|
||||
|
||||
if (newSettings.websiteName && newSettings.websiteName.length > 15) {
|
||||
newSettings.websiteName = newSettings.websiteName.substring(0, 15);
|
||||
}
|
||||
if (newSettings.websiteName && newSettings.websiteName.length > 15) {
|
||||
newSettings.websiteName = newSettings.websiteName.substring(0, 15);
|
||||
}
|
||||
|
||||
if (newSettings.itemsPerPage && newSettings.itemsPerPage < 1) {
|
||||
newSettings.itemsPerPage = 12; // Default fallback if invalid
|
||||
}
|
||||
if (newSettings.itemsPerPage && newSettings.itemsPerPage < 1) {
|
||||
newSettings.itemsPerPage = 12; // Default fallback if invalid
|
||||
}
|
||||
|
||||
// Handle password hashing
|
||||
if (newSettings.password) {
|
||||
// If password is provided, hash it
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
newSettings.password = await bcrypt.hash(newSettings.password, salt);
|
||||
} else {
|
||||
// If password is empty/not provided, keep existing password
|
||||
const existingSettings = storageService.getSettings();
|
||||
newSettings.password = existingSettings.password;
|
||||
}
|
||||
|
||||
// Check for deleted tags and remove them from all videos
|
||||
// Handle password hashing
|
||||
if (newSettings.password) {
|
||||
// If password is provided, hash it
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
newSettings.password = await bcrypt.hash(newSettings.password, salt);
|
||||
} else {
|
||||
// If password is empty/not provided, keep existing password
|
||||
const existingSettings = storageService.getSettings();
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
const newTagsList: string[] = newSettings.tags || [];
|
||||
newSettings.password = existingSettings.password;
|
||||
}
|
||||
|
||||
const deletedTags = oldTags.filter((tag) => !newTagsList.includes(tag));
|
||||
// Check for deleted tags and remove them from all videos
|
||||
const existingSettings = storageService.getSettings();
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
const newTagsList: string[] = newSettings.tags || [];
|
||||
|
||||
if (deletedTags.length > 0) {
|
||||
console.log("Tags deleted:", deletedTags);
|
||||
const allVideos = storageService.getVideos();
|
||||
let videosUpdatedCount = 0;
|
||||
const deletedTags = oldTags.filter((tag) => !newTagsList.includes(tag));
|
||||
|
||||
for (const video of allVideos) {
|
||||
if (video.tags && video.tags.some((tag) => deletedTags.includes(tag))) {
|
||||
const updatedTags = video.tags.filter(
|
||||
(tag) => !deletedTags.includes(tag)
|
||||
);
|
||||
storageService.updateVideo(video.id, { tags: updatedTags });
|
||||
videosUpdatedCount++;
|
||||
}
|
||||
}
|
||||
console.log(`Removed deleted tags from ${videosUpdatedCount} videos`);
|
||||
}
|
||||
if (deletedTags.length > 0) {
|
||||
logger.info("Tags deleted:", deletedTags);
|
||||
const allVideos = storageService.getVideos();
|
||||
let videosUpdatedCount = 0;
|
||||
|
||||
storageService.saveSettings(newSettings);
|
||||
|
||||
// Check for moveSubtitlesToVideoFolder change
|
||||
if (newSettings.moveSubtitlesToVideoFolder !== existingSettings.moveSubtitlesToVideoFolder) {
|
||||
if (newSettings.moveSubtitlesToVideoFolder !== undefined) {
|
||||
// Run asynchronously
|
||||
const { moveAllSubtitles } = await import("../services/subtitleService");
|
||||
moveAllSubtitles(newSettings.moveSubtitlesToVideoFolder)
|
||||
.catch(err => console.error("Error moving subtitles in background:", err));
|
||||
for (const video of allVideos) {
|
||||
if (video.tags && video.tags.some((tag) => deletedTags.includes(tag))) {
|
||||
const updatedTags = video.tags.filter(
|
||||
(tag) => !deletedTags.includes(tag)
|
||||
);
|
||||
storageService.updateVideo(video.id, { tags: updatedTags });
|
||||
videosUpdatedCount++;
|
||||
}
|
||||
}
|
||||
logger.info(`Removed deleted tags from ${videosUpdatedCount} videos`);
|
||||
}
|
||||
|
||||
// Check for moveThumbnailsToVideoFolder change
|
||||
if (newSettings.moveThumbnailsToVideoFolder !== existingSettings.moveThumbnailsToVideoFolder) {
|
||||
if (newSettings.moveThumbnailsToVideoFolder !== undefined) {
|
||||
// Run asynchronously
|
||||
const { moveAllThumbnails } = await import("../services/thumbnailService");
|
||||
moveAllThumbnails(newSettings.moveThumbnailsToVideoFolder)
|
||||
.catch(err => console.error("Error moving thumbnails in background:", err));
|
||||
}
|
||||
storageService.saveSettings(newSettings);
|
||||
|
||||
// Check for moveSubtitlesToVideoFolder change
|
||||
if (
|
||||
newSettings.moveSubtitlesToVideoFolder !==
|
||||
existingSettings.moveSubtitlesToVideoFolder
|
||||
) {
|
||||
if (newSettings.moveSubtitlesToVideoFolder !== undefined) {
|
||||
// Run asynchronously
|
||||
const { moveAllSubtitles } = await import("../services/subtitleService");
|
||||
moveAllSubtitles(newSettings.moveSubtitlesToVideoFolder).catch((err) =>
|
||||
logger.error("Error moving subtitles in background:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply settings immediately where possible
|
||||
downloadManager.setMaxConcurrentDownloads(
|
||||
newSettings.maxConcurrentDownloads
|
||||
);
|
||||
// Check for moveThumbnailsToVideoFolder change
|
||||
if (
|
||||
newSettings.moveThumbnailsToVideoFolder !==
|
||||
existingSettings.moveThumbnailsToVideoFolder
|
||||
) {
|
||||
if (newSettings.moveThumbnailsToVideoFolder !== undefined) {
|
||||
// Run asynchronously
|
||||
const { moveAllThumbnails } = await import(
|
||||
"../services/thumbnailService"
|
||||
);
|
||||
moveAllThumbnails(newSettings.moveThumbnailsToVideoFolder).catch((err) =>
|
||||
logger.error("Error moving thumbnails in background:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...newSettings, password: undefined },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating settings:", error);
|
||||
res.status(500).json({ error: "Failed to update settings" });
|
||||
// Apply settings immediately where possible
|
||||
downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads);
|
||||
|
||||
res.json(
|
||||
successResponse({ ...newSettings, password: undefined }, "Settings updated")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if password authentication is enabled
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getPasswordEnabled = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Return true only if login is enabled AND a password is set
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||
|
||||
res.json(successResponse({ enabled: isEnabled }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify password for authentication
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyPassword = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { password } = req.body;
|
||||
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
if (!mergedSettings.loginEnabled) {
|
||||
res.json(successResponse({ verified: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mergedSettings.password) {
|
||||
// If no password set but login enabled, allow access
|
||||
res.json(successResponse({ verified: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
|
||||
if (isMatch) {
|
||||
res.json(successResponse({ verified: true }));
|
||||
} else {
|
||||
throw new ValidationError("Incorrect password", "password");
|
||||
}
|
||||
};
|
||||
|
||||
export const getPasswordEnabled = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Return true only if login is enabled AND a password is set
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||
|
||||
res.json({ enabled: isEnabled });
|
||||
} catch (error) {
|
||||
console.error("Error checking password status:", error);
|
||||
res.status(500).json({ error: "Failed to check password status" });
|
||||
/**
|
||||
* Upload cookies file
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const uploadCookies = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
if (!req.file) {
|
||||
throw new ValidationError("No file uploaded", "file");
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyPassword = async (req: Request, res: Response) => {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const targetPath = path.join(DATA_DIR, "cookies.txt");
|
||||
|
||||
try {
|
||||
const { password } = req.body;
|
||||
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
if (!mergedSettings.loginEnabled) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (!mergedSettings.password) {
|
||||
// If no password set but login enabled, allow access
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
|
||||
if (isMatch) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(401).json({ success: false, error: "Incorrect password" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error verifying password:", error);
|
||||
res.status(500).json({ error: "Failed to verify password" });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadCookies = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const targetPath = path.join(DATA_DIR, "cookies.txt");
|
||||
|
||||
// Move the file to the target location
|
||||
fs.moveSync(req.file.path, targetPath, { overwrite: true });
|
||||
|
||||
console.log(`Cookies uploaded and saved to ${targetPath}`);
|
||||
res.json({ success: true, message: "Cookies uploaded successfully" });
|
||||
logger.info(`Cookies uploaded and saved to ${targetPath}`);
|
||||
res.json(successMessage("Cookies uploaded successfully"));
|
||||
} catch (error: any) {
|
||||
console.error("Error uploading cookies:", error);
|
||||
// Clean up temp file if it exists
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to upload cookies", details: error.message });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkCookies = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const cookiesPath = path.join(DATA_DIR, "cookies.txt");
|
||||
const exists = fs.existsSync(cookiesPath);
|
||||
res.json({ exists });
|
||||
} catch (error) {
|
||||
console.error("Error checking cookies:", error);
|
||||
res.status(500).json({ error: "Failed to check cookies" });
|
||||
}
|
||||
/**
|
||||
* Check if cookies file exists
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const checkCookies = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const cookiesPath = path.join(DATA_DIR, "cookies.txt");
|
||||
const exists = fs.existsSync(cookiesPath);
|
||||
res.json(successResponse({ exists }));
|
||||
};
|
||||
|
||||
export const deleteCookies = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const cookiesPath = path.join(DATA_DIR, "cookies.txt");
|
||||
/**
|
||||
* Delete cookies file
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const deleteCookies = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const cookiesPath = path.join(DATA_DIR, "cookies.txt");
|
||||
|
||||
if (fs.existsSync(cookiesPath)) {
|
||||
fs.unlinkSync(cookiesPath);
|
||||
res.json({ success: true, message: "Cookies deleted successfully" });
|
||||
} else {
|
||||
res.status(404).json({ error: "Cookies file not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting cookies:", error);
|
||||
res.status(500).json({ error: "Failed to delete cookies" });
|
||||
if (fs.existsSync(cookiesPath)) {
|
||||
fs.unlinkSync(cookiesPath);
|
||||
res.json(successMessage("Cookies deleted successfully"));
|
||||
} else {
|
||||
throw new NotFoundError("Cookies file", "cookies.txt");
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,50 +10,92 @@ import { asyncHandler } from "../middleware/errorHandler";
|
||||
const router = express.Router();
|
||||
|
||||
// Video routes
|
||||
router.get("/search", videoController.searchVideos);
|
||||
router.post("/download", videoController.downloadVideo);
|
||||
router.get("/search", asyncHandler(videoController.searchVideos));
|
||||
router.post("/download", asyncHandler(videoController.downloadVideo));
|
||||
router.post(
|
||||
"/upload",
|
||||
videoController.upload.single("video"),
|
||||
videoController.uploadVideo
|
||||
asyncHandler(videoController.uploadVideo)
|
||||
);
|
||||
router.get("/videos", asyncHandler(videoController.getVideos));
|
||||
router.get("/videos/:id", asyncHandler(videoController.getVideoById));
|
||||
router.put("/videos/:id", asyncHandler(videoController.updateVideoDetails));
|
||||
router.delete("/videos/:id", asyncHandler(videoController.deleteVideo));
|
||||
router.get(
|
||||
"/videos/:id/comments",
|
||||
asyncHandler(videoController.getVideoComments)
|
||||
);
|
||||
router.post("/videos/:id/rate", asyncHandler(videoController.rateVideo));
|
||||
router.post(
|
||||
"/videos/:id/refresh-thumbnail",
|
||||
asyncHandler(videoController.refreshThumbnail)
|
||||
);
|
||||
router.post(
|
||||
"/videos/:id/view",
|
||||
asyncHandler(videoController.incrementViewCount)
|
||||
);
|
||||
router.put(
|
||||
"/videos/:id/progress",
|
||||
asyncHandler(videoController.updateProgress)
|
||||
);
|
||||
router.get("/videos", videoController.getVideos);
|
||||
router.get("/videos/:id", videoController.getVideoById);
|
||||
router.put("/videos/:id", videoController.updateVideoDetails);
|
||||
router.delete("/videos/:id", videoController.deleteVideo);
|
||||
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||
router.post("/videos/:id/rate", videoController.rateVideo);
|
||||
router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail);
|
||||
router.post("/videos/:id/view", videoController.incrementViewCount);
|
||||
router.put("/videos/:id/progress", videoController.updateProgress);
|
||||
|
||||
router.post("/scan-files", scanController.scanFiles);
|
||||
router.post("/cleanup-temp-files", cleanupController.cleanupTempFiles);
|
||||
router.post("/scan-files", asyncHandler(scanController.scanFiles));
|
||||
router.post(
|
||||
"/cleanup-temp-files",
|
||||
asyncHandler(cleanupController.cleanupTempFiles)
|
||||
);
|
||||
|
||||
router.get("/download-status", videoController.getDownloadStatus);
|
||||
router.get("/check-video-download", videoController.checkVideoDownloadStatus);
|
||||
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
||||
router.get("/download-status", asyncHandler(videoController.getDownloadStatus));
|
||||
router.get(
|
||||
"/check-video-download",
|
||||
asyncHandler(videoController.checkVideoDownloadStatus)
|
||||
);
|
||||
router.get(
|
||||
"/check-bilibili-parts",
|
||||
asyncHandler(videoController.checkBilibiliParts)
|
||||
);
|
||||
router.get(
|
||||
"/check-bilibili-collection",
|
||||
videoController.checkBilibiliCollection
|
||||
asyncHandler(videoController.checkBilibiliCollection)
|
||||
);
|
||||
|
||||
// Download management
|
||||
router.post("/downloads/cancel/:id", downloadController.cancelDownload);
|
||||
router.delete("/downloads/queue/:id", downloadController.removeFromQueue);
|
||||
router.delete("/downloads/queue", downloadController.clearQueue);
|
||||
router.get("/downloads/history", downloadController.getDownloadHistory);
|
||||
router.post(
|
||||
"/downloads/cancel/:id",
|
||||
asyncHandler(downloadController.cancelDownload)
|
||||
);
|
||||
router.delete(
|
||||
"/downloads/queue/:id",
|
||||
asyncHandler(downloadController.removeFromQueue)
|
||||
);
|
||||
router.delete("/downloads/queue", asyncHandler(downloadController.clearQueue));
|
||||
router.get(
|
||||
"/downloads/history",
|
||||
asyncHandler(downloadController.getDownloadHistory)
|
||||
);
|
||||
router.delete(
|
||||
"/downloads/history/:id",
|
||||
downloadController.removeDownloadHistory
|
||||
asyncHandler(downloadController.removeDownloadHistory)
|
||||
);
|
||||
router.delete(
|
||||
"/downloads/history",
|
||||
asyncHandler(downloadController.clearDownloadHistory)
|
||||
);
|
||||
router.delete("/downloads/history", downloadController.clearDownloadHistory);
|
||||
|
||||
// Collection routes
|
||||
router.get("/collections", collectionController.getCollections);
|
||||
router.post("/collections", collectionController.createCollection);
|
||||
router.put("/collections/:id", collectionController.updateCollection);
|
||||
router.delete("/collections/:id", collectionController.deleteCollection);
|
||||
router.get("/collections", asyncHandler(collectionController.getCollections));
|
||||
router.post(
|
||||
"/collections",
|
||||
asyncHandler(collectionController.createCollection)
|
||||
);
|
||||
router.put(
|
||||
"/collections/:id",
|
||||
asyncHandler(collectionController.updateCollection)
|
||||
);
|
||||
router.delete(
|
||||
"/collections/:id",
|
||||
asyncHandler(collectionController.deleteCollection)
|
||||
);
|
||||
|
||||
// Subscription routes
|
||||
router.post(
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import os from 'os';
|
||||
import { checkCookies, deleteCookies, deleteLegacyData, formatFilenames, getPasswordEnabled, getSettings, migrateData, updateSettings, uploadCookies, verifyPassword } from '../controllers/settingsController';
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import os from "os";
|
||||
import {
|
||||
checkCookies,
|
||||
deleteCookies,
|
||||
deleteLegacyData,
|
||||
formatFilenames,
|
||||
getPasswordEnabled,
|
||||
getSettings,
|
||||
migrateData,
|
||||
updateSettings,
|
||||
uploadCookies,
|
||||
verifyPassword,
|
||||
} from "../controllers/settingsController";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ dest: os.tmpdir() });
|
||||
|
||||
router.get('/', getSettings);
|
||||
router.post('/', updateSettings);
|
||||
router.get('/password-enabled', getPasswordEnabled);
|
||||
router.post('/verify-password', verifyPassword);
|
||||
router.post('/migrate', migrateData);
|
||||
router.post('/delete-legacy', deleteLegacyData);
|
||||
router.post('/format-filenames', formatFilenames);
|
||||
router.post('/upload-cookies', upload.single('file'), uploadCookies);
|
||||
router.post('/delete-cookies', deleteCookies);
|
||||
router.get('/check-cookies', checkCookies);
|
||||
router.get("/", asyncHandler(getSettings));
|
||||
router.post("/", asyncHandler(updateSettings));
|
||||
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
||||
router.post("/verify-password", asyncHandler(verifyPassword));
|
||||
router.post("/migrate", asyncHandler(migrateData));
|
||||
router.post("/delete-legacy", asyncHandler(deleteLegacyData));
|
||||
router.post("/format-filenames", asyncHandler(formatFilenames));
|
||||
router.post(
|
||||
"/upload-cookies",
|
||||
upload.single("file"),
|
||||
asyncHandler(uploadCookies)
|
||||
);
|
||||
router.post("/delete-cookies", asyncHandler(deleteCookies));
|
||||
router.get("/check-cookies", asyncHandler(checkCookies));
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user