refactor: refactor controller

This commit is contained in:
Peifan Li
2025-12-14 22:23:08 -05:00
parent 07ca438930
commit 4e0dd4cd8c
12 changed files with 1425 additions and 1259 deletions

View File

@@ -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');
}
});
});
});

View File

@@ -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');
}
});
});
});

View File

@@ -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');
}
});
});

View File

@@ -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: [] });
});
});

View File

@@ -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`
)
);
};

View File

@@ -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"));
};

View File

@@ -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"));
};

View File

@@ -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));
};

View File

@@ -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

View File

@@ -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(

View File

@@ -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;