Compare commits

..

No commits in common. "master" and "4.4.3" have entirely different histories.

110 changed files with 11466 additions and 17727 deletions

5
.eslintignore Normal file
View file

@ -0,0 +1,5 @@
lib
/videos/*.ts
build
dev.js
tsc.ts

56
.eslintrc.json Normal file
View file

@ -0,0 +1,56 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"overrides": [
{
"files": ["gui/react/**/*"],
"rules": {
"no-console": 0
}
}
],
"rules": {
"no-console": 2,
"react/prop-types": 0,
"react-hooks/exhaustive-deps": 0,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "warn",
"@typescript-eslint/no-unused-vars" : "warn",
"indent": [
"error",
2
],
"linebreak-style": [
"warn",
"windows"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

View file

@ -47,10 +47,9 @@ body:
label: Service
description: "Please tell us what service the bug occured in."
options:
- Funimation
- Crunchyroll
- Hidive
- AnimationDigitalNetwork
- AnimeOnegai
- All
- Irrelevant
validations:

View file

@ -3,6 +3,8 @@ name: auto-documentation
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
documentation:
@ -18,7 +20,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
- run: pnpm i
- run: pnpm run docs
- uses: stefanzweifel/git-auto-commit-action@v4

View file

@ -5,6 +5,8 @@ name: build and push docker image
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build-node:
@ -27,6 +29,6 @@ jobs:
github-token: ${{ github.token }}
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
"multidl/multi-downloader-nx:latest"
"izuco/multi-downloader-nx:latest"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -2,16 +2,15 @@ name: Release Builds
on:
release:
types: [ published ]
types: [ created ]
jobs:
build:
strategy:
matrix:
build_type: [ linux, macos, windows ]
build_arch: [ x64 ]
build_type: [ ubuntu, macos, windows ]
gui: [ gui, cli ]
runs-on: ubuntu-latest
runs-on: ${{ matrix.build_type }}-latest
steps:
- name: Checkout
uses: actions/checkout@v2
@ -21,7 +20,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
check-latest: true
- name: Install Node modules
run: |
@ -39,7 +38,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 }}-${{ matrix.build_arch }}-${{ matrix.gui }}.7z
asset_path: ./lib/_builds/multi-downloader-nx-${{ matrix.build_type }}64-${{ matrix.gui }}.7z
asset_content_type: application/x-7z-compressed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -61,6 +60,6 @@ jobs:
github-token: ${{ github.token }}
push: true
tags: |
"multidl/multi-downloader-nx:${{ github.event.release.tag_name }}"
"izuco/multi-downloader-nx:${{ github.event.release.tag_name }}"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -17,10 +17,10 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
check-latest: true
- run: pnpm i
- run: npx eslint .
- run: pnpx eslint .
test:
needs: eslint
runs-on: ubuntu-latest
@ -32,7 +32,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
check-latest: true
- run: pnpm i
- run: pnpm run test

20
.gitignore vendored
View file

@ -4,8 +4,6 @@
**/node_modules/
/videos/*.json
/videos/*.ts
/videos/*.m4s
/videos/*.txt
.DS_Store
ffmpeg
mkvmerge
@ -21,11 +19,12 @@ token.yml
lib
test.*
updates.json
*_token.yml
*_profile.yml
*_sess.yml
funi_token.yml
cr_token.yml
hd_profile.yml
hd_sess.yml
hd_token.yml
archive.json
guistate.json
fonts
.webpack/
out/
@ -34,12 +33,7 @@ gui/react/build/
docker-compose.yml
crunchyendpoints
.vscode
.idea
/logs
/tmp/*/
!videos/.gitkeep
/videos/*
/tmp/*.*
bin
widevine/*
!widevine/.gitkeep
/videos/*/
/tmp/*.*

View file

@ -1,50 +0,0 @@
export interface ADNPlayerConfig {
player: Player;
}
export interface Player {
image: string;
options: Options;
}
export interface Options {
user: User;
chromecast: Chromecast;
ios: Ios;
video: Video;
dock: any[];
preference: Preference;
}
export interface Chromecast {
appId: string;
refreshTokenUrl: string;
}
export interface Ios {
videoUrl: string;
appUrl: string;
title: string;
}
export interface Preference {
quality: string;
autoplay: boolean;
language: string;
green: boolean;
}
export interface User {
hasAccess: boolean;
profileId: number;
refreshToken: string;
refreshTokenUrl: string;
}
export interface Video {
startDate: null;
currentDate: Date;
available: boolean;
free: boolean;
url: string;
}

46
@types/adnSearch.d.ts vendored
View file

@ -1,46 +0,0 @@
export interface ADNSearch {
shows: ADNSearchShow[];
total: number;
}
export interface ADNSearchShow {
id: number;
title: string;
type: string;
originalTitle: string;
shortTitle: string;
reference: string;
age: string;
languages: string[];
summary: string;
image: string;
image2x: string;
imageHorizontal: string;
imageHorizontal2x: string;
url: string;
urlPath: string;
episodeCount: number;
genres: string[];
copyright: string;
rating: number;
ratingsCount: number;
commentsCount: number;
qualities: string[];
simulcast: boolean;
free: boolean;
available: boolean;
download: boolean;
basedOn: string;
tagline: null;
firstReleaseYear: string;
productionStudio: string;
countryOfOrigin: string;
productionTeam: ProductionTeam[];
nextVideoReleaseDate: null;
indexable: boolean;
}
export interface ProductionTeam {
role: string;
name: string;
}

View file

@ -1,51 +0,0 @@
export interface ADNStreams {
links: Links;
video: Video;
metadata: Metadata;
}
export interface Links {
streaming: Streaming;
subtitles: Subtitles;
history: string;
nextVideoUrl: string;
previousVideoUrl: string;
}
export interface Streaming {
[streams: string]: Streams;
}
export interface Streams {
mobile: string;
sd: string;
hd: string;
fhd: string;
auto: string;
}
export interface Subtitles {
all: string;
}
export interface Metadata {
title: string;
subtitle: string;
summary: null;
rating: number;
}
export interface Video {
guid: string;
id: number;
currentTime: number;
duration: number;
url: string;
image: string;
tcEpisodeStart?:string;
tcEpisodeEnd?: string;
tcIntroStart?: string;
tcIntroEnd?: string;
tcEndingStart?: string;
tcEndingEnd?: string;
}

View file

@ -1,11 +0,0 @@
export interface ADNSubtitles {
[subtitleLang: string]: Subtitle[];
}
export interface Subtitle {
startTime: number;
endTime: number;
positionAlign: string;
lineAlign: string;
text: string;
}

77
@types/adnVideos.d.ts vendored
View file

@ -1,77 +0,0 @@
export interface ADNVideos {
videos: ADNVideo[];
}
export interface ADNVideo {
id: number;
title: string;
name: string;
number: string;
shortNumber: string;
season: string;
reference: string;
type: string;
order: number;
image: string;
image2x: string;
summary: string;
releaseDate: Date;
duration: number;
url: string;
urlPath: string;
embeddedUrl: string;
languages: string[];
qualities: string[];
rating: number;
ratingsCount: number;
commentsCount: number;
available: boolean;
download: boolean;
free: boolean;
freeWithAds: boolean;
show: Show;
indexable: boolean;
isSelected?: boolean;
}
export interface Show {
id: number;
title: string;
type: string;
originalTitle: string;
shortTitle: string;
reference: string;
age: string;
languages: string[];
summary: string;
image: string;
image2x: string;
imageHorizontal: string;
imageHorizontal2x: string;
url: string;
urlPath: string;
episodeCount: number;
genres: string[];
copyright: string;
rating: number;
ratingsCount: number;
commentsCount: number;
qualities: string[];
simulcast: boolean;
free: boolean;
available: boolean;
download: boolean;
basedOn: string;
tagline: string;
firstReleaseYear: string;
productionStudio: string;
countryOfOrigin: string;
productionTeam: ProductionTeam[];
nextVideoReleaseDate: Date;
indexable: boolean;
}
export interface ProductionTeam {
role: string;
name: string;
}

View file

@ -1,88 +0,0 @@
export interface AnimeOnegaiSearch {
text: string;
list: AOSearchResult[];
}
export interface AOSearchResult {
/**
* Asset ID
*/
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
title: string;
active: boolean;
excerpt: string;
description: string;
bg: string;
poster: string;
entry: string;
code_name: string;
/**
* The Video ID required to get the streams
*/
video_entry: string;
trailer: string;
year: number;
/**
* Asset Type, Known Possibilities
* * 1 - Video
* * 2 - Series
*/
asset_type: 1 | 2;
status: number;
permalink: string;
duration: string;
subtitles: boolean;
price: number;
rent_price: number;
rating: number;
color: number | null;
classification: number;
brazil_classification: null | string;
likes: number;
views: number;
button: string;
stream_url: string;
stream_url_backup: string;
copyright: null | string;
skip_intro: null | string;
ending: null | string;
bumper_intro: string;
ads: string;
age_restriction: boolean | null;
epg: null;
allow_languages: string[] | null;
allow_countries: string[] | null;
classification_text: string;
locked: boolean;
resign: boolean;
favorite: boolean;
actors_list: null;
voiceactors_list: null;
artdirectors_list: null;
audios_list: null;
awards_list: null;
companies_list: null;
countries_list: null;
directors_list: null;
edition_list: null;
genres_list: null;
music_list: null;
photograpy_list: null;
producer_list: null;
screenwriter_list: null;
season_list: null;
tags_list: null;
chapter_id: number;
chapter_entry: string;
chapter_poster: string;
progress_time: number;
progress_percent: number;
included_subscription: number;
paid_content: number;
rent_content: number;
objectID: string;
lang: string;
}

View file

@ -1,36 +0,0 @@
export interface AnimeOnegaiSeasons {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
number: number;
asset_id: number;
entry: string;
description: string;
active: boolean;
allow_languages: string[];
allow_countries: string[];
list: Episode[];
}
export interface Episode {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
number: number;
description: string;
thumbnail: string;
entry: string;
video_entry: string;
active: boolean;
season_id: number;
stream_url: string;
skip_intro: null;
ending: null;
open_free: boolean;
asset_id: number;
age_restriction: boolean;
}

View file

@ -1,111 +0,0 @@
export interface AnimeOnegaiSeries {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
title: string;
active: boolean;
excerpt: string;
description: string;
bg: string;
poster: string;
entry: string;
code_name: string;
/**
* The Video ID required to get the streams
*/
video_entry: string;
trailer: string;
year: number;
asset_type: number;
status: number;
permalink: string;
duration: string;
subtitles: boolean;
price: number;
rent_price: number;
rating: number;
color: number;
classification: number;
brazil_classification: string;
likes: number;
views: number;
button: string;
stream_url: string;
stream_url_backup: string;
copyright: string;
skip_intro: null;
ending: null;
bumper_intro: string;
ads: string;
age_restriction: boolean;
epg: null;
allow_languages: string[];
allow_countries: string[];
classification_text: string;
locked: boolean;
resign: boolean;
favorite: boolean;
actors_list: CtorsList[];
voiceactors_list: CtorsList[];
artdirectors_list: any[];
audios_list: SList[];
awards_list: any[];
companies_list: any[];
countries_list: any[];
directors_list: CtorsList[];
edition_list: any[];
genres_list: SList[];
music_list: any[];
photograpy_list: any[];
producer_list: any[];
screenwriter_list: any[];
season_list: any[];
tags_list: TagsList[];
chapter_id: number;
chapter_entry: string;
chapter_poster: string;
progress_time: number;
progress_percent: number;
included_subscription: number;
paid_content: number;
rent_content: number;
objectID: string;
lang: string;
}
export interface CtorsList {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
Permalink?: string;
country: number | null;
year: number | null;
death: number | null;
image: string;
genre: null;
description: string;
permalink?: string;
background?: string;
}
export interface SList {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
age_restriction?: number;
}
export interface TagsList {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
position: number;
status: boolean;
}

View file

@ -1,41 +0,0 @@
export interface AnimeOnegaiStream {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
source_url: string;
backup_url: string;
live: boolean;
token_handler: number;
entry: string;
job: string;
drm: boolean;
transcoding_content_id: string;
transcoding_asset_id: string;
status: number;
thumbnail: string;
hls: string;
dash: string;
widevine_proxy: string;
playready_proxy: string;
apple_licence: string;
apple_certificate: string;
dpath: string;
dbin: string;
subtitles: Subtitle[];
origin: number;
offline_entry: string;
offline_status: boolean;
}
export interface Subtitle {
ID: number;
CreatedAt: Date;
UpdatedAt: Date;
DeletedAt: null;
name: string;
lang: string;
entry_id: string;
url: string;
}

View file

