Compare commits

..

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

160 changed files with 52019 additions and 30837 deletions

View file

@ -1 +0,0 @@
**/node_modules

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
lib
/videos/*.ts
build

43
.eslintrc.json Normal file
View file

@ -0,0 +1,43 @@
{
"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"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"indent": [
"error",
2
],
"linebreak-style": [
"warn",
"windows"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

View file

@ -1,11 +1,9 @@
name: Bug
description: File a bug report
assignees:
- AnimeDL
- AnidlSupport
labels:
- bug
title: "[BUG]: "
- izu-co
labels: bug
title: "BUG"
body:
- type: markdown
attributes:
@ -37,8 +35,8 @@ body:
label: Type
description: "Please tell us if you are using the gui or the cli version."
options:
- CLI
- GUI
- Cli
- Gui
validations:
required: true
- type: dropdown
@ -47,11 +45,9 @@ body:
label: Service
description: "Please tell us what service the bug occured in."
options:
- Funimation
- Crunchyroll
- Hidive
- AnimationDigitalNetwork
- AnimeOnegai
- All
- Both
- Irrelevant
validations:
required: true
@ -83,7 +79,6 @@ body:
attributes:
label: Console Output
description: "Please paste the console output from the beginning till termination here. If you are using the gui open the log folder under 'Debug > Open Log Folder' in the Menu. Please copy the content of latest.log here."
render: Shell
validations:
required: true
- type: textarea
@ -91,3 +86,4 @@ body:
attributes:
label: Additional Information
description: "Do you have any additional information you can provide?"
render: Text

View file

@ -1,29 +0,0 @@
name: Enhancement
description: Suggest a enhancement or feature
labels:
- enhancement
title: "[Feedback]: "
body:
- type: markdown
attributes:
value: |
Thank you for giving feedback with this program.
This template will guide you through all the information we need.
- type: dropdown
id: programversion
attributes:
label: Type
description: "Is this suggestion for the CLI, GUI, or Both?"
options:
- CLI
- GUI
- Both
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggestion
description: "What is your suggestion?"
validations:
required: true

View file

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

View file

@ -3,6 +3,8 @@ name: auto-documentation
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
documentation:
@ -12,15 +14,13 @@ jobs:
- uses: actions/checkout@v2
with:
ref: ${{ github.head_ref }}
- uses: pnpm/action-setup@v2
- name: Use Node.js 16
uses: actions/setup-node@v2
with:
version: 8.6.6
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- run: pnpm i
- run: pnpm run docs
node-version: 16
cache: 'npm'
- run: npm i
- run: npm run docs
- uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: ${{ github.event.head_commit.message }} + Documentation

View file

@ -1,32 +0,0 @@
# This workflow will build a Node project with Docker
name: build and push docker image
on:
push:
branches: [ master ]
jobs:
build-node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
if: ${{ github.ref == 'refs/heads/master' }}
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@v2.9.0
with:
github-token: ${{ github.token }}
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
"multidl/multi-downloader-nx:latest"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -2,30 +2,29 @@ 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
- uses: pnpm/action-setup@v2
with:
version: 8.6.6
- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: 20
node-version: 16
check-latest: true
- name: Install Node modules
run: |
pnpm install
npm install
- name: Get name and version from package.json
run: |
test -n $(node -p -e "require('./package.json').name") &&
@ -33,34 +32,14 @@ jobs:
echo PACKAGE_NAME=$(node -p -e "require('./package.json').name") >> $GITHUB_ENV &&
echo PACKAGE_VERSION=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV || exit 1
- name: Make build
run: pnpm run build-${{ matrix.build_type }}-${{ matrix.gui }}
run: npm run build-${{ matrix.build_type }}-${{ matrix.gui }}
- name: Upload release
uses: actions/upload-release-asset@v1
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 }}
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@v2.9.0
with:
github-token: ${{ github.token }}
push: true
tags: |
"multidl/multi-downloader-nx:${{ github.event.release.tag_name }}"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -11,29 +11,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
- name: Use Node.js 14
uses: actions/setup-node@v2
with:
version: 8.6.6
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
check-latest: true
- run: pnpm i
node-version: 14
cache: 'npm'
- run: npm i
- run: npx eslint .
test:
needs: eslint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
- name: Use Node.js 14
uses: actions/setup-node@v2
with:
version: 8.6.6
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
check-latest: true
- run: pnpm i
- run: pnpm run test
node-version: 14
cache: 'npm'
- run: npm i
- run: npm run test

22
.gitignore vendored
View file

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

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,139 +0,0 @@
import { Images } from './crunchyEpisodeList';
export interface CrunchyAndroidEpisodes {
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Actions;
__actions__: Actions;
total: number;
items: CrunchyAndroidEpisode[];
}
export interface Actions {
}
export interface CrunchyAndroidEpisode {
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: Actions;
playback: string;
id: string;
channel_id: ChannelID;
series_id: string;
series_title: string;
series_slug_title: string;
season_id: string;
season_title: string;
season_slug_title: string;
season_number: number;
episode: string;
episode_number: number;
sequence_number: number;
production_episode_id: string;
title: string;
slug_title: string;
description: string;
next_episode_id: string;
next_episode_title: string;
hd_flag: boolean;
maturity_ratings: MaturityRating[];
extended_maturity_rating: Actions;
is_mature: boolean;
mature_blocked: boolean;
episode_air_date: Date;
upload_date: Date;
availability_starts: Date;
availability_ends: Date;
eligible_region: string;
available_date: Date;
free_available_date: Date;
premium_date: Date;
premium_available_date: Date;
is_subbed: boolean;
is_dubbed: boolean;
is_clip: boolean;
seo_title: string;
seo_description: string;
season_tags: string[];
available_offline: boolean;
subtitle_locales: Locale[];
availability_notes: string;
audio_locale: Locale;
versions: Version[];
closed_captions_available: boolean;
identifier: string;
media_type: MediaType;
slug: string;
images: Images;
duration_ms: number;
is_premium_only: boolean;
listing_id: string;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
}
export interface Links {
'episode/channel': Link;
'episode/next_episode': Link;
'episode/season': Link;
'episode/series': Link;
streams: Link;
}
export interface Link {
href: string;
}
export interface Thumbnail {
width: number;
height: number;
type: string;
source: string;
}
export enum Locale {
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',
}
export enum MediaType {
Episode = 'episode',
}
export enum ChannelID {
Crunchyroll = 'crunchyroll',
}
export enum MaturityRating {
Tv14 = 'TV-14',
}
export interface Version {
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
season_guid: string;
media_guid: string;
is_premium_only: boolean;
}

View file

@ -1,189 +0,0 @@
import { ImageType, Images, Image } from './objectInfo';
export interface CrunchyAndroidObject {
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Actions;
__actions__: Actions;
total: number;
items: AndroidObject[];
}
export interface Actions {
}
export interface AndroidObject {
__class__: string;
__href__: string;
__links__: Links;
__actions__: Actions;
id: string;
external_id: string;
channel_id: string;
title: string;
description: string;
promo_title: string;
promo_description: string;
type: string;
slug: string;
slug_title: string;
images: Images;
movie_listing_metadata?: MovieListingMetadata;
movie_metadata?: MovieMetadata;
playback?: string;
episode_metadata?: EpisodeMetadata;
streams_link?: string;
season_metadata?: SeasonMetadata;
linked_resource_key: string;
isSelected?: boolean;
f_num: string;
s_num: string;
}
export interface Links {
'episode/season': LinkData;
'episode/series': LinkData;
resource: LinkData;
'resource/channel': LinkData;
streams: LinkData;
}
export interface LinkData {
href: string;
}
export interface EpisodeMetadata {
audio_locale: Locale;
availability_ends: Date;
availability_notes: string;
availability_starts: Date;
available_date: null;
available_offline: boolean;
closed_captions_available: boolean;
duration_ms: number;
eligible_region: string;
episode: string;
episode_air_date: Date;
episode_number: number;
extended_maturity_rating: Record<unknown>;
free_available_date: Date;
identifier: string;
is_clip: boolean;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
premium_available_date: Date;
premium_date: null;
season_id: string;
season_number: number;
season_slug_title: string;
season_title: string;
sequence_number: number;
series_id: string;
series_slug_title: string;
series_title: string;
subtitle_locales: Locale[];
tenant_categories?: string[];
upload_date: Date;
versions: EpisodeMetadataVersion[];
}
export interface MovieListingMetadata {
availability_notes: string;
available_date: null;
available_offline: boolean;
duration_ms: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
first_movie_id: string;
free_available_date: Date;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
movie_release_year: number;
premium_available_date: Date;
premium_date: null;
subtitle_locales: Locale[];
tenant_categories: string[];
}
export interface MovieMetadata {
availability_notes: string;
available_offline: boolean;
closed_captions_available: boolean;
duration_ms: number;
extended_maturity_rating: Record<unknown>;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
movie_listing_id: string;
movie_listing_slug_title: string;
movie_listing_title: string;
}
export interface SeasonMetadata {
audio_locale: Locale;
audio_locales: Locale[];
extended_maturity_rating: Record<unknown>;
identifier: string;
is_mature: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_display_number: string;
season_sequence_number: number;
subtitle_locales: Locale[];
versions: SeasonMetadataVersion[];
}
export interface SeasonMetadataVersion {
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
}
export interface SeriesMetadata {
audio_locales: Locale[];
availability_notes: string;
episode_count: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
is_dubbed: boolean;
is_mature: boolean;
is_simulcast: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_count: number;
series_launch_year: number;
subtitle_locales: Locale[];
tenant_categories?: string[];
}
export enum Locale {
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,93 +0,0 @@
export interface CrunchyAndroidStreams {
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: Record<unknown, unknown>;
media_id: string;
audio_locale: Locale;
subtitles: Subtitles;
closed_captions: Subtitles;
streams: Streams;
bifs: string[];
versions: Version[];
captions: Record<unknown, unknown>;
}
export interface Subtitles {
'': Subtitle;
'en-US'?: Subtitle;
'es-LA'?: Subtitle;
'es-419'?: Subtitle;
'es-ES'?: Subtitle;
'pt-BR'?: Subtitle;
'fr-FR'?: Subtitle;
'de-DE'?: Subtitle;
'ar-ME'?: Subtitle;
'ar-SA'?: Subtitle;
'it-IT'?: Subtitle;
'ru-RU'?: Subtitle;
'tr-TR'?: Subtitle;
'hi-IN'?: Subtitle;
'zh-CN'?: Subtitle;
'ko-KR'?: Subtitle;
'ja-JP'?: Subtitle;
}
export interface Links {
resource: Resource;
}
export interface Resource {
href: string;
}
export interface Streams {
[key: string]: { [key: string]: Download };
}
export interface Download {
hardsub_locale: Locale;
hardsub_lang?: string;
url: string;
}
export interface Urls {
'': Download;
}
export interface Subtitle {
locale: Locale;
url: string;
format: string;
}
export interface Version {
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
season_guid: string;
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,89 +1,90 @@
import { Links } from './crunchyAndroidEpisodes';
export interface CrunchyEpisodeList {
total: number;
data: CrunchyEpisode[];
meta: Meta;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: unknown;
__actions__: unknown;
total: number;
items: Item[];
}
export interface CrunchyEpisode {
next_episode_id: string;
series_id: string;
season_number: number;
next_episode_title: string;
availability_notes: string;
duration_ms: number;
series_slug_title: string;
series_title: string;
is_dubbed: boolean;
versions: Version[] | null;
identifier: string;
sequence_number: number;
eligible_region: Record<unknown>;
availability_starts: Date;
images: Images;
season_id: string;
seo_title: string;
is_premium_only: boolean;
extended_maturity_rating: Record<unknown>;
title: string;
production_episode_id: string;
premium_available_date: Date;
season_title: string;
seo_description: string;
audio_locale: Locale;
id: string;
media_type: MediaType;
availability_ends: Date;
free_available_date: Date;
playback: string;
channel_id: ChannelID;
episode: string;
is_mature: boolean;
listing_id: string;
episode_air_date: Date;
slug: string;
available_date: Date;
subtitle_locales: Locale[];
slug_title: string;
available_offline: boolean;
description: string;
is_subbed: boolean;
premium_date: Date;
upload_date: Date;
season_slug_title: string;
closed_captions_available: boolean;
episode_number: number;
season_tags: any[];
maturity_ratings: MaturityRating[];
streams_link?: string;
mature_blocked: boolean;
is_clip: boolean;
hd_flag: boolean;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
__links__?: Links;
export interface Item {
__class__: Class;
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: unknown;
id: string;
channel_id: ChannelID;
series_id: string;
series_title: string;
series_slug_title: string;
season_id: string;
season_title: string;
season_slug_title: string;
season_number: number;
episode: string;
episode_number: number | null;
sequence_number: number;
production_episode_id: string;
title: string;
slug_title: string;
description: string;
next_episode_id?: string;
next_episode_title?: string;
hd_flag: boolean;
is_mature: boolean;
mature_blocked: boolean;
episode_air_date: string;
is_subbed: boolean;
is_dubbed: boolean;
is_clip: boolean;
seo_title: string;
seo_description: string;
season_tags: string[];
available_offline: boolean;
media_type: Class;
slug: string;
images: Images;
duration_ms: number;
ad_breaks: AdBreak[];
is_premium_only: boolean;
listing_id: string;
subtitle_locales: SubtitleLocale[];
playback?: string;
availability_notes: string;
available_date?: string;
hide_season_title?: boolean;
hide_season_number?: boolean;
isSelected?: boolean;
seq_id: string;
}
export enum Locale {
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',
export enum Class {
Episode = 'episode',
}
export interface Links {
ads: Ads;
'episode/channel': Ads;
'episode/next_episode'?: Ads;
'episode/season': Ads;
'episode/series': Ads;
streams?: Ads;
}
export interface Ads {
href: string;
}
export interface AdBreak {
type: AdBreakType;
offset_ms: number;
}
export enum AdBreakType {
Midroll = 'midroll',
Preroll = 'preroll',
}
export enum ChannelID {
@ -91,44 +92,28 @@ export enum ChannelID {
}
export interface Images {
poster_tall?: Array<Image[]>;
poster_wide?: Array<Image[]>;
promo_image?: Array<Image[]>;
thumbnail?: Array<Image[]>;
thumbnail: Array<Thumbnail[]>;
}
export interface Image {
height: number;
source: string;
type: ImageType;
export interface Thumbnail {
width: number;
height: number;
type: ThumbnailType;
source: string;
}
export enum ImageType {
PosterTall = 'poster_tall',
PosterWide = 'poster_wide',
PromoImage = 'promo_image',
export enum ThumbnailType {
Thumbnail = 'thumbnail',
}
export enum MaturityRating {
Tv14 = 'TV-14',
export enum SubtitleLocale {
ArSA = 'ar-SA',
DeDE = 'de-DE',
EnUS = 'en-US',
Es419 = 'es-419',
EsES = 'es-ES',
FrFR = 'fr-FR',
ItIT = 'it-IT',
PtBR = 'pt-BR',
RuRU = 'ru-RU',
}
export enum MediaType {
Episode = 'episode',
}
export interface Version {
audio_locale: Locale;
guid: string;
is_premium_only: boolean;
media_guid: string;
original: boolean;
season_guid: string;
variant: string;
}
export interface Meta {
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

@ -1,37 +1,81 @@
// Generated by https://quicktype.io
export interface CrunchySearch {
total: number;
data: CrunchySearchData[];
meta: Record<string, unknown>;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: CrunchySearchLinks;
__actions__: unknown;
total: number;
items: CrunchySearchItem[];
}
export interface CrunchySearchData {
type: string;
count: number;
items: CrunchySearchItem[];
export interface CrunchySearchLinks {
continuation?: Continuation;
}
export interface Continuation {
href: string;
}
export interface CrunchySearchItem {
title: string;
images: Images;
series_metadata?: SeriesMetadata;
promo_description: string;
external_id: string;
slug: string;
new: boolean;
slug_title: string;
channel_id: ChannelID;
description: string;
linked_resource_key: string;
type: ItemType;
id: string;
promo_title: string;
search_metadata: SearchMetadata;
movie_listing_metadata?: MovieListingMetadata;
playback?: string;
streams_link?: string;
episode_metadata?: EpisodeMetadata;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: CrunchySearchLinks;
__actions__: unknown;
type: string;
total: number;
items: ItemItem[];
}
export interface ItemItem {
__actions__: unknown;
__class__: Class;
__href__: string;
__links__: PurpleLinks;
channel_id: ChannelID;
description: string;
external_id: string;
id: string;
images: Images;
linked_resource_key: string;
new: boolean;
new_content: boolean;
promo_description: string;
promo_title: string;
search_metadata: SearchMetadata;
series_metadata?: SeriesMetadata;
slug: string;
slug_title: string;
title: string;
type: ItemType;
episode_metadata?: EpisodeMetadata;
playback?: string;
isSelected?: boolean;
season_number?: string;
is_premium_only?: boolean;
hide_metadata?: boolean;
seq_id?: string;
f_num?: string;
s_num?: string;
ep_num?: string;
last_public?: string;
subtitle_locales?: string[];
availability_notes?: string
}
export enum Class {
Panel = 'panel',
}
export interface PurpleLinks {
resource: Continuation;
'resource/channel': Continuation;
'episode/season'?: Continuation;
'episode/series'?: Continuation;
streams?: Continuation;
}
export enum ChannelID {
@ -39,145 +83,83 @@ export enum ChannelID {
}
export interface EpisodeMetadata {
audio_locale: Locale;
availability_ends: Date;
availability_notes: string;
availability_starts: Date;
available_date: null;
available_offline: boolean;
closed_captions_available: boolean;
duration_ms: number;
eligible_region: string[];
episode: string;
episode_air_date: Date;
episode_number: number;
extended_maturity_rating: Record<unknown>;
free_available_date: Date;
identifier: string;
is_clip: boolean;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: MaturityRating[];
premium_available_date: Date;
premium_date: null;
season_id: string;
season_number: number;
season_slug_title: string;
season_title: string;
sequence_number: number;
series_id: string;
series_slug_title: string;
series_title: string;
subtitle_locales: Locale[];
upload_date: Date;
versions: Version[] | null;
tenant_categories?: string[];
ad_breaks: AdBreak[];
availability_notes: string;
available_offline: boolean;
duration_ms: number;
episode: string;
episode_air_date: string;
episode_number: number;
is_clip: boolean;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_id: string;
season_number: number;
season_slug_title: string;
season_title: string;
sequence_number: number;
series_id: string;
series_slug_title: string;
series_title: string;
subtitle_locales: string[];
tenant_categories?: TenantCategory[];
available_date?: string;
free_available_date?: string;
}
export enum Locale {
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',
export interface AdBreak {
offset_ms: number;
type: AdBreakType;
}
export enum MaturityRating {
Tv14 = 'TV-14',
TvMa = 'TV-MA',
export enum AdBreakType {
Midroll = 'midroll',
Preroll = 'preroll',
}
export interface Version {
audio_locale: Locale;
guid: string;
is_premium_only: boolean;
media_guid: string;
original: boolean;
season_guid: string;
variant: string;
export enum TenantCategory {
Action = 'Action',
Drama = 'Drama',
SciFi = 'Sci-Fi',
}
export interface Images {
poster_tall?: Array<Image[]>;
poster_wide?: Array<Image[]>;
promo_image?: Array<Image[]>;
thumbnail?: Array<Image[]>;
poster_tall?: Array<PosterTall[]>;
poster_wide?: Array<PosterTall[]>;
thumbnail?: Array<PosterTall[]>;
}
export interface Image {
export interface PosterTall {
height: number;
source: string;
type: ImageType;
type: PosterTallType;
width: number;
}
export enum ImageType {
export enum PosterTallType {
PosterTall = 'poster_tall',
PosterWide = 'poster_wide',
PromoImage = 'promo_image',
Thumbnail = 'thumbnail',
}
export interface MovieListingMetadata {
availability_notes: string;
available_date: null;
available_offline: boolean;
duration_ms: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
first_movie_id: string;
free_available_date: Date;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
movie_release_year: number;
premium_available_date: Date;
premium_date: null;
subtitle_locales: any[];
tenant_categories: string[];
}
export interface SearchMetadata {
score: number;
}
export interface SeriesMetadata {
audio_locales: Locale[];
availability_notes: string;
episode_count: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
is_dubbed: boolean;
is_mature: boolean;
is_simulcast: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: MaturityRating[];
season_count: number;
series_launch_year: number;
subtitle_locales: Locale[];
tenant_categories?: string[];
}
export enum ItemType {
Episode = 'episode',
MovieListing = 'movie_listing',
Series = 'series',
availability_notes: string;
episode_count: number;
extended_description: string;
is_dubbed: boolean;
is_mature: boolean;
is_simulcast: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_count: number;
tenant_categories: TenantCategory[];
}

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,
@ -17,66 +14,37 @@ export type CrunchyDownloadOptions = {
partsize: number,
callbackMaker?: (data: DownloadInfo) => HLSCallback,
timeout: number,
waittime: number,
fsRetryTime: number,
dlsubs: string[],
skipsubs: boolean,
nosubs?: boolean,
mp4: boolean,
override: string[],
videoTitle: string,
force: 'Y'|'y'|'N'|'n'|'C'|'c',
ffmpegOptions: string[],
mkvmergeOptions: string[],
defaultSub: LanguageItem,
defaultAudio: LanguageItem,
ccTag: string,
dlVideoOnce: boolean,
skipmux?: boolean,
syncTiming: boolean,
nocleanup: boolean,
chapters: boolean,
fontName: string | undefined,
originalFontSize: boolean,
fontSize: number,
dubLang: string[],
videoTitle: string
}
export type CrunchyMultiDownload = {
export type CurnchyMultiDownload = {
dubLang: string[],
all?: boolean,
but?: boolean,
e?: string,
s?: string
e?: string
}
export type CrunchyMuxOptions = {
output: string,
skipSubMux?: boolean
keepAllVideos?: bolean
novids?: boolean,
mp4: boolean,
forceMuxer?: 'ffmpeg'|'mkvmerge',
nocleanup?: boolean,
videoTitle: string,
ffmpegOptions: string[],
mkvmergeOptions: string[],
defaultSub: LanguageItem,
defaultAudio: LanguageItem,
ccTag: string,
syncTiming: boolean,
videoTitle: string
}
export type CrunchyEpMeta = {
data: {
mediaId: string,
lang?: LanguageItem,
playback?: string,
versions?: EpisodeVersion[] | null,
isSubbed: boolean,
isDubbed: boolean,
playback?: string
}[],
seriesTitle: string,
seasonTitle: string,
episodeNumber: string,
episodeTitle: string,
@ -84,27 +52,15 @@ export type CrunchyEpMeta = {
season: number,
showID: string,
e: string,
image: string,
image: string
}
export type DownloadedMedia = {
type: 'Video',
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
type: 'Subtitle'
} & sxItem )
export type ParseItem = {
@ -115,8 +71,6 @@ export type ParseItem = {
title: string,
playback?: string,
season_number?: number|string,
episode_number?: number|string,
season_count?: number|string,
is_premium_only?: boolean,
hide_metadata?: boolean,
seq_id?: string,
@ -126,86 +80,52 @@ export type ParseItem = {
ep_num?: string
last_public?: string,
subtitle_locales?: string[],
availability_notes?: string,
identifier?: string,
versions?: Version[] | null,
media_type?: string | null,
movie_release_year?: number | null,
availability_notes?: string
}
export interface SeriesSearch {
total: number;
data: SeriesSearchItem[];
meta: Meta;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Actions;
__actions__: Actions;
total: number;
items: SeriesSearchItem[];
}
export interface SeriesSearchItem {
description: string;
seo_description: string;
number_of_episodes: number;
is_dubbed: boolean;
identifier: string;
channel_id: string;
slug_title: string;
season_sequence_number: number;
season_tags: string[];
extended_maturity_rating: Record<unknown>;
is_mature: boolean;
audio_locale: string;
season_number: number;
images: Record<unknown>;
mature_blocked: boolean;
versions: Version[];
title: string;
is_subbed: boolean;
id: string;
audio_locales: string[];
subtitle_locales: string[];
availability_notes: string;
series_id: string;
season_display_number: string;
is_complete: boolean;
keywords: any[];
maturity_ratings: string[];
is_simulcast: boolean;
seo_title: string;
}
export interface Version {
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: Links;
__actions__: string[];
id: string;
channel_id: string;
title: string;
slug_title: string;
series_id: string;
season_number: number;
is_complete: boolean;
description: string;
keywords: any[];
season_tags: string[];
images: Actions;
is_mature: boolean;
mature_blocked: boolean;
is_subbed: boolean;
is_dubbed: boolean;
is_simulcast: boolean;
seo_title: string;
seo_description: string;
availability_notes: string;
}
export interface EpisodeVersion {
audio_locale: Locale;
guid: string;
is_premium_only: boolean;
media_guid: string;
original: boolean;
season_guid: string;
variant: string;
export interface Links {
'season/channel': Season;
'season/episodes': Season;
'season/series': Season;
}
export enum Locale {
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',
}
export interface Meta {
versions_considered: boolean;
export interface Season {
href: string;
}

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

@ -1,73 +0,0 @@
export interface HidiveDashboard {
Code: number;
Status: string;
Message: null;
Messages: Messages;
Data: Data;
Timestamp: string;
IPAddress: string;
}
export interface Data {
TitleRows: TitleRow[];
LoadTime: number;
}
export interface TitleRow {
Name: string;
Titles: Title[];
LoadTime: number;
}
export interface Title {
Id: number;
Name: string;
ShortSynopsis: string;
MediumSynopsis: string;
LongSynopsis: string;
KeyArtUrl: string;
MasterArtUrl: string;
Rating: null | string;
OverallRating: number;
RatingCount: number;
MALScore: null;
UserRating: number;
RunTime: number | null;
ShowInfoTitle: string;
FirstPremiereDate: Date;
EpisodeCount: number;
SeasonName: string;
RokuHDArtUrl: string;
RokuSDArtUrl: string;
IsRateable: boolean;
InQueue: boolean;
IsFavorite: boolean;
IsContinueWatching: boolean;
ContinueWatching: ContinueWatching;
Episodes: any[];
LoadTime: number;
}
export interface ContinueWatching {
Id: string;
ProfileId: number;
EpisodeId: number;
Status: Status | null;
CurrentTime: number;
UserId: number;
TitleId: number;
SeasonId: number;
VideoId: number;
TotalSeconds: number;
CreatedDT: Date;
ModifiedDT: Date | null;
}
export enum Status {
Paused = 'Paused',
Playing = 'Playing',
Watching = 'Watching',
}
export interface Messages {
}

View file

@ -1,84 +0,0 @@
export interface HidiveEpisodeList {
Code: number;
Status: string;
Message: null;
Messages: Record<unknown, unknown>;
Data: Data;
Timestamp: string;
IPAddress: string;
}
export interface Data {
Title: HidiveTitle;
}
export interface HidiveTitle {
Id: number;
Name: string;
ShortSynopsis: string;
MediumSynopsis: string;
LongSynopsis: string;
KeyArtUrl: string;
MasterArtUrl: string;
Rating: string;
OverallRating: number;
RatingCount: number;
MALScore: null;
UserRating: number;
RunTime: number;
ShowInfoTitle: string;
FirstPremiereDate: Date;
EpisodeCount: number;
SeasonName: string;
RokuHDArtUrl: string;
RokuSDArtUrl: string;
IsRateable: boolean;
InQueue: boolean;
IsFavorite: boolean;
IsContinueWatching: boolean;
ContinueWatching: ContinueWatching;
Episodes: HidiveEpisode[];
LoadTime: number;
}
export interface ContinueWatching {
Id: string;
ProfileId: number;
EpisodeId: number;
Status: string;
CurrentTime: number;
UserId: number;
TitleId: number;
SeasonId: number;
VideoId: number;
TotalSeconds: number;
CreatedDT: Date;
ModifiedDT: Date;
}
export interface HidiveEpisode {
Id: number;
Number: number;
Name: string;
Summary: string;
HIDIVEPremiereDate: Date;
ScreenShotSmallUrl: string;
ScreenShotCompressedUrl: string;
SeasonNumber: number;
TitleId: number;
SeasonNumberValue: number;
EpisodeNumberValue: number;
VideoKey: string;
DisplayNameLong: string;
PercentProgress: number;
LoadTime: number;
}
export interface HidiveEpisodeExtra extends HidiveEpisode {
titleId: number;
epKey: string;
nameLong: string;
seriesTitle: string;
seriesId?: number;
isSelected: boolean;
}

View file

@ -1,47 +0,0 @@
export interface HidiveSearch {
Code: number;
Status: string;
Message: null;
Messages: Record<unknown, unknown>;
Data: HidiveSearchData;
Timestamp: string;
IPAddress: string;
}
export interface HidiveSearchData {
Query: string;
Slug: string;
TitleResults: HidiveSearchItem[];
SearchId: number;
IsSearchPinned: boolean;
IsPinnedSearchAvailable: boolean;
}
export interface HidiveSearchItem {
Id: number;
Name: string;
ShortSynopsis: string;
MediumSynopsis: string;
LongSynopsis: string;
KeyArtUrl: string;
MasterArtUrl: string;
Rating: string;
OverallRating: number;
RatingCount: number;
MALScore: null;
UserRating: number;
RunTime: number | null;
ShowInfoTitle: string;
FirstPremiereDate: Date;
EpisodeCount: number;
SeasonName: string;
RokuHDArtUrl: string;
RokuSDArtUrl: string;
IsRateable: boolean;
InQueue: boolean;
IsFavorite: boolean;
IsContinueWatching: boolean;
ContinueWatching: null;
Episodes: any[];
LoadTime: number;
}

View file

@ -1,61 +0,0 @@
export interface HidiveVideoList {
Code: number;
Status: string;
Message: null;
Messages: Record<unknown, unknown>;
Data: HidiveVideo;
Timestamp: string;
IPAddress: string;
}
export interface HidiveVideo {
ShowAds: boolean;
CaptionCssUrl: string;
FontSize: number;
FontScale: number;
CaptionLanguages: string[];
CaptionLanguage: string;
CaptionVttUrls: Record<string, string>;
VideoLanguages: string[];
VideoLanguage: string;
VideoUrls: Record<string, HidiveStreamList>;
FontColorName: string;
AutoPlayNextEpisode: boolean;
MaxStreams: number;
CurrentTime: number;
FontColorCode: string;
RunTime: number;
AdUrl: null;
}
export interface HidiveStreamList {
hls: string[];
drm: string[];
drmEnabled: boolean;
}
export interface HidiveStreamInfo extends HidiveStreamList {
language?: string;
episodeTitle?: string;
seriesTitle?: string;
season?: number;
episodeNumber?: number;
uncut?: boolean;
image?: string;
}
export interface HidiveSubtitleInfo {
language: string;
cc: boolean;
url: string;
}
export type DownloadedMedia = {
type: 'Video',
lang: LanguageItem,
path: string,
uncut: boolean
} | ({
type: 'Subtitle',
cc: boolean
} & sxItem )

30
@types/hls-download.d.ts vendored Normal file
View file

@ -0,0 +1,30 @@
declare module 'hls-download' {
import type { ProgressData } from './messageHandler';
export type HLSCallback = (data: ProgressData) => unknown;
export default class hlsDownload {
constructor(options: {
m3u8json: {
segments: Record<string, unknown>[],
mediaSequence?: number,
},
output?: string,
threads?: number,
retries?: number,
offset?: number,
baseurl?: string,
proxy?: string,
skipInit?: boolean,
timeout?: number,
fsRetryTime?: number,
callback?: HLSCallback
})
async download() : Promise<{
ok: boolean,
parts: {
first: number,
total: number,
compleated: number
}
}>
}
}

View file

@ -1,29 +1,20 @@
import { HLSCallback } from 'hls-download';
import type { FunimationSearch } from './funiSearch';
import type { AvailableMuxer } from '../modules/module.args';
import { LanguageItem } from '../modules/module.langsData';
export interface MessageHandler {
name: string
auth: (data: AuthData) => Promise<AuthResponse>;
version: () => Promise<string>;
checkToken: () => Promise<CheckTokenResponse>;
search: (data: SearchData) => Promise<SearchResponse>,
availableDubCodes: () => Promise<string[]>,
availableSubCodes: () => Promise<string[]>,
handleDefault: (name: string) => Promise<any>,
resolveItems: (data: ResolveItemsData) => Promise<boolean>,
resolveItems: (data: ResolveItemsData) => Promise<ResponseBase<QueueItem[]>>,
listEpisodes: (id: string) => Promise<EpisodeListResponse>,
downloadItem: (data: QueueItem) => void,
isDownloading: () => Promise<boolean>,
downloadItem: (data) => void,
isDownloading: () => boolean,
writeToClipboard: (text: string) => void,
openFolder: (path: FolderTypes) => void,
openFile: (data: [FolderTypes, string]) => void,
openURL: (data: string) => void;
getQueue: () => Promise<QueueItem[]>,
removeFromQueue: (index: number) => void,
clearQueue: () => void,
setDownloadQueue: (data: boolean) => void,
getDownloadQueue: () => Promise<boolean>
}
export type FolderTypes = 'content' | 'config';
@ -31,6 +22,7 @@ export type FolderTypes = 'content' | 'config';
export type QueueItem = {
title: string,
episode: string,
ids: string[],
fileName: string,
dlsubs: string[],
parent: {
@ -38,19 +30,14 @@ export type QueueItem = {
season: string
},
q: number,
dlVideoOnce: boolean,
dubLang: string[],
image: string,
} & ResolveItemsData
}
export type ResolveItemsData = {
id: string,
dubLang: string[],
all: boolean,
but: boolean,
novids: boolean,
noaudio: boolean
dlVideoOnce: boolean,
e: string,
fileName: string,
q: number,
@ -93,22 +80,18 @@ export type FuniEpisodeData = {
episode: string,
show: string,
season: string
},
image: string
}
};
export type AuthData = { username: string, password: string };
export type SearchData = { search: string, page?: number, 'search-type'?: string, 'search-locale'?: string };
export type FuniGetShowData = { id: number, e?: string, but: boolean, all: boolean };
export type FuniGetEpisodeData = { subs: FuniSubsData, fnSlug: FuniEpisodeData, simul?: boolean; dubLang: string[], s: string }
export type FuniStreamData = { force?: 'Y'|'y'|'N'|'n'|'C'|'c', callbackMaker?: (data: DownloadInfo) => HLSCallback, q: number, x: number, fileName: string, numbers: number, novids?: boolean,
export type FuniStreamData = { callbackMaker?: (data: DownloadInfo) => HLSCallback, q: number, x: number, fileName: string, numbers: number, novids?: boolean,
timeout: number, partsize: number, fsRetryTime: number, noaudio?: boolean, mp4: boolean, ass: boolean, fontSize: number, fontName?: string, skipmux?: boolean,
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
}
forceMuxer: AvailableMuxer | undefined, simul: boolean, skipSubMux: boolean, nocleanup: boolean, override: string[], videoTitle: string }
export type FuniSubsData = { nosubs?: boolean, sub: boolean, dlsubs: string[] }
export type DownloadData = { id: string, e: string, dubLang: string[], dlsubs: string[], fileName: string, q: number }
export type AuthResponse = ResponseBase<undefined>;
export type FuniSearchReponse = ResponseBase<FunimationSearch>;
@ -130,11 +113,10 @@ export type ProgressData = {
cur: number,
percent: number|string,
time: number,
downloadSpeed: number,
bytes: number
downloadSpeed: number
};
export type PossibleMessages = keyof ServiceHandler;
export type PossibleMessanges = keyof ServiceHandler;
export type DownloadInfo = {
image: string,
@ -142,20 +124,10 @@ export type DownloadInfo = {
title: string
},
title: string,
language: LanguageItem,
fileName: string
}
export type ExtendedProgress = {
progress: ProgressData,
downloadInfo: DownloadInfo
}
export type GuiState = {
setup: boolean,
services: Record<string, GuiStateService>
}
export type GuiStateService = {
queue: QueueItem[]
}

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

260
@types/objectInfo.d.ts vendored
View file

@ -1,211 +1,93 @@
// Generated by https://quicktype.io
export interface ObjectInfo {
total: number;
data: CrunchyObject[];
meta: Record<unknown>;
__class__: string;
__href__: string;
__resource_key__: string;
__links__: unknown;
__actions__: unknown;
total: number;
items: Item[];
}
export interface CrunchyObject {
__links__?: Links;
channel_id: string;
slug: string;
images: Images;
linked_resource_key: string;
description: string;
promo_description: string;
external_id: string;
title: string;
series_metadata?: SeriesMetadata;
id: string;
slug_title: string;
type: string;
promo_title: string;
movie_listing_metadata?: MovieListingMetadata;
movie_metadata?: MovieMetadata;
playback?: string;
episode_metadata?: EpisodeMetadata;
streams_link?: string;
season_metadata?: SeasonMetadata;
isSelected?: boolean;
f_num: string;
s_num: string;
export interface Item {
__class__: string;
__href__: string;
__links__: Links;
__actions__: unknown;
id: string;
external_id: string;
channel_id: string;
title: string;
description: string;
promo_title: string;
promo_description: string;
type: string;
slug: string;
slug_title: string;
images: Images;
episode_metadata: EpisodeMetadata;
playback: string;
linked_resource_key: string;
type: string;
s_num?: string;
f_num?: string;
movie_metadata?: {
movie_listing_id: string;
movie_listing_title: string
};
isSelected?: boolean
}
export interface Links {
'episode/season': LinkData;
'episode/series': LinkData;
resource: LinkData;
'resource/channel': LinkData;
streams: LinkData;
'episode/season': EpisodeSeason;
'episode/series': EpisodeSeason;
resource: EpisodeSeason;
'resource/channel': EpisodeSeason;
streams: EpisodeSeason;
}
export interface LinkData {
export interface EpisodeSeason {
href: string;
}
export interface EpisodeMetadata {
audio_locale: Locale;
availability_ends: Date;
availability_notes: string;
availability_starts: Date;
available_date: null;
available_offline: boolean;
closed_captions_available: boolean;
duration_ms: number;
eligible_region: string;
episode: string;
episode_air_date: Date;
episode_number: number;
extended_maturity_rating: Record<unknown>;
free_available_date: Date;
identifier: string;
is_clip: boolean;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
premium_available_date: Date;
premium_date: null;
season_id: string;
season_number: number;
season_slug_title: string;
season_title: string;
sequence_number: number;
series_id: string;
series_slug_title: string;
series_title: string;
subtitle_locales: Locale[];
tenant_categories?: string[];
upload_date: Date;
versions: EpisodeMetadataVersion[];
series_id: string;
series_title: string;
series_slug_title: string;
season_id: string;
season_title: string;
season_slug_title: string;
season_number: number;
episode_number: number;
episode: string;
sequence_number: number;
duration_ms: number;
ad_breaks: AdBreak[];
episode_air_date: string;
is_premium_only: boolean;
is_mature: boolean;
mature_blocked: boolean;
is_subbed: boolean;
is_dubbed: boolean;
is_clip: boolean;
available_offline: boolean;
maturity_ratings: string[];
subtitle_locales: string[];
availability_notes: string;
}
export interface EpisodeMetadataVersion {
audio_locale: Locale;
guid: string;
is_premium_only: boolean;
media_guid: string;
original: boolean;
season_guid: string;
variant: string;
export interface AdBreak {
type: string;
offset_ms: number;
}
export interface Images {
poster_tall?: Array<Image[]>;
poster_wide?: Array<Image[]>;
promo_image?: Array<Image[]>;
thumbnail?: Array<Image[]>;
thumbnail: Array<Thumbnail[]>;
}
export interface Image {
height: number;
source: string;
type: ImageType;
export interface Thumbnail {
width: number;
height: number;
type: string;
source: string;
}
export enum ImageType {
PosterTall = 'poster_tall',
PosterWide = 'poster_wide',
PromoImage = 'promo_image',
Thumbnail = 'thumbnail',
}
export interface MovieListingMetadata {
availability_notes: string;
available_date: null;
available_offline: boolean;
duration_ms: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
first_movie_id: string;
free_available_date: Date;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
movie_release_year: number;
premium_available_date: Date;
premium_date: null;
subtitle_locales: Locale[];
tenant_categories: string[];
}
export interface MovieMetadata {
availability_notes: string;
available_offline: boolean;
closed_captions_available: boolean;
duration_ms: number;
extended_maturity_rating: Record<unknown>;
is_dubbed: boolean;
is_mature: boolean;
is_premium_only: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
movie_listing_id: string;
movie_listing_slug_title: string;
movie_listing_title: string;
}
export interface SeasonMetadata {
audio_locale: Locale;
audio_locales: Locale[];
extended_maturity_rating: Record<unknown>;
identifier: string;
is_mature: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_display_number: string;
season_sequence_number: number;
subtitle_locales: Locale[];
versions: SeasonMetadataVersion[];
}
export interface SeasonMetadataVersion {
audio_locale: Locale;
guid: string;
original: boolean;
variant: string;
}
export interface SeriesMetadata {
audio_locales: Locale[];
availability_notes: string;
episode_count: number;
extended_description: string;
extended_maturity_rating: Record<unknown>;
is_dubbed: boolean;
is_mature: boolean;
is_simulcast: boolean;
is_subbed: boolean;
mature_blocked: boolean;
maturity_ratings: string[];
season_count: number;
series_launch_year: number;
subtitle_locales: Locale[];
tenant_categories?: string[];
}
export enum Locale {
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,119 +1,34 @@
// Generated by https://quicktype.io
export interface PlaybackData {
total: number;
data: { [key: string]: { [key: string]: StreamDetails } }[];
meta: Meta;
audio_locale: string;
subtitles: { [key: string]: Subtitle };
streams: { [key: string]: { [key: string]: Stream } };
QoS: QoS;
}
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;
export interface QoS {
region: string;
cloudFrontRequestId: string;
lambdaRunTime: number;
}
export interface CrunchyStreams {
'': StreamDetails;
'en-US'?: StreamDetails;
'es-LA'?: StreamDetails;
'es-419'?: StreamDetails;
'es-ES'?: StreamDetails;
'pt-BR'?: StreamDetails;
'fr-FR'?: StreamDetails;
'de-DE'?: StreamDetails;
'ar-ME'?: StreamDetails;
'ar-SA'?: StreamDetails;
'it-IT'?: StreamDetails;
'ru-RU'?: StreamDetails;
'tr-TR'?: StreamDetails;
'hi-IN'?: StreamDetails;
'zh-CN'?: StreamDetails;
'ko-KR'?: StreamDetails;
'ja-JP'?: StreamDetails;
[string: string]: StreamDetails;
}
export interface StreamDetails {
//hardsub_locale: Locale;
export interface Stream {
hardsub_locale: string;
url: string;
vcodec: Vcodec;
hardsub_lang?: string;
audio_lang?: string;
type?: string;
}
export interface Meta {
media_id: string;
subtitles: Subtitles;
bifs: string[];
versions: Version[];
audio_locale: Locale;
closed_captions: Subtitles;
captions: Subtitles;
export enum Vcodec {
H264 = 'h264',
}
export interface Subtitles {
''?: SubtitleInfo;
'en-US'?: SubtitleInfo;
'es-LA'?: SubtitleInfo;
'es-419'?: SubtitleInfo;
'es-ES'?: SubtitleInfo;
'pt-BR'?: SubtitleInfo;
'fr-FR'?: SubtitleInfo;
'de-DE'?: SubtitleInfo;
'ar-ME'?: SubtitleInfo;
'ar-SA'?: SubtitleInfo;
'it-IT'?: SubtitleInfo;
'ru-RU'?: SubtitleInfo;
'tr-TR'?: SubtitleInfo;
'hi-IN'?: SubtitleInfo;
'zh-CN'?: SubtitleInfo;
'ko-KR'?: SubtitleInfo;
'ja-JP'?: SubtitleInfo;
}
export interface SubtitleInfo {
format: string;
export interface Subtitle {
locale: Locale;
url: string;
format: string;
}
export interface Version {
audio_locale: Locale;
guid: string;
is_premium_only: boolean;
media_guid: string;
original: boolean;
season_guid: string;
variant: string;
}
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,10 +1,8 @@
import { ExtendedProgress, QueueItem } from './messageHandler';
import { ExtendedProgress } from './messageHandler';
export type RandomEvents = {
progress: ExtendedProgress,
finish: undefined,
queueChange: QueueItem[],
current: QueueItem|undefined
finish: undefined
}
export interface RandomEvent<T extends keyof RandomEvents> {

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

@ -1,45 +0,0 @@
import { GUIConfig } from '../modules/module.cfg-loader';
import { AuthResponse, CheckTokenResponse, EpisodeListResponse, FolderTypes, QueueItem, ResolveItemsData, SearchData, SearchResponse } from './messageHandler';
export type WSMessage<T extends keyof MessageTypes, P extends 0|1 = 0> = {
name: T,
data: MessageTypes[T][P]
}
export type WSMessageWithID<T extends keyof MessageTypes, P extends 0|1 = 0> = WSMessage<T, P> & {
id: string
}
export type UnknownWSMessage = {
name: keyof MessageTypes,
data: MessageTypes[keyof MessageTypes][0],
id: string
}
export type MessageTypes = {
'auth': [AuthData, AuthResponse],
'version': [undefined, string],
'checkToken': [undefined, CheckTokenResponse],
'search': [SearchData, SearchResponse],
'default': [string, unknown],
'availableDubCodes': [undefined, string[]],
'availableSubCodes': [undefined, string[]],
'resolveItems': [ResolveItemsData, boolean],
'listEpisodes': [string, EpisodeListResponse],
'downloadItem': [QueueItem, undefined],
'isDownloading': [undefined, boolean],
'openFolder': [FolderTypes, undefined],
'changeProvider': [undefined, boolean],
'type': [undefined, 'crunchy'|'hidive'|'ao'|'adn'|undefined],
'setup': ['crunchy'|'hidive'|'ao'|'adn'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined],
'isSetup': [undefined, boolean],
'setupServer': [GUIConfig, boolean],
'requirePassword': [undefined, boolean],
'getQueue': [undefined, QueueItem[]],
'removeFromQueue': [number, undefined],
'clearQueue': [undefined, undefined],
'setDownloadQueue': [boolean, undefined],
'getDownloadQueue': [undefined, boolean]
}

View file

@ -1,39 +0,0 @@
FROM node AS builder
WORKDIR "/app"
COPY . .
# Install 7z for packaging
RUN apt-get update
RUN apt-get install p7zip-full -y
# Update bin-path for docker/linux
RUN echo 'ffmpeg: "./bin/ffmpeg/ffmpeg"\nmkvmerge: "./bin/mkvtoolnix/mkvmerge"' > /app/config/bin-path.yml
#Build AniDL
RUN npm install -g pnpm
RUN pnpm i
RUN pnpm run build-linux-gui
# Move build to new Clean Image
FROM node
WORKDIR "/app"
COPY --from=builder /app/lib/_builds/multi-downloader-nx-linux-x64-gui ./
# Install mkvmerge and ffmpeg
RUN mkdir -p /app/bin/mkvtoolnix
RUN mkdir -p /app/bin/ffmpeg
RUN apt-get update
RUN apt-get install xdg-utils -y
RUN apt-get install mkvtoolnix -y
#RUN apt-get install ffmpeg -y
RUN mv /usr/bin/mkvmerge /app/bin/mkvtoolnix/mkvmerge
#RUN mv /usr/bin/ffmpeg /app/bin/ffmpeg/ffmpeg
CMD [ "/app/aniDL" ]

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

BIN
build/Icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
build/Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

1
build/credits.md Normal file
View file

@ -0,0 +1 @@
All credits go to KX-DAREKON#0420

View file

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

View file

@ -1,24 +1,4 @@
# 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

View file

@ -1 +0,0 @@
port: 3000

2212
crunchy.ts

File diff suppressed because it is too large Load diff

21
dev.js
View file

@ -1,21 +0,0 @@
const { exec } = require('child_process');
const path = require('path');
const toRun = process.argv.slice(2).join(' ').split('---');
const waitForProcess = async (proc) => {
return new Promise((resolve, reject) => {
proc.stdout?.on('data', (data) => process.stdout.write(data));
proc.stderr?.on('data', (data) => process.stderr.write(data));
proc.on('close', resolve);
proc.on('error', reject);
});
};
(async () => {
await waitForProcess(exec('pnpm run tsc test false'));
for (let command of toRun) {
await waitForProcess(exec(`node index.js --service hidive ${command}`, {
cwd: path.join(__dirname, 'lib')
}));
}
})();

View file

@ -1,10 +1,10 @@
# multi-downloader-nx (5.1.5v)
# multi-downloader-nx (3.0.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.
@ -18,7 +18,7 @@ This tool is not responsible for your actions; please make an informed decision
#### `--auth`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--auth ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--auth ` | `boolean` | `No`| `NaN` | `NaN` |
Most of the shows on both services are only accessible if you payed for the service.
In order for them to know who you are you are required to log in.
@ -26,27 +26,21 @@ If you trigger this command, you will be prompted for the username and password
#### `--username`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--username ${username}` | `string` | `No`| `NaN` | `undefined`| `username: ` |
| Both | `--username ${username}` | `string` | `No`| `NaN` | `undefined`| `username: ` |
Set the username to use for the authentication. If not provided, you will be prompted for the input
#### `--password`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--password ${password}` | `string` | `No`| `NaN` | `undefined`| `password: ` |
| Both | `--password ${password}` | `string` | `No`| `NaN` | `undefined`| `password: ` |
Set the password to use for the authentication. If not provided, you will be prompted for the input
#### `--silentAuth`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--silentAuth ` | `boolean` | `No`| `NaN` | `false`| `silentAuth: ` |
| Both | `--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,14 +52,14 @@ 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 | `--fontName ${fontName}` | `string` | `No`| `NaN` | `NaN` |
Set the font to use in subtiles
### Search
#### `--search`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--search ${search}` | `string` | `No`| `-f` | `NaN` |
| Both | `--search ${search}` | `string` | `No`| `-f` | `NaN` |
Search of an anime by the given string
#### `--search-type`
@ -77,19 +71,19 @@ Search only for type of anime listings (e.g. episodes, series)
#### `--page`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--page ${page}` | `number` | `No`| `-p` | `NaN` |
| Crunchyroll | `--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` | [`''`, `es-LA`, `es-419`, `es-ES`, `pt-BR`, `fr-FR`, `de-DE`, `ar-ME`, `ar-SA`, `it-IT`, `ru-RU`, `tr-TR`, `en-US`] | ``| `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**
| --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--new ` | `boolean` | `No`| `NaN` | `NaN` |
| Crunchyroll | `--new ` | `boolean` | `No`| `NaN` | `NaN` |
Get last updated series list
### Downloading
@ -104,296 +98,191 @@ 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**
| --- | --- | --- | --- | --- | ---|
| All | `-s ${ID}` | `string` | `No`| `NaN` | `NaN` |
| Both | `-s ${ID}` | `string` | `No`| `NaN` | `NaN` |
Used to set the season ID to download from
#### `-e`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `-e ${selection}` | `string` | `No`| `--episode` | `NaN` |
| Both | `-e ${selection}` | `string` | `No`| `--epsisode` | `NaN` |
Set the episode(s) to download from any given show.
For multiple selection: 1-4 OR 1,2,3,4
For special episodes: S1-4 OR S1,S2,S3,S4 where S is the special letter
#### `--extid`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--extid ${selection}` | `string` | `No`| `--externalid` | `NaN` |
Set the external id to lookup/download.
Allows you to download or view legacy Crunchyroll Ids
#### `-q`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `-q ${qualityLevel}` | `number` | `No`| `NaN` | `0`| `q: ` |
| Both | `-q ${qualityLevel}` | `number` | `No`| `NaN` | `0`| `q: ` |
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: ` |
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**
| --- | --- | --- | --- | --- | --- | ---|
| Hidive | `--removeBumpers ` | `boolean` | `No`| `NaN` | `true`| `removeBumpers: ` |
If selected, it will remove the bumpers such as the hidive intro from the final file.
Currently disabling this sometimes results in bugs such as video/audio desync
#### `--originalFontSize`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Hidive | `--originalFontSize ` | `boolean` | `No`| `NaN` | `true`| `originalFontSize: ` |
If selected, it will prefer to keep the original Font Size defined by the service.
#### `-x`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `-x ${server}` | `number` | `No`| `--server` | [`1`, `2`, `3`, `4`] | `1`| `x: ` |
| Both | `-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`] | `1`| `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`, `es-419`, `es`, `pt-BR`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `zh`, `en`, `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: ` |
| Both | `--dlsubs ${sub1} ${sub2}` | `array` | `No`| `NaN` | [`all`, `none`, `es-419`, `es`, `pt-BR`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `zh`, `en`, `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: es-419, es, fr, de, ar, ar, it, ru, tr
#### `--novids`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--novids ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--novids ` | `boolean` | `No`| `NaN` | `NaN` |
Skip downloading videos
#### `--noaudio`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
Skip downloading audio
#### `--nosubs`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--nosubs ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--nosubs ` | `boolean` | `No`| `NaN` | `NaN` |
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: ` |
| Both | `--dubLang ${dub1} ${dub2}` | `array` | `No`| `NaN` | [`spa`, `por`, `fra`, `deu`, `ara`, `ita`, `rus`, `tur`, `cmn`, `eng`, `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: spa, spa, fra, deu, ara, ara, ita, rus, tur
#### `--all`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--all ` | `boolean` | `No`| `NaN` | `false`| `all: ` |
| Both | `--all ` | `boolean` | `No`| `NaN` | `false`| `all: ` |
Used to download all episodes from the show
#### `--fontSize`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--fontSize ${fontSize}` | `number` | `No`| `NaN` | `55`| `fontSize: ` |
| Both | `--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**
| --- | --- | --- | --- | --- | ---|
| All | `--allDubs ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--allDubs ` | `boolean` | `No`| `NaN` | `NaN` |
If selected, all available dubs will get downloaded
#### `--timeout`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--timeout ${timeout}` | `number` | `No`| `NaN` | `15000`| `timeout: ` |
| Both | `--timeout ${timeout}` | `number` | `No`| `NaN` | `60000`| `timeout: ` |
Set the timeout of all download reqests. Set in millisecods
#### `--waittime`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--waittime ${waittime}` | `number` | `No`| `NaN` | `0`| `waittime: ` |
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 | `--simul ` | `boolean` | `No`| `NaN` | `false`| `simul: ` |
Force downloading simulcast version instead of uncut version (if available).
#### `--but`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--but ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--but ` | `boolean` | `No`| `NaN` | `NaN` |
Download everything but the -e selection
#### `--downloadArchive`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--downloadArchive ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--downloadArchive ` | `boolean` | `No`| `NaN` | `NaN` |
Used to download all archived shows
#### `--addArchive`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--addArchive ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--addArchive ` | `boolean` | `No`| `NaN` | `NaN` |
Used to add the `-s` and `--srz` to downloadArchive
#### `--partsize`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--partsize ${amount}` | `number` | `No`| `NaN` | `10`| `partsize: ` |
| Both | `--partsize ${amount}` | `number` | `No`| `NaN` | `10`| `partsize: ` |
Set the amount of parts to download at once
If you have a good connection try incresing this number to get a higher overall speed
#### `--fsRetryTime`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--fsRetryTime ${time in seconds}` | `number` | `No`| `NaN` | `5`| `fsRetryTime: ` |
| Both | `--fsRetryTime ${time in seconds}` | `number` | `No`| `NaN` | `5`| `fsRetryTime: ` |
Set the time the downloader waits before retrying if an error while writing the file occurs
#### `--force`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--force ${option}` | `string` | `No`| `NaN` | [`y`, `Y`, `n`, `N`, `c`, `C`] | `NaN` |
If a file already exists, the tool will ask you how to proceed. With this, you can answer in advance.
### Muxing
#### `--mp4`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--mp4 ` | `boolean` | `No`| `NaN` | `false`| `mp4: ` |
| Both | `--mp4 ` | `boolean` | `No`| `NaN` | `false`| `mp4: ` |
If selected, the output file will be an mp4 file (not recommended tho)
#### `--keepAllVideos`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--keepAllVideos ` | `boolean` | `No`| `NaN` | `false`| `keepAllVideos: ` |
If set to true, it will keep all videos in the merge process, rather than discarding the extra videos.
#### `--syncTiming`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, Hidive | `--syncTiming ` | `boolean` | `No`| `NaN` | `false`| `syncTiming: ` |
If enabled attempts to sync timing for multi-dub downloads.
NOTE: This is currently experimental and syncs audio and subtitles, though subtitles has a lot of guesswork
If you find bugs with this, please report it in the discord or github
#### `--skipmux`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--skipmux ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--skipmux ` | `boolean` | `No`| `NaN` | `NaN` |
Skip muxing video, audio and subtitles
#### `--nocleanup`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--nocleanup ` | `boolean` | `No`| `NaN` | `false`| `nocleanup: ` |
| Both | `--nocleanup ` | `boolean` | `No`| `NaN` | `false`| `nocleanup: ` |
Don't delete subtitle, audio and video files after muxing
#### `--skipSubMux`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--skipSubMux ` | `boolean` | `No`| `NaN` | `false`| `skipSubMux: ` |
| Both | `--skipSubMux ` | `boolean` | `No`| `NaN` | `false`| `skipSubMux: ` |
Skip muxing the subtitles
#### `--forceMuxer`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--forceMuxer ${muxer}` | `string` | `No`| `NaN` | [`ffmpeg`, `mkvmerge`] | `undefined`| `forceMuxer: ` |
| Both | `--forceMuxer ${muxer}` | `string` | `No`| `NaN` | [`ffmpeg`, `mkvmerge`] | `undefined`| `forceMuxer: ` |
Force the program to use said muxer or don't mux if the given muxer is not present
#### `--videoTitle`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--videoTitle ${title}` | `string` | `No`| `NaN` | `NaN` |
| Both | `--videoTitle ${title}` | `string` | `No`| `NaN` | `NaN` |
Set the video track name of the merged file
#### `--mkvmergeOptions`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--mkvmergeOptions ${args}` | `array` | `No`| `NaN` | `--no-date,--disable-track-statistics-tags,--engage no_variable_data`| `mkvmergeOptions: ` |
Set the options given to mkvmerge
#### `--ffmpegOptions`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--ffmpegOptions ${args}` | `array` | `No`| `NaN` | ``| `ffmpegOptions: ` |
Set the options given to ffmpeg
#### `--defaultAudio`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| 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
#### `--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
### Filename Template
#### `--fileName`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--fileName ${fileName}` | `string` | `No`| `NaN` | `[${service}] ${showTitle} - S${season}E${episode} [${height}p]`| `fileName: ` |
| Both | `--fileName ${fileName}` | `string` | `No`| `NaN` | `[${service}] ${showTitle} - S${season}E${episode} [${height}p]`| `fileName: ` |
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**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--numbers ${number}` | `number` | `No`| `NaN` | `2`| `numbers: ` |
| Both | `--numbers ${number}` | `number` | `No`| `NaN` | `2`| `numbers: ` |
Set how long a number in the title should be at least.
Set in config: 3; Episode number: 5; Output: 005
@ -402,52 +291,39 @@ Set in config: 1; Episode number: 20; Output: 20
#### `--override`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--override "${toOverride}='${value}'"` | `array` | `No`| `NaN` | ``| `override: ` |
| Both | `--override "${toOverride}='${value}'"` | `array` | `No`| `NaN` | ``| `override: ` |
Override a template variable
#### `--ccTag`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--ccTag ${tag}` | `string` | `No`| `NaN` | `cc`| `ccTag: ` |
Used to set the name for subtitles that contain tranlations for none verbal communication (e.g. signs)
### Debug
#### `--nosess`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--nosess ` | `boolean` | `No`| `NaN` | `false`| `nosess: ` |
| Both | `--nosess ` | `boolean` | `No`| `NaN` | `false`| `nosess: ` |
Reset session cookie for testing purposes
#### `--debug`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--debug ` | `boolean` | `No`| `NaN` | `false`| `debug: ` |
| Both | `--debug ` | `boolean` | `No`| `NaN` | `false`| `debug: ` |
Debug mode (tokens may be revealed in the console output)
### Utilities
#### `--service`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--service ${service}` | `string` | `Yes`| `NaN` | [`crunchy`, `hidive`, `ao`, `adn`] | ``| `service: ` |
| Both | `--service ${service}` | `string` | `Yes`| `NaN` | [`funi`, `crunchy`] | ``| `service: ` |
Set the service you want to use
#### `--update`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--update ` | `boolean` | `No`| `NaN` | `NaN` |
| Both | `--update ` | `boolean` | `No`| `NaN` | `NaN` |
Force the tool to check for updates (code version only)
#### `--skipUpdate`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| All | `--skipUpdate ` | `boolean` | `No`| `NaN` | `false`| `skipUpdate: ` |
If true, the tool won't check for updates
### Help
#### `--help`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| All | `--help ` | `boolean` | `No`| `-h` | `NaN` |
| Both | `--help ` | `boolean` | `No`| `-h` | `NaN` |
Show the help output
### GUI
Show the help output

View file

@ -1,107 +1,40 @@
# Anime Downloader NX by AniDL
[![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 diffrent sites. Currently supported are *Funimation* and *Crunchyroll*.
## 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* 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.
## Dependencies
## Prerequisites
* NodeJS >= 12.4.0 (https://nodejs.org/)
* NPM >= 6.9.0 (https://www.npmjs.org/)
* ffmpeg >= 4.0.0 (https://www.videohelp.com/software/ffmpeg)
* MKVToolNix >= 60.0.0 (https://www.videohelp.com/software/MKVToolNix)
* MKVToolNix >= 20.0.0 (https://www.videohelp.com/software/MKVToolNix)
### Paths Configuration
By default this application uses the following paths to programs (main executables):
* `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.
* [check dependencies](https://david-dm.org/anidl/funimation-downloader-nx)
### Example usage
## CLI Options
See [the documentation](https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md)
#### Logging in
Most services require you to be logged in, in order to download from, an example of how you would login is:
```shell
AniDL --service {ServiceName} --auth
```
#### 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)
## Build instructions
Please note that nodejs and npm must be installed in your system.
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.
### Run from TypeScript
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.
`cd` into the cloned directory and run `npm i`.
Afterwards run `npm run tsc false [true if you want gui, false otherwise]`.
If you want the `js` files you are done. Just `cd` into the `lib` folder.
If you want to package the application, please also `cd` into the `lib` folder and run `npx electron-builder build --publish=never [Your Options]` for the gui build. You may find the options [here](https://www.electron.build/cli).
Since the cli tool requires some more configuration, please see the `modules/build.ts` file and run the commands under `buildBinary`.

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

908
funi.ts Normal file
View file

@ -0,0 +1,908 @@
// modules build-in
import fs from 'fs';
import path from 'path';
// package json
import packageJson from './package.json';
// program name
const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api';
// modules extra
import * as shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
import hlsDownload, { HLSCallback } from '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 { Item } from './@types/items';
// 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';
// 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.log(`\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.log(`[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 }, dubLang: argv.dubLang, fnSlug: episodeData, s: argv.s, simul: argv.simul }, {
ass: false,
...argv
})).isOk !== true)
ok = false;
}
return ok;
}
else{
console.log('[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.log('[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.log('[ERROR]%s\n', ' No token found');
if (this.debug) {
console.log(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.log(`[ERROR] ${searchDataJSON.detail}`);
return { isOk: false, reason: new Error(searchDataJSON.defail) };
}
if(searchDataJSON.items && searchDataJSON.items.hits && log){
const shows = searchDataJSON.items.hits;
console.log('[INFO] Search Results:');
for(const ssn in shows){
console.log(`[#${shows[ssn].id}] ${shows[ssn].title}` + (shows[ssn].tx_date?` (${shows[ssn].tx_date})`:''));
}
}
if (log)
console.log('[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.log('[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.log('[ERROR] Show not found\n');
return { isOk: false, reason: new Error('Show not found') };
}
const showDataItem = showDataJSON.items[0];
console.log('[#%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('[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.log('[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 = []; 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
}
});
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.log(conOut);
}
if(fnSlug.length < 1){
if (log)
console.log('[INFO] Episodes not selected!\n');
return { isOk: true, value: [] } ;
}
else{
if (log)
console.log('[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 = [];
// 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.log(
'[INFO] %s - S%sE%s - %s',
ep.parent.title,
(ep.parent.seasonNumber ? ep.parent.seasonNumber : '?'),
(ep.number ? ep.number : '?'),
ep.title
);
console.log('[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)
};
}
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.log(`[#${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.log('[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.log('[ERROR] 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.log('[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, epsiode: 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 = [],
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.log('[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.log(`[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.log('[ERROR] 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.log(`[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.log(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.log(`[INFO] Servers available:\n\t${plServerList.join('\n\t')}`);
console.log(`[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.log(`[INFO] Selected layer: ${selectedQuality} (${plLayersRes[selectedQuality].width}x${plLayersRes[selectedQuality].height}) @ ${plSelectedServer}`);
console.log('[INFO] Stream URL:',videoUrl);
}
fnOutput = parseFileName(data.fileName, ([
['episode', isNaN(parseInt(fnEpNum as string)) ? fnEpNum : parseInt(fnEpNum as string)],
['title', epsiode.title],
['showTitle', epsiode.showTitle],
['season', season],
['width', plLayersRes[selectedQuality].width],
['height', plLayersRes[selectedQuality].height],
['service', 'Funimation']
] as [AvailableFilenameVars, string|number][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
} as Variable;
}), data.numbers, data.override);
if (fnOutput.length < 1)
throw new Error(`Invalid path generated for input ${data.fileName}`);
if (log)
console.log(`[INFO] Output filename: ${fnOutput.join(path.sep)}.ts`);
}
else if(data.x > plServerList.length){
if (log)
console.log('[ERROR] Server not selected!\n');
return;
}
else{
if (log)
console.log('[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.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}.ts`,
parent: {
title: epsiode.showTitle
},
title: epsiode.title,
image: epsiode.image
}) : 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.log('[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.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.audio.${plAud.language.code}.ts`,
parent: {
title: epsiode.showTitle
},
title: epsiode.title,
image: epsiode.image
}) : 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.log('[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.log('[ERROR] Failed to download subtitles!');
addSubs = false;
break;
}
}
if (addSubs && log)
console.log('[INFO] Subtitles downloaded!');
}
if((puraudio.length < 1 && audioAndVideo.length < 1) || (purvideo.length < 1 && audioAndVideo.length < 1)){
if (log)
console.log('\n[INFO] Unable to locate a video AND audio file\n');
return;
}
if(data.skipmux){
if (log)
console.log('[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.log('[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
});
if(mergerBin.MKVmerge){
await mergeInstance.merge('mkvmerge', mergerBin.MKVmerge);
}
else if(mergerBin.FFmpeg){
await mergeInstance.merge('ffmpeg', mergerBin.FFmpeg);
}
else{
if (log)
console.log('\n[INFO] Done!\n');
return true;
}
if (data.nocleanup) {
return true;
}
mergeInstance.cleanUp();
if (log)
console.log('\n[INFO] Done!\n');
return true;
}
public async downloadFile(filename: string, chunkList: {
segments: Record<string, unknown>[],
}, timeout: number, partsize: number, fsRetryTime: number, callback?: HLSCallback) {
const downloadStatus = await new hlsDownload({
m3u8json: chunkList,
output: `${filename + '.ts'}`,
timeout: timeout,
threads: partsize,
fsRetryTime: fsRetryTime * 1000,
callback
}).download();
return downloadStatus.ok;
}
public async getSubsUrl(m: MediaChild[], parentLanguage: TitleElement|undefined, data: FuniSubsData, episodeID: 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.log('[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 ?? 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 ? '.cc' : ''}`,
lang: a.lang,
url: a.url,
closedCaption: a.isCC
}));
}
}

3
gui.ts
View file

@ -1,3 +0,0 @@
process.env.isGUI = 'true';
import './modules/log';
import './gui/server/index';

2
gui/electron/src/.env Normal file
View file

@ -0,0 +1,2 @@
USE_BROWSER=true
TEST=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

164
gui/electron/src/index.ts Normal file
View file

@ -0,0 +1,164 @@
import { app, BrowserWindow, dialog, } from 'electron';
import path from 'path/posix';
import fs from 'fs';
import dotenv from 'dotenv';
import express from 'express';
import { Console } from 'console';
import json from '../../../package.json';
process.on('uncaughtException', (er, or) => {
console.error(er, or);
});
process.on('unhandledRejection', (er, pr) => {
console.log(er, pr);
});
const getDataDirectory = () => {
switch (process.platform) {
case 'darwin': {
if (!process.env.HOME) {
console.error('Unknown home directory');
process.exit(1);
}
return path.join(process.env.HOME, 'Library', 'Application Support', json.name);
}
case 'win32': {
if (!process.env.APPDATA) {
console.error('Unknown home directory');
process.exit(1);
}
console.log('Appdata', process.env.APPDATA);
return path.join(process.env.APPDATA, json.name);
}
case 'linux': {
if (!process.env.HOME) {
console.error('Unknown home directory');
process.exit(1);
}
return path.join(process.env.HOME, `.${json.name}`);
}
default: {
console.error('Unsupported platform!');
process.exit(1);
}
}
};
if (!fs.existsSync(getDataDirectory()))
fs.mkdirSync(getDataDirectory());
export { getDataDirectory };
process.env.contentDirectory = getDataDirectory();
import './menu';
if (fs.existsSync(path.join(__dirname, '.env')))
dotenv.config({ path: path.join(__dirname, '.env'), debug: true });
if (require('electron-squirrel-startup')) {
app.quit();
}
export const isWindows = process.platform === 'win32';
let mainWindow: BrowserWindow|undefined = undefined;
export { mainWindow };
const icon = path.join(__dirname, 'images', `Logo_Inverted.${isWindows ? 'ico' : 'png'}`);
if (!process.env.TEST) {
// eslint-disable-next-line no-global-assign
console = (() => {
const logFolder = path.join(getDataDirectory(), 'logs');
if (!fs.existsSync(logFolder))
fs.mkdirSync(logFolder);
if (fs.existsSync(path.join(logFolder, 'latest.log')))
fs.renameSync(path.join(logFolder, 'latest.log'), path.join(logFolder, `${Date.now()}.log`));
return new Console(fs.createWriteStream(path.join(logFolder, 'latest.log')));
})();
}
const createWindow = async () => {
// Create the browser window.
mainWindow = new BrowserWindow({
height: 600,
width: 800,
title: 'AniDL GUI BETA',
webPreferences: {
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
},
icon,
});
mainWindow.webContents.on('crashed', (e) => console.log(e));
if (!process.env.TEST) {
const response = dialog.showMessageBoxSync(mainWindow, {
title: 'Test Version Information',
message: 'I understand that this is a test version that is subject to changes and most certainly contains errors.'
+ '\nI understand that I am using this tool at my own risk.'
+ '\nI know that bugs or suggestions should be made to Izuco on Discord or under github@izuco.dev'
+ '\nI understand that I should thank Darekon for the art works and the concept art if I see him',
buttons: [
'Cancel',
'I understand'
],
type: 'info'
});
console.log(`[INFO] Popup response: ${response}`);
if (response !== 1 && response !== -1)
app.quit();
}
(await import('./messageHandler')).default(mainWindow);
if (!process.env.USE_BROWSER) {
const app = express();
// Path.sep seems to return / on windows with electron
// \\ in Filename on Linux is possible but I don't see another way rn
const sep = isWindows ? '\\' : '/';
const p = __dirname.split(sep);
p.pop();
p.push('build');
console.log(p.join(sep));
app.use(express.static(p.join(sep)));
await new Promise((resolve) => {
app.listen(3000, () => {
console.log('Express started');
resolve(undefined);
});
});
}
mainWindow.loadURL('http://localhost:3000');
if (process.env.TEST)
mainWindow.webContents.openDevTools();
};
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('quit', () => {
process.exit(0);
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

85
gui/electron/src/menu.ts Normal file
View file

@ -0,0 +1,85 @@
import { Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron';
import path from 'path';
import { getDataDirectory } from '.';
import json from '../../../package.json';
const template: (MenuItemConstructorOptions | MenuItem)[] = [
{
label: 'Edit',
submenu: [
{
role: 'undo'
},
{
role: 'redo'
},
{
type: 'separator'
},
{
role: 'cut'
},
{
role: 'copy'
},
{
role: 'paste'
}
]
},
{
label: 'Debug',
submenu: [
{
role: 'toggleDevTools'
},
{
label: 'Open log folder',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'logs'));
}
},
{
role: 'forceReload'
}
]
},
{
label: 'Help',
submenu: [
{
label: 'Version',
sublabel: json.version
},
{
label: 'GitHub',
click: () => {
shell.openExternal('https://github.com/anidl/multi-downloader-nx');
}
},
{
label: 'Report a Bug',
click: () => {
shell.openExternal(`https://github.com/anidl/multi-downloader-nx/issues/new?assignees=izu-co&labels=bug&template=bug.yml&title=BUG&version=${json.version}`);
}
},
{
type: 'separator'
},
{
label: 'Contributors',
click: () => {
shell.openExternal('https://github.com/anidl/multi-downloader-nx/graphs/contributors');
}
},
{
label: 'Discord',
click: () => {
shell.openExternal('https://discord.gg/qEpbWen5vq');
}
}
]
}
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));

View file

@ -0,0 +1,36 @@
import { BrowserWindow, ipcMain } from 'electron';
import { MessageHandler } from '../../../@types/messageHandler';
import Crunchy from './serviceHandler/crunchyroll';
import Funimation from './serviceHandler/funimation';
export default (window: BrowserWindow) => {
let handler: MessageHandler|undefined;
ipcMain.handle('setup', (_, data) => {
if (data === 'funi') {
handler = new Funimation(window);
} else if (data === 'crunchy') {
handler = new Crunchy(window);
}
});
ipcMain.on('changeProvider', (ev) => {
if (handler?.isDownloading())
return ev.returnValue = false;
handler = undefined;
ev.returnValue = true;
});
ipcMain.handle('type', async () => handler === undefined ? undefined : handler instanceof Funimation ? 'funi' : 'crunchy');
ipcMain.handle('auth', async (_, data) => handler?.auth(data));
ipcMain.handle('checkToken', async () => handler?.checkToken());
ipcMain.handle('search', async (_, data) => handler?.search(data));
ipcMain.handle('default', async (_, data) => handler?.handleDefault(data));
ipcMain.handle('availableDubCodes', async () => handler?.availableDubCodes());
ipcMain.handle('availableSubCodes', async () => handler?.availableSubCodes());
ipcMain.handle('resolveItems', async (_, data) => handler?.resolveItems(data));
ipcMain.handle('listEpisodes', async (_, data) => handler?.listEpisodes(data));
ipcMain.handle('downloadItem', async (_, data) => handler?.downloadItem(data));
ipcMain.handle('writeToClipboard', async (_, data) => handler?.writeToClipboard(data));
ipcMain.handle('openFolder', async (_, data) => handler?.openFolder(data));
ipcMain.on('isDownloading', (ev) => ev.returnValue = handler?.isDownloading());
};

View file

@ -0,0 +1,14 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('Electron', {
ipcRenderer: {
...ipcRenderer,
on: (name: string, handler: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.on(name, handler);
return ipcRenderer;
},
removeListener: (name: string, handler: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.removeListener(name, handler);
}
}
});

View file

@ -0,0 +1,73 @@
import { BrowserWindow, clipboard, dialog, shell } from 'electron';
import { DownloadInfo, FolderTypes, ProgressData } from '../../../../@types/messageHandler';
import { RandomEvent, RandomEvents } from '../../../../@types/randomEvents';
import { loadCfg } from '../../../../modules/module.cfg-loader';
export default class Base {
constructor(private window: BrowserWindow) {}
private downloading = false;
setDownloading(downloading: boolean) {
this.downloading = downloading;
}
getDownloading() {
return this.downloading;
}
alertError(error: Error) {
dialog.showMessageBoxSync(this.window, {
message: `${error.name ?? 'An error occured'}\n${error.message}`,
detail: error.stack,
title: 'Error',
type: 'error'
});
}
makeProgressHandler(videoInfo: DownloadInfo) {
return ((data: ProgressData) => {
const progress = (typeof data.percent === 'string' ?
parseFloat(data.percent) : data.percent) / 100;
this.window.setProgressBar(progress === 1 ? -1 : progress);
this.sendMessage({
name: 'progress',
data: {
downloadInfo: videoInfo,
progress: data
}
});
}).bind(this);
}
getWindow() {
return this.window;
}
sendMessage<T extends keyof RandomEvents>(data: RandomEvent<T>) {
this.window.webContents.send('randomEvent', data);
}
isDownloading() {
return this.downloading;
}
async writeToClipboard(text: string) {
clipboard.writeText(text, 'clipboard');
return true;
}
async openFolder(folderType: FolderTypes) {
const conf = loadCfg();
switch (folderType) {
case 'content':
shell.openPath(conf.dir.content);
break;
case 'config':
shell.openPath(conf.dir.config);
break;
}
}
}

View file

@ -1,129 +1,100 @@
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import Crunchy from '../../../crunchy';
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 CrunchyHandler extends Base implements MessageHandler {
private crunchy: Crunchy;
public name = 'crunchy';
constructor(ws: WebSocketHandler) {
super(ws);
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 };
}
public async handleDefault(name: string) {
return getDefault(name, this.crunchy.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.cr_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
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);
if (!res.isOk)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: a.data.map(a => a.mediaId),
title: a.episodeTitle,
parent: {
title: a.seasonTitle,
season: a.season.toString()
},
e: a.e,
image: a.image,
episode: a.episodeNumber
};
}));
return true;
}
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) {
this.crunchy.refreshToken();
return crunchySearch;
}
return { isOk: true, value: crunchySearch.value };
}
public async checkToken(): Promise<CheckTokenResponse> {
if (await this.crunchy.getProfile()) {
return { isOk: true, value: undefined };
} else {
return { isOk: false, reason: new Error('') };
}
}
public auth(data: AuthData) {
return this.crunchy.doAuth(data);
}
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
});
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' }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(res.reason);
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
import { BrowserWindow } from 'electron';
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import Crunchy from '../../../../crunchy';
import { ArgvType } from '../../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../../modules/module.langsData';
import Base from './base';
class CrunchyHandler extends Base implements MessageHandler {
private crunchy: Crunchy;
constructor(window: BrowserWindow) {
super(window);
this.crunchy = new Crunchy();
}
public async listEpisodes (id: string): Promise<EpisodeListResponse> {
return { isOk: true, value: (await this.crunchy.listSeriesID(id)).list };
}
public async handleDefault(name: string) {
return getDefault(name, this.crunchy.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray = [];
for(const language of languages){
if (language.cr_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<ResponseBase<QueueItem[]>> {
const res = await this.crunchy.downloadFromSeriesID(data.id, data);
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: a.data.map(a => a.mediaId),
title: a.episodeTitle,
parent: {
title: a.seasonTitle,
season: a.season.toString()
},
e: a.e,
episode: a.episodeNumber
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
this.crunchy.refreshToken();
const crunchySearch = await this.crunchy.doSearch(data);
if (!crunchySearch.isOk)
return crunchySearch;
return { isOk: true, value: crunchySearch.value };
}
public async checkToken(): Promise<CheckTokenResponse> {
if (await this.crunchy.getProfile()) {
return { isOk: true, value: undefined };
} else {
return { isOk: false, reason: new Error('') };
}
}
public auth(data: AuthData) {
return this.crunchy.doAuth(data);
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
const _default = buildDefault() as ArgvType;
await this.crunchy.refreshToken();
const res = await this.crunchy.downloadFromSeriesID(data.id, {
dubLang: data.dubLang,
e: data.e
});
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 }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(res.reason);
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
export default CrunchyHandler;

View file

@ -0,0 +1,110 @@
import { BrowserWindow } from 'electron';
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import Funimation from '../../../../funi';
import { ArgvType } from '../../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../../modules/module.langsData';
import Base from './base';
class FunimationHandler extends Base implements MessageHandler {
private funi: Funimation;
constructor(window: BrowserWindow) {
super(window);
this.funi = new Funimation();
}
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 = [];
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<ResponseBase<QueueItem[]>> {
const res = await this.funi.getShow(false, { ...data, id: parseInt(data.id) });
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: [a.episodeID],
title: a.title,
parent: {
title: a.seasonTitle,
season: a.seasonNumber
},
e: a.episodeID,
episode: a.epsiodeNumber,
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
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: DownloadData) {
this.setDownloading(true);
const res = await this.funi.getShow(false, { all: false, but: false, id: parseInt(data.id), e: data.e });
const _default = buildDefault() as ArgvType;
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 } }, { ..._default, callbackMaker: this.makeProgressHandler.bind(this), ass: true, fileName: data.fileName, q: data.q });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
export default FunimationHandler;

View file

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

View file

@ -1,2 +0,0 @@
PORT=3002
CI=false

28270
gui/react/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,38 @@
{
"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",
"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"
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@mui/icons-material": "^5.3.1",
"@mui/lab": "^5.0.0-alpha.66",
"@mui/material": "^5.3.1",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.0",
"@types/node": "^16.11.21",
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.11",
"notistack": "^2.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",
"typescript": "^4.5.5",
"web-vitals": "^2.1.4"
},
"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"
},
"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": [

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

@ -1,3 +0,0 @@
type FCWithChildren<T = object> = React.FC<{
children?: React.ReactNode[]|React.ReactNode
} & T>

View file

@ -1,10 +1,12 @@
import React from 'react';
import { Button, TextField, Box } from '@mui/material';
import { messageChannelContext } from './provider/MessageChannel';
import Layout from './Layout';
const App: React.FC = () => {
return (
<Layout />
);
};
}
export default App;

View file

@ -1,38 +1,17 @@
import React from 'react';
import AuthButton from './components/AuthButton';
import { Box, Button } from '@mui/material';
import MainFrame from './components/MainFrame/MainFrame';
import LogoutButton from './components/LogoutButton';
import AddToQueue from './components/AddToQueue/AddToQueue';
import { messageChannelContext } from './provider/MessageChannel';
import { ClearAll, Folder } from '@mui/icons-material';
import StartQueueButton from './components/StartQueue';
import MenuBar from './components/MenuBar/MenuBar';
import React from "react";
import AuthButton from "./components/AuthButton";
import { Box } from "@mui/material";
import MainFrame from "./components/MainFrame/MainFrame";
import LogoutButton from "./components/LogoutButton";
const Layout: React.FC = () => {
const messageHandler = React.useContext(messageChannelContext);
return <Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '100%', alignItems: 'center',}}>
<MenuBar />
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '93vw',
maxWidth: '93rem',
maxHeight: '3rem'
//backgroundColor: '#ffffff',
}}>
return <Box>
<Box sx={{ height: 50, mb: 4, display: 'flex', gap: 1 }}>
<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>
<AddToQueue />
<StartQueueButton />
</Box>
<MainFrame />
</Box>;
};
}
export default Layout;

View file

@ -1,7 +1,8 @@
import React from 'react';
import { Container, Box, ThemeProvider, createTheme, Theme } from '@mui/material';
import React from "react";
import { Container, Box, ThemeProvider, createTheme, Theme } from "@mui/material";
const makeTheme = (mode: 'dark'|'light') : Partial<Theme> => {
console.log(mode);
return createTheme({
palette: {
mode,
@ -9,11 +10,13 @@ const makeTheme = (mode: 'dark'|'light') : Partial<Theme> => {
});
};
const Style: FCWithChildren = ({children}) => {
const Style: React.FC = ({children}) => {
return <ThemeProvider theme={makeTheme('dark')}>
<Box sx={{ }}/>
{children}
<Container sx={{ mt: 3 }} maxWidth='xl'>
<Box sx={{ position: 'fixed', height: '100%', width: '100%', zIndex: -500, backgroundColor: 'rgb(0, 30, 60)', top: 0, left: 0 }}/>
{children}
</Container>
</ThemeProvider>;
};
}
export default Style;

View file

@ -1,27 +0,0 @@
import { Add } from '@mui/icons-material';
import { Box, Button, Dialog, Divider, Typography } from '@mui/material';
import React from 'react';
import DownloadSelector from './DownloadSelector/DownloadSelector';
import EpisodeListing from './DownloadSelector/Listing/EpisodeListing';
import SearchBox from './SearchBox/SearchBox';
const AddToQueue: React.FC = () => {
const [isOpen, setOpen] = React.useState(false);
return <Box>
<EpisodeListing />
<Dialog open={isOpen} onClose={() => setOpen(false)} maxWidth='md' PaperProps={{ elevation:4 }}>
<Box>
<SearchBox />
<Divider variant='middle'/>
<DownloadSelector onFinish={() => setOpen(false)} />
</Box>
</Dialog>
<Button variant='contained' onClick={() => setOpen(true)} sx={{ maxHeight: '2.3rem' }}>
<Add />
Add to Queue
</Button>
</Box>;
};
export default AddToQueue;

View file

@ -1,327 +0,0 @@
import React, { ChangeEvent } from 'react';
import { Box, Button, Divider, FormControl, InputBase, InputLabel, Link, MenuItem, Select, TextField, Tooltip, Typography } from '@mui/material';
import useStore from '../../../hooks/useStore';
import MultiSelect from '../../reusable/MultiSelect';
import { messageChannelContext } from '../../../provider/MessageChannel';
import LoadingButton from '@mui/lab/LoadingButton';
import { useSnackbar } from 'notistack';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
type DownloadSelectorProps = {
onFinish?: () => unknown
}
const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
const messageHandler = React.useContext(messageChannelContext);
const [store, dispatch] = useStore();
const [availableDubs, setAvailableDubs] = React.useState<string[]>([]);
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 () => {
/* If we don't wait the response is undefined? */
await new Promise((resolve) => setTimeout(() => resolve(undefined), 100));
const dubLang = messageHandler?.handleDefault('dubLang');
const subLang = messageHandler?.handleDefault('dlsubs');
const q = messageHandler?.handleDefault('q');
const fileName = messageHandler?.handleDefault('fileName');
const dlVideoOnce = messageHandler?.handleDefault('dlVideoOnce');
const result = await Promise.all([dubLang, subLang, q, fileName, dlVideoOnce]);
dispatch({
type: 'downloadOptions',
payload: {
...store.downloadOptions,
dubLang: result[0],
dlsubs: result[1],
q: result[2],
fileName: result[3],
dlVideoOnce: result[4],
}
});
setAvailableDubs(await messageHandler?.availableDubCodes() ?? []);
setAvailableSubs(await messageHandler?.availableSubCodes() ?? []);
})();
}, []);
const addToQueue = async () => {
setLoading(true);
const res = await messageHandler?.resolveItems(store.downloadOptions);
if (!res)
return enqueueSnackbar('The request failed. Please check if the ID is correct.', {
variant: 'error'
});
setLoading(false);
if (onFinish)
onFinish();
};
const listEpisodes = async () => {
if (!store.downloadOptions.id) {
return enqueueSnackbar('Please enter a ID', {
variant: 'error'
});
}
setLoading(true);
const res = await messageHandler?.listEpisodes(store.downloadOptions.id);
if (!res || !res.isOk) {
setLoading(false);
return enqueueSnackbar('The request failed. Please check if the ID is correct.', {
variant: 'error'
});
} else {
dispatch({
type: 'episodeListing',
payload: res.value
});
}
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 => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, fileName: e.target.value }
});
}} sx={{ width: '87%' }} label='Filename Overwrite' />
<Tooltip title={
<Typography>
Click here to see the documentation
</Typography>
} arrow placement='top'>
<Link href='https://github.com/anidl/multi-downloader-nx/blob/master/docs/DOCUMENTATION.md#filename-template' rel="noopener noreferrer" target="_blank">
<InfoOutlinedIcon sx={{
transition: '100ms',
'&:hover' : {
color: '#ffffff30',
}
}} />
</Link>
</Tooltip>
</Box>
</Box>
<Box sx={{width: '95%', height: '0.3rem', backgroundColor: '#ffffff50', borderRadius: '10px', marginTop: '10px'}}/>
<LoadingButton sx={{ margin: '15px', textTransform: 'none' }} loading={loading} onClick={addToQueue} variant='contained'>Add to Queue</LoadingButton>
</Box>;
};
export default DownloadSelector;

