Merge pull request #4 from stratuma/drm-api

Added DRM Bypass
This commit is contained in:
Stratum 2024-05-02 03:38:24 +02:00 committed by GitHub
commit 6ca3070450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 5565 additions and 65 deletions

5
.gitignore vendored
View File

@ -18,6 +18,11 @@ debug.json
errors.json
build/
crunchyroll-downloader-output-*/
updater/
# FFMPEG
ffmpeg/
# MP4DECRYPT
mp4decrypt/
src/api/services/widevine.ts

View File

@ -10,7 +10,7 @@
<div class="text-[11px] text-white font-dm"> ADD DOWNLOAD </div>
</button>
</div>
<div class="w-full flex flex-row items-center justify-center">
<div class="relative w-full flex flex-row items-center justify-center">
<img src="/logo.png" class="h-8" />
<div class="text-[10px] leading-[10px] text-opacity-90 text-white select-none"
>Crunchyroll <br />

View File

@ -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/**",
"./updater/**"
]
}
}

View File

@ -1,6 +1,5 @@
<template>
<div class="relative h-screen overflow-hidden">
<Updater />
<MainHeader />
<div class="flex flex-col text-white gap-5 mt-14 p-5 overflow-y-scroll h-[calc(100vh-3.5rem)]">
<!-- <button @click="deletePlaylist">
@ -21,6 +20,10 @@
<div class="flex flex-col w-full">
<div class="flex flex-row h-full">
<div class="flex flex-col">
<div v-if="p.status === 'failed'" class="flex flex-row items-center justify-center gap-1 text-xs capitalize p-1.5 bg-[#863232] rounded-lg">
<Icon name="bitcoin-icons:cross-filled" class="h-3.5 w-3.5 text-white" />
{{ p.status }}
</div>
<div v-if="p.status === 'waiting'" class="flex flex-row items-center justify-center gap-1 text-xs capitalize p-1.5 bg-[#866332] rounded-lg">
<Icon name="mdi:clock" class="h-3.5 w-3.5 text-white" />
{{ p.status }}
@ -37,6 +40,10 @@
<Icon name="mdi:loading" class="h-3.5 w-3.5 text-white animate-spin" />
{{ p.status }}
</div>
<div v-if="p.status === 'decrypting'" class="flex flex-row items-center justify-center gap-1 text-xs capitalize p-1.5 bg-[#866332] rounded-lg">
<Icon name="mdi:loading" class="h-3.5 w-3.5 text-white animate-spin" />
{{ p.status }}
</div>
<div v-if="p.status === 'completed'" class="flex flex-row items-center justify-center gap-1 text-xs capitalize p-1.5 bg-[#266326] rounded-lg">
<Icon name="material-symbols:check" class="h-3.5 w-3.5 text-white" />
{{ p.status }}
@ -74,7 +81,6 @@
<script lang="ts" setup>
import type { ADNEpisode } from '~/components/ADN/Types'
import type { CrunchyEpisode } from '~/components/Episode/Types'
import Updater from '~/components/Updater.vue'
const playlist = ref<
Array<{

View File

@ -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:

View File

@ -29,7 +29,7 @@ interface AccountCreateAttributes {
interface PlaylistAttributes {
id: number
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed'
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'decrypting' | 'completed' | 'failed'
media: CrunchyEpisode | ADNEpisode
dub: Array<string>
sub: Array<string>
@ -48,7 +48,7 @@ interface PlaylistCreateAttributes {
dir: string
quality: 1080 | 720 | 480 | 360 | 240
hardsub: boolean
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed'
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'decrypting' | 'completed' | 'failed'
service: 'CR' | 'ADN'
format: 'mp4' | 'mkv'
}

114
src/api/modules/cmac.ts Normal file
View 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
View 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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,9 @@ import { parse as mpdParse } from 'mpd-parser'
import { loggedInCheck } from '../service/service.service'
import { app } from 'electron'
// Disable when Crunchyroll turns off switch endpoint
const enableDRMBypass = false
const crErrors = [
{
error: 'invalid_grant',
@ -108,28 +111,25 @@ 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`, {
const response = await fetch(
enableDRMBypass
? `https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/tv/samsung/play`
: `https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/console/switch/play`,
{
method: 'GET',
headers: headers
})
}
)
if (response.ok) {
const data: VideoPlaylist = JSON.parse(await response.text())
@ -138,7 +138,7 @@ 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())
}
@ -147,6 +147,36 @@ export async function crunchyGetPlaylist(q: string) {
}
}
export async function deleteVideoToken(content: string, token: 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/token/${content}/${token}`, {
method: 'DELETE',
headers: headers
})
if (response.ok) {
return 'ok'
} else {
throw new Error(await response.text())
}
} catch (e) {
console.log('Delete token failed')
throw new Error(e as string)
}
}
export async function crunchyGetPlaylistMPD(q: string) {
const account = await loggedInCheck('CR')
@ -157,8 +187,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 +197,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 {

View File

@ -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, crunchyGetPlaylistMPD, deleteVideoToken } from '../crunchyroll/crunchyroll.service'
import fs from 'fs'
var cron = require('node-cron')
import { Readable } from 'stream'
@ -12,9 +12,14 @@ import { finished } from 'stream/promises'
import Ffmpeg from 'fluent-ffmpeg'
import { adnGetM3U8Playlist, adnGetPlaylist } from '../adn/adn.service'
import { ADNEpisode } from '../../types/adn'
import { setProgressBar } from '../../../electron/background'
import { messageBox, 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) {
@ -55,7 +60,7 @@ async function deletePlaylistandTMP() {
deletePlaylistandTMP()
// Update Playlist Item
export async function updatePlaylistByID(id: number, status: 'waiting' | 'preparing' | 'downloading' | 'completed' | 'merging' | 'failed') {
export async function updatePlaylistByID(id: number, status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'decrypting' | 'completed' | 'failed') {
await Playlist.update({ status: status }, { where: { id: id } })
}
@ -66,7 +71,7 @@ export async function addEpisodeToPlaylist(
d: Array<string>,
dir: string,
hardsub: boolean,
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed',
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'decrypting' | 'completed' | 'failed',
quality: 1080 | 720 | 480 | 360 | 240,
service: 'CR' | 'ADN',
format: 'mp4' | 'mkv'
@ -145,6 +150,7 @@ async function checkPlaylists() {
(e as any).dataValues.dub.map((s: { locale: any }) => s.locale),
(e as any).dataValues.sub.map((s: { locale: any }) => s.locale),
e.dataValues.hardsub,
(e.dataValues.media as CrunchyEpisode).id,
e.dataValues.id,
(e.dataValues.media as CrunchyEpisode).series_title,
(e.dataValues.media as CrunchyEpisode).season_number,
@ -303,6 +309,7 @@ export async function downloadCrunchyrollPlaylist(
dubs: Array<string>,
subs: Array<string>,
hardsub: boolean,
episodeID: string,
downloadID: number,
name: string,
season: number,
@ -323,24 +330,39 @@ export async function downloadCrunchyrollPlaylist(
var playlist = await crunchyGetPlaylist(e)
if (!playlist) {
await updatePlaylistByID(downloadID, 'failed')
console.log('Playlist not found')
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) {
await deleteVideoToken(episodeID, playlist.data.token)
playlist = await crunchyGetPlaylist(found.guid)
} else {
console.log('Exact Playlist not found, taking what crunchy gives.'),
messageBox(
'error',
['Cancel'],
2,
'Not found japanese stream',
'Not found japanese stream',
'This usually happens when Crunchyroll displays JP as dub on a language but its not available. The download will fail, just start a new download and remove JP from dubs'
)
}
}
}
if (!playlist) {
await updatePlaylistByID(downloadID, 'failed')
console.log('Exact Playlist not found')
return
}
await deleteVideoToken(episodeID, playlist.data.token)
const subFolder = await createFolder()
const audioFolder = await createFolder()
@ -369,8 +391,8 @@ 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)
}
@ -379,39 +401,44 @@ export async function downloadCrunchyrollPlaylist(
}
if (!subPlaylist) {
await updatePlaylistByID(downloadID, 'failed')
console.log('Subtitle Playlist not found')
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`)
} else {
console.warn(`Subtitle ${s}.ass not found, skipping`)
}
await deleteVideoToken(episodeID, playlist.data.token)
}
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)
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 {
console.log(`No Dub Sub Found for ${d}`)
}
await deleteVideoToken(episodeID, playlist.data.token)
}
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 +459,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')
@ -456,12 +483,37 @@ export async function downloadCrunchyrollPlaylist(
if (!list) return
const playlist = await crunchyGetPlaylistMPD(list.url)
const playlist = await crunchyGetPlaylistMPD(list.data.url)
if (!playlist) return
await deleteVideoToken(episodeID, list.data.token)
const assetId = playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].resolvedUri.match(/\/assets\/(?:p\/)?([^_,]+)/)
if (!assetId) {
console.log(playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0])
console.log(playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].uri)
console.log('No AssetID found, exiting.')
await updatePlaylistByID(downloadID, 'failed')
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) {
console.log('No PSSH found, exiting.')
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 +526,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,36 +538,44 @@ 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
}
if (!code) return console.error('No clean stream found')
if (!code) {
await updatePlaylistByID(downloadID, 'failed')
console.log('No Clean stream found')
return
}
const play = await crunchyGetPlaylist(code)
if (!play) return
if (!play) {
await updatePlaylistByID(downloadID, 'failed')
console.log('Failed to get Playlist in download Video')
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')
}
@ -523,10 +583,32 @@ export async function downloadCrunchyrollPlaylist(
if (!mdp) return
await deleteVideoToken(episodeID, play.data.token)
var hq = mdp.playlists.find((i) => i.attributes.RESOLUTION?.height === quality)
if (!hq) return
const assetId = hq.segments[0].resolvedUri.match(/\/assets\/(?:p\/)?([^_,]+)/)
if (!assetId) {
console.log('No AssetID found, exiting.')
return
}
var pssh
var keys: { kid: string; key: string }[] | undefined
if (hq.contentProtection) {
if (!hq.contentProtection['com.widevine.alpha'].pssh) {
console.log('No PSSH found, exiting.')
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 +629,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
}
@ -560,14 +642,14 @@ export async function downloadCrunchyrollPlaylist(
await updatePlaylistByID(downloadID, 'completed')
await deleteFolder(videoFolder)
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 +660,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 +693,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 +709,30 @@ 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) {
await updatePlaylistByID(downloadID, 'decrypting')
console.log('Video Decryption started')
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)
console.log('Video Decryption finished')
concatenatedFile = `${tmp}/main.m4s`
}
return new Promise((resolve, reject) => {
if (!ffmpegP.ffmpeg || !ffmpegP.ffprobe) return
Ffmpeg()

View File

@ -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()

View 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))
}

View File

@ -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)

View 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
}
}