@ -1,5 +1,3 @@
import { Images } from './crunchyEpisodeList';
export interface CrunchyAndroidEpisodes {
__class__: string;
__href__: string;
@ -7,13 +5,13 @@ export interface CrunchyAndroidEpisodes {
__links__: Actions;
__actions__: Actions;
total: number;
items: CrunchyAndroidEpisode[];
items: CrunchyEpisode[];
}
export interface Actions {
}
export interface CrunchyAndroidEpisode {
export interface CrunchyEpisode {
__class__: string;
__href__: string;
__resource_key__: string;
@ -21,7 +19,7 @@ export interface CrunchyAndroidEpisode {
__actions__: Actions;
playback: string;
id: string;
channel_id: ChannelID;
channel_id: string;
series_id: string;
series_title: string;
series_slug_title: string;
@ -39,19 +37,19 @@ export interface CrunchyAndroidEpisode {
next_episode_id: string;
next_episode_title: string;
hd_flag: boolean;
maturity_ratings: MaturityRating[];
maturity_ratings: string[];
extended_maturity_rating: Actions;
is_mature: boolean;
mature_blocked: boolean;
episode_air_date: Date;
upload_date: Date;
availability_starts: Date;
availability_ends: Date;
episode_air_date: string;
upload_date: string;
availability_starts: string;
availability_ends: string;
eligible_region: string;
available_date: Date;
free_available_date: Date;
free_available_date: string;
premium_date: Date;
premium_available_date: Date;
premium_available_date: string;
is_subbed: boolean;
is_dubbed: boolean;
is_clip: boolean;
@ -59,13 +57,13 @@ export interface CrunchyAndroidEpisode {
seo_description: string;
season_tags: string[];
available_offline: boolean;
subtitle_locales: Locale[];
subtitle_locales: string[];
availability_notes: string;
audio_locale: Locale;
audio_locale: string;
versions: Version[];
closed_captions_available: boolean;
identifier: string;
media_type: MediaType;
media_type: string;
slug: string;
images: Images;
duration_ms: number;
@ -78,17 +76,21 @@ export interface CrunchyAndroidEpisode {
}
export interface Links {
'episode/channel': Link;
'episode/next_episode': Link;
'episode/season': Link;
'episode/series': Link;
streams: Link;
'episode/channel': EpisodeChannel;
'episode/next_episode': EpisodeChannel;
'episode/season': EpisodeChannel;
'episode/series': EpisodeChannel;
streams: EpisodeChannel;
}
export interface Link {
export interface EpisodeChannel {
href: string;
}
export interface Images {
thumbnail: Array<Thumbnail[]>;
}
export interface Thumbnail {
width: number;
height: number;
@ -115,18 +117,6 @@ 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,35 +3,18 @@ export interface CrunchyAndroidStreams {
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: Record<unknown, unknown>;
__actions__: Actions;
media_id: string;
audio_locale: Locale;
subtitles: Subtitles;
closed_captions: Subtitles;
audio_locale: string;
subtitles: { [key: string]: Subtitle };
closed_captions: Actions;
streams: Streams;
bifs: string[];
versions: Version[];
captions: Record<unknown, unknown>;
captions: 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 Actions {
}
export interface Links {
@ -47,7 +30,7 @@ export interface Streams {
}
export interface Download {
hardsub_locale: Locale;
hardsub_locale: string;
hardsub_lang?: string;
url: string;
}
@ -57,13 +40,13 @@ export interface Urls {
}
export interface Subtitle {
locale: Locale;
locale: string;
url: string;
format: string;
}
export interface Version {
audio_locale: Locale;
audio_locale: string;
guid: string;
original: boolean;
variant: string;
@ -71,23 +54,3 @@ 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',
}

View file

@ -1,26 +0,0 @@
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,5 +1,3 @@
import { Links } from './crunchyAndroidEpisodes';
export interface CrunchyEpisodeList {
total: number;
data: CrunchyEpisode[];
@ -43,28 +41,27 @@ export interface CrunchyEpisode {
listing_id: string;
episode_air_date: Date;
slug: string;
available_date: Date;
available_date: null;
subtitle_locales: Locale[];
slug_title: string;
available_offline: boolean;
description: string;
is_subbed: boolean;
premium_date: Date;
premium_date: null;
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;
__links__?: Links;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
}
export enum Locale {
@ -130,5 +127,5 @@ export interface Version {
}
export interface Meta {
versions_considered?: boolean;
versions_considered: boolean;
}

View file

@ -1,44 +0,0 @@
import { Locale } from './playbackData';
export interface CrunchyPlayStream {
assetId: string;
audioLocale: Locale;
bifs: string;
burnedInLocale: string;
captions: { [key: string]: Caption };
hardSubs: { [key: string]: HardSub };
playbackType: string;
session: Session;
subtitles: { [key: string]: Subtitle };
token: string;
url: string;
versions: any[];
}
export interface Caption {
format: string;
language: string;
url: string;
}
export interface HardSub {
hlang: string;
url: string;
quality: string;
}
export interface Session {
renewSeconds: number;
noNetworkRetryIntervalSeconds: number;
noNetworkTimeoutSeconds: number;
maximumPauseSeconds: number;
endOfVideoUnloadSeconds: number;
sessionExpirationSeconds: number;
usesStreamLimits: boolean;
}
export interface Subtitle {
format: string;
language: string;
url: string;
}

View file

@ -2,14 +2,11 @@ import { HLSCallback } from 'hls-download';
import { sxItem } from '../crunchy';
import { LanguageItem } from '../modules/module.langsData';
import { DownloadInfo } from './messageHandler';
import { CrunchyPlayStreams } from './enums';
export type CrunchyDownloadOptions = {
hslang: string,
kstream: number,
cstream: keyof typeof CrunchyPlayStreams | 'none',
novids?: boolean,
noaudio?: boolean,
x: number,
q: number,
fileName: string,
@ -34,20 +31,13 @@ export type CrunchyDownloadOptions = {
dlVideoOnce: boolean,
skipmux?: boolean,
syncTiming: boolean,
nocleanup: boolean,
chapters: boolean,
fontName: string | undefined,
originalFontSize: boolean,
fontSize: number,
dubLang: string[],
}
export type CrunchyMultiDownload = {
export type CurnchyMultiDownload = {
dubLang: string[],
all?: boolean,
but?: boolean,
e?: string,
s?: string
e?: string
}
export type CrunchyMuxOptions = {
@ -92,18 +82,8 @@ 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

@ -1,16 +0,0 @@
export enum CrunchyPlayStreams {
'chrome' = 'web/chrome',
'firefox' = 'web/firefox',
'safari' = 'web/safari',
'edge' = 'web/edge',
'fallback' = 'web/fallback',
'ps4' = 'console/ps4',
'ps5' = 'console/ps5',
'switch' = 'console/switch',
'samsungtv' = 'tv/samsung',
'lgtv' = 'tv/lg',
'rokutv' = 'tv/roku',
'android' = 'android/phone',
'iphone' = 'ios/iphone',
'ipad' = 'ios/ipad',
}

34
@types/funiSearch.d.ts vendored Normal file
View file

@ -0,0 +1,34 @@
// Generated by https://quicktype.io
export interface FunimationSearch {
count: number;
items: Items;
limit: string;
offset: string;
}
export interface Items {
hits: Hit[];
}
export interface Hit {
ratings: string;
description: string;
title: string;
image: {
showThumbnail: string,
[key: string]: string
};
starRating: number;
slug: string;
languages: string[];
synopsis: string;
quality: Quality;
id: string;
txDate: number;
}
export interface Quality {
quality: string;
height: number;
}

75
@types/funiSubtitleRequest.d.ts vendored Normal file
View file

@ -0,0 +1,75 @@
// Generated by https://quicktype.io
export interface SubtitleRequest {
primary: Primary;
fallback: Primary[];
}
export interface Primary {
venueVideoId: string;
alphaPackageId: string;
versionContentId: VersionContentID;
manifestPath: string;
fileExt: PrimaryFileEXT;
subtitles: Subtitle[];
accessType: AccessType;
sessionId: string;
audioLanguage: AudioLanguage;
version: Version;
aips: Aip[];
drmToken: string;
drmType: string;
}
export enum AccessType {
Subscription = 'subscription',
}
export interface Aip {
in: number;
out: number;
}
export enum AudioLanguage {
En = 'en',
Ja = 'ja',
}
export enum PrimaryFileEXT {
M3U8 = 'm3u8',
Mp4 = 'mp4',
}
export interface Subtitle {
filePath: string;
fileExt: SubtitleFileEXT;
contentType: ContentType;
languageCode: LanguageCode;
}
export enum ContentType {
Cc = 'cc',
Full = 'full',
}
export enum SubtitleFileEXT {
Dfxp = 'dfxp',
Srt = 'srt',
Vtt = 'vtt',
}
export enum LanguageCode {
En = 'en',
Es = 'es',
Pt = 'pt',
}
export enum Version {
Simulcast = 'simulcast',
Uncut = 'uncut',
}
export enum VersionContentID {
Akusim0012 = 'AKUSIM0012',
Akuunc0012 = 'AKUUNC0012',
}

16
@types/funiTypes.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
import { LanguageItem } from '../modules/module.langsData';
export type FunimationMediaDownload = {
id: string,
title: string,
showTitle: string,
image: string
}
export type Subtitle = {
url: string,
lang: LanguageItem,
ext: string,
out?: string,
closedCaption?: boolean
}

View file

@ -40,7 +40,7 @@ export type QueueItem = {
q: number,
dlVideoOnce: boolean,
dubLang: string[],
image: string,
image: string
} & ResolveItemsData
export type ResolveItemsData = {
@ -106,9 +106,7 @@ 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 = {
hslang?: string; id: string, e: string, dubLang: string[], dlsubs: string[], fileName: string, q: number, novids: boolean, noaudio: boolean, dlVideoOnce: boolean
}
export type DownloadData = { 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>;

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

@ -1,101 +0,0 @@
declare module 'mpd-parser' {
export type Segment = {
uri: string,
timeline: number,
duration: number,
resolvedUri: string,
map: {
uri: string,
resolvedUri: string,
byterange?: {
length: number,
offset: number
}
},
byterange?: {
length: number,
offset: number
},
number: number,
presentationTime: number
}
export type Sidx = {
uri: string,
resolvedUri: string,
byterange: {
length: number,
offset: number
},
map: {
uri: string,
resolvedUri: string,
byterange: {
length: number,
offset: number
}
},
duration: number,
timeline: number,
presentationTime: number,
number: 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[]
sidx?: Sidx
}
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
}

View file

@ -1,43 +0,0 @@
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[];
}

View file

@ -1,33 +0,0 @@
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',
}

View file

@ -1,91 +0,0 @@
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;
}

View file

@ -1,89 +0,0 @@
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;
}

View file

@ -1,35 +0,0 @@
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,29 +1,29 @@
// Generated by https://quicktype.io
export interface PlaybackData {
total: number;
data: { [key: string]: { [key: string]: StreamDetails } }[];
meta: Meta;
data: { [key: string]: { [key: string]: StreamDetails } };
meta: Meta;
}
export interface StreamList {
download_hls: CrunchyStreams;
drm_adaptive_hls: CrunchyStreams;
multitrack_adaptive_hls_v2: CrunchyStreams;
vo_adaptive_hls: CrunchyStreams;
vo_drm_adaptive_hls: CrunchyStreams;
adaptive_hls: CrunchyStreams;
drm_download_dash: CrunchyStreams;
drm_download_hls: CrunchyStreams;
drm_multitrack_adaptive_hls_v2: CrunchyStreams;
vo_drm_adaptive_dash: CrunchyStreams;
adaptive_dash: CrunchyStreams;
urls: CrunchyStreams;
vo_adaptive_dash: CrunchyStreams;
download_dash: CrunchyStreams;
drm_adaptive_dash: CrunchyStreams;
download_hls: Streams;
drm_adaptive_hls: Streams;
multitrack_adaptive_hls_v2: Streams;
vo_adaptive_hls: Streams;
vo_drm_adaptive_hls: Streams;
adaptive_hls: Streams;
drm_download_dash: Streams;
drm_download_hls: Streams;
drm_multitrack_adaptive_hls_v2: Streams;
vo_drm_adaptive_dash: Streams;
adaptive_dash: Streams;
urls: Streams;
vo_adaptive_dash: Streams;
download_dash: Streams;
drm_adaptive_dash: Streams;
}
export interface CrunchyStreams {
export interface Streams {
'': StreamDetails;
'en-US'?: StreamDetails;
'es-LA'?: StreamDetails;
@ -41,12 +41,10 @@ export interface CrunchyStreams {
'zh-CN'?: StreamDetails;
'ko-KR'?: StreamDetails;
'ja-JP'?: StreamDetails;
[string: string]: StreamDetails;
}
export interface StreamDetails {
//hardsub_locale: Locale;
hardsub_locale: string;
hardsub_locale: Locale;
url: string;
hardsub_lang?: string;
audio_lang?: string;
@ -58,12 +56,12 @@ export interface Meta {
bifs: string[];
versions: Version[];
audio_locale: Locale;
closed_captions: Subtitles;
captions: Subtitles;
closed_captions: Record<unknown>;
captions: Record<unknown>;
}
export interface Subtitles {
''?: SubtitleInfo;
'': SubtitleInfo;
'en-US'?: SubtitleInfo;
'es-LA'?: SubtitleInfo;
'es-419'?: SubtitleInfo;

4
@types/ws.d.ts vendored
View file

@ -30,8 +30,8 @@ export type MessageTypes = {
'isDownloading': [undefined, boolean],
'openFolder': [FolderTypes, undefined],
'changeProvider': [undefined, boolean],
'type': [undefined, 'crunchy'|'hidive'|'ao'|'adn'|undefined],
'setup': ['crunchy'|'hidive'|'ao'|'adn'|undefined, undefined],
'type': [undefined, 'funi'|'crunchy'|'hidive'|undefined],
'setup': ['funi'|'crunchy'|'hidive'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined],
'isSetup': [undefined, boolean],

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-linux-gui
RUN pnpm run build-ubuntu-gui
# Move build to new Clean Image
FROM node
WORKDIR "/app"
COPY --from=builder /app/lib/_builds/multi-downloader-nx-linux-x64-gui ./
COPY --from=builder /app/lib/_builds/multi-downloader-nx-ubuntu64-gui ./
# Install mkvmerge and ffmpeg

33
TODO.md
View file

@ -1,15 +1,20 @@
# Todo/Future Ideas list
# GUI
- [ ] Look into implementing wvd file support
- [ ] Merge sync branch with latest master
- [ ] Finish implementing old algorithm
- [ ] Look into adding suggested algorithm [#599](https://github.com/anidl/multi-downloader-nx/issues/599)
- [ ] Remove Funimation
- [ ] Remove old hidive API or find a way to make it work
- [ ] Look into adding other services
- [ ] Refactor downloading code
- [ ] Allow audio and video download at the same time
- [ ] Reduce/Refactor the amount of duplicate/boilerplate code required
- [ ] Create a generic service class for the CLI with set inputs/outputs
- [ ] Modularize site modules to ease addition of new sites
- [ ] Create generic MPD/M3U8 playlist downloader
- [ ] Hls-Download force yes or no on rewrite promt as well as for mkvmerge/ffmpeg
- [x] Pick up if a download is currently in progress
- [x] Send more information with the progress event like the title and image to display more information
- [x] Use Click away listener for the search popup
- [x] Quality select button is uncrontrolled/controlled
- [ ] Set Options font in divider
- [x] Window title
- [x] Only open dev tools in test version
- [x] Add help information (version, contributor, documentation...)
- [x] ContextMenu
- [x] Better episode listing with selectio via left mouse button
- [x] Use Child for Context Menu
# CLI
## New API ?
- [ ] https://playback.prd.funimationsvc.com/v1/play/FMB0001?deviceType=web&playbackStreamId=137917d5-dc9b-4a72-83da-14231fd1d05e
- [ ] https://playlist-service.prd.funimationsvc.com/v1/playlist/show/FMB
- [ ] https://d33et77evd9bgg.cloudfront.net/data/v1/episodes/fullmetal-alchemist.json

924
adn.ts
View file

@ -1,924 +0,0 @@
// Package Info
import packageJson from './package.json';
// Node
import path from 'path';
import fs from 'fs-extra';
import crypto from 'crypto';
// Plugins
import shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
// Modules
import * as fontsData from './modules/module.fontsData';
import * as langsData from './modules/module.langsData';
import * as yamlCfg from './modules/module.cfg-loader';
import * as yargs from './modules/module.app-args';
import * as reqModule from './modules/module.fetch';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import streamdl from './modules/hls-download';
import { console } from './modules/log';
import { domain } from './modules/module.api-urls';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import parseFileName, { Variable } from './modules/module.filename';
import { AvailableFilenameVars } from './modules/module.args';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { sxItem } from './crunchy';
import { DownloadedMedia } from './@types/hidiveTypes';
import { ADNSearch, ADNSearchShow } from './@types/adnSearch';
import { ADNVideo, ADNVideos } from './@types/adnVideos';
import { ADNPlayerConfig } from './@types/adnPlayerConfig';
import { ADNStreams } from './@types/adnStreams';
import { ADNSubtitles } from './@types/adnSubtitles';
export default class AnimationDigitalNetwork implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
public locale: string;
private token: Record<string, any>;
private req: reqModule.Req;
private posAlignMap: { [key: string]: number } = {
'start': 1,
'end': 3
};
private lineAlignMap: { [key: string]: number } = {
'middle': 8,
'end': 4
};
private jpnStrings: string[] = [
'vostf',
'vostde'
];
private deuStrings: string[] = [
'vde'
];
private fraStrings: string[] = [
'vf'
];
private deuSubStrings: string[] = [
'vde',
'vostde'
];
private fraSubStrings: string[] = [
'vf',
'vostf'
];
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadADNToken();
this.req = new reqModule.Req(domain, debug, false, 'adn');
this.locale = 'fr';
}
public async cli() {
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
const argv = yargs.appArgv(this.cfg.cli);
if (['fr', 'de'].includes(argv.locale))
this.locale = argv.locale;
if (argv.debug)
this.debug = true;
// load binaries
this.cfg.bin = await yamlCfg.loadBinCfg();
if (argv.allDubs) {
argv.dubLang = langsData.dubLanguageCodes;
}
if (argv.auth) {
//Authenticate
await this.doAuth({
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
});
} else if (argv.search && argv.search.length > 2) {
//Search
await this.doSearch({ ...argv, search: argv.search as string });
} else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) {
const selected = await this.selectShow(parseInt(argv.s), argv.e, argv.but, argv.all);
if (selected.isOk) {
for (const select of selected.value) {
if (!(await this.getEpisode(select, {...argv, skipsubs: false}))) {
console.error(`Unable to download selected episode ${select.shortNumber}`);
return false;
}
}
}
return true;
} else {
console.info('No option selected or invalid value entered. Try --help.');
}
}
private generateRandomString(length: number) {
const characters = '0123456789abcdef';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
private parseCookies(cookiesString: string | null): Record<string, string> {
const cookies: Record<string, string> = {};
if (cookiesString) {
cookiesString.split(';').forEach(cookie => {
const parts = cookie.split('=');
const name = parts.shift()?.trim();
const value = decodeURIComponent(parts.join('='));
if (name) {
cookies[name] = value;
}
});
}
return cookies;
}
private convertToSSATimestamp(timestamp: number): string {
const seconds = Math.floor(timestamp);
const centiseconds = Math.round((timestamp - seconds) * 100);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
public async doSearch(data: SearchData): Promise<SearchResponse> {
const limit = 12;
const offset = data.page ? data.page * limit : 0;
const searchReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/show/catalog?maxAgeCategory=18&offset=${offset}&limit=${limit}&search=${encodeURIComponent(data.search)}`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!searchReq.ok || !searchReq.res) {
console.error('Search FAILED!');
return { isOk: false, reason: new Error('Search failed. No more information provided') };
}
const searchData = await searchReq.res.json() as ADNSearch;
const searchItems: ADNSearchShow[] = [];
console.info('Search Results:');
for (const show of searchData.shows) {
searchItems.push(show);
let fullType: string;
if (show.type == 'EPS') {
fullType = `S.${show.id}`;
} else if (show.type == 'MOV' || show.type == 'OAV') {
fullType = `E.${show.id}`;
} else {
fullType = 'Unknown';
console.warn(`Unknown type ${show.type}, please report this.`);
}
console.log(`[${fullType}] ${show.title}`);
}
return { isOk: true, value: searchItems.flatMap((a): SearchResponseItem => {
return {
id: a.id+'',
image: a.image ?? '/notFound.png',
name: a.title,
rating: a.rating,
desc: a.summary
};
})};
}
public async doAuth(data: AuthData): Promise<AuthResponse> {
const authData = new URLSearchParams({
'username': data.username,
'password': data.password,
'source': 'Web',
'rememberMe': 'true'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
body: authData
};
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/login', authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Authentication failed!');
return { isOk: false, reason: new Error('Authentication failed') };
}
this.token = await authReq.res.json();
yamlCfg.saveADNToken(this.token);
console.info('Authentication Success');
return { isOk: true, value: undefined };
}
public async refreshToken() {
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/refresh', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token.accessToken}`,
'X-Access-Token': this.token.accessToken,
'content-type': 'application/json'
},
body: JSON.stringify({refreshToken: this.token.refreshToken})
});
if(!authReq.ok || !authReq.res){
console.error('Token refresh failed!');
return { isOk: false, reason: new Error('Token refresh failed') };
}
this.token = await authReq.res.json();
yamlCfg.saveADNToken(this.token);
return { isOk: true, value: undefined };
}
public async getShow(id: number) {
const getShowData = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/video/show/${id}?maxAgeCategory=18&limit=-1&order=asc`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!getShowData.ok || !getShowData.res) {
console.error('Failed to get Series Data');
return { isOk: false };
}
const showData = await getShowData.res.json() as ADNVideos;
return { isOk: true, value: showData };
}
public async listShow(id: number) {
const show = await this.getShow(id);
if (!show.isOk || !show.value) {
console.error('Failed to list show data: Failed to get show');
return { isOk: false };
}
if (show.value.videos.length == 0) {
console.error('No episodes found!');
return { isOk: false };
}
const showData = show.value.videos[0].show;
console.info(`[S.${showData.id}] ${showData.title}`);
const specials: ADNVideo[] = [];
let episodeIndex = 0, specialIndex = 0;
for (const episode of show.value.videos) {
episode.season = episode.season+'';
const seasonNumberTitleParse = episode.season.match(/\d+/);
const seriesNumberTitleParse = episode.show.title.match(/\d+/);
const episodeNumber = parseInt(episode.shortNumber);
if (seasonNumberTitleParse && !isNaN(parseInt(seasonNumberTitleParse[0]))) {
episode.season = seasonNumberTitleParse[0];
} else if (seriesNumberTitleParse && !isNaN(parseInt(seriesNumberTitleParse[0]))) {
episode.season = seriesNumberTitleParse[0];
} else {
episode.season = '1';
}
show.value.videos[episodeIndex].season = episode.season;
if (!episodeNumber) {
specialIndex++;
const special = show.value.videos.splice(episodeIndex, 1);
special[0].shortNumber = 'S'+specialIndex;
specials.push(...special);
episodeIndex--;
} else {
console.info(` (${episode.id}) [E${episode.shortNumber}] ${episode.number} - ${episode.name}`);
}
episodeIndex++;
}
for (const special of specials) {
console.info(` (${special.id}) [${special.shortNumber}] ${special.number} - ${special.name}`);
}
show.value.videos.push(...specials);
return { isOk: true, value: show.value };
}
public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean) {
const getShowData = await this.listShow(id);
if (!getShowData.isOk || !getShowData.value) {
return { isOk: false, value: [] };
}
console.info('');
console.info('-'.repeat(30));
console.info('');
const showData = getShowData.value;
const doEpsFilter = parseSelect(e as string);
const selEpsArr: ADNVideo[] = [];
for (const episode of showData.videos) {
if (
all ||
but && !doEpsFilter.isSelected([episode.shortNumber, episode.id+'']) ||
!but && doEpsFilter.isSelected([episode.shortNumber, episode.id+''])
) {
selEpsArr.push({ isSelected: true, ...episode });
console.info('%s[S%sE%s] %s',
'✓ ',
episode.season,
episode.shortNumber,
episode.name,
);
}
}
return { isOk: true, value: selEpsArr };
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
return console.info('Skip muxing since no vids are downloaded');
if (data.some(a => a.type === 'Audio')) {
hasAudioStreams = true;
}
const merger = new Merger({
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
skipSubMux: options.skipSubMux,
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
closedCaption: a.cc
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}),
chapters: data.filter(a => a.type === 'Chapters').map((a) : MergerInput => {
return {
path: a.path,
lang: a.lang
};
}),
videoTitle: options.videoTitle,
options: {
ffmpeg: options.ffmpegOptions,
mkvmerge: options.mkvmergeOptions
},
defaults: {
audio: options.defaultAudio,
sub: options.defaultSub
},
ccTag: options.ccTag
});
const bin = Merger.checkMerger(this.cfg.bin, options.mp4, options.forceMuxer);
// collect fonts info
// mergers
let isMuxed = false;
if (options.syncTiming) {
await merger.createDelays();
}
if (bin.MKVmerge) {
await merger.merge('mkvmerge', bin.MKVmerge);
isMuxed = true;
} else if (bin.FFmpeg) {
await merger.merge('ffmpeg', bin.FFmpeg);
isMuxed = true;
} else{
console.info('\nDone!\n');
return;
}
if (isMuxed && !options.nocleanup)
merger.cleanUp();
}
public async getEpisode(data: ADNVideo, options: yargs.ArgvType) {
//TODO: Move all the requests for getting the m3u8 here
const res = await this.downloadEpisode(data, options);
if (res === undefined || res.error) {
console.error('Failed to download media list');
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
downloaded({
service: 'adn',
type: 's'
}, data.id+'', [data.shortNumber]);
return { isOk: res, value: undefined };
}
}
public async downloadEpisode(data: ADNVideo, options: yargs.ArgvType) {
if(!this.token.accessToken){
console.error('Authentication required!');
return;
}
if (!this.cfg.bin.ffmpeg)
this.cfg.bin = await yamlCfg.loadBinCfg();
let mediaName = '...';
let fileName;
const variables: Variable[] = [];
if(data.show.title && data.shortNumber && data.title){
mediaName = `${data.show.shortTitle ?? data.show.title} - ${data.shortNumber} - ${data.title}`;
}
const files: DownloadedMedia[] = [];
let dlFailed = false;
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
const refreshToken = await this.refreshToken();
if (!refreshToken.isOk) {
console.error('Failed to refresh token');
return undefined;
}
const configReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/configuration`, {
headers: {
Authorization: `Bearer ${this.token.accessToken}`,
'X-Target-Distribution': this.locale
}
});
if(!configReq.ok || !configReq.res){
console.error('Player Config Request failed!');
return undefined;
}
const configuration = await configReq.res.json() as ADNPlayerConfig;
if (!configuration.player.options.user.hasAccess) {
console.error('You don\'t have access to this video!');
return undefined;
}
const tokenReq = await this.req.getData(configuration.player.options.user.refreshTokenUrl || 'https://gw.api.animationdigitalnetwork.fr/player/refresh/token', {
method: 'POST',
headers: {
'X-Player-Refresh-Token': `${configuration.player.options.user.refreshToken}`
}
});
if(!tokenReq.ok || !tokenReq.res){
console.error('Player Token Request failed!');
return undefined;
}
const token = await tokenReq.res.json() as {
refreshToken: string,
accessToken: string,
token: string
};
const linksUrl = configuration.player.options.video.url || `https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/link`;
const key = this.generateRandomString(16);
const decryptionKey = key + '7fac1178830cfe0c';
const authorization = crypto.publicEncrypt({
'key': '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssg\nnumHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg\n/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6\nKhS+IFEqwvZqgbBpKuwIDAQAB\n-----END PUBLIC KEY-----',
padding: crypto.constants.RSA_PKCS1_PADDING
}, Buffer.from(JSON.stringify({
k: key,
t: token.token
}), 'utf-8')).toString('base64');
//TODO: Add chapter support
const streamsRequest = await this.req.getData(linksUrl+'?freeWithAds=true&adaptive=true&withMetadata=true&source=Web', {
'headers': {
'X-Player-Token': authorization,
'X-Target-Distribution': this.locale
}
});
if(!streamsRequest.ok || !streamsRequest.res){
if (streamsRequest.error?.res.status == 403 || streamsRequest.res?.status == 403) {
console.error('Georestricted!');
} else {
console.error('Streams request failed!');
}
return undefined;
}
const streams = await streamsRequest.res.json() as ADNStreams;
for (const streamName in streams.links.streaming) {
let audDub: langsData.LanguageItem;
if (this.jpnStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'jpn') as langsData.LanguageItem;
} else if (this.deuStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${streamName} not recognized, please report this.`);
continue;
}
if (!options.dubLang.includes(audDub.code)) {
continue;
}
console.info(`Requesting: [${data.id}] ${mediaName} (${audDub.name})`);
variables.push(...([
['title', data.title, true],
['episode', isNaN(parseFloat(data.shortNumber)) ? data.shortNumber : parseFloat(data.shortNumber), false],
['service', 'ADN', false],
['seriesTitle', data.show.shortTitle ?? data.show.title, true],
['showTitle', data.show.title, true],
['season', isNaN(parseFloat(data.season)) ? data.season : parseFloat(data.season), false]
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
sanitize: a[2]
} as Variable;
}));
console.info('Playlists URL: %s', streams.links.streaming[streamName].auto);
let tsFile = undefined;
if (!dlFailed && !options.novids) {
const streamPlaylistsLocationReq = await this.req.getData(streams.links.streaming[streamName].auto);
if (!streamPlaylistsLocationReq.ok || !streamPlaylistsLocationReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLIST LOCATION!');
return undefined;
}
const streamPlaylistLocation = await streamPlaylistsLocationReq.res.json() as {'location': string};
const streamPlaylistsReq = await this.req.getData(streamPlaylistLocation.location);
if (!streamPlaylistsReq.ok || !streamPlaylistsReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
dlFailed = true;
} else {
const streamPlaylistBody = await streamPlaylistsReq.res.text();
const streamPlaylists = m3u8(streamPlaylistBody);
const plServerList: string[] = [],
plStreams: Record<string, Record<string, string>> = {},
plQuality: {
str: string,
dim: string,
CODECS: string,
RESOLUTION: {
width: number,
height: number
}
}[] = [];
for(const pl of streamPlaylists.playlists){
// set quality
const plResolution = pl.attributes.RESOLUTION;
const plResolutionText = `${plResolution.width}x${plResolution.height}`;
// set codecs
const plCodecs = pl.attributes.CODECS;
// parse uri
const plUri = new URL(pl.uri);
let plServer = plUri.hostname;
// set server list
if (plUri.searchParams.get('cdn')){
plServer += ` (${plUri.searchParams.get('cdn')})`;
}
if (!plServerList.includes(plServer)){
plServerList.push(plServer);
}
// add to server
if (!Object.keys(plStreams).includes(plServer)){
plStreams[plServer] = {};
}
if(
plStreams[plServer][plResolutionText]
&& plStreams[plServer][plResolutionText] != pl.uri
&& typeof plStreams[plServer][plResolutionText] != 'undefined'
) {
console.error(`Non duplicate url for ${plServer} detected, please report to developer!`);
} else{
plStreams[plServer][plResolutionText] = pl.uri;
}
// set plQualityStr
const plBandwidth = Math.round(pl.attributes.BANDWIDTH/1024);
const qualityStrAdd = `${plResolutionText} (${plBandwidth}KiB/s)`;
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/([:()/])/g, '\\$1'), 'm');
const qualityStrMatch = !plQuality.map(a => a.str).join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plQuality.push({
str: qualityStrAdd,
dim: plResolutionText,
CODECS: plCodecs,
RESOLUTION: plResolution
});
}
}
options.x = options.x > plServerList.length ? 1 : options.x;
const plSelectedServer = plServerList[options.x - 1];
const plSelectedList = plStreams[plSelectedServer];
plQuality.sort((a, b) => {
const aMatch: RegExpMatchArray | never[] = a.dim.match(/[0-9]+/) || [];
const bMatch: RegExpMatchArray | never[] = b.dim.match(/[0-9]+/) || [];
return parseInt(aMatch[0]) - parseInt(bMatch[0]);
});
let quality = options.q === 0 ? plQuality.length : options.q;
if(quality > plQuality.length) {
console.warn(`The requested quality of ${options.q} is greater than the maximum ${plQuality.length}.\n[WARN] Therefor the maximum will be capped at ${plQuality.length}.`);
quality = plQuality.length;
}
// When best selected video quality is already downloaded
if(dlVideoOnce && options.dlVideoOnce) {
// Select the lowest resolution with the same codecs
while(quality !=1 && plQuality[quality - 1].CODECS == plQuality[quality - 2].CODECS) {
quality--;
}
}
const selPlUrl = plSelectedList[plQuality.map(a => a.dim)[quality - 1]] ? plSelectedList[plQuality.map(a => a.dim)[quality - 1]] : '';
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plQuality.map((a, ind) => `[${ind+1}] ${a.str}`).join('\n\t')}`);
if(selPlUrl != ''){
variables.push({
name: 'height',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.height as number : plQuality[quality - 1].RESOLUTION.height
}, {
name: 'width',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.width as number : plQuality[quality - 1].RESOLUTION.width
});
console.info(`Selected quality: ${Object.keys(plSelectedList).find(a => plSelectedList[a] === selPlUrl)} @ ${plSelectedServer}`);
console.info('Stream URL:', selPlUrl);
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + audDub.name, variables, options.numbers, options.override).join(path.sep);
console.info(`Output filename: ${outFile}`);
const chunkPage = await this.req.getData(selPlUrl);
if(!chunkPage.ok || !chunkPage.res){
console.error('CAN\'T FETCH VIDEO PLAYLIST!');
dlFailed = true;
} else {
const chunkPageBody = await chunkPage.res.text();
const chunkPlaylist = m3u8(chunkPageBody);
const totalParts = chunkPlaylist.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in stream:', totalParts, mathMsg);
tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
const split = outFile.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(outFile as string);
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
});
const dlStreamByPl = await new streamdl({
output: `${tsFile}.ts`,
timeout: options.timeout,
m3u8json: chunkPlaylist,
baseurl: selPlUrl.replace('playlist.m3u8',''),
threads: options.partsize,
fsRetryTime: options.fsRetryTime * 1000,
override: options.force,
callback: options.callbackMaker ? options.callbackMaker({
fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
image: data.image,
parent: {
title: data.show.title
},
title: data.title,
language: audDub
}) : undefined
}).download();
if (!dlStreamByPl.ok) {
console.error(`DL Stats: ${JSON.stringify(dlStreamByPl.parts)}\n`);
dlFailed = true;
}
files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: audDub
});
dlVideoOnce = true;
}
} else{
console.error('Quality not selected!\n');
dlFailed = true;
}
}
} else if (options.novids) {
console.info('Downloading skipped!');
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
}
await this.sleep(options.waittime);
}
const compiledChapters: string[] = [];
if (options.chapters) {
if (streams.video.tcIntroStart) {
if (streams.video.tcIntroStart != '00:00:00') {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Prologue`
);
}
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcIntroStart+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Opening`
);
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcIntroEnd+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Episode`
);
} else {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Episode`
);
}
if (streams.video.tcEndingStart) {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcEndingStart+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Ending Start`
);
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcEndingEnd+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Ending End`
);
}
if (compiledChapters.length > 0) {
try {
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
const split = outFile.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(outFile as string);
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
});
fs.writeFileSync(`${tsFile}.txt`, compiledChapters.join('\r\n'));
files.push({
path: `${tsFile}.txt`,
lang: langsData.languages.find(a=>a.code=='jpn'),
type: 'Chapters'
});
} catch {
console.error('Failed to write chapter file');
}
}
}
if(options.dlsubs.indexOf('all') > -1){
options.dlsubs = ['all'];
}
if (options.nosubs) {
console.info('Subtitles downloading disabled from nosubs flag.');
options.skipsubs = true;
}
if(!options.skipsubs && options.dlsubs.indexOf('none') == -1) {
if (Object.keys(streams.links.subtitles).length !== 0) {
const subtitlesUrlReq = await this.req.getData(streams.links.subtitles.all);
if(!subtitlesUrlReq.ok || !subtitlesUrlReq.res){
console.error('Subtitle location request failed!');
return undefined;
}
const subtitleUrl = await subtitlesUrlReq.res.json() as {'location': string};
const encryptedSubtitlesReq = await this.req.getData(subtitleUrl.location);
if(!encryptedSubtitlesReq.ok || !encryptedSubtitlesReq.res){
console.error('Subtitle request failed!');
return undefined;
}
const encryptedSubtitles = await encryptedSubtitlesReq.res.text();
const iv = Buffer.from(encryptedSubtitles.slice(0, 24), 'base64');
const derivedKey = Buffer.from(decryptionKey, 'hex');
const encryptedData = Buffer.from(encryptedSubtitles.slice(24), 'base64');
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
const decryptedData = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString('utf8');
let subIndex = 0;
const subtitles = JSON.parse(decryptedData) as ADNSubtitles;
if (Object.keys(subtitles).length === 0) {
console.warn('No subtitles found.');
}
for (const subName in subtitles) {
let subLang: langsData.LanguageItem;
if (this.deuSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${subName} not recognized, please report this.`);
continue;
}
if (!options.dlsubs.includes(subLang.locale) && !options.dlsubs.includes('all')) {
continue;
}
const sxData: Partial<sxItem> = {};
sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, false, options.ccTag);
sxData.path = path.join(this.cfg.dir.content, sxData.file);
const split = sxData.path.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(sxData.path as string);
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
});
sxData.language = subLang;
if(options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) {
let subBody = '[Script Info]'
+ '\nScriptType:V4.00+'
+ '\nWrapStyle: 0'
+ '\nPlayResX: 1280'
+ '\nPlayResY: 720'
+ '\nScaledBorderAndShadow: yes'
+ ''
+ '\n[V4+ Styles]'
+ '\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'
+ `\nStyle: Default,${options.fontName ?? 'Arial'},${options.fontSize ?? 50},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.95,0,2,0,0,70,0`
+ '\n[Events]'
+ '\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text';
for (const sub of subtitles[subName]) {
const [start, end, text, lineAlign, positionAlign] =
[sub.startTime, sub.endTime, sub.text, sub.lineAlign, sub.positionAlign];
for (const subProp in sub) {
switch (subProp) {
case 'startTime':
case 'endTime':
case 'text':
case 'lineAlign':
case 'positionAlign':
break;
default:
console.warn(`json2ass: Unknown style: ${subProp}`);
}
}
const alignment = (this.posAlignMap[positionAlign] || 2) + (this.lineAlignMap[lineAlign] || 0);
const xtext = text
.replace(/ \\N$/g, '\\N')
.replace(/\\N$/, '')
.replace(/\r/g, '')
.replace(/\n/g, '\\N')
.replace(/\\N +/g, '\\N')
.replace(/ +\\N/g, '\\N')
.replace(/(\\N)+/g, '\\N')
.replace(/<b[^>]*>([^<]*)<\/b>/g, '{\\b1}$1{\\b0}')
.replace(/<i[^>]*>([^<]*)<\/i>/g, '{\\i1}$1{\\i0}')
.replace(/<u[^>]*>([^<]*)<\/u>/g, '{\\u1}$1{\\u0}')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/<[^>]>/g, '')
.replace(/\\N$/, '')
.replace(/ +$/, '');
subBody += `\nDialogue: 0,${this.convertToSSATimestamp(start)},${this.convertToSSATimestamp(end)},Default,,0,0,0,,${(alignment !== 2 ? `{\\a${alignment}}` : '')}${xtext}`;
}
sxData.title = `${subLang.language}`;
sxData.fonts = fontsData.assFonts(subBody) as Font[];
fs.writeFileSync(sxData.path, subBody);
console.info(`Subtitle converted: ${sxData.file}`);
files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
}
subIndex++;
}
} else {
console.warn('Couldn\'t find subtitles.');
}
} else{
console.info('Subtitles downloading skipped!');
}
return {
error: dlFailed,
data: files,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

825
ao.ts
View file

@ -1,825 +0,0 @@
// Package Info
import packageJson from './package.json';
// Node
import path from 'path';
import fs from 'fs-extra';
// Plugins
import shlp from 'sei-helper';
// Modules
import * as fontsData from './modules/module.fontsData';
import * as langsData from './modules/module.langsData';
import * as yamlCfg from './modules/module.cfg-loader';
import * as yargs from './modules/module.app-args';
import * as reqModule from './modules/module.fetch';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import getKeys, { canDecrypt } from './modules/widevine';
import streamdl, { M3U8Json } from './modules/hls-download';
import { exec } from './modules/sei-helper-fixes';
import { console } from './modules/log';
import { domain } from './modules/module.api-urls';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import parseFileName, { Variable } from './modules/module.filename';
import { AvailableFilenameVars } from './modules/module.args';
import { parse } from './modules/module.transform-mpd';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { AOSearchResult, AnimeOnegaiSearch } from './@types/animeOnegaiSearch';
import { AnimeOnegaiSeries } from './@types/animeOnegaiSeries';
import { AnimeOnegaiSeasons, Episode } from './@types/animeOnegaiSeasons';
import { DownloadedMedia } from './@types/hidiveTypes';
import { AnimeOnegaiStream } from './@types/animeOnegaiStream';
import { sxItem } from './crunchy';
type parsedMultiDubDownload = {
data: {
lang: string,
videoId: string
episode: Episode
}[],
seriesTitle: string,
seasonTitle: string,
episodeTitle: string,
episodeNumber: number,
seasonNumber: number,
seriesID: number,
seasonID: number,
image: string,
}
export default class AnimeOnegai implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
private token: Record<string, any>;
private req: reqModule.Req;
public locale: string;
public jpnStrings: string[] = [
'Japonés con Subtítulos en Español',
'Japonés con Subtítulos en Portugués',
'Japonês com legendas em espanhol',
'Japonês com legendas em português',
'Japonés'
];
public spaStrings: string[] = [
'Doblaje en Español',
'Dublagem em espanhol',
'Español',
];
public porStrings: string[] = [
'Doblaje en Portugués',
'Dublagem em português'
];
private defaultOptions: RequestInit = {
'headers': {
'origin': 'https://www.animeonegai.com',
'referer': 'https://www.animeonegai.com/',
}
};
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadAOToken();
this.req = new reqModule.Req(domain, debug, false, 'ao');
this.locale = 'es';
}
public async cli() {
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
const argv = yargs.appArgv(this.cfg.cli);
if (['pt', 'es'].includes(argv.locale))
this.locale = argv.locale;
if (argv.debug)
this.debug = true;
// load binaries
this.cfg.bin = await yamlCfg.loadBinCfg();
if (argv.allDubs) {
argv.dubLang = langsData.dubLanguageCodes;
}
if (argv.auth) {
//Authenticate
await this.doAuth({
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
});
} else if (argv.search && argv.search.length > 2) {
//Search
await this.doSearch({ ...argv, search: argv.search as string });
} else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) {
const selected = await this.selectShow(parseInt(argv.s), argv.e, argv.but, argv.all, argv);
if (selected.isOk) {
for (const select of selected.value) {
if (!(await this.downloadEpisode(select, {...argv, skipsubs: false}))) {
console.error(`Unable to download selected episode ${select.episodeNumber}`);
return false;
}
}
}
return true;
} else if (argv.token) {
this.token = {token: argv.token};
yamlCfg.saveAOToken(this.token);
console.info('Saved token');
} else {
console.info('No option selected or invalid value entered. Try --help.');
}
}
public async doSearch(data: SearchData): Promise<SearchResponse> {
const searchReq = await this.req.getData(`https://api.animeonegai.com/v1/search/algolia/${encodeURIComponent(data.search)}?lang=${this.locale}`, this.defaultOptions);
if (!searchReq.ok || !searchReq.res) {
console.error('Search FAILED!');
return { isOk: false, reason: new Error('Search failed. No more information provided') };
}
const searchData = await searchReq.res.json() as AnimeOnegaiSearch;
const searchItems: AOSearchResult[] = [];
console.info('Search Results:');
for (const hit of searchData.list) {
searchItems.push(hit);
let fullType: string;
if (hit.asset_type == 2) {
fullType = `S.${hit.ID}`;
} else if (hit.asset_type == 1) {
fullType = `E.${hit.ID}`;
} else {
fullType = 'Unknown';
console.warn(`Unknown asset type ${hit.asset_type}, please report this.`);
}
console.log(`[${fullType}] ${hit.title}`);
}
return { isOk: true, value: searchItems.filter(a => a.asset_type == 2).flatMap((a): SearchResponseItem => {
return {
id: a.ID+'',
image: a.poster ?? '/notFound.png',
name: a.title,
rating: a.likes,
desc: a.description
};
})};
}
public async doAuth(data: AuthData): Promise<AuthResponse> {
data;
console.error('Authentication not possible, manual authentication required due to recaptcha. In order to login use the --token flag. You can get the token by logging into the website, and opening the dev console and running the command "localStorage.ott_token"');
return { isOk: false, reason: new Error('Authentication not possible, manual authentication required do to recaptcha.') };
}
public async getShow(id: number) {
const getSeriesData = await this.req.getData(`https://api.animeonegai.com/v1/asset/${id}?lang=${this.locale}`, this.defaultOptions);
if (!getSeriesData.ok || !getSeriesData.res) {
console.error('Failed to get Show Data');
return { isOk: false };
}
const seriesData = await getSeriesData.res.json() as AnimeOnegaiSeries;
const getSeasonData = await this.req.getData(`https://api.animeonegai.com/v1/asset/content/${id}?lang=${this.locale}`, this.defaultOptions);
if (!getSeasonData.ok || !getSeasonData.res) {
console.error('Failed to get Show Data');
return { isOk: false };
}
const seasonData = await getSeasonData.res.json() as AnimeOnegaiSeasons[];
return { isOk: true, data: seriesData, seasons: seasonData };
}
public async listShow(id: number, outputEpisode: boolean = true) {
const series = await this.getShow(id);
if (!series.isOk || !series.data) {
console.error('Failed to list series data: Failed to get series');
return { isOk: false };
}
console.info(`[S.${series.data.ID}] ${series.data.title} (${series.seasons.length} Seasons)`);
if (series.seasons.length === 0 && series.data.asset_type !== 1) {
console.info(' No Seasons found!');
return { isOk: false };
}
const episodes: { [key: string]: (Episode & { lang?: string })[] } = {};
for (const season of series.seasons) {
let lang: string | undefined = undefined;
if (this.jpnStrings.includes(season.name.trim())) lang = 'ja';
else if (this.porStrings.includes(season.name.trim())) lang = 'pt';
else if (this.spaStrings.includes(season.name.trim())) lang = 'es';
else {lang = 'unknown';console.error(`Language (${season.name.trim()}) not known, please report this!`);}
for (const episode of season.list) {
if (!episodes[episode.number]) {
episodes[episode.number] = [];
}
/*if (!episodes[episode.number].find(a=>a.lang == lang))*/ episodes[episode.number].push({...episode, lang});
}
}
//Item is movie, lets define it manually
if (series.data.asset_type === 1 && series.seasons.length === 0) {
let lang: string | undefined;
if (this.jpnStrings.some(str => series.data.title.includes(str))) lang = 'ja';
else if (this.porStrings.some(str => series.data.title.includes(str))) lang = 'pt';
else if (this.spaStrings.some(str => series.data.title.includes(str))) lang = 'es';
else {lang = 'unknown';console.error('Language could not be parsed from movie title, please report this!');}
episodes[1] = [{
'video_entry': series.data.video_entry,
'number': 1,
'season_id': 1,
'name': series.data.title,
'ID': series.data.ID,
'CreatedAt': series.data.CreatedAt,
'DeletedAt': series.data.DeletedAt,
'UpdatedAt': series.data.UpdatedAt,
'active': series.data.active,
'description': series.data.description,
'age_restriction': series.data.age_restriction,
'asset_id': series.data.ID,
'ending': null,
'entry': series.data.entry,
'stream_url': series.data.stream_url,
'skip_intro': null,
'thumbnail': series.data.bg,
'open_free': false,
lang
}]; // as unknown as (Episode & { lang?: string })[];
// The above needs to be uncommented if the episode number should be M1 instead of 1
}
//Enable to output episodes seperate from selection
if (outputEpisode) {
for (const episodeKey in episodes) {
const episode = episodes[episodeKey][0];
const langs = Array.from(new Set(episodes[episodeKey].map(a=>a.lang)));
console.info(` [E.${episode.ID}] E${episode.number} - ${episode.name} (${langs.map(a=>{
if (a) return langsData.languages.find(b=>b.ao_locale === a)?.name;
return 'Unknown';
}).join(', ')})`);
}
}
return { isOk: true, value: episodes, series: series };
}
public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean, options: yargs.ArgvType) {
const getShowData = await this.listShow(id, false);
if (!getShowData.isOk || !getShowData.value) {
return { isOk: false, value: [] };
}
//const showData = getShowData.value;
const doEpsFilter = parseSelect(e as string);
// build selected episodes
const selEpsArr: parsedMultiDubDownload[] = [];
const episodes = getShowData.value;
const seasonNumberTitleParse = getShowData.series.data.title.match(/\d+/);
const seasonNumber = seasonNumberTitleParse ? parseInt(seasonNumberTitleParse[0]) : 1;
for (const episodeKey in getShowData.value) {
const episode = episodes[episodeKey][0];
const selectedLangs: string[] = [];
const selected: {
lang: string,
videoId: string
episode: Episode
}[] = [];
for (const episode of episodes[episodeKey]) {
const lang = langsData.languages.find(a=>a.ao_locale === episode.lang);
let isSelected = false;
if (typeof selected.find(a=>a.lang == episode.lang) == 'undefined') {
if (options.dubLang.includes(lang?.code ?? 'Unknown')) {
if ((but && !doEpsFilter.isSelected([episode.number+'', episode.ID+''])) || all || (!but && doEpsFilter.isSelected([episode.number+'', episode.ID+'']))) {
isSelected = true;
selected.push({lang: episode.lang as string, videoId: episode.video_entry, episode: episode });
}
}
const selectedLang = isSelected ? `${lang?.name ?? 'Unknown'}` : `${lang?.name ?? 'Unknown'}`;
if (!selectedLangs.includes(selectedLang)) {
selectedLangs.push(selectedLang);
}
}
}
if (selected.length > 0) {
selEpsArr.push({
'data': selected,
'seasonNumber': seasonNumber,
'episodeNumber': episode.number,
'episodeTitle': episode.name,
'image': episode.thumbnail,
'seasonID': episode.season_id,
'seasonTitle': getShowData.series.data.title,
'seriesTitle': getShowData.series.data.title,
'seriesID': getShowData.series.data.ID
});
}
console.info(` [S${seasonNumber}E${episode.number}] - ${episode.name} (${selectedLangs.join(', ')})`);
}
return { isOk: true, value: selEpsArr, showData: getShowData.series };
}
public async downloadEpisode(data: parsedMultiDubDownload, options: yargs.ArgvType): Promise<boolean> {
const res = await this.downloadMediaList(data, options);
if (res === undefined || res.error) {
return false;
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
downloaded({
service: 'ao',
type: 's'
}, data.seasonID+'', [data.episodeNumber+'']);
}
return true;
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
return console.info('Skip muxing since no vids are downloaded');
if (data.some(a => a.type === 'Audio')) {
hasAudioStreams = true;
}
const merger = new Merger({
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
skipSubMux: options.skipSubMux,
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
closedCaption: a.cc
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}),
videoTitle: options.videoTitle,
options: {
ffmpeg: options.ffmpegOptions,
mkvmerge: options.mkvmergeOptions
},
defaults: {
audio: options.defaultAudio,
sub: options.defaultSub
},
ccTag: options.ccTag
});
const bin = Merger.checkMerger(this.cfg.bin, options.mp4, options.forceMuxer);
// collect fonts info
// mergers
let isMuxed = false;
if (options.syncTiming) {
await merger.createDelays();
}
if (bin.MKVmerge) {
await merger.merge('mkvmerge', bin.MKVmerge);
isMuxed = true;
} else if (bin.FFmpeg) {
await merger.merge('ffmpeg', bin.FFmpeg);
isMuxed = true;
} else{
console.info('\nDone!\n');
return;
}
if (isMuxed && !options.nocleanup)
merger.cleanUp();
}
public async downloadMediaList(medias: parsedMultiDubDownload, options: yargs.ArgvType) : Promise<{
data: DownloadedMedia[],
fileName: string,
error: boolean
} | undefined> {
if(!this.token.token){
console.error('Authentication required!');
return;
}
if (!this.cfg.bin.ffmpeg)
this.cfg.bin = await yamlCfg.loadBinCfg();
let mediaName = '...';
let fileName;
const variables: Variable[] = [];
if(medias.seasonTitle && medias.episodeNumber && medias.episodeTitle){
mediaName = `${medias.seasonTitle} - ${medias.episodeNumber} - ${medias.episodeTitle}`;
}
const files: DownloadedMedia[] = [];
let subIndex = 0;
let dlFailed = false;
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
for (const media of medias.data) {
console.info(`Requesting: [E.${media.episode.ID}] ${mediaName}`);
const AuthHeaders = {
headers: {
Authorization: `Bearer ${this.token.token}`,
'Referer': 'https://www.animeonegai.com/',
'Origin': 'https://www.animeonegai.com',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Type': 'application/json'
}
};
const playbackReq = await this.req.getData(`https://api.animeonegai.com/v1/media/${media.videoId}?lang=${this.locale}`, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Request Stream URLs FAILED!');
return undefined;
}
const streamData = await playbackReq.res.json() as AnimeOnegaiStream;
variables.push(...([
['title', medias.episodeTitle, true],
['episode', medias.episodeNumber, false],
['service', 'AO', false],
['seriesTitle', medias.seriesTitle, true],
['showTitle', medias.seasonTitle, true],
['season', medias.seasonNumber, false]
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
sanitize: a[2]
} as Variable;
}));
if (!canDecrypt) {
console.warn('Decryption not enabled!');
}
const lang = langsData.languages.find(a=>a.ao_locale == media.lang) as langsData.LanguageItem;
if (!lang) {
console.error(`Unable to find language for code ${media.lang}`);
return;
}
let tsFile = undefined;
if (!streamData.dash) {
console.error('You don\'t have access to download this content');
continue;
}
console.info('Playlists URL: %s', streamData.dash);
if(!dlFailed && !(options.novids && options.noaudio)){
const streamPlaylistsReq = await this.req.getData(streamData.dash, AuthHeaders);
if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
dlFailed = true;
} else {
const streamPlaylistBody = (await streamPlaylistsReq.res.text()).replace(/<BaseURL>(.*?)<\/BaseURL>/g, `<BaseURL>${streamData.dash.split('/dash/')[0]}/dash/$1</BaseURL>`);
//Parse MPD Playlists
const streamPlaylists = await parse(streamPlaylistBody, lang as langsData.LanguageItem, streamData.dash.split('/dash/')[0]+'/dash/');
//Get name of CDNs/Servers
const streamServers = Object.keys(streamPlaylists);
options.x = options.x > streamServers.length ? 1 : options.x;
const selectedServer = streamServers[options.x - 1];
const selectedList = streamPlaylists[selectedServer];
//set Video Qualities
const videos = selectedList.video.map(item => {
return {
...item,
resolutionText: `${item.quality.width}x${item.quality.height} (${Math.round(item.bandwidth/1024)}KiB/s)`
};
});
const audios = selectedList.audio.map(item => {
return {
...item,
resolutionText: `${Math.round(item.bandwidth/1024)}kB/s`
};
});
videos.sort((a, b) => {
return a.quality.width - b.quality.width;
});
audios.sort((a, b) => {
return a.bandwidth - b.bandwidth;
});
let chosenVideoQuality = options.q === 0 ? videos.length : options.q;
if(chosenVideoQuality > videos.length) {
console.warn(`The requested quality of ${options.q} is greater than the maximum ${videos.length}.\n[WARN] Therefor the maximum will be capped at ${videos.length}.`);
chosenVideoQuality = videos.length;
}
chosenVideoQuality--;
let chosenAudioQuality = options.q === 0 ? audios.length : options.q;
if(chosenAudioQuality > audios.length) {
chosenAudioQuality = audios.length;
}
chosenAudioQuality--;
const chosenVideoSegments = videos[chosenVideoQuality];
const chosenAudioSegments = audios[chosenAudioQuality];
console.info(`Servers available:\n\t${streamServers.join('\n\t')}`);
console.info(`Available Video Qualities:\n\t${videos.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`);
console.info(`Available Audio Qualities:\n\t${audios.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`);
variables.push({
name: 'height',
type: 'number',
replaceWith: chosenVideoSegments.quality.height
}, {
name: 'width',
type: 'number',
replaceWith: chosenVideoSegments.quality.width
});
console.info(`Selected quality: \n\tVideo: ${chosenVideoSegments.resolutionText}\n\tAudio: ${chosenAudioSegments.resolutionText}\n\tServer: ${selectedServer}`);
//console.info('Stream URL:', chosenVideoSegments.segments[0].uri);
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + lang.name, variables, options.numbers, options.override).join(path.sep);
const tempFile = parseFileName(`temp-${media.videoId}`, variables, options.numbers, options.override).join(path.sep);
const tempTsFile = path.isAbsolute(tempFile as string) ? tempFile : path.join(this.cfg.dir.content, tempFile);
let [audioDownloaded, videoDownloaded] = [false, false];
// When best selected video quality is already downloaded
if(dlVideoOnce && options.dlVideoOnce) {
console.info('Already downloaded video, skipping video download...');
} else if (options.novids) {
console.info('Skipping video download...');
} else {
//Download Video
const totalParts = chosenVideoSegments.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in video stream:', totalParts, mathMsg);
tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
const split = outFile.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(outFile as string);
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
});
const videoJson: M3U8Json = {
segments: chosenVideoSegments.segments
};
try {
const videoDownload = await new streamdl({
output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.mp4` : `${tsFile}.video.mp4`,
timeout: options.timeout,
m3u8json: videoJson,
// baseurl: chunkPlaylist.baseUrl,
threads: options.partsize,
fsRetryTime: options.fsRetryTime * 1000,
override: options.force,
callback: options.callbackMaker ? options.callbackMaker({
fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
image: medias.image,
parent: {
title: medias.seasonTitle
},
title: medias.episodeTitle,
language: lang
}) : undefined
}).download();
if(!videoDownload.ok){
console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`);
dlFailed = true;
} else {
dlVideoOnce = true;
videoDownloaded = true;
}
} catch (e) {
console.error(e);
dlFailed = true;
}
}
if (chosenAudioSegments && !options.noaudio) {
//Download Audio (if available)
const totalParts = chosenAudioSegments.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in audio stream:', totalParts, mathMsg);
tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
const split = outFile.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(outFile as string);
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
});
const audioJson: M3U8Json = {
segments: chosenAudioSegments.segments
};
try {
const audioDownload = await new streamdl({
output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.mp4` : `${tsFile}.audio.mp4`,
timeout: options.timeout,
m3u8json: audioJson,
// baseurl: chunkPlaylist.baseUrl,
threads: options.partsize,
fsRetryTime: options.fsRetryTime * 1000,
override: options.force,
callback: options.callbackMaker ? options.callbackMaker({
fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
image: medias.image,
parent: {
title: medias.seasonTitle
},
title: medias.episodeTitle,
language: lang
}) : undefined
}).download();
if(!audioDownload.ok){
console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`);
dlFailed = true;
} else {
audioDownloaded = true;
}
} catch (e) {
console.error(e);
dlFailed = true;
}
} else if (options.noaudio) {
console.info('Skipping audio download...');
}
//Handle Decryption if needed
if ((chosenVideoSegments.pssh || chosenAudioSegments.pssh) && (videoDownloaded || audioDownloaded)) {
console.info('Decryption Needed, attempting to decrypt');
const encryptionKeys = await getKeys(chosenVideoSegments.pssh, streamData.widevine_proxy, {});
if (encryptionKeys.length == 0) {
console.error('Failed to get encryption keys');
return undefined;
}
/*const keys = {} as Record<string, string>;
encryptionKeys.forEach(function(key) {
keys[key.kid] = key.key;
});*/
if (this.cfg.bin.mp4decrypt) {
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
const commandVideo = commandBase+`"${tempTsFile}.video.enc.mp4" "${tempTsFile}.video.mp4"`;
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.mp4" "${tempTsFile}.audio.mp4"`;
if (videoDownloaded) {
console.info('Started decrypting video');
const decryptVideo = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandVideo);
if (!decryptVideo.isOk) {
console.error(decryptVideo.err);
console.error(`Decryption failed with exit code ${decryptVideo.err.code}`);
fs.renameSync(`${tempTsFile}.video.enc.mp4`, `${tsFile}.video.enc.mp4`);
return undefined;
} else {
console.info('Decryption done for video');
if (!options.nocleanup) {
fs.removeSync(`${tempTsFile}.video.enc.mp4`);
}
fs.renameSync(`${tempTsFile}.video.mp4`, `${tsFile}.video.mp4`);
files.push({
type: 'Video',
path: `${tsFile}.video.mp4`,
lang: lang
});
}
}
if (audioDownloaded) {
console.info('Started decrypting audio');
const decryptAudio = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandAudio);
if (!decryptAudio.isOk) {
console.error(decryptAudio.err);
console.error(`Decryption failed with exit code ${decryptAudio.err.code}`);
fs.renameSync(`${tempTsFile}.audio.enc.mp4`, `${tsFile}.audio.enc.mp4`);
return undefined;
} else {
if (!options.nocleanup) {
fs.removeSync(`${tempTsFile}.audio.enc.mp4`);
}
fs.renameSync(`${tempTsFile}.audio.mp4`, `${tsFile}.audio.mp4`);
files.push({
type: 'Audio',
path: `${tsFile}.audio.mp4`,
lang: lang
});
console.info('Decryption done for audio');
}
}
} else {
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
}
} else {
if (videoDownloaded) {
files.push({
type: 'Video',
path: `${tsFile}.video.mp4`,
lang: lang
});
}
if (audioDownloaded) {
files.push({
type: 'Audio',
path: `${tsFile}.audio.mp4`,
lang: lang
});
}
}
}
} else if (options.novids && options.noaudio) {
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
}
if(options.dlsubs.indexOf('all') > -1){
options.dlsubs = ['all'];
}
if (options.nosubs) {
console.info('Subtitles downloading disabled from nosubs flag.');
options.skipsubs = true;
}
if (!options.skipsubs && options.dlsubs.indexOf('none') == -1) {
if(streamData.subtitles.length > 0) {
for(const sub of streamData.subtitles) {
const subLang = langsData.languages.find(a => a.ao_locale === sub.lang);
if (!subLang) {
console.warn(`Language not found for subtitle language: ${sub.lang}, Skipping`);
continue;
}
const sxData: Partial<sxItem> = {};
sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, false, options.ccTag);
sxData.path = path.join(this.cfg.dir.content, sxData.file);
sxData.language = subLang;
if((options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) && sub.url.includes('.ass')) {
const getSubtitle = await this.req.getData(sub.url, AuthHeaders);
if (getSubtitle.ok && getSubtitle.res) {
console.info(`Subtitle Downloaded: ${sub.url}`);
const sBody = await getSubtitle.res.text();
sxData.title = `${subLang.language}`;
sxData.fonts = fontsData.assFonts(sBody) as Font[];
fs.writeFileSync(sxData.path, sBody);
files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
} else{
console.warn(`Failed to download subtitle: ${sxData.file}`);
}
}
subIndex++;
}
} else{
console.warn('Can\'t find urls for subtitles!');
}
}
else{
console.info('Subtitles downloading skipped!');
}
await this.sleep(options.waittime);
}
return {
error: dlFailed,
data: files,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

View file

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

View file

@ -1,24 +1,5 @@
# Set the quality of the stream, 0 is highest available.
q: 0
# Set which stream to use
kstream: 1
# Set which server to use
server: 1
# How many parts to download at once. Increasing may improve download speed.
partsize: 10
# Set whether to mux into an mp4 or not. Not recommended.
mp4: false
# Whether to delete any created files or not
nocleanup: false
# Whether to only download the relevant video once
dlVideoOnce: false
# Whether to keep all downloaded videos or only a single copy
keepAllVideos: false
# What to use as the file name template
fileName: "[${service}] ${showTitle} - S${season}E${episode} [${height}p]"
# What Audio languages to download
dubLang: ["jpn"]
# What Subtitle languages to download
dlsubs: ["all"]
# What language Audio to set as default
defaultAudio: "jpn"
nServer: 1
mp4mux: false
noCleanUp: false
dlVideoOnce: false

View file

@ -1 +1 @@
port: 3000
port: 3000

1348
crunchy.ts

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
# multi-downloader-nx (5.1.5v)
# multi-downloader-nx (4.4.3v)
If you find any bugs in this documentation or in the program itself please report it [over on GitHub](https://github.com/anidl/multi-downloader-nx/issues).
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).
## Legal Warning
This application is not endorsed by or affiliated with *Crunchyroll*, *Hidive*, *AnimeOnegai*, or *AnimationDigitalNetwork*.
This application is not endorsed by or affiliated with *Funimation* or *Crunchyroll*.
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.
@ -38,15 +38,9 @@ Set the password to use for the authentication. If not provided, you will be pro
#### `--silentAuth`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--silentAuth ` | `boolean` | `No`| `NaN` | `false`| `silentAuth: ` |
| Funimation, Crunchyroll | `--silentAuth ` | `boolean` | `No`| `NaN` | `false`| `silentAuth: ` |
Authenticate every time the script runs. Use at your own risk.
#### `--token`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, AnimeOnegai | `--token ${token}` | `string` | `No`| `NaN` | `undefined`| `token: ` |
Allows you to login with your token (Example on crunchy is Refresh Token/etp-rt cookie)
### Fonts
#### `--dlFonts`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
@ -58,7 +52,7 @@ Use this command to download all the fonts and add them to the muxed **mkv** fil
#### `--fontName`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Hidive, AnimationDigitalNetwork | `--fontName ${fontName}` | `string` | `No`| `NaN` | `NaN` |
| Funimation, Hidive | `--fontName ${fontName}` | `string` | `No`| `NaN` | `NaN` |
Set the font to use in subtiles
### Search
@ -80,12 +74,12 @@ Search only for type of anime listings (e.g. episodes, series)
| Crunchyroll, Hidive | `--page ${page}` | `number` | `No`| `-p` | `NaN` |
The output is organized in pages. Use this command to output the items for the given page
#### `--locale`
#### `--search-locale`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, AnimeOnegai, AnimationDigitalNetwork | `--locale ${locale}` | `string` | `No`| `NaN` | [`''`, `en-US`, `en-IN`, `es-LA`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr-FR`, `de-DE`, `ar-ME`, `ar-SA`, `it-IT`, `ru-RU`, `tr-TR`, `hi-IN`, `zh-CN`, `zh-TW`, `zh-HK`, `ko-KR`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `fr`, `de`, `''`, `es`, `pt`] | `en-US`| `locale: ` |
| Crunchyroll | `--search-locale ${locale}` | `string` | `No`| `NaN` | [`''`, `en-US`, `en-IN`, `es-LA`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr-FR`, `de-DE`, `ar-ME`, `ar-SA`, `it-IT`, `ru-RU`, `tr-TR`, `hi-IN`, `zh-CN`, `zh-TW`, `ko-KR`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`] | ``| `search-locale: ` |
Set the local that will be used for the API.
Set the search local that will be used for searching for items.
#### `--new`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -104,7 +98,8 @@ Get video list by Movie Listing ID
| --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--series ${ID}` | `string` | `No`| `--srz` | `NaN` |
Requested is the ID of a show not a season.
This command is used only for crunchyroll.
Requested is the ID of a show not a season.
#### `-s`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -135,26 +130,12 @@ Set the quality level. Use 0 to use the maximum quality.
#### `--dlVideoOnce`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, AnimeOnegai | `--dlVideoOnce ` | `boolean` | `No`| `NaN` | `false`| `dlVideoOnce: ` |
| Crunchyroll | `--dlVideoOnce ` | `boolean` | `No`| `NaN` | `false`| `dlVideoOnce: ` |
If selected, the best selected quality will be downloaded only for the first language,
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, AnimationDigitalNetwork | `--chapters ` | `boolean` | `No`| `NaN` | `true`| `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`] | `web`| `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.
#### `--removeBumpers`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
@ -171,34 +152,29 @@ If selected, it will prefer to keep the original Font Size defined by the servic
#### `-x`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `-x ${server}` | `number` | `No`| `--server` | [`1`, `2`, `3`, `4`] | `1`| `x: ` |
| Crunchyroll, Funimation | `-x ${server}` | `number` | `No`| `--server` | [`1`, `2`, `3`, `4`] | `1`| `x: ` |
Select the server to use
#### `--kstream`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--kstream ${stream}` | `number` | `No`| `-k` | [`1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`] | `1`| `kstream: ` |
| Crunchyroll | `--kstream ${stream}` | `number` | `No`| `-k` | [`1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`] | `2`| `kstream: ` |
Select specific stream
#### `--cstream`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--cstream ${device}` | `string` | `No`| `--cs` | [`chrome`, `firefox`, `safari`, `edge`, `fallback`, `ps4`, `ps5`, `switch`, `samsungtv`, `lgtv`, `rokutv`, `android`, `iphone`, `ipad`, `none`] | `chrome`| `cstream: ` |
Select specific crunchy play stream by device, or disable stream with "none"
#### `--hslang`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--hslang ${hslang}` | `string` | `No`| `NaN` | [`none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `zh-HK`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `ja`] | `none`| `hslang: ` |
| Crunchyroll | `--hslang ${hslang}` | `string` | `No`| `NaN` | [`none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `ja`] | `none`| `hslang: ` |
Download video with specific hardsubs
#### `--dlsubs`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--dlsubs ${sub1} ${sub2}` | `array` | `No`| `NaN` | [`all`, `none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `zh-HK`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `ja`] | `all`| `dlsubs: ` |
| All | `--dlsubs ${sub1} ${sub2}` | `array` | `No`| `NaN` | [`all`, `none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `ja`] | `all`| `dlsubs: ` |
Download subtitles by language tag (space-separated)
Crunchy Only: en, en-IN, es-419, es-419, es-ES, pt-BR, pt-PT, fr, de, ar, ar, it, ru, tr, hi, zh-CN, zh-TW, zh-HK, ko, ca-ES, pl-PL, th-TH, ta-IN, ms-MY, vi-VN, id-ID, te-IN, ja
Funi Only: zh
Crunchy Only: en-IN, es-419, es-ES, pt-PT, fr, de, ar, ar, it, ru, tr, hi, zh-CN, zh-TW, ko, ca-ES, pl-PL, th-TH, ta-IN, ms-MY, vi-VN, id-ID
#### `--novids`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -208,7 +184,7 @@ Skip downloading videos
#### `--noaudio`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
| Funimation | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
Skip downloading audio
#### `--nosubs`
@ -220,10 +196,11 @@ Skip downloading subtitles
#### `--dubLang`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--dubLang ${dub1} ${dub2}` | `array` | `No`| `NaN` | [`eng`, `spa`, `spa-419`, `spa-ES`, `por`, `fra`, `deu`, `ara-ME`, `ara`, `ita`, `rus`, `tur`, `hin`, `cmn`, `zho`, `chi`, `zh-HK`, `kor`, `cat`, `pol`, `tha`, `tam`, `may`, `vie`, `ind`, `tel`, `jpn`] | `jpn`| `dubLang: ` |
| All | `--dubLang ${dub1} ${dub2}` | `array` | `No`| `NaN` | [`eng`, `spa`, `spa-419`, `spa-ES`, `por`, `fra`, `deu`, `ara-ME`, `ara`, `ita`, `rus`, `tur`, `hin`, `cmn`, `zho`, `chi`, `kor`, `cat`, `pol`, `tha`, `tam`, `may`, `vie`, `ind`, `jpn`] | `jpn`| `dubLang: ` |
Set the language to download:
Crunchy Only: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, zho, chi, zh-HK, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
Funi Only: cmn
Crunchy Only: eng, spa-419, spa-ES, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, zho, chi, kor, cat, pol, tha, tam, may, vie, ind
#### `--all`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
@ -235,14 +212,7 @@ Used to download all episodes from the show
| --- | --- | --- | --- | --- | --- | ---|
| All | `--fontSize ${fontSize}` | `number` | `No`| `NaN` | `55`| `fontSize: ` |
When converting the subtitles to ass, this will change the font size
In most cases, requires "--originaFontSize false" to take effect
#### `--combineLines`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Hidive | `--combineLines ` | `boolean` | `No`| `NaN` | `NaN` |
If selected, will prevent a line from shifting downwards
Used to set the fontsize of the subtitles
#### `--allDubs`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -264,7 +234,7 @@ Set the time the program waits between downloads. Set in millisecods
#### `--simul`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Hidive | `--simul ` | `boolean` | `No`| `NaN` | `false`| `simul: ` |
| Funimation, Hidive | `--simul ` | `boolean` | `No`| `NaN` | `false`| `simul: ` |
Force downloading simulcast version instead of uncut version (if available).
#### `--but`
@ -373,14 +343,14 @@ Set the options given to ffmpeg
| All | `--defaultAudio ${args}` | `string` | `No`| `NaN` | `eng`| `defaultAudio: ` |
Set the default audio track by language code
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, zh-HK, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, kor, cat, pol, tha, tam, may, vie, ind, jpn
#### `--defaultSub`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--defaultSub ${args}` | `string` | `No`| `NaN` | `eng`| `defaultSub: ` |
Set the default subtitle track by language code
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, zh-HK, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, kor, cat, pol, tha, tam, may, vie, ind, jpn
### Filename Template
#### `--fileName`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
@ -389,7 +359,7 @@ Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara
Set the filename template. Use ${variable_name} to insert variables.
You can also create folders by inserting a path seperator in the filename
You may use 'title', 'episode', 'showTitle', 'seriesTitle', 'season', 'width', 'height', 'service' as variables.
You may use 'title', 'episode', 'showTitle', 'season', 'width', 'height', 'service' as variables.
#### `--numbers`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
@ -428,7 +398,7 @@ Debug mode (tokens may be revealed in the console output)
#### `--service`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--service ${service}` | `string` | `Yes`| `NaN` | [`crunchy`, `hidive`, `ao`, `adn`] | ``| `service: ` |
| All | `--service ${service}` | `string` | `Yes`| `NaN` | [`funi`, `crunchy`, `hidive`] | ``| `service: ` |
Set the service you want to use
#### `--update`

View file

@ -2,14 +2,17 @@
[![Discord Shield](https://discord.com/api/guilds/884479461997805568/widget.png?style=banner2)](https://discord.gg/qEpbWen5vq)
This downloader can download anime from different sites. Currently supported are *Crunchyroll*, *Hidive*, *AnimeOnegai*, and *AnimationDigitalNetwork*.
This downloader can download anime from different sites. Currently supported are *Funimation*, *Crunchyroll*, and *Hidive*.
## Legal Warning
This application is not endorsed by or affiliated with *Crunchyroll*, *Hidive*, *AnimeOnegai*, or *AnimationDigitalNetwork*. 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.
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.
## Dependencies
## Prerequisites
* 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)
@ -20,88 +23,28 @@ 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.
## CLI Information
### Node Modules
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.
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.
### Example usage
* [check dependencies](https://david-dm.org/anidl/funimation-downloader-nx)
#### Logging in
## CLI Options
Most services require you to be logged in, in order to download from, an example of how you would login is:
See [the documentation](https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md)
```shell
AniDL --service {ServiceName} --auth
```
## Build instructions
#### 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 >= 18.0.0 (https://nodejs.org/)
* NPM >= 6.9.0 (https://www.npmjs.org/)
* PNPM >= 7.0.0 (https://pnpm.io/)
### Build Setup
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)
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)
First clone this repo `git clone https://github.com/anidl/multi-downloader-nx.git`.
`cd` into the cloned directory and run `pnpm i`. Next, decide if you want to package the application, build the code, or run from typescript.
`cd` into the cloned directory and run `pnpm i`.
Afterwards run `pnpm run tsc false [true if you want gui, false otherwise]`.
### Run from TypeScript
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
You can run the code from native TypeScript, this requires ts-node which you can install with pnpm with the following command: `pnpm -g i ts-node`
Afterwords, you can run the application like this:
* CLI: `ts-node -T ./index.ts --help`
### Run as JavaScript
If you want to build the application into JavaScript code to run, you can do that as well like this:
* CLI: `pnpm run prebuild-cli`
* GUI: `pnpm run prebuild-gui`
Then you can cd into the `lib` folder and you will be able to run the CLI or GUI as follows:
* CLI: `node ./index.js --help`
* GUI: `node ./gui.js`
### Build the application into an executable
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.
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.

View file

@ -1,64 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'no-console': 2,
'react/prop-types': 0,
'react-hooks/exhaustive-deps': 0,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-declaration-merging': 'warn',
'@typescript-eslint/no-unused-vars' : 'warn',
'indent': [
'error',
2
],
'linebreak-style': [
'warn',
'windows'
],
'quotes': [
'error',
'single'
],
'semi': [
'error',
'always'
]
},
plugins: {
react
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2020,
sourceType: 'module'
},
parser: tseslint.parser
}
},
{
ignores: [
'**/lib',
'**/videos/*.ts',
'**/build',
'dev.js',
'tsc.ts'
]
},
{
files: ['gui/react/**/*'],
rules: {
'no-console': 0
}
}
);