View file

@ -1,191 +0,0 @@
import { Box, List, ListItem, Typography, Divider, Dialog, Select, MenuItem, FormControl, InputLabel, Checkbox } from '@mui/material';
import { CheckBox, CheckBoxOutlineBlank } from '@mui/icons-material';
import React from 'react';
import useStore from '../../../../hooks/useStore';
import ContextMenu from '../../../reusable/ContextMenu';
import { useSnackbar } from 'notistack';
const EpisodeListing: React.FC = () => {
const [store, dispatch] = useStore();
const [season, setSeason] = React.useState<'all'|string>('all');
const { enqueueSnackbar } = useSnackbar();
const seasons = React.useMemo(() => {
const s: string[] = [];
for (const {season} of store.episodeListing) {
if (s.includes(season))
continue;
s.push(season);
}
return s;
}, [ store.episodeListing ]);
const [selected, setSelected] = React.useState<string[]>([]);
React.useEffect(() => {
setSelected(parseSelect(store.downloadOptions.e));
}, [ store.episodeListing ]);
const close = () => {
dispatch({
type: 'episodeListing',
payload: []
});
dispatch({
type: 'downloadOptions',
payload: {
...store.downloadOptions,
e: `${([...new Set([...parseSelect(store.downloadOptions.e), ...selected])]).join(',')}`
}
});
};
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' }}>
Episodes
</Typography>
<FormControl sx={{ mr: 2, mt: 2 }}>
<InputLabel id='seasonSelectLabel'>Season</InputLabel>
<Select labelId="seasonSelectLabel" label='Season' value={season} onChange={(e) => setSeason(e.target.value)}>
<MenuItem value='all'>Show all Epsiodes</MenuItem>
{seasons.map((a, index) => {
return <MenuItem value={a} key={`MenuItem_SeasonSelect_${index}`}>
{a}
</MenuItem>;
})}
</Select>
</FormControl>
</Box>
<List>
<ListItem sx={{ display: 'grid', gridTemplateColumns: '25px 1fr 5fr' }}>
<Checkbox
indeterminate={store.episodeListing.some(a => selected.includes(a.e)) && !store.episodeListing.every(a => selected.includes(a.e))}
checked={store.episodeListing.every(a => selected.includes(a.e))}
onChange={() => {
if (selected.length > 0) {
setSelected([]);
} else {
setSelected(getEpisodesForSeason(season).map(a => a.e));
}
}}
/>
</ListItem>
{getEpisodesForSeason(season).map((item, index, { length }) => {
const e = isNaN(parseInt(item.e)) ? item.e : parseInt(item.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));
}}>
{ isSelected ? <CheckBox /> : <CheckBoxOutlineBlank /> }
<Typography color='text.primary' sx={{ textAlign: 'center' }}>
{idStr}
</Typography>
<img ref={imageRef} style={{ width: 'inherit', maxHeight: '200px', minWidth: '150px' }} src={item.img} alt="thumbnail" />
<Box sx={{ display: 'flex', flexDirection: 'column', pl: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr min-content' }}>
<Typography color='text.primary' variant="h5">
{item.name}
</Typography>
<Typography color='text.primary'>
{item.time.startsWith('00:') ? item.time.slice(3) : item.time}
</Typography>
</Box>
<Typography color='text.primary' ref={summaryRef}>
{item.description}
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'fit-content 1fr' }}>
<Typography>
<br />
Available audio languages: {item.lang.join(', ')}
</Typography>
</Box>
</Box>
</ListItem>
<ContextMenu options={[ { text: 'Copy image URL', onClick: async () => {
await navigator.clipboard.writeText(item.img);
enqueueSnackbar('Copied URL to clipboard', {
variant: 'info'
});
}},
{
text: 'Open image in new tab',
onClick: () => {
window.open(item.img);
}
} ]} popupItem={imageRef} />
<ContextMenu options={[
{
onClick: async () => {
await navigator.clipboard.writeText(item.description!);
enqueueSnackbar('Copied summary to clipboard', {
variant: 'info'
});
},
text: 'Copy summary to clipboard'
}
]} popupItem={summaryRef} />
{index < length - 1 && <Divider />}
</Box>;
})}
</List>
</Dialog>;
};
const parseSelect = (s: string): string[] => {
const ret: string[] = [];
s.split(',').forEach(item => {
if (item.includes('-')) {
const split = item.split('-');
if (split.length !== 2)
return;
const match = split[0].match(/[A-Za-z]+/);
if (match && match.length > 0) {
if (match.index && match.index !== 0) {
return;
}
const letters = split[0].substring(0, match[0].length);
const number = parseInt(split[0].substring(match[0].length));
const b = parseInt(split[1]);
if (isNaN(number) || isNaN(b)) {
return;
}
for (let i = number; i <= b; i++) {
ret.push(`${letters}${i}`);
}
} else {
const a = parseInt(split[0]);
const b = parseInt(split[1]);
if (isNaN(a) || isNaN(b)) {
return;
}
for (let i = a; i <= b; i++) {
ret.push(`${i}`);
}
}
} else {
ret.push(item);
}
});
return [...new Set(ret)];
};
export default EpisodeListing;

