From c9bb529b47d4d9ed993b4eb0e69f6adb7b327212 Mon Sep 17 00:00:00 2001 From: MasterAcnolo Date: Wed, 28 Oct 2026 21:56:11 +0100 Subject: [PATCH] refactor(info): responsabilities is now better managed between controller and services. Error is handled better. Normalize info --- server/controller/info.controller.js | 132 ++++------------------- server/services/info.services.js | 153 +++++++++++++++++++-------- 2 files changed, 131 insertions(+), 154 deletions(-) diff --git a/server/controller/info.controller.js b/server/controller/info.controller.js index ebe2a26..b5c120a 100644 --- a/server/controller/info.controller.js +++ b/server/controller/info.controller.js @@ -1,130 +1,42 @@ -const { fetchInfo } = require("../services/info.services"); -const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers"); +const {fetchNormalizedInfo} = require("../services/info.services"); const { logger } = require("../logger"); const { isValidUrl } = require("../helpers/validation.helpers"); -const {isUrlPlaylist} = require("../helpers/playlist.helpers"); /** * Handles metadata retrieval for a video or playlist. * - * This controller: - * - Validates incoming URL (query or body) - * - 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) + * Returns normalized JSON responses. Accepts URL via query (?url=...) + * or request body for flexibility. * * @param {import("express").Request} req * @param {import("express").Response} 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)) { - 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. ENCODED: ${encodedUrl}`); - - // Lightweight heuristic to detect playlist URLs. It works for Youtube. - const isPlaylistUrl = isUrlPlaylist(url); - - 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) { - - /** - * 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( - `Info controller error: ${err && err.message ? err.message : err}` - ); - - return res.status(500).send("Unable to fetch info"); + if (encodedUrl) { + logger.info(`Info request received. ENCODED: ${encodedUrl}`); } + + const result = await fetchNormalizedInfo(url); + return res.json(result); + } catch (err) { + logger.error( + `Info controller error: ${err && err.message ? err.message : err}` + ); + return res.status(500).json({ + error: {code: "FETCH_FAILED", message: "Unable to fetch info"}, + }); + } } module.exports = { infoController }; \ No newline at end of file diff --git a/server/services/info.services.js b/server/services/info.services.js index 7644b62..a61edd0 100644 --- a/server/services/info.services.js +++ b/server/services/info.services.js @@ -2,6 +2,8 @@ const { execFile } = require("child_process"); const { userYtDlp, denoPath } = require("../helpers/path.helpers"); const {getUserBrowser} = require("../helpers/getBrowser.helpers"); 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. @@ -21,54 +23,117 @@ const { logger } = require("../logger"); * */ function fetchInfo(url) { + /** + * Base yt-dlp arguments used to retrieve metadata + * without downloading media files. + */ + const metadataArgs = [ + "--js-runtimes", + `deno:${denoPath}`, + "--dump-single-json", + "--ignore-errors", + "--yes-playlist", + "--cookies-from-browser", + `${getUserBrowser()}`, + "--extractor-args", + "youtube:player_client=default", + "--ignore-no-formats-error", + ]; - /** - * Base yt-dlp arguments used to retrieve metadata - * without downloading media files. - */ - const metadataArgs = [ - "--js-runtimes", `deno:${denoPath}`, - "--dump-single-json", - "--ignore-errors", - "--yes-playlist", - "--cookies-from-browser",`${getUserBrowser()}`, - "--extractor-args","youtube:player_client=default", - "--ignore-no-formats-error", - ]; - - return new Promise((resolve, reject) => { - - execFile(userYtDlp,[...metadataArgs , url], - { 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) => { - - if (stderr) { - const lower = stderr.toLowerCase(); - if (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.`)); - } + return new Promise((resolve, reject) => { + execFile( + userYtDlp, + [...metadataArgs, url], + { + 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) => { + if (stderr) { + const lower = stderr?.toLowerCase(); + if ( + 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.` + ) + ); } + } - if (error) { - logger.error(`YT-DLP Error: ${error.message}`); - if (stderr) logger.debug(`stderr: ${stderr}`); - return reject(new Error("Unable to fetch Info.")); - } + if (error) { + logger.error(`YT-DLP Error: ${error.message}`); + if (stderr) logger.debug(`stderr: ${stderr}`); + return reject(new Error("Unable to fetch Info.")); + } - if (!stdout) return reject(new Error("No Data Received.")); + if (!stdout) return reject(new Error("No Data Received.")); - try { - const data = JSON.parse(stdout); - - resolve(data); - } catch (e) { - logger.error(`JSON Parsing Error: ${e.message}`); - reject(new Error("JSON unreadable.")); - } - }); - }); + try { + const data = JSON.parse(stdout); + resolve(data); + } catch (e) { + logger.error(`JSON Parsing Error: ${e.message}`); + 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} 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};