923
funi.ts Normal file
View file

@ -0,0 +1,923 @@
// modules build-in
import fs from 'fs';
import path from 'path';
// package json
import packageJson from './package.json';
// modules extra
import { console } from './modules/log';
import * as shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
import hlsDownload, { HLSCallback } from './modules/hls-download';
// extra
import * as appYargs from './modules/module.app-args';
import * as yamlCfg from './modules/module.cfg-loader';
import vttConvert from './modules/module.vttconvert';
// types
import type { Item } from './@types/items.js';
// params
// Import modules after argv has been exported
import getData from './modules/module.getdata';
import merger from './modules/module.merger';
import parseSelect from './modules/module.parseSelect';
import { EpisodeData, MediaChild } from './@types/episode';
import { Subtitle } from './@types/funiTypes';
import { StreamData } from './@types/streamData';
import { DownloadedFile } from './@types/downloadedFile';
import parseFileName, { Variable } from './modules/module.filename';
import { downloaded } from './modules/module.downloadArchive';
import { FunimationMediaDownload } from './@types/funiTypes';
import * as langsData from './modules/module.langsData';
import { TitleElement } from './@types/episode';
import { AvailableFilenameVars } from './modules/module.args';
import { AuthData, AuthResponse, CheckTokenResponse, FuniGetEpisodeData, FuniGetEpisodeResponse, FuniGetShowData, SearchData, FuniSearchReponse, FuniShowResponse, FuniStreamData, FuniSubsData, FuniEpisodeData, ResponseBase } from './@types/messageHandler';
import { ServiceClass } from './@types/serviceClassInterface';
import { SubtitleRequest } from './@types/funiSubtitleRequest';
// program name
const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api';
// check page
// fn variables
let fnEpNum: string|number = 0,
fnOutput: string[] = [],
season = 0,
tsDlPath: {
path: string,
lang: langsData.LanguageItem
}[] = [],
stDlPath: Subtitle[] = [];
export default class Funi implements ServiceClass {
public static epIdLen = 4;
public static typeIdLen = 0;
public cfg: yamlCfg.ConfigObject;
private token: string | boolean;
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadFuniToken();
}
public checkToken(): CheckTokenResponse {
const isOk = typeof this.token === 'string';
return isOk ? { isOk, value: undefined } : { isOk, reason: new Error('Not authenticated') };
}
public async cli() : Promise<boolean|undefined> {
const argv = appYargs.appArgv(this.cfg.cli);
if (argv.debug)
this.debug = true;
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
if (argv.allDubs) {
argv.dubLang = langsData.dubLanguageCodes;
}
// select mode
if (argv.silentAuth && !argv.auth) {
const data: AuthData = {
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
};
await this.auth(data);
}
if(argv.auth){
const data: AuthData = {
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
};
await this.auth(data);
}
else if(argv.search){
this.searchShow(true, { search: argv.search });
}
else if(argv.s && !isNaN(parseInt(argv.s)) && parseInt(argv.s) > 0){
const data = await this.getShow(true, { id: parseInt(argv.s), but: argv.but, all: argv.all, e: argv.e });
if (!data.isOk) {
console.error(`${data.reason.message}`);
return false;
}
let ok = true;
for (const episodeData of data.value) {
if ((await this.getEpisode(true, { subs: { dlsubs: argv.dlsubs, nosubs: argv.nosubs, sub: false, ccTag: argv.ccTag }, dubLang: argv.dubLang, fnSlug: episodeData, s: argv.s, simul: argv.simul }, {
ass: false,
...argv
})).isOk !== true)
ok = false;
}
return ok;
}
else{
console.info('No option selected or invalid value entered. Try --help.');
}
}
public async auth(data: AuthData): Promise<AuthResponse> {
const authOpts = {
user: data.username,
pass: data.password
};
const authData = await getData({
baseUrl: api_host,
url: '/auth/login/',
auth: authOpts,
debug: this.debug,
});
if(authData.ok && authData.res){
const resJSON = JSON.parse(authData.res.body);
if(resJSON.token){
console.info('Authentication success, your token: %s%s\n', resJSON.token.slice(0,8),'*'.repeat(32));
yamlCfg.saveFuniToken({'token': resJSON.token});
this.token = resJSON.token;
return { isOk: true, value: undefined };
} else {
console.info('[ERROR]%s\n', ' No token found');
if (this.debug) {
console.info(resJSON);
}
return { isOk: false, reason: new Error(resJSON) };
}
}
return { isOk: false, reason: new Error('Login request failed') };
}
public async searchShow(log: boolean, data: SearchData): Promise<FuniSearchReponse> {
const qs = {unique: true, limit: 100, q: data.search, offset: 0 };
const searchData = await getData({
baseUrl: api_host,
url: '/source/funimation/search/auto/',
querystring: qs,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!searchData.ok || !searchData.res){
return { isOk: false, reason: new Error('Request is not ok') };
}
const searchDataJSON = JSON.parse(searchData.res.body);
if(searchDataJSON.detail){
console.error(`${searchDataJSON.detail}`);
return { isOk: false, reason: new Error(searchDataJSON.defail) };
}
if(searchDataJSON.items && searchDataJSON.items.hits && log){
const shows = searchDataJSON.items.hits;
console.info('Search Results:');
for(const ssn in shows){
console.info(`[#${shows[ssn].id}] ${shows[ssn].title}` + (shows[ssn].tx_date?` (${shows[ssn].tx_date})`:''));
}
}
if (log)
console.info('Total shows found: %s\n',searchDataJSON.count);
return { isOk: true, value: searchDataJSON };
}
public async listShowItems(id: number) : Promise<ResponseBase<Item[]>> {
const showData = await getData({
baseUrl: api_host,
url: `/source/catalog/title/${id}`,
token: this.token,
useToken: true,
debug: this.debug,
});
// check errors
if(!showData.ok || !showData.res){ return { isOk: false, reason: new Error('ShowData is not ok') }; }
const showDataJSON = JSON.parse(showData.res.body);
if(showDataJSON.status){
console.error('Error #%d: %s\n', showDataJSON.status, showDataJSON.data.errors[0].detail);
return { isOk: false, reason: new Error(showDataJSON.data.errors[0].detail) };
}
else if(!showDataJSON.items || showDataJSON.items.length<1){
console.error('Show not found\n');
return { isOk: false, reason: new Error('Show not found') };
}
const showDataItem = showDataJSON.items[0];
console.info('[#%s] %s (%s)',showDataItem.id,showDataItem.title,showDataItem.releaseYear);
// show episodes
const qs: {
limit: number,
sort: string,
sort_direction: string,
title_id: number,
language?: string
} = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: id };
const episodesData = await getData({
baseUrl: api_host,
url: '/funimation/episodes/',
querystring: qs,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!episodesData.ok || !episodesData.res){ return { isOk: false, reason: new Error('episodesData is not ok') }; }
let epsDataArr: Item[] = JSON.parse(episodesData.res.body).items;
const epNumRegex = /^([A-Z0-9]*[A-Z])?(\d+)$/i;
const parseEpStr = (epStr: string) => {
const match = epStr.match(epNumRegex);
if (!match) {
console.error('No match found');
return ['', ''];
}
if(match.length > 2){
const spliced = [...match].splice(1);
spliced[0] = spliced[0] ? spliced[0] : '';
return spliced;
}
else return [ '', match[0] ];
};
epsDataArr = epsDataArr.map(e => {
const baseId = e.ids.externalAsianId ? e.ids.externalAsianId : e.ids.externalEpisodeId;
e.id = baseId.replace(new RegExp('^' + e.ids.externalShowId), '');
if(e.id.match(epNumRegex)){
const epMatch = parseEpStr(e.id);
Funi.epIdLen = epMatch[1].length > Funi.epIdLen ? epMatch[1].length : Funi.epIdLen;
Funi.typeIdLen = epMatch[0].length > Funi.typeIdLen ? epMatch[0].length : Funi.typeIdLen;
e.id_split = epMatch;
}
else{
Funi.typeIdLen = 3 > Funi.typeIdLen? 3 : Funi.typeIdLen;
console.error('FAILED TO PARSE: ', e.id);
e.id_split = [ 'ZZZ', 9999 ];
}
return e;
});
epsDataArr.sort((a, b) => {
if (a.item.seasonOrder < b.item.seasonOrder && a.id.localeCompare(b.id) < 0) {
return -1;
}
if (a.item.seasonOrder > b.item.seasonOrder && a.id.localeCompare(b.id) > 0) {
return 1;
}
return 0;
});
return { isOk: true, value: epsDataArr };
}
public async getShow(log: boolean, data: FuniGetShowData) : Promise<FuniShowResponse> {
const showList = await this.listShowItems(data.id);
if (!showList.isOk)
return showList;
const eps = showList.value;
const epSelList = parseSelect(data.e as string, data.but);
const fnSlug: FuniEpisodeData[] = [], epSelEpsTxt: string[] = []; let is_selected = false;
for(const e in eps){
eps[e].id_split[1] = parseInt(eps[e].id_split[1].toString()).toString().padStart(Funi.epIdLen, '0');
let epStrId = eps[e].id_split.join('');
// select
is_selected = false;
if (data.all || epSelList.isSelected(epStrId)) {
fnSlug.push({
title:eps[e].item.titleSlug,
episode:eps[e].item.episodeSlug,
episodeID:epStrId,
epsiodeNumber: eps[e].item.episodeNum,
seasonTitle: eps[e].item.seasonTitle,
seasonNumber: eps[e].item.seasonNum,
ids: {
episode: eps[e].ids.externalEpisodeId,
season: eps[e].ids.externalSeasonId,
show: eps[e].ids.externalShowId
},
image: eps[e].item.poster
});
epSelEpsTxt.push(epStrId);
is_selected = true;
}
// console vars
const tx_snum = eps[e].item.seasonNum=='1'?'':` S${eps[e].item.seasonNum}`;
const tx_type = eps[e].mediaCategory != 'episode' ? eps[e].mediaCategory : '';
const tx_enum = eps[e].item.episodeNum && eps[e].item.episodeNum !== '' ?
`#${(parseInt(eps[e].item.episodeNum) < 10 ? '0' : '')+eps[e].item.episodeNum}` : '#'+eps[e].item.episodeId;
const qua_str = eps[e].quality.height ? eps[e].quality.quality + eps[e].quality.height : 'UNK';
const aud_str = eps[e].audio.length > 0 ? `, ${eps[e].audio.join(', ')}` : '';
const rtm_str = eps[e].item.runtime !== '' ? eps[e].item.runtime : '??:??';
// console string
eps[e].id_split[0] = eps[e].id_split[0].toString().padStart(Funi.typeIdLen, ' ');
epStrId = eps[e].id_split.join('');
let conOut = `[${epStrId}] `;
conOut += `${eps[e].item.titleName+tx_snum} - ${tx_type+tx_enum} ${eps[e].item.episodeName} `;
conOut += `(${rtm_str}) [${qua_str+aud_str}]`;
conOut += is_selected ? ' (selected)' : '';
conOut += eps.length-1 == parseInt(e) ? '\n' : '';
console.info(conOut);
}
if(fnSlug.length < 1){
if (log)
console.info('Episodes not selected!\n');
return { isOk: true, value: [] } ;
}
else{
if (log)
console.info('Selected Episodes: %s\n',epSelEpsTxt.join(', '));
return { isOk: true, value: fnSlug };
}
}
public async getEpisode(log: boolean, data: FuniGetEpisodeData, downloadData: FuniStreamData) : Promise<FuniGetEpisodeResponse> {
const episodeData = await getData({
baseUrl: api_host,
url: `/source/catalog/episode/${data.fnSlug.title}/${data.fnSlug.episode}/`,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!episodeData.ok || !episodeData.res){return { isOk: false, reason: new Error('Unable to get episodeData') }; }
const ep = JSON.parse(episodeData.res.body).items[0] as EpisodeData, streamIds: { id: number, lang: langsData.LanguageItem }[] = [];
// build fn
season = parseInt(ep.parent.seasonNumber);
if(ep.mediaCategory != 'Episode'){
ep.number = ep.number !== '' ? ep.mediaCategory+ep.number : ep.mediaCategory+'#'+ep.id;
}
fnEpNum = isNaN(parseInt(ep.number)) ? ep.number : parseInt(ep.number);
// is uncut
const uncut = {
Japanese: false,
English: false
};
// end
if (log) {
console.info(
'%s - S%sE%s - %s',
ep.parent.title,
(ep.parent.seasonNumber ? ep.parent.seasonNumber : '?'),
(ep.number ? ep.number : '?'),
ep.title
);
console.info('Available streams (Non-Encrypted):');
}
// map medias
const media = await Promise.all(ep.media.map(async (m) =>{
if(m.mediaType == 'experience'){
if(m.version.match(/uncut/i) && m.language){
uncut[m.language] = true;
}
return {
id: m.id,
language: m.language,
version: m.version,
type: m.experienceType,
subtitles: await this.getSubsUrl(m.mediaChildren, m.language, data.subs, ep.ids.externalEpisodeId, data.subs.ccTag)
};
}
else{
return { id: 0, type: '' };
}
}));
// select
stDlPath = [];
for(const m of media){
let selected = false;
if(m.id > 0 && m.type == 'Non-Encrypted'){
const dub_type = m.language;
if (!dub_type)
continue;
let localSubs: Subtitle[] = [];
const selUncut = !data.simul && uncut[dub_type] && m.version?.match(/uncut/i)
? true
: (!uncut[dub_type] || data.simul && m.version?.match(/simulcast/i) ? true : false);
for (const curDub of data.dubLang) {
const item = langsData.languages.find(a => a.code === curDub);
if(item && (dub_type === item.funi_name_lagacy || dub_type === (item.funi_name ?? item.name)) && selUncut){
streamIds.push({
id: m.id,
lang: item
});
stDlPath.push(...m.subtitles);
localSubs = m.subtitles;
selected = true;
}
}
if (log) {
const subsToDisplay: langsData.LanguageItem[] = [];
localSubs.forEach(a => {
if (!subsToDisplay.includes(a.lang))
subsToDisplay.push(a.lang);
});
console.info(`[#${m.id}] ${dub_type} [${m.version}]${(selected?' (selected)':'')}${
localSubs && localSubs.length > 0 && selected ? ` (using ${subsToDisplay.map(a => `'${a.name}'`).join(', ')} for subtitles)` : ''
}`);
}
}
}
const already: string[] = [];
stDlPath = stDlPath.filter(a => {
if (already.includes(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`)) {
return false;
} else {
already.push(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`);
return true;
}
});
if(streamIds.length < 1){
if (log)
console.error('Track not selected\n');
return { isOk: false, reason: new Error('Track not selected') };
}
else{
tsDlPath = [];
for (const streamId of streamIds) {
const streamData = await getData({
baseUrl: api_host,
url: `/source/catalog/video/${streamId.id}/signed`,
token: this.token,
dinstid: 'uuid',
useToken: true,
debug: this.debug,
});
if(!streamData.ok || !streamData.res){return { isOk: false, reason: new Error('Unable to get streamdata') };}
const streamDataRes = JSON.parse(streamData.res.body) as StreamData;
if(streamDataRes.errors){
if (log)
console.info('Error #%s: %s\n',streamDataRes.errors[0].code,streamDataRes.errors[0].detail);
return { isOk: false, reason: new Error(streamDataRes.errors[0].detail) };
}
else{
for(const u in streamDataRes.items){
if(streamDataRes.items[u].videoType == 'm3u8'){
tsDlPath.push({
path: streamDataRes.items[u].src,
lang: streamId.lang
});
break;
}
}
}
}
if(tsDlPath.length < 1){
if (log)
console.error('Unknown error\n');
return { isOk: false, reason: new Error('Unknown error') };
}
else{
const res = await this.downloadStreams(true, {
id: data.fnSlug.episodeID,
title: ep.title,
showTitle: ep.parent.title,
image: ep.thumb
}, downloadData);
if (res === true) {
downloaded({
service: 'funi',
type: 's'
}, data.s, [data.fnSlug.episodeID]);
return { isOk: res, value: undefined };
}
return { isOk: false, reason: new Error('Unknown download error') };
}
}
}
public async downloadStreams(log: boolean, episode: FunimationMediaDownload, data: FuniStreamData): Promise<boolean|void> {
// req playlist
const purvideo: DownloadedFile[] = [];
const puraudio: DownloadedFile[] = [];
const audioAndVideo: DownloadedFile[] = [];
for (const streamPath of tsDlPath) {
const plQualityReq = await getData({
url: streamPath.path,
debug: this.debug,
});
if(!plQualityReq.ok || !plQualityReq.res){return;}
const plQualityLinkList = m3u8(plQualityReq.res.body);
const mainServersList = [
'vmfst-api.prd.funimationsvc.com',
'd33et77evd9bgg.cloudfront.net',
'd132fumi6di1wa.cloudfront.net',
'funiprod.akamaized.net',
];
const plServerList: string[] = [],
plStreams: Record<string|number, {
[key: string]: string
}> = {},
plLayersStr: string[] = [],
plLayersRes: Record<string|number, {
width: number,
height: number
}> = {};
let plMaxLayer = 1,
plNewIds = 1,
plAud: undefined|{
uri: string
language: langsData.LanguageItem
};
// new uris
const vplReg = /streaming_video_(\d+)_(\d+)_(\d+)_index\.m3u8/;
if(plQualityLinkList.playlists[0].uri.match(vplReg)){
const audioKey = Object.keys(plQualityLinkList.mediaGroups.AUDIO).pop();
if (!audioKey)
return console.error('No audio key found');
if(plQualityLinkList.mediaGroups.AUDIO[audioKey]){
const audioDataParts = plQualityLinkList.mediaGroups.AUDIO[audioKey],
audioEl = Object.keys(audioDataParts);
const audioData = audioDataParts[audioEl[0]];
let language = langsData.languages.find(a => a.code === audioData.language || a.locale === audioData.language);
if (!language) {
language = langsData.languages.find(a => a.funi_name_lagacy === audioEl[0] || ((a.funi_name ?? a.name) === audioEl[0]));
if (!language) {
if (log)
console.error(`Unable to find language for locale ${audioData.language} or name ${audioEl[0]}`);
return;
}
}
plAud = {
uri: audioData.uri,
language: language
};
}
plQualityLinkList.playlists.sort((a, b) => {
const aMatch = a.uri.match(vplReg), bMatch = b.uri.match(vplReg);
if (!aMatch || !bMatch) {
console.info('Unable to match');
return 0;
}
const av = parseInt(aMatch[3]);
const bv = parseInt(bMatch[3]);
if(av > bv){
return 1;
}
if (av < bv) {
return -1;
}
return 0;
});
}
for(const s of plQualityLinkList.playlists){
if(s.uri.match(/_Layer(\d+)\.m3u8/) || s.uri.match(vplReg)){
// set layer and max layer
let plLayerId: number|string = 0;
const match = s.uri.match(/_Layer(\d+)\.m3u8/);
if(match){
plLayerId = parseInt(match[1]);
}
else{
plLayerId = plNewIds, plNewIds++;
}
plMaxLayer = plMaxLayer < plLayerId ? plLayerId : plMaxLayer;
// set urls and servers
const plUrlDl = s.uri;
const plServer = new URL(plUrlDl).host;
if(!plServerList.includes(plServer)){
plServerList.push(plServer);
}
if(!Object.keys(plStreams).includes(plServer)){
plStreams[plServer] = {};
}
if(plStreams[plServer][plLayerId] && plStreams[plServer][plLayerId] != plUrlDl){
console.warn(`Non duplicate url for ${plServer} detected, please report to developer!`);
}
else{
plStreams[plServer][plLayerId] = plUrlDl;
}
// set plLayersStr
const plResolution = s.attributes.RESOLUTION;
plLayersRes[plLayerId] = plResolution;
const plBandwidth = Math.round(s.attributes.BANDWIDTH/1024);
if(plLayerId<10){
plLayerId = plLayerId.toString().padStart(2,' ');
}
const qualityStrAdd = `${plLayerId}: ${plResolution.width}x${plResolution.height} (${plBandwidth}KiB/s)`;
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g,'\\$1'),'m');
const qualityStrMatch = !plLayersStr.join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plLayersStr.push(qualityStrAdd);
}
}
else {
console.info(s.uri);
}
}
for(const s of mainServersList){
if(plServerList.includes(s)){
plServerList.splice(plServerList.indexOf(s), 1);
plServerList.unshift(s);
break;
}
}
const plSelectedServer = plServerList[data.x-1];
const plSelectedList = plStreams[plSelectedServer];
plLayersStr.sort();
if (log) {
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plLayersStr.join('\n\t')}`);
}
const selectedQuality = data.q === 0 || data.q > Object.keys(plLayersRes).length
? Object.keys(plLayersRes).pop() as string
: data.q;
const videoUrl = data.x < plServerList.length+1 && plSelectedList[selectedQuality] ? plSelectedList[selectedQuality] : '';
if(videoUrl != ''){
if (log) {
console.info(`Selected layer: ${selectedQuality} (${plLayersRes[selectedQuality].width}x${plLayersRes[selectedQuality].height}) @ ${plSelectedServer}`);
console.info('Stream URL:',videoUrl);
}
fnOutput = parseFileName(data.fileName, ([
['episode', isNaN(parseInt(fnEpNum as string)) ? fnEpNum : parseInt(fnEpNum as string), true],
['title', episode.title, true],
['showTitle', episode.showTitle, true],
['season', season, false],
['width', plLayersRes[selectedQuality].width, false],
['height', plLayersRes[selectedQuality].height, false],
['service', 'Funimation', false]
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
sanitize: a[2]
} as Variable;
}), data.numbers, data.override);
if (fnOutput.length < 1)
throw new Error(`Invalid path generated for input ${data.fileName}`);
if (log)
console.info(`Output filename: ${fnOutput.join(path.sep)}.ts`);
}
else if(data.x > plServerList.length){
if (log)
console.error('Server not selected!\n');
return;
}
else{
if (log)
console.error('Layer not selected!\n');
return;
}
let dlFailed = false;
let dlFailedA = false;
await fs.promises.mkdir(path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1)), { recursive: true });
video: if (!data.novids) {
if (plAud && (purvideo.length > 0 || audioAndVideo.length > 0)) {
break video;
} else if (!plAud && (audioAndVideo.some(a => a.lang === streamPath.lang) || puraudio.some(a => a.lang === streamPath.lang))) {
break video;
}
// download video
const reqVideo = await getData({
url: videoUrl,
debug: this.debug,
});
if (!reqVideo.ok || !reqVideo.res) { break video; }
const chunkList = m3u8(reqVideo.res.body);
const tsFile = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}`);
dlFailed = !await this.downloadFile(tsFile, chunkList, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}.ts`,
parent: {
title: episode.showTitle
},
title: episode.title,
image: episode.image,
language: streamPath.lang,
}) : undefined);
if (!dlFailed) {
if (plAud) {
purvideo.push({
path: `${tsFile}.ts`,
lang: plAud.language
});
} else {
audioAndVideo.push({
path: `${tsFile}.ts`,
lang: streamPath.lang
});
}
}
}
else{
if (log)
console.info('Skip video downloading...\n');
}
audio: if (plAud && !data.noaudio) {
// download audio
if (audioAndVideo.some(a => a.lang === plAud?.language) || puraudio.some(a => a.lang === plAud?.language))
break audio;
const reqAudio = await getData({
url: plAud.uri,
debug: this.debug,
});
if (!reqAudio.ok || !reqAudio.res) { return; }
const chunkListA = m3u8(reqAudio.res.body);
const tsFileA = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.audio.${plAud.language.code}`);
dlFailedA = !await this.downloadFile(tsFileA, chunkListA, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.audio.${plAud.language.code}.ts`,
parent: {
title: episode.showTitle
},
title: episode.title,
image: episode.image,
language: plAud.language
}) : undefined);
if (!dlFailedA)
puraudio.push({
path: `${tsFileA}.ts`,
lang: plAud.language
});
}
}
// add subs
const subsExt = !data.mp4 || data.mp4 && data.ass ? '.ass' : '.srt';
let addSubs = true;
// download subtitles
if(stDlPath.length > 0){
if (log)
console.info('Downloading subtitles...');
for (const subObject of stDlPath) {
const subsSrc = await getData({
url: subObject.url,
debug: this.debug,
});
if(subsSrc.ok && subsSrc.res){
const assData = vttConvert(subsSrc.res.body, (subsExt == '.srt' ? true : false), subObject.lang.name, data.fontSize, data.fontName);
subObject.out = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.subtitle${subObject.ext}${subsExt}`);
fs.writeFileSync(subObject.out, assData);
}
else{
if (log)
console.error('Failed to download subtitles!');
addSubs = false;
break;
}
}
if (addSubs && log)
console.info('Subtitles downloaded!');
}
if((puraudio.length < 1 && audioAndVideo.length < 1) || (purvideo.length < 1 && audioAndVideo.length < 1)){
if (log)
console.info('\nUnable to locate a video AND audio file\n');
return;
}
if(data.skipmux){
if (log)
console.info('Skipping muxing...');
return;
}
// check exec
this.cfg.bin = await yamlCfg.loadBinCfg();
const mergerBin = merger.checkMerger(this.cfg.bin, data.mp4, data.forceMuxer);
if ( data.novids ){
if (log)
console.info('Video not downloaded. Skip muxing video.');
}
const ffext = !data.mp4 ? 'mkv' : 'mp4';
const mergeInstance = new merger({
onlyAudio: puraudio,
onlyVid: purvideo,
output: `${path.join(this.cfg.dir.content, ...fnOutput)}.${ffext}`,
subtitles: stDlPath.map(a => {
return {
file: a.out as string,
language: a.lang,
title: a.lang.name,
closedCaption: a.closedCaption
};
}),
videoAndAudio: audioAndVideo,
simul: data.simul,
skipSubMux: data.skipSubMux,
videoTitle: data.videoTitle,
options: {
ffmpeg: data.ffmpegOptions,
mkvmerge: data.mkvmergeOptions
},
defaults: {
audio: data.defaultAudio,
sub: data.defaultSub
},
ccTag: data.ccTag
});
if(mergerBin.MKVmerge){
await mergeInstance.merge('mkvmerge', mergerBin.MKVmerge);
}
else if(mergerBin.FFmpeg){
await mergeInstance.merge('ffmpeg', mergerBin.FFmpeg);
}
else{
if (log)
console.info('\nDone!\n');
return true;
}
if (data.nocleanup) {
return true;
}
mergeInstance.cleanUp();
if (log)
console.info('\nDone!\n');
return true;
}
public async downloadFile(filename: string, chunkList: {
segments: Record<string, unknown>[],
}, timeout: number, partsize: number, fsRetryTime: number, override?: 'Y' | 'y' | 'N' | 'n' | 'C' | 'c', callback?: HLSCallback) {
const downloadStatus = await new hlsDownload({
m3u8json: chunkList,
output: `${filename + '.ts'}`,
timeout: timeout,
threads: partsize,
fsRetryTime: fsRetryTime * 1000,
override,
callback
}).download();
return downloadStatus.ok;
}
public async getSubsUrl(m: MediaChild[], parentLanguage: TitleElement|undefined, data: FuniSubsData, episodeID: string, ccTag: string) : Promise<Subtitle[]> {
if((data.nosubs && !data.sub) || data.dlsubs.includes('none')){
return [];
}
const subs = await getData({
baseUrl: 'https://playback.prd.funimationsvc.com/v1/play',
url: `/${episodeID}`,
token: this.token,
useToken: true,
debug: this.debug,
querystring: { deviceType: 'web' }
});
if (!subs.ok || !subs.res || !subs.res.body) {
console.error('Subtitle Request failed.');
return [];
}
const parsed: SubtitleRequest = JSON.parse(subs.res.body);
const found: {
isCC: boolean;
url: string;
lang: langsData.LanguageItem;
}[] = parsed.primary.subtitles.filter(a => a.fileExt === 'vtt').map(subtitle => {
return {
isCC: subtitle.contentType === 'cc',
url: subtitle.filePath,
lang: langsData.languages.find(a => a.funi_locale === subtitle.languageCode || a.locale === subtitle.languageCode)
};
}).concat(m.filter(a => a.filePath.split('.').pop() === 'vtt').map(media => {
const lang = langsData.languages.find(a => media.language === a.funi_name_lagacy || media.language === (a.funi_name || a.name));
const pLang = langsData.languages.find(a => parentLanguage === a.funi_name_lagacy || (a.funi_name || a.name) === parentLanguage);
return {
isCC: pLang?.code === lang?.code,
url: media.filePath,
lang
};
})).filter((a) => a.lang !== undefined) as {
isCC: boolean;
url: string;
lang: langsData.LanguageItem;
}[];
const ret = found.filter(item => {
return data.dlsubs.includes('all') || data.dlsubs.some(a => a === item.lang.locale);
});
return ret.map(a => ({
ext: `.${a.lang.code}${a.isCC ? `.${ccTag}` : ''}`,
lang: a.lang,
url: a.url,
closedCaption: a.isCC
}));
}
}

