feat: Add centralized API client and query configuration

This commit is contained in:
Peifan Li
2025-12-27 15:16:03 -05:00
parent 1817c67034
commit 3d0bf3440b
11 changed files with 743 additions and 305 deletions

View File

@@ -2,17 +2,17 @@ import { Request, Response } from "express";
import fs from "fs-extra";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteVideo,
getVideoById,
getVideos,
updateVideoDetails,
deleteVideo,
getVideoById,
getVideos,
updateVideoDetails,
} from "../../controllers/videoController";
import {
checkBilibiliCollection,
checkBilibiliParts,
downloadVideo,
getDownloadStatus,
searchVideos,
checkBilibiliCollection,
checkBilibiliParts,
downloadVideo,
getDownloadStatus,
searchVideos,
} from "../../controllers/videoDownloadController";
import { rateVideo } from "../../controllers/videoMetadataController";
import downloadManager from "../../services/downloadManager";
@@ -63,6 +63,13 @@ describe("VideoController", () => {
json,
status,
};
(storageService.handleVideoDownloadCheck as any) = vi.fn().mockReturnValue({
shouldSkip: false,
shouldForce: false,
});
(storageService.checkVideoDownloadBySourceId as any) = vi.fn().mockReturnValue({
found: false,
});
});
describe("searchVideos", () => {

View File

@@ -1,7 +1,7 @@
import { Request, Response } from "express";
import downloadManager from "../services/downloadManager";
import * as storageService from "../services/storageService";
import { successMessage } from "../utils/response";
import { sendData, sendSuccessMessage } from "../utils/response";
/**
* Cancel a download
@@ -13,7 +13,7 @@ export const cancelDownload = async (
): Promise<void> => {
const { id } = req.params;
downloadManager.cancelDownload(id);
res.status(200).json(successMessage("Download cancelled"));
sendSuccessMessage(res, "Download cancelled");
};
/**
@@ -26,7 +26,7 @@ export const removeFromQueue = async (
): Promise<void> => {
const { id } = req.params;
downloadManager.removeFromQueue(id);
res.status(200).json(successMessage("Removed from queue"));
sendSuccessMessage(res, "Removed from queue");
};
/**
@@ -38,7 +38,7 @@ export const clearQueue = async (
res: Response
): Promise<void> => {
downloadManager.clearQueue();
res.status(200).json(successMessage("Queue cleared"));
sendSuccessMessage(res, "Queue cleared");
};
/**
@@ -52,7 +52,7 @@ export const getDownloadHistory = async (
): Promise<void> => {
const history = storageService.getDownloadHistory();
// Return array directly for backward compatibility (frontend expects response.data to be DownloadHistoryItem[])
res.status(200).json(history);
sendData(res, history);
};
/**
@@ -65,7 +65,7 @@ export const removeDownloadHistory = async (
): Promise<void> => {
const { id } = req.params;
storageService.removeDownloadHistoryItem(id);
res.status(200).json(successMessage("Removed from history"));
sendSuccessMessage(res, "Removed from history");
};
/**
@@ -77,5 +77,5 @@ export const clearDownloadHistory = async (
res: Response
): Promise<void> => {
storageService.clearDownloadHistory();
res.status(200).json(successMessage("History cleared"));
sendSuccessMessage(res, "History cleared");
};

View File

@@ -7,11 +7,11 @@ import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
import { getVideoDuration } from "../services/metadataService";
import * as storageService from "../services/storageService";
import { logger } from "../utils/logger";
import { successResponse } from "../utils/response";
import { sendData, sendSuccess, successResponse } from "../utils/response";
import {
execFileSafe,
validateImagePath,
validateVideoPath,
execFileSafe,
validateImagePath,
validateVideoPath,
} from "../utils/security";
// Configure Multer for file uploads
@@ -45,7 +45,7 @@ export const getVideos = async (
): Promise<void> => {
const videos = storageService.getVideos();
// Return array directly for backward compatibility (frontend expects response.data to be Video[])
res.status(200).json(videos);
sendData(res, videos);
};
/**
@@ -65,7 +65,7 @@ export const getVideoById = async (
}
// Return video object directly for backward compatibility (frontend expects response.data to be Video)
res.status(200).json(video);
sendData(res, video);
};
/**
@@ -83,7 +83,7 @@ export const deleteVideo = async (
throw new NotFoundError("Video", id);
}
res.status(200).json(successResponse(null, "Video deleted successfully"));
sendSuccess(res, null, "Video deleted successfully");
};
/**
@@ -100,7 +100,7 @@ export const getVideoComments = async (
m.getComments(id)
);
// Return comments array directly for backward compatibility (frontend expects response.data to be Comment[])
res.status(200).json(comments);
sendData(res, comments);
};
/**
@@ -217,7 +217,7 @@ export const updateVideoDetails = async (
}
// Return format expected by frontend: { success: true, video: ... }
res.status(200).json({
sendData(res, {
success: true,
video: updatedVideo,
});
@@ -268,7 +268,7 @@ export const getAuthorChannelUrl = async (
if (existingVideo) {
storageService.updateVideo(existingVideo.id, { channelUrl });
}
res.status(200).json({ success: true, channelUrl });
sendData(res, { success: true, channelUrl });
return;
}
}
@@ -278,9 +278,7 @@ export const getAuthorChannelUrl = async (
// If we have the video in database, try to get channelUrl from there first
// (already checked above, but this is for clarity)
if (existingVideo && existingVideo.channelUrl) {
res
.status(200)
.json({ success: true, channelUrl: existingVideo.channelUrl });
sendData(res, { success: true, channelUrl: existingVideo.channelUrl });
return;
}
@@ -322,7 +320,7 @@ export const getAuthorChannelUrl = async (
});
}
res.status(200).json({ success: true, channelUrl: spaceUrl });
sendData(res, { success: true, channelUrl: spaceUrl });
return;
}
} catch (error) {
@@ -332,9 +330,9 @@ export const getAuthorChannelUrl = async (
}
// If we couldn't get the channel URL, return null
res.status(200).json({ success: true, channelUrl: null });
sendData(res, { success: true, channelUrl: null });
} catch (error) {
logger.error("Error getting author channel URL:", error);
res.status(200).json({ success: true, channelUrl: null });
sendData(res, { success: true, channelUrl: null });
}
};

View File

@@ -1,19 +1,19 @@
import { Request, Response } from "express";
import { ValidationError } from "../errors/DownloadErrors";
import { DownloadResult } from "../services/downloaders/bilibili/types";
import downloadManager from "../services/downloadManager";
import * as downloadService from "../services/downloadService";
import * as storageService from "../services/storageService";
import { DownloadResult } from "../services/downloaders/bilibili/types";
import {
extractBilibiliVideoId,
extractSourceVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl,
extractBilibiliVideoId,
isBilibiliUrl,
isValidUrl,
processVideoUrl,
resolveShortUrl,
trimBilibiliUrl
} from "../utils/helpers";
import { logger } from "../utils/logger";
import { sendBadRequest, sendData, sendInternalError } from "../utils/response";
/**
* Search for videos
@@ -39,7 +39,7 @@ export const searchVideos = async (
offset
);
// Return { results } format for backward compatibility (frontend expects response.data.results)
res.status(200).json({ results });
sendData(res, { results });
};
/**
@@ -56,19 +56,12 @@ export const checkVideoDownloadStatus = async (
throw new ValidationError("URL is required", "url");
}
let videoUrl = extractUrlFromText(url);
// Resolve shortened URLs
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
}
// Extract source video ID
const { id: sourceVideoId, platform } = extractSourceVideoId(videoUrl);
// Process URL: extract from text, resolve shortened URLs, extract source video ID
const { sourceVideoId } = await processVideoUrl(url);
if (!sourceVideoId) {
// Return object directly for backward compatibility (frontend expects response.data.found)
res.status(200).json({ found: false });
sendData(res, { found: false });
return;
}
@@ -77,39 +70,41 @@ export const checkVideoDownloadStatus = async (
storageService.checkVideoDownloadBySourceId(sourceVideoId);
if (downloadCheck.found) {
// If status is "exists", verify the video still exists in the database
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
const existingVideo = storageService.getVideoById(downloadCheck.videoId);
if (!existingVideo) {
// Video was deleted but not marked in download history, update it
storageService.markVideoDownloadDeleted(downloadCheck.videoId);
// Return object directly for backward compatibility
res.status(200).json({
found: true,
status: "deleted",
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
});
return;
}
// Verify video exists if status is "exists"
const verification = storageService.verifyVideoExists(
downloadCheck,
storageService.getVideoById
);
// Return object directly for backward compatibility
res.status(200).json({
if (verification.updatedCheck) {
// Video was deleted but not marked, return deleted status
sendData(res, {
found: true,
status: "deleted",
title: verification.updatedCheck.title,
author: verification.updatedCheck.author,
downloadedAt: verification.updatedCheck.downloadedAt,
});
return;
}
if (verification.exists && verification.video) {
// Video exists, return exists status
sendData(res, {
found: true,
status: "exists",
videoId: downloadCheck.videoId,
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
title: downloadCheck.title || verification.video.title,
author: downloadCheck.author || verification.video.author,
downloadedAt: downloadCheck.downloadedAt,
videoPath: existingVideo.videoPath,
thumbnailPath: existingVideo.thumbnailPath,
videoPath: verification.video.videoPath,
thumbnailPath: verification.video.thumbnailPath,
});
return;
}
// Return object directly for backward compatibility
res.status(200).json({
sendData(res, {
found: true,
status: downloadCheck.status,
title: downloadCheck.title,
@@ -121,7 +116,7 @@ export const checkVideoDownloadStatus = async (
}
// Return object directly for backward compatibility
res.status(200).json({ found: false });
sendData(res, { found: false });
};
/**
@@ -144,123 +139,86 @@ export const downloadVideo = async (
let videoUrl = youtubeUrl;
if (!videoUrl) {
return res.status(400).json({ error: "Video URL is required" });
return sendBadRequest(res, "Video URL is required");
}
logger.info("Processing download request for input:", videoUrl);
// Extract URL if the input contains text with a URL
videoUrl = extractUrlFromText(videoUrl);
logger.info("Extracted URL:", videoUrl);
// Process URL: extract from text, resolve shortened URLs, extract source video ID
const { videoUrl: processedUrl, sourceVideoId, platform } = await processVideoUrl(videoUrl);
logger.info("Processed URL:", processedUrl);
// Check if the input is a valid URL
if (!isValidUrl(videoUrl)) {
if (!isValidUrl(processedUrl)) {
// If not a valid URL, treat it as a search term
return res.status(400).json({
error: "Not a valid URL",
isSearchTerm: true,
searchTerm: videoUrl,
});
return sendBadRequest(res, "Not a valid URL");
}
// Resolve shortened URLs first to get the real URL for checking
let resolvedUrl = videoUrl;
if (videoUrl.includes("b23.tv")) {
resolvedUrl = await resolveShortUrl(videoUrl);
logger.info("Resolved shortened URL to:", resolvedUrl);
}
// Extract source video ID for checking download history
const { id: sourceVideoId, platform } = extractSourceVideoId(resolvedUrl);
// Use processed URL as resolved URL
const resolvedUrl = processedUrl;
logger.info("Resolved URL to:", resolvedUrl);
// Check if video was previously downloaded (skip for collections/multi-part)
if (sourceVideoId && !downloadAllParts && !downloadCollection) {
const downloadCheck =
storageService.checkVideoDownloadBySourceId(sourceVideoId);
if (downloadCheck.found) {
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
// Verify the video still exists
const existingVideo = storageService.getVideoById(
downloadCheck.videoId
);
if (existingVideo) {
// Video exists, add to download history as "skipped" and return success
storageService.addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
sourceUrl: resolvedUrl,
finishedAt: Date.now(),
status: "skipped",
videoPath: existingVideo.videoPath,
thumbnailPath: existingVideo.thumbnailPath,
videoId: existingVideo.id,
});
// Use the consolidated handler to check download status
const checkResult = storageService.handleVideoDownloadCheck(
downloadCheck,
resolvedUrl,
storageService.getVideoById,
(item) => storageService.addDownloadHistoryItem(item),
forceDownload
);
return res.status(200).json({
success: true,
skipped: true,
videoId: downloadCheck.videoId,
title: downloadCheck.title || existingVideo.title,
author: downloadCheck.author || existingVideo.author,
videoPath: existingVideo.videoPath,
message: "Video already exists, skipped download",
});
}
// Video was deleted but not marked, update the record
storageService.markVideoDownloadDeleted(downloadCheck.videoId);
}
if (checkResult.shouldSkip && checkResult.response) {
// Video should be skipped, return response
return sendData(res, checkResult.response);
}
if (downloadCheck.status === "deleted" && !forceDownload) {
// Video was previously downloaded but deleted - add to history and skip
storageService.addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || "Unknown Title",
author: downloadCheck.author,
sourceUrl: resolvedUrl,
finishedAt: Date.now(),
status: "deleted",
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
});
// If status is "deleted" and not forcing download, handle separately
if (downloadCheck.found && downloadCheck.status === "deleted" && !forceDownload) {
// Video was previously downloaded but deleted - add to history and skip
storageService.addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || "Unknown Title",
author: downloadCheck.author,
sourceUrl: resolvedUrl,
finishedAt: Date.now(),
status: "deleted",
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
});
return res.status(200).json({
success: true,
skipped: true,
previouslyDeleted: true,
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
message:
"Video was previously downloaded but deleted, skipped download",
});
}
return sendData(res, {
success: true,
skipped: true,
previouslyDeleted: true,
title: downloadCheck.title,
author: downloadCheck.author,
downloadedAt: downloadCheck.downloadedAt,
deletedAt: downloadCheck.deletedAt,
message: "Video was previously downloaded but deleted, skipped download",
});
}
}
// Determine initial title for the download task
let initialTitle = "Video";
try {
// Resolve shortened URLs (like b23.tv) first to get correct info
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
logger.info("Resolved shortened URL to:", videoUrl);
}
// Try to fetch video info for all URLs
// Try to fetch video info for all URLs (using already processed URL)
logger.info("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
const info = await downloadService.getVideoInfo(resolvedUrl);
if (info && info.title) {
initialTitle = info.title;
logger.info("Fetched initial title:", initialTitle);
}
} catch (err) {
logger.warn("Failed to fetch video info for title, using default:", err);
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
if (resolvedUrl.includes("youtube.com") || resolvedUrl.includes("youtu.be")) {
initialTitle = "YouTube Video";
} else if (isBilibiliUrl(videoUrl)) {
} else if (isBilibiliUrl(resolvedUrl)) {
initialTitle = "Bilibili Video";
}
}
@@ -272,10 +230,13 @@ export const downloadVideo = async (
const downloadTask = async (
registerCancel: (cancel: () => void) => void
) => {
// Use resolved URL for download (already processed)
let downloadUrl = resolvedUrl;
// Trim Bilibili URL if needed
if (isBilibiliUrl(videoUrl)) {
videoUrl = trimBilibiliUrl(videoUrl);
logger.info("Using trimmed Bilibili URL:", videoUrl);
if (isBilibiliUrl(downloadUrl)) {
downloadUrl = trimBilibiliUrl(downloadUrl);
logger.info("Using trimmed Bilibili URL:", downloadUrl);
// If downloadCollection is true, handle collection/series download
if (downloadCollection && collectionInfo) {
@@ -303,7 +264,7 @@ export const downloadVideo = async (
// If downloadAllParts is true, handle multi-part download
if (downloadAllParts) {
const videoId = extractBilibiliVideoId(videoUrl);
const videoId = extractBilibiliVideoId(downloadUrl);
if (!videoId) {
throw new Error("Could not extract Bilibili video ID");
}
@@ -326,7 +287,7 @@ export const downloadVideo = async (
);
// Start downloading the first part
const baseUrl = videoUrl.split("?")[0];
const baseUrl = downloadUrl.split("?")[0];
const firstPartUrl = `${baseUrl}?p=1`;
// Check if part 1 already exists
@@ -448,7 +409,7 @@ export const downloadVideo = async (
logger.info("Downloading single Bilibili video part");
const result = await downloadService.downloadSingleBilibiliPart(
videoUrl,
downloadUrl,
1,
1,
"", // seriesTitle not used when totalParts is 1
@@ -464,10 +425,10 @@ export const downloadVideo = async (
);
}
}
} else if (videoUrl.includes("missav") || videoUrl.includes("123av")) {
} else if (downloadUrl.includes("missav") || downloadUrl.includes("123av")) {
// MissAV/123av download
const videoData = await downloadService.downloadMissAVVideo(
videoUrl,
downloadUrl,
downloadId,
registerCancel
);
@@ -475,7 +436,7 @@ export const downloadVideo = async (
} else {
// YouTube download
const videoData = await downloadService.downloadYouTubeVideo(
videoUrl,
downloadUrl,
downloadId,
registerCancel
);
@@ -485,15 +446,15 @@ export const downloadVideo = async (
// Determine type
let type = "youtube";
if (videoUrl.includes("missav") || videoUrl.includes("123av")) {
if (resolvedUrl.includes("missav") || resolvedUrl.includes("123av")) {
type = "missav";
} else if (isBilibiliUrl(videoUrl)) {
} else if (isBilibiliUrl(resolvedUrl)) {
type = "bilibili";
}
// Add to download manager
downloadManager
.addDownload(downloadTask, downloadId, initialTitle, videoUrl, type)
.addDownload(downloadTask, downloadId, initialTitle, resolvedUrl, type)
.then((result: any) => {
logger.info("Download completed successfully:", result);
})
@@ -502,16 +463,14 @@ export const downloadVideo = async (
});
// Return success immediately indicating the download is queued/started
res.status(200).json({
sendData(res, {
success: true,
message: "Download queued",
downloadId,
});
} catch (error: any) {
logger.error("Error queuing download:", error);
res
.status(500)
.json({ error: "Failed to queue download", details: error.message });
sendInternalError(res, "Failed to queue download");
}
};
@@ -536,7 +495,7 @@ export const getDownloadStatus = async (
});
}
// Return status object directly for backward compatibility (frontend expects response.data to be DownloadStatus)
res.status(200).json(status);
sendData(res, status);
};
/**
@@ -577,7 +536,7 @@ export const checkBilibiliParts = async (
const result = await downloadService.checkBilibiliVideoParts(videoId);
// Return result object directly for backward compatibility (frontend expects response.data.success, response.data.videosNumber)
res.status(200).json(result);
sendData(res, result);
};
/**
@@ -619,7 +578,7 @@ export const checkBilibiliCollection = async (
const result = await downloadService.checkBilibiliCollectionOrSeries(videoId);
// Return result object directly for backward compatibility (frontend expects response.data.success, response.data.type)
res.status(200).json(result);
sendData(res, result);
};
/**
@@ -650,10 +609,10 @@ export const checkPlaylist = async (
try {
const result = await downloadService.checkPlaylist(playlistUrl);
res.status(200).json(result);
sendData(res, result);
} catch (error) {
logger.error("Error checking playlist:", error);
res.status(200).json({
sendData(res, {
success: false,
error: error instanceof Error ? error.message : "Failed to check playlist"
});

View File

@@ -31,6 +31,8 @@ export {
recordVideoDownload,
markVideoDownloadDeleted,
updateVideoDownloadRecord,
verifyVideoExists,
handleVideoDownloadCheck,
} from "./videoDownloadTracking";
// Settings

View File

@@ -3,7 +3,7 @@ import { DatabaseError } from "../../errors/DownloadErrors";
import { db } from "../../db";
import { videoDownloads } from "../../db/schema";
import { logger } from "../../utils/logger";
import { VideoDownloadCheckResult } from "./types";
import { VideoDownloadCheckResult, Video } from "./types";
/**
* Check if a video has been downloaded before by its source video ID
@@ -163,3 +163,165 @@ export function updateVideoDownloadRecord(
}
}
/**
* Verify if a video still exists in the database and update download record if needed
* This consolidates the common pattern of checking video existence and handling deleted videos
*
* @param downloadCheck - Result from checkVideoDownloadBySourceId or checkVideoDownloadByUrl
* @param getVideoById - Function to get video by ID from storage service
* @returns Object with verification result and updated check if video was deleted
*/
export function verifyVideoExists(
downloadCheck: VideoDownloadCheckResult,
getVideoById: (videoId: string) => Video | undefined
): {
exists: boolean;
video?: Video;
updatedCheck?: VideoDownloadCheckResult;
} {
// If not found, nothing to verify
if (!downloadCheck.found) {
return { exists: false };
}
// If status is "exists" and we have a videoId, verify it still exists
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
const existingVideo = getVideoById(downloadCheck.videoId);
if (!existingVideo) {
// Video was deleted but not marked in download history, update it
markVideoDownloadDeleted(downloadCheck.videoId);
// Return updated check result
return {
exists: false,
updatedCheck: {
...downloadCheck,
status: "deleted",
videoId: undefined,
deletedAt: Date.now(),
},
};
}
// Video exists
return {
exists: true,
video: existingVideo,
};
}
// Status is "deleted" or no videoId
return {
exists: false,
};
}
/**
* Handle video download check result and determine appropriate action
* This consolidates the logic for handling download checks in controllers
*
* @param downloadCheck - Result from checkVideoDownloadBySourceId
* @param sourceUrl - Source URL of the video
* @param getVideoById - Function to get video by ID from storage service
* @param addDownloadHistoryItem - Function to add item to download history
* @param forceDownload - Whether to force download even if video was deleted
* @returns Object indicating whether to skip download and response data if applicable
*/
export function handleVideoDownloadCheck(
downloadCheck: VideoDownloadCheckResult,
sourceUrl: string,
getVideoById: (videoId: string) => Video | undefined,
addDownloadHistoryItem: (item: {
id: string;
title: string;
author?: string;
sourceUrl: string;
finishedAt: number;
status: "success" | "failed" | "skipped" | "deleted";
videoPath?: string;
thumbnailPath?: string;
videoId?: string;
}) => void,
forceDownload: boolean = false
): {
shouldSkip: boolean;
shouldForce: boolean;
response?: {
success: boolean;
skipped?: boolean;
videoId?: string;
title?: string;
author?: string;
videoPath?: string;
thumbnailPath?: string;
message?: string;
};
} {
// If not found, proceed with download
if (!downloadCheck.found) {
return { shouldSkip: false, shouldForce: false };
}
// Verify video exists if status is "exists"
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
const verification = verifyVideoExists(downloadCheck, getVideoById);
if (verification.exists && verification.video) {
// Video exists, add to download history as "skipped" and return success
addDownloadHistoryItem({
id: Date.now().toString(),
title: downloadCheck.title || verification.video.title,
author: downloadCheck.author || verification.video.author,
sourceUrl,
finishedAt: Date.now(),
status: "skipped",
videoPath: verification.video.videoPath,
thumbnailPath: verification.video.thumbnailPath,
videoId: verification.video.id,
});
return {
shouldSkip: true,
shouldForce: false,
response: {
success: true,
skipped: true,
videoId: downloadCheck.videoId,
title: downloadCheck.title || verification.video.title,
author: downloadCheck.author || verification.video.author,
videoPath: verification.video.videoPath,
thumbnailPath: verification.video.thumbnailPath,
message: "Video already exists, skipped download",
},
};
}
// Video was deleted but not marked, update the record
if (verification.updatedCheck) {
// Record was updated, continue with download check
}
}
// If status is "deleted" and not forcing download, skip
if (downloadCheck.status === "deleted" && !forceDownload) {
return {
shouldSkip: true,
shouldForce: false,
response: {
success: true,
skipped: true,
message: "Video was previously downloaded but deleted. Use force download to re-download.",
},
};
}
// If forcing download or status is "deleted" with forceDownload=true, proceed
if (downloadCheck.status === "deleted" && forceDownload) {
return { shouldSkip: false, shouldForce: true };
}
// Default: proceed with download
return { shouldSkip: false, shouldForce: false };
}