View file

@ -1,9 +1,10 @@
import { Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField } from '@mui/material';
import { Check, Close } from '@mui/icons-material';
import React from 'react';
import { messageChannelContext } from '../provider/MessageChannel';
import Require from './Require';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItem, ListItemAvatar, TextField } from "@mui/material";
import { grey } from '@mui/material/colors'
import { Check, Close, CloseOutlined, PortraitOutlined } from '@mui/icons-material'
import React from "react";
import { messageChannelContext } from "../provider/MessageChannel";
import Require from "./Require";
import { useSnackbar } from "notistack";
const AuthButton: React.FC = () => {
const snackbar = useSnackbar();
@ -23,10 +24,11 @@ const AuthButton: React.FC = () => {
const [authed, setAuthed] = React.useState(false);
const checkAuth = async () => {
console.log(await messageChannel?.checkToken());
setAuthed((await messageChannel?.checkToken())?.isOk ?? false);
};
}
React.useEffect(() => { checkAuth(); }, []);
React.useEffect(() => { checkAuth() }, []);
const handleSubmit = async () => {
if (!messageChannel)
@ -52,7 +54,7 @@ const AuthButton: React.FC = () => {
}
await checkAuth();
setLoading(false);
};
}
return <Require value={messageChannel}>
<Dialog open={open}>
@ -106,7 +108,7 @@ const AuthButton: React.FC = () => {
</DialogActions>
</Dialog>
<Button startIcon={authed ? <Check />: <Close />} variant="contained" onClick={() => setOpen(true)}>Authenticate</Button>
</Require>;
};
</Require>
}
export default AuthButton;