View file

@ -1,3 +0,0 @@
{
"presets": ["@babel/preset-env","@babel/preset-react", "@babel/preset-typescript"]
}

View file

@ -1,46 +1,39 @@
{
"name": "anidl-gui",
"version": "1.0.0",
"name": "react",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.20",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20",
"concurrently": "^8.2.2",
"@babel/core": ">=7.0.0-0 <8.0.0",
"@babel/plugin-syntax-flow": "^7.14.5",
"@babel/plugin-transform-react-jsx": "^7.14.9",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.9",
"@mui/lab": "^5.0.0-alpha.120",
"@mui/material": "^5.11.9",
"@types/node": "^18.14.0",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.11",
"notistack": "^2.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.5.2",
"uuid": "^9.0.1",
"ws": "^8.17.1"
},
"devDependencies": {
"@babel/cli": "^7.24.7",
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@types/node": "^20.14.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.0",
"style-loader": "^3.3.4",
"ts-node": "^10.9.2",
"webpack": "^5.92.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^9.0.0",
"ws": "^8.12.1"
},
"proxy": "http://localhost:3000",
"scripts": {
"build": "npx tsc && npx webpack",
"start": "npx concurrently -k npm:frontend npm:backend",
"frontend": "npx webpack-dev-server",
"backend": "npx ts-node -T ../../gui.ts"
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
@ -53,5 +46,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^9.0.1"
}
}

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,8 +1,6 @@
<!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,21 +13,15 @@ const Layout: React.FC = () => {
const messageHandler = React.useContext(messageChannelContext);
return <Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '100%', alignItems: 'center',}}>
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<MenuBar />
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '93vw',
maxWidth: '93rem',
maxHeight: '3rem'
//backgroundColor: '#ffffff',
}}>
<Box sx={{ height: 50, mb: 4, display: 'flex', gap: 1, mt: 3 }}>
<LogoutButton />
<AuthButton />
<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>
<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>
<AddToQueue />
<StartQueueButton />
</Box>

