refactor(info): responsabilities is now better managed between controller and services.

Error is handled better. Normalize info
This commit is contained in:
MasterAcnolo
2026-10-28 21:56:11 +01:00
parent 8c3e1135e5
commit c9bb529b47
2 changed files with 131 additions and 154 deletions

View File

@@ -1,129 +1,41 @@
const { fetchInfo } = require("../services/info.services"); const {fetchNormalizedInfo} = require("../services/info.services");
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation.helpers"); const { isValidUrl } = require("../helpers/validation.helpers");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Handles metadata retrieval for a video or playlist. * Handles metadata retrieval for a video or playlist.
* *
* This controller: * Returns normalized JSON responses. Accepts URL via query (?url=...)
* - Validates incoming URL (query or body) * or request body for flexibility.
* - Calls yt-dlp metadata extraction service
* - Detects playlist vs single video context
* - Applies fallback strategy if playlist extraction fails
* - Returns normalized parsed response (video or playlist)
* *
* @param {import("express").Request} req * @param {import("express").Request} req
* @param {import("express").Response} res * @param {import("express").Response} res
*/ */
async function infoController(req, res) { async function infoController(req, res) {
try {
const url = req.query.url || (req.body && req.body.url);
const encodedUrl = typeof url === "string" ? encodeURIComponent(url) : undefined;
// Previously, i support both POST and GET element, but it's kinda weird. So now it only support GET.
const url = req.query.url;
const encodedUrl = encodeURIComponent(url)
/**
* Basic validation:
* - URL must exist
* - Must be a string
* - Must pass custom URL validation
*/
if (!url || typeof url !== "string" || !isValidUrl(url)) { if (!url || typeof url !== "string" || !isValidUrl(url)) {
return res.status(400).send("Invalid URL Or Missing"); return res.status(400).json({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
} }
logger.info(`Info request received. RAW URL: ${url}`); logger.info(`Info request received. RAW URL: ${url}`);
if (encodedUrl) {
logger.info(`Info request received. ENCODED: ${encodedUrl}`); logger.info(`Info request received. ENCODED: ${encodedUrl}`);
}
// Lightweight heuristic to detect playlist URLs. It works for Youtube. const result = await fetchNormalizedInfo(url);
const isPlaylistUrl = isUrlPlaylist(url); return res.json(result);
logger.info(
isPlaylistUrl
? "Estimated Data Type: Playlist"
: "Estimated Data Type: Video"
);
try {
let data;
/**
* Primary metadata fetch attempt
*/
try {
data = await fetchInfo(url);
} catch (err) { } catch (err) {
/**
* Fallback strategy:
* If playlist extraction fails, retry as single video
*/
if (isPlaylistUrl) {
logger.info(
`Playlist fetch failed: ${err.message}. Retrying as single video...`
);
// Remove playlist query parameter (best-effort cleanup)
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
try {
data = await fetchInfo(singleVideoUrl);
logger.info("Single video fetch succeeded after playlist failure");
} catch (retryErr) {
logger.error(
`Single video fetch also failed: ${retryErr.message}`
);
// Preserve original error context
logger.error(err);
}
} else {
logger.error(err);
}
}
/**
* Normalize response based on yt-dlp output type
*/
if (data._type === "playlist") {
// If the playlist got only one video, show it as a video.
if (data.entries && data.entries.length === 1) {
const singleVideo = parseVideo(data.entries[0]);
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
return res.json(singleVideo);
}
const playlist = parsePlaylist(data);
logger.info(
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
);
return res.json(playlist);
} else {
const video = parseVideo(data);
logger.info(`Video fetched: ${video.title}`);
return res.json(video);
}
} catch (err) {
logger.error( logger.error(
`Info controller error: ${err && err.message ? err.message : err}` `Info controller error: ${err && err.message ? err.message : err}`
); );
return res.status(500).json({
return res.status(500).send("Unable to fetch info"); error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
} }
} }

View File

@@ -2,6 +2,8 @@ const { execFile } = require("child_process");
const { userYtDlp, denoPath } = require("../helpers/path.helpers"); const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const {getUserBrowser} = require("../helpers/getBrowser.helpers"); const {getUserBrowser} = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const {parseVideo, parsePlaylist} = require("../helpers/parseInfo.helpers");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Retrieves metadata for a video or playlist using yt-dlp. * Retrieves metadata for a video or playlist using yt-dlp.
@@ -21,33 +23,44 @@ const { logger } = require("../logger");
* *
*/ */
function fetchInfo(url) { function fetchInfo(url) {
/** /**
* Base yt-dlp arguments used to retrieve metadata * Base yt-dlp arguments used to retrieve metadata
* without downloading media files. * without downloading media files.
*/ */
const metadataArgs = [ const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`, "--js-runtimes",
`deno:${denoPath}`,
"--dump-single-json", "--dump-single-json",
"--ignore-errors", "--ignore-errors",
"--yes-playlist", "--yes-playlist",
"--cookies-from-browser",`${getUserBrowser()}`, "--cookies-from-browser",
"--extractor-args","youtube:player_client=default", `${getUserBrowser()}`,
"--extractor-args",
"youtube:player_client=default",
"--ignore-no-formats-error", "--ignore-no-formats-error",
]; ];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(
execFile(userYtDlp,[...metadataArgs , url], userYtDlp,
{ timeout: 30_000, // 30s, if more timeout [...metadataArgs, url],
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller) {
timeout: 30_000, // 30s, if more timeout
maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
},
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (stderr) { if (stderr) {
const lower = stderr.toLowerCase(); const lower = stderr?.toLowerCase();
if (lower.includes("sign in to confirm") || lower.includes("failed to decrypt") || lower.includes("could not copy")) { if (
return reject(new Error(`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`)); lower.includes("sign in to confirm") ||
lower.includes("failed to decrypt") ||
lower.includes("could not copy")
) {
return reject(
new Error(
`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`
)
);
} }
} }
@@ -61,14 +74,66 @@ function fetchInfo(url) {
try { try {
const data = JSON.parse(stdout); const data = JSON.parse(stdout);
resolve(data); resolve(data);
} catch (e) { } catch (e) {
logger.error(`JSON Parsing Error: ${e.message}`); logger.error(`JSON Parsing Error: ${e.message}`);
reject(new Error("JSON unreadable.")); reject(new Error("JSON unreadable."));
} }
}); }
);
}); });
} }
module.exports = { fetchInfo }; /**
* Fetches and normalizes metadata for a video or playlist.
* Handles playlist fallback to single video when playlist extraction fails
* or when playlist contains only one entry.
*
* @param {string} url - Video or playlist URL
* @returns {Promise<Object>} Normalized metadata object
*/
async function fetchNormalizedInfo(url) {
if (!url || typeof url !== "string") {
throw new Error("Invalid URL");
}
const isPlaylistUrl = isUrlPlaylist(url);
let data;
try {
data = await fetchInfo(url);
} catch (err) {
if (isPlaylistUrl) {
logger.info(
`Playlist fetch failed: ${err.message}. Retrying as single video...`
);
try {
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
data = await fetchInfo(singleVideoUrl);
logger.info("Single video fetch succeeded after playlist failure");
} catch (retryErr) {
logger.error(`Single video fetch also failed: ${retryErr.message}`);
throw err;
}
} else {
throw err;
}
}
if (data && data._type === "playlist") {
if (data.entries && data.entries.length === 1) {
const singleVideo = parseVideo(data.entries[0]);
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
return singleVideo;
}
const playlist = parsePlaylist(data);
logger.info(`Playlist fetched: ${playlist.title} (${playlist.count} videos)`);
return playlist;
}
const video = parseVideo(data);
logger.info(`Video fetched: ${video.title}`);
return video;
}
module.exports = {fetchInfo, fetchNormalizedInfo};