├── .gitignore ├── assets └── example.gif ├── src ├── util │ ├── ffmpeg.js │ └── common.js ├── constants.js └── index.js ├── README.md ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdulrahman1s/fem-dl/HEAD/assets/example.gif -------------------------------------------------------------------------------- /src/util/ffmpeg.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import ffmpegPath from 'ffmpeg-static' 3 | 4 | 5 | export default (args, { silent = false, pipe } = {}) => new Promise((resolve, reject) => { 6 | const child = spawn(ffmpegPath, args) 7 | let err = '' 8 | 9 | child.stdout.on('data', (data) => silent || console.log(data.toString())) 10 | child.stderr.on('data', (data) => (err += data, silent || console.log(data.toString()))) 11 | 12 | if (pipe) child.stderr.pipe(pipe) 13 | 14 | child.on('error', reject) 15 | child.on('exit', (code) => code ? reject(err) : resolve()) 16 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontend Masters Downloader 2 | 3 | ![gif](./assets/example.gif) 4 | 5 | 6 | ### Features 7 | - Download episodes sorted by their lesson/section 8 | - Option to include subtitles/captions to the episodes 9 | - Support multiple video quality 10 | - Support multiple video formats (currently: mp4 and mkv) 11 | - Retry on fail (useful for slow/bad internet connection) 12 | 13 | 14 | ### Requirements 15 | - Nodejs v16 or newer installed 16 | - Valid FrontendMasters account cookies ([Guide](https://developer.chrome.com/docs/devtools/storage/cookies/)) 17 | 18 | ### Usage 19 | ```s 20 | $ npx fem-dl 21 | ``` 22 | 23 | or via pnpm 24 | ```s 25 | $ pnpm dlx fem-dl 26 | ``` 27 | 28 | ### Disclaimer 29 | I am not responsible for any use of this program, please read [FrontendMasters terms of service](https://static.frontendmasters.com/assets/legal/MasterServicesAgreement.pdf) before using this. 30 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const FEM_BASE = 'frontendmasters.com' 2 | 3 | export const FEM_ENDPOINT = `https://${FEM_BASE}` 4 | export const FEM_API_ENDPOINT = `https://api.${FEM_BASE}/v1` 5 | export const FEM_CAPTIONS_ENDPOINT = `https://captions.${FEM_BASE}` 6 | export const PLAYLIST_EXT = 'm3u8' 7 | export const CAPTION_EXT = 'vtt' 8 | export const QUALITY_FORMAT = { 9 | 2160: ['index_2160p_Q10_20mbps'], 10 | 1440: ['index_1440p_Q10_9mbps'], 11 | 1080: ['index_1080_Q10_7mbps', 'index_1080_Q8_7mbps'], 12 | 720: ['index_720_Q8_5mbps'], 13 | 360: ['index_360_Q8_2mbps'] 14 | } 15 | 16 | export const FEM_COURSE_REG = /(:?https?:\/\/)?frontendmasters\.com\/courses\/([^/]+)/ 17 | 18 | export const SUPPORTED_FORMATS = [ 19 | 'mp4', 20 | 'mkv' 21 | ] 22 | 23 | 24 | export const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36' 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fem-dl", 3 | "version": "0.1.7", 4 | "description": "Frontend Masters Downloader", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js" 8 | }, 9 | "bin": { 10 | "fem-dl": "src/index.js" 11 | }, 12 | "type": "module", 13 | "dependencies": { 14 | "@dropb/ffmpeg-progress": "^2.0.0", 15 | "fetch-cookie": "^2.1.0", 16 | "fetch-retry": "^5.0.3", 17 | "ffmpeg-static": "^5.1.0", 18 | "kleur": "^4.1.5", 19 | "node-fetch": "^3.2.10", 20 | "ora": "^6.1.2", 21 | "prompts": "^2.4.2" 22 | }, 23 | "engines": { 24 | "node": ">=16" 25 | }, 26 | "homepage": "https://github.com/abdulrahman1s/fem-dl#readme", 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/abdulrahman1s/fem-dl.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/abdulrahman1s/fem-dl/issues" 33 | }, 34 | "license": "UNLICENSE", 35 | "files": [ 36 | "src/*" 37 | ], 38 | "keywords": [ 39 | "frontendmasters", 40 | "fem", 41 | "downloader", 42 | "cli", 43 | "courses" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/util/common.js: -------------------------------------------------------------------------------- 1 | import defaultFetch from 'node-fetch' 2 | import extendFetchCookie from 'fetch-cookie' 3 | import extendFetchRetry from 'fetch-retry' 4 | import fs from 'node:fs/promises' 5 | import os from 'node:os' 6 | import { join } from 'node:path' 7 | 8 | 9 | 10 | export function formatBytes(bytes, decimals = 2) { 11 | if (!+bytes) return '0 Bytes' 12 | 13 | const k = 1024 14 | const dm = decimals < 0 ? 0 : decimals 15 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 16 | 17 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 18 | 19 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` 20 | } 21 | 22 | 23 | export function isPathExists(path) { 24 | return fs.access(path).then(() => true).catch(() => false) 25 | } 26 | 27 | export async function ensureDir(path) { 28 | if (!await isPathExists(path)) { 29 | await fs.mkdir(path, { recursive: true }) 30 | } 31 | } 32 | 33 | export async function ensureEmpty(path) { 34 | await fs.rm(path, { force: true, recursive: true }).catch(() => null) 35 | await fs.mkdir(path, { recursive: true }) 36 | } 37 | 38 | export function extendedFetch(options, cookies) { 39 | let actualFetch = defaultFetch 40 | 41 | actualFetch = extendFetchCookie(actualFetch, cookies) 42 | actualFetch = extendFetchRetry(actualFetch) 43 | 44 | const fetch = (url, returnType) => actualFetch(url, options).then((res) => { 45 | if (!res.ok) throw res 46 | if (returnType === 'json') return res.json() 47 | if (returnType === 'text') return res.text() 48 | if (returnType === 'binary') return res.arrayBuffer().then(Buffer.from) 49 | return res 50 | }) 51 | 52 | fetch.json = (url) => fetch(url, 'json') 53 | fetch.text = (url) => fetch(url, 'text') 54 | fetch.binary = (url) => fetch(url, 'binary') 55 | fetch.raw = (url) => fetch(url) 56 | 57 | return fetch 58 | } 59 | 60 | 61 | 62 | export function safeJoin(...path) { 63 | const regex = os.platform() === 'win32' ? /[\/\\:*?"<>|]/g : /(\/|\\|:)/g 64 | path[path.length - 1] = path[path.length - 1].replace(regex, '') 65 | return join(...path) 66 | } 67 | 68 | 69 | export { setTimeout as sleep } from 'node:timers/promises' 70 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { FEM_ENDPOINT, FEM_API_ENDPOINT, FEM_CAPTIONS_ENDPOINT, CAPTION_EXT, PLAYLIST_EXT, QUALITY_FORMAT, FEM_COURSE_REG, SUPPORTED_FORMATS, USER_AGENT } from './constants.js' 4 | import { sleep, isPathExists, ensureDir, extendedFetch, safeJoin, formatBytes } from './util/common.js' 5 | import ffmpeg from './util/ffmpeg.js' 6 | import fs from 'node:fs/promises' 7 | import prompts from 'prompts' 8 | import ora from 'ora' 9 | import colors from 'kleur' 10 | import os from 'node:os' 11 | import https, { Agent } from 'node:https' 12 | import extendFetchCookie from 'fetch-cookie' 13 | import { FfmpegProgress } from '@dropb/ffmpeg-progress' 14 | 15 | console.clear() 16 | 17 | https.globalAgent = new Agent({ keepAlive: true }) 18 | 19 | const env = process.env 20 | const exitOnCancel = (state) => { 21 | if (state.aborted) process.nextTick(() => process.exit(0)) 22 | } 23 | 24 | 25 | const { 26 | COURSE_SLUG, 27 | PREFERRED_QUALITY, 28 | DOWNLOAD_DIR, 29 | EXTENSION, 30 | INCLUDE_CAPTION, 31 | TOKEN 32 | } = await prompts([{ 33 | type: 'text', 34 | name: 'COURSE_SLUG', 35 | message: 'The url of the course you want to download', 36 | initial: env['FEM_DL_COURSE_URL'] || 'https://frontendmasters.com/courses/...', 37 | validate: v => !v.endsWith('...') && FEM_COURSE_REG.test(v), 38 | format: v => v.match(FEM_COURSE_REG)[2], 39 | onState: exitOnCancel 40 | }, { 41 | type: 'password', 42 | name: 'TOKEN', 43 | message: 'Paste the value of "fem_auth_mod" cookie (visit: https://frontendmasters.com)', 44 | format: v => decodeURIComponent(v) === v ? encodeURIComponent(v) : v, 45 | initial: env['FEM_DL_COOKIES'], 46 | onState: exitOnCancel 47 | }, { 48 | type: 'select', 49 | name: 'PREFERRED_QUALITY', 50 | message: 'Which stream quality do you prefer?', 51 | choices: [2160, 1440, 1080, 720, 360].map((value) => ({ title: value + 'p', value })), 52 | format: v => QUALITY_FORMAT[v], 53 | onState: exitOnCancel 54 | }, { 55 | type: 'select', 56 | message: 'Which video format you prefer?', 57 | name: 'EXTENSION', 58 | initial: 1, 59 | choices: SUPPORTED_FORMATS.map((value) => ({ title: value, value })), 60 | onState: exitOnCancel 61 | }, { 62 | type: 'confirm', 63 | initial: true, 64 | name: 'INCLUDE_CAPTION', 65 | message: 'Include episode caption?', 66 | onState: exitOnCancel 67 | }, { 68 | type: 'text', 69 | message: 'Download directory path', 70 | name: 'DOWNLOAD_DIR', 71 | initial: env['FEM_DL_DOWNLOAD_PATH'] || safeJoin(os.homedir(), 'Downloads'), 72 | validate: v => isPathExists(v), 73 | onState: exitOnCancel 74 | }]) 75 | 76 | console.clear() 77 | 78 | const headers = { 79 | 'User-Agent': USER_AGENT, 80 | 'Origin': 'https://frontendmasters.com', 81 | 'Referer': 'https://frontendmasters.com/' 82 | } 83 | 84 | const cookies = new extendFetchCookie.toughCookie.CookieJar() 85 | 86 | await cookies.setCookie(`fem_auth_mod=${TOKEN}; Path=/; Domain=frontendmasters.com; HttpOnly; Secure`, FEM_ENDPOINT) 87 | 88 | const fetch = extendedFetch({ 89 | headers, 90 | retries: 5, 91 | retryDelay: 1000 92 | }, cookies) 93 | 94 | const spinner = ora(`Searching for ${COURSE_SLUG}...`).start() 95 | const course = await fetch.json(`${FEM_API_ENDPOINT}/kabuki/courses/${COURSE_SLUG}`) 96 | 97 | if (course.code === 404) { 98 | spinner.fail(`Couldn't find this course "${COURSE_SLUG}"`) 99 | process.exit() 100 | } 101 | 102 | 103 | for (const data of Object.values(course.lessonData)) course.lessonElements[course.lessonElements.findIndex(x => x === data.index)] = { 104 | title: data.title, 105 | slug: data.slug, 106 | url: `${data.sourceBase}/source?f=${PLAYLIST_EXT}`, 107 | index: data.index 108 | } 109 | 110 | const [lessons, totalEpisodes] = course.lessonElements.reduce((acc, cur) => { 111 | if (typeof cur === 'string') (acc[0][cur] = [], acc[2] = cur) 112 | else (acc[0][acc[2]].push(cur), acc[1]++) 113 | return acc 114 | }, [{}, 0, '']) 115 | 116 | 117 | let i = 1, x = 0, QUALITY = PREFERRED_QUALITY, downgradeAlert = false 118 | 119 | const coursePath = safeJoin(DOWNLOAD_DIR, course.title) 120 | 121 | for (const [lesson, episodes] of Object.entries(lessons)) { 122 | const 123 | lessonName = `${i++}. ${lesson}`, 124 | lessonPath = safeJoin(coursePath, lessonName) 125 | 126 | await ensureDir(lessonPath) 127 | 128 | for (const episode of episodes) { 129 | const 130 | fileName = `${episode.index + 1}. ${episode.title}.${EXTENSION}`, 131 | captionPath = safeJoin(lessonPath, `${episode.title}.${CAPTION_EXT}`), 132 | tempFilePath = safeJoin(lessonPath, `${episode.title}.tmp.${EXTENSION}`), 133 | finalFilePath = safeJoin(lessonPath, fileName) 134 | 135 | spinner.text = `[0%] Downloading ${colors.red(lessonName)}/${colors.cyan().bold(fileName)} | Size: 0KB | Remaining: ${++x}/${totalEpisodes}` 136 | 137 | if (await isPathExists(finalFilePath)) { 138 | await sleep(100) 139 | continue 140 | } 141 | 142 | 143 | let { url: m3u8RequestUrl } = await fetch.json(episode.url) 144 | const availableQualities = await fetch.text(m3u8RequestUrl) 145 | 146 | // Automatically downgrade quality when preferred quality not found 147 | const qualities = Object.values(QUALITY_FORMAT) 148 | 149 | while (!QUALITY.some((it) => availableQualities.includes(it)) && availableQualities.includes('#EXTM3U')) { 150 | const index = qualities.findIndex(it => it.every(q => QUALITY.includes(q))) 151 | 152 | QUALITY = qualities[index - 1] 153 | 154 | if (typeof QUALITY === 'undefined') { 155 | console.warn(`This shouldn't happen, please fill an issue`) 156 | console.warn(`Selected Quality: ${PREFERRED_QUALITY}\nCourse: ${COURSE_SLUG}\nm3u8: ${availableQualities}`) 157 | process.exit() 158 | } 159 | } 160 | 161 | if (!downgradeAlert && !PREFERRED_QUALITY.some(it => QUALITY.includes(it))) { 162 | downgradeAlert = true 163 | const [formattedQuality] = Object.entries(QUALITY_FORMAT).find(([_, it]) => it.every(q => QUALITY.includes(q))) 164 | spinner.clear() 165 | console.log(`\nThe preferred quality was not found, downgraded to ${formattedQuality}p`) 166 | } 167 | 168 | const streamQuality = QUALITY.find(it => availableQualities.includes(it)) 169 | const m3u8Url = [...m3u8RequestUrl.split('/').slice(0, -1), `${streamQuality}.${PLAYLIST_EXT}`].join('/') 170 | 171 | headers['Cookie'] = await cookies.getCookieString(m3u8Url) 172 | 173 | const progress = new FfmpegProgress() 174 | 175 | progress.on('data', (data) => { 176 | if (data.percentage && data.size) spinner.text = `[${data.percentage.toFixed()}%] Downloading ${colors.red(lessonName)}/${colors.cyan().bold(fileName)} | Size: ${formatBytes(data.size)} | Remaining: ${x}/${totalEpisodes}` 177 | }) 178 | 179 | await ffmpeg([ 180 | '-y', 181 | '-headers', Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join('\r\n') + '\r\n', 182 | '-i', 183 | m3u8Url, 184 | '-map', '0', 185 | '-c', 186 | 'copy', tempFilePath 187 | ], { 188 | pipe: progress, 189 | silent: true 190 | }) 191 | 192 | 193 | // Merge caption 194 | if (INCLUDE_CAPTION) { 195 | spinner.text = `Downloading captions for ${episode.title}...` 196 | 197 | const captions = await fetch.text(`${FEM_CAPTIONS_ENDPOINT}/assets/courses/${course.datePublished}-${course.slug}/${episode.index}-${episode.slug}.${CAPTION_EXT}`) 198 | 199 | await fs.writeFile(captionPath, captions) 200 | 201 | spinner.text = `Merging captions to ${episode.title}...` 202 | 203 | let args = [] 204 | 205 | switch (EXTENSION) { 206 | case 'mkv': args = [ 207 | '-y', 208 | '-i', tempFilePath, 209 | '-i', captionPath, 210 | '-map', '0', 211 | '-map', '1', 212 | '-c', 213 | 'copy', 214 | finalFilePath 215 | ]; break 216 | 217 | case 'mp4': args = [ 218 | '-y', 219 | '-i', tempFilePath, 220 | '-i', captionPath, 221 | '-c', 222 | 'copy', 223 | '-c:s', 'mov_text', 224 | '-metadata:s:s:0', 'language=eng', 225 | finalFilePath 226 | ]; break; 227 | default: 228 | throw new Error(`Unknown extension found: ${EXTENSION}`) 229 | } 230 | 231 | await ffmpeg(args, { silent: true }) 232 | await fs.rm(captionPath) 233 | } else { 234 | await fs.copyFile(tempFilePath, finalFilePath) 235 | } 236 | 237 | await fs.rm(tempFilePath).catch(() => null) 238 | } 239 | } 240 | 241 | 242 | spinner.succeed('Finished') 243 | --------------------------------------------------------------------------------