feat: Add centralized API client and query configuration
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ export {
|
||||
recordVideoDownload,
|
||||
markVideoDownloadDeleted,
|
||||
updateVideoDownloadRecord,
|
||||
verifyVideoExists,
|
||||
handleVideoDownloadCheck,
|
||||
} from "./videoDownloadTracking";
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
193
frontend/src/utils/apiClient.ts
Normal file
193
frontend/src/utils/apiClient.ts
Normal 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;
|
||||
|
||||
32
frontend/src/utils/queryConfig.ts
Normal file
32
frontend/src/utils/queryConfig.ts
Normal 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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user