4.5.1 #595

Merged
AnimeDL merged 73 commits from mpd-support into master 2024-03-18 02:08:39 +00:00
59 changed files with 11170 additions and 1920 deletions

View file

@ -8,9 +8,10 @@ jobs:
build:
strategy:
matrix:
build_type: [ ubuntu, macos, windows ]
build_type: [ linux, macos, windows ]
build_arch: [ x64 ]
gui: [ gui, cli ]
runs-on: ${{ matrix.build_type }}-latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
@ -38,7 +39,7 @@ jobs:
with:
upload_url: ${{ github.event.release.upload_url }}
asset_name: multi-downloader-nx-${{ matrix.build_type }}-${{ matrix.gui }}.7z
asset_path: ./lib/_builds/multi-downloader-nx-${{ matrix.build_type }}64-${{ matrix.gui }}.7z
asset_path: ./lib/_builds/multi-downloader-nx-${{ matrix.build_type }}-${{ matrix.build_arch }}-${{ matrix.gui }}.7z
asset_content_type: application/x-7z-compressed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View file

@ -24,6 +24,7 @@ cr_token.yml
hd_profile.yml
hd_sess.yml
hd_token.yml
hd_new_token.yml
archive.json
guistate.json
fonts
@ -37,4 +38,7 @@ crunchyendpoints
/logs
/tmp/*/
/videos/*/
/tmp/*.*
/tmp/*.*
bin
widevine/*
!widevine/.gitkeep

View file

@ -1,3 +1,5 @@
import { Images } from './crunchyEpisodeList';
export interface CrunchyAndroidEpisodes {
__class__: string;
__href__: string;
@ -5,13 +7,13 @@ export interface CrunchyAndroidEpisodes {
__links__: Actions;
__actions__: Actions;
total: number;
items: CrunchyEpisode[];
items: CrunchyAndroidEpisode[];
}
export interface Actions {
}
export interface CrunchyEpisode {
export interface CrunchyAndroidEpisode {
__class__: string;
__href__: string;
__resource_key__: string;
@ -19,7 +21,7 @@ export interface CrunchyEpisode {
__actions__: Actions;
playback: string;
id: string;
channel_id: string;
channel_id: ChannelID;
series_id: string;
series_title: string;
series_slug_title: string;
@ -37,19 +39,19 @@ export interface CrunchyEpisode {
next_episode_id: string;
next_episode_title: string;
hd_flag: boolean;
maturity_ratings: string[];
maturity_ratings: MaturityRating[];
extended_maturity_rating: Actions;
is_mature: boolean;
mature_blocked: boolean;
episode_air_date: string;
upload_date: string;
availability_starts: string;
availability_ends: string;
episode_air_date: Date;
upload_date: Date;
availability_starts: Date;
availability_ends: Date;
eligible_region: string;
available_date: Date;
free_available_date: string;
free_available_date: Date;
premium_date: Date;
premium_available_date: string;
premium_available_date: Date;
is_subbed: boolean;
is_dubbed: boolean;
is_clip: boolean;
@ -57,13 +59,13 @@ export interface CrunchyEpisode {
seo_description: string;
season_tags: string[];
available_offline: boolean;
subtitle_locales: string[];
subtitle_locales: Locale[];
availability_notes: string;
audio_locale: string;
audio_locale: Locale;
versions: Version[];
closed_captions_available: boolean;
identifier: string;
media_type: string;
media_type: MediaType;
slug: string;
images: Images;
duration_ms: number;
@ -76,21 +78,17 @@ export interface CrunchyEpisode {
}
export interface Links {
'episode/channel': EpisodeChannel;
'episode/next_episode': EpisodeChannel;
'episode/season': EpisodeChannel;
'episode/series': EpisodeChannel;
streams: EpisodeChannel;
'episode/channel': Link;
'episode/next_episode': Link;
'episode/season': Link;
'episode/series': Link;
streams: Link;
}
export interface EpisodeChannel {
export interface Link {
href: string;
}
export interface Images {
thumbnail: Array<Thumbnail[]>;
}
export interface Thumbnail {
width: number;
height: number;
@ -117,6 +115,18 @@ export enum Locale {
jaJP = 'ja-JP',
}
export enum MediaType {
Episode = 'episode',
}
export enum ChannelID {
Crunchyroll = 'crunchyroll',
}
export enum MaturityRating {
Tv14 = 'TV-14',
}
export interface Version {
audio_locale: Locale;
guid: string;

View file

@ -3,18 +3,35 @@ export interface CrunchyAndroidStreams {
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: Actions;
__actions__: Record<unknown, unknown>;
media_id: string;
audio_locale: string;
subtitles: { [key: string]: Subtitle };
closed_captions: Actions;
audio_locale: Locale;
subtitles: Subtitles;
closed_captions: Subtitles;
streams: Streams;
bifs: string[];
versions: Version[];
captions: Actions;
captions: Record<unknown, unknown>;
}
export interface Actions {
export interface Subtitles {
'': Subtitle;
'en-US'?: Subtitle;
'es-LA'?: Subtitle;
'es-419'?: Subtitle;
'es-ES'?: Subtitle;
'pt-BR'?: Subtitle;
'fr-FR'?: Subtitle;
'de-DE'?: Subtitle;
'ar-ME'?: Subtitle;
'ar-SA'?: Subtitle;
'it-IT'?: Subtitle;
'ru-RU'?: Subtitle;
'tr-TR'?: Subtitle;
'hi-IN'?: Subtitle;
'zh-CN'?: Subtitle;
'ko-KR'?: Subtitle;
'ja-JP'?: Subtitle;
}
export interface Links {
@ -30,7 +47,7 @@ export interface Streams {
}
export interface Download {
hardsub_locale: string;
hardsub_locale: Locale;
hardsub_lang?: string;
url: string;
}
@ -40,13 +57,13 @@ export interface Urls {
}
export interface Subtitle {
locale: string;
locale: Locale;
url: string;
format: string;
}
export interface Version {
audio_locale: string;
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
@ -54,3 +71,23 @@ export interface Version {
media_guid: string;
is_premium_only: boolean;
}
export enum Locale {
default = '',
enUS = 'en-US',
esLA = 'es-LA',
es419 = 'es-419',
esES = 'es-ES',
ptBR = 'pt-BR',
frFR = 'fr-FR',
deDE = 'de-DE',
arME = 'ar-ME',
arSA = 'ar-SA',
itIT = 'it-IT',
ruRU = 'ru-RU',
trTR = 'tr-TR',
hiIN = 'hi-IN',
zhCN = 'zh-CN',
koKR = 'ko-KR',
jaJP = 'ja-JP',
}

26
@types/crunchyChapters.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
export interface CrunchyChapters {
[key: string]: CrunchyChapter;
lastUpdate: Date;
mediaId: string;
}
export interface CrunchyChapter {
approverId: string;
distributionNumber: string;
end: number;
start: number;
title: string;
seriesId: string;
new: boolean;
type: string;
}
export interface CrunchyOldChapter {
media_id: string;
startTime: number;
endTime: number;
duration: number;
comparedWith: string;
ordering: string;
last_updated: Date;
}

View file

@ -1,3 +1,5 @@
import { Links } from './crunchyAndroidEpisodes';
export interface CrunchyEpisodeList {
total: number;
data: CrunchyEpisode[];
@ -41,27 +43,28 @@ export interface CrunchyEpisode {
listing_id: string;
episode_air_date: Date;
slug: string;
available_date: null;
available_date: Date;
subtitle_locales: Locale[];
slug_title: string;
available_offline: boolean;
description: string;
is_subbed: boolean;
premium_date: null;
premium_date: Date;
upload_date: Date;
season_slug_title: string;
closed_captions_available: boolean;
episode_number: number;
season_tags: any[];
maturity_ratings: MaturityRating[];
streams_link: string;
streams_link?: string;
mature_blocked: boolean;
is_clip: boolean;
hd_flag: boolean;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
__links__?: Links;
}
export enum Locale {
@ -127,5 +130,5 @@ export interface Version {
}
export interface Meta {
versions_considered: boolean;
versions_considered?: boolean;
}

View file

@ -31,9 +31,14 @@ export type CrunchyDownloadOptions = {
dlVideoOnce: boolean,
skipmux?: boolean,
syncTiming: boolean,
nocleanup: boolean,
chapters: boolean,
fontName: string | undefined,
fontSize: number,
dubLang: string[],
}
export type CurnchyMultiDownload = {
export type CrunchyMultiDownload = {
dubLang: string[],
all?: boolean,
but?: boolean,
@ -82,8 +87,18 @@ export type DownloadedMedia = {
lang: LanguageItem,
path: string,
isPrimary?: boolean
} | {
type: 'Audio',
lang: LanguageItem,
path: string,
isPrimary?: boolean
} | {
type: 'Chapters',
lang: LanguageItem,
path: string
} | ({
type: 'Subtitle',
signs: boolean,
cc: boolean
} & sxItem )

View file

@ -40,7 +40,7 @@ export type QueueItem = {
q: number,
dlVideoOnce: boolean,
dubLang: string[],
image: string
image: string,
} & ResolveItemsData
export type ResolveItemsData = {
@ -106,7 +106,9 @@ export type FuniStreamData = { force?: 'Y'|'y'|'N'|'n'|'C'|'c', callbackMaker?:
forceMuxer: AvailableMuxer | undefined, simul: boolean, skipSubMux: boolean, nocleanup: boolean, override: string[], videoTitle: string,
ffmpegOptions: string[], mkvmergeOptions: string[], defaultAudio: LanguageItem, defaultSub: LanguageItem, ccTag: string }
export type FuniSubsData = { nosubs?: boolean, sub: boolean, dlsubs: string[], ccTag: string }
export type DownloadData = { id: string, e: string, dubLang: string[], dlsubs: string[], fileName: string, q: number, novids: boolean, noaudio: boolean, dlVideoOnce: boolean }
export type DownloadData = {
hslang?: string; id: string, e: string, dubLang: string[], dlsubs: string[], fileName: string, q: number, novids: boolean, noaudio: boolean, dlVideoOnce: boolean
}
export type AuthResponse = ResponseBase<undefined>;
export type FuniSearchReponse = ResponseBase<FunimationSearch>;

71
@types/mpd-parser.d.ts vendored Normal file
View file

@ -0,0 +1,71 @@
declare module 'mpd-parser' {
export type Segment = {
uri: string,
timeline: number,
duration: number,
resolvedUri: string,
map: {
uri: string,
resolvedUri: string,
},
number: number,
presentationTime: number
}
export type Playlist = {
attributes: {
NAME: string,
BANDWIDTH: number,
CODECS: string,
'PROGRAM-ID': number,
// Following for video only
'FRAME-RATE'?: number,
AUDIO?: string, // audio stream name
SUBTITLES?: string,
RESOLUTION?: {
width: number,
height: number
}
},
uri: string,
endList: boolean,
timeline: number,
resolvedUri: string,
targetDuration: number,
discontinuitySequence: number,
discontinuityStarts: [],
timelineStarts: {
start: number,
timeline: number
}[],
mediaSequence: number,
contentProtection?: {
[type: string]: {
pssh?: Uint8Array
}
}
segments: Segment[]
}
export type Manifest = {
allowCache: boolean,
discontinuityStarts: [],
segments: [],
endList: true,
duration: number,
playlists: Playlist[],
mediaGroups: {
AUDIO: {
audio: {
[name: string]: {
language: string,
autoselect: boolean,
default: boolean,
playlists: Playlist[]
}
}
}
}
}
export function parse(manifest: string): Manifest
}

43
@types/newHidiveEpisode.d.ts vendored Normal file
View file

@ -0,0 +1,43 @@
export interface NewHidiveEpisode {
description: string;
duration: number;
title: string;
categories: string[];
contentDownload: ContentDownload;
favourite: boolean;
subEvents: any[];
thumbnailUrl: string;
longDescription: string;
posterUrl: string;
offlinePlaybackLanguages: string[];
externalAssetId: string;
maxHeight: number;
rating: Rating;
episodeInformation: EpisodeInformation;
id: number;
accessLevel: string;
playerUrlCallback: string;
thumbnailsPreview: string;
displayableTags: any[];
plugins: any[];
watchStatus: string;
computedReleases: any[];
licences: any[];
type: string;
}
export interface ContentDownload {
permission: string;
period: string;
}
export interface EpisodeInformation {
seasonNumber: number;
episodeNumber: number;
season: number;
}
export interface Rating {
rating: string;
descriptors: any[];
}

33
@types/newHidivePlayback.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
export interface NewHidivePlayback {
watermark: null;
skipMarkers: any[];
annotations: null;
dash: Format[];
hls: Format[];
}
export interface Format {
subtitles: Subtitle[];
url: string;
drm: DRM;
}
export interface DRM {
encryptionMode: string;
containerType: string;
jwtToken: string;
url: string;
keySystems: string[];
}
export interface Subtitle {
format: Formats;
language: string;
url: string;
}
export enum Formats {
Scc = 'scc',
Srt = 'srt',
Vtt = 'vtt',
}

91
@types/newHidiveSearch.d.ts vendored Normal file
View file

@ -0,0 +1,91 @@
export interface NewHidiveSearch {
results: Result[];
}
export interface Result {
hits: Hit[];
nbHits: number;
page: number;
nbPages: number;
hitsPerPage: number;
exhaustiveNbHits: boolean;
exhaustiveTypo: boolean;
exhaustive: Exhaustive;
query: string;
params: string;
index: string;
renderingContent: RenderingContent;
processingTimeMS: number;
processingTimingsMS: ProcessingTimingsMS;
serverTimeMS: number;
}
export interface Exhaustive {
nbHits: boolean;
typo: boolean;
}
export interface Hit {
type: string;
weight: number;
id: number;
name: string;
description: string;
meta: RenderingContent;
coverUrl: string;
smallCoverUrl: string;
seasonsCount: number;
tags: string[];
localisations: HitLocalisations;
ratings: Ratings;
objectID: string;
_highlightResult: HighlightResult;
}
export interface HighlightResult {
name: Description;
description: Description;
tags: Description[];
localisations: HighlightResultLocalisations;
}
export interface Description {
value: string;
matchLevel: string;
matchedWords: string[];
fullyHighlighted?: boolean;
}
export interface HighlightResultLocalisations {
en_US: PurpleEnUS;
}
export interface PurpleEnUS {
title: Description;
description: Description;
}
export interface HitLocalisations {
[language: string]: HitLocalization;
}
export interface HitLocalization {
title: string;
description: string;
}
export interface RenderingContent {
}
export interface Ratings {
US: string[];
}
export interface ProcessingTimingsMS {
_request: Request;
}
export interface Request {
queue: number;
roundTrip: number;
}

89
@types/newHidiveSeason.d.ts vendored Normal file
View file

@ -0,0 +1,89 @@
export interface NewHidiveSeason {
title: string;
description: string;
longDescription: string;
smallCoverUrl: string;
coverUrl: string;
titleUrl: string;
posterUrl: string;
seasonNumber: number;
episodeCount: number;
displayableTags: any[];
rating: Rating;
contentRating: Rating;
id: number;
series: Series;
episodes: Episode[];
paging: Paging;
licences: any[];
}
export interface Rating {
rating: string;
descriptors: any[];
}
export interface Episode {
accessLevel: string;
availablePurchases?: any[];
licenceIds?: any[];
type: string;
id: number;
title: string;
description: string;
thumbnailUrl: string;
posterUrl: string;
duration: number;
favourite: boolean;
contentDownload: ContentDownload;
offlinePlaybackLanguages: string[];
externalAssetId: string;
subEvents: any[];
maxHeight: number;
thumbnailsPreview: string;
longDescription: string;
episodeInformation: EpisodeInformation;
categories: string[];
displayableTags: any[];
watchStatus: string;
computedReleases: any[];
}
export interface ContentDownload {
permission: string;
}
export interface EpisodeInformation {
seasonNumber: number;
episodeNumber: number;
season: number;
}
export interface Paging {
moreDataAvailable: boolean;
lastSeen: number;
}
export interface Series {
seriesId: number;
title: string;
description: string;
longDescription: string;
displayableTags: any[];
rating: Rating;
contentRating: Rating;
}
export interface NewHidiveSeriesExtra extends Series {
season: NewHidiveSeason;
}
export interface NewHidiveEpisodeExtra extends Episode {
titleId: number;
nameLong: string;
seasonTitle: string;
seriesTitle: string;
seriesId?: number;
isSelected: boolean;
jwtToken?: string;
}

35
@types/newHidiveSeries.d.ts vendored Normal file
View file

@ -0,0 +1,35 @@
export interface NewHidiveSeries {
id: number;
title: string;
description: string;
longDescription: string;
smallCoverUrl: string;
coverUrl: string;
titleUrl: string;
posterUrl: string;
seasons: Season[];
rating: Rating;
contentRating: Rating;
displayableTags: any[];
paging: Paging;
}
export interface Rating {
rating: string;
descriptors: any[];
}
export interface Paging {
moreDataAvailable: boolean;
lastSeen: number;
}
export interface Season {
title: string;
description: string;
longDescription: string;
seasonNumber: number;
episodeCount: number;
displayableTags: any[];
id: number;
}

View file

@ -1,7 +1,7 @@
// Generated by https://quicktype.io
export interface PlaybackData {
total: number;
data: { [key: string]: { [key: string]: StreamDetails } };
data: [{ [key: string]: { [key: string]: StreamDetails } }];
meta: Meta;
}
@ -56,7 +56,7 @@ export interface Meta {
bifs: string[];
versions: Version[];
audio_locale: Locale;
closed_captions: Record<unknown>;
closed_captions: Subtitles;
captions: Record<unknown>;
}

View file

@ -15,13 +15,13 @@ RUN echo 'ffmpeg: "./bin/ffmpeg/ffmpeg"\nmkvmerge: "./bin/mkvtoolnix/mkvmerge"'
RUN npm install -g pnpm
RUN pnpm i
RUN pnpm run build-ubuntu-gui
RUN pnpm run build-linux-gui
# Move build to new Clean Image
FROM node
WORKDIR "/app"
COPY --from=builder /app/lib/_builds/multi-downloader-nx-ubuntu64-gui ./
COPY --from=builder /app/lib/_builds/multi-downloader-nx-linux-x64-gui ./
# Install mkvmerge and ffmpeg

View file

@ -1,3 +1,4 @@
ffmpeg: "ffmpeg.exe"
mkvmerge: "mkvmerge.exe"
ffprobe: "ffprobe.exe"
mp4decrypt: "mp4decrypt.exe"

1067
crunchy.ts

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# multi-downloader-nx (4.4.4v)
# multi-downloader-nx (4.5.1v)
If you find any bugs in this documentation or in the programm itself please report it [over on GitHub](https://github.com/anidl/multi-downloader-nx/issues).
@ -98,8 +98,7 @@ Get video list by Movie Listing ID
| --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--series ${ID}` | `string` | `No`| `--srz` | `NaN` |
This command is used only for crunchyroll.
Requested is the ID of a show not a season.
Requested is the ID of a show not a season.
#### `-s`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -136,6 +135,27 @@ If selected, the best selected quality will be downloaded only for the first lan
then the worst video quality with the same audio quality will be downloaded for every other language.
By the later merge of the videos, no quality difference will be present.
This will speed up the download speed, if multiple languages are selected.
#### `--chapters`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--chapters ` | `boolean` | `No`| `NaN` | `false`| `chapters: ` |
Will fetch the chapters and add them into the final video.
Currently only works with mkvmerge.
#### `--crapi`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--crapi ` | `string` | `No`| `NaN` | [`android`, `web`] | `android`| `crapi: ` |
If set to Android, it has lower quality, but Non-DRM streams,
If set to Web, it has a higher quality adaptive stream, but everything is DRM.
#### `--hdapi`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Hidive | `--hdapi ` | `string` | `No`| `NaN` | [`old`, `new`] | `old`| `hdapi: ` |
If set to Old, it has lower quality, but Non-DRM streams, but some people can't use it,
If set to New, it has a higher quality stream, but everything is DRM.
#### `--removeBumpers`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|

View file

@ -8,11 +8,8 @@ This downloader can download anime from different sites. Currently supported are
This application is not endorsed by or affiliated with *Funimation*, *Crunchyroll*, or *Hidive*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application.
## Prerequisites
## Dependencies
* NodeJS >= 14.6.0 (https://nodejs.org/)
* NPM >= 6.9.0 (https://www.npmjs.org/)
* PNPM >= 7.0.0 (https://pnpm.io/)
* ffmpeg >= 4.0.0 (https://www.videohelp.com/software/ffmpeg)
* MKVToolNix >= 60.0.0 (https://www.videohelp.com/software/MKVToolNix)
@ -23,22 +20,53 @@ By default this application uses the following paths to programs (main executabl
* `ffmpeg.exe` (From PATH)
* `ffprobe.exe` (From PATH)
* `mkvmerge.exe` (From PATH)
* `mp4decrypt.exe` (From PATH)
To change these paths you need to edit `bin-path.yml` in `./config/` directory.
### Node Modules
## CLI Information
After installing NodeJS with NPM go to directory with `package.json` file and type: `npm i`. Afterwards run `npm run tsc`. You can now find a lib folder containing the js code execute.
See [the documentation](https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md) for a complete list of what options are available. You can define defaults for the commands by editing the `cli-defaults.yml` file in the `./config/` directory.
* [check dependencies](https://david-dm.org/anidl/funimation-downloader-nx)
### Example usage
## CLI Options
#### Logging in
See [the documentation](https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md)
Most services require you to be logged in, in order to download from, an example of how you would login is:
## Build instructions
```shell
AniDL --service {ServiceName} --auth
```
Please note that nodejs, npm, and pnpm must be installed in your system. For instructions on how to install pnpm, check (https://pnpm.io/installation)
#### Searching
In order to find the IDs to download, you can search from each service by using the `--search` flag like this:
```shell
AniDL --service {ServiceName} --search {SearchTerm}
```
#### Downloading
Once you have the ID which you can obtain from using the search or other means, you are ready to download, which you can do like this:
```shell
AniDL --service {ServiceName} -s {SeasonID} -e {EpisodeNumber}
```
## Building and running from source
### Build Dependencies
Dependencies that are only required for running from code. These are not required if you are using the prebuilt binaries.
* NodeJS >= 14.6.0 (https://nodejs.org/)
* NPM >= 6.9.0 (https://www.npmjs.org/)
* PNPM >= 7.0.0 (https://pnpm.io/)
### Build Instructions
Please note that NodeJS, NPM, and PNPM must be installed on your system. For instructions on how to install pnpm, check (https://pnpm.io/installation)
First clone this repo `git clone https://github.com/anidl/multi-downloader-nx.git`.
@ -47,4 +75,14 @@ Afterwards run `pnpm run tsc false [true if you want gui, false otherwise]`.
If you want the `js` files you are done. Just `cd` into the `lib` folder, and run `node index.js --help` to get started with the CLI, or run `node gui.js` to run the GUI
If you want to package the application, run pnpm run build-`{platform}`-`{type}` where `{platform}` is the operating system (currently the choices are windows, ubuntu, macos, and arm) and `{type}` is cli or gui.
If you want to package the application, run pnpm run build-`{platform}`-`{type}` where `{platform}` is the operating system (currently the choices are windows, linux, macos, alpine, android, and arm) and `{type}` is cli or gui.
## DRM Decryption
### Decryption Requirements
* mp4decrypt >= Any (http://www.bento4.com/) - Only required for decrypting
### Instructions
In order to decrypt DRM content, you will need to have a dumped CDM, after that you will need to place the CDM files (`device_client_id_blob` and `device_private_key`) into the `./widevine/` directory. For legal reasons we do not include the CDM with the software, and you will have to source one yourself.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,6 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title>Multi Downloader</title>
<link rel="icon" type="image/webp" href="favicon.webp">
<meta charset="UTF-8">
<meta
http-equiv="Content-Security-Policy"

View file

@ -13,15 +13,21 @@ const Layout: React.FC = () => {
const messageHandler = React.useContext(messageChannelContext);
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
return <Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '100%', alignItems: 'center',}}>
<MenuBar />
<Box sx={{ height: 50, mb: 4, display: 'flex', gap: 1, mt: 3 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '93vw',
maxWidth: '93rem',
maxHeight: '3rem'
//backgroundColor: '#ffffff',
}}>
<LogoutButton />
<AuthButton />
<Box sx={{ display: 'flex', gap: 1, height: 36 }}>
<Button variant="contained" startIcon={<Folder />} onClick={() => messageHandler?.openFolder('content')}>Open Output Directory</Button>
<Button variant="contained" startIcon={<ClearAll />} onClick={() => messageHandler?.clearQueue() }>Clear Queue</Button>
</Box>
<Button variant="contained" startIcon={<Folder />} onClick={() => messageHandler?.openFolder('content')} sx={{ height: '37px' }}>Open Output Directory</Button>
<Button variant="contained" startIcon={<ClearAll />} onClick={() => messageHandler?.clearQueue() } sx={{ height: '37px' }}>Clear Queue</Button>
<AddToQueue />
<StartQueueButton />
</Box>

View file

@ -11,10 +11,8 @@ const makeTheme = (mode: 'dark'|'light') : Partial<Theme> => {
const Style: FCWithChildren = ({children}) => {
return <ThemeProvider theme={makeTheme('dark')}>
<Container maxWidth='xl'>
<Box sx={{ position: 'fixed', height: '100%', width: '100%', zIndex: -500, backgroundColor: 'rgb(0, 30, 60)', top: 0, left: 0 }}/>
<Box sx={{ }}/>
{children}
</Container>
</ThemeProvider>;
};

View file

@ -1,5 +1,5 @@
import { Add } from '@mui/icons-material';
import { Box, Button, Dialog, Divider } from '@mui/material';
import { Box, Button, Dialog, Divider, Typography } from '@mui/material';
import React from 'react';
import DownloadSelector from './DownloadSelector/DownloadSelector';
import EpisodeListing from './DownloadSelector/Listing/EpisodeListing';
@ -10,14 +10,14 @@ const AddToQueue: React.FC = () => {
return <Box>
<EpisodeListing />
<Dialog open={isOpen} onClose={() => setOpen(false)} maxWidth='md'>
<Box sx={{ border: '2px solid white', p: 2 }}>
<Dialog open={isOpen} onClose={() => setOpen(false)} maxWidth='md' PaperProps={{ elevation:4 }}>
<Box>
<SearchBox />
<Divider variant='middle' className="divider-width" light sx={{ color: 'text.primary', fontSize: '1.2rem' }}>Options</Divider>
<Divider variant='middle'/>
<DownloadSelector onFinish={() => setOpen(false)} />
</Box>
</Dialog>
<Button variant='contained' onClick={() => setOpen(true)}>
<Button variant='contained' onClick={() => setOpen(true)} sx={{ maxHeight: '2.3rem' }}>
<Add />
Add to Queue
</Button>

View file

@ -1,10 +1,11 @@
import React from 'react';
import { Box, Button, TextField } from '@mui/material';
import { Box, Button, Divider, InputBase, Link, MenuItem, Select, TextField, Tooltip, Typography } from '@mui/material';
import useStore from '../../../hooks/useStore';
import MultiSelect from '../../reusable/MultiSelect';
import { messageChannelContext } from '../../../provider/MessageChannel';
import LoadingButton from '@mui/lab/LoadingButton';
import { useSnackbar } from 'notistack';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
type DownloadSelectorProps = {
onFinish?: () => unknown
@ -78,14 +79,36 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
setLoading(false);
};
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ m: 2, gap: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', flexWrap: 'wrap' }}>
return <Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Box sx={{display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: '5px',
}}>
<Box sx={{
width: '50rem',
height: '21rem',
margin: '10px',
display: 'flex',
justifyContent: 'space-between',
//backgroundColor: '#ffffff30',
}}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.7rem',
//backgroundColor: '#ff000030'
}}>
<Typography sx={{fontSize: '1.4rem'}}>
General Options
</Typography>
<TextField value={store.downloadOptions.id} required onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, id: e.target.value }
});
}} label='Item ID' />
}} label='Show ID'/>
<TextField type='number' value={store.downloadOptions.q} required onChange={e => {
const parsed = parseInt(e.target.value);
if (isNaN(parsed) || parsed < 0 || parsed > 10)
@ -94,13 +117,77 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
type: 'downloadOptions',
payload: { ...store.downloadOptions, q: parsed }
});
}} label='Quality Level (0 for max)' />
<TextField disabled={store.downloadOptions.all} value={store.downloadOptions.e} required onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, e: e.target.value }
});
}} label='Episode Select' />
}} label='Quality Level (0 for max)'/>
<Box sx={{ display: 'flex', gap: '5px' }}>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, noaudio: !store.downloadOptions.noaudio } })} variant={store.downloadOptions.noaudio ? 'contained' : 'outlined'}>Skip Audio</Button>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, novids: !store.downloadOptions.novids } })} variant={store.downloadOptions.novids ? 'contained' : 'outlined'}>Skip Video</Button>
</Box>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, dlVideoOnce: !store.downloadOptions.dlVideoOnce } })} variant={store.downloadOptions.dlVideoOnce ? 'contained' : 'outlined'}>Skip Unnecessary</Button>
<Tooltip title={
<Typography>
Currently only supported on Hidive
</Typography>
}
arrow
placement='top'>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, simul: !store.downloadOptions.simul } })} variant={store.downloadOptions.simul ? 'contained' : 'outlined'}>Download Simulcast ver.</Button>
</Tooltip>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.7rem',
//backgroundColor: '#00000020'
}}>
<Typography sx={{fontSize: '1.4rem'}}>
Episode Options
</Typography>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '1px'
}}>
<Box sx={{
borderColor: '#595959',
borderStyle: 'solid',
borderWidth: '1px',
borderRadius: '5px',
//backgroundColor: '#ff4567',
width: '15rem',
height: '3.5rem',
display: 'flex',
'&:hover' : {
borderColor: '#ffffff',
},
}}>
<InputBase sx={{
ml: 2,
flex: 1,
}}
disabled={store.downloadOptions.all} value={store.downloadOptions.e} required onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, e: e.target.value }
});
}} placeholder='Episode Select'/>
<Divider orientation='vertical'/>
<LoadingButton loading={loading} disableElevation disableFocusRipple disableRipple disableTouchRipple onClick={listEpisodes} variant='text' sx={{ textTransform: 'none'}}><Typography>List<br/>Episodes</Typography></LoadingButton>
</Box>
</Box>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, all: !store.downloadOptions.all } })} variant={store.downloadOptions.all ? 'contained' : 'outlined'}>Download All</Button>
<Button sx={{ textTransform: 'none'}} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, but: !store.downloadOptions.but } })} variant={store.downloadOptions.but ? 'contained' : 'outlined'}>Download All but</Button>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.7rem',
//backgroundColor: '#00ff0020'
}}>
<Typography sx={{fontSize: '1.4rem'}}>
Language Options
</Typography>
<MultiSelect
title='Dub Languages'
values={availableDubs}
@ -113,6 +200,7 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
}}
allOption
/>
<MultiSelect
title='Sub Languages'
values={availableSubs}
@ -124,22 +212,105 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
});
}}
/>
<TextField value={store.downloadOptions.fileName} onChange={e => {
<Tooltip title={
<Typography>
Comming Soon
</Typography>
}
arrow placement='top'>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '1rem'
}}>
<Box sx={{
borderColor: '#595959',
borderStyle: 'solid',
borderWidth: '1px',
borderRadius: '5px',
//backgroundColor: '#ff4567',
width: '15rem',
height: '3.5rem',
display: 'flex',
'&:hover' : {
borderColor: '#ffffff',
},
}}>
<Button sx={{ textTransform: 'none' }} variant='outlined' disabled={true}>Hardsub</Button>
<Divider orientation='vertical'/>
<Select sx={{
flex: 1
}}
title='Hardsub lang.'
placeholder='Hardsub lang.'
disabled={true}
value={store.downloadOptions.hslang}
onChange={(e) => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, hslang: (e.target.value as string) === '' ? undefined : e.target.value as string }
});
}}
>
<MenuItem>Deutsch</MenuItem>
</Select>
</Box>
<Tooltip title={
<Typography>
Burns the selected subtitle <b>PERMANENTLY</b> onto the video<br/>You can choose only <b>1</b> subtitle per video
</Typography>
} arrow placement='top'>
<InfoOutlinedIcon sx={{
transition: '100ms',
ml: '0.35rem',
mr: '0.65rem',
'&:hover' : {
color: '#ffffff30',
}
}} />
</Tooltip>
</Box>
</Tooltip>
</Box>
</Box>
<Box sx={{width: '95%', height: '0.3rem', backgroundColor: '#ffffff50', borderRadius: '10px', marginBottom: '20px'}}/>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '15px'
}}>
<TextField value={store.downloadOptions.fileName} onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, fileName: e.target.value }
});
}} sx={{ width: '50%' }} label='Filename' />
<Button onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, all: !store.downloadOptions.all } })} variant={store.downloadOptions.all ? 'contained' : 'outlined'}>Download all</Button>
<Button onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, but: !store.downloadOptions.but } })} variant={store.downloadOptions.but ? 'contained' : 'outlined'}>Download all but</Button>
<Button onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, noaudio: !store.downloadOptions.noaudio } })} variant={store.downloadOptions.noaudio ? 'contained' : 'outlined'}>Skip Audio</Button>
<Button onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, novids: !store.downloadOptions.novids } })} variant={store.downloadOptions.novids ? 'contained' : 'outlined'}>Skip Video</Button>
<Button onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, dlVideoOnce: !store.downloadOptions.dlVideoOnce } })} variant={store.downloadOptions.dlVideoOnce ? 'contained' : 'outlined'}>Skip unnecessary Downloads</Button>
</Box>
<Box sx={{ gap: 2, flex: 0, m: 1, mb: 3, display: 'flex', justifyContent: 'center' }}>
<LoadingButton loading={loading} onClick={listEpisodes} variant='contained'>List episodes</LoadingButton>
<LoadingButton loading={loading} onClick={addToQueue} variant='contained'>Add to Queue</LoadingButton>
}} sx={{ width: '87%' }} label='Filename Overwrite' />
<Tooltip title={
<Typography>
Click here to see the documentation
</Typography>
} arrow placement='top'>
<Link href='https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md#filename-template' rel="noopener noreferrer" target="_blank">
<InfoOutlinedIcon sx={{
transition: '100ms',
'&:hover' : {
color: '#ffffff30',
}
}} />
</Link>
</Tooltip>
</Box>
</Box>
<Box sx={{width: '95%', height: '0.3rem', backgroundColor: '#ffffff50', borderRadius: '10px', marginTop: '10px'}}/>
<LoadingButton sx={{ margin: '15px', textTransform: 'none' }} loading={loading} onClick={addToQueue} variant='contained'>Add to Queue</LoadingButton>
</Box>;
};

View file

@ -26,6 +26,7 @@ const LogoutButton: React.FC = () => {
startIcon={<ExitToApp />}
variant='contained'
onClick={logout}
sx={{ maxHeight: '2.3rem' }}
>
Service select
</Button>

View file

@ -1,4 +0,0 @@
.divider-width::before, .divider-width::after {
border-top: 3px solid white !important;
transform: translateY(40%) !important;
}

View file

@ -1,10 +1,9 @@
import { Box } from '@mui/material';
import React from 'react';
import './MainFrame.css';
import Queue from './Queue/Queue';
const MainFrame: React.FC = () => {
return <Box sx={{ marginLeft: 1 }}>
return <Box sx={{ }}>
<Queue />
</Box>;
};

View file

@ -1,7 +1,8 @@
import { Box, Button, CircularProgress, Divider, LinearProgress, Skeleton, Typography } from '@mui/material';
import { Badge, Box, Button, CircularProgress, Divider, IconButton, LinearProgress, Skeleton, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { messageChannelContext } from '../../../provider/MessageChannel';
import { queueContext } from '../../../provider/QueueProvider';
import DeleteIcon from '@mui/icons-material/Delete';
import useDownloadManager from '../DownloadManager/DownloadManager';
@ -16,102 +17,393 @@ const Queue: React.FC = () => {
return data || queue.length > 0 ? <>
{data && <>
<Box sx={{ height: 200, display: 'grid', gridTemplateColumns: '20% 1fr', gap: 1, mb: 1, mt: 1 }}>
<img src={data.downloadInfo.image} height='auto' width='100%' alt="Thumbnail" />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr max-content' }}>
<Typography variant='h5' color='text.primary'>
{data.downloadInfo.title}
</Typography>
<Typography variant='h5' color='text.primary'>
Language: {data.downloadInfo.language.name}
</Typography>
</Box>
<Typography variant='h6' color='text.primary'>
<Box sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center',
}}>
<Box sx={{
marginTop: '2rem',
marginBottom: '1rem',
height: '12rem',
width: '93vw',
maxWidth: '93rem',
backgroundColor: '#282828',
boxShadow: '0px 0px 50px #00000090',
borderRadius: '10px',
display: 'flex',
transition: '250ms'
}}>
<img style={{
borderRadius: '5px',
margin: '5px',
boxShadow: '0px 0px 10px #00000090',
userSelect: 'none',
}}
src={data.downloadInfo.image} height='auto' width='auto' alt="Thumbnail" />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center'
}}>
<Box sx={{
display: 'flex',
}}>
<Box sx={{
//backgroundColor: '#ff0000',
width: '70%',
marginLeft: '10px'
}}>
<Box sx={{
flexDirection: 'column',
display: 'flex',
justifyContent: 'space-between',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
{data.downloadInfo.parent.title}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.2rem',
}}>
{data.downloadInfo.title}
</Typography>
</Box>
<LinearProgress variant='determinate' sx={{ height: '10px' }} value={(typeof data.progress.percent === 'string' ? parseInt(data.progress.percent) : data.progress.percent)} />
</Box>
<Box sx={{
//backgroundColor: '#00ff00',
width: '30%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
Downloading: {data.downloadInfo.language.name}
</Typography>
</Box>
</Box>
<Box sx={{
height: '50%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
//backgroundColor: '#0000ff',
}}>
<LinearProgress variant='determinate'
sx={{
height: '20px',
width: '97.53%',
margin: '10px',
boxShadow: '0px 0px 10px #00000090',
borderRadius: '10px',
}} value={(typeof data.progress.percent === 'string' ? parseInt(data.progress.percent) : data.progress.percent)}
/>
<Box>
<Typography variant="body1" color='text.primary'>
<Typography color='text.primary'
sx={{
fontSize: '1.3rem',
}}>
{data.progress.cur} / {(data.progress.total)} parts ({data.progress.percent}% | {formatTime(data.progress.time)} | {(data.progress.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s | {(data.progress.bytes / 1024 / 1024).toFixed(2)}MB)
</Typography>
</Box>
</Box>
</Box>
</Box>
</Box>
</>
}
{
current && !data && <>
<Box sx={{ height: 200, display: 'grid', gridTemplateColumns: '20% 1fr', gap: 1, mb: 1, mt: 1 }}>
<img src={current.image} height='auto' width='100%' alt="Thumbnail" />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr max-content' }}>
<Typography variant='h5' color='text.primary'>
{current.title}
</Typography>
<Typography variant='h5' color='text.primary'>
Language: <CircularProgress variant="indeterminate" />
</Typography>
</Box>
<Typography variant='h6' color='text.primary'>
{current.parent.title}
</Typography>
</Box>
<LinearProgress variant='indeterminate' sx={{ height: '10px' }} />
<Box>
<Typography variant="body1" color='text.primary'>
0 / ? parts (0% | X:XX | 0 MB/s | 0MB)
</Typography>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}>
<Box sx={{
marginTop: '2rem',
marginBottom: '1rem',
height: '12rem',
width: '93vw',
maxWidth: '93rem',
backgroundColor: '#282828',
boxShadow: '0px 0px 50px #00000090',
borderRadius: '10px',
display: 'flex',
overflow: 'hidden',
transition: '250ms'
}}>
<img style={{
borderRadius: '5px',
margin: '5px',
boxShadow: '0px 0px 10px #00000090',
userSelect: 'none',
maxWidth: '20.5rem',
}}
src={current.image} height='auto' width='auto' alt="Thumbnail" />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center',
//backgroundColor: '#ffffff0f'
}}>
<Box sx={{
display: 'flex',
}}>
<Box sx={{
width: '70%',
marginLeft: '10px'
}}>
<Box sx={{
flexDirection: 'column',
display: 'flex',
justifyContent: 'space-between',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
{current.parent.title}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.2rem',
}}>
{current.title}
</Typography>
</Box>
</Box>
<Box sx={{
//backgroundColor: '#00ff00',
width: '30%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
Downloading:
</Typography>
<CircularProgress variant="indeterminate" sx={{
marginLeft: '2rem',
}}/>
</Box>
</Box>
</Box>
<Box sx={{
height: '50%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
//backgroundColor: '#0000ff',
}}>
<LinearProgress variant='indeterminate'
sx={{
height: '20px',
width: '97.53%',
margin: '10px',
boxShadow: '0px 0px 10px #00000090',
borderRadius: '10px',
}}
/>
<Box>
<Typography color='text.primary'
sx={{
fontSize: '1.3rem',
}}>
0 / ? parts (0% | XX:XX | 0 MB/s | 0MB)
</Typography>
</Box>
</Box>
</Box>
</Box>
</Box>
</>
}
{queue.length && data && <Divider variant="fullWidth" />}
{queue.map((queueItem, index, { length }) => {
return <Box key={`queue_item_${index}`}>
<Box sx={{ height: 200, display: 'grid', gridTemplateColumns: '20% 1fr', gap: 1, mb: 1, mt: 1 }}>
<img src={queueItem.image} height='auto' width='100%' alt="Thumbnail" />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 200px' }}>
<Typography variant='h5' color='text.primary'>
{queueItem.title}
return <Box key={`queue_item_${index}`} sx={{
display: 'flex',
mb: '-1.5rem',
flexDirection: 'column',
alignItems: 'center',
}}>
<Box sx={{
marginTop: '1.5rem',
marginBottom: '1.5rem',
height: '11rem',
width: '90vw',
maxWidth: '90rem',
backgroundColor: '#282828',
boxShadow: '0px 0px 10px #00000090',
borderRadius: '10px',
display: 'flex',
overflow: 'hidden',
}}>
<img style={{
borderRadius: '5px',
margin: '5px',
boxShadow: '0px 0px 5px #00000090',
userSelect: 'none',
maxWidth: '18.5rem'
}}
src={queueItem.image} height='auto' width='auto' alt="Thumbnail" />
<Box sx={{
margin: '5px',
display: 'flex',
width: '100%',
justifyContent: 'space-between',
}}>
<Box sx={{
width: '30%',
marginRight: '5px',
marginLeft: '5px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
{queueItem.parent.title}
</Typography>
<Typography variant='h5' color='text.primary'>
Languages: {queueItem.dubLang.join(', ')}
<Typography color='text.primary' sx={{
fontSize: '1.6rem',
marginTop: '-0.4rem',
marginBottom: '0.4rem',
}}>
S{queueItem.parent.season}E{queueItem.episode}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.2rem',
marginTop: '-0.4rem',
marginBottom: '0.4rem',
textOverflow: 'ellipsis',
}}>
{queueItem.title}
</Typography>
</Box>
<Box sx={{
width: '40%',
marginRight: '5px',
marginLeft: '5px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
whiteSpace: 'nowrap',
justifyContent: 'space-between',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
Dub(s): {queueItem.dubLang.join(', ')}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
Sub(s): {queueItem.dlsubs.join(', ')}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
Quality: {queueItem.q}
</Typography>
</Box>
<Typography variant='h6' color='text.primary'>
{queueItem.parent.title}
</Typography>
</Box>
<Typography variant='body1' color='text.primary'>
S{queueItem.parent.season}E{queueItem.episode} <br />
Quality: {queueItem.q}
</Typography>
<Button onClick={() => {
<Box sx={{
marginRight: '5px',
marginLeft: '5px',
width: '30%',
justifyContent: 'center',
alignItems: 'center',
display: 'flex'
}}>
<Tooltip title="Delete from queue" arrow placement='top'>
<IconButton
onClick={() => {
msg.removeFromQueue(index);
}} sx={{ position: 'relative', left: '50%', transform: 'translateX(-50%)', width: '60%' }} variant="outlined" color="warning">
Remove from Queue
</Button>
}}
sx={{
backgroundColor: '#ff573a25',
height: '40px',
transition: '250ms',
'&:hover' : {
backgroundColor: '#ff573a',
}
}}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
{index < length - 1 && <Divider variant="fullWidth" />}
</Box>;
;
})}
</> : <Box>
<Typography color='text.primary' variant='h4'>
</> : <Box sx={{
display: 'flex',
width: '100%',
height: '12rem',
flexDirection: 'column',
alignItems: 'center',
}}>
<Typography color='text.primary' sx={{
fontSize: '2rem',
margin: '10px'
}}>
Selected episodes will be shown here
</Typography>
<Box sx={{ height: 200, display: 'grid', gridTemplateColumns: '20% 1fr', gap: 1 }}>
<Skeleton variant='rectangular' height={'100%'}/>
<Box sx={{ display: 'grid', gridTemplateRows: '33% 1fr', gap: 1 }}>
<Skeleton variant='text' height={'100%'} />
<Skeleton variant='text' height={'100%'} />
<Box sx={{
display: 'flex',
margin: '10px'
}}>
<Skeleton variant='rectangular' height={'10rem'} width={'20rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Box sx={{
display: 'flex',
flexDirection: 'column',
}}>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
</Box>
</Box>
<Box sx={{
display: 'flex',
margin: '10px'
}}>
<Skeleton variant='rectangular' height={'10rem'} width={'20rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Box sx={{
display: 'flex',
flexDirection: 'column',
}}>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
</Box>
</Box>
</Box>;

View file

@ -44,13 +44,15 @@ const MenuBar: React.FC = () => {
if (!msg)
return <></>;
return <Box sx={{ width: '100%', display: 'flex' }}>
return <Box sx={{ display: 'flex', marginBottom: '1rem', width: '100%', alignItems: 'center' }}>
<Box sx={{ position: 'relative', left: '0%', width: '50%'}}>
<Button onClick={(e) => handleClick(e, 'settings')}>
Settings
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
Help
</Button>
</Box>
<Menu open={openMenu === 'settings'} anchorEl={anchorEl} onClose={handleClose}>
<MenuItem onClick={() => {
msg.openFolder('config');
@ -108,7 +110,7 @@ const MenuBar: React.FC = () => {
Version: {store.version}
</MenuItem>
</Menu>
<Typography variant="h5" color="text.primary" component="div" align="center" sx={{flexGrow: 1}}>
<Typography variant="h5" color="text.primary">
{transformService(store.service)}
</Typography>
</Box>;

View file

@ -29,6 +29,7 @@ const StartQueueButton: React.FC = () => {
startIcon={start ? <PauseCircleFilled /> : <PlayCircleFilled /> }
variant='contained'
onClick={change}
sx={{ maxHeight: '2.3rem' }}
>
{
start ? 'Stop Queue' : 'Start Queue'

View file

@ -4,13 +4,17 @@ import App from './App';
import ServiceProvider from './provider/ServiceProvider';
import Style from './Style';
import MessageChannel from './provider/MessageChannel';
import { IconButton } from '@mui/material';
import { Box, IconButton } from '@mui/material';
import { CloseOutlined } from '@mui/icons-material';
import { SnackbarProvider, SnackbarKey } from 'notistack';
import Store from './provider/Store';
import ErrorHandler from './provider/ErrorHandler';
import QueueProvider from './provider/QueueProvider';
document.body.style.backgroundColor = "rgb(0, 30, 60)";
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
const notistackRef = React.createRef<SnackbarProvider>();
const onClickDismiss = (key: SnackbarKey | undefined) => () => {
if (notistackRef.current)
@ -34,7 +38,9 @@ root.render(
<MessageChannel>
<ServiceProvider>
<QueueProvider>
<App />
<Box>
<App />
</Box>
</QueueProvider>
</ServiceProvider>
</MessageChannel>

View file

@ -18,14 +18,12 @@ const ServiceProvider: FCWithChildren = ({ children }) => {
};
return service === undefined ?
<Box>
<Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please choose your service</Typography>
<Box sx={{ justifyContent: 'center', alignItems: 'center', display: 'flex', flexDirection: 'column', position: 'relative', top: '40vh'}}>
<Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please select your service</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button size='large' variant="contained" onClick={() => setService('funi')} startIcon={<Avatar src={'https://static.funimation.com/static/img/favicon.ico'} />}>Funimation</Button>
<Divider orientation='vertical' flexItem />
<Button size='large' variant="contained" onClick={() => setService('crunchy')} startIcon={<Avatar src={'https://static.crunchyroll.com/cxweb/assets/img/favicons/favicon-32x32.png'} />}>Crunchyroll</Button>
<Divider orientation='vertical' flexItem />
<Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://www.hidive.com/favicon.ico'} />}>Hidive</Button>
<Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://static.diceplatform.com/prod/original/dce.hidive/settings/HIDIVE_AppLogo_1024x1024.0G0vK.jpg'} />}>Hidive</Button>
</Box>
</Box>
: <serviceContext.Provider value={service}>

View file

@ -13,6 +13,8 @@ export type DownloadOptions = {
all: boolean,
but: boolean,
novids: boolean,
hslang?: string,
simul: boolean,
noaudio: boolean
}
@ -48,7 +50,8 @@ const initialState: StoreState = {
all: false,
but: false,
noaudio: false,
novids: false
novids: false,
simul: false
},
service: undefined,
episodeListing: [],

View file

@ -91,6 +91,7 @@ class CrunchyHandler extends Base implements MessageHandler {
console.debug(`Got download options: ${JSON.stringify(data)}`);
this.setDownloading(true);
const _default = yargs.appArgv(this.crunchy.cfg.cli, true);
this.crunchy.api = _default.crapi;
const res = await this.crunchy.downloadFromSeriesID(data.id, {
dubLang: data.dubLang,
e: data.e
@ -98,7 +99,7 @@ class CrunchyHandler extends Base implements MessageHandler {
if (res.isOk) {
for (const select of res.value) {
if (!(await this.crunchy.downloadEpisode(select, {..._default, skipsubs: false, callbackMaker: this.makeProgressHandler.bind(this), q: data.q, fileName: data.fileName, dlsubs: data.dlsubs, dlVideoOnce: data.dlVideoOnce, force: 'y',
novids: data.novids }))) {
novids: data.novids, hslang: data.hslang || 'none' }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);

View file

@ -70,7 +70,7 @@ class FunimationHandler extends Base implements MessageHandler {
},
image: a.image,
e: a.episodeID,
episode: a.epsiodeNumber,
episode: a.epsiodeNumber
};
}));
return true;

View file

@ -26,7 +26,13 @@ class HidiveHandler extends Base implements MessageHandler {
return { isOk: true, value: undefined };
}
public async getAPIVersion() {
const _default = yargs.appArgv(this.hidive.cfg.cli, true);
this.hidive.api = _default.hdapi;
}
public async search(data: SearchData): Promise<SearchResponse> {
await this.getAPIVersion();
console.debug(`Got search options: ${JSON.stringify(data)}`);
const hidiveSearch = await this.hidive.doSearch(data);
if (!hidiveSearch.isOk) {
@ -42,7 +48,7 @@ class HidiveHandler extends Base implements MessageHandler {
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.hd_locale)
if (language.new_hd_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
@ -51,7 +57,7 @@ class HidiveHandler extends Base implements MessageHandler {
public async availableSubCodes(): Promise<string[]> {
const subLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.hd_locale)
if (language.new_hd_locale)
subLanguageCodesArray.push(language.locale);
}
return ['all', 'none', ...new Set(subLanguageCodesArray)];
@ -62,63 +68,120 @@ class HidiveHandler extends Base implements MessageHandler {
if (isNaN(parse) || parse <= 0)
return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.Id],
title: item.Name,
parent: {
title: item.seriesTitle,
season: parseFloat(item.SeasonNumberValue+'')+''
},
image: item.ScreenShotSmallUrl,
e: parseFloat(item.EpisodeNumberValue+'')+'',
episode: parseFloat(item.EpisodeNumberValue+'')+'',
};
}));
return true;
await this.getAPIVersion();
if (this.hidive.api == 'old') {
const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.Id],
title: item.Name,
parent: {
title: item.seriesTitle,
season: parseFloat(item.SeasonNumberValue+'')+''
},
image: item.ScreenShotSmallUrl,
e: parseFloat(item.EpisodeNumberValue+'')+'',
episode: parseFloat(item.EpisodeNumberValue+'')+'',
};
}));
return true;
} else {
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.id],
title: item.title,
parent: {
title: item.seriesTitle,
season: item.episodeInformation.seasonNumber+''
},
image: item.thumbnailUrl,
e: item.episodeInformation.episodeNumber+'',
episode: item.episodeInformation.episodeNumber+'',
};
}));
return true;
}
}
public async listEpisodes(id: string): Promise<EpisodeListResponse> {
const parse = parseInt(id);
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
const request = await this.hidive.listShow(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.Episodes.map(function(item) {
const language = item.Summary.match(/^Audio: (.*)/m);
language?.shift();
const description = item.Summary.split('\r\n');
return {
e: parseFloat(item.EpisodeNumberValue+'')+'',
lang: language ? language[0].split(', ') : [],
name: item.Name,
season: parseFloat(item.SeasonNumberValue+'')+'',
seasonTitle: request.value.Name,
episode: parseFloat(item.EpisodeNumberValue+'')+'',
id: item.Id+'',
img: item.ScreenShotSmallUrl,
description: description ? description[0] : '',
time: ''
};
})};
await this.getAPIVersion();
if (this.hidive.api == 'old') {
const request = await this.hidive.listShow(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.Episodes.map(function(item) {
const language = item.Summary.match(/^Audio: (.*)/m);
language?.shift();
const description = item.Summary.split('\r\n');
return {
e: parseFloat(item.EpisodeNumberValue+'')+'',
lang: language ? language[0].split(', ') : [],
name: item.Name,
season: parseFloat(item.SeasonNumberValue+'')+'',
seasonTitle: request.value.Name,
episode: parseFloat(item.EpisodeNumberValue+'')+'',
id: item.Id+'',
img: item.ScreenShotSmallUrl,
description: description ? description[0] : '',
time: ''
};
})};
} else {
const request = await this.hidive.listSeries(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.map(function(item) {
const description = item.description.split('\r\n');
return {
e: item.episodeInformation.episodeNumber+'',
lang: [],
name: item.title,
season: item.episodeInformation.seasonNumber+'',
seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1].title,
episode: item.episodeInformation.episodeNumber+'',
id: item.id+'',
img: item.thumbnailUrl,
description: description ? description[0] : '',
time: ''
};
})};
}
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.hidive.cfg.cli, true);
const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
this.hidive.api = _default.hdapi;
if (this.hidive.api == 'old') {
const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
for (const ep of res.value) {
await this.hidive.getEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids});
for (const ep of res.value) {
await this.hidive.getEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids });
}
} else {
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
for (const ep of res.value) {
await this.hidive.downloadEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids });
}
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);

1098
hidive.ts

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ import { console } from './log';
const buildsDir = './_builds';
const nodeVer = 'node18-';
type BuildTypes = `${'ubuntu'|'windows'|'macos'|'arm'}64`
type BuildTypes = `${'windows'|'macos'|'linux'|'linuxstatic'|'alpine'}-${'x64'|'arm64'}`|'linuxstatic-armv7'
(async () => {
const buildType = process.argv[2] as BuildTypes;
@ -21,16 +21,23 @@ type BuildTypes = `${'ubuntu'|'windows'|'macos'|'arm'}64`
// main
async function buildBinary(buildType: BuildTypes, gui: boolean) {
const buildStr = 'multi-downloader-nx';
const acceptableBuilds = ['windows64','ubuntu64','macos64'];
const acceptablePlatforms = ['windows','linux','linuxstatic','macos','alpine'];
const acceptableArchs = ['x64','arm64'];
const acceptableBuilds: string[] = ['linuxstatic-armv7'];
for (const platform of acceptablePlatforms) {
for (const arch of acceptableArchs) {
acceptableBuilds.push(platform+'-'+arch);
}
}
if(!acceptableBuilds.includes(buildType)){
console.error('[ERROR] unknown build type!');
console.error('Unknown build type!');
process.exit(1);
}
await modulesCleanup('.');
if(!fs.existsSync(buildsDir)){
fs.mkdirSync(buildsDir);
}
const buildFull = `${buildStr}-${buildType}-${gui ? 'gui' : 'cli'}`;
const buildFull = `${buildStr}-${getFriendlyName(buildType)}-${gui ? 'gui' : 'cli'}`;
const buildDir = `${buildsDir}/${buildFull}`;
if(fs.existsSync(buildDir)){
fs.removeSync(buildDir);
@ -38,7 +45,7 @@ async function buildBinary(buildType: BuildTypes, gui: boolean) {
fs.mkdirSync(buildDir);
const buildConfig = [
gui ? 'gui.js' : 'index.js',
'--target', nodeVer + getTarget(buildType),
'--target', nodeVer + buildType,
'--output', `${buildDir}/${pkg.short_name}`,
];
console.info(`[Build] Build configuration: ${buildFull}`);
@ -51,6 +58,7 @@ async function buildBinary(buildType: BuildTypes, gui: boolean) {
}
fs.mkdirSync(`${buildDir}/config`);
fs.mkdirSync(`${buildDir}/videos`);
fs.mkdirSync(`${buildDir}/widevine`);
fs.copySync('./config/bin-path.yml', `${buildDir}/config/bin-path.yml`);
fs.copySync('./config/cli-defaults.yml', `${buildDir}/config/cli-defaults.yml`);
fs.copySync('./config/dir-path.yml', `${buildDir}/config/dir-path.yml`);
@ -70,15 +78,12 @@ async function buildBinary(buildType: BuildTypes, gui: boolean) {
execSync(`7z a -t7z "${buildsDir}/${buildFull}.7z" "${buildDir}"`,{stdio:[0,1,2]});
}
function getTarget(bt: string) : string {
switch(bt){
case 'windows64':
return 'windows-x64';
case 'ubuntu64':
return 'linux-x64';
case 'macos64':
return 'macos-x64';
default:
return 'windows-x64';
function getFriendlyName(buildString: string): string {
if (buildString.includes('armv7')) {
return 'android';
}
}
if (buildString.includes('linuxstatic')) {
buildString = buildString.replace('linuxstatic', 'linux');
}
return buildString;
}

114
modules/cmac.ts Normal file
View file

@ -0,0 +1,114 @@
//Originally from https://github.com/Frooastside/node-widevine/blob/main/src/cmac.ts
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;
}
}

View file

@ -26,7 +26,7 @@ const fixMiddleWare = (res: Response) => {
export type HLSCallback = (data: ProgressData) => unknown;
type M3U8Json = {
export type M3U8Json = {
segments: Record<string, unknown>[],
mediaSequence?: number,
}

161
modules/license.ts Normal file
View file

@ -0,0 +1,161 @@
//Originaly from https://github.com/Frooastside/node-widevine/blob/main/src/license.ts
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;
}
}

View file

@ -0,0 +1,749 @@
//Originally from https://github.com/Frooastside/node-widevine/blob/main/src/license_protocol.proto
syntax = "proto2";
package license_protocol;
enum LicenseType {
STREAMING = 1;
OFFLINE = 2;
// License type decision is left to provider.
AUTOMATIC = 3;
}
enum PlatformVerificationStatus {
// The platform is not verified.
PLATFORM_UNVERIFIED = 0;
// Tampering detected on the platform.
PLATFORM_TAMPERED = 1;
// The platform has been verified by means of software.
PLATFORM_SOFTWARE_VERIFIED = 2;
// The platform has been verified by means of hardware (e.g. secure boot).
PLATFORM_HARDWARE_VERIFIED = 3;
// Platform verification was not performed.
PLATFORM_NO_VERIFICATION = 4;
// Platform and secure storage capability have been verified by means of
// software.
PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED = 5;
}
// LicenseIdentification is propagated from LicenseRequest to License,
// incrementing version with each iteration.
message LicenseIdentification {
optional bytes request_id = 1;
optional bytes session_id = 2;
optional bytes purchase_id = 3;
optional LicenseType type = 4;
optional int32 version = 5;
optional bytes provider_session_token = 6;
}
message License {
message Policy {
// Indicates that playback of the content is allowed.
optional bool can_play = 1 [default = false];
// Indicates that the license may be persisted to non-volatile
// storage for offline use.
optional bool can_persist = 2 [default = false];
// Indicates that renewal of this license is allowed.
optional bool can_renew = 3 [default = false];
// For the |*duration*| fields, playback must halt when
// license_start_time (seconds since the epoch (UTC)) +
// license_duration_seconds is exceeded. A value of 0
// indicates that there is no limit to the duration.
// Indicates the rental window.
optional int64 rental_duration_seconds = 4 [default = 0];
// Indicates the viewing window, once playback has begun.
optional int64 playback_duration_seconds = 5 [default = 0];
// Indicates the time window for this specific license.
optional int64 license_duration_seconds = 6 [default = 0];
// The |renewal*| fields only apply if |can_renew| is true.
// The window of time, in which playback is allowed to continue while
// renewal is attempted, yet unsuccessful due to backend problems with
// the license server.
optional int64 renewal_recovery_duration_seconds = 7 [default = 0];
// All renewal requests for this license shall be directed to the
// specified URL.
optional string renewal_server_url = 8;
// How many seconds after license_start_time, before renewal is first
// attempted.
optional int64 renewal_delay_seconds = 9 [default = 0];
// Specifies the delay in seconds between subsequent license
// renewal requests, in case of failure.
optional int64 renewal_retry_interval_seconds = 10 [default = 0];
// Indicates that the license shall be sent for renewal when usage is
// started.
optional bool renew_with_usage = 11 [default = false];
// Indicates to client that license renewal and release requests ought to
// include ClientIdentification (client_id).
optional bool always_include_client_id = 12 [default = false];
// Duration of grace period before playback_duration_seconds (short window)
// goes into effect. Optional.
optional int64 play_start_grace_period_seconds = 13 [default = 0];
// Enables "soft enforcement" of playback_duration_seconds, letting the user
// finish playback even if short window expires. Optional.
optional bool soft_enforce_playback_duration = 14 [default = false];
// Enables "soft enforcement" of rental_duration_seconds. Initial playback
// must always start before rental duration expires. In order to allow
// subsequent playbacks to start after the rental duration expires,
// soft_enforce_playback_duration must be true. Otherwise, subsequent
// playbacks will not be allowed once rental duration expires. Optional.
optional bool soft_enforce_rental_duration = 15 [default = true];
}
message KeyContainer {
enum KeyType {
SIGNING = 1; // Exactly one key of this type must appear.
CONTENT = 2; // Content key.
KEY_CONTROL = 3; // Key control block for license renewals. No key.
OPERATOR_SESSION = 4; // wrapped keys for auxiliary crypto operations.
ENTITLEMENT = 5; // Entitlement keys.
OEM_CONTENT = 6; // Partner-specific content key.
}
// The SecurityLevel enumeration allows the server to communicate the level
// of robustness required by the client, in order to use the key.
enum SecurityLevel {
// Software-based whitebox crypto is required.
SW_SECURE_CRYPTO = 1;
// Software crypto and an obfuscated decoder is required.
SW_SECURE_DECODE = 2;
// The key material and crypto operations must be performed within a
// hardware backed trusted execution environment.
HW_SECURE_CRYPTO = 3;
// The crypto and decoding of content must be performed within a hardware
// backed trusted execution environment.
HW_SECURE_DECODE = 4;
// The crypto, decoding and all handling of the media (compressed and
// uncompressed) must be handled within a hardware backed trusted
// execution environment.
HW_SECURE_ALL = 5;
}
message KeyControl {
// |key_control| is documented in:
// Widevine Modular DRM Security Integration Guide for CENC
// If present, the key control must be communicated to the secure
// environment prior to any usage. This message is automatically generated
// by the Widevine License Server SDK.
optional bytes key_control_block = 1;
optional bytes iv = 2;
}
message OutputProtection {
// Indicates whether HDCP is required on digital outputs, and which
// version should be used.
enum HDCP {
HDCP_NONE = 0;
HDCP_V1 = 1;
HDCP_V2 = 2;
HDCP_V2_1 = 3;
HDCP_V2_2 = 4;
HDCP_V2_3 = 5;
HDCP_NO_DIGITAL_OUTPUT = 0xff;
}
optional HDCP hdcp = 1 [default = HDCP_NONE];
// Indicate the CGMS setting to be inserted on analog output.
enum CGMS {
CGMS_NONE = 42;
COPY_FREE = 0;
COPY_ONCE = 2;
COPY_NEVER = 3;
}
optional CGMS cgms_flags = 2 [default = CGMS_NONE];
enum HdcpSrmRule {
HDCP_SRM_RULE_NONE = 0;
// In 'required_protection', this means most current SRM is required.
// Update the SRM on the device. If update cannot happen,
// do not allow the key.
// In 'requested_protection', this means most current SRM is requested.
// Update the SRM on the device. If update cannot happen,
// allow use of the key anyway.
CURRENT_SRM = 1;
}
optional HdcpSrmRule hdcp_srm_rule = 3 [default = HDCP_SRM_RULE_NONE];
// Optional requirement to indicate analog output is not allowed.
optional bool disable_analog_output = 4 [default = false];
// Optional requirement to indicate digital output is not allowed.
optional bool disable_digital_output = 5 [default = false];
}
message VideoResolutionConstraint {
// Minimum and maximum video resolutions in the range (height x width).
optional uint32 min_resolution_pixels = 1;
optional uint32 max_resolution_pixels = 2;
// Optional output protection requirements for this range. If not
// specified, the OutputProtection in the KeyContainer applies.
optional OutputProtection required_protection = 3;
}
message OperatorSessionKeyPermissions {
// Permissions/key usage flags for operator service keys
// (type = OPERATOR_SESSION).
optional bool allow_encrypt = 1 [default = false];
optional bool allow_decrypt = 2 [default = false];
optional bool allow_sign = 3 [default = false];
optional bool allow_signature_verify = 4 [default = false];
}
optional bytes id = 1;
optional bytes iv = 2;
optional bytes key = 3;
optional KeyType type = 4;
optional SecurityLevel level = 5 [default = SW_SECURE_CRYPTO];
optional OutputProtection required_protection = 6;
// NOTE: Use of requested_protection is not recommended as it is only
// supported on a small number of platforms.
optional OutputProtection requested_protection = 7;
optional KeyControl key_control = 8;
optional OperatorSessionKeyPermissions operator_session_key_permissions = 9;
// Optional video resolution constraints. If the video resolution of the
// content being decrypted/decoded falls within one of the specified ranges,
// the optional required_protections may be applied. Otherwise an error will
// be reported.
// NOTE: Use of this feature is not recommended, as it is only supported on
// a small number of platforms.
repeated VideoResolutionConstraint video_resolution_constraints = 10;
// Optional flag to indicate the key must only be used if the client
// supports anti rollback of the user table. Content provider can query the
// client capabilities to determine if the client support this feature.
optional bool anti_rollback_usage_table = 11 [default = false];
// Optional not limited to commonly known track types such as SD, HD.
// It can be some provider defined label to identify the track.
optional string track_label = 12;
}
optional LicenseIdentification id = 1;
optional Policy policy = 2;
repeated KeyContainer key = 3;
// Time of the request in seconds (UTC) as set in
// LicenseRequest.request_time. If this time is not set in the request,
// the local time at the license service is used in this field.
optional int64 license_start_time = 4;
optional bool remote_attestation_verified = 5 [default = false];
// Client token generated by the content provider. Optional.
optional bytes provider_client_token = 6;
// 4cc code specifying the CENC protection scheme as defined in the CENC 3.0
// specification. Propagated from Widevine PSSH box. Optional.
optional uint32 protection_scheme = 7;
// 8 byte verification field "HDCPDATA" followed by unsigned 32 bit minimum
// HDCP SRM version (whether the version is for HDCP1 SRM or HDCP2 SRM
// depends on client max_hdcp_version).
// Additional details can be found in Widevine Modular DRM Security
// Integration Guide for CENC.
optional bytes srm_requirement = 8;
// If present this contains a signed SRM file (either HDCP1 SRM or HDCP2 SRM
// depending on client max_hdcp_version) that should be installed on the
// client device.
optional bytes srm_update = 9;
// Indicates the status of any type of platform verification performed by the
// server.
optional PlatformVerificationStatus platform_verification_status = 10
[default = PLATFORM_NO_VERIFICATION];
// IDs of the groups for which keys are delivered in this license, if any.
repeated bytes group_ids = 11;
}
enum ProtocolVersion {
VERSION_2_0 = 20;
VERSION_2_1 = 21;
VERSION_2_2 = 22;
}
message LicenseRequest {
message ContentIdentification {
message WidevinePsshData {
repeated bytes pssh_data = 1;
optional LicenseType license_type = 2;
optional bytes request_id = 3; // Opaque, client-specified.
}
message WebmKeyId {
optional bytes header = 1;
optional LicenseType license_type = 2;
optional bytes request_id = 3; // Opaque, client-specified.
}
message ExistingLicense {
optional LicenseIdentification license_id = 1;
optional int64 seconds_since_started = 2;
optional int64 seconds_since_last_played = 3;
optional bytes session_usage_table_entry = 4;
}
message InitData {
enum InitDataType {
CENC = 1;
WEBM = 2;
}
optional InitDataType init_data_type = 1 [default = CENC];
optional bytes init_data = 2;
optional LicenseType license_type = 3;
optional bytes request_id = 4;
}
oneof content_id_variant {
// Exactly one of these must be present.
WidevinePsshData widevine_pssh_data = 1;
WebmKeyId webm_key_id = 2;
ExistingLicense existing_license = 3;
InitData init_data = 4;
}
}
enum RequestType {
NEW = 1;
RENEWAL = 2;
RELEASE = 3;
}
// The client_id provides information authenticating the calling device. It
// contains the Widevine keybox token that was installed on the device at the
// factory. This field or encrypted_client_id below is required for a valid
// license request, but both should never be present in the same request.
optional ClientIdentification client_id = 1;
optional ContentIdentification content_id = 2;
optional RequestType type = 3;
// Time of the request in seconds (UTC) as set by the client.
optional int64 request_time = 4;
// Old-style decimal-encoded string key control nonce.
optional bytes key_control_nonce_deprecated = 5;
optional ProtocolVersion protocol_version = 6 [default = VERSION_2_0];
// New-style uint32 key control nonce, please use instead of
// key_control_nonce_deprecated.
optional uint32 key_control_nonce = 7;
// Encrypted ClientIdentification message, used for privacy purposes.
optional EncryptedClientIdentification encrypted_client_id = 8;
}
message MetricData {
enum MetricType {
// The time spent in the 'stage', specified in microseconds.
LATENCY = 1;
// The UNIX epoch timestamp at which the 'stage' was first accessed in
// microseconds.
TIMESTAMP = 2;
}
message TypeValue {
optional MetricType type = 1;
// The value associated with 'type'. For example if type == LATENCY, the
// value would be the time in microseconds spent in this 'stage'.
optional int64 value = 2 [default = 0];
}
// 'stage' that is currently processing the SignedMessage. Required.
optional string stage_name = 1;
// metric and associated value.
repeated TypeValue metric_data = 2;
}
message VersionInfo {
// License SDK version reported by the Widevine License SDK. This field
// is populated automatically by the SDK.
optional string license_sdk_version = 1;
// Version of the service hosting the license SDK. This field is optional.
// It may be provided by the hosting service.
optional string license_service_version = 2;
}
message SignedMessage {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
SUB_LICENSE = 6;
CAS_LICENSE_REQUEST = 7;
CAS_LICENSE = 8;
EXTERNAL_LICENSE_REQUEST = 9;
EXTERNAL_LICENSE = 10;
}
enum SessionKeyType {
UNDEFINED = 0;
WRAPPED_AES_KEY = 1;
EPHERMERAL_ECC_PUBLIC_KEY = 2;
}
optional MessageType type = 1;
optional bytes msg = 2;
// Required field that contains the signature of the bytes of msg.
// For license requests, the signing algorithm is determined by the
// certificate contained in the request.
// For license responses, the signing algorithm is HMAC with signing key based
// on |session_key|.
optional bytes signature = 3;
// If populated, the contents of this field will be signaled by the
// |session_key_type| type. If the |session_key_type| is WRAPPED_AES_KEY the
// key is the bytes of an encrypted AES key. If the |session_key_type| is
// EPHERMERAL_ECC_PUBLIC_KEY the field contains the bytes of an RFC5208 ASN1
// serialized ECC public key.
optional bytes session_key = 4;
// Remote attestation data which will be present in the initial license
// request for ChromeOS client devices operating in verified mode. Remote
// attestation challenge data is |msg| field above. Optional.
optional bytes remote_attestation = 5;
repeated MetricData metric_data = 6;
// Version information from the SDK and license service. This information is
// provided in the license response.
optional VersionInfo service_version_info = 7;
// Optional field that contains the algorithm type used to generate the
// session_key and signature in a LICENSE message.
optional SessionKeyType session_key_type = 8 [default = WRAPPED_AES_KEY];
// The core message is the simple serialization of fields used by OEMCrypto.
// This field was introduced in OEMCrypto API v16.
optional bytes oemcrypto_core_message = 9;
}
enum HashAlgorithmProto {
// Unspecified hash algorithm: SHA_256 shall be used for ECC based algorithms
// and SHA_1 shall be used otherwise.
HASH_ALGORITHM_UNSPECIFIED = 0;
HASH_ALGORITHM_SHA_1 = 1;
HASH_ALGORITHM_SHA_256 = 2;
HASH_ALGORITHM_SHA_384 = 3;
}
// ClientIdentification message used to authenticate the client device.
message ClientIdentification {
enum TokenType {
KEYBOX = 0;
DRM_DEVICE_CERTIFICATE = 1;
REMOTE_ATTESTATION_CERTIFICATE = 2;
OEM_DEVICE_CERTIFICATE = 3;
}
message NameValue {
optional string name = 1;
optional string value = 2;
}
// Capabilities which not all clients may support. Used for the license
// exchange protocol only.
message ClientCapabilities {
enum HdcpVersion {
HDCP_NONE = 0;
HDCP_V1 = 1;
HDCP_V2 = 2;
HDCP_V2_1 = 3;
HDCP_V2_2 = 4;
HDCP_V2_3 = 5;
HDCP_NO_DIGITAL_OUTPUT = 0xff;
}
enum CertificateKeyType {
RSA_2048 = 0;
RSA_3072 = 1;
ECC_SECP256R1 = 2;
ECC_SECP384R1 = 3;
ECC_SECP521R1 = 4;
}
enum AnalogOutputCapabilities {
ANALOG_OUTPUT_UNKNOWN = 0;
ANALOG_OUTPUT_NONE = 1;
ANALOG_OUTPUT_SUPPORTED = 2;
ANALOG_OUTPUT_SUPPORTS_CGMS_A = 3;
}
optional bool client_token = 1 [default = false];
optional bool session_token = 2 [default = false];
optional bool video_resolution_constraints = 3 [default = false];
optional HdcpVersion max_hdcp_version = 4 [default = HDCP_NONE];
optional uint32 oem_crypto_api_version = 5;
// Client has hardware support for protecting the usage table, such as
// storing the generation number in secure memory. For Details, see:
// Widevine Modular DRM Security Integration Guide for CENC
optional bool anti_rollback_usage_table = 6 [default = false];
// The client shall report |srm_version| if available.
optional uint32 srm_version = 7;
// A device may have SRM data, and report a version, but may not be capable
// of updating SRM data.
optional bool can_update_srm = 8 [default = false];
repeated CertificateKeyType supported_certificate_key_type = 9;
optional AnalogOutputCapabilities analog_output_capabilities = 10
[default = ANALOG_OUTPUT_UNKNOWN];
optional bool can_disable_analog_output = 11 [default = false];
// Clients can indicate a performance level supported by OEMCrypto.
// This will allow applications and providers to choose an appropriate
// quality of content to serve. Currently defined tiers are
// 1 (low), 2 (medium) and 3 (high). Any other value indicates that
// the resource rating is unavailable or reporting erroneous values
// for that device. For details see,
// Widevine Modular DRM Security Integration Guide for CENC
optional uint32 resource_rating_tier = 12 [default = 0];
}
message ClientCredentials {
optional TokenType type = 1 [default = KEYBOX];
optional bytes token = 2;
}
// Type of factory-provisioned device root of trust. Optional.
optional TokenType type = 1 [default = KEYBOX];
// Factory-provisioned device root of trust. Required.
optional bytes token = 2;
// Optional client information name/value pairs.
repeated NameValue client_info = 3;
// Client token generated by the content provider. Optional.
optional bytes provider_client_token = 4;
// Number of licenses received by the client to which the token above belongs.
// Only present if client_token is specified.
optional uint32 license_counter = 5;
// List of non-baseline client capabilities.
optional ClientCapabilities client_capabilities = 6;
// Serialized VmpData message. Optional.
optional bytes vmp_data = 7;
// Optional field that may contain additional provisioning credentials.
repeated ClientCredentials device_credentials = 8;
}
// EncryptedClientIdentification message used to hold ClientIdentification
// messages encrypted for privacy purposes.
message EncryptedClientIdentification {
// Provider ID for which the ClientIdentifcation is encrypted (owner of
// service certificate).
optional string provider_id = 1;
// Serial number for the service certificate for which ClientIdentification is
// encrypted.
optional bytes service_certificate_serial_number = 2;
// Serialized ClientIdentification message, encrypted with the privacy key
// using AES-128-CBC with PKCS#5 padding.
optional bytes encrypted_client_id = 3;
// Initialization vector needed to decrypt encrypted_client_id.
optional bytes encrypted_client_id_iv = 4;
// AES-128 privacy key, encrypted with the service public key using RSA-OAEP.
optional bytes encrypted_privacy_key = 5;
}
// DRM certificate definition for user devices, intermediate, service, and root
// certificates.
message DrmCertificate {
enum Type {
ROOT = 0; // ProtoBestPractices: ignore.
DEVICE_MODEL = 1;
DEVICE = 2;
SERVICE = 3;
PROVISIONER = 4;
}
enum ServiceType {
UNKNOWN_SERVICE_TYPE = 0;
LICENSE_SERVER_SDK = 1;
LICENSE_SERVER_PROXY_SDK = 2;
PROVISIONING_SDK = 3;
CAS_PROXY_SDK = 4;
}
enum Algorithm {
UNKNOWN_ALGORITHM = 0;
RSA = 1;
ECC_SECP256R1 = 2;
ECC_SECP384R1 = 3;
ECC_SECP521R1 = 4;
}
message EncryptionKey {
// Device public key. PKCS#1 ASN.1 DER-encoded. Required.
optional bytes public_key = 1;
// Required. The algorithm field contains the curve used to create the
// |public_key| if algorithm is one of the ECC types.
// The |algorithm| is used for both to determine the if the certificate is
// ECC or RSA. The |algorithm| also specifies the parameters that were used
// to create |public_key| and are used to create an ephemeral session key.
optional Algorithm algorithm = 2 [default = RSA];
}
// Type of certificate. Required.
optional Type type = 1;
// 128-bit globally unique serial number of certificate.
// Value is 0 for root certificate. Required.
optional bytes serial_number = 2;
// POSIX time, in seconds, when the certificate was created. Required.
optional uint32 creation_time_seconds = 3;
// POSIX time, in seconds, when the certificate should expire. Value of zero
// denotes indefinite expiry time. For more information on limited lifespan
// DRM certificates see (go/limited-lifespan-drm-certificates).
optional uint32 expiration_time_seconds = 12;
// Device public key. PKCS#1 ASN.1 DER-encoded. Required.
optional bytes public_key = 4;
// Widevine system ID for the device. Required for intermediate and
// user device certificates.
optional uint32 system_id = 5;
// Deprecated field, which used to indicate whether the device was a test
// (non-production) device. The test_device field in ProvisionedDeviceInfo
// below should be observed instead.
optional bool test_device_deprecated = 6 [deprecated = true];
// Service identifier (web origin) for the provider which owns the
// certificate. Required for service and provisioner certificates.
optional string provider_id = 7;
// This field is used only when type = SERVICE to specify which SDK uses
// service certificate. This repeated field is treated as a set. A certificate
// may be used for the specified service SDK if the appropriate ServiceType
// is specified in this field.
repeated ServiceType service_types = 8;
// Required. The algorithm field contains the curve used to create the
// |public_key| if algorithm is one of the ECC types.
// The |algorithm| is used for both to determine the if the certificate is ECC
// or RSA. The |algorithm| also specifies the parameters that were used to
// create |public_key| and are used to create an ephemeral session key.
optional Algorithm algorithm = 9 [default = RSA];
// Optional. May be present in DEVICE certificate types. This is the root
// of trust identifier that holds an encrypted value that identifies the
// keybox or other root of trust that was used to provision a DEVICE drm
// certificate.
optional bytes rot_id = 10;
// Optional. May be present in devices that explicitly support dual keys. When
// present the |public_key| is used for verification of received license
// request messages.
optional EncryptionKey encryption_key = 11;
}
// DrmCertificate signed by a higher (CA) DRM certificate.
message SignedDrmCertificate {
// Serialized certificate. Required.
optional bytes drm_certificate = 1;
// Signature of certificate. Signed with root or intermediate
// certificate specified below. Required.
optional bytes signature = 2;
// SignedDrmCertificate used to sign this certificate.
optional SignedDrmCertificate signer = 3;
// Optional field that indicates the hash algorithm used in signature scheme.
optional HashAlgorithmProto hash_algorithm = 4;
}
message WidevinePsshData {
enum Type {
SINGLE = 0; // Single PSSH to be used to retrieve content keys.
ENTITLEMENT = 1; // Primary PSSH used to retrieve entitlement keys.
ENTITLED_KEY = 2; // Secondary PSSH containing entitled key(s).
}
message EntitledKey {
// ID of entitlement key used for wrapping |key|.
optional bytes entitlement_key_id = 1;
// ID of the entitled key.
optional bytes key_id = 2;
// Wrapped key. Required.
optional bytes key = 3;
// IV used for wrapping |key|. Required.
optional bytes iv = 4;
// Size of entitlement key used for wrapping |key|.
optional uint32 entitlement_key_size_bytes = 5 [default = 32];
}
// Entitlement or content key IDs. Can onnly present in SINGLE or ENTITLEMENT
// PSSHs. May be repeated to facilitate delivery of multiple keys in a
// single license. Cannot be used in conjunction with content_id or
// group_ids, which are the preferred mechanism.
repeated bytes key_ids = 2;
// Content identifier which may map to multiple entitlement or content key
// IDs to facilitate the delivery of multiple keys in a single license.
// Cannot be present in conjunction with key_ids, but if used must be in all
// PSSHs.
optional bytes content_id = 4;
// Crypto period index, for media using key rotation. Always corresponds to
// The content key period. This means that if using entitlement licensing
// the ENTITLED_KEY PSSHs will have sequential crypto_period_index's, whereas
// the ENTITELEMENT PSSHs will have gaps in the sequence. Required if doing
// key rotation.
optional uint32 crypto_period_index = 7;
// Protection scheme identifying the encryption algorithm. The protection
// scheme is represented as a uint32 value. The uint32 contains 4 bytes each
// representing a single ascii character in one of the 4CC protection scheme
// values. To be deprecated in favor of signaling from content.
// 'cenc' (AES-CTR) protection_scheme = 0x63656E63,
// 'cbc1' (AES-CBC) protection_scheme = 0x63626331,
// 'cens' (AES-CTR pattern encryption) protection_scheme = 0x63656E73,
// 'cbcs' (AES-CBC pattern encryption) protection_scheme = 0x63626373.
optional uint32 protection_scheme = 9;
// Optional. For media using key rotation, this represents the duration
// of each crypto period in seconds.
optional uint32 crypto_period_seconds = 10;
// Type of PSSH. Required if not SINGLE.
optional Type type = 11 [default = SINGLE];
// Key sequence for Widevine-managed keys. Optional.
optional uint32 key_sequence = 12;
// Group identifiers for all groups to which the content belongs. This can
// be used to deliver licenses to unlock multiple titles / channels.
// Optional, and may only be present in ENTITLEMENT and ENTITLED_KEY PSSHs, and
// not in conjunction with key_ids.
repeated bytes group_ids = 13;
// Copy/copies of the content key used to decrypt the media stream in which
// the PSSH box is embedded, each wrapped with a different entitlement key.
// May also contain sub-licenses to support devices with OEMCrypto 13 or
// older. May be repeated if using group entitlement keys. Present only in
// PSSHs of type ENTITLED_KEY.
repeated EntitledKey entitled_keys = 14;
// Video feature identifier, which is used in conjunction with |content_id|
// to determine the set of keys to be returned in the license. Cannot be
// present in conjunction with |key_ids|.
// Current values are "HDR".
optional string video_feature = 15;
//////////////////////////// Deprecated Fields ////////////////////////////
enum Algorithm {
UNENCRYPTED = 0;
AESCTR = 1;
};
optional Algorithm algorithm = 1 [deprecated = true];
// Content provider name.
optional string provider = 3 [deprecated = true];
// Track type. Acceptable values are SD, HD and AUDIO. Used to
// differentiate content keys used by an asset.
optional string track_type = 5 [deprecated = true];
// The name of a registered policy to be used for this asset.
optional string policy = 6 [deprecated = true];
// Optional protected context for group content. The grouped_license is a
// serialized SignedMessage.
optional bytes grouped_license = 8 [deprecated = true];
}
// File Hashes for Verified Media Path (VMP) support.
message FileHashes {
message Signature {
optional string filename = 1;
optional bool test_signing = 2; //0 - release, 1 - testing
optional bytes SHA512Hash = 3;
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
optional bytes signature = 5;
}
optional bytes signer = 1;
repeated Signature signatures = 2;
}

4996
modules/license_protocol.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,8 @@ const domain = {
www_beta: 'https://beta.crunchyroll.com',
api_beta: 'https://beta-api.crunchyroll.com',
hd_www: 'https://www.hidive.com',
hd_api: 'https://api.hidive.com'
hd_api: 'https://api.hidive.com',
hd_new: 'https://dce-frontoffice.imggaming.com'
};
export type APIType = {
@ -41,6 +42,9 @@ export type APIType = {
hd_clientWeb: string,
hd_clientExo: string,
hd_api: string,
hd_new_api: string,
hd_new_apiKey: string,
hd_new_version: string,
}
// api urls
@ -77,6 +81,10 @@ const api: APIType = {
hd_clientWeb: 'okhttp/3.4.1',
hd_clientExo: 'smartexoplayer/1.6.0.R (Linux;Android 6.0) ExoPlayerLib/2.6.0',
hd_api: `${domain.hd_api}/api/v1`,
//Hidive New API
hd_new_api: `${domain.hd_new}/api`,
hd_new_apiKey: '857a1e5d-e35e-4fdf-805b-a87b6f8364bf',
hd_new_version: '6.0.1.bbf09a2'
};
// set header

View file

@ -31,7 +31,8 @@ let argvC: {
new: boolean | undefined;
'movie-listing': string | undefined;
series: string | undefined;
s: string | undefined;
s: string | undefined;
srz: string | undefined;
e: string | undefined;
extid: string | undefined;
q: number;
@ -64,6 +65,9 @@ let argvC: {
_: (string | number)[];
$0: string;
dlVideoOnce: boolean;
chapters: boolean;
crapi: 'android' | 'web';
hdapi: 'old' | 'new';
removeBumpers: boolean;
originalFontSize: boolean;
keepAllVideos: boolean;

View file

@ -138,8 +138,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
alias: 'srz',
describe: 'Get season list by series ID',
docDescribe: 'This command is used only for crunchyroll.'
+ '\n Requested is the ID of a show not a season.',
docDescribe: 'Requested is the ID of a show not a season.',
service: ['crunchy'],
type: 'string',
usage: '${ID}'
@ -203,6 +202,47 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: false
}
},
{
name: 'chapters',
describe: 'Will fetch the chapters and add them into the final video',
type: 'boolean',
group: 'dl',
service: ['crunchy'],
docDescribe: 'Will fetch the chapters and add them into the final video.'
+ '\nCurrently only works with mkvmerge.',
usage: '',
default: {
default: false
}
},
{
name: 'crapi',
describe: 'Selects the API type for Crunchyroll',
type: 'string',
group: 'dl',
service: ['crunchy'],
docDescribe: 'If set to Android, it has lower quality, but Non-DRM streams,'
+ '\nIf set to Web, it has a higher quality adaptive stream, but everything is DRM.',
usage: '',
choices: ['android', 'web'],
default: {
default: 'android'
}
},
{
name: 'hdapi',
describe: 'Selects the API type for Hidive',
type: 'string',
group: 'dl',
service: ['hidive'],
docDescribe: 'If set to Old, it has lower quality, but Non-DRM streams, but some people can\'t use it,'
+ '\nIf set to New, it has a higher quality stream, but everything is DRM.',
usage: '',
choices: ['old', 'new'],
default: {
default: 'old'
}
},
{
name: 'removeBumpers',
describe: 'Remove bumpers from final video',

View file

@ -26,7 +26,8 @@ const stateFile = path.join(workingDir, 'config', 'guistate');
const tokenFile = {
funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token'),
hd: path.join(workingDir, 'config', 'hd_token')
hd: path.join(workingDir, 'config', 'hd_token'),
hdNew: path.join(workingDir, 'config', 'hd_new_token')
};
export const ensureConfig = () => {
@ -82,7 +83,8 @@ export type ConfigObject = {
bin: {
ffmpeg?: string,
mkvmerge?: string,
ffprobe?: string
ffprobe?: string,
mp4decrypt?: string
},
cli: {
[key: string]: any
@ -146,7 +148,8 @@ const loadBinCfg = async () => {
const defaultBin = {
ffmpeg: 'ffmpeg',
mkvmerge: 'mkvmerge',
ffprobe: 'ffprobe'
ffprobe: 'ffprobe',
mp4decrypt: 'mp4decrypt'
};
const keys = Object.keys(defaultBin) as (keyof typeof defaultBin)[];
for(const dir of keys){
@ -240,7 +243,7 @@ const saveHDSession = (data: Record<string, unknown>) => {
const loadHDToken = () => {
let token = loadYamlCfgFile(tokenFile.cr, true);
let token = loadYamlCfgFile(tokenFile.hd, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
@ -290,6 +293,25 @@ const loadHDProfile = () => {
return profile;
};
const loadNewHDToken = () => {
let token = loadYamlCfgFile(tokenFile.hdNew, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveNewHDToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.hdNew);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.hdNew}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadFuniToken = () => {
const loadedToken = loadYamlCfgFile<{
token?: string
@ -361,6 +383,8 @@ export {
loadHDSession,
saveHDToken,
loadHDToken,
saveNewHDToken,
loadNewHDToken,
saveHDProfile,
loadHDProfile,
getState,

View file

@ -3,6 +3,7 @@
export type LanguageItem = {
cr_locale?: string,
hd_locale?: string,
new_hd_locale?: string,
locale: string,
code: string,
name: string,
@ -13,12 +14,12 @@ export type LanguageItem = {
}
const languages: LanguageItem[] = [
{ cr_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', },
{ cr_locale: 'es-LA', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' },
{ cr_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'pt-BR', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'pt-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' },
{ cr_locale: 'fr-FR', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
@ -142,9 +143,9 @@ const sortTags = (data: string[]) => {
return sort.map(e => e.locale as string);
};
const subsFile = (fnOutput:string, subsIndex: string, langItem: LanguageItem, isCC: boolean, ccTag: string) => {
const subsFile = (fnOutput:string, subsIndex: string, langItem: LanguageItem, isCC: boolean, ccTag: string, isSigns?: boolean, format?: string) => {
subsIndex = (parseInt(subsIndex) + 1).toString().padStart(2, '0');
return `${fnOutput}.${subsIndex}.${langItem.code}.${langItem.language}${isCC ? `.${ccTag}` : ''}.ass`;
return `${fnOutput}.${subsIndex}.${langItem.code}.${langItem.language}${isCC ? `.${ccTag}` : ''}${isSigns ? '.signs' : ''}.${format ? format : 'ass'}`;
};
// construct dub langs const

View file

@ -21,6 +21,7 @@ export type SubtitleInput = {
language: LanguageItem,
file: string,
closedCaption?: boolean,
signs?: boolean,
delay?: number
}
@ -37,6 +38,7 @@ export type MergerOptions = {
onlyVid: MergerInput[],
onlyAudio: MergerInput[],
subtitles: SubtitleInput[],
chapters?: MergerInput[],
ccTag: string,
output: string,
videoTitle?: string,
@ -162,7 +164,7 @@ class Merger {
args.push(`-i "${sub.file}"`);
}
if (this.options.output.split('.').pop() === 'mkv')
if (this.options.output.split('.').pop() === 'mkv') {
if (this.options.fonts) {
let fontIndex = 0;
for (const font of this.options.fonts) {
@ -170,6 +172,9 @@ class Merger {
fontIndex++;
}
}
}
//TODO: Make it possible for chapters to work with ffmpeg merging
args.push(...metaData);
args.push(...this.options.subtitles.map((_, subIndex) => `-map ${subIndex + index}`));
@ -178,7 +183,7 @@ class Merger {
'-c:a copy',
this.options.output.split('.').pop()?.toLowerCase() === 'mp4' ? '-c:s mov_text' : '-c:s ass',
...this.options.subtitles.map((sub, subindex) => `-metadata:s:s:${subindex} title="${
(sub.language.language || sub.language.name) + `${sub.closedCaption === true ? ` ${this.options.ccTag}` : ''}`
(sub.language.language || sub.language.name) + `${sub.closedCaption === true ? ` ${this.options.ccTag}` : ''}` + `${sub.signs === true ? ' Signs' : ''}`
}" -metadata:s:s:${subindex} language=${sub.language.code}`)
);
args.push(...this.options.options.ffmpeg);
@ -281,7 +286,7 @@ class Merger {
`--sync 0:-${Math.ceil(subObj.delay*1000)}`
);
}
args.push('--track-name', `0:"${(subObj.language.language || subObj.language.name) + `${subObj.closedCaption === true ? ` ${this.options.ccTag}` : ''}`}"`);
args.push('--track-name', `0:"${(subObj.language.language || subObj.language.name) + `${subObj.closedCaption === true ? ` ${this.options.ccTag}` : ''}` + `${subObj.signs === true ? ' Signs' : ''}`}"`);
args.push('--language', `0:"${subObj.language.code}"`);
//TODO: look into making Closed Caption default if it's the only sub of the default language downloaded
if (this.options.defaults.sub.code === subObj.language.code && !subObj.closedCaption) {
@ -296,6 +301,7 @@ class Merger {
'--no-subtitles',
);
}
if (this.options.fonts && this.options.fonts.length > 0) {
for (const f of this.options.fonts) {
args.push('--attachment-name', f.name);
@ -308,6 +314,10 @@ class Merger {
);
}
if (this.options.chapters && this.options.chapters.length > 0) {
args.push(`--chapters "${this.options.chapters[0].path}"`);
}
return args.join(' ');
};
@ -405,6 +415,7 @@ class Merger {
public cleanUp() {
this.options.onlyAudio.concat(this.options.onlyVid).concat(this.options.videoAndAudio).forEach(a => fs.unlinkSync(a.path));
this.options.chapters?.forEach(a => fs.unlinkSync(a.path));
this.options.subtitles.forEach(a => fs.unlinkSync(a.file));
}

View file

@ -61,7 +61,9 @@ class Req {
options.headers = {...options.headers, ...params.headers};
}
if(options.method == 'POST'){
(options.headers as Headers)['Content-Type'] = 'application/x-www-form-urlencoded';
if (!(options.headers as Headers)['Content-Type']) {
(options.headers as Headers)['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
if(params.body){
options.body = params.body;

View file

@ -0,0 +1,122 @@
import { parse as mpdParse } from 'mpd-parser';
import { LanguageItem, findLang, languages } from './module.langsData';
type Segment = {
uri: string;
timeline: number;
duration: number;
map: {
uri: string;
};
number: number;
presentationTime: number;
}
export type PlaylistItem = {
pssh?: string,
bandwidth: number,
segments: Segment[]
}
type AudioPlayList = {
language: LanguageItem,
default: boolean
} & PlaylistItem
type VideoPlayList = {
quality: {
width: number,
height: number
}
} & PlaylistItem
export type MPDParsed = {
[server: string]: {
audio: AudioPlayList[],
video: VideoPlayList[]
}
}
export function parse(manifest: string, language?: LanguageItem, url?: string) {
if (!manifest.includes('BaseURL') && url) {
manifest = manifest.replace(/(<MPD*\b[^>]*>)/gm, `$1<BaseURL>${url}</BaseURL>`);
}
const parsed = mpdParse(manifest);
const ret: MPDParsed = {};
for (const item of Object.values(parsed.mediaGroups.AUDIO.audio)){
for (const playlist of item.playlists) {
const host = new URL(playlist.resolvedUri).hostname;
if (!Object.prototype.hasOwnProperty.call(ret, host))
ret[host] = { audio: [], video: [] };
//Find and add audio language if it is found in the MPD
let audiolang: LanguageItem;
const foundlanguage = findLang(languages.find(a => a.code === item.language)?.cr_locale ?? 'unknown');
if (item.language) {
audiolang = foundlanguage;
} else {
audiolang = language ? language : foundlanguage;
}
const pItem: AudioPlayList = {
bandwidth: playlist.attributes.BANDWIDTH,
language: audiolang,
default: item.default,
segments: playlist.segments.map((segment): Segment => {
const uri = segment.resolvedUri;
const map_uri = segment.map.resolvedUri;
return {
duration: segment.duration,
map: { uri: map_uri },
number: segment.number,
presentationTime: segment.presentationTime,
timeline: segment.timeline,
uri
};
})
};
if (playlist.contentProtection &&
playlist.contentProtection?.['com.widevine.alpha'].pssh)
pItem.pssh = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
ret[host].audio.push(pItem);
}
}
for (const playlist of parsed.playlists) {
const host = new URL(playlist.resolvedUri).hostname;
if (!Object.prototype.hasOwnProperty.call(ret, host))
ret[host] = { audio: [], video: [] };
const pItem: VideoPlayList = {
bandwidth: playlist.attributes.BANDWIDTH,
quality: playlist.attributes.RESOLUTION!,
segments: playlist.segments.map((segment): Segment => {
const uri = segment.resolvedUri;
const map_uri = segment.map.resolvedUri;
return {
duration: segment.duration,
map: { uri: map_uri },
number: segment.number,
presentationTime: segment.presentationTime,
timeline: segment.timeline,
uri
};
})
};
if (playlist.contentProtection &&
playlist.contentProtection?.['com.widevine.alpha'].pssh)
pItem.pssh = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
ret[host].video.push(pItem);
}
return ret;
}
function arrayBufferToBase64(buffer: Uint8Array): string {
return Buffer.from(buffer).toString('base64');
}

View file

@ -69,7 +69,7 @@ function loadCSS(cssStr: string): Css {
function parseStyle(stylegroup: string, line: string, style: any) {
const defaultSFont = rFont == '' ? defaultStyleFont : rFont; //redeclare cause of let
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song')) { //base for dialog, everything else use defaultStyle
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song') || stylegroup.startsWith('Q0') || stylegroup.startsWith('Q1')) { //base for dialog, everything else use defaultStyle
style = `${defaultSFont},${fontSize},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2.6,0,2,20,20,46,1`;
}
@ -261,6 +261,7 @@ function convert(css: Css, vtt: Vtt[]) {
song_cap: [],
};
const linesMap: Record<string, number> = {};
let previousLine: ReturnType<typeof convertLine> | undefined = undefined;
for (const l in vtt) {
const x = convertLine(stylesMap, vtt[l]);
if (x.ind !== '' && linesMap[x.ind] !== undefined) {
@ -278,7 +279,17 @@ function convert(css: Css, vtt: Vtt[]) {
linesMap[x.ind] = events[x.type as keyof typeof events].length - 1;
}
}
/**
* What cursed code have I brought upon this land?
* This checks if a subtitle should be multi-line, and if it is, pops the just inserted
* subtitle and the previous subtitle, and merges them into a single subtitle.
*/
if (previousLine?.start == x.start && previousLine.type == x.type && previousLine.style == x.style) {
events[x.type as keyof typeof events].pop();
const previousLinePop = events[x.type as keyof typeof events].pop();
events[x.type as keyof typeof events].push(previousLinePop + '\\N'+x.text);
}
previousLine = x;
}
if (events.subtitle.length > 0) {
ass = ass.concat(
@ -399,6 +410,23 @@ function vtt(group: string | undefined, xFontSize: number | undefined, vttStr: s
fontSize = xFontSize && xFontSize > 0 ? xFontSize : 34; // 1em to pix
tmMrg = timeMargin ? timeMargin : 0; //
rFont = replaceFont ? replaceFont : rFont;
if (vttStr.match(/::cue(?:.(.+)\))?{([^}]+)}/g)) {
const cssLines = [];
let defaultCss = '';
const cssGroups = vttStr.matchAll(/::cue(?:.(.+)\))?{([^}]+)}/g);
for (const cssGroup of cssGroups) {
//Below code will bulldoze defined sizes for custom ones
/*if (!options.originalFontSize) {
cssGroup[2] = cssGroup[2].replace(/( font-size:.+?;)/g, '').replace(/(font-size:.+?;)/g, '');
}*/
if (cssGroup[1]) {
cssLines.push(`${cssGroup[1]}{${defaultCss}${cssGroup[2]}}`);
} else {
defaultCss = cssGroup[2];
}
}
cssStr += cssLines.join('\r\n');
}
return convert(
loadCSS(cssStr),
loadVTT(vttStr)

77
modules/widevine.ts Normal file
View file

@ -0,0 +1,77 @@
import { KeyContainer, Session } from './license';
import fs from 'fs';
import { console } from './log';
import got from 'got';
import { workingDir } from './module.cfg-loader';
import path from 'path';
import { ReadError, Response } from 'got';
//read cdm files located in the same directory
let privateKey: Buffer, identifierBlob: Buffer;
export let canDecrypt: boolean;
try {
privateKey = fs.readFileSync(path.join(workingDir, 'widevine', 'device_private_key'));
identifierBlob = fs.readFileSync(path.join(workingDir, 'widevine', 'device_client_id_blob'));
canDecrypt = true;
} catch (e) {
canDecrypt = false;
}
export default async function getKeys(pssh: string | undefined, licenseServer: string, authData: Record<string, string>): Promise<KeyContainer[]> {
if (!pssh || !canDecrypt) return [];
//pssh found in the mpd manifest
const psshBuffer = Buffer.from(
pssh,
'base64'
);
//Create a new widevine session
const session = new Session({ privateKey, identifierBlob }, psshBuffer);
//Generate license
let response;
try {
response = await got(licenseServer, {
method: 'POST',
body: session.createLicenseRequest(),
headers: authData,
responseType: 'text'
});
} catch(_error){
const error = _error as {
name: string
} & ReadError & {
res: Response<unknown>
};
if(error.response && error.response.statusCode && error.response.statusMessage){
console.error(`${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`);
} else{
console.error(`${error.name}: ${error.code || error.message}`);
}
if(error.response && !error.res){
error.res = error.response;
const docTitle = (error.res.body as string).match(/<title>(.*)<\/title>/);
if(error.res.body && docTitle){
console.error(docTitle[1]);
}
}
if(error.res && error.res.body && error.response.statusCode
&& error.response.statusCode != 404 && error.response.statusCode != 403){
console.error('Body:', error.res.body);
}
return [];
}
if (response.statusCode === 200) {
//Parse License and return keys
try {
const json = JSON.parse(response.body);
return session.parseLicense(Buffer.from(json['license'], 'base64'));
} catch {
return session.parseLicense(response.rawBody);
}
} else {
console.info('License request failed:', response.statusMessage, response.body);
return [];
}
}

View file

@ -1,7 +1,7 @@
{
"name": "multi-downloader-nx",
"short_name": "aniDL",
"version": "4.4.4",
"version": "4.5.1",
"description": "Downloader for Crunchyroll, Funimation, or Hidive via CLI or GUI",
"keywords": [
"download",
@ -45,6 +45,8 @@
"@babel/core": "^7.22.9",
"@babel/plugin-syntax-flow": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.22.5",
"@types/xmldom": "^0.1.34",
"@yao-pkg/pkg": "^5.11.1",
"cheerio": "1.0.0-rc.12",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
@ -56,12 +58,16 @@
"got": "^11.8.6",
"iso-639": "^0.2.2",
"log4js": "^6.9.1",
"long": "^5.2.3",
"lookpath": "^1.2.2",
"m3u8-parsed": "^1.3.0",
"mpd-parser": "^1.3.0",
"open": "^8.4.2",
"protobufjs": "^7.2.5",
"sei-helper": "^3.3.0",
"typescript-eslint": "0.0.1-alpha.0",
"ws": "^8.13.0",
"xmldom": "^0.6.0",
"yaml": "^2.3.1",
"yargs": "^17.7.2"
},
@ -69,7 +75,6 @@
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/ffprobe": "^1.1.4",
"@types/ffprobe-static": "^2.0.1",
"@types/fs-extra": "^11.0.1",
"@types/node": "^18.15.11",
"@types/ws": "^8.5.5",
@ -82,9 +87,10 @@
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-react": "7.32.2",
"pkg": "^5.8.1",
"protoc": "^1.1.3",
"removeNPMAbsolutePaths": "^3.0.1",
"ts-node": "^10.9.1",
"ts-proto": "^1.169.1",
"typescript": "5.1.6"
},
"scripts": {
@ -92,19 +98,24 @@
"start": "pnpm prestart && cd lib && node gui.js",
"docs": "ts-node modules/build-docs.ts",
"tsc": "ts-node tsc.ts",
"proto:compile": "protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=\"esModuleInterop=true\" --ts_proto_opt=\"forceLong=long\" --ts_proto_opt=\"env=node\" --ts_proto_out=. modules/*.proto",
"prebuild-cli": "pnpm run tsc false false",
"build-windows-cli": "pnpm run prebuild-cli && cd lib && node modules/build windows64",
"build-ubuntu-cli": "pnpm run prebuild-cli && cd lib && node modules/build ubuntu64",
"build-arm-cli": "pnpm run prebuild-cli && cd lib && node modules/build arm64",
"build-macos-cli": "pnpm run prebuild-cli && cd lib && node modules/build macos64",
"build-windows-cli": "pnpm run prebuild-cli && cd lib && node modules/build windows-x64",
"build-linux-cli": "pnpm run prebuild-cli && cd lib && node modules/build linuxstatic-x64",
"build-arm-cli": "pnpm run prebuild-cli && cd lib && node modules/build linux-arm64",
"build-macos-cli": "pnpm run prebuild-cli && cd lib && node modules/build macos-x64",
"build-alpine-cli": "pnpm run prebuild-cli && cd lib && node modules/build alpine-x64",
"build-android-cli": "pnpm run prebuild-cli && cd lib && node modules/build linuxstatic-armv7",
"prebuild-gui": "pnpm run tsc",
"build-windows-gui": "pnpm run prebuild-gui && cd lib && node modules/build windows64 true",
"build-ubuntu-gui": "pnpm run prebuild-gui && cd lib && node modules/build ubuntu64 true",
"build-arm-gui": "pnpm run prebuild-gui && cd lib && node modules/build arm64 true",
"build-macos-gui": "pnpm run prebuild-gui && cd lib && node modules/build macos64 true",
"build-windows-gui": "pnpm run prebuild-gui && cd lib && node modules/build windows-x64 true",
"build-linux-gui": "pnpm run prebuild-gui && cd lib && node modules/build linuxstatic-x64 true",
"build-arm-gui": "pnpm run prebuild-gui && cd lib && node modules/build linux-arm64 true",
"build-macos-gui": "pnpm run prebuild-gui && cd lib && node modules/build macos-x64 true",
"build-alpine-gui": "pnpm run prebuild-gui && cd lib && node modules/build alpine-x64 true",
"build-android-gui": "pnpm run prebuild-gui && cd lib && node modules/build linuxstatic-armv7 true",
"eslint": "eslint *.js modules",
"eslint-fix": "eslint *.js modules --fix",
"pretest": "pnpm run tsc",
"test": "pnpm run pretest && cd lib && node modules/build windows64 && node modules/build ubuntu64 && node modules/build macos64"
"test": "pnpm run pretest && cd lib && node modules/build windows-x64 && node modules/build linuxstatic-x64 && node modules/build macos-x64"
}
}

File diff suppressed because it is too large Load diff

6
tsc.ts
View file

@ -34,6 +34,7 @@ const ignore = [
'./config/updates.json$',
'./config/cr_token.yml$',
'./config/funi_token.yml$',
'./config/new_hd_token.yml$',
'./config/hd_token.yml$',
'./config/hd_sess.yml$',
'./config/hd_profile.yml$',
@ -42,7 +43,10 @@ const ignore = [
'./fonts*',
'./gui/react*',
'./dev.js$',
'*/node_modules/*'
'*/node_modules/*',
'./widevine/*',
'./videos/*',
'./logs/*',
].map(a => a.replace(/\*/g, '[^]*').replace(/\.\//g, escapeRegExp(__dirname) + '/').replace(/\//g, path.sep === '\\' ? '\\\\' : '/')).map(a => new RegExp(a, 'i'));
export { ignore };

0
widevine/.gitkeep Normal file
View file