├── src ├── types │ ├── download.ts │ ├── Subtitle.ts │ ├── language.ts │ ├── Requester.ts │ ├── langs.d.ts │ └── m3u8.d.ts ├── Errors.ts ├── commands │ ├── cr-dl.ts │ ├── cr-dl-logout.ts │ ├── cr-dl-login.ts │ ├── cr-dl-language.ts │ ├── common.ts │ └── cr-dl-download.ts ├── interfaces │ └── video.ts ├── requester │ ├── cloudscraper.ts │ └── got.ts ├── Utils.ts ├── downloader │ ├── M3uDownloader.ts │ ├── FontDownloader.ts │ ├── VideoMuxer.ts │ └── ListDownloader.ts └── api │ ├── CrDl.ts │ └── MediaVilosPlayer.ts ├── jest.config.js ├── .gitignore ├── tsconfig.json ├── .vscode └── launch.json ├── tests ├── login.test.ts └── download.test.ts ├── .eslintrc.js ├── LICENSE ├── package.json ├── README.md └── README.de.md /src/types/download.ts: -------------------------------------------------------------------------------- 1 | export interface DownloadItem { 2 | url: string; 3 | destination: string; 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | "verbose": false 5 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | cookies.data 4 | cookies.txt 5 | node_modules 6 | *.bat 7 | SubData 8 | VodVidData 9 | VodVid.m3u8 10 | .vs 11 | tmp 12 | -------------------------------------------------------------------------------- /src/types/Subtitle.ts: -------------------------------------------------------------------------------- 1 | export interface LocalSubtitle { 2 | title: string; 3 | path: string; 4 | language: string; 5 | default: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/language.ts: -------------------------------------------------------------------------------- 1 | 2 | export const languages = ["enUS", "enGB", "esLA", "esES", "ptBR", "ptPT", "frFR", "deDE", "arME", "itIT", "ruRU"] as const; 3 | export type Language = typeof languages[number]; 4 | //export type Language = "enUS" | "enGB" | "esLA" | "esES" | "ptBR" | "ptPT" | "frFR" | "deDE" | "arME" | "itIT" | "ruRU"; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "lib", 6 | "strict": true, 7 | "declaration": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": [ 11 | "src/**/*.ts" 12 | ], 13 | "exclude": [ 14 | "node_modules" 15 | ] 16 | } -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | export class CrDlError extends Error { 2 | } 3 | CrDlError.prototype.name = "CrDlException"; 4 | 5 | export class UserInputError extends CrDlError { 6 | } 7 | UserInputError.prototype.name = "UserInputException"; 8 | 9 | export class RuntimeError extends CrDlError { 10 | } 11 | RuntimeError.prototype.name = "RuntimeException"; 12 | 13 | export class NetworkError extends CrDlError { 14 | } 15 | NetworkError.prototype.name = "NetworkException"; 16 | 17 | export class CloudflareError extends CrDlError { 18 | } 19 | CloudflareError.prototype.name = "CloudflareException"; 20 | -------------------------------------------------------------------------------- /src/commands/cr-dl.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { program } from "commander"; 3 | import { login } from "./cr-dl-login"; 4 | import { logout } from "./cr-dl-logout"; 5 | import { language } from "./cr-dl-language"; 6 | import { download } from "./cr-dl-download"; 7 | 8 | program.version("4.0.1") 9 | .addCommand(login) 10 | .addCommand(logout) 11 | .addCommand(language) 12 | .addCommand(download); 13 | 14 | 15 | /*gf.command("login", "Login into CR. Cookies are stores in "cookies.data".") 16 | .command("logout", "Logs out of CR.") 17 | .action((a) => { console.log(a) })*/ 18 | 19 | program.parseAsync(process.argv).catch(console.log); 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Attach", 8 | "port": 9999, 9 | "skipFiles": [ 10 | "/**" 11 | ] 12 | }, 13 | { 14 | "name": "Debug Jest Tests", 15 | "type": "node", 16 | "request": "launch", 17 | "runtimeArgs": [ 18 | "--inspect-brk", 19 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 20 | "--runInBand" 21 | ], 22 | "console": "integratedTerminal", 23 | "internalConsoleOptions": "neverOpen", 24 | "port": 9229 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/types/Requester.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from "stream"; 2 | import { Response } from "got/dist/source"; 3 | export interface Requester { 4 | get: (url: string) => Promise<{ 5 | body: Buffer; 6 | url: string; 7 | }>; 8 | post: (url: string, formdata?: Record) => Promise<{ 9 | body: Buffer; 10 | }>; 11 | } 12 | 13 | export interface OnResponse { 14 | on(name: "response", listener: (response: Response) => void): void; 15 | } 16 | 17 | export interface RequesterCdn { 18 | stream: (url: string) => Readable & OnResponse; 19 | get: (url: string) => Promise<{ 20 | body: Buffer; 21 | url: string; 22 | }>; 23 | } 24 | -------------------------------------------------------------------------------- /src/types/langs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "langs" { 2 | namespace langs { 3 | interface Language { 4 | "name": string; 5 | "local": string; 6 | "1": string; 7 | "2": string; 8 | "2T": string; 9 | "2B": string; 10 | "3": string; 11 | } 12 | type Type = "1" | "2" | "2B" | "2T" | "3" 13 | 14 | function all(): Language[]; 15 | function has(crit: Type, val: string): boolean; 16 | function codes(type: Type): string[]; 17 | function names(local: boolean): string[]; 18 | function where(crit: Type, val: string): Language; 19 | } 20 | export = langs 21 | } 22 | -------------------------------------------------------------------------------- /src/interfaces/video.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../types/language"; 2 | 3 | export interface VideoInfo { 4 | getSubtitles(): Promise; 5 | getDefaultLanguage(): Promise; 6 | getAvailableResolutions(hardSubLang: Language | null): Promise; 7 | getStreams(resolution: number, hardSubLang: Language | null): Promise; 8 | getEpisodeTitle(): Promise; 9 | getSeriesTitle(): Promise; 10 | getSeasonTitle(): Promise; 11 | getEpisodeNumber(): Promise; 12 | isRegionBlocked(): Promise; 13 | isPremiumBlocked(): Promise; 14 | } 15 | 16 | export interface SubtitleInfo { 17 | getTitle(): Promise; 18 | getLanguage(): Promise; 19 | getLanguageISO6392T(): Promise; 20 | getData(): Promise; 21 | isDefault(): Promise; 22 | } 23 | export interface StreamInfo { 24 | getHardsubLanguage(): Language; 25 | getAudioLanguage(): Language; 26 | getWidth(): number; 27 | getHeight(): number; 28 | getUrl(): string; 29 | } 30 | -------------------------------------------------------------------------------- /tests/login.test.ts: -------------------------------------------------------------------------------- 1 | import { CrDl } from "../src/api/CrDl"; 2 | import { Language } from "../src/types/language"; 3 | 4 | test("CrDl login/logout", async () => { 5 | jest.setTimeout(100000); 6 | const crdl = new CrDl(); 7 | 8 | expect(await crdl.isLoggedIn()).toBe(false); 9 | const user = await crdl.login("GeliebtLebhafterLeopard@spam.care", "coat-unadorned-glacier"); 10 | expect(user.username).toBe("GeliebtLebhafterLeopard"); 11 | expect(user.email).toBe("GeliebtLebhafterLeopard@spam.care"); 12 | expect(await crdl.isLoggedIn()).toBe(true); 13 | await crdl.logout(); 14 | expect(await crdl.isLoggedIn()).toBe(false); 15 | }); 16 | 17 | 18 | test("CrDl setLang/getLang", async () => { 19 | jest.setTimeout(100000); 20 | const crdl = new CrDl(); 21 | const languages: Language[] = ["enUS", "enGB", "esLA", "esES", "ptBR", "ptPT", "frFR", "deDE", "arME", "itIT", "ruRU"]; 22 | 23 | expect(typeof (await crdl.getLang())).toBe("string"); 24 | 25 | for (const lang of languages) { 26 | await crdl.setLang(lang); 27 | expect(await crdl.getLang()).toBe(lang); 28 | } 29 | }); 30 | 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": 2018, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "@typescript-eslint" 22 | ], 23 | "rules": { 24 | "indent": [ 25 | "error", 26 | 4 27 | ], 28 | "linebreak-style": [ 29 | "error", 30 | "windows" 31 | ], 32 | "quotes": [ 33 | "error", 34 | "double" 35 | ], 36 | "semi": [ 37 | "error", 38 | "always" 39 | ], 40 | "no-use-before-define": "off", 41 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false }], 42 | "eol-last": ["error", "always"] 43 | } 44 | }; -------------------------------------------------------------------------------- /src/commands/cr-dl-logout.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import { loadCookies, getRequester, saveCookies } from "./common"; 4 | import { CrDl } from "../api/CrDl"; 5 | import { UserInputError } from "../Errors"; 6 | 7 | export const logout = new Command(); 8 | 9 | logout 10 | .name("logout") 11 | .description("Logs out of CR.") 12 | .option("--proxy ", "HTTP proxy used to access Crunchyroll.") 13 | .option("--cookies ", "File to read cookies from and dump cookie jar in", "cookies.txt") 14 | .action(async function (cmdObj) { 15 | 16 | const options: { proxy?: string; cookies: string } = { proxy: cmdObj.proxy, cookies: cmdObj.cookies }; 17 | 18 | loadCookies(options); 19 | const requester = getRequester(options); 20 | const crDl = new CrDl({ requester: requester }); 21 | 22 | try { 23 | await crDl.logout(); 24 | console.log("Successfully logged out!"); 25 | } catch (error) { 26 | if (error instanceof UserInputError) { 27 | console.log(error.message); // Dont print stacktrace 28 | } else { 29 | console.log(error); 30 | } 31 | } 32 | saveCookies(options); 33 | }); 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@derkraken/cr-dl", 3 | "version": "4.0.1", 4 | "description": "A tool to quickly download anime from Crunchyroll", 5 | "main": "lib/api/CrDl.js", 6 | "types": "lib/api/CrDl.d.ts", 7 | "keywords": [ 8 | "crunchyroll", 9 | "anime", 10 | "anime-downloader", 11 | "crunchyroll-downloader", 12 | "weeb", 13 | "download", 14 | "downloader" 15 | ], 16 | "files": [ 17 | "lib" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/DasKraken/CR-dl.git" 22 | }, 23 | "scripts": { 24 | "build": "tsc --declaration", 25 | "watch": "tsc --declaration --watch", 26 | "test": "jest", 27 | "eslint": "eslint \"src/**/*.ts\"" 28 | }, 29 | "author": "DasKraken", 30 | "license": "Unlicense", 31 | "bin": { 32 | "cr-dl": "./lib/commands/cr-dl.js" 33 | }, 34 | "engines": { 35 | "node": ">=12.0.0" 36 | }, 37 | "dependencies": { 38 | "@types/diacritics": "^1.3.1", 39 | "async": "^3.2.0", 40 | "cli-progress": "^3.8.1", 41 | "cloudscraper": "^4.6.0", 42 | "commander": "^5.0.0", 43 | "diacritics": "^1.3.0", 44 | "got": "^10.7.0", 45 | "html-entities": "^1.3.1", 46 | "langs": "^2.0.0", 47 | "m3u8": "git+https://git@github.com/tedconf/node-m3u8.git", 48 | "pretty-bytes": "^5.1.0", 49 | "read": "^1.0.7", 50 | "request": "^2.88.2", 51 | "string-format": "^2.0.0", 52 | "tunnel": "0.0.6" 53 | }, 54 | "devDependencies": { 55 | "@types/async": "^3.2.1", 56 | "@types/cli-progress": "^3.4.2", 57 | "@types/jest": "^25.2.1", 58 | "@types/node": "^14.0.11", 59 | "@types/read": "0.0.28", 60 | "@types/request": "^2.48.4", 61 | "@types/request-promise": "^4.1.46", 62 | "@types/string-format": "^2.0.0", 63 | "@types/tunnel": "0.0.1", 64 | "@typescript-eslint/eslint-plugin": "^3.1.0", 65 | "@typescript-eslint/parser": "^3.1.0", 66 | "eslint": "^7.2.0", 67 | "jest": "^26.0.1", 68 | "ts-jest": "^26.1.0", 69 | "typescript": "^3.8.3" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/requester/cloudscraper.ts: -------------------------------------------------------------------------------- 1 | import cloudscraper from "cloudscraper"; 2 | import request from "request"; 3 | import { Requester } from "../types/Requester"; 4 | 5 | 6 | const agentOptions = { 7 | ciphers: [ 8 | "TLS_AES_128_GCM_SHA256", 9 | "TLS_AES_256_GCM_SHA384", 10 | "TLS_CHACHA20_POLY1305_SHA256", 11 | "ECDHE-ECDSA-AES128-GCM-SHA256", 12 | "ECDHE-RSA-AES128-GCM-SHA256", 13 | "ECDHE-ECDSA-AES256-GCM-SHA384", 14 | "ECDHE-RSA-AES256-GCM-SHA384", 15 | "ECDHE-ECDSA-CHACHA20-POLY1305", 16 | "ECDHE-RSA-CHACHA20-POLY1305", 17 | "ECDHE-RSA-AES128-SHA", 18 | "ECDHE-RSA-AES256-SHA", 19 | "AES128-GCM-SHA256", 20 | "AES256-GCM-SHA384", 21 | "AES128-SHA", 22 | "AES256-SHA", 23 | "DES-CBC3-SHA" 24 | ].join(":"), 25 | ecdhCurve: "prime256v1" 26 | }; 27 | 28 | export default function (jar: request.CookieJar, proxy?: string): Requester { 29 | return { 30 | get: (url: string): Promise<{ body: Buffer; url: string }> => { 31 | return new Promise((resolve, reject) => { 32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-ignore 34 | cloudscraper({ method: "GET", url: url, jar: jar, encoding: null, resolveWithFullResponse: true, proxy: proxy, agentOptions: agentOptions }).then((r: request.Response) => { 35 | resolve({ body: r.body, url: r.request.uri.href as string }); 36 | }).catch(reject); 37 | 38 | }); 39 | }, 40 | post: (url: string, formData?: Record): Promise<{ body: Buffer }> => { 41 | return new Promise((resolve, reject) => { 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-ignore 44 | cloudscraper({ method: "POST", url: url, jar: jar, encoding: null, resolveWithFullResponse: true, proxy: proxy, formData: formData, agentOptions: agentOptions }).then((r: request.Response) => { 45 | resolve({ body: r.body }); 46 | }).catch(reject); 47 | 48 | }); 49 | } 50 | }; 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/cr-dl-login.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import read from "read"; 4 | import { loadCookies, getRequester, saveCookies } from "./common"; 5 | import { CrDl } from "../api/CrDl"; 6 | import { UserInputError } from "../Errors"; 7 | 8 | export const login = new Command(); 9 | 10 | login 11 | .name("login") 12 | .description("Login into CR. Cookies are stores in \"cookies.data\".") 13 | .arguments("[username] [password]") 14 | .option("--proxy ", "HTTP proxy used to access Crunchyroll.") 15 | .option("--cookies ", "File to read cookies from and dump cookie jar in", "cookies.txt") 16 | .action(async function (username: string | undefined, password: string | undefined, cmdObj) { 17 | 18 | const options: { proxy?: string; cookies: string } = { proxy: cmdObj.proxy, cookies: cmdObj.cookies }; 19 | 20 | loadCookies(options); 21 | const requester = getRequester(options); 22 | const crDl = new CrDl({ requester: requester }); 23 | const user = await crDl.getLoggedIn(); 24 | if (user) { 25 | console.log(`Already logged in as ${user.username}!`); 26 | saveCookies(options); 27 | return; 28 | } 29 | 30 | if (!username) { 31 | username = await new Promise((resolve, reject) => { 32 | read({ prompt: "Enter username: " }, (er, user: string) => { if (er) { reject(er); } else { resolve(user); } }); 33 | }); 34 | 35 | } 36 | if (!password) { 37 | password = await new Promise((resolve, reject) => { 38 | read({ prompt: "Enter password: ", silent: true }, (er, pass: string) => { if (er) { reject(er); } else { resolve(pass); } }); 39 | }); 40 | } 41 | 42 | try { 43 | const user = await crDl.login(username, password); 44 | console.log(`Successfully logged in as ${user.username}!`); 45 | } catch (error) { 46 | if (error instanceof UserInputError) { 47 | console.log(error.message); // Dont print stacktrace 48 | } else { 49 | console.log(error); 50 | } 51 | } 52 | saveCookies(options, true); 53 | }); 54 | -------------------------------------------------------------------------------- /src/commands/cr-dl-language.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import { loadCookies, getRequester, saveCookies } from "./common"; 4 | import { CrDl } from "../api/CrDl"; 5 | import { UserInputError } from "../Errors"; 6 | import { languages, Language } from "../types/language"; 7 | 8 | export const language = new Command(); 9 | 10 | language 11 | .name("language").alias("lang") 12 | .description("Get or set the language of CR and metadata. (Note 1: It doesn't change default subtitle language. Note 2: Videos that aren't available in selected language may not work). Available options are: " + languages.join(", ")) 13 | .arguments("[language]") 14 | .option("--proxy ", "HTTP proxy used to access Crunchyroll.") 15 | .option("--cookies ", "File to read cookies from and dump cookie jar in", "cookies.txt") 16 | .action(async function (language: Language | undefined, cmdObj) { 17 | 18 | const options: { proxy?: string; cookies: string } = { proxy: cmdObj.proxy, cookies: cmdObj.cookies }; 19 | loadCookies(options); 20 | const requester = getRequester(options); 21 | const crDl = new CrDl({ requester: requester }); 22 | 23 | 24 | 25 | if (language) { 26 | if (!languages.includes(language)) { 27 | console.log("Unknown language. Must be one of: " + languages.join(", ")); 28 | } else { 29 | // Set language 30 | try { 31 | await crDl.setLang(language); 32 | } catch (error) { 33 | if (error instanceof UserInputError) { 34 | console.log(error.message); // Dont print stacktrace 35 | } else { 36 | console.log(error); 37 | } 38 | } 39 | } 40 | } 41 | 42 | // Get language 43 | try { 44 | const language = await crDl.getLang(); 45 | console.log("Selected Language: " + language); 46 | } catch (error) { 47 | if (error instanceof UserInputError) { 48 | console.log(error.message); // Dont print stacktrace 49 | } else { 50 | console.log(error); 51 | } 52 | } 53 | saveCookies(options, true); 54 | }); 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import {remove as removeDiacritics} from "diacritics"; 3 | import m3u8, { M3U } from "m3u8"; 4 | 5 | 6 | export function pad(num: string | number, size: number): string { 7 | let s = num.toString(); 8 | while (s.length < size) s = "0" + s; 9 | return s; 10 | } 11 | 12 | export function deleteFolderRecursive(path: fs.PathLike): void { 13 | if (fs.existsSync(path)) { 14 | fs.readdirSync(path).forEach(function (file) { 15 | const curPath = path + "/" + file; 16 | if (fs.lstatSync(curPath).isDirectory()) { // recurse 17 | deleteFolderRecursive(curPath); 18 | } else { // delete file 19 | fs.unlinkSync(curPath); 20 | } 21 | }); 22 | fs.rmdirSync(path); 23 | } 24 | } 25 | 26 | export function toFilename(str: string): string { 27 | return str.replace(/[\\/:*?"<>|]+/g, "_"); 28 | } 29 | 30 | export function formatScene(s: string): string { 31 | s = removeDiacritics(s); 32 | s = s.replace(/[^A-Za-z0-9._-]/g, "."); 33 | s = s.replace(/\.{2,}/g, "."); 34 | s = s.replace(/-{2,}/g, "-"); 35 | s = s.replace(/_{2,}/g, "_"); 36 | s = s.replace(/[._-]{2,}/g, "."); 37 | s = s.replace(/^[._-]/, ""); 38 | s = s.replace(/[._-]$/, ""); 39 | return s; 40 | } 41 | 42 | export function makeid(length: number): string { 43 | let result = ""; 44 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; 45 | const charactersLength = characters.length; 46 | for (let i = 0; i < length; i++) { 47 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 48 | } 49 | return result; 50 | } 51 | 52 | 53 | 54 | 55 | 56 | export function parseM3U(data: string): Promise { 57 | return new Promise((resolve, reject) => { 58 | const parser = m3u8.createStream(); 59 | parser.on("m3u", function (m3u: M3U) { 60 | resolve(m3u); 61 | // fully parsed m3u file 62 | }); 63 | parser.on("error", function (err: Error) { 64 | reject(err); 65 | }); 66 | parser.write(data); 67 | parser.end(); 68 | }); 69 | } 70 | 71 | export function matchAll(str: string, regex: RegExp): RegExpMatchArray[] { 72 | let m: RegExpExecArray | null; 73 | 74 | const results: RegExpExecArray[] = []; 75 | 76 | while ((m = regex.exec(str)) !== null) { 77 | // This is necessary to avoid infinite loops with zero-width matches 78 | if (m.index === regex.lastIndex) { 79 | regex.lastIndex++; 80 | } 81 | 82 | // The result can be accessed through the `m`-variable. 83 | results.push(m); 84 | } 85 | return results; 86 | } 87 | -------------------------------------------------------------------------------- /src/requester/got.ts: -------------------------------------------------------------------------------- 1 | import got, { AgentByProtocol } from "got"; 2 | import { Readable } from "stream"; 3 | import { RequesterCdn } from "../types/Requester"; 4 | import * as tunnel from "tunnel"; 5 | import * as util from "util"; 6 | import * as https from "https"; 7 | import { UserInputError } from "../Errors"; 8 | import { URL } from "url"; 9 | 10 | function getProxyAuth(proxyURL: URL): string | undefined { 11 | if (proxyURL.username.length > 0) { 12 | return decodeURIComponent(proxyURL.username) + ":" + decodeURIComponent(proxyURL.password); 13 | } else { 14 | return undefined; 15 | } 16 | } 17 | 18 | export default function (proxy?: string, retry?: number): RequesterCdn { 19 | const proxyURL = proxy ? new URL(proxy) : undefined; 20 | let agent: AgentByProtocol | undefined; 21 | if (proxyURL) { 22 | if (proxyURL.protocol == "http:") { 23 | agent = { 24 | http: tunnel.httpOverHttp({ 25 | proxy: { 26 | host: proxyURL.hostname, 27 | port: parseInt(proxyURL.port) || 80, 28 | proxyAuth: getProxyAuth(proxyURL) 29 | 30 | } 31 | }), 32 | https: tunnel.httpsOverHttp({ 33 | proxy: { 34 | host: proxyURL.hostname, 35 | port: parseInt(proxyURL.port) || 80, 36 | proxyAuth: getProxyAuth(proxyURL) 37 | 38 | } 39 | }) as https.Agent 40 | }; 41 | } else if (proxyURL.protocol == "https:") { 42 | agent = { 43 | http: tunnel.httpOverHttps({ 44 | proxy: { 45 | host: proxyURL.hostname, 46 | port: parseInt(proxyURL.port) || 443, 47 | proxyAuth: getProxyAuth(proxyURL) 48 | } 49 | }), 50 | https: tunnel.httpsOverHttps({ 51 | proxy: { 52 | host: proxyURL.hostname, 53 | port: parseInt(proxyURL.port) || 443, 54 | proxyAuth: getProxyAuth(proxyURL) 55 | } 56 | }) as https.Agent 57 | }; 58 | } else { 59 | throw new UserInputError("Unsupported proxy protocol: " + util.inspect(proxyURL.protocol)); 60 | } 61 | } 62 | return { 63 | stream: (url: string): Readable => { 64 | return got.stream(url, { agent, retry, timeout: 15000 }); 65 | }, 66 | get: (url: string): Promise<{ body: Buffer; url: string }> => { 67 | return got.get(url, { responseType: "buffer", agent, retry, timeout: 15000 }); 68 | } 69 | }; 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/downloader/M3uDownloader.ts: -------------------------------------------------------------------------------- 1 | import { RequesterCdn } from "../types/Requester"; 2 | import { DownloadItem } from "../types/download"; 3 | import * as path from "path"; 4 | import { RuntimeError } from "../Errors"; 5 | import { parseM3U } from "../Utils"; 6 | import { M3U } from "m3u8"; 7 | 8 | 9 | function getFilenameFromURI(uri: string): string { 10 | const m = uri.match(/\/([^/?]+)(?:\?.*)?$/); 11 | if (!m) throw new Error("No filename found"); 12 | return decodeURI(m[1]); 13 | } 14 | 15 | 16 | export class M3uDownloader { 17 | _videoFiles?: DownloadItem[]; 18 | _keyFile?: DownloadItem; 19 | _m3u?: M3U; 20 | 21 | // {destination}/file.m3u8 - {destination}/{subFolder}/video.ts 22 | async load(url: string, destination: string, subFolder: string, requester: RequesterCdn): Promise { 23 | const m3u = (await requester.get(url)).body.toString(); 24 | this._m3u = await parseM3U(m3u); 25 | if (this._m3u.items.StreamItem.length > 0) { // Stream List 26 | return await this.load(this._m3u.items.StreamItem[0].properties.uri ?? "", destination, subFolder, requester); 27 | } else { 28 | const dataDir: string = path.join(destination, subFolder); 29 | this._videoFiles = []; 30 | this._keyFile = undefined; 31 | 32 | if (this._m3u.properties["EXT-X-KEY"]) { 33 | const keyURIMatch = this._m3u.properties["EXT-X-KEY"].match(/URI="([^"]+)"/); 34 | if (!keyURIMatch) throw new RuntimeError("No key URI found"); 35 | const keyURI = keyURIMatch[1]; 36 | 37 | const keyFile = path.join(dataDir, getFilenameFromURI(keyURI)); 38 | const keyFileRelative = path.join(subFolder, getFilenameFromURI(keyURI)).replace(/\\/g, "/"); 39 | 40 | this._keyFile = { 41 | url: keyURI, 42 | destination: keyFile 43 | }; 44 | this._m3u.properties["EXT-X-KEY"] = this._m3u.properties["EXT-X-KEY"].replace(keyURI, keyFileRelative); 45 | 46 | } else { 47 | throw new RuntimeError("No key found. This should never happen"); 48 | } 49 | for (const item of this._m3u.items.PlaylistItem) { 50 | const filename: string = getFilenameFromURI(item.properties.uri as string); 51 | const uri: string = item.properties.uri ?? ""; 52 | item.properties.uri = path.join(subFolder, filename).replace(/\\/g, "/"); 53 | this._videoFiles.push({ 54 | url: uri, 55 | destination: path.join(dataDir, filename) 56 | }); 57 | } 58 | } 59 | } 60 | getVideoFiles(): DownloadItem[] { 61 | if (!this._videoFiles) throw new RuntimeError("Tried to get video files before loading"); 62 | return this._videoFiles; 63 | } 64 | getKeyFile(): DownloadItem | undefined { 65 | return this._keyFile; 66 | } 67 | getModifiedM3u(): string { 68 | if (!this._m3u) throw new RuntimeError("Tried to get m3u before loading"); 69 | return this._m3u.toString(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/types/m3u8.d.ts: -------------------------------------------------------------------------------- 1 | declare module "m3u8" { 2 | import { Stream } from "stream"; 3 | namespace M3uParser { 4 | interface AttributeList { 5 | attributes: { 6 | "audio"?: string; 7 | "autoselect"?: boolean; 8 | "bandwidth"?: number; 9 | "average-bandwidth"?: number; 10 | "frame-rate"?: number; 11 | "byterange"?: string; 12 | "channels"?: string; 13 | "codecs"?: string; 14 | "default"?: boolean; 15 | "duration"?: number; 16 | "forced"?: boolean; 17 | "group-id"?: string; 18 | "language"?: string; 19 | "name"?: string; 20 | "program-id"?: number; 21 | "resolution"?: [number, number]; 22 | "subtitles"?: string; 23 | "title"?: string; 24 | "type"?: string; 25 | "uri"?: string; 26 | "video"?: string; 27 | }; 28 | get(key: K): AttributeList["attributes"][K]; 29 | getCoerced(key: K): string; 30 | set(key: K, value: string): void; 31 | } 32 | interface Item { 33 | attributes: AttributeList; 34 | properties: { 35 | byteRange: null | string; 36 | daiPlacementOpportunity: null | string; 37 | date: null | Date; 38 | discontinuity: null | boolean; 39 | duration: null | number; 40 | title: null | string; 41 | uri: null | string; 42 | }; 43 | get(key: K): Item["properties"][K]; 44 | get(key: K): AttributeList["attributes"][K]; 45 | 46 | set(key: K, value: Item["properties"][K]): void; 47 | set(key: K, value: string): void; 48 | } 49 | 50 | type PlaylistItem = Item 51 | type StreamItem = Item 52 | type IframeStreamItem = Item 53 | type MediaItem = Item 54 | 55 | 56 | 57 | interface PropertiesOptions { 58 | allowCache: string; 59 | iframesOnly: boolean; 60 | independentSegmentsindependentSegments: boolean; 61 | mediaSequence: number; 62 | playlistType: "EVENT" | "VOD"; 63 | targetDuration: number; 64 | version: number; 65 | } 66 | 67 | type Properties = Record & PropertiesOptions; 68 | 69 | interface M3U { 70 | items: { 71 | PlaylistItem: PlaylistItem[]; 72 | StreamItem: StreamItem[]; 73 | IframeStreamItem: IframeStreamItem[]; 74 | MediaItem: MediaItem[]; 75 | }; 76 | properties: Properties; 77 | 78 | get(key: K): Properties[K]; 79 | set(key: K, value: string): void; 80 | //... 81 | } 82 | } 83 | class M3uParser extends Stream { 84 | static createStream(): M3uParser; 85 | on(name: "m3u", listener: (m3u: M3uParser.M3U) => void): this; 86 | on(name: "error", listener: (m3u: Error) => void): this; 87 | write(chunk: string | Buffer): true; 88 | end(): void; 89 | } 90 | 91 | export = M3uParser; 92 | 93 | } 94 | -------------------------------------------------------------------------------- /tests/download.test.ts: -------------------------------------------------------------------------------- 1 | import { CrDl } from "../src/api/CrDl"; 2 | 3 | test("CrDl getEpisodesFromUrl", async () => { 4 | jest.setTimeout(100000); 5 | const crdl = new CrDl(); 6 | await crdl.setLang("deDE"); 7 | const seasons = await crdl.getEpisodesFormUrl("https://www.crunchyroll.com/new-game"); 8 | //console.log(JSON.stringify(seasons)); 9 | 10 | expect(seasons).toStrictEqual([ 11 | { 12 | "name": "(OmU) NEW GAME!", 13 | "episodes": [{ "url": "/de/new-game/episode-1-it-actually-feels-like-i-started-my-job-715393", "name": "(OmU) NEW GAME! Folge 1", "number": "1" }, { "url": "/de/new-game/episode-2-so-this-is-an-adult-drinking-party-715395", "name": "(OmU) NEW GAME! Folge 2", "number": "2" }, { "url": "/de/new-game/episode-3-what-happens-if-im-late-to-work-715397", "name": "(OmU) NEW GAME! Folge 3", "number": "3" }, { "url": "/de/new-game/episode-4-the-first-paycheck-715399", "name": "(OmU) NEW GAME! Folge 4", "number": "4" }, { "url": "/de/new-game/episode-5-thats-how-many-nights-we-have-to-stay-over-715401", "name": "(OmU) NEW GAME! Folge 5", "number": "5" }, { "url": "/de/new-game/episode-6-like-the-release-is-canceled-715403", "name": "(OmU) NEW GAME! Folge 6", "number": "6" }, { "url": "/de/new-game/episode-7-please-train-the-new-hires-properly-715405", "name": "(OmU) NEW GAME! Folge 7", "number": "7" }, { "url": "/de/new-game/episode-8-its-summer-break-715407", "name": "(OmU) NEW GAME! Folge 8", "number": "8" }, { "url": "/de/new-game/episode-9-do-we-have-to-come-into-work-715409", "name": "(OmU) NEW GAME! Folge 9", "number": "9" }, { "url": "/de/new-game/episode-10-full-time-employment-is-a-loophole-in-the-law-to-make-wages-lower-715411", "name": "(OmU) NEW GAME! Folge 10", "number": "10" }, { "url": "/de/new-game/episode-11-there-were-leaked-pictures-of-the-game-on-the-internet-yesterday-715413", "name": "(OmU) NEW GAME! Folge 11", "number": "11" }, { "url": "/de/new-game/episode-12-one-of-my-dreams-came-true-715415", "name": "(OmU) NEW GAME! Folge 12", "number": "12" }], 14 | "isLanguageUnavailable": false, 15 | "isRegionBlocked": false 16 | }, 17 | { 18 | "name": "(OmU) NEW GAME!!", 19 | "episodes": [{ "url": "/de/new-game/episode-1-of-all-the-embarrassing-things-to-be-caught-doing-742151", "name": "(OmU) NEW GAME!! Folge 1", "number": "1" }, { "url": "/de/new-game/episode-2-this-is-just-turning-into-cos-purr-lay-742153", "name": "(OmU) NEW GAME!! Folge 2", "number": "2" }, { "url": "/de/new-game/episode-3-ooh-im-so-embarrassed-742155", "name": "(OmU) NEW GAME!! Folge 3", "number": "3" }, { "url": "/de/new-game/episode-4-how-dense-can-you-be-742157", "name": "(OmU) NEW GAME!! Folge 4", "number": "4" }, { "url": "/de/new-game/episode-5-hey-dont-touch-me-there-742159", "name": "(OmU) NEW GAME!! Folge 5", "number": "5" }, { "url": "/de/new-game/episode-6-wow-its-so-amazing-742161", "name": "(OmU) NEW GAME!! Folge 6", "number": "6" }, { "url": "/de/new-game/episode-7-im-sensing-a-very-intense-gaze-742163", "name": "(OmU) NEW GAME!! Folge 7", "number": "7" }, { "url": "/de/new-game/episode-8-im-telling-you-i-want-a-maid-caf-742165", "name": "(OmU) NEW GAME!! Folge 8", "number": "8" }, { "url": "/de/new-game/episode-9-at-least-put-a-shirt-on-742167", "name": "(OmU) NEW GAME!! Folge 9", "number": "9" }, { "url": "/de/new-game/episode-10-its-gonna-really-break-the-immersion-742169", "name": "(OmU) NEW GAME!! Folge 10", "number": "10" }, { "url": "/de/new-game/episode-11-whats-hidden-in-your-heart-742171", "name": "(OmU) NEW GAME!! Folge 11", "number": "11" }, { "url": "/de/new-game/episode-12-make-sure-you-buy-it-742173", "name": "(OmU) NEW GAME!! Folge 12", "number": "12" }], 20 | "isLanguageUnavailable": false, 21 | "isRegionBlocked": false 22 | }, 23 | { 24 | "name": "NEW GAME! (Russian)", 25 | "episodes": [], 26 | "isLanguageUnavailable": false, 27 | "isRegionBlocked": true 28 | }, 29 | { 30 | "name": "NEW GAME!! (Russian)", 31 | "episodes": [], 32 | "isLanguageUnavailable": false, 33 | "isRegionBlocked": true 34 | }, 35 | { 36 | "name": "(EN) NEW GAME!", 37 | "episodes": [], 38 | "isLanguageUnavailable": true, 39 | "isRegionBlocked": false 40 | }, 41 | { 42 | "name": "(EN) NEW GAME!!", 43 | "episodes": [], 44 | "isLanguageUnavailable": true, 45 | "isRegionBlocked": false 46 | } 47 | ]); 48 | }); 49 | 50 | /*test("CrDl getEpisodesFromUrl", async () => { 51 | 52 | }); 53 | */ 54 | -------------------------------------------------------------------------------- /src/commands/common.ts: -------------------------------------------------------------------------------- 1 | import got from "../requester/got"; 2 | import cloudscraper from "../requester/cloudscraper"; 3 | import * as request from "request"; 4 | import * as fs from "fs"; 5 | import { Requester, RequesterCdn } from "../types/Requester"; 6 | 7 | const cookies = request.jar(); 8 | 9 | 10 | interface ToughCookie { 11 | key: string; 12 | value: string; 13 | domain: string; 14 | path: string; 15 | secure: boolean; 16 | expires?: string; 17 | httpOnly: boolean; 18 | hostOnly: boolean; 19 | lastAccess: string; 20 | creation: string; 21 | } 22 | 23 | export function loadCookies(options: { cookies: string }): void { 24 | 25 | if (fs.existsSync(options.cookies)) { 26 | const fileData = fs.readFileSync(options.cookies, { encoding: "utf8" }).toString(); 27 | try { 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-ignore 30 | cookies._jar._importCookiesSync(JSON.parse(fileData)); 31 | return; 32 | } catch (e) { 33 | // empty 34 | } 35 | if (/^#(?: Netscape)? HTTP Cookie File/.test(fileData)) { 36 | const now = (new Date()).toISOString(); 37 | const cookieList: ToughCookie[] = fileData 38 | .split("\n") 39 | .map(line => line.split("\t").map(s => s.trim())) 40 | .filter(line => line.length === 7) 41 | .filter(cookie => cookie[0].endsWith("crunchyroll.com")) 42 | .map(cookie => ({ 43 | key: decodeURIComponent(cookie[5]), 44 | value: decodeURIComponent(cookie[6]), 45 | domain: cookie[0], 46 | path: cookie[2], 47 | secure: cookie[3] === "TRUE", 48 | expires: (new Date(parseInt(cookie[4]) * 1000)).toISOString(), 49 | httpOnly: false, 50 | hostOnly: true, 51 | lastAccess: now, 52 | creation: now 53 | })) 54 | .map(cookie => { 55 | if (cookie.domain.substr(0, 10) === "#HttpOnly_") { 56 | cookie.httpOnly = true; 57 | cookie.domain = cookie.domain.substr(10); 58 | } 59 | return cookie; 60 | }) 61 | .map(cookie => { 62 | if (cookie.domain.startsWith(".")) { 63 | cookie.domain = cookie.domain.substr(1); 64 | cookie.hostOnly = false; 65 | } 66 | return cookie; 67 | }); 68 | const out = { 69 | "version": "tough-cookie@2.3.4", 70 | "storeType": "MemoryCookieStore", 71 | "rejectPublicSuffixes": true, 72 | "cookies": cookieList 73 | }; 74 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 75 | // @ts-ignore 76 | cookies._jar._importCookiesSync(out); 77 | return; 78 | } 79 | } 80 | 81 | 82 | } 83 | 84 | export function saveCookies(options: { cookies: string }, createFile?: boolean): void { 85 | 86 | if (!createFile && !fs.existsSync(options.cookies)) { 87 | return; 88 | } 89 | 90 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 91 | // @ts-ignore 92 | const cookieList: ToughCookie[] = cookies._jar.serializeSync().cookies; 93 | //fs.writeFileSync("cookies.data", JSON.stringify(cookies._jar.serializeSync())); 94 | 95 | 96 | let data = `# Netscape HTTP Cookie File 97 | # https://curl.haxx.se/rfc/cookie_spec.html 98 | # This is a generated file! Do not edit. 99 | 100 | `; 101 | const formatDate = (date: string): number => Math.round((new Date(date)).getTime() / 1000); 102 | 103 | for (const cookie of cookieList) { 104 | if (!cookie.hostOnly) { 105 | cookie.domain = "." + cookie.domain; 106 | } 107 | data += [ 108 | cookie.httpOnly ? "#HttpOnly_" + cookie.domain : cookie.domain, 109 | cookie.hostOnly ? "FALSE" : "TRUE", 110 | cookie.path, 111 | cookie.secure ? "TRUE" : "FALSE", 112 | cookie.expires && cookie.expires != "Infinity" ? formatDate(cookie.expires) : "0", 113 | encodeURIComponent(cookie.key), 114 | encodeURIComponent(cookie.value || "") 115 | ].join("\t") + "\n"; 116 | } 117 | 118 | 119 | fs.writeFileSync(options.cookies, data); 120 | 121 | } 122 | 123 | export function getRequester(options: { proxy?: string }): Requester { 124 | return cloudscraper(cookies, options.proxy); 125 | } 126 | 127 | export function getRequesterCdn(options: { proxyCdn?: string }): RequesterCdn { 128 | return got(options.proxyCdn); 129 | } 130 | -------------------------------------------------------------------------------- /src/downloader/FontDownloader.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | import { RequesterCdn } from "../types/Requester"; 4 | import { ListDownloader } from "./ListDownloader"; 5 | 6 | const fontsRootUrl = "https://static.crunchyroll.com/vilos/assets/fonts/"; 7 | 8 | // List at view-source:https://static.crunchyroll.com/vilos/player.html 9 | const fontFiles: Record = { 10 | "Adobe Arabic": "AdobeArabic-Bold.otf", 11 | "Andale Mono": "andalemo.ttf", 12 | "Arial": "arial.ttf", 13 | "Arial Bold": "arialbd.ttf", 14 | "Arial Bold Italic": "arialbi.ttf", 15 | "Arial Italic": "ariali.ttf", 16 | "Arial Unicode MS": "arialuni.ttf", 17 | "Arial Black": "ariblk.ttf", 18 | "Comic Sans MS": "comic.ttf", 19 | "Comic Sans MS Bold": "comicbd.ttf", 20 | "Courier New": "cour.ttf", 21 | "Courier New Bold": "courbd.ttf", 22 | "Courier New Bold Italic": "courbi.ttf", 23 | "Courier New Italic": "couri.ttf", 24 | "DejaVu LGC Sans Mono Bold": "DejaVuLGCSansMono-Bold.ttf", 25 | "DejaVu LGC Sans Mono Bold Oblique": "DejaVuLGCSansMono-BoldOblique.ttf", 26 | "DejaVu LGC Sans Mono Oblique": "DejaVuLGCSansMono-Oblique.ttf", 27 | "DejaVu LGC Sans Mono": "DejaVuLGCSansMono.ttf", 28 | "DejaVu Sans Bold": "DejaVuSans-Bold.ttf", 29 | "DejaVu Sans Bold Oblique": "DejaVuSans-BoldOblique.ttf", 30 | "DejaVu Sans ExtraLight": "DejaVuSans-ExtraLight.ttf", 31 | "DejaVu Sans Oblique": "DejaVuSans-Oblique.ttf", 32 | "DejaVu Sans": "DejaVuSans.ttf", 33 | "DejaVu Sans Condensed Bold": "DejaVuSansCondensed-Bold.ttf", 34 | "DejaVu Sans Condensed Bold Oblique": "DejaVuSansCondensed-BoldOblique.ttf", 35 | "DejaVu Sans Condensed Oblique": "DejaVuSansCondensed-Oblique.ttf", 36 | "DejaVu Sans Condensed": "DejaVuSansCondensed.ttf", 37 | "DejaVu Sans Mono Bold": "DejaVuSansMono-Bold.ttf", 38 | "DejaVu Sans Mono Bold Oblique": "DejaVuSansMono-BoldOblique.ttf", 39 | "DejaVu Sans Mono Oblique": "DejaVuSansMono-Oblique.ttf", 40 | "DejaVu Sans Mono": "DejaVuSansMono.ttf", 41 | "Georgia": "georgia.ttf", 42 | "Georgia Bold": "georgiab.ttf", 43 | "Georgia Italic": "georgiai.ttf", 44 | "Georgia Bold Italic": "georgiaz.ttf", 45 | "Impact": "impact.ttf", 46 | "Rubik Black": "Rubik-Black.ttf", 47 | "Rubik Black Italic": "Rubik-BlackItalic.ttf", 48 | "Rubik Bold": "Rubik-Bold.ttf", 49 | "Rubik Bold Italic": "Rubik-BoldItalic.ttf", 50 | "Rubik Italic": "Rubik-Italic.ttf", 51 | "Rubik Light": "Rubik-Light.ttf", 52 | "Rubik Light Italic": "Rubik-LightItalic.ttf", 53 | "Rubik Medium": "Rubik-Medium.ttf", 54 | "Rubik Medium Italic": "Rubik-MediumItalic.ttf", 55 | "Rubik": "Rubik-Regular.ttf", 56 | "Tahoma": "tahoma.ttf", 57 | "Times New Roman": "times.ttf", 58 | "Times New Roman Bold": "timesbd.ttf", 59 | "Times New Roman Bold Italic": "timesbi.ttf", 60 | "Times New Roman Italic": "timesi.ttf", 61 | "Trebuchet MS": "trebuc.ttf", 62 | "Trebuchet MS Bold": "trebucbd.ttf", 63 | "Trebuchet MS Bold Italic": "trebucbi.ttf", 64 | "Trebuchet MS Italic": "trebucit.ttf", 65 | "Verdana": "verdana.ttf", 66 | "Verdana Bold": "verdanab.ttf", 67 | "Verdana Italic": "verdanai.ttf", 68 | "Verdana Bold Italic": "verdanaz.ttf", 69 | "Webdings": "webdings.ttf" 70 | }; 71 | 72 | const availableFonts: Record = {}; 73 | for (const f in fontFiles) { 74 | if (f && Object.prototype.hasOwnProperty.call(fontFiles, f)) { 75 | (availableFonts[f.toLowerCase()] = fontsRootUrl + fontFiles[f]); 76 | } 77 | } 78 | 79 | export async function downloadFontsFromSubtitles(requester: RequesterCdn, retry: number, subtitles: { path: string }[], destination: string): Promise { 80 | 81 | //const dir = path.join(options.tmpDir, "Fonts") 82 | await fs.promises.mkdir(destination, { recursive: true }); 83 | 84 | const fontsToInclude: string[] = []; 85 | const fontsInSub: Record = {}; 86 | 87 | for (const subtitle of subtitles) { 88 | 89 | const subContent = fs.readFileSync(subtitle.path).toString(); 90 | 91 | // https://github.com/Dador/JavascriptSubtitlesOctopus/blob/a824d5571961daa839722bb4cfc62e06fd6a2e11/src/pre-worker.js#L16 92 | const regex1 = /\nStyle: [^,]*?,([^,]*?),/ig; 93 | const regex2 = /\\fn([^\\}]*?)[\\}]/g; 94 | 95 | let matches; 96 | while ((matches = regex1.exec(subContent)) || (matches = regex2.exec(subContent))) { 97 | const font: string = matches[1].trim().toLowerCase(); 98 | if (!(font in fontsInSub)) { 99 | fontsInSub[font] = true; 100 | if (font in availableFonts) { 101 | const filePath = path.join(destination, availableFonts[font].split("/").pop() as string); 102 | await ListDownloader.safeDownload(availableFonts[font], filePath, retry, requester); 103 | fontsToInclude.push(filePath); 104 | } else { 105 | console.log("Unknown font: " + font); 106 | } 107 | } 108 | } 109 | } 110 | return fontsToInclude; 111 | } 112 | -------------------------------------------------------------------------------- /src/downloader/VideoMuxer.ts: -------------------------------------------------------------------------------- 1 | import { RuntimeError } from "../Errors"; 2 | import { EventEmitter } from "events"; 3 | import { LocalSubtitle } from "../types/Subtitle"; 4 | import { spawn } from "child_process"; 5 | import * as readline from "readline"; 6 | 7 | 8 | function parseProgressLine(line: string): Record | null { 9 | const progress: Record = {}; 10 | 11 | // Remove all spaces after = and trim 12 | line = line.replace(/=\s+/g, "=").trim(); 13 | const progressParts = line.split(" "); 14 | 15 | // Split every progress part by "=" to get key and value 16 | for (let i = 0; i < progressParts.length; i++) { 17 | const progressSplit = progressParts[i].split("=", 2); 18 | const key = progressSplit[0]; 19 | const value = progressSplit[1]; 20 | 21 | // This is not a progress line 22 | if (typeof value === "undefined") 23 | return null; 24 | 25 | progress[key] = value; 26 | } 27 | 28 | return progress; 29 | } 30 | 31 | export interface VideoMuxerOptions { 32 | input: string; 33 | subtitles: LocalSubtitle[]; 34 | fonts: string[]; 35 | output: string; 36 | } 37 | export declare interface VideoMuxer { 38 | on(name: "info", listener: (data: string) => void): this; 39 | on(name: "total", listener: (totalMilliseconds: number, totalString: string) => void): this; 40 | on(name: "progress", listener: (progressMilliseconds: number, progressString: string, fps: number) => void): this; 41 | on(name: "end", listener: (code: number) => void): this; 42 | } 43 | export class VideoMuxer extends EventEmitter { 44 | input: string; 45 | subtitles: LocalSubtitle[]; 46 | fonts: string[]; 47 | output: string; 48 | 49 | constructor({ 50 | input, 51 | subtitles, 52 | fonts, 53 | output 54 | }: VideoMuxerOptions) { 55 | super(); 56 | this.input = input.replace(/\\/g, "/"); // ffmpeg cant handle backslash 57 | this.subtitles = subtitles; 58 | this.fonts = fonts.map((font) => font.replace(/\\/g, "/")); 59 | this.output = output; 60 | } 61 | run(): Promise { 62 | return new Promise((resolve, reject) => { 63 | 64 | const command = this._makeCommand(); 65 | 66 | //console.log(command) 67 | const proc = spawn("ffmpeg", command, { 68 | windowsHide: true 69 | }); 70 | proc.stdout.on("data", function (data) { 71 | console.log("[ffmpeg]: " + data); 72 | }); 73 | proc.stderr.setEncoding("utf8"); 74 | 75 | const rl = readline.createInterface({ 76 | input: proc.stderr 77 | }); 78 | 79 | 80 | rl.on("line", (data: string) => { 81 | const dataString = data.toString(); 82 | //console.log(util.inspect(dataString)) 83 | this.emit("info", dataString); 84 | const match: RegExpExecArray | null = /Duration: ([0-9]{2}):([0-9]{2}):([0-9]{2}).([0-9]{2}),/.exec(dataString); 85 | if (match) { 86 | 87 | const totalString = `${match[1]}:${match[2]}:${match[3]}.${match[4]}`; 88 | 89 | // Video duration in milliseconds 90 | const totalMilliseconds = parseInt(match[4]) * 10 + parseInt(match[3]) * 1000 + parseInt(match[2]) * 60000 + parseInt(match[1]) * 3600000; 91 | 92 | //console.log("total", totalMilliseconds, totalString) 93 | this.emit("total", totalMilliseconds, totalString); 94 | return; 95 | } 96 | 97 | const progress = parseProgressLine(dataString); 98 | if (progress) { 99 | const match = /([0-9]{2}):([0-9]{2}):([0-9]{2}).([0-9]{2})/.exec(progress.time); 100 | if (match) { 101 | const progressString = progress.time; 102 | 103 | // Video progress in milliseconds 104 | const progressMilliseconds = parseInt(match[4]) * 10 + parseInt(match[3]) * 1000 + parseInt(match[2]) * 60000 + parseInt(match[1]) * 3600000; 105 | //console.log("progress", progressMilliseconds, progressString, parseInt(progress.fps)) 106 | this.emit("progress", progressMilliseconds, progressString, parseInt(progress.fps)); 107 | } 108 | } 109 | 110 | }); 111 | 112 | proc.on("close", (code) => { 113 | this.emit("end", code); 114 | if (code == 0) { 115 | resolve(); 116 | } else { 117 | reject(new RuntimeError(`ffmpeg process exited with code ${code}`)); 118 | } 119 | }); 120 | proc.on("error", function (err) { 121 | reject(new RuntimeError(err.message)); 122 | }); 123 | }); 124 | } 125 | _makeCommand(): string[] { 126 | const command = ["-stats", "-allowed_extensions", "ALL", "-y", "-i", this.input]; 127 | 128 | for (const subtitle of this.subtitles) { 129 | command.push("-i", subtitle.path); 130 | } 131 | 132 | 133 | command.push("-map", "0:v", "-map", "0:a"); 134 | 135 | let i = 1; 136 | let s = 0; 137 | for (const subtitle of this.subtitles) { 138 | command.push("-map", i.toString()); 139 | command.push("-metadata:s:s:" + s, "title=" + subtitle.title); 140 | command.push("-metadata:s:s:" + s, "language=" + subtitle.language); 141 | command.push("-disposition:s:" + s, subtitle.default ? "default" : "0"); 142 | i++; 143 | s++; 144 | } 145 | 146 | let t = 0; 147 | for (const font of this.fonts) { 148 | const fileEnding = font.split(".").pop()?.trim().toLowerCase() ?? "unknown"; 149 | let mimeType: string; 150 | 151 | // https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/matroska.c#L131 152 | if (fileEnding == "ttf") { 153 | mimeType = "application/x-truetype-font"; 154 | } else if (fileEnding == "otf") { 155 | mimeType = "application/vnd.ms-opentype"; 156 | } else { 157 | console.log("unknown font format: " + fileEnding); 158 | break; 159 | } 160 | 161 | command.push("-attach", font); 162 | command.push("-metadata:s:t:" + t, "mimetype=" + mimeType); 163 | t++; 164 | } 165 | 166 | command.push("-c", "copy", this.output); 167 | return command; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/api/CrDl.ts: -------------------------------------------------------------------------------- 1 | 2 | import cloudscraper from "../requester/cloudscraper"; 3 | import * as request from "request"; 4 | import got from "../requester/got"; 5 | import { RuntimeError, UserInputError } from "../Errors"; 6 | import { Requester, RequesterCdn } from "../types/Requester"; 7 | import { Language } from "../types/language"; 8 | import { VilosVideoInfo } from "./MediaVilosPlayer"; 9 | import { VideoInfo } from "../interfaces/video"; 10 | import { AllHtmlEntities } from "html-entities"; 11 | import { matchAll } from "../Utils"; 12 | 13 | 14 | 15 | export interface Options { 16 | requester?: Requester; 17 | requesterCdn?: RequesterCdn; 18 | } 19 | 20 | export interface Episode { 21 | url: string; 22 | name: string; 23 | number: string; // Episode number can also be non numerical 24 | } 25 | export interface Season { 26 | name: string; 27 | episodes: Episode[]; 28 | isRegionBlocked: boolean; 29 | isLanguageUnavailable: boolean; 30 | } 31 | export interface User { 32 | username: string; 33 | email: string; 34 | createdAt: string; 35 | } 36 | 37 | 38 | 39 | export class CrDl { 40 | _requester: Requester; 41 | _requesterCdn: RequesterCdn; 42 | constructor(options?: Options) { 43 | if (options && options.requester) { 44 | this._requester = options.requester; 45 | } else { 46 | this._requester = cloudscraper(request.jar()); 47 | } 48 | 49 | if (options && options.requesterCdn) { 50 | this._requesterCdn = options.requesterCdn; 51 | } else { 52 | this._requesterCdn = got(); 53 | } 54 | } 55 | 56 | async isLoggedIn(): Promise { 57 | const res = await this._requester.get("http://www.crunchyroll.com/videos/anime"); 58 | return res.body.indexOf(" -1; 59 | } 60 | 61 | async getLoggedIn(): Promise { 62 | const res = await this._requester.get("http://www.crunchyroll.com/videos/anime"); 63 | const m = /\$\.extend\(traits, (.*)\);$/m.exec(res.body.toString()); 64 | if (m) { 65 | return JSON.parse(m[1]) as User; 66 | } else { 67 | return undefined; 68 | } 69 | } 70 | 71 | async login(username: string, password: string): Promise { 72 | const loginPage: { body: Buffer; url: string } = await this._requester.get("https://www.crunchyroll.com/login"); 73 | 74 | const loginTokenMatch = /name="login_form\[_token\]" value="([^"]+)" \/>/.exec(loginPage.body.toString()); 75 | if (!loginTokenMatch) { 76 | throw new RuntimeError("Error logging in: No login token found."); 77 | } 78 | const token = loginTokenMatch[1]; 79 | try { 80 | await this._requester.post(loginPage.url, { 81 | "login_form[_token]": token, 82 | "login_form[name]": username, 83 | "login_form[password]": password, 84 | "login_form[redirect_url]": "/" 85 | }); 86 | } catch (e) { 87 | console.log(e); 88 | 89 | } 90 | const user = await this.getLoggedIn(); 91 | if (user) { 92 | return user; 93 | } else { 94 | throw new UserInputError("Couldn't log in. Wrong credentials?"); 95 | } 96 | 97 | } 98 | async logout(): Promise { 99 | await this._requester.get("http://www.crunchyroll.com/logout"); 100 | if (await this.isLoggedIn()) { 101 | throw new RuntimeError("Couldn't log out."); 102 | } else { 103 | return; 104 | } 105 | } 106 | 107 | async getLang(): Promise { 108 | const res = await this._requester.get("http://www.crunchyroll.com/videos/anime"); 109 | const m = res.body.toString().match(/[^<]+<\/a>/); 110 | if (m) { 111 | return m[1] as Language; 112 | } else { 113 | throw new RuntimeError("Couldn't find Language"); 114 | } 115 | } 116 | async setLang(lang: Language): Promise { 117 | const res = await this._requester.get("http://www.crunchyroll.com/videos/anime"); 118 | const tokenMatch = res.body.toString().match(/[^<]+<\/a>/); 119 | let token; 120 | if (tokenMatch) { 121 | token = tokenMatch[1]; 122 | } else { 123 | throw new RuntimeError("Couldn't find token"); 124 | } 125 | 126 | 127 | await this._requester.post("https://www.crunchyroll.com/ajax/", { 128 | "req": "RpcApiTranslation_SetLang", 129 | "locale": lang, 130 | "_token": token 131 | }); 132 | 133 | const newLang = await this.getLang(); 134 | if (newLang == lang) { 135 | //console.log("Language changed to " + lang); 136 | return; 137 | } else { 138 | throw new RuntimeError("Couldn't change language. Currently selected: " + newLang); 139 | } 140 | } 141 | 142 | async getEpisodesFormUrl(url: string): Promise { 143 | const list: Season[] = []; 144 | let seasonNum = -1; 145 | const page: string = (await this._requester.get(url)).body.toString(); 146 | 147 | 148 | const regionBlockedSeasons: string[] = matchAll(page, /

[^<]+?: ([^<]+)<\/p>/g).map((v: RegExpMatchArray) => AllHtmlEntities.decode(v[1])); 149 | const languageBlockedSeasons: string[] = matchAll(page, /

([^<]+) (?:ist in|no está|is not|n'est pas|non è|недоступен)[^<]+<\/p>/g).map((v: RegExpMatchArray) => AllHtmlEntities.decode(v[1])); 150 | 151 | console.log(regionBlockedSeasons); 152 | console.log(languageBlockedSeasons); 153 | 154 | const regex = /(?:[^$]*\s*\S+ (\S+))|(?:[^<]+<\/a>)/gm; 155 | let m: RegExpExecArray | null; 156 | list[0] = { 157 | name: "", 158 | episodes: [], 159 | isLanguageUnavailable: languageBlockedSeasons.length > 0, 160 | isRegionBlocked: regionBlockedSeasons.length > 0 161 | }; 162 | while ((m = regex.exec(page)) !== null) { 163 | if (m[4]) { 164 | if (seasonNum != -1) list[seasonNum].episodes = list[seasonNum].episodes.reverse(); 165 | seasonNum++; 166 | const seasonName = AllHtmlEntities.decode(m[4]); 167 | list[seasonNum] = { 168 | name: seasonName, 169 | episodes: [], 170 | isLanguageUnavailable: languageBlockedSeasons.includes(seasonName), 171 | isRegionBlocked: regionBlockedSeasons.includes(seasonName) 172 | }; 173 | } else { 174 | if (seasonNum == -1) seasonNum = 0; 175 | list[seasonNum].episodes.push({ 176 | url: m[1], 177 | name: AllHtmlEntities.decode(m[2]), 178 | number: m[3] 179 | }); 180 | } 181 | } 182 | if (seasonNum != -1) list[seasonNum].episodes = list[seasonNum].episodes.reverse(); 183 | list.reverse(); 184 | 185 | return list; 186 | } 187 | 188 | async loadEpisode(url: string): Promise { 189 | const html = (await this._requester.get(url)).body.toString(); 190 | return new VilosVideoInfo(html, url, this._requesterCdn); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CR-dl 2 | *Read this in other language*: [Deutsch](README.de.md) 3 | 4 | CR-dl is a tool to quickly download anime from [Crunchyroll](http://www.crunchyroll.com/) 5 | 6 | ## Installation 7 | 8 | Windows users can get a Windows bundle from the [Releases Page](https://github.com/DasKraken/CR-dl/releases). 9 | 10 | 11 | ### Without using the bundle: 12 | 13 | CR-dl requires [node.js (v12 or later)](https://nodejs.org) and [ffmpeg](https://www.ffmpeg.org) to be installed on the system and available in PATH 14 | 15 | After these are installed run: 16 | 17 | npm install -g @derkraken/cr-dl 18 | 19 | now you can run it with ```cr-dl``` 20 | 21 | ## Usage 22 | CR-dl is a CLI-Tool and can only be run from the terminal. 23 | 24 | *Note:* If you use the Windows bundle with Powershell, instead of calling ```cr-dl```, you have to write it out: ```.\cr-dl.exe``` . 25 | 26 | Following commands are available: 27 | 28 | 29 | Log in into Crunchyroll to be get access to premium content (This will create a file 'cookies.txt' to store the session). If username and/or password is not given, it will prompt you for them. On Problems (e.g. Captcha) see [alternative login methods to CR-dl](#alternative-login-methods-to-cr-dl): 30 | ``` 31 | cr-dl login [username] [password] 32 | ``` 33 | 34 | 35 | Log out: 36 | ``` 37 | cr-dl logout 38 | ``` 39 | 40 | Change the language of Crunchyroll. This will change the language of the metadata for file naming. 41 | Note 1: This wont change the default subtitle language. 42 | Note 2: Series that aren't available in selected language may not work. 43 | Allowed values are: enUS, enGB, esLA, esES, ptBR, ptPT, frFR, deDE, arME, itIT, ruRU 44 | ``` 45 | cr-dl language 46 | ``` 47 | 48 | 49 | Downloading a video or a series from a URL: 50 | ``` 51 | cr-dl download 52 | ``` 53 | 54 | ### Optional arguments: 55 | Following optional arguments can be provided to 'download': 56 | ```-f , --format ``` 57 | Video resolution (default: "1080p") 58 | 59 | ```--season ``` 60 | A season number or a comma-separated list (without spaces) of season numbers to download. A ```-``` (minus) can be used to specify a range (e.g. ```1,3-5```). Works only for series-links. Note: Season 1 is the bottom-most season on the website. 61 | 62 | ```--episode ``` 63 | A comma-separated list of episode numbers to download. A ```-``` (minus) can be used to specify a range (e.g. ```01,03-05,SP2```). If a given episode number exists in multiple seasons, you must specify one with --season. 64 | 65 | ```-c N, --connections ``` 66 | Number of simultaneous connections (default: 5) 67 | 68 | ```--sub-lang ``` 69 | Specify subtitle languages as a comma separated list to include in video. (e.g. deDE,enUS). Set to ```none``` to embed no subtitles. Use --list-subs for available languages. (Default: All available) 70 | 71 | ```--default-sub ``` 72 | Specify subtitle language to be set as default. (e.g. enUS). (Default: if --sub-lang defined: first entry, otherwise: crunchyroll default) 73 | 74 | ```--attach-fonts``` 75 | Automatically download and attach all fonts that are used in subtitles. 76 | 77 | ```--list-subs``` 78 | Don't download. List all available subtitles for the video. 79 | 80 | ```--subs-only``` 81 | Download only subtitles. No Video. 82 | 83 | ```--hardsub``` 84 | Download hardsubbed video stream. Only one subtitle language specified by --default-sub will be included. 85 | 86 | ```--retry ``` 87 | Max number of download attempts before aborting. (Default: 5) 88 | 89 | ```--cookies ``` 90 | File to read cookies from and dump cookie jar in (default: "cookies.txt") 91 | 92 | ```--no-progress-bar``` 93 | Hide progress bar. 94 | 95 | ```--proxy ``` 96 | HTTP(s) proxy to access Crunchyroll. This is enough to bypass geo-blocking. 97 | 98 | ```--proxy-cdn ``` 99 | HTTP proxy used to download video files. Not required for bypassing geo-blocking. 100 | 101 | ```-o , --output ``` 102 | Output filename template, see the "OUTPUT TEMPLATE" for all the info 103 | 104 | 105 | ### OUTPUT TEMPLATE 106 | Output template is a string specified with -o where every {...} will be replaced with the value represented by given name. 107 | Default is ``` -o "{seasonTitle} [{resolution}]/{seasonTitle} - {episodeNumber} - {episodeTitle} [{resolution}].mkv" ``` 108 | 109 | Allowed names are: 110 | 111 | **{episodeTitle}**: Title of the episode. 112 | 113 | **{seriesTitle}**: Title of the series. (seasonTitle should be preferred to differentiate seasons) 114 | 115 | **{episodeNumber}**: Episode number in two digits e.g. "02". Can also be a special episode e.g. "SP1" 116 | 117 | **{seasonTitle}**: Title of the season. 118 | 119 | **{resolution}**: Resolution of the video. E.g. 1080p 120 | 121 | 122 | Additionally you can append **!scene** to the name e.g. ```{seasonTitle!scene}``` to make it a dot separated title as used in Scene releases. 123 | E.g. **"Food Wars! Shokugeki no Sōma" => "Food.Wars.Shokugeki.no.Soma"** 124 | 125 | ### Template examples: 126 | Name it like a scene release: 127 | 128 | -o "{seasonTitle!scene}.{resolution}.WEB.x264-byME/{seasonTitle!scene}.E{episodeNumber}.{resolution}.WEB.x264-byME.mkv" 129 | 130 | Name it like a Fansub: 131 | 132 | -o "[MySubs] {seasonTitle} - {episodeNumber} [{resolution}].mkv" 133 | 134 | ## Examples 135 | 136 | Log in as user "MyName" with password entered in prompt: 137 | ``` 138 | > cr-dl login "MyName" 139 | Enter password: 140 | ``` 141 | 142 | Download episode 4 of HINAMATSURI: 143 | ``` 144 | cr-dl download http://www.crunchyroll.com/hinamatsuri/episode-4-disownment-rock-n-roll-fever-769303 145 | ``` 146 | 147 | 148 | Download all episodes of HINAMATSURI in 720p using 10 simultaneous connections, and will set the default subtitle language to enUS: 149 | ``` 150 | cr-dl download -c 10 --default-sub enUS http://www.crunchyroll.com/hinamatsuri -f 720p 151 | ``` 152 | 153 | 154 | Download only the second season of Food Wars in 1080p: 155 | ``` 156 | cr-dl download http://www.crunchyroll.com/food-wars-shokugeki-no-soma --season 2 157 | ``` 158 | 159 | 160 | Download Bungo Stray Dogs 1 and 2: 161 | ``` 162 | cr-dl download http://www.crunchyroll.com/bungo-stray-dogs --season 14,15 163 | ``` 164 | 165 | 166 | Download episodes 1,3,4,5 and special 2 of DITF (SP2 is not available anymore): 167 | ``` 168 | cr-dl download http://www.crunchyroll.com/darling-in-the-franxx --season 6 --episode 1,3-5,SP2 169 | ``` 170 | 171 | 172 | Download video and add only german and english subs and set german as default: 173 | ``` 174 | cr-dl download --sub-lang deDE,enUS 175 | ``` 176 | 177 | Download video and name it like a scene release: 178 | ``` 179 | cr-dl download -o "{seasonTitle!scene}.{resolution}.WEB.x264-byME/{seasonTitle!scene}.E{episodeNumber}.{resolution}.WEB.x264-byME.mkv" 180 | ``` 181 | 182 | ## Alternative login methods to CR-dl 183 | 184 | If you are unable to login using CR-dl (e.g. Captcha), it's possible to use the cookies from your browser. 185 | 186 | In order to extract cookies from browser use any conforming browser extension for exporting cookies. For example, [cookies.txt](https://chrome.google.com/webstore/detail/cookiestxt/njabckikapfpffapmjgojcnbfjonfjfg) (for Chrome) or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) (for Firefox). 187 | 188 | By default CR-dl uses the file `cookies.txt` in the current working directory. To change this use the `--cookies` option, for example `--cookies /path/to/cookies/file.txt`. 189 | 190 | 191 | ## License 192 | This is free and unencumbered software released into the public domain. 193 | 194 | Anyone is free to copy, modify, publish, use, compile, sell, or 195 | distribute this software, either in source code form or as a compiled 196 | binary, for any purpose, commercial or non-commercial, and by any 197 | means. 198 | 199 | In jurisdictions that recognize copyright laws, the author or authors 200 | of this software dedicate any and all copyright interest in the 201 | software to the public domain. We make this dedication for the benefit 202 | of the public at large and to the detriment of our heirs and 203 | successors. We intend this dedication to be an overt act of 204 | relinquishment in perpetuity of all present and future rights to this 205 | software under copyright law. 206 | 207 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 208 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 209 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 210 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 211 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 212 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 213 | OTHER DEALINGS IN THE SOFTWARE. 214 | 215 | For more information, please refer to 216 | -------------------------------------------------------------------------------- /src/api/MediaVilosPlayer.ts: -------------------------------------------------------------------------------- 1 | import { UserInputError, RuntimeError } from "../Errors"; 2 | import * as langs from "langs"; 3 | import { RequesterCdn } from "../types/Requester"; 4 | import { VideoInfo, SubtitleInfo, StreamInfo } from "../interfaces/video"; 5 | import { parseM3U } from "../Utils"; 6 | import { M3U, StreamItem } from "m3u8"; 7 | import { Language } from "../types/language"; 8 | 9 | 10 | class VilosSubtitleInfo implements SubtitleInfo { 11 | _sub: VilosVideoInfoConfigSubtitle; 12 | _isDefault: boolean; 13 | _requester: RequesterCdn; 14 | constructor(sub: VilosVideoInfoConfigSubtitle, isDefault: boolean, requester: RequesterCdn) { 15 | this._sub = sub; 16 | this._isDefault = isDefault; 17 | this._requester = requester; 18 | } 19 | async getTitle(): Promise { 20 | return this._sub.title; 21 | } 22 | async getLanguage(): Promise { 23 | return this._sub.language; 24 | } 25 | async getLanguageISO6392T(): Promise { 26 | return langs.where("1", this._sub.language.substring(0, 2))["2T"]; 27 | } 28 | async getData(): Promise { 29 | return (await this._requester.get(this._sub.url)).body.toString(); 30 | } 31 | async isDefault(): Promise { 32 | return this._isDefault; 33 | } 34 | } 35 | 36 | class StreamVilosPlayer implements StreamInfo { 37 | _stream: StreamItem; 38 | _hardsubLang: Language; 39 | _audioLang: Language; 40 | constructor(stream: StreamItem, hardsubLang: Language, audioLang: Language) { 41 | this._stream = stream; 42 | this._hardsubLang = hardsubLang; 43 | this._audioLang = audioLang; 44 | } 45 | getHardsubLanguage(): Language { 46 | return this._hardsubLang; 47 | } 48 | getAudioLanguage(): Language { 49 | return this._audioLang; 50 | } 51 | getWidth(): number { 52 | return this._stream.attributes.attributes.resolution?.[0] ?? 0; 53 | } 54 | getHeight(): number { 55 | return this._stream.attributes.attributes.resolution?.[1] ?? 0; 56 | } 57 | getUrl(): string { 58 | if (!this._stream.properties.uri) throw new RuntimeError("No URI for Stream found"); 59 | return this._stream.properties.uri; 60 | } 61 | } 62 | 63 | interface VilosVideoInfoConfigSubtitle { 64 | language: Language; 65 | url: string; 66 | title: string; 67 | format: "ass"; 68 | } 69 | interface VilosVideoInfoConfigStream { 70 | format: "adaptive_dash" | "adaptive_hls" | "drm_adaptive_dash" | "drm_multitrack_adaptive_hls_v2" 71 | | "multitrack_adaptive_hls_v2" | "vo_adaptive_dash" | "vo_adaptive_hls" | "vo_drm_adaptive_dash" 72 | | "vo_drm_adaptive_hls" | "trailer_hls" | "trailer_dash"; 73 | audio_lang: Language; 74 | hardsub_lang: Language; 75 | url: string; 76 | resolution: string; // currently always "adaptive" 77 | 78 | data?: string; 79 | } 80 | 81 | interface VilosVideoInfoConfig { 82 | metadata: { 83 | id: string; 84 | series_id: string; 85 | type: string; 86 | channel_id: unknown; 87 | title: string; 88 | description: string; 89 | episode_number: string; 90 | display_episode_number: string; 91 | is_mature: boolean; 92 | up_next: { 93 | title: string; 94 | id: string; 95 | channel_id: unknown; 96 | channel_name: unknown; 97 | description: string; 98 | display_episode_number: string; 99 | duration: number; 100 | episode_number: string; 101 | episode_title: string; 102 | extra_title: unknown; 103 | is_mature: boolean; 104 | is_premium_only: boolean; 105 | media_title: string; 106 | release_date: string; 107 | season_title: string; 108 | series_id: string; 109 | series_title: string; 110 | type: string; 111 | thumbnail: { 112 | url: string; 113 | width: number; 114 | height: number; 115 | }; 116 | }; 117 | duration: number; 118 | }; 119 | thumbnail: { 120 | url: string; 121 | }; 122 | streams: VilosVideoInfoConfigStream[]; 123 | ad_breaks: { 124 | type: string; // "preroll"/"midroll" 125 | offset: number; 126 | }[]; 127 | subtitles: VilosVideoInfoConfigSubtitle[]; 128 | } 129 | 130 | export class VilosVideoInfo implements VideoInfo { 131 | _html: string; 132 | _url: string; 133 | _language: Language; 134 | _config: VilosVideoInfoConfig; 135 | _requester: RequesterCdn; 136 | 137 | constructor(html: string, url: string, requester: RequesterCdn) { 138 | this._html = html; 139 | this._url = url; 140 | this._requester = requester; 141 | 142 | const matchConfig = this._html.match(/vilos\.config\.media = (.+);/); 143 | if (!matchConfig) throw new Error("Couldn't find video config on webpage."); 144 | this._config = JSON.parse(matchConfig[1]); 145 | 146 | const matchLanguage = this._html.match(/vilos\.config\.player\.language = ([^;]+);/); 147 | if (!matchLanguage) throw new Error("Couldn't find default language"); 148 | this._language = JSON.parse(matchLanguage[1]); 149 | 150 | 151 | } 152 | async getSubtitles(): Promise { 153 | const subtitles: VilosSubtitleInfo[] = []; 154 | for (let i = 0; i < this._config.subtitles.length; i++) { 155 | const isDefault = this._config.subtitles[i].language == this._language; 156 | subtitles.push(new VilosSubtitleInfo(this._config.subtitles[i], isDefault, this._requester)); 157 | } 158 | return subtitles; 159 | } 160 | 161 | async getDefaultLanguage(): Promise { 162 | return this._language; 163 | } 164 | 165 | async _loadStreamData(stream: VilosVideoInfoConfigStream): Promise { 166 | if (stream.data) return stream.data; 167 | return (await this._requester.get(stream.url)).body.toString(); 168 | } 169 | async _getStreamForHardsubLang(hardSubLang: Language | null): Promise { 170 | for (const stream of this._config.streams) { 171 | if (stream.hardsub_lang == hardSubLang && stream.format == "adaptive_hls") { 172 | return stream; 173 | } 174 | } 175 | return null; 176 | } 177 | async getAvailableResolutions(hardSubLang: Language): Promise { 178 | 179 | 180 | const selectedStream = await this._getStreamForHardsubLang(hardSubLang); 181 | if (!selectedStream) { 182 | throw new UserInputError("No stream found for hardsub language: " + hardSubLang); 183 | } 184 | if (!selectedStream.data) { 185 | selectedStream.data = await this._loadStreamData(selectedStream); 186 | } 187 | 188 | const availableResolutions: number[] = []; 189 | const regexResolutions = /RESOLUTION=[0-9]+x([0-9]+)/gm; 190 | let m; 191 | while ((m = regexResolutions.exec(selectedStream.data)) !== null) { 192 | const r = parseInt(m[1]); 193 | if (availableResolutions.indexOf(r) == -1) { 194 | availableResolutions.push(r); 195 | } 196 | } 197 | return availableResolutions; 198 | } 199 | async getStreams(resolution: number, hardSubLang: Language | null): Promise { 200 | const selectedStream = await this._getStreamForHardsubLang(hardSubLang); 201 | if (!selectedStream) { 202 | throw new UserInputError("No stream found for hardsub language: " + hardSubLang); 203 | } 204 | if (!selectedStream.data) { 205 | selectedStream.data = await this._loadStreamData(selectedStream); 206 | } 207 | 208 | // selectedStream is a group of Streams in different resolutions. We need to filter out one resolution. 209 | const m3uData: M3U = await parseM3U(selectedStream.data); 210 | const streamList: StreamVilosPlayer[] = []; 211 | for (const streamItem of m3uData.items.StreamItem) { 212 | if (streamItem.attributes.attributes.resolution?.[1] == resolution) { 213 | streamList.push(new StreamVilosPlayer(streamItem, selectedStream.hardsub_lang, selectedStream.audio_lang)); 214 | } 215 | } 216 | return streamList; 217 | 218 | } 219 | async getEpisodeTitle(): Promise { 220 | return this._config.metadata.title; 221 | } 222 | async getSeriesTitle(): Promise { 223 | const seriesTitleMatch = /"mediaTitle":(".*?[^\\]")/.exec(this._html); 224 | const seriesTitle: string = seriesTitleMatch ? JSON.parse(seriesTitleMatch[1]) : "undefined"; 225 | return seriesTitle; 226 | } 227 | async getSeasonTitle(): Promise { 228 | const seasonTitleMatch = /"seasonTitle":(".*?[^\\]")/.exec(this._html); 229 | const seasonTitle = seasonTitleMatch ? JSON.parse(seasonTitleMatch[1]) : "undefined"; 230 | return seasonTitle; 231 | } 232 | async getEpisodeNumber(): Promise { 233 | return this._config.metadata.episode_number; 234 | } 235 | async isRegionBlocked(): Promise { 236 | return this._config.streams.length == 0; 237 | } 238 | async isPremiumBlocked(): Promise { 239 | return this._html.includes("class=\"showmedia-trailer-notice\""); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /README.de.md: -------------------------------------------------------------------------------- 1 | # CR-dl 2 | *Auf anderer Sprache lesen*: [English](README.md) 3 | 4 | CR-dl ist ein Tool um schnell und einfach von [Crunchyroll](http://www.crunchyroll.com/) herunterladen zu können. 5 | 6 | ## Installation 7 | 8 | Windows Nutzer können von der [Releases Seite](https://github.com/DasKraken/CR-dl/releases) ein Windows-Bundle mit allen Abhängigkeiten runterladen. 9 | 10 | 11 | ### Falls das Bundle nicht verwendet wird: 12 | 13 | CR-dl erfordert, dass [node.js (v12 oder höher)](https://nodejs.org) und [ffmpeg](https://www.ffmpeg.org) auf dem System installiert sind und über PATH erreichbar sind. 14 | 15 | Nachdem sie installiert sind, führt aus: 16 | 17 | npm install -g @derkraken/cr-dl 18 | 19 | nun kann man es mit ```cr-dl``` aufrufen. 20 | 21 | ## Verwendung 22 | CR-dl ist ein CLI-Programm und kann somit nur über die Konsole aufgeführt werden. 23 | 24 | Bei Windows könnt ihr eine Konsole öffnen indem ihr im Projektverzeichnis (wo sich die cr-dl.exe befindet) bei gedrückter Shift-Taste rechtsklickt und im Kontextmenü 25 | "Powershell Fenster hier öffnen" oder "Eingabeaufforderung hier öffnen" auswählt. 26 | 27 | *Anmerkung:* Falls Windows-Bundle mit Powershell verwendet wird, kann man es nicht mit ```cr-dl``` aufrufen, sondern muss ausgeschrienben werden: ```.\cr-dl.exe```. 28 | 29 | Folgende Befehle können verwendet werden: 30 | 31 | Sich in Crunchyroll einloggen um auf Premiumcontent zuzugreifen (Dies erstellt die Datei 'cookies.txt', die die Sitzung speichert). Falls Benutzername und/oder Password nicht angegeben wird, wird eine Eingabe aufgefordert. Bei Problemen (z.B. Captcha) siehe [Alternative Einloggemöglichkeiten für CR-dl](#alternative-einloggemöglichkeiten-für-cr-dl): 32 | ``` 33 | cr-dl login [username] [password] 34 | ``` 35 | 36 | 37 | Ausloggen: 38 | ``` 39 | cr-dl logout 40 | ``` 41 | 42 | Ändern der Sprache von CR und somit die Sprache der Dateibenennung. 43 | Anmerkung 1: Dies ändert nicht die Untertitelsprache 44 | Anmerkung 2: Videos die nicht in der gewählten Sprache verfügbar sind werden evtl. nicht heruntergeladen. 45 | Erlaubt sind: enUS, enGB, esLA, esES, ptBR, ptPT, frFR, deDE, arME, itIT, ruRU 46 | ``` 47 | cr-dl language 48 | ``` 49 | 50 | 51 | Ein Video oder eine Serie von einer URL herunterladen: 52 | ``` 53 | cr-dl download 54 | ``` 55 | 56 | 57 | ### Optionale Argumente: 58 | Folgende optionale Argumente können mit 'download' verwendet werden: 59 | ```-f , --format ``` 60 | Video Auflösung (Standard: "1080p") 61 | 62 | ```--season ``` 63 | Eine Staffelnummer oder eine kommagetrennte Liste (ohne Leerzeichen) von Staffelnummern zum Herunterladen. Ein ```-``` (Minus) kann verwendet werden um einen Bereich anzugeben (z.B.: ```1,3-5```). Funktioniert nur mit Serien-URLs. Anmerkung: Staffel 1 ist die unterste Staffel auf der Webseite. 64 | 65 | ```--episode ``` 66 | Eine kommagetrennte Liste (ohne Leerzeichen) von Episodennummern zum Herunterladen. Ein ```-``` (Minus) kann verwendet werden um einen Bereich anzugeben (z.B.: ```01,03-05,SP2```). Funktioniert nur mit Serien-URLs. Falls eine angegebene Episodennummer in mehreren Staffeln verfügbar ist, muss eine Staffel mit --season ausgewählt werden. 67 | 68 | ```-c N, --connections N``` 69 | Anzahl der gleichzeitigen Verbindungen (Standard: 5) 70 | 71 | ```--sub-lang ``` 72 | Eine kommagetrennte Liste (ohne Leerzeichen) an Sprachen die in das Video eingebettet werden sollen. (z.B.: deDE,enUS). Setze auf ```none``` um keine Untertitel einzubetten. 73 | 74 | ```--default-sub ``` 75 | Sprache, dessen Untertitel als Standard ausgewählt werden sollen. (z.B: enUS). Standard ist, falls angegeben, der erste Eintrag von --subLangs, ansonsten CR-Standard. 76 | 77 | ```--attach-fonts``` 78 | Lädt automatisch die von den Untertiteln benötigten Schriftarten herunter und hängt sie an die Videodatei an. 79 | 80 | ```--list-subs``` 81 | Video nicht herunterladen. Zeigt nur Liste an verfügbaren Untertitelsprachen an. 82 | 83 | ```--subs-only``` 84 | Lade nur Untertitel herunter. Kein Video. 85 | 86 | ```--hardsub``` 87 | Lade einen Hardsub Videostream runter. Nur eine Untertitelsprache wird eingebettet, die mit --default-sub spezifiziert werden kann. 88 | 89 | ```--retry ``` 90 | Anzahl Wiederholungsversuche bei Netzwerkproblemen. (Standard: 5) 91 | 92 | ```--cookies ``` 93 | Datei, wo cookies gelesen und gespeichert werden (Standard: "cookies.txt") 94 | 95 | ```--no-progress-bar``` 96 | Keinen Fortschrittsbalken anzeigen. 97 | 98 | ```--proxy ``` 99 | HTTP proxy für Zugriff auf Crunchyroll. Dies ist ausreichend um Länderrestriktionen zu umgehen. 100 | 101 | ```--proxy-cdn ``` 102 | HTTP proxy für Download der Videodateien. Nicht nötig um Länderrestriktionen zu umgehen. 103 | 104 | 105 | ```-o , --output ``` 106 | Vorlage zur Ordner- und Dateibenennung. Siehe unten für mehr Informationen 107 | 108 | ### Ausgabevorlage 109 | Die Ausgabevorlage wird mit -o festgelegt. 110 | Standard ist ```-o "{seasonTitle} [{resolution}]/{seasonTitle} - {episodeNumber} - {episodeTitle} [{resolution}].mkv"``` 111 | Dabei wird jedes {...} durch den entsprechenden Text ersetzt. 112 | 113 | 114 | Erlaubt sind: 115 | **{episodeTitle}**: Titel der Folge. 116 | 117 | **{seriesTitle}**: Title der Serie. (Es sollte stattdessen möglichst **{seasonTitle}** verwendet werden um Staffeln zu unterscheiden) 118 | 119 | **{episodeNumber}**: Folgennummer in zwei Ziffern z.B.: "02". Kann auch Spezialepisode sein z.B.: "SP1" 120 | 121 | **{seasonTitle}**: Titel der Staffel. 122 | 123 | **{resolution}**: Auflösung vom Video. z.B.: 1080p 124 | 125 | Zusätzlich kann man **!scene** anhängen z.B. ```{seasonTitle!scene}``` um es zu einem durch Punkte separierten Titel zu konvertieren, wie es in Scene-Releases verwendet wird. 126 | Z.B.: **"Food Wars! Shokugeki no Sōma" => "Food.Wars.Shokugeki.no.Soma"** 127 | 128 | ### Templatebeispiele: 129 | Benenne es wie ein Szene-Release: 130 | 131 | -o "{seasonTitle!scene}.{resolution}.WEB.x264-byME/{seasonTitle!scene}.E{episodeNumber}.{resolution}.WEB.x264-byME.mkv" 132 | 133 | Benenne es wie ein Fansub: 134 | 135 | -o "[MySubs] {seasonTitle} - {episodeNumber} [{resolution}].mkv" 136 | 137 | 138 | ## Beispiele 139 | 140 | Einloggen als User "MyName" mit in Aufforderung eingegebenen Password: 141 | ``` 142 | > cr-dl login "MyName" 143 | Enter password: 144 | ``` 145 | 146 | 147 | Episode 4 von HINAMATSURI herunterladen: (in 1080p) 148 | ``` 149 | cr-dl download http://www.crunchyroll.com/hinamatsuri/episode-4-disownment-rock-n-roll-fever-769303 150 | ``` 151 | 152 | 153 | Lädt alle Episoden von HINAMATSURI in 720p mit 10 gleichzeitigen Verbindungen, und setzt die Standard-Untertitelsprache auf enUS: 154 | ``` 155 | cr-dl download -c 10 --default-sub enUS http://www.crunchyroll.com/hinamatsuri -f 720p 156 | ``` 157 | 158 | 159 | Lädt nur die 2te Staffel von Food Wars in 1080p: 160 | ``` 161 | cr-dl download http://www.crunchyroll.com/food-wars-shokugeki-no-soma --season 2 162 | ``` 163 | 164 | 165 | Lädt Bungo Stray Dogs 1 and 2: 166 | ``` 167 | cr-dl download http://www.crunchyroll.com/bungo-stray-dogs --season 14,15 168 | ``` 169 | 170 | 171 | Lädt die Episoden 1,3,4,5 und Spezial 2 von DITF (SP2 nicht mehr verfügbar): 172 | ``` 173 | cr-dl download http://www.crunchyroll.com/darling-in-the-franxx --season 6 --episode 1,3-5,SP2 174 | ``` 175 | 176 | 177 | Lädt Video mit deutschen und englischen Untertiteln herunter und setzt deutsch als Standard: 178 | ``` 179 | cr-dl download --sub-lang deDE,enUS 180 | ``` 181 | 182 | 183 | Lädt Video(s) und bennent es wie ein Scene-Release: 184 | ``` 185 | cr-dl download -o "{seasonTitle!scene}.{resolution}.WEB.x264-byME/{seasonTitle!scene}.E{episodeNumber}.{resolution}.WEB.x264-byME.mkv" 186 | ``` 187 | 188 | ## Alternative Einloggemöglichkeiten für CR-dl 189 | 190 | Falls Sie sich nicht über CR-dl einloggen können (z.B. wegen Captcha) kann man alternativ die Cookies aus dem Browser übernehmen. 191 | 192 | Um Cookies aus dem Browser zu extrahieren, verwenden Sie eine beliebige konforme Browser-Erweiterung für den Export von Cookies. Zum Beispiel, [cookies.txt](https://chrome.google.com/webstore/detail/cookiestxt/njabckikapfpffapmjgojcnbfjonfjfg) (für Chrome) oder [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) (für Firefox). 193 | 194 | Standardmäßig nutzt CR-dl die Datei `cookies.txt` im Arbeitsverzeichnis. Um dies zu ändern nutzen Sie die `--cookies` Option, zum Beispiel `--cookies /path/to/cookies/file.txt`. 195 | 196 | 197 | ## Lizenz 198 | This is free and unencumbered software released into the public domain. 199 | 200 | Anyone is free to copy, modify, publish, use, compile, sell, or 201 | distribute this software, either in source code form or as a compiled 202 | binary, for any purpose, commercial or non-commercial, and by any 203 | means. 204 | 205 | In jurisdictions that recognize copyright laws, the author or authors 206 | of this software dedicate any and all copyright interest in the 207 | software to the public domain. We make this dedication for the benefit 208 | of the public at large and to the detriment of our heirs and 209 | successors. We intend this dedication to be an overt act of 210 | relinquishment in perpetuity of all present and future rights to this 211 | software under copyright law. 212 | 213 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 214 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 215 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 216 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 217 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 218 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 219 | OTHER DEALINGS IN THE SOFTWARE. 220 | 221 | For more information, please refer to 222 | -------------------------------------------------------------------------------- /src/downloader/ListDownloader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RuntimeError, 3 | NetworkError 4 | } from "../Errors"; 5 | import * as async from "async"; 6 | import * as fs from "fs"; 7 | import { EventEmitter } from "events"; 8 | import { DownloadItem } from "../types/download"; 9 | import { RequesterCdn } from "../types/Requester"; 10 | import * as util from "util"; 11 | import * as stream from "stream"; 12 | import { Response } from "got/dist/source"; 13 | const pipeline = util.promisify(stream.pipeline); 14 | 15 | 16 | export interface DownloadUpdateOptions { 17 | filesInProgress: number; 18 | finishedFiles: number; 19 | downloadedSize: number; 20 | estimatedSize: number; 21 | speed: number; 22 | 23 | } 24 | export interface ListDownloader { 25 | on(name: "update", listener: (options: DownloadUpdateOptions) => void): this; 26 | } 27 | 28 | export class ListDownloader extends EventEmitter { 29 | _list: (DownloadItem & { downloadedSize?: number; totalSize?: number })[]; 30 | _attempts: number; 31 | _connections: number; 32 | _abort: boolean; 33 | _requester: RequesterCdn; 34 | 35 | _filesInProgress: number; 36 | 37 | _finishedFiles: number; 38 | _downloadedSize: number; 39 | _estimatedSize: number; 40 | 41 | _speed: number; 42 | _lastSpeedCheck: number; 43 | _downloadedSinceCheck: number; 44 | 45 | _lastUpdateEmit: number; 46 | 47 | constructor(list: DownloadItem[], attempts: number, connections: number, requester: RequesterCdn) { 48 | super(); 49 | this._list = list; 50 | this._attempts = attempts; 51 | this._connections = connections; 52 | this._requester = requester; 53 | this._abort = false; 54 | 55 | this._filesInProgress = 0; 56 | 57 | this._finishedFiles = 0; 58 | this._downloadedSize = 0; 59 | this._estimatedSize = 1; 60 | 61 | this._speed = 0; 62 | this._lastSpeedCheck = Date.now(); 63 | this._lastUpdateEmit = Date.now(); 64 | this._downloadedSinceCheck = 0; 65 | } 66 | _recalculateDownloaded(): void { 67 | this._downloadedSize = 0; 68 | for (const item of this._list) { 69 | if (item.downloadedSize) { 70 | this._downloadedSize += item.downloadedSize; 71 | } 72 | } 73 | } 74 | _estimateSize(): void { 75 | this._estimatedSize = 0; 76 | let numberKnownSize = 0; 77 | for (const item of this._list) { 78 | if (item.totalSize) { 79 | this._estimatedSize += item.totalSize; 80 | // console.log(item.totalSize); 81 | numberKnownSize++; 82 | } 83 | } 84 | if (numberKnownSize > 0) { 85 | const averageSize = this._estimatedSize / numberKnownSize; 86 | this._estimatedSize += averageSize * (this._list.length - numberKnownSize); 87 | } else { 88 | this._estimatedSize = 1; 89 | } 90 | 91 | 92 | } 93 | _emitUpdate(): void { 94 | if (this._abort) return; 95 | this.emit("update", { 96 | filesInProgress: this._filesInProgress, 97 | finishedFiles: this._finishedFiles, 98 | downloadedSize: this._downloadedSize, 99 | estimatedSize: this._estimatedSize, 100 | speed: this._speed, 101 | }); 102 | } 103 | _emitUpdateLimit(): void { 104 | if (this._abort) return; 105 | const timeDiff = Date.now() - this._lastUpdateEmit; 106 | if (timeDiff < 100) return; // update every 0.1 seconds 107 | this._lastUpdateEmit -= 100; 108 | this.emit("update", { 109 | filesInProgress: this._filesInProgress, 110 | finishedFiles: this._finishedFiles, 111 | downloadedSize: this._downloadedSize, 112 | estimatedSize: this._estimatedSize, 113 | speed: this._speed, 114 | }); 115 | } 116 | _updateSpeed(amount: number): void { 117 | this._downloadedSinceCheck += amount; 118 | const timeDiff = Date.now() - this._lastSpeedCheck; 119 | if (timeDiff >= 1000) { 120 | this._speed = this._downloadedSinceCheck / (timeDiff / 1000); 121 | this._downloadedSinceCheck = 0; 122 | this._lastSpeedCheck = Date.now(); 123 | } 124 | 125 | } 126 | async _downloadFile(index: number): Promise { 127 | this._filesInProgress++; 128 | 129 | const file = this._list[index]; 130 | for (let attempt = 0; attempt < this._attempts; attempt++) { 131 | try { 132 | file.downloadedSize = 0; 133 | let status = -1; 134 | let contentEncoding: string | undefined = "unset"; 135 | const stream = this._requester.stream(file.url); 136 | stream.on("response", (response: Response) => { 137 | status = response.statusCode; 138 | file.totalSize = parseInt(response.headers["content-length"] ?? "-2"); 139 | contentEncoding = response.headers["content-encoding"]; 140 | this._estimateSize(); 141 | this._emitUpdate(); 142 | }); 143 | stream.on("data", (chunk) => { 144 | file.downloadedSize += chunk.length; 145 | this._downloadedSize += chunk.length; 146 | this._updateSpeed(chunk.length); 147 | this._emitUpdateLimit(); 148 | }); 149 | await pipeline( 150 | stream, 151 | fs.createWriteStream(file.destination) 152 | ); 153 | 154 | if (status !== 200) { 155 | throw new NetworkError("Status code: " + status); 156 | } 157 | 158 | // check if file size it equal to transmitted size 159 | const s = (await fs.promises.stat(file.destination)).size; 160 | if (s !== file.downloadedSize) { 161 | console.log(s + " !== " + file.downloadedSize); 162 | throw new NetworkError("Transmission incomplete. (Downloaded Size)."); 163 | } 164 | // as long as the data is uncompressed content_length must be equal the file size 165 | if ((contentEncoding == "identity" || contentEncoding == undefined) && s !== file.totalSize) { 166 | console.log(s + " !== " + file.totalSize); 167 | throw new NetworkError("Transmission incomplete. (content_length)."); 168 | } 169 | 170 | return; 171 | } catch (e) { 172 | console.log("Error on " + file.url + "."); 173 | file.downloadedSize = 0; 174 | this._recalculateDownloaded(); 175 | this._emitUpdate(); 176 | if (attempt >= this._attempts - 1) { 177 | throw e; 178 | } 179 | console.log("Error: " + e.message + ". Retrying..."); 180 | } 181 | /*try { 182 | await new Promise((resolve, reject) => { 183 | file.downloadedSize = 0; 184 | request(file.url, { 185 | forever: true, 186 | timeout: 20000, 187 | proxy: this.options.httpProxyCdn 188 | }).on("error", (e) => { 189 | reject(new NetworkError(e.message)); 190 | }).on("response", (response) => { 191 | if (response.statusCode != 200) { 192 | reject(new NetworkError("HTTP status code: " + (response.statusCode))); 193 | return; 194 | } 195 | file.totalSize = Number.parseInt(response.headers['content-length'] ?? "1"); 196 | this.estimateSize(); 197 | this.emitUpdate(); 198 | response.on("data", (chunk) => { 199 | file.downloadedSize += chunk.length; 200 | this.downloadedSize += chunk.length; 201 | this.updateSpeed(chunk.length) 202 | this.emitUpdate(); 203 | }); 204 | response.pipe(fs.createWriteStream(file.destination)).on("finish", (resolve)); 205 | }) 206 | }); 207 | this.filesInProgress--; 208 | return; 209 | } catch (e) { 210 | file.downloadedSize = 0; 211 | this.recalculateDownloaded(); 212 | this.emitUpdate(); 213 | if (!(e instanceof NetworkError) || attempt >= this.options.maxAttempts - 1 || this.abort) { 214 | this.filesInProgress--; 215 | throw e; 216 | } 217 | console.log("Network error: " + e.message + ". Retrying..."); 218 | }*/ 219 | } 220 | throw new RuntimeError("Too many attempts. (This code should't be reachable)"); 221 | } 222 | startDownload(): Promise { 223 | this._lastSpeedCheck = Date.now(); 224 | this._lastUpdateEmit = Date.now(); 225 | this._downloadedSinceCheck = 0; 226 | return new Promise((resolve, reject) => { 227 | async.forEachOfLimit(this._list, 5, (value, key: number | string, callback) => { 228 | this._downloadFile(key as number).then(() => { callback(); }, callback); 229 | }, err => { 230 | if (err) { 231 | this._abort = true; 232 | this.emit("error", err); 233 | reject(err); 234 | return; 235 | } 236 | this.emit("finish"); 237 | resolve(); 238 | }); 239 | }); 240 | } 241 | static async safeDownload(url: string, destination: string, maxAttempts: number, requester: RequesterCdn): Promise { 242 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 243 | try { 244 | let status = -1; 245 | 246 | let contentLength = -1; 247 | let contentEncoding: string | undefined = "unset"; 248 | let downloadedSize = 0; 249 | 250 | const stream = requester.stream(url); 251 | stream.on("response", (response: Response) => { 252 | status = response.statusCode; 253 | contentLength = parseInt(response.headers["content-length"] ?? "-2"); 254 | contentEncoding = response.headers["content-encoding"]; 255 | }); 256 | stream.on("data", (chunk) => { 257 | downloadedSize += chunk.length; 258 | }); 259 | await pipeline( 260 | stream, 261 | fs.createWriteStream(destination) 262 | ); 263 | 264 | if (status !== 200) { 265 | throw new NetworkError("Status code: " + status); 266 | } 267 | 268 | // check if file size it equal to transmitted size 269 | const s = (await fs.promises.stat(destination)).size; 270 | if (s !== downloadedSize) { 271 | console.log(s + " !== " + downloadedSize); 272 | throw new NetworkError("Transmission incomplete. (Downloaded Size)."); 273 | } 274 | // as long as the data is uncompressed content_length must be equal the file size 275 | if ((contentEncoding == "identity" || contentEncoding == undefined) && s !== contentLength) { 276 | console.log(s + " !== " + contentLength); 277 | throw new NetworkError("Transmission incomplete. (content_length)."); 278 | } 279 | 280 | return; 281 | } catch (e) { 282 | console.log("Error on " + url + "."); 283 | if (attempt >= maxAttempts - 1) { 284 | throw e; 285 | } 286 | console.log("Network error: " + e.message + ". Retrying..."); 287 | } 288 | } 289 | throw new RuntimeError("Too many attempts. (This code should't be reachable)"); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/commands/cr-dl-download.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import { loadCookies, getRequester, saveCookies, getRequesterCdn } from "./common"; 4 | import { CrDl, Episode } from "../api/CrDl"; 5 | import { UserInputError, RuntimeError } from "../Errors"; 6 | import { languages, Language } from "../types/language"; 7 | import { makeid, pad, toFilename, formatScene, deleteFolderRecursive } from "../Utils"; 8 | import * as util from "util"; 9 | import * as fs from "fs"; 10 | import * as path from "path"; 11 | import { SubtitleInfo, StreamInfo } from "../interfaces/video"; 12 | import { downloadFontsFromSubtitles } from "../downloader/FontDownloader"; 13 | import { Requester, RequesterCdn } from "../types/Requester"; 14 | import * as format_ from "string-format"; 15 | import { M3uDownloader } from "../downloader/M3uDownloader"; 16 | import { ListDownloader, DownloadUpdateOptions } from "../downloader/ListDownloader"; 17 | import { VideoMuxer } from "../downloader/VideoMuxer"; 18 | import * as cliProgress from "cli-progress"; 19 | import prettyBytes from "pretty-bytes"; 20 | import { spawn } from "child_process"; 21 | const format = format_.create({ 22 | scene: formatScene 23 | }); 24 | export const download = new Command(); 25 | 26 | interface Options { 27 | proxy?: string; 28 | proxyCdn?: string; 29 | format: string; 30 | connections: number; 31 | listSubs: boolean; 32 | defaultSub?: Language | "none"; 33 | subLang?: (Language | "none")[]; 34 | hardsub: boolean; 35 | attachFonts: boolean; 36 | subsOnly: boolean; 37 | output?: string; 38 | progressBar: boolean; 39 | retry: number; 40 | season?: string[]; 41 | episode?: string[]; 42 | cookies: string; 43 | } 44 | 45 | let requester: Requester; 46 | let requesterCdn: RequesterCdn; 47 | 48 | download 49 | .name("download").alias("dl") 50 | .description("Download video or series from URL") 51 | .arguments("") 52 | .option("-f, --format ", "Video resolution", "1080p") 53 | .option("--season ", "A season number or a comma-separated list (without spaces) of season numbers to download. A ```-``` (minus) can be used to specify a range (e.g. ```1,3-5```). Works only for series-links. Note: Season 1 is the bottom-most season on the website.") 54 | .option("--episode ", "A comma-separated list of episode numbers to download. A ```-``` (minus) can be used to specify a range (e.g. ```01,03-05,SP2```). If a given episode number exists in multiple seasons, you must specify one with --season.") 55 | .option("-c, --connections ", "Number of simultaneous connections", "5") 56 | .option("--sub-lang ", "Specify subtitle languages as a comma separated list to include in video. (e.g. deDE,enUS). Set to ```none``` to embed no subtitles. Use --list-subs for available languages. (Default: All available)") 57 | .option("--default-sub ", "Specify subtitle language to be set as default. (e.g. enUS). (Default: if --sub-lang defined: first entry, otherwise: crunchyroll default)") 58 | .option("--attach-fonts", "Automatically download and attach all fonts that are used in subtitles.") 59 | .option("--list-subs", "Don't download. List all available subtitles for the video.") 60 | .option("--subs-only", "Download only subtitles. No Video.") 61 | .option("--hardsub", "Download hardsubbed video stream. Only one subtitle language specified by --default-sub will be included.") 62 | .option("--retry ", "Max number of download attempts before aborting.", "5") 63 | .option("--cookies ", "File to read cookies from and dump cookie jar in", "cookies.txt") 64 | .option("--no-progress-bar", "Hide progress bar.") 65 | .option("--proxy ", "HTTP(s) proxy to access Crunchyroll. This is enough to bypass geo-blocking.") 66 | .option("--proxy-cdn ", "HTTP proxy used to download video files. Not required for bypassing geo-blocking.") 67 | .option("-o, --output