View file

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

View file

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

View file

@ -1,11 +1,10 @@
import React, { ChangeEvent } from 'react';
import { Box, Button, Divider, FormControl, InputBase, InputLabel, Link, MenuItem, Select, TextField, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { Box, Button, TextField } 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
@ -18,8 +17,6 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
const [availableSubs, setAvailableSubs ] = React.useState<string[]>([]);
const [ loading, setLoading ] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
React.useEffect(() => {
(async () => {
@ -81,246 +78,68 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
setLoading(false);
};
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='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)
return;
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, q: parsed }
});
}} 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={store.service == 'hidive' ? '' :
<Typography>
Simulcast is only supported on Hidive
</Typography>}
arrow placement='top'
>
<Box>
<Button sx={{ textTransform: 'none'}} disabled={store.service != 'hidive'} onClick={() => dispatch({ type: 'downloadOptions', payload: { ...store.downloadOptions, simul: !store.downloadOptions.simul } })} variant={store.downloadOptions.simul ? 'contained' : 'outlined'}>Download Simulcast ver.</Button>
</Box>
</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}
selected={store.downloadOptions.dubLang}
onChange={(e) => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, dubLang: e }
});
}}
allOption
/>
<MultiSelect
title='Sub Languages'
values={availableSubs}
selected={store.downloadOptions.dlsubs}
onChange={(e) => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, dlsubs: e }
});
}}
/>
<Tooltip title={store.service == 'crunchy' ? '' :
<Typography>
Hardsubs are only supported on Crunchyroll
</Typography>
}
arrow placement='top'>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '1rem'
}}>
<Box sx={{
borderRadius: '5px',
//backgroundColor: '#ff4567',
width: '15rem',
height: '3.5rem',
display: 'flex',
}}>
<FormControl fullWidth>
<InputLabel id='hsLabel'>Hardsub Language</InputLabel>
<Select
MenuProps={{
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250
}
}
}}
labelId='hsLabel'
label='Hardsub Language'
disabled={store.service != 'crunchy'}
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 value=''>No Hardsub</MenuItem>
{availableSubs.map((lang) => {
if(lang === 'all' || lang === 'none')
return undefined;
return <MenuItem value={lang}>{lang}</MenuItem>;
})}
</Select>
</FormControl>
</Box>
<Tooltip title={
<Typography>
Downloads the hardsub version of the selected subtitle.<br/>Subtitles are displayed <b>PERMANENTLY!</b><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 => {
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ m: 2, gap: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', flexWrap: 'wrap' }}>
<TextField value={store.downloadOptions.id} required onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, id: e.target.value }
});
}} label='Item ID' />
<TextField type='number' value={store.downloadOptions.q} required onChange={e => {
const parsed = parseInt(e.target.value);
if (isNaN(parsed) || parsed < 0 || parsed > 10)
return;
dispatch({
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' />
<MultiSelect
title='Dub Languages'
values={availableDubs}
selected={store.downloadOptions.dubLang}
onChange={(e) => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, fileName: e.target.value }
payload: { ...store.downloadOptions, dubLang: e }
});
}} 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>
}}
allOption
/>
<MultiSelect
title='Sub Languages'
values={availableSubs}
selected={store.downloadOptions.dlsubs}
onChange={(e) => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, dlsubs: e }
});
}}
/>
<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>
</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