View file

@ -1,37 +1,36 @@
import { ExitToApp } from '@mui/icons-material';
import { Button } from '@mui/material';
import React from 'react';
import useStore from '../hooks/useStore';
import { messageChannelContext } from '../provider/MessageChannel';
import Require from './Require';
import { ExitToApp } from "@mui/icons-material";
import { Button } from "@mui/material";
import React from "react";
import useStore from "../hooks/useStore";
import { messageChannelContext } from "../provider/MessageChannel";
import Require from "./Require";
const LogoutButton: React.FC = () => {
const messageChannel = React.useContext(messageChannelContext);
const [, dispatch] = useStore();
const logout = async () => {
if (await messageChannel?.isDownloading())
const logout = () => {
if (messageChannel?.isDownloading())
return alert('You are currently downloading. Please finish the download first.');
if (await messageChannel?.logout())
if (messageChannel?.logout())
dispatch({
type: 'service',
payload: undefined
});
})
else
alert('Unable to change service');
};
}
return <Require value={messageChannel}>
<Button
startIcon={<ExitToApp />}
variant='contained'
onClick={logout}
sx={{ maxHeight: '2.3rem' }}
>
Service select
</Button>
</Require>;
</Require>
};
}
export default LogoutButton;

View file

@ -0,0 +1,12 @@
import Queue from "./Queue/Queue";
import { Box } from "@mui/material";
import React from "react";
import EpisodeListing from "../Listing/EpisodeListing";
const Bottom: React.FC = () => {
return <Box sx={{ display: 'grid', gridTemplateColumns: '1fr' }}>
<Queue />
</Box>
}
export default Bottom;

