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/
|
||||
|
||||
# MP4DECRYPT
|
||||
mp4decrypt/
|
||||
keys/
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "crunchyroll-downloader",
|
||||
"author": "Stratum",
|
||||
"description": "Crunchyroll Downloader",
|
||||
"version": "1.0.7",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"main": ".output/src/electron/background.js",
|
||||
"repository": "https://github.com/stratuma/Crunchyroll-Downloader-v4.0",
|
||||
@ -54,15 +54,19 @@
|
||||
"fastify": "^4.26.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"long": "^5.2.3",
|
||||
"mpd-parser": "^1.3.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"protobufjs": "^7.2.6",
|
||||
"sequelize": "^6.37.3",
|
||||
"sqlite3": "5.1.6"
|
||||
},
|
||||
"build": {
|
||||
"extraResources": [
|
||||
"./ffmpeg/**"
|
||||
"./ffmpeg/**",
|
||||
"./mp4decrypt/**",
|
||||
"./keys/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,9 @@ importers:
|
||||
jsencrypt:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
long:
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
mpd-parser:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@ -56,6 +59,9 @@ importers:
|
||||
node-cron:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
protobufjs:
|
||||
specifier: ^7.2.6
|
||||
version: 7.2.6
|
||||
sequelize:
|
||||
specifier: ^6.37.3
|
||||
version: 6.37.3(sqlite3@5.1.6(encoding@0.1.13))
|
||||
@ -858,6 +864,36 @@ packages:
|
||||
'@polka/url@1.0.0-next.25':
|
||||
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':
|
||||
resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@ -3746,6 +3782,9 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
long@5.2.3:
|
||||
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
|
||||
|
||||
lowercase-keys@2.0.0:
|
||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -4679,6 +4718,10 @@ packages:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
protobufjs@7.2.6:
|
||||
resolution: {integrity: sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
protocols@2.0.1:
|
||||
resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==}
|
||||
|
||||
@ -7032,6 +7075,29 @@ snapshots:
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
slash: 4.0.0
|
||||
@ -10591,6 +10657,8 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
long@5.2.3: {}
|
||||
|
||||
lowercase-keys@2.0.0: {}
|
||||
|
||||
lru-cache@10.2.2: {}
|
||||
@ -11732,6 +11800,21 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
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: {}
|
||||
|
||||
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
|
||||
|
||||
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 = {
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
Authorization: `Bearer ${login.access_token}`,
|
||||
'X-Cr-Disable-Drm': 'true'
|
||||
}
|
||||
|
||||
const query: any = {
|
||||
q: q,
|
||||
n: 100,
|
||||
type: 'series',
|
||||
ratings: false,
|
||||
locale: 'de-DE'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/console/switch/play`, {
|
||||
method: 'GET',
|
||||
@ -138,7 +130,42 @@ export async function crunchyGetPlaylist(q: string) {
|
||||
|
||||
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 {
|
||||
throw new Error(await response.text())
|
||||
}
|
||||
@ -157,8 +184,7 @@ export async function crunchyGetPlaylistMPD(q: string) {
|
||||
if (!data) return
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
'X-Cr-Disable-Drm': 'true'
|
||||
Authorization: `Bearer ${data.access_token}`
|
||||
}
|
||||
|
||||
try {
|
||||
@ -168,7 +194,9 @@ export async function crunchyGetPlaylistMPD(q: string) {
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const parsed = mpdParse(await response.text())
|
||||
const raw = await response.text()
|
||||
|
||||
const parsed = mpdParse(raw)
|
||||
|
||||
return parsed
|
||||
} else {
|
||||
|
@ -4,7 +4,7 @@ import { concatenateTSFiles } from '../../services/concatenate'
|
||||
import { createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
|
||||
import { downloadADNSub, downloadCRSub } from '../../services/subs'
|
||||
import { CrunchyEpisode } from '../../types/crunchyroll'
|
||||
import { crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
|
||||
import { crunchyGetPlaylist, crunchyGetPlaylistDRM, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
|
||||
import fs from 'fs'
|
||||
var cron = require('node-cron')
|
||||
import { Readable } from 'stream'
|
||||
@ -14,7 +14,12 @@ import { adnGetM3U8Playlist, adnGetPlaylist } from '../adn/adn.service'
|
||||
import { ADNEpisode } from '../../types/adn'
|
||||
import { setProgressBar } from '../../../electron/background'
|
||||
import { getFFMPEGPath } from '../../services/ffmpeg'
|
||||
import { getDRMKeys, Uint8ArrayToBase64 } from '../../services/decryption'
|
||||
import { getMP4DecryptPath } from '../../services/mp4decrypt'
|
||||
const ffmpegP = getFFMPEGPath()
|
||||
const mp4e = getMP4DecryptPath()
|
||||
import util from 'util'
|
||||
const exec = util.promisify(require('child_process').exec)
|
||||
|
||||
// DB Account existence check
|
||||
export async function loggedInCheck(service: string) {
|
||||
@ -327,9 +332,9 @@ export async function downloadCrunchyrollPlaylist(
|
||||
return
|
||||
}
|
||||
|
||||
if (playlist.versions && playlist.versions.length !== 0) {
|
||||
if (playlist.audioLocale !== subs[0]) {
|
||||
const found = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||
if (playlist.data.versions && playlist.data.versions.length !== 0) {
|
||||
if (playlist.data.audioLocale !== subs[0]) {
|
||||
const found = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||
if (found) {
|
||||
playlist = await crunchyGetPlaylist(found.guid)
|
||||
}
|
||||
@ -369,10 +374,10 @@ export async function downloadCrunchyrollPlaylist(
|
||||
for (const s of subs) {
|
||||
var subPlaylist
|
||||
|
||||
if (playlist.audioLocale !== 'ja-JP') {
|
||||
const foundStream = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||
if (playlist.data.audioLocale !== 'ja-JP') {
|
||||
const foundStream = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
|
||||
if (foundStream) {
|
||||
subPlaylist = await crunchyGetPlaylist(foundStream.guid)
|
||||
subPlaylist = await crunchyGetPlaylistDRM(foundStream.guid)
|
||||
}
|
||||
} else {
|
||||
subPlaylist = playlist
|
||||
@ -383,7 +388,7 @@ export async function downloadCrunchyrollPlaylist(
|
||||
return
|
||||
}
|
||||
|
||||
const found = subPlaylist.subtitles.find((sub) => sub.language === s)
|
||||
const found = subPlaylist.data.subtitles.find((sub) => sub.language === s)
|
||||
if (found) {
|
||||
subDownloadList.push({ ...found, isDub: false })
|
||||
console.log(`Subtitle ${s}.ass found, adding to download`)
|
||||
@ -394,14 +399,14 @@ export async function downloadCrunchyrollPlaylist(
|
||||
|
||||
for (const d of dubs) {
|
||||
var found
|
||||
if (playlist.versions) {
|
||||
found = playlist.versions.find((p) => p.audio_locale === d)
|
||||
if (playlist.data.versions) {
|
||||
found = playlist.data.versions.find((p) => p.audio_locale === d)
|
||||
}
|
||||
|
||||
if (found) {
|
||||
const list = await crunchyGetPlaylist(found.guid)
|
||||
const list = await crunchyGetPlaylistDRM(found.guid)
|
||||
if (list) {
|
||||
const foundSub = list.subtitles.find((sub) => sub.language === d)
|
||||
const foundSub = list.data.subtitles.find((sub) => sub.language === d)
|
||||
if (foundSub) {
|
||||
subDownloadList.push({ ...foundSub, isDub: true })
|
||||
} else {
|
||||
@ -410,8 +415,8 @@ export async function downloadCrunchyrollPlaylist(
|
||||
}
|
||||
dubDownloadList.push(found)
|
||||
console.log(`Audio ${d}.aac found, adding to download`)
|
||||
} else if (playlist.versions.length === 0) {
|
||||
const foundSub = playlist.subtitles.find((sub) => sub.language === d)
|
||||
} else if (playlist.data.versions.length === 0) {
|
||||
const foundSub = playlist.data.subtitles.find((sub) => sub.language === d)
|
||||
if (foundSub) {
|
||||
subDownloadList.push({ ...foundSub, isDub: true })
|
||||
} else {
|
||||
@ -432,7 +437,7 @@ export async function downloadCrunchyrollPlaylist(
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('Using ja-JP Audio because no Audio in download list')
|
||||
@ -452,16 +457,30 @@ export async function downloadCrunchyrollPlaylist(
|
||||
const audioDownload = async () => {
|
||||
const audios: Array<string> = []
|
||||
for (const v of dubDownloadList) {
|
||||
const list = await crunchyGetPlaylist(v.guid)
|
||||
const list = await crunchyGetPlaylistDRM(v.guid)
|
||||
|
||||
if (!list) return
|
||||
|
||||
const playlist = await crunchyGetPlaylistMPD(list.url)
|
||||
const playlist = await crunchyGetPlaylistMPD(list.data.url)
|
||||
|
||||
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 }[] = []
|
||||
|
||||
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({
|
||||
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
|
||||
@ -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)
|
||||
}
|
||||
@ -486,11 +505,11 @@ export async function downloadCrunchyrollPlaylist(
|
||||
|
||||
if (!playlist) return
|
||||
|
||||
if (playlist.versions && playlist.versions.length !== 0) {
|
||||
if (playlist.versions.find((p) => p.audio_locale === dubs[0])) {
|
||||
code = playlist.versions.find((p) => p.audio_locale === dubs[0])?.guid
|
||||
if (playlist.data.versions && playlist.data.versions.length !== 0) {
|
||||
if (playlist.data.versions.find((p) => p.audio_locale === dubs[0])) {
|
||||
code = playlist.data.versions.find((p) => p.audio_locale === dubs[0])?.guid
|
||||
} 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 {
|
||||
code = e
|
||||
@ -498,24 +517,24 @@ export async function downloadCrunchyrollPlaylist(
|
||||
|
||||
if (!code) return console.error('No clean stream found')
|
||||
|
||||
const play = await crunchyGetPlaylist(code)
|
||||
const play = await crunchyGetPlaylistDRM(code)
|
||||
|
||||
if (!play) return
|
||||
|
||||
var downloadURL
|
||||
|
||||
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) {
|
||||
downloadURL = hardsubURL
|
||||
console.log('Hardsub Playlist found')
|
||||
} else {
|
||||
downloadURL = play.url
|
||||
downloadURL = play.data.url
|
||||
console.log('Hardsub Playlist not found')
|
||||
}
|
||||
} else {
|
||||
downloadURL = play.url
|
||||
downloadURL = play.data.url
|
||||
console.log('Hardsub disabled, skipping')
|
||||
}
|
||||
|
||||
@ -527,6 +546,20 @@ export async function downloadCrunchyrollPlaylist(
|
||||
|
||||
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 }[] = []
|
||||
|
||||
p.push({
|
||||
@ -547,7 +580,7 @@ export async function downloadCrunchyrollPlaylist(
|
||||
dn.partsToDownload = p.length
|
||||
}
|
||||
|
||||
const file = await downloadParts(p, downloadID, videoFolder)
|
||||
const file = await downloadParts(p, downloadID, videoFolder, keys ? keys : undefined)
|
||||
|
||||
return file
|
||||
}
|
||||
@ -562,12 +595,11 @@ export async function downloadCrunchyrollPlaylist(
|
||||
|
||||
await deleteFolder(subFolder)
|
||||
await deleteFolder(audioFolder)
|
||||
await deleteFolder(videoFolder)
|
||||
|
||||
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 dn = downloading.find((i) => i.id === downloadID)
|
||||
|
||||
@ -578,7 +610,10 @@ async function downloadParts(parts: { filename: string; url: string }[], downloa
|
||||
let success = false
|
||||
while (!success) {
|
||||
try {
|
||||
const stream = fs.createWriteStream(`${path}/${part.filename}`)
|
||||
var stream
|
||||
|
||||
stream = fs.createWriteStream(`${path}/${part.filename}`)
|
||||
|
||||
const { body } = await fetch(part.url)
|
||||
|
||||
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)
|
||||
|
||||
try {
|
||||
@ -624,9 +659,27 @@ async function mergeParts(parts: { filename: string; url: string }[], downloadID
|
||||
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)
|
||||
|
||||
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) => {
|
||||
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
||||
Ffmpeg()
|
||||
|
@ -4,9 +4,13 @@ import { createFolder, deleteFolder } from './folder'
|
||||
import { concatenateTSFiles } from './concatenate'
|
||||
import Ffmpeg from 'fluent-ffmpeg'
|
||||
import { getFFMPEGPath } from './ffmpeg'
|
||||
import { getMP4DecryptPath } from '../services/mp4decrypt'
|
||||
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 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) {
|
||||
@ -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 {
|
||||
const list: Array<string> = []
|
||||
|
||||
@ -66,9 +70,27 @@ async function mergePartsAudio(parts: { filename: string; url: string }[], tmp:
|
||||
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)
|
||||
|
||||
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) => {
|
||||
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
|
||||
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 path from 'path'
|
||||
import { messageBox } from '../../electron/background'
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const appPath = app.getAppPath()
|
||||
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