@ -42,10 +42,6 @@ const EpisodeListing: React.FC = () => {
});
};
const getEpisodesForSeason = (season: string|'all') => {
return store.episodeListing.filter((a) => season === 'all' ? true : a.season === season);
};
return <Dialog open={store.episodeListing.length > 0} onClose={close} scroll='paper' maxWidth='xl' sx={{ p: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 200px 20px' }}>
<Typography color='text.primary' variant="h5" sx={{ textAlign: 'center', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
@ -72,28 +68,28 @@ const EpisodeListing: React.FC = () => {
if (selected.length > 0) {
setSelected([]);
} else {
setSelected(getEpisodesForSeason(season).map(a => a.e));
setSelected(store.episodeListing.map(a => a.e));
}
}}
/>
</ListItem>
{getEpisodesForSeason(season).map((item, index, { length }) => {
{store.episodeListing.filter((a) => season === 'all' ? true : a.season === season).map((item, index, { length }) => {
const e = isNaN(parseInt(item.e)) ? item.e : parseInt(item.e);
const idStr = `S${item.season}E${e}`;
const idStr = `S${item.season}E${e}`
const isSelected = selected.includes(e.toString());
const imageRef = React.createRef<HTMLImageElement>();
const summaryRef = React.createRef<HTMLParagraphElement>();
return <Box {...{ mouseData: isSelected }} key={`Episode_List_Item_${index}`}>
<ListItem sx={{backdropFilter: isSelected ? 'brightness(1.5)' : '', '&:hover': {backdropFilter: 'brightness(1.5)'}, display: 'grid', gridTemplateColumns: '25px 50px 1fr 5fr' }}
onClick={() => {
let arr: string[] = [];
if (isSelected) {
arr = [...selected.filter(a => a !== e.toString())];
} else {
arr = [...selected, e.toString()];
}
setSelected(arr.filter(a => a.length > 0));
}}>
<ListItem sx={{backdropFilter: isSelected ? 'brightness(1.5)' : '', '&:hover': {backdropFilter: 'brightness(1.5)'}, display: 'grid', gridTemplateColumns: '25px 50px 1fr 5fr' }}
onClick={() => {
let arr: string[] = [];
if (isSelected) {
arr = [...selected.filter(a => a !== e.toString())];
} else {
arr = [...selected, e.toString()];
}
setSelected(arr.filter(a => a.length > 0));
}}>
{ isSelected ? <CheckBox /> : <CheckBoxOutlineBlank /> }
<Typography color='text.primary' sx={{ textAlign: 'center' }}>
{idStr}
@ -137,9 +133,9 @@ const EpisodeListing: React.FC = () => {
await navigator.clipboard.writeText(item.description!);
enqueueSnackbar('Copied summary to clipboard', {
variant: 'info'
});
})
},
text: 'Copy summary to clipboard'
text: "Copy summary to clipboard"
}
]} popupItem={summaryRef} />
{index < length - 1 && <Divider />}

View file

@ -40,7 +40,7 @@ const SearchBox: React.FC = () => {
s.value = s.value.slice(0, 10);
setSearchResult(s);
}
}, 500);
}, 1000);
return () => clearTimeout(timeOutId);
}, [search]);
@ -100,9 +100,9 @@ const SearchBox: React.FC = () => {
await navigator.clipboard.writeText(a.desc!);
enqueueSnackbar('Copied summary to clipboard', {
variant: 'info'
});
})
},
text: 'Copy summary to clipboard'
text: "Copy summary to clipboard"
}
]} popupItem={summaryRef} />
}