View file

@ -0,0 +1,20 @@
import { Box, Divider, Typography } from "@mui/material";
import React from "react";
import useStore from "../../../../hooks/useStore";
const Queue: React.FC = () => {
const [ { queue } ] = useStore();
return <Box>
{queue.map((item, index) => {
return <Box key={`QueueItem_${index}`} sx={{ gap: 1, display: 'flex', flexDirection: 'column' }}>
<Typography color='text.primary'>
{`[${index}] S${item.parent.season}E${item.episode} - ${item.title} (${item.dubLang.join(', ')})`}
</Typography>
<Divider />
</Box>
})}
</Box>
}
export default Queue;

View file

@ -1,40 +1,47 @@
import React from 'react';
import { ExtendedProgress, QueueItem } from '../../../../../../@types/messageHandler';
import { RandomEvent } from '../../../../../../@types/randomEvents';
import { messageChannelContext } from '../../../provider/MessageChannel';
import React from "react";
import { ExtendedProgress } from "../../../../../../@types/messageHandler";
import { RandomEvent } from "../../../../../../@types/randomEvents";
import useStore from "../../../hooks/useStore";
import { messageChannelContext } from "../../../provider/MessageChannel";
const useDownloadManager = () => {
const [ { currentDownload }, dispatch ] = useStore();
const messageHandler = React.useContext(messageChannelContext);
const [progressData, setProgressData] = React.useState<ExtendedProgress|undefined>();
const [current, setCurrent] = React.useState<undefined|QueueItem>();
React.useEffect(() => {
const handler = (ev: RandomEvent<'progress'>) => {
console.log(ev.data);
setProgressData(ev.data);
};
}
messageHandler?.randomEvents.on('progress', handler);
const currentHandler = (ev: RandomEvent<'current'>) => {
setCurrent(ev.data);
};
const finishHandler = () => {
setProgressData(undefined);
};
dispatch({
type: 'finish',
payload: undefined
})
}
messageHandler?.randomEvents.on('progress', handler);
messageHandler?.randomEvents.on('current', currentHandler);
messageHandler?.randomEvents.on('finish', finishHandler);
return () => {
messageHandler?.randomEvents.removeListener('progress', handler);
messageHandler?.randomEvents.removeListener('finish', finishHandler);
messageHandler?.randomEvents.removeListener('current', currentHandler);
messageHandler?.randomEvents.removeListener('finish', finishHandler)
};
}, [messageHandler]);
React.useEffect(() => {
if (!currentDownload)
return;
if (messageHandler?.isDownloading())
return;
messageHandler?.downloadItem(currentDownload);
}, [currentDownload]);
return { data: progressData, current};
};
return progressData;
}
export default useDownloadManager;

