added decryption for crunchyroll
This commit is contained in:
parent
a34c57c46f
commit
200f757a18
4
.gitignore
vendored
4
.gitignore
vendored
@ -21,3 +21,7 @@ crunchyroll-downloader-output-*/
|
|||||||
|
|
||||||
# FFMPEG
|
# FFMPEG
|
||||||
ffmpeg/
|
ffmpeg/
|
||||||
|
|
||||||
|
# MP4DECRYPT
|
||||||
|
mp4decrypt/
|
||||||
|
keys/
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"name": "crunchyroll-downloader",
|
"name": "crunchyroll-downloader",
|
||||||
"author": "Stratum",
|
"author": "Stratum",
|
||||||
"description": "Crunchyroll Downloader",
|
"description": "Crunchyroll Downloader",
|
||||||
"version": "1.0.7",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": ".output/src/electron/background.js",
|
"main": ".output/src/electron/background.js",
|
||||||
"repository": "https://github.com/stratuma/Crunchyroll-Downloader-v4.0",
|
"repository": "https://github.com/stratuma/Crunchyroll-Downloader-v4.0",
|
||||||
@ -54,15 +54,19 @@
|
|||||||
"fastify": "^4.26.2",
|
"fastify": "^4.26.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
|
"long": "^5.2.3",
|
||||||
"mpd-parser": "^1.3.0",
|
"mpd-parser": "^1.3.0",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"protobufjs": "^7.2.6",
|
||||||
"sequelize": "^6.37.3",
|
"sequelize": "^6.37.3",
|
||||||
"sqlite3": "5.1.6"
|
"sqlite3": "5.1.6"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
"./ffmpeg/**"
|
"./ffmpeg/**",
|
||||||
|
"./mp4decrypt/**",
|
||||||
|
"./keys/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
83
pnpm-lock.yaml
generated
83
pnpm-lock.yaml
generated
@ -47,6 +47,9 @@ importers:
|
|||||||
jsencrypt:
|
jsencrypt:
|
||||||
specifier: ^3.3.2
|
specifier: ^3.3.2
|
||||||
version: 3.3.2
|
version: 3.3.2
|
||||||
|
long:
|
||||||
|
specifier: ^5.2.3
|
||||||
|
version: 5.2.3
|
||||||
mpd-parser:
|
mpd-parser:
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0
|
version: 1.3.0
|
||||||
@ -56,6 +59,9 @@ importers:
|
|||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
protobufjs:
|
||||||
|
specifier: ^7.2.6
|
||||||
|
version: 7.2.6
|
||||||
sequelize:
|
sequelize:
|
||||||
specifier: ^6.37.3
|
specifier: ^6.37.3
|
||||||
version: 6.37.3(sqlite3@5.1.6(encoding@0.1.13))
|
version: 6.37.3(sqlite3@5.1.6(encoding@0.1.13))
|
||||||
@ -858,6 +864,36 @@ packages:
|
|||||||
'@polka/url@1.0.0-next.25':
|
'@polka/url@1.0.0-next.25':
|
||||||
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
|
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2':
|
||||||
|
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2':
|
||||||
|
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.4':
|
||||||
|
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.0':
|
||||||
|
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.0':
|
||||||
|
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2':
|
||||||
|
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.0':
|
||||||
|
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2':
|
||||||
|
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0':
|
||||||
|
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||||
|
|
||||||
'@rollup/plugin-alias@5.1.0':
|
'@rollup/plugin-alias@5.1.0':
|
||||||
resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==}
|
resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@ -3746,6 +3782,9 @@ packages:
|
|||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
long@5.2.3:
|
||||||
|
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
|
||||||
|
|
||||||
lowercase-keys@2.0.0:
|
lowercase-keys@2.0.0:
|
||||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -4679,6 +4718,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
protobufjs@7.2.6:
|
||||||
|
resolution: {integrity: sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
protocols@2.0.1:
|
protocols@2.0.1:
|
||||||
resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==}
|
resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==}
|
||||||
|
|
||||||
@ -7032,6 +7075,29 @@ snapshots:
|
|||||||
|
|
||||||
'@polka/url@1.0.0-next.25': {}
|
'@polka/url@1.0.0-next.25': {}
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.4': {}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
'@protobufjs/inquire': 1.1.0
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.0': {}
|
||||||
|
|
||||||
'@rollup/plugin-alias@5.1.0(rollup@4.17.2)':
|
'@rollup/plugin-alias@5.1.0(rollup@4.17.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
slash: 4.0.0
|
slash: 4.0.0
|
||||||
@ -10591,6 +10657,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
|
long@5.2.3: {}
|
||||||
|
|
||||||
lowercase-keys@2.0.0: {}
|
lowercase-keys@2.0.0: {}
|
||||||
|
|
||||||
lru-cache@10.2.2: {}
|
lru-cache@10.2.2: {}
|
||||||
@ -11732,6 +11800,21 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
protobufjs@7.2.6:
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
'@protobufjs/base64': 1.1.2
|
||||||
|
'@protobufjs/codegen': 2.0.4
|
||||||
|
'@protobufjs/eventemitter': 1.1.0
|
||||||
|
'@protobufjs/fetch': 1.1.0
|
||||||
|
'@protobufjs/float': 1.0.2
|
||||||
|
'@protobufjs/inquire': 1.1.0
|
||||||
|
'@protobufjs/path': 1.1.2
|
||||||
|
'@protobufjs/pool': 1.1.0
|
||||||
|
'@protobufjs/utf8': 1.1.0
|
||||||
|
'@types/node': 20.12.7
|
||||||
|
long: 5.2.3
|
||||||
|
|
||||||
protocols@2.0.1: {}
|
protocols@2.0.1: {}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
|
114
src/api/modules/cmac.ts
Normal file
114
src/api/modules/cmac.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
//Code from https://github.com/Frooastside/node-widevine/
|
||||||
|
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export class AES_CMAC {
|
||||||
|
private readonly BLOCK_SIZE = 16
|
||||||
|
private readonly XOR_RIGHT = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87])
|
||||||
|
private readonly EMPTY_BLOCK_SIZE_BUFFER = Buffer.alloc(this.BLOCK_SIZE)
|
||||||
|
|
||||||
|
private _key: Buffer
|
||||||
|
private _subkeys: { first: Buffer; second: Buffer }
|
||||||
|
|
||||||
|
public constructor(key: Buffer) {
|
||||||
|
if (![16, 24, 32].includes(key.length)) {
|
||||||
|
throw new Error('Key size must be 128, 192, or 256 bits.')
|
||||||
|
}
|
||||||
|
this._key = key
|
||||||
|
this._subkeys = this._generateSubkeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
public calculate(message: Buffer): Buffer {
|
||||||
|
const blockCount = this._getBlockCount(message)
|
||||||
|
|
||||||
|
let x = this.EMPTY_BLOCK_SIZE_BUFFER
|
||||||
|
let y
|
||||||
|
|
||||||
|
for (let i = 0; i < blockCount - 1; i++) {
|
||||||
|
const from = i * this.BLOCK_SIZE
|
||||||
|
const block = message.subarray(from, from + this.BLOCK_SIZE)
|
||||||
|
y = this._xor(x, block)
|
||||||
|
x = this._aes(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
y = this._xor(x, this._getLastBlock(message))
|
||||||
|
x = this._aes(y)
|
||||||
|
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
private _generateSubkeys(): { first: Buffer; second: Buffer } {
|
||||||
|
const l = this._aes(this.EMPTY_BLOCK_SIZE_BUFFER)
|
||||||
|
|
||||||
|
let first = this._bitShiftLeft(l)
|
||||||
|
if (l[0] & 0x80) {
|
||||||
|
first = this._xor(first, this.XOR_RIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = this._bitShiftLeft(first)
|
||||||
|
if (first[0] & 0x80) {
|
||||||
|
second = this._xor(second, this.XOR_RIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { first: first, second: second }
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getBlockCount(message: Buffer): number {
|
||||||
|
const blockCount = Math.ceil(message.length / this.BLOCK_SIZE)
|
||||||
|
return blockCount === 0 ? 1 : blockCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private _aes(message: Buffer): Buffer {
|
||||||
|
const cipher = crypto.createCipheriv(`aes-${this._key.length * 8}-cbc`, this._key, Buffer.alloc(this.BLOCK_SIZE))
|
||||||
|
const result = cipher.update(message).subarray(0, 16)
|
||||||
|
cipher.destroy()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getLastBlock(message: Buffer): Buffer {
|
||||||
|
const blockCount = this._getBlockCount(message)
|
||||||
|
const paddedBlock = this._padding(message, blockCount - 1)
|
||||||
|
|
||||||
|
let complete = false
|
||||||
|
if (message.length > 0) {
|
||||||
|
complete = message.length % this.BLOCK_SIZE === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = complete ? this._subkeys.first : this._subkeys.second
|
||||||
|
return this._xor(paddedBlock, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _padding(message: Buffer, blockIndex: number): Buffer {
|
||||||
|
const block = Buffer.alloc(this.BLOCK_SIZE)
|
||||||
|
|
||||||
|
const from = blockIndex * this.BLOCK_SIZE
|
||||||
|
|
||||||
|
const slice = message.subarray(from, from + this.BLOCK_SIZE)
|
||||||
|
block.set(slice)
|
||||||
|
|
||||||
|
if (slice.length !== this.BLOCK_SIZE) {
|
||||||
|
block[slice.length] = 0x80
|
||||||
|
}
|
||||||
|
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bitShiftLeft(input: Buffer): Buffer {
|
||||||
|
const output = Buffer.alloc(input.length)
|
||||||
|
let overflow = 0
|
||||||
|
for (let i = input.length - 1; i >= 0; i--) {
|
||||||
|
output[i] = (input[i] << 1) | overflow
|
||||||
|
overflow = input[i] & 0x80 ? 1 : 0
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private _xor(a: Buffer, b: Buffer): Buffer {
|
||||||
|
const length = Math.min(a.length, b.length)
|
||||||
|
const output = Buffer.alloc(length)
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
output[i] = a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
148
src/api/modules/license.ts
Normal file
148
src/api/modules/license.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
//Code from https://github.com/Frooastside/node-widevine/
|
||||||
|
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import Long from 'long'
|
||||||
|
import { AES_CMAC } from './cmac'
|
||||||
|
import {
|
||||||
|
ClientIdentification,
|
||||||
|
License,
|
||||||
|
LicenseRequest,
|
||||||
|
LicenseRequest_RequestType,
|
||||||
|
LicenseType,
|
||||||
|
ProtocolVersion,
|
||||||
|
SignedMessage,
|
||||||
|
SignedMessage_MessageType,
|
||||||
|
SignedMessage_SessionKeyType,
|
||||||
|
WidevinePsshData
|
||||||
|
} from './license_protocol'
|
||||||
|
|
||||||
|
const WIDEVINE_SYSTEM_ID = new Uint8Array([237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237])
|
||||||
|
|
||||||
|
export type KeyContainer = {
|
||||||
|
kid: string
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentDecryptionModule = {
|
||||||
|
privateKey: Buffer
|
||||||
|
identifierBlob: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Session {
|
||||||
|
private _devicePrivateKey: crypto.KeyObject
|
||||||
|
private _identifierBlob: ClientIdentification
|
||||||
|
private _identifier: Buffer
|
||||||
|
private _pssh: Buffer
|
||||||
|
private _rawLicenseRequest?: Buffer
|
||||||
|
|
||||||
|
constructor(contentDecryptionModule: ContentDecryptionModule, pssh: Buffer) {
|
||||||
|
this._devicePrivateKey = crypto.createPrivateKey(contentDecryptionModule.privateKey)
|
||||||
|
this._identifierBlob = ClientIdentification.decode(contentDecryptionModule.identifierBlob)
|
||||||
|
this._identifier = this._generateIdentifier()
|
||||||
|
this._pssh = pssh
|
||||||
|
}
|
||||||
|
|
||||||
|
createLicenseRequest(): Buffer {
|
||||||
|
if (!this._pssh.subarray(12, 28).equals(Buffer.from(WIDEVINE_SYSTEM_ID))) {
|
||||||
|
throw new Error('the pssh is not an actuall pssh')
|
||||||
|
}
|
||||||
|
const pssh = this._parsePSSH(this._pssh)
|
||||||
|
if (!pssh) {
|
||||||
|
throw new Error('pssh is invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenseRequest: LicenseRequest = {
|
||||||
|
type: LicenseRequest_RequestType.NEW,
|
||||||
|
clientId: this._identifierBlob,
|
||||||
|
contentId: {
|
||||||
|
widevinePsshData: {
|
||||||
|
psshData: [this._pssh.subarray(32)],
|
||||||
|
licenseType: LicenseType.STREAMING,
|
||||||
|
requestId: this._identifier
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestTime: Long.fromNumber(Date.now()).divide(1000),
|
||||||
|
protocolVersion: ProtocolVersion.VERSION_2_1,
|
||||||
|
keyControlNonce: crypto.randomInt(2 ** 31),
|
||||||
|
keyControlNonceDeprecated: Buffer.alloc(0),
|
||||||
|
encryptedClientId: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
this._rawLicenseRequest = Buffer.from(LicenseRequest.encode(licenseRequest).finish())
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createSign('sha1')
|
||||||
|
.update(this._rawLicenseRequest)
|
||||||
|
.sign({ key: this._devicePrivateKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 20 })
|
||||||
|
|
||||||
|
const signedLicenseRequest: SignedMessage = {
|
||||||
|
type: SignedMessage_MessageType.LICENSE_REQUEST,
|
||||||
|
msg: this._rawLicenseRequest,
|
||||||
|
signature: Buffer.from(signature),
|
||||||
|
sessionKey: Buffer.alloc(0),
|
||||||
|
remoteAttestation: Buffer.alloc(0),
|
||||||
|
metricData: [],
|
||||||
|
serviceVersionInfo: undefined,
|
||||||
|
sessionKeyType: SignedMessage_SessionKeyType.UNDEFINED,
|
||||||
|
oemcryptoCoreMessage: Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(SignedMessage.encode(signedLicenseRequest).finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
parseLicense(rawLicense: Buffer) {
|
||||||
|
if (!this._rawLicenseRequest) {
|
||||||
|
throw new Error('please request a license first')
|
||||||
|
}
|
||||||
|
const signedLicense = SignedMessage.decode(rawLicense)
|
||||||
|
const sessionKey = crypto.privateDecrypt(this._devicePrivateKey, signedLicense.sessionKey)
|
||||||
|
|
||||||
|
const cmac = new AES_CMAC(Buffer.from(sessionKey))
|
||||||
|
|
||||||
|
const encKeyBase = Buffer.concat([Buffer.from('ENCRYPTION'), Buffer.from('\x00', 'ascii'), this._rawLicenseRequest, Buffer.from('\x00\x00\x00\x80', 'ascii')])
|
||||||
|
const authKeyBase = Buffer.concat([Buffer.from('AUTHENTICATION'), Buffer.from('\x00', 'ascii'), this._rawLicenseRequest, Buffer.from('\x00\x00\x02\x00', 'ascii')])
|
||||||
|
|
||||||
|
const encKey = cmac.calculate(Buffer.concat([Buffer.from('\x01'), encKeyBase]))
|
||||||
|
const serverKey = Buffer.concat([cmac.calculate(Buffer.concat([Buffer.from('\x01'), authKeyBase])), cmac.calculate(Buffer.concat([Buffer.from('\x02'), authKeyBase]))])
|
||||||
|
/*const clientKey = Buffer.concat([
|
||||||
|
cmac.calculate(Buffer.concat([Buffer.from("\x03"), authKeyBase])),
|
||||||
|
cmac.calculate(Buffer.concat([Buffer.from("\x04"), authKeyBase]))
|
||||||
|
]);*/
|
||||||
|
|
||||||
|
const calculatedSignature = crypto.createHmac('sha256', serverKey).update(signedLicense.msg).digest()
|
||||||
|
|
||||||
|
if (!calculatedSignature.equals(signedLicense.signature)) {
|
||||||
|
throw new Error('signatures do not match')
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = License.decode(signedLicense.msg)
|
||||||
|
|
||||||
|
return license.key.map((keyContainer) => {
|
||||||
|
const keyId = keyContainer.id.length ? keyContainer.id.toString('hex') : keyContainer.type.toString()
|
||||||
|
const decipher = crypto.createDecipheriv(`aes-${encKey.length * 8}-cbc`, encKey, keyContainer.iv)
|
||||||
|
const decryptedKey = decipher.update(keyContainer.key)
|
||||||
|
decipher.destroy()
|
||||||
|
const key: KeyContainer = {
|
||||||
|
kid: keyId,
|
||||||
|
key: decryptedKey.toString('hex')
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parsePSSH(pssh: Buffer): WidevinePsshData | null {
|
||||||
|
try {
|
||||||
|
return WidevinePsshData.decode(pssh.subarray(32))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _generateIdentifier(): Buffer {
|
||||||
|
return Buffer.from(`${crypto.randomBytes(8).toString('hex')}${'01'}${'00000000000000'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
get pssh(): Buffer {
|
||||||
|
return this._pssh
|
||||||
|
}
|
||||||
|
}
|
4893
src/api/modules/license_protocol.ts
Normal file
4893
src/api/modules/license_protocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -108,23 +108,15 @@ export async function crunchyGetPlaylist(q: string) {
|
|||||||
|
|
||||||
if (!account) return
|
if (!account) return
|
||||||
|
|
||||||
const { data, error } = await crunchyLogin(account.username, account.password)
|
const { data: login, error } = await crunchyLogin(account.username, account.password)
|
||||||
|
|
||||||
if (!data) return
|
if (!login) return
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Authorization: `Bearer ${data.access_token}`,
|
Authorization: `Bearer ${login.access_token}`,
|
||||||
'X-Cr-Disable-Drm': 'true'
|
'X-Cr-Disable-Drm': 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
const query: any = {
|
|
||||||
q: q,
|
|
||||||
n: 100,
|
|
||||||
type: 'series',
|
|
||||||
ratings: false,
|
|
||||||
locale: 'de-DE'
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/console/switch/play`, {
|
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/console/switch/play`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -138,7 +130,42 @@ export async function crunchyGetPlaylist(q: string) {
|
|||||||
|
|
||||||
data.subtitles = Object.values((data as any).subtitles)
|
data.subtitles = Object.values((data as any).subtitles)
|
||||||
|
|
||||||
return data
|
return { data: data, account_id: login.account_id }
|
||||||
|
} else {
|
||||||
|
throw new Error(await response.text())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crunchyGetPlaylistDRM(q: string) {
|
||||||
|
const account = await loggedInCheck('CR')
|
||||||
|
|
||||||
|
if (!account) return
|
||||||
|
|
||||||
|
const { data: login, error } = await crunchyLogin(account.username, account.password)
|
||||||
|
|
||||||
|
if (!login) return
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${login.access_token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/web/chrome/play`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: VideoPlaylist = JSON.parse(await response.text())
|
||||||
|
|
||||||
|
data.hardSubs = Object.values((data as any).hardSubs)
|
||||||
|
|
||||||
|
data.subtitles = Object.values((data as any).subtitles)
|
||||||
|
|
||||||
|
return { data: data, account_id: login.account_id }
|
||||||
} else {
|
} else {
|
||||||
throw new Error(await response.text())
|
throw new Error(await response.text())
|
||||||
}
|
}
|
||||||
@ -157,8 +184,7 @@ export async function crunchyGetPlaylistMPD(q: string) {
|
|||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Authorization: `Bearer ${data.access_token}`,
|
Authorization: `Bearer ${data.access_token}`
|
||||||
'X-Cr-Disable-Drm': 'true'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -168,7 +194,9 @@ export async function crunchyGetPlaylistMPD(q: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const parsed = mpdParse(await response.text())
|
const raw = await response.text()
|
||||||
|
|
||||||
|
const parsed = mpdParse(raw)
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,7 @@ import { concatenateTSFiles } from '../../services/concatenate'
|
|||||||
import { createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
|
import { createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
|
||||||
import { downloadADNSub, downloadCRSub } from '../../services/subs'
|
import { downloadADNSub, downloadCRSub } from '../../services/subs'
|
||||||
import { CrunchyEpisode } from '../../types/crunchyroll'
|
import { CrunchyEpisode } from '../../types/crunchyroll'
|
||||||
import { crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
|
import { crunchyGetPlaylist, crunchyGetPlaylistDRM, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
var cron = require('node-cron')
|
var cron = require('node-cron')
|
||||||
import { Readable } from 'stream'
|
import { Readable } from 'stream'
|
||||||
@ -14,7 +14,12 @@ import { adnGetM3U8Playlist, adnGetPlaylist } from '../adn/adn.service'
|
|||||||
import { ADNEpisode } from '../../types/adn'
|
import { ADNEpisode } from '../../types/adn'
|
||||||
import { setProgressBar } from '../../../electron/background'
|
import { setProgressBar } from '../../../electron/background'
|
||||||
import { getFFMPEGPath } from '../../services/ffmpeg'
|
import { getFFMPEGPath } from '../../services/ffmpeg'
|
||||||
|
import { getDRMKeys, Uint8ArrayToBase64 } from '../../services/decryption'
|
||||||
|
import { getMP4DecryptPath } from '../../services/mp4decrypt'
|
||||||
const ffmpegP = getFFMPEGPath()
|
const ffmpegP = getFFMPEGPath()
|
||||||
|
const mp4e = getMP4DecryptPath()
|
||||||
|
import util from 'util'
|
||||||
|
const exec = util.promisify(require('child_process').exec)
|
||||||
|
|
||||||
// DB Account existence check
|
// DB Account existence check
|
||||||
export async function loggedInCheck(service: string) {
|
export async function loggedInCheck(service: string) {
|
||||||
@ -327,9 +332,9 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.versions && playlist.versions.length !== 0) {
|
if (playlist.data.versions && playlist.data.versions.length !== 0) {
|
||||||
if (playlist.audioLocale !== subs[0]) {
|
if (playlist.data.audioLocale !== subs[0]) {
|
||||||
const found = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
|
const found = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||||
if (found) {
|
if (found) {
|
||||||
playlist = await crunchyGetPlaylist(found.guid)
|
playlist = await crunchyGetPlaylist(found.guid)
|
||||||
}
|
}
|
||||||
@ -369,10 +374,10 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
for (const s of subs) {
|
for (const s of subs) {
|
||||||
var subPlaylist
|
var subPlaylist
|
||||||
|
|
||||||
if (playlist.audioLocale !== 'ja-JP') {
|
if (playlist.data.audioLocale !== 'ja-JP') {
|
||||||
const foundStream = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
|
const foundStream = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||||
if (foundStream) {
|
if (foundStream) {
|
||||||
subPlaylist = await crunchyGetPlaylist(foundStream.guid)
|
subPlaylist = await crunchyGetPlaylistDRM(foundStream.guid)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subPlaylist = playlist
|
subPlaylist = playlist
|
||||||
@ -383,7 +388,7 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = subPlaylist.subtitles.find((sub) => sub.language === s)
|
const found = subPlaylist.data.subtitles.find((sub) => sub.language === s)
|
||||||
if (found) {
|
if (found) {
|
||||||
subDownloadList.push({ ...found, isDub: false })
|
subDownloadList.push({ ...found, isDub: false })
|
||||||
console.log(`Subtitle ${s}.ass found, adding to download`)
|
console.log(`Subtitle ${s}.ass found, adding to download`)
|
||||||
@ -394,14 +399,14 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
for (const d of dubs) {
|
for (const d of dubs) {
|
||||||
var found
|
var found
|
||||||
if (playlist.versions) {
|
if (playlist.data.versions) {
|
||||||
found = playlist.versions.find((p) => p.audio_locale === d)
|
found = playlist.data.versions.find((p) => p.audio_locale === d)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
const list = await crunchyGetPlaylist(found.guid)
|
const list = await crunchyGetPlaylistDRM(found.guid)
|
||||||
if (list) {
|
if (list) {
|
||||||
const foundSub = list.subtitles.find((sub) => sub.language === d)
|
const foundSub = list.data.subtitles.find((sub) => sub.language === d)
|
||||||
if (foundSub) {
|
if (foundSub) {
|
||||||
subDownloadList.push({ ...foundSub, isDub: true })
|
subDownloadList.push({ ...foundSub, isDub: true })
|
||||||
} else {
|
} else {
|
||||||
@ -410,8 +415,8 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
}
|
}
|
||||||
dubDownloadList.push(found)
|
dubDownloadList.push(found)
|
||||||
console.log(`Audio ${d}.aac found, adding to download`)
|
console.log(`Audio ${d}.aac found, adding to download`)
|
||||||
} else if (playlist.versions.length === 0) {
|
} else if (playlist.data.versions.length === 0) {
|
||||||
const foundSub = playlist.subtitles.find((sub) => sub.language === d)
|
const foundSub = playlist.data.subtitles.find((sub) => sub.language === d)
|
||||||
if (foundSub) {
|
if (foundSub) {
|
||||||
subDownloadList.push({ ...foundSub, isDub: true })
|
subDownloadList.push({ ...foundSub, isDub: true })
|
||||||
} else {
|
} else {
|
||||||
@ -432,7 +437,7 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dubDownloadList.length === 0) {
|
if (dubDownloadList.length === 0) {
|
||||||
const jpVersion = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
|
const jpVersion = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||||
|
|
||||||
if (jpVersion) {
|
if (jpVersion) {
|
||||||
console.log('Using ja-JP Audio because no Audio in download list')
|
console.log('Using ja-JP Audio because no Audio in download list')
|
||||||
@ -452,16 +457,30 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
const audioDownload = async () => {
|
const audioDownload = async () => {
|
||||||
const audios: Array<string> = []
|
const audios: Array<string> = []
|
||||||
for (const v of dubDownloadList) {
|
for (const v of dubDownloadList) {
|
||||||
const list = await crunchyGetPlaylist(v.guid)
|
const list = await crunchyGetPlaylistDRM(v.guid)
|
||||||
|
|
||||||
if (!list) return
|
if (!list) return
|
||||||
|
|
||||||
const playlist = await crunchyGetPlaylistMPD(list.url)
|
const playlist = await crunchyGetPlaylistMPD(list.data.url)
|
||||||
|
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
|
|
||||||
|
const assetId = playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].uri.match(/\/assets\/(?:p\/)?([^_,]+)/)
|
||||||
|
|
||||||
|
if (!assetId) return
|
||||||
|
|
||||||
|
var pssh
|
||||||
|
var keys: { kid: string; key: string }[] | undefined
|
||||||
|
|
||||||
var p: { filename: string; url: string }[] = []
|
var p: { filename: string; url: string }[] = []
|
||||||
|
|
||||||
|
if (playlist.mediaGroups.AUDIO.audio.main.playlists[0].contentProtection) {
|
||||||
|
if (!playlist.mediaGroups.AUDIO.audio.main.playlists[0].contentProtection['com.widevine.alpha'].pssh) return
|
||||||
|
pssh = Uint8ArrayToBase64(playlist.mediaGroups.AUDIO.audio.main.playlists[0].contentProtection['com.widevine.alpha'].pssh)
|
||||||
|
|
||||||
|
keys = await getDRMKeys(pssh, assetId[1], list.account_id)
|
||||||
|
}
|
||||||
|
|
||||||
p.push({
|
p.push({
|
||||||
filename: (playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
|
filename: (playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
|
||||||
url: playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.resolvedUri
|
url: playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.resolvedUri
|
||||||
@ -474,7 +493,7 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = await downloadMPDAudio(p, audioFolder, list.audioLocale)
|
const path = await downloadMPDAudio(p, audioFolder, list.data.audioLocale, keys ? keys : undefined)
|
||||||
|
|
||||||
audios.push(path as string)
|
audios.push(path as string)
|
||||||
}
|
}
|
||||||
@ -486,11 +505,11 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
|
|
||||||
if (playlist.versions && playlist.versions.length !== 0) {
|
if (playlist.data.versions && playlist.data.versions.length !== 0) {
|
||||||
if (playlist.versions.find((p) => p.audio_locale === dubs[0])) {
|
if (playlist.data.versions.find((p) => p.audio_locale === dubs[0])) {
|
||||||
code = playlist.versions.find((p) => p.audio_locale === dubs[0])?.guid
|
code = playlist.data.versions.find((p) => p.audio_locale === dubs[0])?.guid
|
||||||
} else {
|
} else {
|
||||||
code = playlist.versions.find((p) => p.audio_locale === 'ja-JP')?.guid
|
code = playlist.data.versions.find((p) => p.audio_locale === 'ja-JP')?.guid
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
code = e
|
code = e
|
||||||
@ -498,24 +517,24 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
if (!code) return console.error('No clean stream found')
|
if (!code) return console.error('No clean stream found')
|
||||||
|
|
||||||
const play = await crunchyGetPlaylist(code)
|
const play = await crunchyGetPlaylistDRM(code)
|
||||||
|
|
||||||
if (!play) return
|
if (!play) return
|
||||||
|
|
||||||
var downloadURL
|
var downloadURL
|
||||||
|
|
||||||
if (hardsub) {
|
if (hardsub) {
|
||||||
const hardsubURL = play.hardSubs.find((h) => h.hlang === subs[0])?.url
|
const hardsubURL = play.data.hardSubs.find((h) => h.hlang === subs[0])?.url
|
||||||
|
|
||||||
if (hardsubURL) {
|
if (hardsubURL) {
|
||||||
downloadURL = hardsubURL
|
downloadURL = hardsubURL
|
||||||
console.log('Hardsub Playlist found')
|
console.log('Hardsub Playlist found')
|
||||||
} else {
|
} else {
|
||||||
downloadURL = play.url
|
downloadURL = play.data.url
|
||||||
console.log('Hardsub Playlist not found')
|
console.log('Hardsub Playlist not found')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
downloadURL = play.url
|
downloadURL = play.data.url
|
||||||
console.log('Hardsub disabled, skipping')
|
console.log('Hardsub disabled, skipping')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,6 +546,20 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
if (!hq) return
|
if (!hq) return
|
||||||
|
|
||||||
|
const assetId = hq.segments[0].uri.match(/\/assets\/(?:p\/)?([^_,]+)/)
|
||||||
|
|
||||||
|
if (!assetId) return
|
||||||
|
|
||||||
|
var pssh
|
||||||
|
var keys: { kid: string; key: string }[] | undefined
|
||||||
|
|
||||||
|
if (hq.contentProtection) {
|
||||||
|
if (!hq.contentProtection['com.widevine.alpha'].pssh) return
|
||||||
|
pssh = Uint8ArrayToBase64(hq.contentProtection['com.widevine.alpha'].pssh)
|
||||||
|
|
||||||
|
keys = await getDRMKeys(pssh, assetId[1], play.account_id)
|
||||||
|
}
|
||||||
|
|
||||||
var p: { filename: string; url: string }[] = []
|
var p: { filename: string; url: string }[] = []
|
||||||
|
|
||||||
p.push({
|
p.push({
|
||||||
@ -547,7 +580,7 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
dn.partsToDownload = p.length
|
dn.partsToDownload = p.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await downloadParts(p, downloadID, videoFolder)
|
const file = await downloadParts(p, downloadID, videoFolder, keys ? keys : undefined)
|
||||||
|
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
@ -562,12 +595,11 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
await deleteFolder(subFolder)
|
await deleteFolder(subFolder)
|
||||||
await deleteFolder(audioFolder)
|
await deleteFolder(audioFolder)
|
||||||
await deleteFolder(videoFolder)
|
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadParts(parts: { filename: string; url: string }[], downloadID: number, dir: string) {
|
async function downloadParts(parts: { filename: string; url: string }[], downloadID: number, dir: string, drmkeys?: { kid: string; key: string }[] | undefined) {
|
||||||
const path = await createFolder()
|
const path = await createFolder()
|
||||||
const dn = downloading.find((i) => i.id === downloadID)
|
const dn = downloading.find((i) => i.id === downloadID)
|
||||||
|
|
||||||
@ -578,7 +610,10 @@ async function downloadParts(parts: { filename: string; url: string }[], downloa
|
|||||||
let success = false
|
let success = false
|
||||||
while (!success) {
|
while (!success) {
|
||||||
try {
|
try {
|
||||||
const stream = fs.createWriteStream(`${path}/${part.filename}`)
|
var stream
|
||||||
|
|
||||||
|
stream = fs.createWriteStream(`${path}/${part.filename}`)
|
||||||
|
|
||||||
const { body } = await fetch(part.url)
|
const { body } = await fetch(part.url)
|
||||||
|
|
||||||
const readableStream = Readable.from(body as any)
|
const readableStream = Readable.from(body as any)
|
||||||
@ -608,10 +643,10 @@ async function downloadParts(parts: { filename: string; url: string }[], downloa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await mergeParts(parts, downloadID, path, dir)
|
return await mergeParts(parts, downloadID, path, dir, drmkeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeParts(parts: { filename: string; url: string }[], downloadID: number, tmp: string, dir: string) {
|
async function mergeParts(parts: { filename: string; url: string }[], downloadID: number, tmp: string, dir: string, drmkeys: { kid: string; key: string }[] | undefined) {
|
||||||
const tempname = (Math.random() + 1).toString(36).substring(2)
|
const tempname = (Math.random() + 1).toString(36).substring(2)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -624,9 +659,27 @@ async function mergeParts(parts: { filename: string; url: string }[], downloadID
|
|||||||
list.push(`${tmp}/${part.filename}`)
|
list.push(`${tmp}/${part.filename}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const concatenatedFile = `${tmp}/main.m4s`
|
var concatenatedFile: string
|
||||||
|
|
||||||
|
if (drmkeys) {
|
||||||
|
concatenatedFile = `${tmp}/temp-main.m4s`
|
||||||
|
} else {
|
||||||
|
concatenatedFile = `${tmp}/main.m4s`
|
||||||
|
}
|
||||||
|
|
||||||
await concatenateTSFiles(list, concatenatedFile)
|
await concatenateTSFiles(list, concatenatedFile)
|
||||||
|
|
||||||
|
if (drmkeys) {
|
||||||
|
const inputFilePath = `${tmp}/temp-main.m4s`
|
||||||
|
const outputFilePath = `${tmp}/main.m4s`
|
||||||
|
const keyArgument = `--show-progress --key ${drmkeys[1].kid}:${drmkeys[1].key}`
|
||||||
|
|
||||||
|
const command = `${mp4e} ${keyArgument} "${inputFilePath}" "${outputFilePath}"`
|
||||||
|
|
||||||
|
const { stdout, stderr } = await exec(command)
|
||||||
|
concatenatedFile = `${tmp}/main.m4s`
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
||||||
Ffmpeg()
|
Ffmpeg()
|
||||||
|
@ -4,9 +4,13 @@ import { createFolder, deleteFolder } from './folder'
|
|||||||
import { concatenateTSFiles } from './concatenate'
|
import { concatenateTSFiles } from './concatenate'
|
||||||
import Ffmpeg from 'fluent-ffmpeg'
|
import Ffmpeg from 'fluent-ffmpeg'
|
||||||
import { getFFMPEGPath } from './ffmpeg'
|
import { getFFMPEGPath } from './ffmpeg'
|
||||||
|
import { getMP4DecryptPath } from '../services/mp4decrypt'
|
||||||
const ffmpegP = getFFMPEGPath()
|
const ffmpegP = getFFMPEGPath()
|
||||||
|
const mp4e = getMP4DecryptPath()
|
||||||
|
import util from 'util'
|
||||||
|
const exec = util.promisify(require('child_process').exec)
|
||||||
|
|
||||||
export async function downloadMPDAudio(parts: { filename: string; url: string }[], dir: string, name: string) {
|
export async function downloadMPDAudio(parts: { filename: string; url: string }[], dir: string, name: string, drmkeys?: { kid: string; key: string }[] | undefined) {
|
||||||
const path = await createFolder()
|
const path = await createFolder()
|
||||||
|
|
||||||
const maxParallelDownloads = 5
|
const maxParallelDownloads = 5
|
||||||
@ -38,7 +42,7 @@ export async function downloadMPDAudio(parts: { filename: string; url: string }[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await mergePartsAudio(parts, path, dir, name)
|
return await mergePartsAudio(parts, path, dir, name, drmkeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndPipe(url: string, stream: fs.WriteStream, index: number) {
|
async function fetchAndPipe(url: string, stream: fs.WriteStream, index: number) {
|
||||||
@ -58,7 +62,7 @@ async function fetchAndPipe(url: string, stream: fs.WriteStream, index: number)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergePartsAudio(parts: { filename: string; url: string }[], tmp: string, dir: string, name: string) {
|
async function mergePartsAudio(parts: { filename: string; url: string }[], tmp: string, dir: string, name: string, drmkeys?: { kid: string; key: string }[] | undefined) {
|
||||||
try {
|
try {
|
||||||
const list: Array<string> = []
|
const list: Array<string> = []
|
||||||
|
|
||||||
@ -66,9 +70,27 @@ async function mergePartsAudio(parts: { filename: string; url: string }[], tmp:
|
|||||||
list.push(`${tmp}/${part.filename}`)
|
list.push(`${tmp}/${part.filename}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const concatenatedFile = `${tmp}/main.m4s`
|
var concatenatedFile: string
|
||||||
|
|
||||||
|
if (drmkeys) {
|
||||||
|
concatenatedFile = `${tmp}/temp-main.m4s`
|
||||||
|
} else {
|
||||||
|
concatenatedFile = `${tmp}/main.m4s`
|
||||||
|
}
|
||||||
|
|
||||||
await concatenateTSFiles(list, concatenatedFile)
|
await concatenateTSFiles(list, concatenatedFile)
|
||||||
|
|
||||||
|
if (drmkeys) {
|
||||||
|
const inputFilePath = `${tmp}/temp-main.m4s`
|
||||||
|
const outputFilePath = `${tmp}/main.m4s`
|
||||||
|
const keyArgument = `--show-progress --key ${drmkeys[1].kid}:${drmkeys[1].key}`
|
||||||
|
|
||||||
|
const command = `${mp4e} ${keyArgument} "${inputFilePath}" "${outputFilePath}"`
|
||||||
|
|
||||||
|
await exec(command)
|
||||||
|
concatenatedFile = `${tmp}/main.m4s`
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
||||||
Ffmpeg()
|
Ffmpeg()
|
||||||
|
68
src/api/services/decryption.ts
Normal file
68
src/api/services/decryption.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Session } from '../modules/license'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { getWVKPath } from './widevine'
|
||||||
|
const keys = getWVKPath()
|
||||||
|
|
||||||
|
export async function getDRMKeys(pssh: string, assetID: string, userID: string) {
|
||||||
|
const auth = await getWVKey(assetID, userID)
|
||||||
|
const depssh = Buffer.from(pssh, 'base64')
|
||||||
|
|
||||||
|
if (!keys) return
|
||||||
|
|
||||||
|
if (!keys.key) return
|
||||||
|
|
||||||
|
if (!keys.client) return
|
||||||
|
|
||||||
|
const privateKey = readFileSync(keys.key)
|
||||||
|
const identifierBlob = readFileSync(keys.client)
|
||||||
|
|
||||||
|
const session = new Session({ privateKey, identifierBlob }, depssh)
|
||||||
|
|
||||||
|
const response = await fetch('https://lic.drmtoday.com/license-proxy-widevine/cenc/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: session.createLicenseRequest(),
|
||||||
|
headers: {
|
||||||
|
'dt-custom-data': auth.custom_data,
|
||||||
|
'x-dt-auth-token': auth.token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const json = JSON.parse(await response.text())
|
||||||
|
return session.parseLicense(Buffer.from(json['license'], 'base64')) as { kid: string; key: string }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWVKey(assetID: string, userID: string) {
|
||||||
|
const body = {
|
||||||
|
accounting_id: 'crunchyroll',
|
||||||
|
asset_id: assetID,
|
||||||
|
session_id: new Date().getUTCMilliseconds().toString().padStart(3, '0') + process.hrtime.bigint().toString().slice(0, 13),
|
||||||
|
user_id: userID
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://pl.crunchyroll.com/drm/v1/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: {
|
||||||
|
custom_data: string
|
||||||
|
token: string
|
||||||
|
} = JSON.parse(await response.text())
|
||||||
|
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
throw new Error(await response.text())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Uint8ArrayToBase64(pssh: Uint8Array) {
|
||||||
|
var u8 = new Uint8Array(pssh)
|
||||||
|
return btoa(String.fromCharCode.apply(null, u8 as any))
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { messageBox } from '../../electron/background'
|
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
const appPath = app.getAppPath()
|
const appPath = app.getAppPath()
|
||||||
const resourcesPath = path.dirname(appPath)
|
const resourcesPath = path.dirname(appPath)
|
||||||
|
21
src/api/services/mp4decrypt.ts
Normal file
21
src/api/services/mp4decrypt.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
const appPath = app.getAppPath()
|
||||||
|
const resourcesPath = path.dirname(appPath)
|
||||||
|
const decryptPath = path.join(resourcesPath, 'mp4decrypt')
|
||||||
|
if (isDev) {
|
||||||
|
require('dotenv').config()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMP4DecryptPath() {
|
||||||
|
if (isDev) {
|
||||||
|
const mp4Decrypt = process.env.MP4DECRYPT
|
||||||
|
|
||||||
|
return mp4Decrypt
|
||||||
|
} else {
|
||||||
|
const mp4Decrypt = path.join(decryptPath, 'mp4decrypt.exe')
|
||||||
|
|
||||||
|
return mp4Decrypt
|
||||||
|
}
|
||||||
|
}
|
23
src/api/services/widevine.ts
Normal file
23
src/api/services/widevine.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
const appPath = app.getAppPath()
|
||||||
|
const resourcesPath = path.dirname(appPath)
|
||||||
|
const keysPath = path.join(resourcesPath, 'keys')
|
||||||
|
if (isDev) {
|
||||||
|
require('dotenv').config()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWVKPath() {
|
||||||
|
if (isDev) {
|
||||||
|
const clientid = process.env.WV_DID
|
||||||
|
const key = process.env.WV_PRV
|
||||||
|
|
||||||
|
return { client: clientid, key: key }
|
||||||
|
} else {
|
||||||
|
const clientid = path.join(keysPath, 'device_client_id_blob')
|
||||||
|
const key = path.join(keysPath, 'device_private_key')
|
||||||
|
|
||||||
|
return { client: clientid, key: key }
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user