View File

@@ -200,6 +200,34 @@ export function extractSourceVideoId(url: string): {
return { id: url, platform: "other" };
}
/**
* Process video URL: extract from text, resolve shortened URLs, and extract source video ID
* This consolidates the common pattern used across multiple controllers
*
* @param input - URL string that may contain text with a URL
* @returns Object containing processed videoUrl, sourceVideoId, and platform
*/
export async function processVideoUrl(
input: string
): Promise<{
videoUrl: string;
sourceVideoId: string | null;
platform: string;
}> {
// Extract URL from text that might contain a title and URL
let videoUrl = extractUrlFromText(input);
// Resolve shortened URLs (like b23.tv)
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
}
// Extract source video ID and platform
const { id: sourceVideoId, platform } = extractSourceVideoId(videoUrl);
return { videoUrl, sourceVideoId, platform };
}
// Helper function to create a safe filename that preserves non-Latin characters
export function sanitizeFilename(filename: string): string {
// Remove hashtags (e.g. #tag)

View File

@@ -3,6 +3,8 @@
* Provides consistent response formats across all controllers
*/
import { Response } from "express";
export interface ApiResponse<T> {
success: boolean;
data?: T;
@@ -47,3 +49,88 @@ export function successMessage(message: string): ApiResponse<null> {
message,
};
}
/**
* Send a successful response (200 OK)
* @param res - Express response object
* @param data - The data to return
* @param message - Optional success message
*/
export function sendSuccess<T>(
res: Response,
data: T,
message?: string
): void {
res.status(200).json(successResponse(data, message));
}
/**
* Send a successful response with just a message (200 OK)
* @param res - Express response object
* @param message - Success message
*/
export function sendSuccessMessage(res: Response, message: string): void {
res.status(200).json(successMessage(message));
}
/**
* Send data directly (for backward compatibility - returns data directly, not wrapped)
* @param res - Express response object
* @param data - The data to return directly
*/
export function sendData<T>(res: Response, data: T): void {
res.status(200).json(data);
}
/**
* Send an error response (400 Bad Request)
* @param res - Express response object
* @param error - Error message
*/
export function sendBadRequest(res: Response, error: string): void {
res.status(400).json(errorResponse(error));
}
/**
* Send a not found response (404 Not Found)
* @param res - Express response object
* @param error - Error message (default: "Resource not found")
*/
export function sendNotFound(res: Response, error: string = "Resource not found"): void {
res.status(404).json(errorResponse(error));
}
/**
* Send a conflict response (409 Conflict)
* @param res - Express response object
* @param error - Error message
*/
export function sendConflict(res: Response, error: string): void {
res.status(409).json(errorResponse(error));
}
/**
* Send an internal server error response (500 Internal Server Error)
* @param res - Express response object
* @param error - Error message (default: "Internal server error")
*/
export function sendInternalError(
res: Response,
error: string = "Internal server error"
): void {
res.status(500).json(errorResponse(error));
}
/**
* Send a custom status code response
* @param res - Express response object
* @param statusCode - HTTP status code
* @param data - The data to return
*/
export function sendStatus<T>(
res: Response,
statusCode: number,
data: T
): void {
res.status(statusCode).json(data);
}

View File

@@ -80,43 +80,13 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const videoContainerRef = useRef<HTMLDivElement>(null);
const [subtitleMenuAnchor, setSubtitleMenuAnchor] = useState<null | HTMLElement>(null);
const wasPlayingBeforeHidden = useRef<boolean>(false);
const videoSrcRef = useRef<string>('');
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [loadError, setLoadError] = useState<string | null>(null);
// Handle Page Visibility API for mobile browsers
useEffect(() => {
const handleVisibilityChange = () => {
const videoElement = videoRef.current;
if (!videoElement) return;
if (document.hidden) {
// Page is hidden (user switched apps)
wasPlayingBeforeHidden.current = !videoElement.paused;
if (wasPlayingBeforeHidden.current) {
videoElement.pause();
}
} else {
// Page is visible again
// Wait a bit for the page to fully restore before resuming
setTimeout(() => {
if (wasPlayingBeforeHidden.current && videoElement && !document.hidden) {
videoElement.play().catch(err => {
console.error('Error resuming playback:', err);
setIsPlaying(false);
});
}
}, 100);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// Memory management: Clean up video source when component unmounts or src changes
useEffect(() => {
@@ -141,7 +111,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
if (src) {
setIsLoading(true);
setLoadError(null);
// Clear any existing timeout
if (loadTimeoutRef.current) {
clearTimeout(loadTimeoutRef.current);
@@ -159,7 +129,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
// Use preload="metadata" for large files to reduce initial memory usage
videoElement.preload = 'metadata';
videoElement.src = src;
// For mobile browsers, try to load the video
const handleCanPlay = () => {
setIsLoading(false);
@@ -282,11 +252,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
if (isFullscreen) {
// Show controls first
setControlsVisible(true);
// After 5 seconds, hide completely
hideControlsTimerRef.current = setTimeout(() => {
setControlsVisible(false);
@@ -315,12 +285,12 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const handleMouseMove = () => {
setControlsVisible(true);
// Reset timer on mouse move
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
// Hide again after 5 seconds of no movement
hideControlsTimerRef.current = setTimeout(() => {
setControlsVisible(false);
@@ -485,7 +455,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
@@ -584,7 +554,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
return (
<Box
<Box
ref={videoContainerRef}
sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}
>
@@ -770,17 +740,17 @@ const VideoControls: React.FC<VideoControlsProps> = ({
</video>
{/* Custom Controls Area */}
<Box
<Box
sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
opacity: isFullscreen
? (controlsVisible ? 0.3 : 0)
opacity: isFullscreen
? (controlsVisible ? 0.3 : 0)
: 1,
visibility: isFullscreen && !controlsVisible ? 'hidden' : 'visible',
transition: 'opacity 0.3s, visibility 0.3s, background-color 0.3s',
pointerEvents: isFullscreen && !controlsVisible ? 'none' : 'auto',
'&:hover': {
'&:hover': {
opacity: isFullscreen && controlsVisible ? 1 : (isFullscreen ? 0 : 1)
}
}}
@@ -802,12 +772,12 @@ const VideoControls: React.FC<VideoControlsProps> = ({
{/* Left Side: Volume and Play */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mr: { xs: 0.5, sm: 1 } }}>
{/* Volume Control (Hidden on mobile/tablet, shown on desktop) */}
<Box
ref={volumeSliderRef}
sx={{
position: 'relative',
display: { xs: 'none', md: 'flex' },
alignItems: 'center'
<Box
ref={volumeSliderRef}
sx={{
position: 'relative',
display: { xs: 'none', md: 'flex' },
alignItems: 'center'
}}
onMouseEnter={() => {
if (volumeSliderHideTimerRef.current) {
@@ -895,7 +865,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
</IconButton>
</Tooltip>
</Stack>
<Typography variant="caption" sx={{ minWidth: { xs: '35px', sm: '45px' }, textAlign: 'right', fontSize: '0.75rem' }}>
{formatTime(currentTime)}
</Typography>
@@ -1020,85 +990,85 @@ const VideoControls: React.FC<VideoControlsProps> = ({
alignItems="center"
sx={{ width: '100%', flexWrap: 'wrap' }}
>
<Tooltip title="-10m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-600)}
<Tooltip title="-10m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-600)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowLeft />
</IconButton>
</Tooltip>
<Tooltip title="-1m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastRewind />
</IconButton>
</Tooltip>
<Tooltip title="-10s" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Replay10 />
</IconButton>
</Tooltip>
<Tooltip title="+10s" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Forward10 />
</IconButton>
</Tooltip>
<Tooltip title="+1m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastForward />
</IconButton>
</Tooltip>
<Tooltip title="+10m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(600)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowRight />
</IconButton>
</Tooltip>
{/* Mobile: Fullscreen, Loop */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ display: { xs: 'flex', sm: 'none' }, ml: 1 }}>
{/* Fullscreen */}
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')} disableHoverListener={isTouch}>
<IconButton
onClick={handleToggleFullscreen}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowLeft />
</IconButton>
</Tooltip>
<Tooltip title="-1m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastRewind />
</IconButton>
</Tooltip>
<Tooltip title="-10s" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(-10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Replay10 />
</IconButton>
</Tooltip>
<Tooltip title="+10s" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Forward10 />
</IconButton>
</Tooltip>
<Tooltip title="+1m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastForward />
</IconButton>
</Tooltip>
<Tooltip title="+10m" disableHoverListener={isTouch}>
<IconButton
onClick={() => handleSeek(600)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowRight />
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* Mobile: Fullscreen, Loop */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ display: { xs: 'flex', sm: 'none' }, ml: 1 }}>
{/* Fullscreen */}
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')} disableHoverListener={isTouch}>
<IconButton
onClick={handleToggleFullscreen}
size="small"
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* Loop */}
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`} disableHoverListener={isTouch}>
<IconButton
color={isLooping ? "primary" : "default"}
onClick={handleToggleLoop}
size="small"
>
<Loop />
</IconButton>
</Tooltip>
</Stack>
{/* Loop */}
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`} disableHoverListener={isTouch}>
<IconButton
color={isLooping ? "primary" : "default"}
onClick={handleToggleLoop}
size="small"
>
<Loop />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Box>
</Box>
);

View File

@@ -0,0 +1,193 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* Centralized API client for all backend API calls
* Provides consistent error handling, request/response interceptors, and type safety
*/
// Get API URL from environment variable
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5551/api';
// Create axios instance with default configuration
const apiClient: AxiosInstance = axios.create({
baseURL: API_URL,
timeout: 30000, // 30 seconds default timeout
headers: {
'Content-Type': 'application/json',
},
});
/**
* Request interceptor - can be used for adding auth tokens, logging, etc.
*/
apiClient.interceptors.request.use(
(config) => {
// Add any request modifications here (e.g., auth tokens)
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* Response interceptor - handles common error patterns
*/
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError) => {
// Handle common error cases
if (error.response) {
// Server responded with error status
const status = error.response.status;
const data = error.response.data as any;
// Handle specific error cases
if (status === 401) {
// Unauthorized - could trigger logout or redirect
console.error('Unauthorized request:', error.config?.url);
} else if (status === 403) {
// Forbidden
console.error('Forbidden request:', error.config?.url);
} else if (status === 404) {
// Not found
console.error('Resource not found:', error.config?.url);
} else if (status === 429) {
// Too many requests
console.error('Rate limited:', error.config?.url);
} else if (status >= 500) {
// Server error
console.error('Server error:', error.config?.url, data);
}
} else if (error.request) {
// Request was made but no response received
console.error('Network error - no response received:', error.config?.url);
} else {
// Something else happened
console.error('Request setup error:', error.message);
}
return Promise.reject(error);
}
);
/**
* Type-safe API response wrapper
*/
export interface ApiResponse<T = any> {
success?: boolean;
data?: T;
error?: string;
message?: string;
[key: string]: any; // Allow additional properties for backward compatibility
}
/**
* Extract error message from axios error
*/
export function getErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse>;
if (axiosError.response?.data?.error) {
return axiosError.response.data.error;
}
if (axiosError.response?.data?.message) {
return axiosError.response.data.message;
}
if (axiosError.message) {
return axiosError.message;
}
}
if (error instanceof Error) {
return error.message;
}
return 'An unknown error occurred';
}
/**
* Extract wait time from rate limit error (429 or 401 with waitTime)
*/
export function getWaitTime(error: unknown): number {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ waitTime?: number }>;
if (axiosError.response?.data?.waitTime) {
return axiosError.response.data.waitTime;
}
}
return 0;
}
/**
* Check if error is a rate limit error (429)
*/
export function isRateLimitError(error: unknown): boolean {
if (axios.isAxiosError(error)) {
return error.response?.status === 429;
}
return false;
}
/**
* Check if error is an authentication error (401)
*/
export function isAuthError(error: unknown): boolean {
if (axios.isAxiosError(error)) {
return error.response?.status === 401;
}
return false;
}
/**
* API client methods - type-safe wrappers around axios
*/
export const api = {
/**
* GET request
*/
get: <T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return apiClient.get<T>(url, config);
},
/**
* POST request
*/
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return apiClient.post<T>(url, data, config);
},
/**
* PUT request
*/
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return apiClient.put<T>(url, data, config);
},
/**
* PATCH request
*/
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return apiClient.patch<T>(url, data, config);
},
/**
* DELETE request
*/
delete: <T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return apiClient.delete<T>(url, config);
},
};
/**
* Export the axios instance for advanced use cases
*/
export { apiClient };
/**
* Export API_URL for cases where it's needed directly
*/
export { API_URL };
export default api;

View File

@@ -0,0 +1,32 @@
/**
* Centralized React Query configuration constants
* Provides consistent query behavior across all contexts
*/
export const defaultQueryConfig = {
retry: 3,
retryDelay: 1000,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
};
/**
* Configuration for frequently updated data (e.g., download status)
*/
export const frequentQueryConfig = {
retry: 3,
retryDelay: 1000,
staleTime: 1000, // 1 second
gcTime: 5 * 60 * 1000, // 5 minutes
};
/**
* Configuration for rarely changing data (e.g., settings)
*/
export const stableQueryConfig = {
retry: 3,
retryDelay: 1000,
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
};