View file

@ -0,0 +1,146 @@
import React from "react";
import { Backdrop, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, OutlinedInput, Select, 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 { Folder } from "@mui/icons-material";
const DownloadSelector: React.FC = () => {
const messageHandler = React.useContext(messageChannelContext);
const [store, dispatch] = useStore();
const [availableDubs, setAvailableDubs] = React.useState<string[]>([]);
const [availableSubs, setAvailableSubs ] = React.useState<string[]>([]);
const [ loading, setLoading ] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
React.useEffect(() => {
(async () => {
/* If we don't wait the response is undefined? */
await new Promise((resolve) => setTimeout(() => resolve(undefined), 100));
const dubLang = messageHandler?.handleDefault('dubLang');
const subLang = messageHandler?.handleDefault('dlsubs');
const q = messageHandler?.handleDefault('q');
const fileName = messageHandler?.handleDefault('fileName');
const result = await Promise.all([dubLang, subLang, q, fileName]);
dispatch({
type: 'downloadOptions',
payload: {
...store.downloadOptions,
dubLang: result[0],
dlsubs: result[1],
q: result[2],
fileName: result[3],
}
});
setAvailableDubs(await messageHandler?.availableDubCodes() ?? []);
setAvailableSubs(await messageHandler?.availableSubCodes() ?? []);
})();
}, []);
const addToQueue = async () => {
setLoading(true);
const res = await messageHandler?.resolveItems(store.downloadOptions);
if (!res || !res.isOk) {
console.error(res);
setLoading(false);
return enqueueSnackbar('The request failed. Please check if the ID is correct.', {
variant: 'error'
});
} else {
dispatch({
type: 'queue',
payload: res.value
});
}
setLoading(false);
}
const listEpisodes = async () => {
if (!store.downloadOptions.id) {
return enqueueSnackbar('Please enter a ID', {
variant: 'error'
});
}
setLoading(true);
const res = await messageHandler?.listEpisodes(store.downloadOptions.id);
if (!res || !res.isOk) {
console.log(res);
setLoading(false);
return enqueueSnackbar('The request failed. Please check if the ID is correct.', {
variant: 'error'
});
} else {
dispatch({
type: 'episodeListing',
payload: res.value
});
}
setLoading(false);
}
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, dubLang: e }
});
}}
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>
</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>
<Button variant="contained" startIcon={<Folder />} onClick={() => messageHandler?.openFolder('content')}>Open Output Directory</Button>
</Box>
</Box>
};
export default DownloadSelector;

