From 31e80ab5e2322fb810ac8b78a3a5591f6bed23ae Mon Sep 17 00:00:00 2001 From: "Heliomar P. Marques" Date: Thu, 4 Jul 2024 19:41:20 -0300 Subject: [PATCH] refac: udemy services and more --- app/app.js | 1381 +++++++++++++--------------- app/core/services/index.js | 7 + app/core/services/m3u8.service.js | 168 ++++ app/core/services/udemy.service.js | 291 ++++++ app/helpers/ui.js | 4 +- app/helpers/utils.js | 6 + package-lock.json | 12 + package.json | 1 + 8 files changed, 1105 insertions(+), 765 deletions(-) create mode 100644 app/core/services/index.js create mode 100644 app/core/services/m3u8.service.js create mode 100644 app/core/services/udemy.service.js diff --git a/app/app.js b/app/app.js index f98ae1aa..5173fa75 100644 --- a/app/app.js +++ b/app/app.js @@ -14,14 +14,16 @@ const https = require("https"); const cookie = require("cookie"); const { Settings, ui, utils } = require('./helpers') +const { default: UdemyService } = require('./core/services'); -const pageSize = 25; -const msgDRMProtected = translate("Contains DRM protection and cannot be downloaded"); -const ajaxTimeout = 40000; // 40 segundos +const PAGE_SIZE = 25; +const MSG_DRM_PROTECTED = translate("Contains DRM protection and cannot be downloaded"); +const HTTP_TIMEOUT = 40000; // 40 segundos -let loggers = []; +const loggers = []; let headersAuth; let repoAccount = "heliomarpm"; +let udemyService; ipcRenderer.on("saveDownloads", () => saveDownloads(true)); @@ -118,7 +120,7 @@ $(".ui.dashboard .content").on("click", ".download-success, .course-encrypted", $(".ui.dashboard .content").on("click", ".download.button, .download-error", function (e) { e.stopImmediatePropagation(); - initializeDownload($(this).parents(".course")); + setupDownload($(this).parents(".course")); }); $(".ui.dashboard .content").on("click", "#clear_logger", clearLogArea); @@ -275,70 +277,46 @@ async function checkUpdate(account, silent = false) { if (!silent) { showAlert(translate("Failed to check for updates"), translate("Check for updates")); } - appendLog("Failed to check for updates", error.message, true); + appendLog("Failed to check for updates", error); } finally { ui.busyCheckUpdate(false); } } -function checkLogin() { +async function checkLogin() { if (Settings.accessToken) { - ui.busyLogin(true); - + // TODO: remover isso headersAuth = { Authorization: `Bearer ${Settings.accessToken}` }; - let url = "https://www.udemy.com/api-2.0/contexts/me/?header=True"; - - axios({ - timeout: ajaxTimeout, - method: "GET", - url, - headers: headersAuth, - }).then((response) => { - ui.busyLogin(false); - ui.busyLoadCourses(true); - ui.showDashboard(); - const resp = response.data; + try { + ui.busyLogin(true); - const subscriber = utils.toBoolean(resp.header.user.enableLabsInPersonalPlan); - Settings.subscriber = subscriber; + udemyService = new UdemyService(Settings.subDomain, HTTP_TIMEOUT); + const userContext = await udemyService.fetchProfile(Settings.accessToken, 30000); - url = !subscriber - ? `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscribed-courses?page_size=${pageSize}&page_size=30&ordering=-last_accessed` - : `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscription-course-enrollments?page_size=${pageSize}&page_size=30&ordering=-last_accessed`; + if (!userContext.header.isLoggedIn) { + ui.resetToLogin(); + return; + } + ui.busyLogin(false); + ui.showDashboard(); - axios({ - timeout: ajaxTimeout, - method: "GET", - url, - headers: headersAuth, - }).then((response) => { - renderCourses(response.data); - ui.busyLoadCourses(false); + Settings.subscriber = utils.toBoolean(userContext.header.user.enableLabsInPersonalPlan); + fetchCourses(Settings.subscriber); - if (Settings.downloadedCourses) { - renderDownloads(); - } + if (Settings.download.checkNewVersion) { + checkUpdate("heliomarpm", true); + } - if (Settings.download.checkNewVersion) { - checkUpdate(repoAccount, true); - } - }).catch((error) => { - ui.busyOff(); - console.error("beforeLogin_Error:", error); - showAlert(error.message, "Profile Error"); - }); - }).catch((error) => { + } catch (error) { + console.error("Failed to fetch user profile", error); if (!process.env.DEBUG_MODE) Settings.accessToken = null; - ui.busyOff(); - console.error("checkLogin_Error:", error); - showAlert(error.message, "Login Error"); - ui.resetToLogin(); - }).finally(() => { + showAlert(error.message, error.name || "Error"); + } finally { console.log("access-token", Settings.accessToken); - }); + } } } @@ -415,7 +393,7 @@ function loginWithAccessToken() { }); } -function htmlCourseCard(course, downloadSection = false) { +function createCourseElement(course, downloadSection = false) { if (!course.completed) { course.completed = false; } @@ -456,7 +434,7 @@ function htmlCourseCard(course, downloadSection = false) { ${downloadSection ? tagDismiss : ""} -
${course.encryptedVideos == 0 ? "" : msgDRMProtected}
+
${course.encryptedVideos == 0 ? "" : MSG_DRM_PROTECTED}
@@ -483,7 +461,7 @@ function htmlCourseCard(course, downloadSection = false) {
-

${msgDRMProtected}

+

${MSG_DRM_PROTECTED}

${translate("Click to dismiss")}

@@ -535,302 +513,260 @@ function htmlCourseCard(course, downloadSection = false) { return $course; } -function initializeDownload($course, subtitle) { - ui.prepareDownload($course); +function resetCourse($course, $elMessage, autoRetry, courseData, subtitle) { + if ($elMessage.hasClass("download-success")) { + $course.attr("course-completed", true); + } else { + $course.attr("course-completed", ""); - const courseId = $course.attr("course-id"); + if ($elMessage.hasClass("download-error")) { + if (autoRetry && courseData.errorCount++ < 5) { + debugger; + $course.length = 1; + startDownload($course, courseData, subtitle); + return; + } + } + } - const skipSubtitles = Boolean(Settings.download.skipSubtitles); - const defaultSubtitle = skipSubtitles ? null : subtitle ?? Settings.download.defaultSubtitle; - const downloadType = Number(Settings.download.type); + $course.find(".download-quality").hide(); + $course.find(".download-speed").hide().find(".value").html(0); + $course.find(".download-status").hide().html(ui.actionCardTemplate); + $course.css("padding", "14px 0px"); + $elMessage.css("display", "flex"); +} - const url = `https://${Settings.subDomain}.udemy.com/api-2.0/courses/${courseId}/cached-subscriber-curriculum-items?page_size=10000`; +function renderCourses(response, isResearch = false) { + console.log("renderCourse", { response, isResearch }); - console.clear(); - console.log("downloadButtonClick", url); + const $coursesSection = $(".ui.dashboard .ui.courses.section"); + const $coursesItems = $coursesSection.find(".ui.courses.items").empty(); - ui.busyPrepareDownload(true); - axios({ - timeout: ajaxTimeout, - type: "GET", - url, - headers: headersAuth, - }).then((response) => { - const resp = response.data; + $coursesSection.find(".disposable").remove(); - ui.enableDownloadButton($course, false); - ui.showProgress($course, true); + if (response.results.length) { + // response.results.forEach(course => { + // $coursesItems.append(htmlCourseCard(course)); + // }); + const courseElements = response.results.map(course => createCourseElement(course)); + $coursesItems.append(courseElements); - const courseData = []; - courseData["id"] = courseId; - courseData["chapters"] = []; - courseData["name"] = $course.find(".coursename").text(); - courseData["totalLectures"] = 0; - courseData["encryptedVideos"] = 0; - courseData["errorCount"] = 0; - - let chapterIndex = -1; - let lectureIndex = -1; - let remaining = resp.count; - let availableSubs = []; - - if (resp.results[0]._class == "lecture") { - chapterIndex++; - lectureIndex = 0; - courseData["chapters"][chapterIndex] = []; - courseData["chapters"][chapterIndex]["name"] = "Chapter 1"; - courseData["chapters"][chapterIndex]["lectures"] = []; - remaining--; + if (response.next) { + // added loadMore Button + $coursesSection.append( + `` + ); } - $.each(resp.results, function (i, v) { - if (v._class.toLowerCase() == "chapter") { - chapterIndex++; - lectureIndex = 0; - courseData["chapters"][chapterIndex] = []; - courseData["chapters"][chapterIndex]["name"] = v.title; - courseData["chapters"][chapterIndex]["lectures"] = []; - remaining--; - } else if ( - v._class.toLowerCase() == "lecture" && - (v.asset.asset_type.toLowerCase() == "video" || - v.asset.asset_type.toLowerCase() == "article" || - v.asset.asset_type.toLowerCase() == "file" || - v.asset.asset_type.toLowerCase() == "e-book") - ) { - if (v.asset.asset_type.toLowerCase() != "video" && downloadType == Settings.DownloadType.OnlyLectures) { - remaining--; - if (!remaining) { - if (Object.keys(availableSubs).length) { - askForSubtitle(availableSubs, initDownload, $course, courseData, defaultSubtitle); - } else { - initDownload($course, courseData); - } - } - return; - } + } else { + let msg = ""; + if (!isResearch) { + msg = getMsgChangeSearchMode(); + appendLog(translate("No Courses Found"), msg); + } - function getLecture(lectureName, chapterIndex, lectureIndex) { - const url = `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscribed-courses/${courseId}/lectures/${v.id}?fields[lecture]=asset,supplementary_assets&fields[asset]=stream_urls,download_urls,captions,title,filename,data,body,media_sources,media_license_token`; - // console.log("getLecture", url); - - axios({ - timeout: ajaxTimeout, - type: "GET", - url, - headers: headersAuth, - }).then((response) => { - const resp = response.data; - - var src = ""; - var videoQuality = ""; - var type = ""; - - if (v.asset.asset_type.toLowerCase() == "article") { - if (resp.asset.data) { - src = resp.asset.data.body; - } else { - src = resp.asset.body; - } - videoQuality = v.asset.asset_type; - type = "Article"; - } else if (v.asset.asset_type.toLowerCase() == "file" || v.asset.asset_type.toLowerCase() == "e-book") { - src = resp.asset.download_urls[v.asset.asset_type][0].file; - videoQuality = v.asset.asset_type; - type = "File"; - } else { - const qualities = []; - const qualitySrcMap = {}; - - const medias = resp.asset.stream_urls?.Video ?? resp.asset.media_sources; - medias.forEach(function (val) { - if (val.type != "application/dash+xml") { - if (val.label.toLowerCase() != "auto") { - qualities.push(val.label); - } - qualitySrcMap[val.label] = val.file ?? val.src; - } - }); - - const lowest = Math.min(...qualities); - const highest = Math.max(...qualities); - - videoQuality = (qualities.length == 0 ? "Auto" : Settings.download.videoQuality).toString(); - type = "Video"; - src = medias[0].src ?? medias[0].file; - - switch (videoQuality.toLowerCase()) { - case "auto": - videoQuality = medias[0].label; - break; - case "lowest": - src = qualitySrcMap[lowest]; - videoQuality = lowest; - break; - case "highest": - // has stream use it otherwise user highest quality - if (qualitySrcMap["Auto"]) { - src = qualitySrcMap["Auto"]; - } else { - src = qualitySrcMap[highest]; - videoQuality = highest; - } - break; - default: - videoQuality = videoQuality.slice(0, -1); - if (qualitySrcMap[videoQuality]) { - src = qualitySrcMap[videoQuality]; - } else { - videoQuality = medias[0].label; - } - } - } + $coursesItems.append( + `
+ ${translate("No Courses Found")}
+ ${translate("Remember, you will only be able to see the courses you are enrolled in")} + ${msg} +
` + ); + } - courseData["chapters"][chapterIndex]["lectures"][lectureIndex] = { - src: src, - name: lectureName, - quality: videoQuality, - type: type, - }; +} - if (!skipSubtitles && resp.asset.captions.length) { - courseData["chapters"][chapterIndex]["lectures"][lectureIndex].caption = []; +async function renderDownloads() { + console.log("renderDownloads"); - resp.asset.captions.forEach(function (caption) { - caption.video_label in availableSubs - ? (availableSubs[caption.video_label] = availableSubs[caption.video_label] + 1) - : (availableSubs[caption.video_label] = 1); + const $downloadsSection = $(".ui.downloads.section .ui.courses.items"); + if ($downloadsSection.find(".ui.course.item").length) { + return; + } - courseData["chapters"][chapterIndex]["lectures"][lectureIndex].caption[caption.video_label] = - caption.url; - }); - } + const downloadedCourses = Settings.downloadedCourses || []; + if (!downloadedCourses.length) { + // if ($downloadsSection.find(".ui.yellow.message").length) { + // return; + // } + // $downloadsSection.append( + // `
+ // ${translate("There are no Downloads to display")} + //
` + // ); + } + else { + ui.busyLoadDownloads(true); + // await utils.sleep(10); + // // downloadedCourses.forEach(course => { + // downloadedCourses.map(course => { + // const $courseItem = htmlCourseCard(course, true); + // $downloadsSection.append($courseItem); - if (resp.supplementary_assets.length && downloadType != Settings.DownloadType.OnlyLectures) { - courseData["chapters"][chapterIndex]["lectures"][lectureIndex]["supplementary_assets"] = []; - let supplementary_assets_remaining = resp.supplementary_assets.length; + // if (!course.completed && Settings.download.autoStartDownload) { + // initializeDownload($courseItem, course.selectedSubtitle); + // // $courseItem.find(".action.buttons").find(".pause.button").removeClass("disabled"); + // } + // }); + // ui.busyLoadDownloads(false); - $.each(resp.supplementary_assets, function (a, b) { - const url = `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscribed-courses/${courseId}/lectures/${v.id}/supplementary-assets/${b.id}?fields[asset]=download_urls,external_url,asset_type`; - // console.log("getLecture&Attachments", url); + function addCourseToDOM(course) { + return new Promise((resolve, _reject) => { + const $courseItem = createCourseElement(course, true); + $downloadsSection.append($courseItem); - axios({ - timeout: ajaxTimeout, - type: "GET", - url, - headers: headersAuth, - }).then((response) => { - const resp = response.data; - - console.log("carregando anexos"); - if (resp.download_urls) { - courseData["chapters"][chapterIndex]["lectures"][lectureIndex][ - "supplementary_assets" - ].push({ - src: resp.download_urls[resp.asset_type][0].file, - name: b.title, - quality: "Attachment", - type: "File", - }); - } else { - courseData["chapters"][chapterIndex]["lectures"][lectureIndex][ - "supplementary_assets" - ].push({ - src: ``, - name: b.title, - quality: "Attachment", - type: "Url", - }); - } - supplementary_assets_remaining--; - if (!supplementary_assets_remaining) { - remaining--; - courseData["totalLectures"] += 1; - - if (!remaining) { - console.log("download de video anexo", courseData); - if (Object.keys(availableSubs).length) { - askForSubtitle(availableSubs, initDownload, $course, courseData, defaultSubtitle); - } else { - initDownload($course, courseData); - } - } - } - }).catch((error) => { - const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); - appendLog(`getLectureAndAttachments_Error: (${statusCode})`, error.message); - resetCourse($course, $course.find(".download-error"), false, courseData); - }); - }); - } else { - remaining--; - courseData["totalLectures"] += 1; - - if (!remaining) { - if (Object.keys(availableSubs).length) { - askForSubtitle(availableSubs, initDownload, $course, courseData, defaultSubtitle); - } else { - initDownload($course, courseData); - } - } - } - }).catch((error) => { - const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); - appendLog(`getLecture_Error: (${statusCode})`, error.message); - resetCourse($course, $course.find(".download-error"), false, courseData); - }); + if (!course.completed && Settings.download.autoStartDownload) { + setupDownload($courseItem, course.selectedSubtitle); } - getLecture(v.title, chapterIndex, lectureIndex); - lectureIndex++; - } else if (downloadType != Settings.DownloadType.OnlyLectures) { - const srcUrl = `https://${Settings.subDomain}.udemy.com${$course.attr("course-url")}t/${v._class}/${v.id}`; - - // Adiciona um chapter default, para cursos que tem apenas quiz - if (courseData["chapters"].length === 0) { - chapterIndex++; - lectureIndex = 0; - courseData["chapters"][chapterIndex] = []; - courseData["chapters"][chapterIndex]["name"] = "Chapter 0"; - courseData["chapters"][chapterIndex]["lectures"] = []; + // Simula atraso de 200ms para demonstração + // setTimeout(() => { resolve(); }, 200); + resolve(); + }); + } + + const promises = downloadedCourses.map(course => addCourseToDOM(course)); + + // Executa todas as Promessas em paralelo + Promise.all(promises) + .then(() => ui.busyLoadDownloads(false)) + .catch(error => { + console.error("Error adding courses:", error); + ui.busyLoadDownloads(false); + }); + + } +} + +async function fetchCourseContent(courseId, courseName, courseUrl) { + try { + ui.busyBuildCourseData(true); + + const response = await udemyService.fetchCourseContent(courseId, "all") + if (!response) { + ui.busyBuildCourseData(false); + showAlert(`Id: ${courseId}`, translate("Course not found")); + return null; + } + + const downloadType = Number(Settings.download.type); + const downloadAttachments = downloadType === Settings.DownloadType.Both || downloadType === Settings.DownloadType.OnlyAttachments; + + const courseData = { + id: courseId, + name: courseName, + chapters: [], + totalLectures: 0, + encryptedVideos: 0, + errorCount: 0, + availableSubs: [], + }; + + let chapterData = null; + response.results.forEach((item) => { + const type = item._class.toLowerCase(); + if (type == "chapter") { + if (chapterData) { + courseData.chapters.push(chapterData); } + chapterData = { id: item.id, name: item.title.trim(), lectures: [] }; + } + else if (type == "quiz") { + const srcUrl = `${courseUrl}t/${item._class}/${item.id}`; - courseData["chapters"][chapterIndex]["lectures"][lectureIndex] = { + chapterData.lectures.push({ + type: "url", + name: item.title, src: ``, - name: v.title, quality: "Attachment", - type: "Url", - }; - remaining--; - courseData["totalLectures"] += 1; - - if (!remaining) { - if (Object.keys(availableSubs).length) { - askForSubtitle(availableSubs, initDownload, $course, courseData, defaultSubtitle); - } else { - initDownload($course, courseData); + }); + courseData.totalLectures++; + } + else { + const lecture = { type, name: item.title, src: "", quality: Settings.download.videoQuality, isEncrypted: false }; + const { asset, supplementary_assets } = item; + + if (asset.asset_type.toLowerCase() == "article") { + lecture.type = "article"; + lecture.quality = asset.asset_type; + lecture.src = asset.data?.body ?? asset.body; + + } else if (asset.asset_type.toLowerCase() == "file" || asset.asset_type.toLowerCase() == "e-book") { + lecture.type = "file"; + lecture.quality = asset.asset_type; + lecture.src = asset.download_urls[asset.asset_type][0].file; + + } else { + const streams = asset.streams; + switch (lecture.quality.toLowerCase()) { + case "auto": + case "highest": + lecture.quality = streams.maxQuality.toString(); + break; + case "lowest": + lecture.quality = streams.minQuality.toString(); + break; + default: + lecture.quality = lecture.quality.slice(0, -1); } - } - lectureIndex++; - } else { - remaining--; - if (!remaining) { - if (Object.keys(availableSubs).length) { - askForSubtitle(availableSubs, initDownload, $course, courseData, defaultSubtitle); - } else { - initDownload($course, courseData); + if (!streams.sources[lecture.quality]) { + lecture.quality = streams.maxQuality.toString(); + } + lecture.src = streams.sources[lecture.quality].url; + lecture.type = streams.sources[lecture.quality].type; + if (streams.isEncrypted) { + lecture.isEncrypted = true; + courseData.encryptedVideos++; } } + + if (!Settings.download.skipSubtitles && asset.captions.length > 0) { + // TODO: renomear para subtitles + lecture.caption = {}; + + asset.captions.forEach((caption) => { + caption.video_label in courseData.availableSubs + ? (courseData.availableSubs[caption.video_label] = courseData.availableSubs[caption.video_label] + 1) + : (courseData.availableSubs[caption.video_label] = 1); + + lecture.caption[caption.video_label] = caption.url; + }); + } + + if (downloadAttachments && supplementary_assets.length > 0) { + const attachments = lecture.supplementary_assets = []; + + supplementary_assets.forEach((attachment) => { + const type = attachment.download_urls ? "file" : "url"; + const src = attachment.download_urls + ? attachment.download_urls[attachment.asset_type][0].file + : ``; + + attachments.push({ type, name: attachment.title, src, quality: "Attachment" }); + }); + } + + chapterData.lectures.push(lecture); + courseData.totalLectures++; } }); - }).catch((error) => { + + if (chapterData) { + courseData.chapters.push(chapterData); + } + + ui.busyBuildCourseData(false); + return courseData; + } catch (error) { let msgError; - const statusCode = error.response?.status || 0; + const statusCode = error.response ? error.response.status : 0; switch (statusCode) { case 403: - msgError = translate("You do not have permission to access this course") + `\nId: ${courseId}`; - showAlert(msgError, "Download Error"); + msgError = translate("You do not have permission to access this course"); + prompt.alert(msgError); break; case 504: msgError = "Gateway timeout"; @@ -839,87 +775,312 @@ function initializeDownload($course, subtitle) { msgError = error.message; break; } - const errorCode = error.code ? ` :${error.code}` : ""; - appendLog(`download_Error: (${statusCode}${errorCode})`, msgError); - - ui.enableDownloadButton($course, true); - ui.showProgress($course, false); - }).finally(() => { - ui.busyPrepareDownload(false); - }); + appendLog(`fetchCourseContent: ${error.code}(${statusCode})`, msgError); + throw utils.newError("EBUILD_COURSE_DATA", msgError); + } } -function initDownload($course, courseData, subTitle = "") { - const $clone = $course.clone(); - const subtitle = (Array.isArray(subTitle) ? subTitle[0] : subTitle).split("|"); - const $downloads = $(".ui.downloads.section .ui.courses.items"); - const $courses = $(".ui.courses.section .ui.courses.items"); +async function fetchCourses(isSubscriber) { + ui.busyLoadCourses(true); - $course.find('input[name="selectedSubtitle"]').val(subtitle); - if ($course.parents(".courses.section").length) { - const $downloadItem = $downloads.find("[course-id=" + $course.attr("course-id") + "]"); - if ($downloadItem.length) { - $downloadItem.replaceWith($clone); - } else { - $downloads.prepend($clone); - } - } else { - const $courseItem = $courses.find("[course-id=" + $course.attr("course-id") + "]"); - if ($courseItem.length) { - $courseItem.replaceWith($clone); + try { + const courses = await udemyService.fetchCourses(PAGE_SIZE, isSubscriber); + console.log("fetch courses", courses); + renderCourses(courses); + if (Settings.downloadedCourses) { + renderDownloads(); } - } - $course.push($clone[0]); - - let timerDownloader = null; - const downloader = new Downloader(); - const $actionButtons = $course.find(".action.buttons"); - const $pauseButton = $actionButtons.find(".pause.button"); - const $resumeButton = $actionButtons.find(".resume.button"); - const lectureChapterMap = {}; - const labelColorMap = { - 144: "purple", - 240: "orange", - 360: "blue", - 480: "teal", - 720: "olive", - 1080: "green", - Highest: "green", - auto: "red", - Attachment: "pink", - Subtitle: "black", - }; - let currentLecture = 0; - courseData["chapters"].forEach(function (lecture, chapterIndex) { - lecture["lectures"].forEach(function (x, lectureIndex) { - currentLecture++; - lectureChapterMap[currentLecture] = { chapterIndex, lectureIndex }; - }); - }); - - const courseName = sanitize(courseData["name"]); //, { replacement: (s) => "? ".indexOf(s) > -1 ? "" : "-", }).trim(); - const $progressCombined = $course.find(".combined.progress"); - const $progressIndividual = $course.find(".individual.progress"); - - const $downloadSpeed = $course.find(".download-speed"); - const $downloadSpeedValue = $downloadSpeed.find(".value"); - const $downloadSpeedUnit = $downloadSpeed.find(".download-unit"); - const $downloadQuality = $course.find(".download-quality"); - const setLabelQuality = (label) => { - const lastClass = $downloadQuality.attr("class").split(" ").pop(); - - $downloadQuality - .html(label.toString() + (!isNaN(parseFloat(label)) ? "p" : "")) - .removeClass(lastClass) - .addClass(labelColorMap[label] || "grey"); + } catch (error) { + appendLog("Error Fetching Courses", error); + showAlert(error.message, "Fetching Courses"); + } finally { + ui.busyLoadCourses(false); } +} - $course.css("cssText", "padding-top: 35px !important").css("padding-bottom", "25px"); +function loadMore(loadMoreButton) { + const $button = $(loadMoreButton); + const $courses = $button.prev(".courses.items"); + const url = $button.data("url"); + + ui.busyLoadCourses(true); + axios({ + timeout: HTTP_TIMEOUT, + method: "GET", + url, + headers: { Authorization: `Bearer weweqvasdvsadfw` } //headersAuth, + }).then(({ data: resp }) => { + // $.each(resp.results, (_index, course) => { + // htmlCourseCard(course).appendTo($courses); + // }); + $courses.append(...resp.results.map(createCourseElement)); + if (!resp.next) { + $button.remove(); + } else { + $button.data("url", resp.next); + } + }).catch(error => { + const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); + appendLog(`loadMore_Error: (${statusCode})`, error); + }).finally(() => { + ui.busyLoadCourses(false); + }); +} + +async function search(keyword) { + ui.busyLoadCourses(true); + + try { + const courses = await udemyService.fetchSearchCourses(keyword, PAGE_SIZE, Settings.subscriber); + console.log("search courses", courses); + renderCourses(courses, !!keyword); + } catch (error) { + const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); + appendLog(`search_Error: (${statusCode})`, error); + } finally { + ui.busyLoadCourses(false); + } +} + +function getMsgChangeSearchMode() { + const msg = Settings.subscriber + ? translate("This account has been identified with a subscription plan") + : translate("This account was identified without a subscription plan"); + + const button = ` +
+ +
`; + + return `

${msg}
${translate("If it's wrong, change the search mode and try again")}${button}

`; +} + +/** + * Toggles the subscriber setting and clears the search field. + */ +function toggleSubscriber() { + Settings.subscriber = !Settings.subscriber; + search(""); +} + +function addDownloadHistory(courseId, completed = false, encryptedVideos = 0, selectedSubtitle = "", pathDownloaded = "") { + const items = Settings.downloadHistory; + const index = items.findIndex((x) => x.id == courseId); + + completed = Boolean(completed); + + if (index !== -1) { + const item = items[index]; + if (completed !== item.completed) { + item.completed = completed; + item.date = new Date(Date.now()).toLocaleDateString(); + } + item.encryptedVideos = encryptedVideos; + item.selectedSubtitle = selectedSubtitle; + item.pathDownloaded = pathDownloaded; + } else { + items.push({ + id: courseId, + completed, + date: new Date(Date.now()).toLocaleDateString(), + encryptedVideos, + selectedSubtitle, + pathDownloaded, + }); + } + + Settings.downloadHistory = items; +} + +function getDownloadHistory(courseId) { + return Settings.downloadHistory.find((x) => x.id === courseId) || undefined; +} + +function saveDownloads(shouldQuitApp) { + ui.busySavingHistory(true); + + function getProgress($progress) { + const dataPercent = $progress.attr("data-percent"); + return parseInt(dataPercent, 10); + } + + const downloadedCourses = []; + const downloads = $(".ui.downloads.section .ui.courses.items .ui.course.item"); + + downloads.each((_index, element) => { + const $el = $(element); + const hasProgress = $el.find(".progress.active").length > 0; + const individualProgress = hasProgress ? getProgress($el.find(".download-status .individual.progress")) : 0; + const combinedProgress = hasProgress ? getProgress($el.find(".download-status .combined.progress")) : 0; + const isCompleted = !hasProgress && $el.attr("course-completed") === "true"; + + const courseData = { + id: $el.attr("course-id"), + url: $el.attr("course-url"), + title: $el.find(".coursename").text(), + image: $el.find(".image img").attr("src"), + individualProgress: Math.min(100, individualProgress), + combinedProgress: Math.min(100, combinedProgress), + completed: isCompleted, + progressStatus: $el.find(".download-status .label").text(), + encryptedVideos: $el.find('input[name="encryptedvideos"]').val(), + selectedSubtitle: $el.find('input[name="selectedSubtitle"]').val(), + pathDownloaded: $el.find('input[name="path-downloaded"]').val(), + }; + + downloadedCourses.push(courseData); + addDownloadHistory( + courseData.id, + courseData.completed, + courseData.encryptedVideos, + courseData.selectedSubtitle, + courseData.pathDownloaded + ); + }); + + Settings.downloadedCourses = downloadedCourses; + + if (shouldQuitApp) { + ipcRenderer.send("quitApp"); + } else { + ui.busySavingHistory(false); + } +} + +function removeCurseDownloads(courseId) { + const $downloads = $(".ui.downloads.section .ui.courses.items .ui.course.item"); //.slice(0); + + $downloads.each((_index, element) => { + const $el = $(element); + if ($el.attr("course-id") == courseId) { + $el.remove(); + } + }); +} + +async function setupDownload($course, subtitle) { + ui.prepareDownload($course); + + const courseId = $course.attr("course-id"); + const courseName = $course.find(".coursename").text(); + const courseUrl = `https://${Settings.subDomain}.udemy.com${$course.attr("course-url")}`; + + const skipSubtitles = Boolean(Settings.download.skipSubtitles); + const defaultSubtitle = skipSubtitles ? null : subtitle ?? Settings.download.defaultSubtitle; + + console.clear(); + + try { + const courseData = await fetchCourseContent(courseId, courseName, courseUrl); + if (!courseData) { + return; + } + + ui.enableDownloadButton($course, false); + ui.showProgress($course, true); + + try { + console.log("Downloading", courseData); + askForSubtitle(courseData.availableSubs, courseData.totalLectures, defaultSubtitle, (subtitle) => { + startDownload($course, courseData, subtitle); + }); + } catch (error) { + throw utils.newError("EASK_FOR_SUBTITLE", error.message); + } + + } catch (error) { + let msgError; + const statusCode = error.response?.status || 0; + switch (statusCode) { + case 403: + msgError = translate("You do not have permission to access this course") + `\nId: ${courseId}`; + showAlert(msgError, "Download Error"); + break; + case 504: + msgError = "Gateway timeout"; + break; + default: + msgError = error; + } + const errorCode = error.code ? ` :${error.code}` : ""; + appendLog(`download_Error: (${statusCode}${errorCode})`, msgError); + ui.busyOff(); + } +} + +function startDownload($course, courseData, subTitle = "") { + const $clone = $course.clone(); + const subtitle = (Array.isArray(subTitle) ? subTitle[0] : subTitle).split("|"); + const $downloads = $(".ui.downloads.section .ui.courses.items"); + const $courses = $(".ui.courses.section .ui.courses.items"); + + $course.find('input[name="selectedSubtitle"]').val(subtitle); + if ($course.parents(".courses.section").length) { + const $downloadItem = $downloads.find("[course-id=" + $course.attr("course-id") + "]"); + if ($downloadItem.length) { + $downloadItem.replaceWith($clone); + } else { + $downloads.prepend($clone); + } + } else { + const $courseItem = $courses.find("[course-id=" + $course.attr("course-id") + "]"); + if ($courseItem.length) { + $courseItem.replaceWith($clone); + } + } + $course.push($clone[0]); + + let timerDownloader = null; + const downloader = new Downloader(); + const $actionButtons = $course.find(".action.buttons"); + const $pauseButton = $actionButtons.find(".pause.button"); + const $resumeButton = $actionButtons.find(".resume.button"); + + const lectureChapterMap = {}; + let sequenceMap = 0; + courseData.chapters.forEach((chapter, chapterIndex) => { + chapter.lectures.forEach((_lecture, lectureIndex) => { + sequenceMap++; + // console.log("currentLecture", sequenceMap, chapterIndex, lectureIndex); + lectureChapterMap[sequenceMap] = { chapterIndex, lectureIndex }; + }); + }); + + const courseName = sanitize(courseData["name"]); //, { replacement: (s) => "? ".indexOf(s) > -1 ? "" : "-", }).trim(); + const $progressCombined = $course.find(".combined.progress"); + const $progressIndividual = $course.find(".individual.progress"); + + const $downloadSpeed = $course.find(".download-speed"); + const $downloadSpeedValue = $downloadSpeed.find(".value"); + const $downloadSpeedUnit = $downloadSpeed.find(".download-unit"); + const $downloadQuality = $course.find(".download-quality"); + + const labelColorMap = { + 144: "purple", + 240: "orange", + 360: "blue", + 480: "teal", + 720: "olive", + 1080: "green", + Highest: "green", + auto: "red", + Attachment: "pink", + Subtitle: "black", + }; + + const setLabelQuality = (label) => { + const lastClass = $downloadQuality.attr("class").split(" ").pop(); + $downloadQuality + .html(label.toString() + (!isNaN(parseFloat(label)) ? "p" : "")) + .removeClass(lastClass) + .addClass(labelColorMap[label] || "grey"); + } const downloadDirectory = Settings.downloadDirectory(); $course.find('input[name="path-downloaded"]').val(`${downloadDirectory}/${courseName}`); $course.find(".open-dir.button").show(); + $course.css("cssText", "padding-top: 35px !important").css("padding-bottom", "25px"); $pauseButton.click(function () { stopDownload(); @@ -981,9 +1142,9 @@ function initDownload($course, courseData, subTitle = "") { fs.mkdirSync(seqName.fullPath, { recursive: true }); downloadLecture(chapterIndex, lectureIndex, countLectures, seqName.name); - } catch (err) { - appendLog("downloadChapter_Error:", err.message); - dialog.showErrorBox("downloadChapter_Error", err.message); + } catch (error) { + appendLog("downloadChapter_Error:", error); + dialog.showErrorBox("downloadChapter_Error", error.message); resetCourse($course, $course.find(".download-error"), false, courseData); } @@ -1004,9 +1165,9 @@ function initDownload($course, courseData, subTitle = "") { return; } - const lectureData = courseData["chapters"][chapterIndex]["lectures"][lectureIndex]; - const lectureType = lectureData["type"].toLowerCase(); - const lectureName = lectureData["name"].trim(); + const lectureData = courseData.chapters[chapterIndex].lectures[lectureIndex]; + const lectureType = lectureData.type.toLowerCase(); + const lectureName = lectureData.name.trim(); const sanitizedLectureName = sanitize(lectureName); function dlStart(dl, typeVideo, callback) { @@ -1054,6 +1215,7 @@ function initDownload($course, courseData, subTitle = "") { case 1: case -1: + console.log(`dl~status: ${dl.status}`); const stats = dl.getStats(); const speedAndUnit = utils.getDownloadSpeed(stats.present.speed || 0); $downloadSpeedValue.html(speedAndUnit.value); @@ -1066,35 +1228,24 @@ function initDownload($course, courseData, subTitle = "") { dl.emit("end"); clearInterval(timerDownloader); } else if (dl.status === -1) { + console.warn("Download error, retrying..."); axios({ - timeout: ajaxTimeout, + timeout: HTTP_TIMEOUT, type: "HEAD", url: dl.url, }).then(() => { - resetCourse( - $course, - $course.find(".download-error"), - Settings.download.autoRetry, - courseData, - subtitle - ); + resetCourse($course, $course.find(".download-error"), Settings.download.autoRetry, courseData, subtitle); }).catch((error) => { const statusCode = error.response?.status || 0; const errorCode = error.code ? ` :${error.code}` : ""; - appendLog(`downloadLecture_Error: (${statusCode}${errorCode})`, error.message); + appendLog(`downloadLecture_Error: (${statusCode}${errorCode})`, error); try { if (statusCode == 401 || statusCode == 403) { fs.unlinkSync(dl.filePath); } } finally { - resetCourse( - $course, - $course.find(".download-error"), - Settings.download.autoRetry, - courseData, - subtitle - ); + resetCourse($course, $course.find(".download-error"), Settings.download.autoRetry, courseData, subtitle); } }); @@ -1111,6 +1262,7 @@ function initDownload($course, courseData, subTitle = "") { }, 1000); dl.on("error", function (dl) { + console.error("dl.on(error)", dl.error.message); if (hasDRMProtection(dl)) { dl.emit("end"); } else { @@ -1120,20 +1272,20 @@ function initDownload($course, courseData, subTitle = "") { dl.on("start", function () { let file = dl.filePath.split("/").slice(-2).join("/"); - - console.log("startDownload", file); + console.log("dl.on(start)", file); $pauseButton.removeClass("disabled"); }); dl.on("stop", function () { - console.warn("stopDownload"); + console.warn("dl.on(stop)"); }); dl.on("end", function () { + console.log("dl.on(end)", { path: dl.filePath, typeVideo }); if (typeVideo && hasDRMProtection(dl)) { $course.find('input[name="encryptedvideos"]').val(++courseData.encryptedVideos); - appendLog(`DRM Protected::${courseData.name}`, dl.filePath, false); + appendLog(`DRM Protected::${courseData.name}`, dl.filePath); fs.unlink(dl.filePath + ".mtd", (err) => { if (err) { console.error("dl.on(end)__fs.unlink", err.message); @@ -1154,16 +1306,16 @@ function initDownload($course, courseData, subTitle = "") { function downloadAttachments(index, totalAttachments) { $progressIndividual.progress("reset"); - const attachment = lectureData["supplementary_assets"][index]; - const attachmentName = attachment["name"].trim(); + const attachment = lectureData.supplementary_assets[index]; + const attachmentName = attachment.name.trim(); - setLabelQuality(attachment["quality"]); + setLabelQuality(attachment.quality); - if (attachment["type"] == "Article" || attachment["type"] == "Url") { + if (["article", "url"].includes(attachment.type)) { const wfDir = downloadDirectory + "/" + courseName + "/" + chapterName; fs.writeFile( utils.getSequenceName(lectureIndex + 1, countLectures, attachmentName + ".html", `.${index + 1} `, wfDir).fullPath, - attachment["src"], + attachment.src, function () { index++; if (index == totalAttachments) { @@ -1176,7 +1328,6 @@ function initDownload($course, courseData, subTitle = "") { } ); } else { - //Download anexos let fileExtension = attachment.src.split("/").pop().split("?").shift().split(".").pop(); fileExtension = attachment.name.split(".").pop() == fileExtension ? "" : "." + fileExtension; @@ -1188,24 +1339,21 @@ function initDownload($course, courseData, subTitle = "") { `${downloadDirectory}/${courseName}/${chapterName}` ); + // try deleting the download started without data if (fs.existsSync(seqName.fullPath + ".mtd") && !fs.statSync(seqName.fullPath + ".mtd").size) { fs.unlinkSync(seqName.fullPath + ".mtd"); } if (fs.existsSync(seqName.fullPath + ".mtd")) { - console.log("downloadAttachments: Reiniciando download", seqName.fullPath); var dl = downloader.resumeDownload(seqName.fullPath); } else if (fs.existsSync(seqName.fullPath)) { endDownload(); return; } else { - if (seqName.fullPath.includes(".mp4") || attachment["type"].toLowerCase() == "video") { - console.log("downloadAttachements: Iniciando download do Video", attachment["src"]); - } - var dl = downloader.download(attachment["src"], seqName.fullPath); + var dl = downloader.download(attachment.src, seqName.fullPath); } - dlStart(dl, attachment["type"].toLowerCase() == "video", endDownload); + dlStart(dl, attachment.type.includes("video"), endDownload); function endDownload() { index++; @@ -1224,10 +1372,10 @@ function initDownload($course, courseData, subTitle = "") { function checkAttachment() { $progressIndividual.progress("reset"); - const attachment = lectureData["supplementary_assets"]; + const attachment = lectureData.supplementary_assets; if (attachment) { - lectureData["supplementary_assets"].sort(utils.dynamicSort("name")); + lectureData.supplementary_assets.sort(utils.dynamicSort("name")); downloadAttachments(0, attachment.length); } else { @@ -1315,8 +1463,8 @@ function initDownload($course, courseData, subTitle = "") { return await response.text(); } else console.log("getFile_Buffer", response.statusText); - } catch (err) { - appendLog("getFile_Error", err.message); + } catch (error) { + appendLog("getFile_Error", error); } retry++; @@ -1360,9 +1508,9 @@ function initDownload($course, courseData, subTitle = "") { maximumQuality = readQuality; getUrl = true; } - } catch (err) { - appendLog("getPlaylist_Error", err.message); - captureException(err); + } catch (error) { + appendLog("getPlaylist_Error", error); + captureException(error); } } } @@ -1380,20 +1528,20 @@ function initDownload($course, courseData, subTitle = "") { $progressIndividual.progress("reset"); - const lectureQuality = lectureData["quality"]; + const lectureQuality = lectureData.quality; setLabelQuality(lectureQuality); if (lectureType == "article" || lectureType == "url") { const wfDir = `${downloadDirectory}/${courseName}/${chapterName}`; fs.writeFile( utils.getSequenceName(lectureIndex + 1, countLectures, sanitizedLectureName + ".html", ". ", wfDir).fullPath, - lectureData["src"], + lectureData.src, function () { - if (lectureData["supplementary_assets"]) { - lectureData["supplementary_assets"].sort( + if (lectureData.supplementary_assets) { + lectureData.supplementary_assets.sort( utils.dynamicSort("name") ); - const totalAttachments = lectureData["supplementary_assets"].length; + const totalAttachments = lectureData.supplementary_assets.length; let indexador = 0; downloadAttachments(indexador, totalAttachments); } else { @@ -1416,7 +1564,7 @@ function initDownload($course, courseData, subTitle = "") { const skipLecture = Number(Settings.download.type) === Settings.DownloadType.OnlyAttachments; // if not stream - if (lectureQuality != "Highest") { + if (lectureType == "video/mp4" || lectureType == "video") { if (fs.existsSync(seqName.fullPath + ".mtd") && !fs.statSync(seqName.fullPath + ".mtd").size) { fs.unlinkSync(seqName.fullPath + ".mtd"); } @@ -1428,11 +1576,11 @@ function initDownload($course, courseData, subTitle = "") { endDownloadAttachment(); return; } else { - console.log("downloadLecture: Iniciando download do Video ", lectureData["src"]); - var dl = downloader.download(lectureData["src"], seqName.fullPath); + console.log("downloadLecture: Iniciando download do Video ", lectureData.src); + var dl = downloader.download(lectureData.src, seqName.fullPath); } - dlStart(dl, lectureType == "video", endDownloadAttachment); + dlStart(dl, lectureType.includes("video"), endDownloadAttachment); } else { if (fs.existsSync(seqName.fullPath + ".mtd")) { fs.unlinkSync(seqName.fullPath + ".mtd"); @@ -1441,8 +1589,8 @@ function initDownload($course, courseData, subTitle = "") { return; } - getPlaylist(lectureData["src"]).then(async (list) => { - console.log("getPlaylist~getFile(binary): ", lectureData["src"]); + getPlaylist(lectureData.src).then(async (list) => { + console.log("getPlaylist~getFile(binary): ", lectureData.src); if (list.length > 0) { const result = [list.length]; @@ -1477,16 +1625,16 @@ function initDownload($course, courseData, subTitle = "") { function endDownloadAttachment() { clearInterval(timerDownloader); - if (courseData["chapters"][chapterIndex]["lectures"][lectureIndex].caption) { + if (courseData.chapters[chapterIndex].lectures[lectureIndex].caption) { downloadSubtitle(); } else { checkAttachment(); } } } - } catch (err) { - appendLog("downloadLecture_Error:", err.message); - captureException(err); + } catch (error) { + appendLog("downloadLecture_Error:", error); + captureException(error); resetCourse($course, $course.find(".download-error"), false, courseData); } @@ -1505,13 +1653,16 @@ function initDownload($course, courseData, subTitle = "") { } } -function askForSubtitle(subtitlesAvailable, fnDownload, $course, courseData, defaultSubtitle = "") { +function askForSubtitle(subtitlesAvailable, totalLectures, defaultSubtitle = "", callback) { const subtitleLanguages = []; const languages = []; const totals = {}; const languageKeys = {}; - if (!subtitlesAvailable) return; + if (Object.keys(subtitlesAvailable).length === 0) { + callback(""); + return; + } defaultSubtitle = defaultSubtitle.replace(/\s*\[.*?\]/g, '').trim(); for (const key in subtitlesAvailable) { @@ -1519,7 +1670,7 @@ function askForSubtitle(subtitlesAvailable, fnDownload, $course, courseData, def // default subtitle exists if (subtitle === defaultSubtitle) { - fnDownload($course, courseData, key); + callback(key); return; } @@ -1534,14 +1685,14 @@ function askForSubtitle(subtitlesAvailable, fnDownload, $course, courseData, def }; if (languages.length === 1) { - fnDownload($course, courseData, languageKeys[0]); + callback(languageKeys[0]); return; } else if (languages.length === 0) { return; } languages.forEach(language => { - totals[language] = Math.min(courseData["totalLectures"], totals[language]); + totals[language] = Math.min(totalLectures, totals[language]); }); languages.sort(); @@ -1551,6 +1702,7 @@ function askForSubtitle(subtitlesAvailable, fnDownload, $course, courseData, def value: languageKeys[language].join("|"), }); }); + subtitleLanguages.unshift({ name: "", value: "" }); const $subtitleModal = $(".ui.subtitle.modal"); const $subtitleDropdown = $subtitleModal.find(".ui.dropdown"); @@ -1561,310 +1713,11 @@ function askForSubtitle(subtitlesAvailable, fnDownload, $course, courseData, def onChange: (subtitle) => { $subtitleModal.modal("hide"); $subtitleDropdown.dropdown({ values: [] }); - fnDownload($course, courseData, subtitle); + callback(subtitle); }, }); } - -function resetCourse($course, $elMessage, autoRetry, courseData, subtitle) { - if ($elMessage.hasClass("download-success")) { - $course.attr("course-completed", true); - } else { - $course.attr("course-completed", ""); - - if ($elMessage.hasClass("download-error")) { - if (autoRetry && courseData.errorCount++ < 5) { - $course.length = 1; - initDownload($course, courseData, subtitle); - return; - } - } - } - - $course.find(".download-quality").hide(); - $course.find(".download-speed").hide().find(".value").html(0); - $course.find(".download-status").hide().html(ui.actionCardTemplate); - $course.css("padding", "14px 0px"); - $elMessage.css("display", "flex"); -} - -function renderCourses(response, keyword = "") { - console.log("renderCourse", { response, keyword }); - - const $coursesSection = $(".ui.dashboard .ui.courses.section"); - const $coursesItems = $coursesSection.find(".ui.courses.items").empty(); - - $coursesSection.find(".disposable").remove(); - - if (response.results.length) { - // response.results.forEach(course => { - // $coursesItems.append(htmlCourseCard(course)); - // }); - const courseElements = response.results.map(course => htmlCourseCard(course)); - $coursesItems.append(courseElements); - - if (response.next) { - // added loadMore Button - $coursesSection.append( - `` - ); - } - - } else { - let msg = ""; - if (!keyword.length) { - msg = getMsgChangeSearchMode(); - appendLog(translate("No Courses Found"), msg, false); - } - - $coursesItems.append( - `
- ${translate("No Courses Found")}
- ${translate("Remember, you will only be able to see the courses you are enrolled in")} - ${msg} -
` - ); - } - -} - -async function renderDownloads() { - console.log("renderDownloads"); - - const $downloadsSection = $(".ui.downloads.section .ui.courses.items"); - if ($downloadsSection.find(".ui.course.item").length) { - return; - } - - const downloadedCourses = Settings.downloadedCourses || []; - if (!downloadedCourses.length) { - $downloadsSection.append( - `
- ${translate("There are no Downloads to display")} -
` - ); - } - else { - ui.busyLoadDownloads(true); - // await utils.sleep(10); - // // downloadedCourses.forEach(course => { - // downloadedCourses.map(course => { - // const $courseItem = htmlCourseCard(course, true); - // $downloadsSection.append($courseItem); - - // if (!course.completed && Settings.download.autoStartDownload) { - // initializeDownload($courseItem, course.selectedSubtitle); - // // $courseItem.find(".action.buttons").find(".pause.button").removeClass("disabled"); - // } - // }); - // ui.busyLoadDownloads(false); - - function addCourseToDOM(course) { - return new Promise((resolve, _reject) => { - const $courseItem = htmlCourseCard(course, true); - $downloadsSection.append($courseItem); - - if (!course.completed && Settings.download.autoStartDownload) { - initializeDownload($courseItem, course.selectedSubtitle); - } - - // Simula atraso de 200ms para demonstração - // setTimeout(() => { resolve(); }, 200); - resolve(); - }); - } - - const promises = downloadedCourses.map(course => addCourseToDOM(course)); - - // Executa todas as Promessas em paralelo - Promise.all(promises) - .then(() => ui.busyLoadDownloads(false)) - .catch(error => { - console.error("Error adding courses:", error); - ui.busyLoadDownloads(false); - }); - - } -} - -function loadMore(loadMoreButton) { - const $button = $(loadMoreButton); - const $courses = $button.prev(".courses.items"); - const url = $button.data("url"); - - ui.busyLoadCourses(true); - axios({ - timeout: ajaxTimeout, - method: "GET", - url, - headers: { Authorization: `Bearer weweqvasdvsadfw` } //headersAuth, - }).then(({ data: resp }) => { - // $.each(resp.results, (_index, course) => { - // htmlCourseCard(course).appendTo($courses); - // }); - $courses.append(...resp.results.map(htmlCourseCard)); - if (!resp.next) { - $button.remove(); - } else { - $button.data("url", resp.next); - } - }).catch(error => { - const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); - appendLog(`loadMore_Error: (${statusCode})`, error.message); - }).finally(() => { - ui.busyLoadCourses(false); - }); -} - -function search(keyword) { - const subscriber = Settings.subscriber; - const url = !subscriber - ? `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscribed-courses?page=1&page_size=30&ordering=title&fields[user]=job_title&page_size=${pageSize}&search=${keyword}` - : `https://${Settings.subDomain}.udemy.com/api-2.0/users/me/subscription-course-enrollments?page=1&page_size=30&ordering=title&fields[user]=job_title&page_size=${pageSize}&search=${keyword}`; - - console.log("search", url); - - ui.busyLoadCourses(true); - axios({ - timeout: ajaxTimeout, // timeout to 5 seconds - method: "GET", - url, - headers: headersAuth, - }).then(response => { - console.log("search done"); - renderCourses(response.data, keyword); - ui.busyLoadCourses(false); - }).catch(error => { - const statusCode = (error.response?.status || 0).toString() + (error.code ? ` :${error.code}` : ""); - appendLog(`search_Error: (${statusCode})`, error.message); - ui.busyLoadCourses(false); - }); -} - -/** - * Returns a message for changing search mode based on account subscription status - * - * @return {string} Message for changing search mode - */ -function getMsgChangeSearchMode() { - const msg = Settings.subscriber - ? translate("This account has been identified with a subscription plan") - : translate("This account was identified without a subscription plan"); - - const button = ` -
- -
`; - - return `

${msg}
${translate("If it's wrong, change the search mode and try again")}${button}

`; -} - -/** - * Toggles the subscriber setting and clears the search field. - */ -function toggleSubscriber() { - Settings.subscriber = !Settings.subscriber; - search(""); -} - -function addDownloadHistory(courseId, completed = false, encryptedVideos = 0, selectedSubtitle = "", pathDownloaded = "") { - const items = Settings.downloadHistory; - const index = items.findIndex((x) => x.id == courseId); - - completed = Boolean(completed); - - if (index !== -1) { - const item = items[index]; - if (completed !== item.completed) { - item.completed = completed; - item.date = new Date(Date.now()).toLocaleDateString(); - } - item.encryptedVideos = encryptedVideos; - item.selectedSubtitle = selectedSubtitle; - item.pathDownloaded = pathDownloaded; - } else { - items.push({ - id: courseId, - completed, - date: new Date(Date.now()).toLocaleDateString(), - encryptedVideos, - selectedSubtitle, - pathDownloaded, - }); - } - - Settings.downloadHistory = items; -} - -function getDownloadHistory(courseId) { - return Settings.downloadHistory.find((x) => x.id === courseId) || undefined; -} - -function saveDownloads(shouldQuitApp) { - ui.busySavingHistory(true); - - function getProgress($progress) { - const dataPercent = $progress.attr("data-percent"); - return parseInt(dataPercent, 10); - } - - const downloadedCourses = []; - const downloads = $(".ui.downloads.section .ui.courses.items .ui.course.item"); - - downloads.each((_index, element) => { - const $el = $(element); - const hasProgress = $el.find(".progress.active").length > 0; - const individualProgress = hasProgress ? getProgress($el.find(".download-status .individual.progress")) : 0; - const combinedProgress = hasProgress ? getProgress($el.find(".download-status .combined.progress")) : 0; - const isCompleted = !hasProgress && $el.attr("course-completed") === "true"; - - const courseData = { - id: $el.attr("course-id"), - url: $el.attr("course-url"), - title: $el.find(".coursename").text(), - image: $el.find(".image img").attr("src"), - individualProgress: Math.min(100, individualProgress), - combinedProgress: Math.min(100, combinedProgress), - completed: isCompleted, - progressStatus: $el.find(".download-status .label").text(), - encryptedVideos: $el.find('input[name="encryptedvideos"]').val(), - selectedSubtitle: $el.find('input[name="selectedSubtitle"]').val(), - pathDownloaded: $el.find('input[name="path-downloaded"]').val(), - }; - - downloadedCourses.push(courseData); - addDownloadHistory( - courseData.id, - courseData.completed, - courseData.encryptedVideos, - courseData.selectedSubtitle, - courseData.pathDownloaded - ); - }); - - Settings.downloadedCourses = downloadedCourses; - - if (shouldQuitApp) { - ipcRenderer.send("quitApp"); - } else { - ui.busySavingHistory(false); - } -} - -function removeCurseDownloads(courseId) { - const $downloads = $(".ui.downloads.section .ui.courses.items .ui.course.item"); //.slice(0); - - $downloads.each((_index, element) => { - const $el = $(element); - if ($el.attr("course-id") == courseId) { - $el.remove(); - } - }); -} - function sendNotification(pathCourse, courseName, urlImage = null) { try { new Notification(courseName, { @@ -1874,7 +1727,7 @@ function sendNotification(pathCourse, courseName, urlImage = null) { shell.openPath(pathCourse); }; } catch (error) { - appendLog("sendNotification", error.message, true); + appendLog("sendNotification", error); } } @@ -1889,7 +1742,9 @@ function clearBagdeLoggers() { $("#badge-logger").hide(); } -function appendLog(title, description, isError = true) { +function appendLog(title, error = "" | Error) { + const description = error instanceof Error ? error.message : error; + // item added to list to display $(".ui.logger.section .ui.list").prepend( `
@@ -1914,8 +1769,8 @@ function appendLog(title, description, isError = true) { $badge.text(qtd > 99 ? "99+" : qtd); $badge.show(); - if (isError) { - console.error(`[${title}] ${description}`); + if (error instanceof Error) { + console.error(`[${title}] ${error.message}\n ${error.stack}`); } else { console.warn(`[${title}] ${description}`); } @@ -1941,9 +1796,9 @@ function saveLogFile() { content += `${item.datetime} - ${item.title}: ${item.description}\n`; }); - fs.writeFile(filePath, content, (err) => { - if (err) { - appendLog("saveLogFile_Error", err.message); + fs.writeFile(filePath, content, (error) => { + if (error) { + appendLog("saveLogFile_Error", error); // captureException(err); return; } @@ -1963,12 +1818,12 @@ function captureException(exception) { } process.on("uncaughtException", (error) => { - appendLog("uncaughtException", error.stack); + appendLog("uncaughtException", error); captureException(error); }); process.on("unhandledRejection", (error) => { - appendLog("unhandledRejection", error.stack); + appendLog("unhandledRejection", error); captureException(error); }); diff --git a/app/core/services/index.js b/app/core/services/index.js new file mode 100644 index 00000000..eeb35987 --- /dev/null +++ b/app/core/services/index.js @@ -0,0 +1,7 @@ +const UdemyService = require("./udemy.service"); +const M3U8Service = require("./m3u8.service"); + +module.exports = { + default: UdemyService, + M3U8Service, +} \ No newline at end of file diff --git a/app/core/services/m3u8.service.js b/app/core/services/m3u8.service.js new file mode 100644 index 00000000..14177c19 --- /dev/null +++ b/app/core/services/m3u8.service.js @@ -0,0 +1,168 @@ +"use strict" + +class M3U8Service { + + /** + * Creates a new instance of M3U8Playlist. + * @param {string} m3u8Url - The URL of the M3U8 playlist. + * @returns {M3U8Service} - The newly created M3U8Playlist instance. + */ + constructor(m3u8Url) { + if (!this._isValidUrl(m3u8Url)) { + throw new Error('Invalid URL'); + } + this._m3u8Url = m3u8Url; + /** @type {Array<{quality: number, resolution: string, url: string}>} */ + this._playlist = []; + } + + /** + * Validates the URL. + * @param {string} url - The URL to validate. + * @returns {boolean} - True if the URL is valid, false otherwise. + */ + _isValidUrl(url) { + try { + new URL(url); + return true; + } catch (_) { + return false; + } + } + + /** + * Checks if the content is a valid M3U8 playlist. + * @param {string} content - The content to check. + * @returns {boolean} - True if the content is a valid M3U8 playlist, false otherwise. + */ + _isValidM3U8Content(content) { + return content.startsWith('#EXTM3U'); + } + + /** + * Extracts URLs and qualities from an M3U8 playlist content. + * @param {string} m3u8Content - The content of the M3U8 playlist. + * @returns {Array<{quality: number, resolution: string, url: string}>} - An array of objects containing the quality, + * resolution, and URL of each playlist. + */ + _extractUrlsAndQualities(m3u8Content) { + const lines = m3u8Content.split('\n'); + const urlsAndQualities = []; + + let currentResolution = null; + let currentQuality = null; + + lines.forEach(line => { + if (line.startsWith('#EXT-X-STREAM-INF')) { + const match = line.match(/RESOLUTION=(\d+x\d+)/); + if (match) { + currentResolution = match[1]; + currentQuality = parseInt(match[1].split('x')[1], 10); + } + } else if (line.startsWith('http')) { + if (currentResolution) { + urlsAndQualities.push({ + quality: currentQuality, + resolution: currentResolution, + url: line + }); + currentResolution = null; + currentQuality = null; + } + } + }); + + return urlsAndQualities; + } + + /** + * Fetches a file from the given URL. + * + * @param {string} url - The URL of the file to fetch. + * @param {boolean} [isBinary=false] - Whether the file is binary or text. + * @param {number} [maxRetries=3] - The maximum number of retries to fetch the file. + * @returns {Promise} - A promise that resolves with the file content. + * @throws {Error} - If the file fails to load after multiple attempts. + */ + static async getFile(url, isBinary = false, maxRetries = 3) { + let retries = 0; + + while (retries < maxRetries) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${isBinary ? 'binary' : 'text'} file: ${response.statusText}`); + } + + return isBinary ? await response.arrayBuffer() : await response.text(); + } catch (error) { + retries++; + } + } + + throw new Error('Failed to load file after multiple attempts'); + } + + /** + * Loads the M3U8 playlist. + * + * @param {number} [maxRetries=3] - The maximum number of retries to fetch the playlist. + * @returns {Promise} - A promise that resolves when the playlist is loaded successfully. + * @throws {Error} - If the playlist fails to load after multiple attempts. + */ + async loadPlaylist(maxRetries = 3) { + try { + const playlistContent = await M3U8Service.getFile(this._m3u8Url, false, maxRetries); + if (!this._isValidM3U8Content(playlistContent)) { + throw new Error('Invalid M3U8 playlist content'); + } + this._playlist = this._extractUrlsAndQualities(playlistContent); + } catch (error) { + throw error; + } + } + + /** + * Retrieves the playlist. + * + * @return {Array<{quality: number, resolution: string, url: string}>} The playlist. + */ + getPlaylist() { + return this._playlist; + } + + _sortPlaylistByQuality(ascending = true) { + return [...this._playlist].sort((a, b) => { + const heightA = parseInt(a.resolution.split('x')[1], 10); + const heightB = parseInt(b.resolution.split('x')[1], 10); + return ascending ? heightA - heightB : heightB - heightA; + }); + } + + /** + * Retrieves the highest quality item from the playlist. + * + * @return {Object|null} The highest quality item from the playlist, or null if the playlist is empty. + */ + getHighestQuality() { + if (this._playlist.length === 0) { + return null; + } + return this._sortPlaylistByQuality(false)[0]; // Retornar o item de maior qualidade + } + + /** + * Retrieves the lowest quality item from the playlist. + * + * @return {Object|null} The lowest quality item from the playlist, or null if the playlist is empty. + */ + getLowestQuality() { + if (this._playlist.length === 0) { + return null; + } + return this._sortPlaylistByQuality(true)[0]; // Retornar o item de menor qualidade + } +} + +module.exports = M3U8Service; diff --git a/app/core/services/udemy.service.js b/app/core/services/udemy.service.js new file mode 100644 index 00000000..a6ade376 --- /dev/null +++ b/app/core/services/udemy.service.js @@ -0,0 +1,291 @@ +"use strict"; + +const axios = require('axios'); +const NodeCache = require('node-cache'); +const M3U8Service = require("./m3u8.service"); + +class UdemyService { + #timeout = 40000; + #headerAuth = null; + + #urlBase; + #urlLogin; + #URL_COURSES = "/users/me/subscribed-courses"; + #URL_COURSES_ENROLL = "/users/me/subscription-course-enrollments"; + #ASSETS_FIELDS = "&fields[asset]=asset_type,title,filename,body,captions,media_sources,stream_urls,download_urls,external_url,media_license_token"; + + #cache = new NodeCache({ stdTTL: 3600 }); // TTL padrão de 1 hora + + constructor(subDomain = "www", httpTimeout = 40000) { + subDomain = (subDomain.trim().length === 0 ? "www" : subDomain.trim()).toLowerCase(); + + this.#urlBase = `https://${subDomain}.udemy.com`; + this.#timeout = httpTimeout; + this.#headerAuth = null; + this.#urlLogin = `${this.#urlBase}/join/login-popup`; + } + + /** + * Creates and returns a new Error object with the specified name and message. + * + * @param {string} name - The name of the error. + * @param {string} [message=""] - The optional error message. Default is an empty string. + * @returns {Error} The newly created Error object. + */ + _error(name, message = "") { + const error = new Error(); + error.name = name; + error.message = message; + return error; + } + + async _prepareStreamSource(el) { + try { + if (el._class === "lecture" && el.asset?.asset_type.toLowerCase() === "video") { + const asset = el.asset; + const stream_urls = asset.stream_urls?.Video || asset.media_sources; + const isEncrypted = Boolean(asset.media_license_token); + if (stream_urls) { + const streams = await this._convertToStreams(stream_urls, isEncrypted); + + delete el.asset.stream_urls; + delete el.asset.media_sources; + el.asset.streams = streams; + } + } + } catch (error) { + throw this._error("EPREPARE_STREAM_SOURCE", error.message); + } + } + + async _prepareStreamsSource(items) { + console.log("Preparing stream urls...", items); + try { + const promises = items.map(async el => { + await this._prepareStreamSource(el); + }); + + await Promise.all(promises); + } catch (error) { + throw this._error("EPREPARE_STREAMS_SOURCE", error.message); + } + } + + /** + * Transforms media sources into a standardized format. + * + * @param {Array} streamUrls - The array of stream URLs. + * @param {boolean} isEncrypted - Indicates if the media is encrypted. + * @returns {Promise<{ + * minQuality: string, + * maxQuality: string, + * isEncrypted: boolean + * sources: { [key: string]: { type: string, url: string } } + * }>} - The transformed media sources. + */ + async _convertToStreams(streamUrls, isEncrypted) { + try { + if (!streamUrls) { + throw this._error("ENO_STREAMS", "No streams found to convert"); + } + const sources = {}; + let minQuality = Number.MAX_SAFE_INTEGER; + let maxQuality = Number.MIN_SAFE_INTEGER; + + let streams = !isEncrypted ? streamUrls : streamUrls.filter(v => !(v.file || v.src).includes("/encrypted-files")); + isEncrypted = isEncrypted ? (streams.length === 0) : isEncrypted; + + streams = streams.length > 0 ? streams : streamUrls; + + const promises = streams.map(async video => { + const type = video.type; + if (type !== "application/dash+xml") { + + const quality = video.label.toLowerCase(); + const url = video.file || video.src; + + sources[quality] = { type, url }; + + if (quality !== "auto") { + const numericQuality = parseInt(quality, 10); + if (!isNaN(numericQuality)) { + if (numericQuality < minQuality) { + minQuality = numericQuality; + } + if (numericQuality > maxQuality) { + maxQuality = numericQuality; + } + } + } else { + if (!isEncrypted) { + const m3u8 = new M3U8Service(url); + await m3u8.loadPlaylist(); + + const lowest = m3u8.getLowestQuality(); + const highest = m3u8.getHighestQuality(); + + if (!isNaN(lowest.quality) && lowest.quality < minQuality) { + minQuality = lowest.quality; + sources[minQuality.toString()] = { type, url: lowest.url } + } + if (!isNaN(highest.quality) && highest.quality > maxQuality) { + maxQuality = highest.quality; + sources[maxQuality.toString()] = { type, url: highest.url } + } + } + } + } + }); + + await Promise.all(promises); + + return { + minQuality: minQuality === Number.MAX_SAFE_INTEGER ? "auto" : minQuality, + maxQuality: maxQuality === Number.MIN_SAFE_INTEGER ? "auto" : maxQuality, + isEncrypted, + sources + }; + } catch (error) { + throw this._error("ECONVERT_TO_STREAMS", error.message); + } + } + + async #fetchUrl(url, method = "GET", httpTimeout = this.#timeout) { + url = `${this.#urlBase}/api-2.0${url}`; + + // Verifique o cache antes de fazer a requisição + const cachedData = this.#cache.get(url); + if (cachedData) { + console.log(`Cache hit: ${url}`); + return cachedData; + } + + console.log(`Fetching URL: ${url}`); + try { + const response = await axios({ + url, + method, + headers: this.#headerAuth, + timeout: this.#timeout, + }); + + // Armazene o resultado no cache + this.#cache.set(url, response.data); + return response.data; + } catch (error) { + console.error(`Error fetching URL: ${url}`, error); + throw error; + } + } + + async fetchProfile(accessToken, httpTimeout = this.#timeout) { + this.#headerAuth = { Authorization: `Bearer ${accessToken}` }; + // return await this._fetchUrl("https://www.udemy.com/api-2.0/users/me"); + return await this.#fetchUrl("/contexts/me/?header=True"); + } + + async fetchSearchCourses(keyword, pageSize, isSubscriber, httpTimeout = this.#timeout) { + if (!keyword) { + return await this.fetchCourses(pageSize, isSubscriber, httpTimeout); + } + + pageSize = Math.max(pageSize, 10); + + const param = `page=1&ordering=title&fields[user]=job_title&page_size=${pageSize}&search=${keyword}`; + const url = !isSubscriber + ? `${this.#URL_COURSES}?${param}` + : `${this.#URL_COURSES_ENROLL}?${param}`; + + return await this.#fetchUrl(url, "GET", httpTimeout); + } + + async fetchCourses(pageSize = 30, isSubscriber = false, httpTimeout = this.#timeout) { + pageSize = Math.max(pageSize, 10); + + const param = `page_size=${pageSize}&ordering=-last_accessed`; + const url = !isSubscriber + ? `${this.#URL_COURSES}?${param}` + : `${this.#URL_COURSES_ENROLL}?${param}`; + + return await this.#fetchUrl(url, "GET", httpTimeout); + } + + async fetchCourse(courseId, httpTimeout = this.#timeout) { + const url = `/courses/${courseId}/cached-subscriber-curriculum-items?page_size=10000`; + return await this.#fetchUrl(url, "GET", httpTimeout); + } + + /** + * Fetches the lecture data for a given course and lecture ID. + * + * @param {number} courseId - The ID of the course. + * @param {number} lectureId - The ID of the lecture. + * @param {boolean} getAttachments - Whether to get supplementary assets. Defaults to false. + * @return {Promise} - The lecture data. + */ + async fetchLecture(courseId, lectureId, getAttachments, httpTimeout = this.#timeout) { + const url = `/users/me/subscribed-courses/${courseId}/lectures/${lectureId}?fields[lecture]=title,asset${getAttachments ? ",supplementary_assets" : ""}` + + const lectureData = await this.#fetchUrl(`${url}${this.#ASSETS_FIELDS}`, "GET", httpTimeout); + // console.log("fetchLecture", lectureData); + // await this._prepareStreamSource(lectureData); + + return lectureData; + } + + async fetchLectureAttachments(lectureId, httpTimeout = this.#timeout) { + const url = `/lectures/${lectureId}/supplementary-assets`; + return await this.#fetchUrl(url); + } + + /** + * Fetches the course content for a given course ID and content type. + * + * @param {number} courseId - The ID of the course. + * @param {'less' | 'all' | 'lectures' | 'attachments'} [contentType='less'] - The type of content to fetch. + * @return {Promise} - The course content data. + */ + async fetchCourseContent(courseId, contentType = "less" | "all" | "lectures" | "attachments", httpTimeout = this.#timeout) { + let url = `/courses/${courseId}/cached-subscriber-curriculum-items?page_size=10000` + + if (contentType !== "less") url += "&fields[lecture]=id,title"; + if (contentType === "all") url += ",asset,supplementary_assets"; + if (contentType === "lectures") url += ",asset"; + if (contentType === "attachments") url += ",supplementary_assets"; + if (contentType !== "less") url += this.#ASSETS_FIELDS; + + const contentData = await this.#fetchUrl(url); + if (!contentData || contentData.count == 0) { + return null; + } + + if (contentData.results[0]._class !== "chapter") { + contentData.results.unshift({ + id: 0, + _class: "chapter", + title: "Chapter 1", + }); + contentData.count++; + } + + await this._prepareStreamsSource(contentData.results); + + return contentData; + } + + get urlBase() { + return this.#urlBase; + } + get urlLogin() { + return this.#urlLogin; + } + + get timeout() { + return this.#timeout; + } + set timeout(value) { + this.#timeout = value; + } +} + +module.exports = UdemyService; diff --git a/app/helpers/ui.js b/app/helpers/ui.js index eb299f09..d80b7313 100644 --- a/app/helpers/ui.js +++ b/app/helpers/ui.js @@ -8,7 +8,7 @@ const ui = { $(tab).addClass("active purple"); }, busyOff: () => { - $(".ui.dimmer").removeClass("active"); + $(".ui .dimmer").removeClass("active"); }, busy: (isActive, text) => { const $busyDimmer = $(".ui.dashboard .dimmer"); @@ -36,7 +36,7 @@ const ui = { busyLoadCourses: (isActive) => { ui.busy(isActive, translate("Loading Courses")); }, - busyPrepareDownload: (isActive) => { + busyBuildCourseData: (isActive) => { ui.busy(isActive, translate("Getting Info")); }, busyLoadDownloads: (isActive) => { diff --git a/app/helpers/utils.js b/app/helpers/utils.js index 423d6b3f..01209515 100644 --- a/app/helpers/utils.js +++ b/app/helpers/utils.js @@ -138,6 +138,12 @@ const utils = { sleep: (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)); }, + newError(name, message = "") { + const error = new Error(); + error.name = name; + error.message = message; + return error; + } } module.exports = utils; diff --git a/package-lock.json b/package-lock.json index 1980377b..261988d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "electron-settings": "3.2.0", "jquery": "^3.7.1", "mt-files-downloader": "github:FaisalUmair/mt-files-downloader-wrapper", + "node-cache": "^5.1.2", "node-vtt-to-srt": "^1.0.1", "sanitize-filename": "^1.6.3" }, @@ -3682,6 +3683,17 @@ "dev": true, "optional": true }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-vtt-to-srt": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-vtt-to-srt/-/node-vtt-to-srt-1.0.1.tgz", diff --git a/package.json b/package.json index 521aa8ce..8c7c39c9 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "electron-settings": "3.2.0", "jquery": "^3.7.1", "mt-files-downloader": "github:FaisalUmair/mt-files-downloader-wrapper", + "node-cache": "^5.1.2", "node-vtt-to-srt": "^1.0.1", "sanitize-filename": "^1.6.3" },