View file

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

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import { Badge, Box, Button, CircularProgress, Divider, IconButton, LinearProgress, Skeleton, Tooltip, Typography } from '@mui/material';
import { Box, Button, CircularProgress, Divider, LinearProgress, Skeleton, 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';
@ -17,104 +16,27 @@ const Queue: React.FC = () => {
return data || queue.length > 0 ? <>
{data && <>
<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>
</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 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 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'>
{data.downloadInfo.parent.title}
</Typography>
</Box>
<LinearProgress variant='determinate' sx={{ height: '10px' }} value={(typeof data.progress.percent === 'string' ? parseInt(data.progress.percent) : data.progress.percent)} />
<Box>
<Typography variant="body1" color='text.primary'>
{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>
@ -122,288 +44,74 @@ const Queue: React.FC = () => {
}
{
current && !data && <>
<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 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>
</Box>
</>
}
{queue.length && data && <Divider variant="fullWidth" />}
{queue.map((queueItem, index, { length }) => {
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',
}}>
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}
</Typography>
<Typography variant='h5' color='text.primary'>
Languages: {queueItem.dubLang.join(', ')}
</Typography>
</Box>
<Typography variant='h6' color='text.primary'>
{queueItem.parent.title}
</Typography>
<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>
<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={{
backgroundColor: '#ff573a25',
height: '40px',
transition: '250ms',
'&:hover' : {
backgroundColor: '#ff573a',
}
}}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
<Typography variant='body1' color='text.primary'>
S{queueItem.parent.season}E{queueItem.episode} <br />
Quality: {queueItem.q}
</Typography>
<Button onClick={() => {
msg.removeFromQueue(index);
}} sx={{ position: 'relative', left: '50%', transform: 'translateX(-50%)', width: '60%' }} variant="outlined" color="warning">
Remove from Queue
</Button>
</Box>
</Box>
</Box>
;
{index < length - 1 && <Divider variant="fullWidth" />}
</Box>;
})}
</> : <Box sx={{
display: 'flex',
width: '100%',
height: '12rem',
flexDirection: 'column',
alignItems: 'center',
}}>
<Typography color='text.primary' sx={{
fontSize: '2rem',
margin: '10px'
}}>
</> : <Box>
<Typography color='text.primary' variant='h4'>
Selected episodes will be shown here
</Typography>
<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 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>
</Box>
</Box>;

View file

@ -2,38 +2,33 @@ import { Box, Button, Menu, MenuItem, Typography } from '@mui/material';
import React from 'react';
import { messageChannelContext } from '../../provider/MessageChannel';
import useStore from '../../hooks/useStore';
import { StoreState } from '../../provider/Store';
import { StoreState } from '../../provider/Store'
const MenuBar: React.FC = () => {
const [ openMenu, setMenuOpen ] = React.useState<'settings'|'help'|undefined>();
const [ openMenu, setMenuOpen ] = React.useState<'settings'|'help'|undefined>();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [store, dispatch] = useStore();
const messageChannel = React.useContext(messageChannelContext);
const getVersion = async() => {
dispatch({
type: 'version',
payload: await messageChannel?.version()
});
}
React.useEffect(() => {
(async () => {
if (!messageChannel || store.version !== '')
return;
dispatch({
type: 'version',
payload: await messageChannel.version()
});
})();
}, [messageChannel]);
getVersion();
const transformService = (service: StoreState['service']) => {
switch(service) {
case 'crunchy':
return 'Crunchyroll';
case 'hidive':
return 'Hidive';
case 'ao':
return 'AnimeOnegai';
case 'adn':
return 'AnimationDigitalNetwork';
case 'crunchy':
return "Crunchyroll"
case 'funi':
return "Funimation"
case "hidive":
return "Hidive"
}
};
}
const msg = React.useContext(messageChannelContext);
@ -49,15 +44,13 @@ const MenuBar: React.FC = () => {
if (!msg)
return <></>;
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')}>
return <Box sx={{ width: '100%', display: 'flex' }}>
<Button onClick={(e) => handleClick(e, 'settings')}>
Settings
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
Help
</Button>
</Box>
</Button>
<Menu open={openMenu === 'settings'} anchorEl={anchorEl} onClose={handleClose}>
<MenuItem onClick={() => {
msg.openFolder('config');
@ -89,7 +82,7 @@ const MenuBar: React.FC = () => {
msg.openURL('https://github.com/anidl/multi-downloader-nx');
handleClose();
}}>
GitHub
GitHub
</MenuItem>
<MenuItem onClick={() => {
msg.openURL('https://github.com/anidl/multi-downloader-nx/issues/new?assignees=AnimeDL,AnidlSupport&labels=bug&template=bug.yml&title=BUG');
@ -115,10 +108,10 @@ const MenuBar: React.FC = () => {
Version: {store.version}
</MenuItem>
</Menu>
<Typography variant="h5" color="text.primary">
<Typography variant="h5" color="text.primary" component="div" align="center" sx={{flexGrow: 1}}>
{transformService(store.service)}
</Typography>
</Box>;
};
export default MenuBar;
export default MenuBar;

View file

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

View file

@ -33,7 +33,7 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
const theme = useTheme();
return <div>
<FormControl sx={{ width: 300 }}>
<FormControl sx={{ m: 1, width: 300 }}>
<InputLabel id="multi-select-label">{props.title}</InputLabel>
<Select
labelId="multi-select-label"

View file

@ -4,17 +4,13 @@ import App from './App';
import ServiceProvider from './provider/ServiceProvider';
import Style from './Style';
import MessageChannel from './provider/MessageChannel';
import { Box, IconButton } from '@mui/material';
import { 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)
@ -38,9 +34,7 @@ root.render(
<MessageChannel>
<ServiceProvider>
<QueueProvider>
<Box>
<App />
</Box>
<App />
</QueueProvider>
</ServiceProvider>
</MessageChannel>

View file

@ -27,7 +27,7 @@ export default class ErrorHandler extends React.Component<{
<Typography variant='body1' color='red'>
{`${this.state.error.er.name}: ${this.state.error.er.message}`}
<br/>
{this.state.error.stack.componentStack?.split('\n').map(a => {
{this.state.error.stack.componentStack.split('\n').map(a => {
return <>
{a}
<br/>

View file

@ -15,11 +15,11 @@ export class RandomEventHandler {
private handler: {
[eventName in keyof RandomEvents]: Handler<eventName>[]
} = {
progress: [],
finish: [],
queueChange: [],
current: []
};
progress: [],
finish: [],
queueChange: [],
current: []
};
public on<T extends keyof RandomEvents>(name: T, listener: Handler<T>) {
if (Object.prototype.hasOwnProperty.call(this.handler, name)) {
@ -50,14 +50,14 @@ async function messageAndResponse<T extends keyof MessageTypes>(socket: WebSocke
resolve(parsed);
}
};
socket.addEventListener('message', handler);
socket.addEventListener('message', handler);
});
const toSend = msg as WSMessageWithID<T>;
toSend.id = id;
socket.send(JSON.stringify(toSend));
return ret;
}
}
const MessageChannelProvider: FCWithChildren = ({ children }) => {
@ -70,7 +70,7 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
const { enqueueSnackbar } = useSnackbar();
React.useEffect(() => {
const wss = new WebSocket(`${location.protocol == 'https:' ? 'wss' : 'ws'}://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/public`);
const wss = new WebSocket(`ws://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/public`);
wss.addEventListener('open', () => {
setPublicWS(wss);
});
@ -103,7 +103,7 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
});
}
const wws = new WebSocket(`${location.protocol == 'https:' ? 'wss' : 'ws'}://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/private?${search}`, );
const wws = new WebSocket(`ws://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/ws?${search}`, );
wws.addEventListener('open', () => {
console.log('[INFO] [WS] Connected');
setSocket(wws);
@ -146,7 +146,7 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
const currentService = await messageAndResponse(socket, { name: 'type', data: undefined });
if (currentService.data !== undefined)
return dispatch({ type: 'service', payload: currentService.data });
if (store.service !== currentService.data)
if (store.service !== currentService.data)
messageAndResponse(socket, { name: 'setup', data: store.service });
})();
}, [store.service, dispatch, socket]);
@ -212,7 +212,7 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
}
const messageHandler: FrontEndMessages = {
name: 'default',
name: "default",
auth: async (data) => (await messageAndResponse(socket, { name: 'auth', data })).data,
version: async () => (await messageAndResponse(socket, { name: 'version', data: undefined })).data,
checkToken: async () => (await messageAndResponse(socket, { name: 'checkToken', data: undefined })).data,
@ -241,4 +241,4 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
</messageChannelContext.Provider>;
};
export default MessageChannelProvider;
export default MessageChannelProvider;

View file

@ -3,7 +3,7 @@ import {Divider, Box, Button, Typography, Avatar} from '@mui/material';
import useStore from '../hooks/useStore';
import { StoreState } from './Store';
type Services = 'crunchy'|'hidive'|'ao'|'adn';
type Services = 'funi'|'crunchy'|'hidive';
export const serviceContext = React.createContext<Services|undefined>(undefined);
@ -18,13 +18,14 @@ const ServiceProvider: FCWithChildren = ({ children }) => {
};
return service === undefined ?
<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>
<Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please choose 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>
<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>
<Button size='large' variant="contained" onClick={() => setService('ao')} startIcon={<Avatar src={'https://www.animeonegai.com/assets/img/anime/general/ao3-favicon.png'} />}>AnimeOnegai</Button>
<Button size='large' variant="contained" onClick={() => setService('adn')} startIcon={<Avatar src={'https://animationdigitalnetwork.de/favicon.ico'} />}>AnimationDigitalNetwork</Button>
<Divider orientation='vertical' flexItem />
<Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://www.hidive.com/favicon.ico'} />}>Hidive</Button>
</Box>
</Box>
: <serviceContext.Provider value={service}>

View file

@ -13,15 +13,13 @@ export type DownloadOptions = {
all: boolean,
but: boolean,
novids: boolean,
hslang?: string,
simul: boolean,
noaudio: boolean
}
export type StoreState = {
episodeListing: Episode[];
downloadOptions: DownloadOptions,
service: 'crunchy'|'hidive'|'ao'|'adn'|undefined,
service: 'crunchy'|'funi'|'hidive'|undefined,
version: string,
}
@ -50,8 +48,7 @@ const initialState: StoreState = {
all: false,
but: false,
noaudio: false,
novids: false,
simul: false
novids: false
},
service: undefined,
episodeListing: [],

View file

@ -1,6 +1,5 @@
{
"compilerOptions": {
"outDir": "./build",
"target": "es5",
"lib": [
"dom",
@ -14,16 +13,15 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "CommonJS",
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
//"noEmit": true,
"noEmit": true,
"jsx": "react-jsx",
"downlevelIteration": true
},
"include": [
"./src",
"./webpack.config.ts"
"./src"
]
}

View file

@ -1,58 +0,0 @@
import type { Configuration } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import path from 'path';
import type { Configuration as DevServerConfig } from 'webpack-dev-server';
const config: Configuration & DevServerConfig = {
devServer: {
proxy: [
{
target: 'http://localhost:3000',
context: ['/public', '/private'],
ws: true
}
],
},
entry: './src/index.tsx',
mode: 'production',
output: {
path: path.resolve(__dirname, './build'),
filename: 'index.js',
},
target: 'web',
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
},
performance: false,
module: {
rules: [
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: {
'loader': 'babel-loader',
options: {
presets: [
'@babel/typescript',
'@babel/preset-react',
['@babel/preset-env', {
targets: 'defaults'
}]
]
}
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'public', 'index.html')
})
]
};
export default config;

View file

@ -4,9 +4,8 @@ import { IncomingMessage } from 'http';
import { MessageHandler, GuiState } from '../../@types/messageHandler';
import { setState, getState, writeYamlCfgFile } from '../../modules/module.cfg-loader';
import CrunchyHandler from './services/crunchyroll';
import FunimationHandler from './services/funimation';
import HidiveHandler from './services/hidive';
import AnimeOnegaiHandler from './services/animeonegai';
import ADNHandler from './services/adn';
import WebSocketHandler from './websocket';
import packageJson from '../../package.json';
@ -32,14 +31,12 @@ export default class ServiceHandler {
});
this.ws.events.on('setup', ({ data }) => {
if (data === 'crunchy') {
if (data === 'funi') {
this.service = new FunimationHandler(this.ws);
} else if (data === 'crunchy') {
this.service = new CrunchyHandler(this.ws);
} else if (data === 'hidive') {
this.service = new HidiveHandler(this.ws);
} else if (data === 'ao') {
this.service = new AnimeOnegaiHandler(this.ws);
} else if (data === 'adn') {
this.service = new ADNHandler(this.ws);
}
});
@ -58,7 +55,7 @@ export default class ServiceHandler {
this.ws.events.on('version', async (_, respond) => {
respond(packageJson.version);
});
this.ws.events.on('type', async (_, respond) => respond(this.service === undefined ? undefined : this.service.name as 'hidive'|'crunchy'|'ao'|'adn'));
this.ws.events.on('type', async (_, respond) => respond(this.service === undefined ? undefined : this.service.name as 'hidive'|'crunchy'|'funi'));
this.ws.events.on('checkToken', async (_, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });

View file

@ -1,139 +0,0 @@
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import AnimationDigitalNetwork from '../../../adn';
import { getDefault } from '../../../modules/module.args';
import { languages } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
import { console } from '../../../modules/log';
import * as yargs from '../../../modules/module.app-args';
class ADNHandler extends Base implements MessageHandler {
private adn: AnimationDigitalNetwork;
public name = 'adn';
constructor(ws: WebSocketHandler) {
super(ws);
this.adn = new AnimationDigitalNetwork();
this.initState();
this.getDefaults();
}
public getDefaults() {
const _default = yargs.appArgv(this.adn.cfg.cli, true);
if (['fr', 'de'].includes(_default.locale))
this.adn.locale = _default.locale;
}
public async auth(data: AuthData) {
return this.adn.doAuth(data);
}
public async checkToken(): Promise<CheckTokenResponse> {
//TODO: implement proper method to check token
return { isOk: true, value: undefined };
}
public async search(data: SearchData): Promise<SearchResponse> {
console.debug(`Got search options: ${JSON.stringify(data)}`);
const search = await this.adn.doSearch(data);
if (!search.isOk) {
return search;
}
return { isOk: true, value: search.value };
}
public async handleDefault(name: string) {
return getDefault(name, this.adn.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.adn_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
const subLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.adn_locale)
subLanguageCodesArray.push(language.locale);
}
return ['all', 'none', ...new Set(subLanguageCodesArray)];
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
const parse = parseInt(data.id);
if (isNaN(parse) || parse <= 0)
return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.adn.selectShow(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: [a.id],
title: a.title,
parent: {
title: a.show.shortTitle,
season: a.season
},
e: a.shortNumber,
image: a.image,
episode: a.shortNumber
};
}));
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.adn.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.videos.map(function(item) {
return {
e: item.shortNumber,
lang: [],
name: item.title,
season: item.season,
seasonTitle: item.show.title,
episode: item.shortNumber,
id: item.id+'',
img: item.image,
description: item.summary,
time: item.duration+''
};
})};
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.adn.cfg.cli, true);
const res = await this.adn.selectShow(parseInt(data.id), data.e, false, false);
if (res.isOk) {
for (const select of res.value) {
if (!(await this.adn.getEpisode(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, noaudio: data.noaudio, hslang: data.hslang || 'none', dubLang: data.dubLang }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(new Error('Failed to download episode, check for additional logs.'));
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
export default ADNHandler;

View file

@ -1,151 +0,0 @@
import { AuthData, CheckTokenResponse, DownloadData, Episode, EpisodeListResponse, MessageHandler, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import AnimeOnegai from '../../../ao';
import { getDefault } from '../../../modules/module.args';
import { languages } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
import { console } from '../../../modules/log';
import * as yargs from '../../../modules/module.app-args';
class AnimeOnegaiHandler extends Base implements MessageHandler {
private ao: AnimeOnegai;
public name = 'ao';
constructor(ws: WebSocketHandler) {
super(ws);
this.ao = new AnimeOnegai();
this.initState();
this.getDefaults();
}
public getDefaults() {
const _default = yargs.appArgv(this.ao.cfg.cli, true);
if (['es', 'pt'].includes(_default.locale))
this.ao.locale = _default.locale;
}
public async auth(data: AuthData) {
return this.ao.doAuth(data);
}
public async checkToken(): Promise<CheckTokenResponse> {
//TODO: implement proper method to check token
return { isOk: true, value: undefined };
}
public async search(data: SearchData): Promise<SearchResponse> {
console.debug(`Got search options: ${JSON.stringify(data)}`);
const search = await this.ao.doSearch(data);
if (!search.isOk) {
return search;
}
return { isOk: true, value: search.value };
}
public async handleDefault(name: string) {
return getDefault(name, this.ao.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.ao_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
const subLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.ao_locale)
subLanguageCodesArray.push(language.locale);
}
return ['all', 'none', ...new Set(subLanguageCodesArray)];
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
const parse = parseInt(data.id);
if (isNaN(parse) || parse <= 0)
return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.ao.cfg.cli, true);
const res = await this.ao.selectShow(parseInt(data.id), data.e, data.but, data.all, _default);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: a.data.map(a => a.videoId),
title: a.episodeTitle,
parent: {
title: a.seasonTitle,
season: a.seasonTitle
},
e: a.episodeNumber+'',
image: a.image,
episode: a.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.ao.listShow(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
const episodes: Episode[] = [];
const seasonNumberTitleParse = request.series.data.title.match(/\d+$/);
const seasonNumber = seasonNumberTitleParse ? parseInt(seasonNumberTitleParse[0]) : 1;
//request.value
for (const episodeKey in request.value) {
const episode = request.value[episodeKey][0];
const langs = Array.from(new Set(request.value[episodeKey].map(a=>a.lang)));
episodes.push({
e: episode.number+'',
lang: langs as string[],
name: episode.name,
season: seasonNumber+'',
seasonTitle: '',
episode: episode.number+'',
id: episode.video_entry+'',
img: episode.thumbnail,
description: episode.description,
time: ''
});
}
return { isOk: true, value: episodes };
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.ao.cfg.cli, true);
const res = await this.ao.selectShow(parseInt(data.id), data.e, false, false, {
..._default,
dubLang: data.dubLang,
e: data.e
});
if (res.isOk) {
for (const select of res.value) {
if (!(await this.ao.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, noaudio: data.noaudio, hslang: data.hslang || 'none', dubLang: data.dubLang }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(new Error('Failed to download episode, check for additional logs.'));
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
export default AnimeOnegaiHandler;

View file

@ -15,17 +15,9 @@ class CrunchyHandler extends Base implements MessageHandler {
this.crunchy = new Crunchy();
this.crunchy.refreshToken();
this.initState();
this.getDefaults();
}
public getDefaults() {
const _default = yargs.appArgv(this.crunchy.cfg.cli, true);
this.crunchy.api = _default.crapi;
this.crunchy.locale = _default.locale;
}
public async listEpisodes (id: string): Promise<EpisodeListResponse> {
this.getDefaults();
await this.crunchy.refreshToken(true);
return { isOk: true, value: (await this.crunchy.listSeriesID(id)).list };
}
@ -48,7 +40,6 @@ class CrunchyHandler extends Base implements MessageHandler {
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
this.getDefaults();
await this.crunchy.refreshToken(true);
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.crunchy.downloadFromSeriesID(data.id, data);
@ -73,9 +64,7 @@ class CrunchyHandler extends Base implements MessageHandler {
}
public async search(data: SearchData): Promise<SearchResponse> {
this.getDefaults();
await this.crunchy.refreshToken(true);
if (!data['search-type']) data['search-type'] = 'series';
console.debug(`Got search options: ${JSON.stringify(data)}`);
const crunchySearch = await this.crunchy.doSearch(data);
if (!crunchySearch.isOk) {
@ -98,12 +87,10 @@ class CrunchyHandler extends Base implements MessageHandler {
}
public async downloadItem(data: DownloadData) {
this.getDefaults();
await this.crunchy.refreshToken(true);
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
@ -111,7 +98,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, noaudio: data.noaudio, hslang: data.hslang || 'none' }))) {
novids: data.novids }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);

View file

@ -0,0 +1,120 @@
import { AuthData, CheckTokenResponse, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import Funimation from '../../../funi';
import { getDefault } from '../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
import { console } from '../../../modules/log';
import * as yargs from '../../../modules/module.app-args';
class FunimationHandler extends Base implements MessageHandler {
private funi: Funimation;
public name = 'funi';
constructor(ws: WebSocketHandler) {
super(ws);
this.funi = new Funimation();
this.initState();
}
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.funi.listShowItems(parse);
if (!request.isOk)
return request;
return { isOk: true, value: request.value.map(item => ({
e: item.id_split.join(''),
lang: item.audio ?? [],
name: item.title,
season: item.seasonNum ?? item.seasonTitle ?? item.item.seasonNum ?? item.item.seasonTitle,
seasonTitle: item.seasonTitle,
episode: item.episodeNum,
id: item.id,
img: item.thumb,
description: item.synopsis,
time: item.runtime ?? item.item.runtime
})) };
}
public async handleDefault(name: string) {
return getDefault(name, this.funi.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.funi_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { ...data, id: parseInt(data.id) });
if (!res.isOk)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: [a.episodeID],
title: a.title,
parent: {
title: a.seasonTitle,
season: a.seasonNumber
},
image: a.image,
e: a.episodeID,
episode: a.epsiodeNumber,
};
}));
return true;
}
public async search(data: SearchData): Promise<SearchResponse> {
console.debug(`Got search options: ${JSON.stringify(data)}`);
const funiSearch = await this.funi.searchShow(false, data);
if (!funiSearch.isOk)
return funiSearch;
return { isOk: true, value: funiSearch.value.items.hits.map(a => ({
image: a.image.showThumbnail,
name: a.title,
desc: a.description,
id: a.id,
lang: a.languages,
rating: a.starRating
})) };
}
public async checkToken(): Promise<CheckTokenResponse> {
return this.funi.checkToken();
}
public auth(data: AuthData) {
return this.funi.auth(data);
}
public async downloadItem(data: QueueItem) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { all: false, but: false, id: parseInt(data.id), e: data.e });
const _default = yargs.appArgv(this.funi.cfg.cli, true);
if (!res.isOk)
return this.alertError(res.reason);
for (const ep of res.value) {
await this.funi.getEpisode(false, { dubLang: data.dubLang, fnSlug: ep, s: data.id, subs: { dlsubs: data.dlsubs, sub: false, ccTag: _default.ccTag } }, { ..._default, callbackMaker: this.makeProgressHandler.bind(this), ass: true, fileName: data.fileName, q: data.q, force: 'y',
noaudio: data.noaudio, novids: data.novids });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
export default FunimationHandler;

View file

@ -13,10 +13,11 @@ class HidiveHandler extends Base implements MessageHandler {
constructor(ws: WebSocketHandler) {
super(ws);
this.hidive = new Hidive();
this.hidive.doInit();
this.initState();
}
public async auth(data: AuthData) {
public auth(data: AuthData) {
return this.hidive.doAuth(data);
}
@ -41,7 +42,7 @@ class HidiveHandler extends Base implements MessageHandler {
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.new_hd_locale)
if (language.hd_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
@ -50,7 +51,7 @@ class HidiveHandler extends Base implements MessageHandler {
public async availableSubCodes(): Promise<string[]> {
const subLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.new_hd_locale)
if (language.hd_locale)
subLanguageCodesArray.push(language.locale);
}
return ['all', 'none', ...new Set(subLanguageCodesArray)];
@ -61,21 +62,21 @@ 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.selectSeries(parseInt(data.id), data.e, data.but, data.all);
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.title,
ids: [item.Id],
title: item.Name,
parent: {
title: item.seriesTitle,
season: item.episodeInformation.seasonNumber+''
season: parseFloat(item.SeasonNumberValue+'')+''
},
image: item.thumbnailUrl,
e: item.episodeInformation.episodeNumber+'',
episode: item.episodeInformation.episodeNumber+'',
image: item.ScreenShotSmallUrl,
e: parseFloat(item.EpisodeNumberValue+'')+'',
episode: parseFloat(item.EpisodeNumberValue+'')+'',
};
}));
return true;
@ -85,22 +86,23 @@ class HidiveHandler extends Base implements MessageHandler {
const parse = parseInt(id);
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
const request = await this.hidive.listSeries(parse);
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.map(function(item) {
const description = item.description.split('\r\n');
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: item.episodeInformation.episodeNumber+'',
lang: [],
name: item.title,
season: item.episodeInformation.seasonNumber+'',
seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1]?.title ?? request.series.title,
episode: item.episodeInformation.episodeNumber+'',
id: item.id+'',
img: item.thumbnailUrl,
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: ''
};
@ -111,12 +113,12 @@ class HidiveHandler extends Base implements MessageHandler {
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.selectSeries(parseInt(data.id), data.e, false, false);
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.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 });
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});
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);

View file

@ -21,12 +21,12 @@ export default class WebSocketHandler {
public events: ExternalEvent = new ExternalEvent();
constructor(server: Server) {
this.wsServer = new ws.WebSocketServer({ noServer: true, path: '/private' });
this.wsServer = new ws.WebSocketServer({ noServer: true, path: '/ws' });
this.wsServer.on('connection', (socket, req) => {
console.info(`[WS] Connection from '${req.socket.remoteAddress}'`);
socket.on('error', (er) => console.error(`[WS] ${er}`));
socket.on('message', (data) => {
socket.on('message', (data) => {
const json = JSON.parse(data.toString()) as UnknownWSMessage;
this.events.emit(json.name, json as any, (data) => {
this.wsServer.clients.forEach(client => {
@ -88,7 +88,7 @@ export class PublicWebSocket {
this.wsServer.on('connection', (socket, req) => {
console.info(`[WS] Connection to public ws from '${req.socket.remoteAddress}'`);
socket.on('error', (er) => console.error(`[WS] ${er}`));
socket.on('message', (msg) => {
socket.on('message', (msg) => {
const data = JSON.parse(msg.toString()) as UnknownWSMessage;
switch (data.name) {
case 'isSetup':
@ -120,4 +120,4 @@ export class PublicWebSocket {
console.error(`[WS] ${er}`);
});
}
}
}

1375
hidive.ts

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,15 @@ import update from './modules/module.updater';
}
if (argv.addArchive) {
if (argv.service === 'crunchy') {
if (argv.service === 'funi') {
if (argv.s === undefined)
return console.error('`-s` not found');
addToArchive({
service: 'funi',
type: 's'
}, argv.s);
console.info('Added %s to the downloadArchive list', argv.s);
} else if (argv.service === 'crunchy') {
if (argv.s === undefined && argv.series === undefined)
return console.error('`-s` or `--srz` not found');
if (argv.s && argv.series)
@ -37,15 +45,6 @@ import update from './modules/module.updater';
type: 's'
}, (argv.s === undefined ? argv.series : argv.s) as string);
console.info('Added %s to the downloadArchive list', (argv.s === undefined ? argv.series : argv.s));
} else if (argv.service === 'ao') {
if (argv.s === undefined)
return console.error('`-s` not found');
addToArchive({
service: 'hidive',
//type: argv.s === undefined ? 'srz' : 's'
type: 's'
}, (argv.s === undefined ? argv.series : argv.s) as string);
console.info('Added %s to the downloadArchive list', (argv.s === undefined ? argv.series : argv.s));
}
} else if (argv.downloadArchive) {
const ids = makeCommand(argv.service);
@ -53,48 +52,14 @@ import update from './modules/module.updater';
overrideArguments(cfg.cli, id);
/* Reimport module to override appArgv */
Object.keys(require.cache).forEach(key => {
if (key.endsWith('crunchy.js') || key.endsWith('hidive.js') || key.endsWith('ao.js'))
if (key.endsWith('crunchy.js') || key.endsWith('funi.js') || key.endsWith('hidive.js'))
delete require.cache[key];
});
let service: ServiceClass;
switch(argv.service) {
case 'crunchy':
service = new (await import('./crunchy')).default;
break;
case 'hidive':
service = new (await import('./hidive')).default;
break;
case 'ao':
service = new (await import('./ao')).default;
break;
case 'adn':
service = new (await import('./adn')).default;
break;
default:
service = new (await import(`./${argv.service}`)).default;
break;
}
const service = new (argv.service === 'funi' ? (await import('./funi')).default : argv.service === 'hidive' ? (await import('./hidive')).default : (await import('./crunchy')).default)(argv.debug) as ServiceClass;
await service.cli();
}
} else {
let service: ServiceClass;
switch(argv.service) {
case 'crunchy':
service = new (await import('./crunchy')).default;
break;
case 'hidive':
service = new (await import('./hidive')).default;
break;
case 'ao':
service = new (await import('./ao')).default;
break;
case 'adn':
service = new (await import('./adn')).default;
break;
default:
service = new (await import(`./${argv.service}`)).default;
break;
}
const service = new (argv.service === 'funi' ? (await import('./funi')).default : argv.service === 'hidive' ? (await import('./hidive')).default : (await import('./crunchy')).default)(argv.debug) as ServiceClass;
await service.cli();
}
})();

View file

@ -3,22 +3,19 @@ import fs from 'fs';
import path from 'path';
import { args, groups } from './module.args';
const transformService = (str: Array<'crunchy'|'hidive'|'ao'|'adn'|'all'>) => {
const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'all'>) => {
const services: string[] = [];
str.forEach(function(part) {
switch(part) {
case 'funi':
services.push('Funimation');
break;
case 'crunchy':
services.push('Crunchyroll');
break;
case 'hidive':
services.push('Hidive');
break;
case 'ao':
services.push('AnimeOnegai');
break;
case 'adn':
services.push('AnimationDigitalNetwork');
break;
case 'all':
services.push('All');
break;
@ -29,11 +26,11 @@ const transformService = (str: Array<'crunchy'|'hidive'|'ao'|'adn'|'all'>) => {
let docs = `# ${packageJSON.name} (${packageJSON.version}v)
If you find any bugs in this documentation or in the program itself please report it [over on GitHub](${packageJSON.bugs.url}).
If you find any bugs in this documentation or in the programm itself please report it [over on GitHub](${packageJSON.bugs.url}).
## Legal Warning
This application is not endorsed by or affiliated with *Crunchyroll*, *Hidive*, *AnimeOnegai*, or *AnimationDigitalNetwork*.
This application is not endorsed by or affiliated with *Funimation* or *Crunchyroll*.
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.

View file

@ -5,13 +5,11 @@ import modulesCleanup from 'removeNPMAbsolutePaths';
import { exec } from '@yao-pkg/pkg';
import { execSync } from 'child_process';
import { console } from './log';
import esbuild from 'esbuild';
import path from 'path';
const buildsDir = './_builds';
const nodeVer = 'node20-';
const nodeVer = 'node18-';
type BuildTypes = `${'windows'|'macos'|'linux'|'linuxstatic'|'alpine'}-${'x64'|'arm64'}`|'linuxstatic-armv7'
type BuildTypes = `${'ubuntu'|'windows'|'macos'|'arm'}64`
(async () => {
const buildType = process.argv[2] as BuildTypes;
@ -23,57 +21,25 @@ type BuildTypes = `${'windows'|'macos'|'linux'|'linuxstatic'|'alpine'}-${'x64'|'
// main
async function buildBinary(buildType: BuildTypes, gui: boolean) {
const buildStr = 'multi-downloader-nx';
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);
}
}
const acceptableBuilds = ['windows64','ubuntu64','macos64'];
if(!acceptableBuilds.includes(buildType)){
console.error('Unknown build type!');
console.error('[ERROR] unknown build type!');
process.exit(1);
}
await modulesCleanup('.');
if(!fs.existsSync(buildsDir)){
fs.mkdirSync(buildsDir);
}
const buildFull = `${buildStr}-${getFriendlyName(buildType)}-${gui ? 'gui' : 'cli'}`;
const buildFull = `${buildStr}-${buildType}-${gui ? 'gui' : 'cli'}`;
const buildDir = `${buildsDir}/${buildFull}`;
if(fs.existsSync(buildDir)){
fs.removeSync(buildDir);
}
fs.mkdirSync(buildDir);
console.info('Running esbuild');
const build = await esbuild.build({
entryPoints: [
gui ? 'gui.js' : 'index.js',
],
sourceRoot: './',
bundle: true,
platform: 'node',
format: 'cjs',
treeShaking: true,
// External source map for debugging
sourcemap: true,
// Minify and keep the original names
minify: true,
keepNames: true,
outfile: path.join(buildsDir, 'index.cjs'),
metafile: true,
external: ['cheerio']
});
if (build.errors?.length > 0) console.error(build.errors);
if (build.warnings?.length > 0) console.warn(build.warnings);
const buildConfig = [
`${buildsDir}/index.cjs`,
'--target', nodeVer + buildType,
gui ? 'gui.js' : 'index.js',
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}`,
'--compress', 'GZip'
];
console.info(`[Build] Build configuration: ${buildFull}`);
try {
@ -85,7 +51,6 @@ 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`);
@ -105,12 +70,15 @@ async function buildBinary(buildType: BuildTypes, gui: boolean) {
execSync(`7z a -t7z "${buildsDir}/${buildFull}.7z" "${buildDir}"`,{stdio:[0,1,2]});
}
function getFriendlyName(buildString: string): string {
if (buildString.includes('armv7')) {
return 'android';
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';
}
if (buildString.includes('linuxstatic')) {
buildString = buildString.replace('linuxstatic', 'linux');
}
return buildString;
}
}

View file

@ -1,114 +0,0 @@
//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;
export type M3U8Json = {
type M3U8Json = {
segments: Record<string, unknown>[],
mediaSequence?: number,
}
@ -421,12 +421,6 @@ const extFn = {
if ((options.url.hostname as string).match('hidive')) {
options.headers['referrer'] = 'https://www.hidive.com/';
options.headers['origin'] = 'https://www.hidive.com';
} else if ((options.url.hostname as string).includes('animecdn')) {
options.headers = {
origin: 'https://www.animeonegai.com',
referer: 'https://www.animeonegai.com/',
range: options.headers['range']
};
}
// console.log(' - Req:', options.url.pathname);
}

View file

@ -1,161 +0,0 @@
//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

@ -1,749 +0,0 @@
//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;
}

File diff suppressed because it is too large Load diff

View file

@ -16,12 +16,9 @@ const makeLogFolder = () => {
};
const makeLogger = () => {
global.console.log =
global.console.info =
global.console.warn =
global.console.error =
global.console.debug = (...data: any[]) => {
console.info((data.length >= 1 ? data.shift() : ''), ...data);
const oldLog = global.console.log;
global.console.log = (data) => {
oldLog(`Unexpected use of console.log. Use the log4js logger instead. ${data}`);
};
makeLogFolder();
log4js.configure({

View file

@ -1,3 +1,5 @@
import { Headers } from 'got/dist/source';
// api domains
const domain = {
www: 'https://www.crunchyroll.com',
@ -5,8 +7,7 @@ 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_new: 'https://dce-frontoffice.imggaming.com'
hd_api: 'https://api.hidive.com'
};
export type APIType = {
@ -24,37 +25,22 @@ export type APIType = {
collections: string
// beta api
beta_auth: string
authBasic: string
authBasicMob: string
authBasicSwitch: string,
beta_authBasic: string
beta_authBasicMob: string
beta_profile: string
beta_cmsToken: string
search: string
cms: string
beta_browse: string
beta_cms: string,
drm: string;
/**
* Web Header
*/
crunchyAuthHeader: Record<string, string>,
/**
* Mobile Header
*/
crunchyAuthHeaderMob: Record<string, string>,
/**
* Switch Header
*/
crunchyAuthHeaderSwitch: Record<string, string>,
beta_authHeader: Headers,
beta_authHeaderMob: Headers,
hd_apikey: string,
hd_devName: string,
hd_appId: string,
hd_clientWeb: string,
hd_clientExo: string,
hd_api: string,
hd_new_api: string,
hd_new_apiKey: string,
hd_new_version: string,
}
// api urls
@ -74,19 +60,16 @@ const api: APIType = {
collections: `${domain.api}/list_collections.0.json`,
// beta api
beta_auth: `${domain.api_beta}/auth/v1/token`,
authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
authBasicMob: 'Basic dXU4aG0wb2g4dHFpOWV0eXl2aGo6SDA2VnVjRnZUaDJ1dEYxM0FBS3lLNE85UTRhX3BlX1o=',
authBasicSwitch: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=',
beta_authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
beta_authBasicMob: 'Basic b2VkYXJteHN0bGgxanZhd2ltbnE6OWxFaHZIWkpEMzJqdVY1ZFc5Vk9TNTdkb3BkSnBnbzE=',
beta_profile: `${domain.api_beta}/accounts/v1/me/profile`,
beta_cmsToken: `${domain.api_beta}/index/v2`,
search: `${domain.api_beta}/content/v2/discover/search`,
cms: `${domain.api_beta}/content/v2/cms`,
beta_browse: `${domain.api_beta}/content/v1/browse`,
beta_cms: `${domain.api_beta}/cms/v2`,
drm: `${domain.api_beta}/drm/v1/auth`,
crunchyAuthHeader: {},
crunchyAuthHeaderMob: {},
crunchyAuthHeaderSwitch: {},
beta_authHeader: {},
beta_authHeaderMob: {},
//hidive API
hd_apikey: '508efd7b42d546e19cc24f4d0b414e57e351ca73',
hd_devName: 'Android',
@ -94,24 +77,14 @@ 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
api.crunchyAuthHeader = {
Authorization: api.authBasic,
api.beta_authHeader = {
Authorization: api.beta_authBasic,
};
api.crunchyAuthHeaderMob = {
Authorization: api.authBasicMob,
'user-agent': 'Crunchyroll/3.60.0 Android/9 okhttp/4.12.0'
};
api.crunchyAuthHeaderSwitch = {
Authorization: api.authBasicSwitch,
api.beta_authHeaderMob = {
Authorization: api.beta_authBasicMob,
};
export {

View file

@ -1,11 +1,6 @@
import yargs, { Choices } from 'yargs';
import { args, AvailableMuxer, groups } from './module.args';
import { LanguageItem } from './module.langsData';
import { DownloadInfo } from '../@types/messageHandler';
import { HLSCallback } from './hls-download';
import leven from 'leven';
import { console } from './log';
import { CrunchyPlayStreams } from '../@types/enums';
let argvC: {
[x: string]: unknown;
@ -22,7 +17,6 @@ let argvC: {
forceMuxer: AvailableMuxer|undefined;
username: string|undefined,
password: string|undefined,
token: string|undefined,
silentAuth: boolean,
skipSubMux: boolean,
downloadArchive: boolean,
@ -33,18 +27,16 @@ let argvC: {
search: string | undefined;
'search-type': string;
page: number | undefined;
locale: string;
'search-locale': string;
new: boolean | undefined;
'movie-listing': string | undefined;
series: string | undefined;
s: string | undefined;
srz: string | undefined;
s: string | undefined;
e: string | undefined;
extid: string | undefined;
q: number;
x: number;
kstream: number;
cstream: keyof typeof CrunchyPlayStreams | 'none';
kstream: number;
partsize: number;
hslang: string;
dlsubs: string[];
@ -54,7 +46,6 @@ let argvC: {
dubLang: string[];
all: boolean;
fontSize: number;
combineLines: boolean;
allDubs: boolean;
timeout: number;
waittime: number;
@ -67,19 +58,16 @@ let argvC: {
debug: boolean | undefined;
nocleanup: boolean;
help: boolean | undefined;
service: 'crunchy' | 'hidive' | 'ao' | 'adn';
service: 'funi' | 'crunchy' | 'hidive';
update: boolean;
fontName: string | undefined;
_: (string | number)[];
$0: string;
dlVideoOnce: boolean;
chapters: boolean;
crapi: 'android' | 'web';
removeBumpers: boolean;
originalFontSize: boolean;
keepAllVideos: boolean;
syncTiming: boolean;
callbackMaker?: (data: DownloadInfo) => HLSCallback;
};
export type ArgvType = typeof argvC;
@ -117,15 +105,15 @@ const getArgv = (cfg: { [key:string]: unknown }, isGUI: boolean) => {
return cfg[key] as T;
} else
return _default;
};
};
const argv = yargs.parserConfiguration({
'duplicate-arguments-array': false,
'camel-case-expansion': false,
})
.wrap(yargs.terminalWidth())
.usage('Usage: $0 [options]')
.help(true);
//.strictOptions()
.help(true).version(false);
const data = args.map(a => {
return {
...a,
@ -147,31 +135,7 @@ const getArgv = (cfg: { [key:string]: unknown }, isGUI: boolean) => {
},
choices: item.name === 'service' && isGUI ? undefined : item.choices as unknown as Choices
});
// Custom logic for suggesting corrections for misspelled options
argv.middleware((argv: Record<string, any>) => {
// List of valid options
const validOptions = [
...args.map(a => a.name),
...args.map(a => a.alias).filter(alias => alias !== undefined) as string[]
];
const unknownOptions = Object.keys(argv).filter(key => !validOptions.includes(key) && key !== '_' && key !== '$0'); // Filter out known options
const suggestedOptions: Record<string, boolean> = {};
unknownOptions.forEach(actualOption => {
const closestOption = validOptions.find(option => {
const levenVal = leven(option, actualOption);
return levenVal <= 2 && levenVal > 0;
});
if (closestOption && !suggestedOptions[closestOption]) {
suggestedOptions[closestOption] = true;
console.info(`Unknown option ${actualOption}, did you mean ${closestOption}?`);
} else if (!suggestedOptions[actualOption]) {
suggestedOptions[actualOption] = true;
console.info(`Unknown option ${actualOption}`);
}
});
});
return argv as unknown as yargs.Argv<typeof argvC>;
};
};

View file

@ -1,5 +1,4 @@
import { aoSearchLocales, dubLanguageCodes, languages, searchLocales, subtitleLanguagesFilter } from './module.langsData';
import { CrunchyPlayStreams } from '../@types/enums';
import { dubLanguageCodes, languages, searchLocales, subtitleLanguagesFilter } from './module.langsData';
const groups = {
'auth': 'Authentication:',
@ -14,13 +13,12 @@ const groups = {
'gui': 'GUI:'
};
export type AvailableFilenameVars = 'title' | 'episode' | 'showTitle' | 'seriesTitle' | 'season' | 'width' | 'height' | 'service'
export type AvailableFilenameVars = 'title' | 'episode' | 'showTitle' | 'season' | 'width' | 'height' | 'service'
const availableFilenameVars: AvailableFilenameVars[] = [
'title',
'episode',
'showTitle',
'seriesTitle',
'season',
'width',
'height',
@ -30,7 +28,7 @@ const availableFilenameVars: AvailableFilenameVars[] = [
export type AvailableMuxer = 'ffmpeg' | 'mkvmerge'
export const muxer: AvailableMuxer[] = [ 'ffmpeg', 'mkvmerge' ];
export type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
name: string,
group: keyof typeof groups,
type: 'boolean'|'string'|'number'|'array',
@ -42,7 +40,7 @@ export type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
default: T|undefined,
name?: string
},
service: Array<'crunchy'|'hidive'|'ao'|'adn'|'all'>,
service: Array<'funi'|'crunchy'|'hidive'|'all'>,
usage: string // -(-)${name} will be added for each command,
demandOption?: true,
transformer?: (value: T) => K
@ -104,16 +102,16 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
usage: '${page}'
},
{
name: 'locale',
describe: 'Set the service locale',
docDescribe: 'Set the local that will be used for the API.',
name: 'search-locale',
describe: 'Set the search locale',
docDescribe: 'Set the search local that will be used for searching for items.',
group: 'search',
choices: ([...searchLocales.filter(a => a !== undefined), ...aoSearchLocales.filter(a => a !== undefined)] as string[]),
choices: (searchLocales.filter(a => a !== undefined) as string[]),
default: {
default: 'en-US'
default: ''
},
type: 'string',
service: ['crunchy', 'ao', 'adn'],
service: ['crunchy'],
usage: '${locale}'
},
{
@ -140,7 +138,8 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
alias: 'srz',
describe: 'Get season list by series ID',
docDescribe: 'Requested is the ID of a show not a season.',
docDescribe: 'This command is used only for crunchyroll.'
+ '\n Requested is the ID of a show not a season.',
service: ['crunchy'],
type: 'string',
usage: '${ID}'
@ -194,7 +193,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Download only once the video with the best selected quality',
type: 'boolean',
group: 'dl',
service: ['crunchy', 'ao'],
service: ['crunchy'],
docDescribe: 'If selected, the best selected quality will be downloaded only for the first language,'
+ '\nthen the worst video quality with the same audio quality will be downloaded for every other language.'
+ '\nBy the later merge of the videos, no quality difference will be present.'
@ -204,33 +203,6 @@ 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', 'adn'],
docDescribe: 'Will fetch the chapters and add them into the final video.'
+ '\nCurrently only works with mkvmerge.',
usage: '',
default: {
default: true
}
},
{
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: 'web'
}
},
{
name: 'removeBumpers',
describe: 'Remove bumpers from final video',
@ -267,7 +239,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
type: 'number',
alias: 'server',
docDescribe: true,
service: ['crunchy'],
service: ['crunchy','funi'],
usage: '${server}'
},
{
@ -277,27 +249,13 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Select specific stream',
choices: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
default: {
default: 1
default: 2
},
docDescribe: true,
service: ['crunchy'],
type: 'number',
usage: '${stream}'
},
{
name: 'cstream',
group: 'dl',
alias: 'cs',
service: ['crunchy'],
type: 'string',
describe: 'Select specific crunchy play stream by device, or disable stream with "none"',
choices: [...Object.keys(CrunchyPlayStreams), 'none'],
default: {
default: 'chrome'
},
docDescribe: true,
usage: '${device}'
},
{
name: 'hslang',
group: 'dl',
@ -315,7 +273,8 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
name: 'dlsubs',
group: 'dl',
describe: 'Download subtitles by language tag (space-separated)'
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.locale).join(', ')}`,
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.locale).join(', ')}`
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.locale).join(', ')}`,
docDescribe: true,
service: ['all'],
type: 'array',
@ -339,7 +298,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
describe: 'Skip downloading audio',
docDescribe: true,
service: ['crunchy', 'hidive'],
service: ['funi'],
type: 'boolean',
usage: ''
},
@ -355,7 +314,8 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
{
name: 'dubLang',
describe: 'Set the language to download: '
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.code).join(', ')}`,
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.code).join(', ')}`
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.code).join(', ')}`,
docDescribe: true,
group: 'dl',
choices: dubLanguageCodes,
@ -384,22 +344,12 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: {
default: 55
},
docDescribe: 'When converting the subtitles to ass, this will change the font size'
+ '\nIn most cases, requires "--originaFontSize false" to take effect',
docDescribe: true,
group: 'dl',
service: ['all'],
type: 'number',
usage: '${fontSize}'
},
{
name: 'combineLines',
describe: 'Merge adjacent lines with same style and text',
docDescribe: 'If selected, will prevent a line from shifting downwards',
group: 'dl',
service: ['hidive'],
type: 'boolean',
usage: ''
},
{
name: 'allDubs',
describe: 'If selected, all available dubs will get downloaded',
@ -438,7 +388,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
describe: 'Force downloading simulcast version instead of uncut version (if available).',
docDescribe: true,
service: ['hidive'],
service: ['funi', 'hidive'],
type: 'boolean',
usage: '',
default: {
@ -571,7 +521,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'util',
service: ['all'],
type: 'string',
choices: ['crunchy', 'hidive', 'ao', 'adn'],
choices: ['funi', 'crunchy', 'hidive'],
usage: '${service}',
default: {
default: ''
@ -592,7 +542,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'fonts',
describe: 'Set the font to use in subtiles',
docDescribe: true,
service: ['hidive', 'adn'],
service: ['funi', 'hidive'],
type: 'string',
usage: '${fontName}',
},
@ -676,25 +626,13 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Authenticate every time the script runs. Use at your own risk.',
docDescribe: true,
group: 'auth',
service: ['crunchy'],
service: ['funi','crunchy'],
type: 'boolean',
usage: '',
default: {
default: false
}
},
{
name: 'token',
describe: 'Allows you to login with your token (Example on crunchy is Refresh Token/etp-rt cookie)',
docDescribe: true,
group: 'auth',
service: ['crunchy', 'ao'],
type: 'string',
usage: '${token}',
default: {
default: undefined
}
},
{
name: 'forceMuxer',
describe: 'Force the program to use said muxer or don\'t mux if the given muxer is not present',
@ -880,6 +818,7 @@ const buildDefault = () => {
};
export {
TAppArg,
getDefault,
buildDefault,
args,

View file

@ -18,18 +18,15 @@ const guiCfgFile = path.join(workingDir, 'config', 'gui');
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile');
const sessCfgFile = {
funi: path.join(workingDir, 'config', 'funi_sess'),
cr: path.join(workingDir, 'config', 'cr_sess'),
hd: path.join(workingDir, 'config', 'hd_sess'),
ao: path.join(workingDir, 'config', 'ao_sess'),
adn: path.join(workingDir, 'config', 'adn_sess')
hd: path.join(workingDir, 'config', 'hd_sess')
};
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'),
hdNew:path.join(workingDir, 'config', 'hd_new_token'),
ao: path.join(workingDir, 'config', 'ao_token'),
adn: path.join(workingDir, 'config', 'adn_token')
hd: path.join(workingDir, 'config', 'hd_token')
};
export const ensureConfig = () => {
@ -85,8 +82,7 @@ export type ConfigObject = {
bin: {
ffmpeg?: string,
mkvmerge?: string,
ffprobe?: string,
mp4decrypt?: string
ffprobe?: string
},
cli: {
[key: string]: any
@ -150,8 +146,7 @@ const loadBinCfg = async () => {
const defaultBin = {
ffmpeg: 'ffmpeg',
mkvmerge: 'mkvmerge',
ffprobe: 'ffprobe',
mp4decrypt: 'mp4decrypt'
ffprobe: 'ffprobe'
};
const keys = Object.keys(defaultBin) as (keyof typeof defaultBin)[];
for(const dir of keys){
@ -217,44 +212,7 @@ const saveCRToken = (data: Record<string, unknown>) => {
console.error('Can\'t save token file to disk!');
}
};
const loadADNToken = () => {
let token = loadYamlCfgFile(tokenFile.adn, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveADNToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.adn);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.adn}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadAOToken = () => {
let token = loadYamlCfgFile(tokenFile.ao, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveAOToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.ao);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.ao}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadHDSession = () => {
let session = loadYamlCfgFile(sessCfgFile.hd, true);
@ -282,7 +240,7 @@ const saveHDSession = (data: Record<string, unknown>) => {
const loadHDToken = () => {
let token = loadYamlCfgFile(tokenFile.hd, true);
let token = loadYamlCfgFile(tokenFile.cr, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
@ -332,19 +290,27 @@ const loadHDProfile = () => {
return profile;
};
const loadNewHDToken = () => {
let token = loadYamlCfgFile(tokenFile.hdNew, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
const loadFuniToken = () => {
const loadedToken = loadYamlCfgFile<{
token?: string
}>(tokenFile.funi, true);
let token: false|string = false;
if (loadedToken && loadedToken.token)
token = loadedToken.token;
// info if token not set
if(!token){
console.info('[INFO] Token not set!\n');
}
return token;
};
const saveNewHDToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.hdNew);
const saveFuniToken = (data: {
token?: string
}) => {
const cfgFolder = path.dirname(tokenFile.funi);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.hdNew}.yml`, yaml.stringify(data));
fs.writeFileSync(`${tokenFile.funi}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
@ -385,22 +351,18 @@ const setState = (state: GuiState) => {
export {
loadBinCfg,
loadCfg,
loadFuniToken,
saveFuniToken,
saveCRSession,
loadCRSession,
saveCRToken,
loadCRToken,
saveADNToken,
loadADNToken,
saveHDSession,
loadHDSession,
saveHDToken,
loadHDToken,
saveNewHDToken,
loadNewHDToken,
saveHDProfile,
loadHDProfile,
saveAOToken,
loadAOToken,
getState,
setState,
writeYamlCfgFile,

View file

@ -11,15 +11,12 @@ export type ItemType = {
}[]
export type DataType = {
funi: {
s: ItemType
},
hidive: {
s: ItemType
},
ao: {
s: ItemType
},
adn: {
s: ItemType
},
crunchy: {
srz: ItemType,
s: ItemType
@ -27,17 +24,14 @@ export type DataType = {
}
const addToArchive = (kind: {
service: 'funi',
type: 's'
} | {
service: 'crunchy',
type: 's'|'srz'
} | {
service: 'hidive',
type: 's'
} | {
service: 'ao',
type: 's'
} | {
service: 'adn',
type: 's'
}, ID: string) => {
const data = loadData();
@ -51,8 +45,8 @@ const addToArchive = (kind: {
});
(data as any)[kind.service][kind.type] = items;
} else {
if (kind.service === 'ao') {
data['ao'] = {
if (kind.service === 'funi') {
data['funi'] = {
s: [
{
id: ID,
@ -71,15 +65,6 @@ const addToArchive = (kind: {
already: [] as string[]
} : []),
};
} else if (kind.service === 'adn') {
data['adn'] = {
s: [
{
id: ID,
already: []
}
]
};
} else {
data['hidive'] = {
s: [
@ -95,17 +80,14 @@ const addToArchive = (kind: {
};
const downloaded = (kind: {
service: 'funi',
type: 's'
} | {
service: 'crunchy',
type: 's'|'srz'
} | {
service: 'hidive',
type: 's'
} | {
service: 'ao',
type: 's'
} | {
service: 'adn',
type: 's'
}, ID: string, episode: string[]) => {
let data = loadData();
if (!Object.prototype.hasOwnProperty.call(data, kind.service) || !Object.prototype.hasOwnProperty.call(data[kind.service], kind.type)
@ -123,7 +105,7 @@ const downloaded = (kind: {
fs.writeFileSync(archiveFile, JSON.stringify(data, null, 4));
};
const makeCommand = (service: 'crunchy'|'hidive'|'ao'|'adn') : Partial<ArgvType>[] => {
const makeCommand = (service: 'funi'|'crunchy'|'hidive') : Partial<ArgvType>[] => {
const data = loadData();
const ret: Partial<ArgvType>[] = [];
const kind = data[service];

View file

@ -1,146 +0,0 @@
import * as yamlCfg from './module.cfg-loader';
import { console } from './log';
import { Method } from 'got';
export type Params = {
method?: Method,
headers?: Record<string, string>,
body?: string | Buffer,
binary?: boolean,
followRedirect?: 'follow' | 'error' | 'manual'
}
// req
export class Req {
private sessCfg: string;
private service: 'cr'|'hd'|'ao'|'adn';
private session: Record<string, {
value: string;
expires: Date;
path: string;
domain: string;
secure: boolean;
'Max-Age'?: string
}> = {};
private cfgDir = yamlCfg.cfgDir;
private curl: boolean|string = false;
constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'hd'|'ao'|'adn') {
this.sessCfg = yamlCfg.sessCfgFile[type];
this.service = type;
}
async getData(durl: string, params?: RequestInit) {
params = params || {};
// options
const options: RequestInit = {
method: params.method ? params.method : 'GET',
headers: {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'accept-language': 'en-US,en;q=0.9',
'cache-control': 'no-cache',
'pragma': 'no-cache',
'sec-ch-ua': '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
'sec-fetch-user': '?1',
'upgrade-insecure-requests': '1',
},
};
// additional params
if(params.headers){
options.headers = {...options.headers, ...params.headers};
}
if(options.method == 'POST'){
if (!(options.headers as Record<string, string>)['Content-Type']) {
(options.headers as Record<string, string>)['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
if(params.body){
options.body = params.body;
}
if(typeof params.redirect == 'string'){
options.redirect = params.redirect;
}
// debug
if(this.debug){
console.debug('[DEBUG] FETCH OPTIONS:');
console.debug(options);
}
// try do request
try {
const res = await fetch(durl, options);
if (!res.ok) {
console.error(`${res.status}: ${res.statusText}`);
const body = await res.text();
const docTitle = body.match(/<title>(.*)<\/title>/);
if(body && docTitle){
console.error(docTitle[1]);
} else {
console.error(body);
}
}
return {
ok: res.ok,
res
};
}
catch(_error){
const error = _error as {
name: string
} & TypeError & {
res: Response
};
if (error.res && error.res.status && error.res.statusText) {
console.error(`${error.name} ${error.res.status}: ${error.res.statusText}`);
} else {
console.error(`${error.name}: ${error.res?.statusText || error.message}`);
}
if(error.res) {
const body = await error.res.text();
const docTitle = body.match(/<title>(.*)<\/title>/);
if(body && docTitle){
console.error(docTitle[1]);
}
}
return {
ok: false,
error,
};
}
}
}
export function buildProxy(proxyBaseUrl: string, proxyAuth: string){
if(!proxyBaseUrl.match(/^(https?|socks4|socks5):/)){
proxyBaseUrl = 'http://' + proxyBaseUrl;
}
const proxyCfg = new URL(proxyBaseUrl);
let proxyStr = `${proxyCfg.protocol}//`;
if(typeof proxyCfg.hostname != 'string' || proxyCfg.hostname == ''){
throw new Error('[ERROR] Hostname and port required for proxy!');
}
if(proxyAuth && typeof proxyAuth == 'string' && proxyAuth.match(':')){
proxyCfg.username = proxyAuth.split(':')[0];
proxyCfg.password = proxyAuth.split(':')[1];
proxyStr += `${proxyCfg.username}:${proxyCfg.password}@`;
}
proxyStr += proxyCfg.hostname;
if(!proxyCfg.port && proxyCfg.protocol == 'http:'){
proxyStr += ':80';
}
else if(!proxyCfg.port && proxyCfg.protocol == 'https:'){
proxyStr += ':443';
}
return proxyStr;
}

View file

@ -31,7 +31,7 @@ const parseFileName = (input: string, variables: Variable[], numbers: number, ov
if (use.type === 'number') {
const len = use.replaceWith.toFixed(0).length;
const replaceStr = len < numbers ? '0'.repeat(numbers - len) + use.replaceWith : use.replaceWith+'';
const replaceStr = len < numbers ? '0'.repeat(numbers - len) + use.replaceWith : use.replaceWith.toFixed(0);
input = input.replace(type, replaceStr);
} else {
if (use.sanitize)

View file

@ -35,10 +35,6 @@ function assFonts(ass: string){
styles.push(addStyle[1]);
}
}
const fontMatches = ass.matchAll(/\\fn([^\\}]+)/g);
for (const match of fontMatches) {
styles.push(match[1]);
}
return [...new Set(styles)];
}

View file

@ -3,45 +3,43 @@
export type LanguageItem = {
cr_locale?: string,
hd_locale?: string,
adn_locale?: string,
new_hd_locale?: string,
ao_locale?: string,
locale: string,
code: string,
name: string,
language?: string
language?: string,
funi_locale?: string,
funi_name?: string,
funi_name_lagacy?: string
}
const languages: LanguageItem[] = [
{ cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', locale: 'en', code: 'eng', name: 'English' },
{ cr_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', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419',ao_locale: 'es',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-ES', new_hd_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' },
{ cr_locale: 'pt-BR', ao_locale: 'pt',new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ 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-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-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' },
{ cr_locale: 'fr-FR', adn_locale: 'fr', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', adn_locale: 'de', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
{ 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' },
{ cr_locale: 'ar-ME', locale: 'ar', code: 'ara-ME', name: 'Arabic' },
{ cr_locale: 'ar-SA', hd_locale: 'Arabic', locale: 'ar', code: 'ara', name: 'Arabic (Saudi Arabia)' },
{ cr_locale: 'it-IT', hd_locale: 'Italian', locale: 'it', code: 'ita', name: 'Italian' },
{ cr_locale: 'ru-RU', hd_locale: 'Russian', locale: 'ru', code: 'rus', name: 'Russian' },
{ cr_locale: 'tr-TR', hd_locale: 'Turkish', locale: 'tr', code: 'tur', name: 'Turkish' },
{ cr_locale: 'hi-IN', locale: 'hi', code: 'hin', name: 'Hindi' },
{ locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' },
{ funi_locale: 'zhMN', locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' },
{ cr_locale: 'zh-CN', locale: 'zh-CN', code: 'zho', name: 'Chinese (Mainland China)' },
{ cr_locale: 'zh-TW', locale: 'zh-TW', code: 'chi', name: 'Chinese (Taiwan)' },
{ cr_locale: 'zh-HK', locale: 'zh-HK', code: 'zh-HK', name: 'Chinese (Hong-Kong)', language: '中文 (粵語)' },
{ cr_locale: 'ko-KR', hd_locale: 'Korean', locale: 'ko', code: 'kor', name: 'Korean' },
{ cr_locale: 'ca-ES', locale: 'ca-ES', code: 'cat', name: 'Catalan' },
{ cr_locale: 'pl-PL', locale: 'pl-PL', code: 'pol', name: 'Polish' },
{ cr_locale: 'th-TH', locale: 'th-TH', code: 'tha', name: 'Thai', language: 'ไทย' },
{ cr_locale: 'ta-IN', locale: 'ta-IN', code: 'tam', name: 'Tamil (India)', language: 'தமிழ்' },
{ cr_locale: 'ms-MY', locale: 'ms-MY', code: 'may', name: 'Malay (Malaysia)', language: 'Bahasa Melayu' },
{ cr_locale: 'vi-VN', locale: 'vi-VN', code: 'vie', name: 'Vietnamese', language: 'Tiếng Việt' },
{ cr_locale: 'id-ID', locale: 'id-ID', code: 'ind', name: 'Indonesian', language: 'Bahasa Indonesia' },
{ cr_locale: 'te-IN', locale: 'te-IN', code: 'tel', name: 'Telugu (India)', language: 'తెలుగు' },
{ cr_locale: 'ja-JP', adn_locale: 'ja', ao_locale: 'ja', hd_locale: 'Japanese', locale: 'ja', code: 'jpn', name: 'Japanese' },
{ cr_locale: 'vi-VN', locale: 'vi-VN', code: 'vie', name: 'Vietnamese', language: 'Tiếng Việt', },
{ cr_locale: 'id-ID', locale: 'id-ID', code: 'ind', name: 'Indonesian', language: 'Bahasa Indonesia', },
{ cr_locale: 'ja-JP', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' },
];
// add en language names
@ -69,11 +67,7 @@ const subtitleLanguagesFilter = (() => {
})();
const searchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1)), ...new Set(languages.map(l => { return l.adn_locale; }).slice(0, -1))];
})();
export const aoSearchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.ao_locale; }).slice(0, -1))];
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1))];
})();
// convert
@ -147,9 +141,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, isSigns?: boolean, format?: string) => {
const subsFile = (fnOutput:string, subsIndex: string, langItem: LanguageItem, isCC: boolean, ccTag: string) => {
subsIndex = (parseInt(subsIndex) + 1).toString().padStart(2, '0');
return `${fnOutput}.${subsIndex}.${langItem.code}.${langItem.language}${isCC ? `.${ccTag}` : ''}${isSigns ? '.signs' : ''}.${format ? format : 'ass'}`;
return `${fnOutput}.${subsIndex}.${langItem.code}.${langItem.language}${isCC ? `.${ccTag}` : ''}.ass`;
};
// construct dub langs const

View file

@ -21,7 +21,6 @@ export type SubtitleInput = {
language: LanguageItem,
file: string,
closedCaption?: boolean,
signs?: boolean,
delay?: number
}
@ -38,7 +37,6 @@ export type MergerOptions = {
onlyVid: MergerInput[],
onlyAudio: MergerInput[],
subtitles: SubtitleInput[],
chapters?: MergerInput[],
ccTag: string,
output: string,
videoTitle?: string,
@ -75,7 +73,7 @@ class Merger {
for (const [vnaIndex, vna] of vnas.entries()) {
const streamInfo = await ffprobe(vna.path, { path: bin.ffprobe as string });
const videoInfo = streamInfo.streams.filter(stream => stream.codec_type == 'video');
vnas[vnaIndex].duration = parseInt(videoInfo[0].duration as string);
vnas[vnaIndex].duration = videoInfo[0].duration;
}
//Sort videoAndAudio streams by duration (shortest first)
vnas.sort((a,b) => {
@ -164,7 +162,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) {
@ -172,9 +170,6 @@ 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}`));
@ -183,7 +178,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.signs === true ? ' Signs' : ''}`
(sub.language.language || sub.language.name) + `${sub.closedCaption === true ? ` ${this.options.ccTag}` : ''}`
}" -metadata:s:s:${subindex} language=${sub.language.code}`)
);
args.push(...this.options.options.ffmpeg);
@ -286,7 +281,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}` : ''}` + `${subObj.signs === true ? ' Signs' : ''}`}"`);
args.push('--track-name', `0:"${(subObj.language.language || subObj.language.name) + `${subObj.closedCaption === true ? ` ${this.options.ccTag}` : ''}`}"`);
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) {
@ -301,7 +296,6 @@ 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);
@ -314,10 +308,6 @@ class Merger {
);
}
if (this.options.chapters && this.options.chapters.length > 0) {
args.push(`--chapters "${this.options.chapters[0].path}"`);
}
return args.join(' ');
};
@ -415,7 +405,6 @@ 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));
}

Some files were not shown because too many files have changed in this diff Show more