View file

@ -0,0 +1,155 @@
import { Accordion, AccordionSummary, AccordionDetails, Box, List, ListItem, Typography, Backdrop, Divider, Container, Dialog, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
import { ExpandMore } from '@mui/icons-material'
import React from "react";
import useStore from "../../../hooks/useStore";
import { Episode } from "../../../../../../@types/messageHandler";
import ContextMenu from "../../reusable/ContextMenu";
import { messageChannelContext } from "../../../provider/MessageChannel";
const EpisodeListing: React.FC = () => {
const [store, dispatch] = useStore();
const [season, setSeason] = React.useState<'all'|string>('all');
const messageHandler = React.useContext(messageChannelContext);
const seasons = React.useMemo(() => {
const s: string[] = [];
for (const {season} of store.episodeListing) {
if (s.includes(season))
continue;
s.push(season);
}
return s;
}, [ store.episodeListing ])
const [selected, setSelected] = React.useState<string[]>([]);
React.useEffect(() => {
setSelected(parseSelect(store.downloadOptions.e));
}, [ store.episodeListing ])
const close = () => {
dispatch({
type: 'episodeListing',
payload: []
});
dispatch({
type: 'downloadOptions',
payload: {
...store.downloadOptions,
e: `${([...new Set([...parseSelect(store.downloadOptions.e), ...selected])]).join(',')}`
}
})
}
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' }}>
Episodes
</Typography>
<FormControl sx={{ mr: 2, mt: 2 }}>
<InputLabel id='seasonSelectLabel'>Season</InputLabel>
<Select labelId="seasonSelectLabel" label='Season' value={season} onChange={(e) => setSeason(e.target.value)}>
<MenuItem value='all'>Show all Epsiodes</MenuItem>
{seasons.map((a, index) => {
return <MenuItem value={a} key={`MenuItem_SeasonSelect_${index}`}>
{a}
</MenuItem>
})}
</Select>
</FormControl>
</Box>
<List>
{store.episodeListing.filter((a) => season === 'all' ? true : a.season === season).map((item, index, { length }) => {
const ref = React.createRef<HTMLDivElement>();
const e = isNaN(parseInt(item.e)) ? item.e : parseInt(item.e);
const isSelected = selected.includes(e.toString());
return <Box ref={ref} key={`Episode_List_Item_${index}`} sx={{
backdropFilter: isSelected ? 'brightness(1.5)' : '',
'&:hover': {
backdropFilter: 'brightness(1.5)'
}
}}
onClick={() => {
let arr: string[] = [];
if (isSelected) {
arr = [...selected.filter(a => a !== e)];
} else {
arr = [...selected, e.toString()];
}
setSelected(arr);
}}>
<ListItem sx={{ display: 'grid', gridTemplateColumns: '50px 1fr 5fr' }}>
<Typography color='text.primary' sx={{ textAlign: 'center' }}>
{e}
</Typography>
<img style={{ width: 'inherit', maxHeight: '200px' }} src={item.img}></img>
<Box sx={{ display: 'flex', flexDirection: 'column', pl: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr min-content' }}>
<Typography color='text.primary' variant="h5">
{item.name}
</Typography>
<Typography color='text.primary'>
{item.time.startsWith('00:') ? item.time.slice(3) : item.time}
</Typography>
</Box>
<Typography color='text.primary'>
{item.description}
</Typography>
</Box>
</ListItem>
<ContextMenu popupItem={ref} options={[
{ text: 'Copy title', onClick: () => messageHandler?.writeToClipboard(item.name) },
{ text: 'Copy description', onClick: () => messageHandler?.writeToClipboard(item.description) },
{ text: 'Copy image URL', onClick: () => messageHandler?.writeToClipboard(item.img) },
"divider",
{ text: 'Copy everything', onClick: () => messageHandler?.writeToClipboard(JSON.stringify(item, null, 2)) },
]} />
{index < length - 1 && <Divider />}
</Box>
})}
</List>
</Dialog>
}
const parseSelect = (s: string): string[] => {
const ret: string[] = [];
s.split(',').forEach(item => {
if (item.includes('-')) {
let split = item.split('-');
if (split.length !== 2)
return;
const match = split[0].match(/[A-Za-z]+/);
if (match && match.length > 0) {
if (match.index && match.index !== 0) {
return;
}
const letters = split[0].substring(0, match[0].length);
const number = parseInt(split[0].substring(match[0].length));
const b = parseInt(split[1]);
if (isNaN(number) || isNaN(b)) {
return;
}
for (let i = number; i <= b; i++) {
ret.push(`${letters}${i}`);
}
} else {
const a = parseInt(split[0]);
const b = parseInt(split[1]);
if (isNaN(a) || isNaN(b)) {
return;
}
for (let i = a; i <= b; i++) {
ret.push(`${i}`);
}
}
} else {
ret.push(item);
}
})
return [...new Set(ret)];
}
export default EpisodeListing;

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,11 +1,26 @@
import { Box } from '@mui/material';
import React from 'react';
import Queue from './Queue/Queue';
import { Box, Divider } from "@mui/material";
import React from "react";
import Bottom from "./Bottom/Bottom";
import DownloadSelector from "./DownloadSelector/DownloadSelector";
import EpisodeListing from "./Listing/EpisodeListing";
import './MainFrame.css';
import Progress from "./Progress/Progress";
import SearchBox from "./SearchBox/SearchBox";
const MainFrame: React.FC = () => {
return <Box sx={{ }}>
<Queue />
</Box>;
};
return <Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', borderCollapse: 'collapse' }}>
<Box sx={{ border: '2px solid white' }}>
<SearchBox />
<Divider variant='middle' className="divider-width" light sx={{ color: 'text.primary', fontSize: '1.2rem' }}>Options</Divider>
<DownloadSelector />
<Divider variant='middle' className="divider-width" light />
<Bottom />
</Box>
<Box sx={{ marginLeft: 1 }}>
<Progress />
</Box>
<EpisodeListing />
</Box>
}
export default MainFrame;

View file

@ -0,0 +1,96 @@
import { Close } from "@mui/icons-material";
import { Box, Skeleton, Typography } from "@mui/material";
import React from "react";
import { messageChannelContext } from "../../../provider/MessageChannel";
import LinearProgressWithLabel from "../../reusable/LinearProgressWithLabel";
import useDownloadManager from "../DownloadManager/DownloadManager";
const Progress: React.FC = () => {
const data = useDownloadManager();
return data ? <Box sx={{ display: 'grid', gridTemplateRows: '1fr 2fr', height: 'auto' }}>
<img style={{ maxWidth: '100%', maxHeight: '100%', border: '2px solid white', padding: 8 }} src={data.downloadInfo.image}></img>
<Box sx={{ display: 'grid', gridTemplateRows: '1ft fit-content', gap: 1 }}>
<table>
<tbody style={{ verticalAlign: 'text-top' }}>
<tr>
<td>
<Typography color='text.primary'>
Title:
</Typography>
</td>
<td>
<Typography color='text.primary'>
{data.downloadInfo.title}
</Typography>
</td>
</tr>
<tr>
<td>
<Typography color='text.primary'>
Season:
</Typography>
</td>
<td>
<Typography color='text.primary'>
{data.downloadInfo.parent.title}
</Typography>
</td>
</tr>
<tr>
<td>
<Typography color='text.primary' sx={{ verticalAlign: 'text-top' }}>
Filename:
</Typography>
</td>
<td>
<Typography color='text.primary'>
{data.downloadInfo.fileName}
</Typography>
</td>
</tr>
<tr>
<td>
<Typography color='text.primary' sx={{ verticalAlign: 'text-top' }}>
Progress:
</Typography>
</td>
<td>
<Typography color='text.primary'>
{`${data.progress.cur} / ${data.progress.total} - ${data.progress.percent}%`}
</Typography>
</td>
</tr>
</tbody>
</table>
<Box>
<Typography color='text.primary'>
{`ETA ${formatTime(data.progress.time)}`}
</Typography>
<Box sx={{ width: '100%' }}>
<LinearProgressWithLabel sx={{ height: '10px' }} value={typeof data.progress.percent === 'string' ? parseInt(data.progress.percent) : data.progress.percent} />
</Box>
</Box>
</Box>
</Box> : <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Skeleton animation={false} variant="rectangular" sx={{ width: '100%', height: '60%', maxHeight: 200 }}/>
<Skeleton animation={false} variant="text"/>
<Skeleton animation={false} variant="text" />
<Skeleton animation={false} variant="text" />
</Box>
}
const formatTime = (time: number) => {
let seconds = Math.ceil(time / 1000);
let minutes = 0;
if (seconds >= 60) {
minutes = Math.floor(seconds / 60);
seconds = seconds % 60;
}
return `${minutes != 0 ? `${minutes} Minutes ` : ''}${seconds !== 0 ? `${seconds} Seconds` : ''}`
}
export default Progress;

View file

@ -1,420 +0,0 @@
import { Badge, Box, Button, CircularProgress, Divider, IconButton, LinearProgress, Skeleton, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { messageChannelContext } from '../../../provider/MessageChannel';
import { queueContext } from '../../../provider/QueueProvider';
import DeleteIcon from '@mui/icons-material/Delete';
import useDownloadManager from '../DownloadManager/DownloadManager';
const Queue: React.FC = () => {
const { data, current } = useDownloadManager();
const queue = React.useContext(queueContext);
const msg = React.useContext(messageChannelContext);
if (!msg)
return <>Never</>;
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>
</Box>
</Box>
</Box>
</>
}
{
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>
</Box>
</Box>
</Box>
</>
}
{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',
}}>
{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>
</Box>
</Box>
</Box>
;
})}
</> : <Box sx={{
display: 'flex',
width: '100%',
height: '12rem',
flexDirection: 'column',
alignItems: 'center',
}}>
<Typography color='text.primary' sx={{
fontSize: '2rem',
margin: '10px'
}}>
Selected episodes will be shown here
</Typography>
<Box sx={{
display: 'flex',
margin: '10px'
}}>
<Skeleton variant='rectangular' height={'10rem'} width={'20rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Box sx={{
display: 'flex',
flexDirection: 'column',
}}>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
</Box>
</Box>
<Box sx={{
display: 'flex',
margin: '10px'
}}>
<Skeleton variant='rectangular' height={'10rem'} width={'20rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Box sx={{
display: 'flex',
flexDirection: 'column',
}}>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
<Skeleton variant='text' height={'100%'} width={'30rem'} sx={{ margin: '5px', borderRadius: '5px' }}/>
</Box>
</Box>
</Box>;
};
const formatTime = (time: number) => {
time = Math.floor(time / 1000);
const minutes = Math.floor(time / 60);
time = time % 60;
return `${minutes.toFixed(0).length < 2 ? `0${minutes}` : minutes}m${time.toFixed(0).length < 2 ? `0${time}` : time}s`;
};
export default Queue;

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