Compare commits

...

163 commits

Author SHA1 Message Date
AnimeDL
64c927c761 Fix Docker Release + Documentation 2024-08-15 19:28:29 +00:00
AnimeDL
16e7fcb71e Fix Docker Release 2024-08-15 12:27:56 -07:00
AnimeDL
d61d7e471f
Update docker.yml
Change docker account
2024-08-15 11:40:39 -07:00
AnimeDL
64272b7689 [ADN] Fix DE only streams 2024-08-15 10:12:21 -07:00
AnimeDL
35777ecb58 [ADN] Forgot to fix login 2024-07-28 09:15:29 -07:00
AnimeDL
423ad7b2c9 [ADN] Fix refresh token request 2024-07-28 00:03:52 -07:00
AnimeDL
5cbead06bc Minor grammar fix 2024-07-18 09:44:12 -07:00
AnimeDL
1704bfb4ec [CR} Hotfix downloading
Pretty hacked together, but should work just fine. I'll work on a proper removal of the old API here soon + Documentation
2024-07-08 23:56:45 +00:00
AnimeDL
b488834c0f [CR} Hotfix downloading
Pretty hacked together, but should work just fine. I'll work on a proper removal of the old API here soon
2024-07-08 16:56:13 -07:00
AnimeDL
8d59666a6c [CR] Fix issue with too many streams when novids and noaudio
Fixes issue where you would have too many active streams if using no videos and no audio downloading
2024-06-29 07:49:13 -07:00
AnimeDL
c14a963024 [CR] Add device_id caching
Fixes issue with creating a lot of random device IDs by saving the created device id in the config + Documentation
2024-06-28 23:52:33 +00:00
AnimeDL
0026de73bf [CR] Add device_id caching
Fixes issue with creating a lot of random device IDs by saving the created device id in the config
2024-06-28 16:52:05 -07:00
AnimeDL
d3238d22ba [CR] Whoops
Forgot the other 3 login places + Documentation
2024-06-28 16:32:14 +00:00
AnimeDL
ab090a6858 [CR] Whoops
Forgot the other 3 login places
2024-06-28 09:31:42 -07:00
AnimeDL
64783a0529 [CR] Hotfix login
Also increment version + Documentation
2024-06-28 16:21:11 +00:00
AnimeDL
05d679e6ca [CR] Hotfix login
Also increment version
2024-06-28 09:20:35 -07:00
AnimeDL
cd9586ab13 [CR] Migrate to android token
Will require a fresh login.
Web wasn't working for password auth, for some reason.

Increment version + Documentation
2024-06-24 17:33:48 +00:00
AnimeDL
0a7cfcd917 [CR] Migrate to android token
Will require a fresh login.
Web wasn't working for password auth, for some reason.

Increment version
2024-06-24 10:33:08 -07:00
AnimeDL
ff978e2c88 Increment version + Documentation 2024-06-23 15:28:00 -07:00
AnimeDL
d81ca76594 Code cleanup
Thanks to TypeScript 5.5, this workaround is no longer needed

Increment version
2024-06-23 15:28:00 -07:00
AnimeDL
9457ee6d26 Minor types change 2024-06-22 16:00:15 -07:00
AnimeDL
9a94c33c8b [CR] Disable Non-Play streams
Disables the non-play streams since they don't work ATM. If the situation changes, I'll either re-enable them, or remove them in a future update
2024-06-22 15:58:44 -07:00
AnimeDL
e88352af3f [CR] Header changes 2024-06-22 15:57:39 -07:00
AnimeDL
870b775175 [CR] Clear stream ASAP
Clears the stream as soon as it's done being requested, rather than when the download is finished
2024-06-22 15:57:27 -07:00
AnimeDL
78f5016dd3 [CR] Migrate to web token
This will require a fresh login
2024-06-21 10:06:34 -07:00
AnimeDL
eaec9e62a7 [CR] Whoops 2024-06-21 10:03:18 -07:00
AnimeDL
f92e8dfacb spelling fix 2024-06-21 10:03:03 -07:00
AnimeDL
a6d740e9e9 Some code cleanup 2024-06-20 13:32:27 -07:00
AnimeDL
436a4ca4d1 Improve private key detection 2024-06-20 13:20:45 -07:00
AnimeDL
ea7df30aa7 Bump version + Documentation 2024-06-20 19:58:17 +00:00
AnimeDL
240ff3870b Bump version 2024-06-20 12:57:47 -07:00
AnimeDL
1340624742 Add zh-HK language + Documentation 2024-06-20 19:56:17 +00:00
AnimeDL
b0b7cffddf Add zh-HK language 2024-06-20 12:55:43 -07:00
AnimeDL
fe3f978082 Bump GUI dependencies 2024-06-20 12:53:55 -07:00
AnimeDL
0045abe08c update typescript 2024-06-20 10:57:03 -07:00
AnimeDL
ef975868a3 [CR] Rename crunchy play stream flag + Documentation 2024-06-20 17:39:15 +00:00
AnimeDL
345aa0f267 [CR] Rename crunchy play stream flag 2024-06-20 10:38:42 -07:00
AnimeDL
1f8ddb27a1 Delete weirdly unused code
inb4 somehow this breaks the whole project
2024-06-20 10:34:22 -07:00
AnimeDL
575ea260b6 [CR] Update mobile basic auth header 2024-06-20 10:01:03 -07:00
AnimeDL
9fdc1ac4db Bump node ver to 20 + Documentation 2024-06-20 04:58:14 +00:00
AnimeDL
cf921295f8 Bump node ver to 20 2024-06-19 21:54:47 -07:00
AnimeDL
ed22970346 Dependency bump 2024-06-19 21:53:12 -07:00
AnimeDL
5f034dc348 Add esbuild step to build process
Seems to run a bit faster, also allows for newer node versions with pkg, and seems to also decrease false-positives. This likely can be used to further improve our build process
2024-06-19 21:52:02 -07:00
AnimeDL
c294cdc280 [CR] Add crunchy play streams selector 2024-06-19 20:28:43 -07:00
AnimeDL
16dbc4f1eb Increment version + Documentation 2024-06-18 22:18:53 +00:00
AnimeDL
fba1b1cf22 Increment version 2024-06-18 15:18:17 -07:00
AnimeDL
141fdcf552 [HD] Added fallback to seriesTitle
Added fallback to seriesTitle when seasonTitle cannot be parsed (usually due to the seasonNumber being wrong)
2024-06-18 15:12:22 -07:00
AnimeDL
3aa844f90b Add .idea to gitignore 2024-06-17 08:58:17 -07:00
AnimeDL
be95c1f3bc [HD] Fix crashing issue in certain circumstances
Fixes a crashing issue when an API request fails with specific types of errors (such as ECONNRESET)
2024-05-30 09:53:19 -07:00
AnimeDL
6275d5abe3 Increment version + Documentation 2024-05-29 02:35:29 +00:00
AnimeDL
85c5d45829 Increment version 2024-05-28 19:34:58 -07:00
AnimeDL
9feb3d2f13 [CR] Hotfix DRM request
Addresses #701
2024-05-28 19:34:21 -07:00
AnimeDL
8b5cafff3d [CR] Hotfix stream deletion if slow download
Fixes issue where if someone's internet, or the server is so slow that the session expires, it would fail to delete the watch session
2024-05-23 17:54:54 -07:00
AnimeDL
9ea6258fec [CR] Implement deleting streams
Allows endless non-drm downloads + Documentation
2024-05-23 16:46:30 +00:00
AnimeDL
fc0736c686 [CR] Implement deleting streams
Allows endless non-drm downloads
2024-05-23 09:45:52 -07:00
AnimeDL
dbc2c7d52b [CR] Hotfix non-DRM 2024-05-22 06:49:21 -07:00
AnimeDL
38f849f1a8
Merge pull request #685 from Denoder/patch-1
[HiDive] Remove unreleased episodes from list
2024-05-21 07:28:06 -07:00
Tera
33afc263e7
[HiDive] Remove unreleased episodes from list 2024-05-21 10:56:07 +03:00
Tera
ab73931fb9
[HiDive] Remove unreleased episodes from list
Allow listing to be visible but now allow it in the episodes list if it's not available
2024-05-19 00:11:51 +03:00
Tera
87c7de7417
Update hidive.ts
Account for when the episode title is added, but still not released, when it's not available there's a 10 second clip.
2024-05-19 00:06:09 +03:00
AnimeDL
f1042ded9f [ADN] Fix some output problems
Fix season detection, and fix null instead of show title in some circumstances + Documentation
2024-05-17 23:53:56 +00:00
AnimeDL
8dd0725f9a [ADN] Fix some output problems
Fix season detection, and fix null instead of show title in some circumstances
2024-05-17 16:53:10 -07:00
AnimeDL
5730450e11
Merge pull request #692 from anidl/dependabot/npm_and_yarn/ts-proto-1.176.0
Bump ts-proto from 1.171.0 to 1.176.0
2024-05-16 21:44:06 -07:00
dependabot[bot]
8da4074b1b
Bump ts-proto from 1.171.0 to 1.176.0
Bumps [ts-proto](https://github.com/stephenh/ts-proto) from 1.171.0 to 1.176.0.
- [Release notes](https://github.com/stephenh/ts-proto/releases)
- [Changelog](https://github.com/stephenh/ts-proto/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stephenh/ts-proto/compare/v1.171.0...v1.176.0)

---
updated-dependencies:
- dependency-name: ts-proto
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-16 07:01:09 +00:00
Tera
130fa5ee11
[HiDive] Remove unreleased episodes from list
When doing a range download/single selection of an anime that's currently airing episodes that haven't released will still get downloaded at 0 bytes. This will skip them if they have a date range in them, rather than an appropriate title.
2024-05-10 00:27:35 +03:00
AnimeDL
b2488edc02 [HD] Fix movie downloading
Fixes #672
2024-04-23 14:35:56 -07:00
AnimeDL
773bbf034c Add SSL support to websocket 2024-04-23 11:10:27 -07:00
AnimeDL
d507135eaa [ADN] Add episode ID to listing 2024-04-21 08:52:12 -07:00
AnimeDL
24191c91d0 Merge pull request #666 from anidl/v5
Release of v5 with support for AnimeOnegai and AnimationDigitalNetwork + Documentation
2024-04-19 23:04:22 +00:00
AnimeDL
2fe20c35f8
Merge pull request #666 from anidl/v5
Release of v5 with support for AnimeOnegai and AnimationDigitalNetwork
2024-04-19 16:03:57 -07:00
AnimeDL
afefbbf9a5
Merge branch 'master' into v5 2024-04-19 15:50:20 -07:00
AnimeDL
b2a602e96e Increment version + Documentation 2024-04-19 22:45:30 +00:00
AnimeDL
e47deb7c57 Increment version 2024-04-19 15:45:21 -07:00
AnimeDL
5b8c497800 [CR] Fix Non-DRM downloading
Thanks bytedream for your reverse engineering
2024-04-19 15:44:40 -07:00
AnimeDL
fb58d306bc [CR] Fix Non-DRM downloading
Thanks bytedream for your reverse engineering
2024-04-19 15:42:59 -07:00
AnimeDL
282fc1ac1a fetch module, give more detailed errors 2024-04-19 15:40:36 -07:00
AnimeDL
c74e6fcb18 [AO] Fix downloading 2024-04-18 13:36:19 -07:00
AnimeDL
39fec7c35c Request Module Fixes 2024-04-18 13:33:34 -07:00
AnimeDL
591fac8b08 [AO] Fix language detection with spaces 2024-04-18 06:25:32 -07:00
AnimeDL
f1982d1cd5 [AO] Fix audio res output 2024-04-17 12:46:35 -07:00
AnimeDL
ba08c7b4e7 [AO] [ADN] Improve API locale handling 2024-04-17 11:59:47 -07:00
AnimeDL
78630bb5f7 [AO] Handle failed download more gracefully 2024-04-17 11:43:53 -07:00
AnimeDL
e3a64abfd8 byterange parsing: Add warning if playlist fails to parse 2024-04-17 10:51:57 -07:00
AnimeDL
0403ee0690 [AO] Remove debug file write 2024-04-17 10:51:34 -07:00
AnimeDL
bc1f69b3b1 Increment version number 2024-04-16 19:54:16 -07:00
AnimeDL
78a16410cc [AO] Fix downloading of movies 2024-04-16 19:42:40 -07:00
AnimeDL
8103bc09d2 [AO] Add additional languages
Also adds warning about unknown languages, and fixes unknown language identifier
2024-04-16 18:34:08 -07:00
AnimeDL
4d134a613d Add unknown option suggestion and warning 2024-04-16 08:35:20 -07:00
AnimeDL
0927e8287c change all console logs to log4js 2024-04-16 08:07:54 -07:00
AnimeDL
52aaef0515 [GUI] Add script to start dev server 2024-04-16 08:07:47 -07:00
AnimeDL
e5c62a9ed4 Add new services to github template 2024-04-15 17:58:17 -07:00
AnimeDL
e7dfc28513 [ADN] Add Chapter Support 2024-04-15 11:02:16 -07:00
AnimeDL
4b8e683bba Decrease search unbounce to 500ms 2024-04-14 16:07:25 -07:00
AnimeDL
3957977a2c eslint fixes 2024-04-14 15:41:55 -07:00
AnidlSupport
56be50c43a Fix select all button for seasons 2024-04-14 14:19:29 -07:00
AnidlSupport
de0b33c7c6 Fix WS version spam 2024-04-14 14:19:07 -07:00
AnidlSupport
6093c202d2 Add start script to gui 2024-04-14 14:18:19 -07:00
AnimeDL
da2a8ffbff [GUI] Fix issue with dub selection and noaudio flag 2024-04-14 11:13:45 -07:00
AnimeDL
1fa4d5bbbb options fixes 2024-04-14 10:00:56 -07:00
AnimeDL
3486ec67a7 [AO] Fix bug with --novids preventing audio download 2024-04-14 09:37:44 -07:00
AnimeDL
0883776580 json2ass: Add unknown style warning 2024-04-13 18:08:18 -07:00
AnimeDL
6629a2ac87 vtt2ass: Add support for right/left styles 2024-04-13 17:55:02 -07:00
AnimeDL
3d3a94c991 vtt2ass: Add support for text-decoration
Adds support for underline text-decoration subtitles, fixes #658
2024-04-13 17:21:57 -07:00
AnimeDL
6c71f0b808 Update .gitignore 2024-04-13 17:20:46 -07:00
AnimeDL
2efc3683b2 [ADN] Improve season number detection logic 2024-04-13 12:55:15 -07:00
AnimeDL
b453d1927a [ADN] Improve season detection 2024-04-13 12:25:00 -07:00
AnimeDL
6aa37c3426 [AO] Improve season detection 2024-04-13 12:21:51 -07:00
AnimeDL
b0790145cc [ADN] More subtitle fixes 2024-04-13 12:10:41 -07:00
AnimeDL
a472ab5dd3 [ADN] Fix subtitles for some players 2024-04-13 11:29:53 -07:00
AnimeDL
533818b812 [ADN] Subtitle style improvements 2024-04-13 10:58:23 -07:00
AnimeDL
257c3b6266 [ADN] Fix issue with --novids subtitle names 2024-04-13 10:49:26 -07:00
AnimeDL
e61c1717ae [ADN] Fix crash issue if no episodes were found 2024-04-13 10:35:53 -07:00
AnimeDL
82146f2c51 [AO] Minor refactor 2024-04-13 10:33:25 -07:00
AnimeDL
ac06400d2a [ADN] Fix issue with --numbers 2024-04-13 10:33:10 -07:00
AnimeDL
a1feba6e6f eslint fixes 2024-04-13 08:49:43 -07:00
AnimeDL
bc79628f4f --locale fixes 2024-04-13 08:20:22 -07:00
AnimeDL
f39645166e
Merge pull request #660 from anidl/adn-support
Add ADN Support
2024-04-13 08:09:12 -07:00
AnimeDL
d260d8f3e6
Merge branch 'v5' into adn-support 2024-04-13 08:06:51 -07:00
AnimeDL
cd0476b700
Merge pull request #661 from anidl/animeonegai
Add AnimeOnegai Support
2024-04-13 07:51:25 -07:00
AnimeDL
01abffa85f
Merge branch 'v5' into animeonegai 2024-04-13 07:50:20 -07:00
AnimeDL
71ae48000b
Merge pull request #662 from anidl/remove-funi
Remove Funimation Support
2024-04-13 07:40:01 -07:00
AnimeDL
b42a543ed5
Merge branch 'v5' into remove-funi 2024-04-13 07:39:37 -07:00
AnimeDL
3952ee4376
Merge pull request #663 from anidl/remove-old-hd-api
Remove old HD API
2024-04-13 07:38:20 -07:00
AnimeDL
7107cef7a4
Merge branch 'v5' into remove-old-hd-api 2024-04-13 07:36:32 -07:00
AnimeDL
c06b990bf5 [ADN] Fix locale downloading issue 2024-04-12 17:48:52 -07:00
AnimeDL
a29807895c [AO] Fix bug with subtitles being overwritten 2024-04-12 16:23:34 -07:00
AnimeDL
f5f32fa701 Add support for AnimationDigitalNetwork
This service brought me great pain
2024-04-12 16:19:23 -07:00
AnimeDL
e9c040ceb7 Update ignored token files 2024-04-11 13:17:29 -07:00
AnimeDL
d2117a1390 [AO] Add --locale support
Currently known supported API locales are "pt" and "es"
2024-04-11 13:11:33 -07:00
AnimeDL
67cdc42d64 [AO] Add access check 2024-04-11 10:35:15 -07:00
AnimeDL
4b5b3919f5 Make sure segments are only generated if none exist
Makes sure that it only generates byterange segments for the mpd if there are non already present.
2024-04-11 08:30:51 -07:00
AnimeDL
79fc6584d7 Make sure segments are only generated if none exist
Makes sure that it only generates byterange segments for the mpd if there are non already present.
2024-04-11 08:29:48 -07:00
AnimeDL
44381a04be [CR] Crunchy Hotfix + Documentation 2024-04-11 15:10:19 +00:00
AnimeDL
9972a48366 [CR] Crunchy Hotfix 2024-04-11 08:09:48 -07:00
AnimeDL
4c4436814b Initial commit to add AnimeOnegai 2024-04-10 22:12:57 -07:00
AnimeDL
b1bae92308 Allow for byterange mpd/dash downloads
Prep for new service
2024-04-10 22:03:17 -07:00
AnimeDL
84ebabffc8 [CR] Fix temp file naming 2024-04-09 15:59:30 -07:00
AnimeDL
6b913d6e85 Set version for release + Documentation 2024-04-09 22:19:14 +00:00
AnimeDL
5226b963ee Set version for release 2024-04-09 15:18:27 -07:00
AnimeDL
3567edae4b Use temp file for decryption
Fixes issue with unicode and path length limits
2024-04-09 14:37:21 -07:00
AnimeDL
d6db234ad1 Fix Readme 2024-04-09 13:56:53 -07:00
AnimeDL
cee5207ac1 Increment version + Documentation 2024-04-09 20:23:39 +00:00
AnimeDL
00a4f7b9ee Increment version 2024-04-09 13:23:17 -07:00
AnimeDL
93ace68398 Improve readme 2024-04-09 13:22:49 -07:00
AnimeDL
66b219ea0a Update .gitignore 2024-04-09 13:09:02 -07:00
AnimeDL
0d065fdd6a [CR] Rewrite how requests are made
Should stop cloudflare errors
2024-04-09 13:08:15 -07:00
AnimeDL
68e4a344d8 Update default cli-defaults.yml 2024-04-08 14:14:59 -07:00
AnimeDL
a022855400 Add missing dev dep 2024-04-08 14:14:47 -07:00
AnimeDL
469fd1b4a4 [CR] Add Episode chapter if no start chapters 2024-04-08 12:32:04 -07:00
AnimeDL
413dea6564 Compress built code 2024-04-08 09:42:25 -07:00
AnimeDL
551b27280e Completely Redo GUI build code 2024-04-08 09:42:15 -07:00
AnimeDL
6e4e10930b Bump Deps & Remove UnusedDeps
Bumps dependencies, and removes unused dependencies
2024-04-07 22:25:56 -07:00
AnimeDL
16d2277d3e eslint and building fixes 2024-04-07 19:48:29 -07:00
AnimeDL
0a3b638c55 Start of Removing Funimation 2024-04-06 21:20:34 -07:00
AnimeDL
e9e14aef2f Remove old HD API 2024-04-06 21:05:04 -07:00
AnimeDL
23f185c877 eslint fixes 2024-04-06 09:10:48 -07:00
AnimeDL
036440b0e5 Fix MPD part names 2024-04-06 08:41:42 -07:00
AnimeDL
ea51c8a7ea Fix linting 2024-04-05 21:57:21 -07:00
AnimeDL
645cdc2068 [CR] Fix Non-DRM stream not always working 2024-04-05 19:39:56 -07:00
AnimeDL
dce10bc7fc Increment version + Documentation 2024-04-04 17:25:19 +00:00
AnimeDL
5846a13c10 Increment version 2024-04-04 10:24:46 -07:00
AnimeDL
2b65f3067e [CR] Fix issue with multi-dub downloads with non-DRM
Fix issue with non-DRM multi-dub downloads, where it would crash trying to delete a file that wasn't downloaded
2024-04-04 10:23:35 -07:00
AnimeDL
7d5e8fa461 ass inline font regex optimization 2024-04-04 09:27:10 -07:00
81 changed files with 8579 additions and 11839 deletions

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18
node-version: 20
- run: pnpm i
- run: pnpm run docs
- uses: stefanzweifel/git-auto-commit-action@v4

View file

@ -27,6 +27,6 @@ jobs:
github-token: ${{ github.token }}
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
"izuco/multi-downloader-nx:latest"
"multidl/multi-downloader-nx:latest"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -21,7 +21,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
node-version: 20
check-latest: true
- name: Install Node modules
run: |
@ -61,6 +61,6 @@ jobs:
github-token: ${{ github.token }}
push: true
tags: |
"izuco/multi-downloader-nx:${{ github.event.release.tag_name }}"
"multidl/multi-downloader-nx:${{ github.event.release.tag_name }}"
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

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

17
.gitignore vendored
View file

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

50
@types/adnPlayerConfig.d.ts vendored Normal file
View file

@ -0,0 +1,50 @@
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 Normal file
View file

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

51
@types/adnStreams.d.ts vendored Normal file
View file

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

11
@types/adnSubtitles.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
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 Normal file
View file

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

88
@types/animeOnegaiSearch.d.ts vendored Normal file
View file

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

36
@types/animeOnegaiSeasons.d.ts vendored Normal file
View file

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

111
@types/animeOnegaiSeries.d.ts vendored Normal file
View file

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

41
@types/animeOnegaiStream.d.ts vendored Normal file
View file

@ -0,0 +1,41 @@
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,6 +1,8 @@
import { Locale } from './playbackData';
export interface CrunchyPlayStream {
assetId: string;
audioLocale: string;
audioLocale: Locale;
bifs: string;
burnedInLocale: string;
captions: { [key: string]: Caption };

View file

@ -2,10 +2,12 @@ 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,

16
@types/enums.ts Normal file
View file

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

View file

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

View file

@ -1,75 +0,0 @@
// 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
View file

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

@ -7,11 +7,40 @@ declare module 'mpd-parser' {
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,
@ -45,6 +74,7 @@ declare module 'mpd-parser' {
}
}
segments: Segment[]
sidx?: Sidx
}
export type Manifest = {

View file

@ -1,8 +1,8 @@
// Generated by https://quicktype.io
export interface PlaybackData {
total: number;
data: [{ [key: string]: { [key: string]: StreamDetails } }];
meta: Meta;
data: { [key: string]: { [key: string]: StreamDetails } }[];
meta: Meta;
}
export interface StreamList {
@ -59,11 +59,11 @@ export interface Meta {
versions: Version[];
audio_locale: Locale;
closed_captions: Subtitles;
captions: Record<unknown>;
captions: Subtitles;
}
export interface Subtitles {
'': SubtitleInfo;
''?: SubtitleInfo;
'en-US'?: SubtitleInfo;
'es-LA'?: SubtitleInfo;
'es-419'?: SubtitleInfo;

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

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

924
adn.ts Normal file
View file

@ -0,0 +1,924 @@
// 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 Normal file
View file

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

View file

@ -1,5 +1,24 @@
# Set the quality of the stream, 0 is highest available.
q: 0
nServer: 1
mp4mux: false
noCleanUp: false
dlVideoOnce: false
# 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"

View file

@ -25,13 +25,13 @@ import getKeys, { canDecrypt } from './modules/widevine';
// load req
import { domain, api } from './modules/module.api-urls';
import * as reqModule from './modules/module.req';
import * as reqModule from './modules/module.fetch';
import { CrunchySearch } from './@types/crunchySearch';
import { CrunchyEpisodeList, CrunchyEpisode } from './@types/crunchyEpisodeList';
import { CrunchyDownloadOptions, CrunchyEpMeta, CrunchyMuxOptions, CrunchyMultiDownload, DownloadedMedia, ParseItem, SeriesSearch, SeriesSearchItem } from './@types/crunchyTypes';
import { ObjectInfo } from './@types/objectInfo';
import parseFileName, { Variable } from './modules/module.filename';
import { CrunchyStreams, PlaybackData } from './@types/playbackData';
import { CrunchyStreams, PlaybackData, Subtitles } from './@types/playbackData';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import { AvailableFilenameVars, getDefault } from './modules/module.args';
@ -44,6 +44,8 @@ import { CrunchyAndroidObject } from './@types/crunchyAndroidObject';
import { CrunchyChapters, CrunchyChapter, CrunchyOldChapter } from './@types/crunchyChapters';
import vtt2ass from './modules/module.vtt2ass';
import { CrunchyPlayStream } from './@types/crunchyPlayStreams';
import { CrunchyPlayStreams } from './@types/enums';
import { randomUUID } from 'node:crypto';
export type sxItem = {
language: langsData.LanguageItem,
@ -210,9 +212,9 @@ export default class Crunchy implements ServiceClass {
console.info('');
}
const fontUrl = fontsData.root + f;
const getFont = await this.req.getData<Buffer>(fontUrl, { binary: true });
const getFont = await this.req.getData(fontUrl);
if(getFont.ok && getFont.res){
fs.writeFileSync(fontLoc, getFont.res.body);
fs.writeFileSync(fontLoc, Buffer.from(await getFont.res.arrayBuffer()));
console.info(`Downloaded: ${f}`);
}
else{
@ -224,15 +226,18 @@ export default class Crunchy implements ServiceClass {
}
public async doAuth(data: AuthData): Promise<AuthResponse> {
const uuid = randomUUID();
const authData = new URLSearchParams({
'username': data.username,
'password': data.password,
'grant_type': 'password',
'scope': 'offline_access'
'scope': 'offline_access',
'device_id': uuid,
'device_type': 'Chrome on Windows'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
headers: api.beta_authHeaderMob,
headers: api.crunchyAuthHeaderMob,
body: authData
};
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
@ -240,7 +245,8 @@ export default class Crunchy implements ServiceClass {
console.error('Authentication failed!');
return { isOk: false, reason: new Error('Authentication failed') };
}
this.token = JSON.parse(authReq.res.body);
this.token = await authReq.res.json();
this.token.device_id = uuid;
this.token.expires = new Date(Date.now() + this.token.expires_in);
yamlCfg.saveCRToken(this.token);
await this.getProfile();
@ -249,21 +255,25 @@ export default class Crunchy implements ServiceClass {
}
public async doAnonymousAuth(){
const uuid = randomUUID();
const authData = new URLSearchParams({
'grant_type': 'client_id',
'scope': 'offline_access',
'device_id': uuid,
'device_type': 'Chrome on Windows'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
headers: api.beta_authHeaderMob,
headers: api.crunchyAuthHeaderMob,
body: authData
};
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Authentication failed!');
console.error('Anonymous Authentication failed!');
return;
}
this.token = JSON.parse(authReq.res.body);
this.token = await authReq.res.json();
this.token.device_id = uuid;
this.token.expires = new Date(Date.now() + this.token.expires_in);
yamlCfg.saveCRToken(this.token);
}
@ -284,7 +294,7 @@ export default class Crunchy implements ServiceClass {
console.error('Get profile failed!');
return false;
}
const profile = JSON.parse(profileReq.res.body);
const profile = await profileReq.res.json();
if (!silent) {
console.info('USER: %s (%s)', profile.username, profile.email);
}
@ -298,22 +308,30 @@ export default class Crunchy implements ServiceClass {
}
public async loginWithToken(refreshToken: string) {
const uuid = randomUUID();
const authData = new URLSearchParams({
'refresh_token': refreshToken,
'refresh_token': this.token.refresh_token,
'grant_type': 'refresh_token',
'scope': 'offline_access'
//'grant_type': 'etp_rt_cookie',
'scope': 'offline_access',
'device_id': uuid,
'device_type': 'Chrome on Windows'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
headers: api.beta_authHeaderMob,
headers: {...api.crunchyAuthHeaderMob, Cookie: `etp_rt=${refreshToken}`},
body: authData
};
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Token Authentication failed!');
if (authReq.res?.status == 400) {
console.warn('Token is likely wrong (Or invalid for given API), please login again!');
}
return;
}
this.token = JSON.parse(authReq.res.body);
this.token = await authReq.res.json();
this.token.device_id = uuid;
this.token.expires = new Date(Date.now() + this.token.expires_in);
yamlCfg.saveCRToken(this.token);
await this.getProfile(false);
@ -332,22 +350,30 @@ export default class Crunchy implements ServiceClass {
} else {
//console.info('[WARN] The token has expired compleatly. I will try to refresh the token anyway, but you might have to reauth.');
}
const uuid = this.token.device_id || randomUUID();
const authData = new URLSearchParams({
'refresh_token': this.token.refresh_token,
'grant_type': 'refresh_token',
'scope': 'offline_access'
//'grant_type': 'etp_rt_cookie',
'scope': 'offline_access',
'device_id': uuid,
'device_type': 'Chrome on Windows'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
headers: api.beta_authHeaderMob,
headers: {...api.crunchyAuthHeaderMob, Cookie: `etp_rt=${this.token.refresh_token}`},
body: authData
};
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Authentication failed!');
console.error('Token Refresh Failed!');
if (authReq.res?.status == 400) {
console.warn('Token is likely wrong, please login again!');
}
return;
}
this.token = JSON.parse(authReq.res.body);
this.token = await authReq.res.json();
this.token.device_id = uuid;
this.token.expires = new Date(Date.now() + this.token.expires_in);
yamlCfg.saveCRToken(this.token);
}
@ -382,7 +408,7 @@ export default class Crunchy implements ServiceClass {
console.error('Authentication CMS token failed!');
return;
}
this.cmsToken = JSON.parse(cmsTokenReq.res.body);
this.cmsToken = await cmsTokenReq.res.json();
console.info('Your Country: %s\n', this.cmsToken.cms?.bucket.split('/')[1]);
}
@ -411,7 +437,7 @@ export default class Crunchy implements ServiceClass {
console.error('Get CMS index FAILED!');
return;
}
console.info(JSON.parse(indexReq.res.body));
console.info(await indexReq.res.json());
}
public async doSearch(data: SearchData): Promise<SearchResponse>{
@ -438,7 +464,7 @@ export default class Crunchy implements ServiceClass {
console.error('Search FAILED!');
return { isOk: false, reason: new Error('Search failed. No more information provided') };
}
const searchResults = JSON.parse(searchReq.res.body) as CrunchySearch;
const searchResults = await searchReq.res.json() as CrunchySearch;
if(searchResults.total < 1){
console.info('Nothing Found!');
return { isOk: true, value: [] };
@ -607,8 +633,8 @@ export default class Crunchy implements ServiceClass {
if(item.hide_metadata){
iMetadata.hide_metadata = item.hide_metadata;
}
const showObjectMetadata = oMetadata.length > 0 && !iMetadata.hide_metadata ? true : false;
const showObjectBooleans = oBooleans.length > 0 && !iMetadata.hide_metadata ? true : false;
const showObjectMetadata = oMetadata.length > 0 && !iMetadata.hide_metadata;
const showObjectBooleans = oBooleans.length > 0 && !iMetadata.hide_metadata;
// make obj ids
const objects_ids: string[] = [];
objects_ids.push(oTypes[item.type as keyof typeof oTypes] + ':' + item.id);
@ -663,7 +689,7 @@ export default class Crunchy implements ServiceClass {
console.info(
'%s- Availability notes: %s',
''.padStart(pad + 2, ' '),
item.availability_notes.replace(/\[[^\]]*\]?/gm, '')
item.availability_notes.replace(/\[[^\]]*]?/gm, '')
);
}
if(item.type == 'series' && getSeries){
@ -699,7 +725,7 @@ export default class Crunchy implements ServiceClass {
console.error('Series Request FAILED!');
return;
}
const seriesData = JSON.parse(seriesReq.res.body);
const seriesData = await seriesReq.res.json();
await this.logObject(seriesData.data[0], pad, false);
}
// seasons list
@ -709,7 +735,7 @@ export default class Crunchy implements ServiceClass {
return;
}
// parse data
const seasonsList = JSON.parse(seriesSeasonListReq.res.body) as SeriesSearch;
const seasonsList = await seriesSeasonListReq.res.json() as SeriesSearch;
if(seasonsList.total < 1){
console.info('Series is empty!');
return;
@ -740,13 +766,13 @@ export default class Crunchy implements ServiceClass {
console.error('Movie Listing Request FAILED!');
return;
}
const movieListing = JSON.parse(movieListingReq.res.body);
const movieListing = await movieListingReq.res.json();
if(movieListing.total < 1){
console.info('Movie Listing is empty!');
return;
}
for(const item of movieListing.data){
this.logObject(item, pad, false, false);
await this.logObject(item, pad, false, false);
}
//Movies
@ -755,9 +781,9 @@ export default class Crunchy implements ServiceClass {
console.error('Movies List Request FAILED!');
return;
}
const moviesList = JSON.parse(moviesListReq.res.body);
const moviesList = await moviesListReq.res.json();
for(const item of moviesList.data){
this.logObject(item, pad+2);
await this.logObject(item, pad + 2);
}
}
@ -782,7 +808,7 @@ export default class Crunchy implements ServiceClass {
console.error('Get newly added FAILED!');
return;
}
const newlyAddedResults = JSON.parse(newlyAddedReq.res.body);
const newlyAddedResults = await newlyAddedReq.res.json();
console.info('Newly added:');
for(const i of newlyAddedResults.items){
await this.logObject(i, 2);
@ -814,8 +840,8 @@ export default class Crunchy implements ServiceClass {
console.error('Show Request FAILED!');
return { isOk: false, reason: new Error('Show request failed. No more information provided.') };
}
const showInfo = JSON.parse(showInfoReq.res.body);
this.logObject(showInfo.data[0], 0);
const showInfo = await showInfoReq.res.json();
await this.logObject(showInfo.data[0], 0);
let episodeList = { total: 0, data: [], meta: {} } as CrunchyEpisodeList;
//get episode info
@ -840,7 +866,7 @@ export default class Crunchy implements ServiceClass {
return { isOk: false, reason: new Error('Episode List request failed. No more information provided.') };
}
//CrunchyEpisodeList
const episodeListAndroid = JSON.parse(reqEpsList.res.body) as CrunchyAndroidEpisodes;
const episodeListAndroid = await reqEpsList.res.json() as CrunchyAndroidEpisodes;
episodeList = {
total: episodeListAndroid.total,
data: episodeListAndroid.items,
@ -853,7 +879,7 @@ export default class Crunchy implements ServiceClass {
return { isOk: false, reason: new Error('Episode List request failed. No more information provided.') };
}
//CrunchyEpisodeList
episodeList = JSON.parse(reqEpsList.res.body) as CrunchyEpisodeList;
episodeList = await reqEpsList.res.json() as CrunchyEpisodeList;
}
const epNumList: {
@ -1014,7 +1040,7 @@ export default class Crunchy implements ServiceClass {
continue;
}
const oldObjectInfo = JSON.parse(extIdReq.res.body) as Record<any, any>;
const oldObjectInfo = await extIdReq.res.json() as Record<any, any>;
for (const object of oldObjectInfo.items) {
objectIds.push(object.id);
}
@ -1061,14 +1087,14 @@ export default class Crunchy implements ServiceClass {
if(!objectReq.ok || !objectReq.res){
console.error('Objects Request FAILED!');
if(objectReq.error && objectReq.error.res && objectReq.error.res.body){
const objectInfo = JSON.parse(objectReq.error.res.body as string);
const objectInfo = await objectReq.error.res.json();
console.info('Body:', JSON.stringify(objectInfo, null, '\t'));
objectInfo.error = true;
return objectInfo;
}
return [];
}
const objectInfoAndroid = JSON.parse(objectReq.res.body) as CrunchyAndroidObject;
const objectInfoAndroid = await objectReq.res.json() as CrunchyAndroidObject;
objectInfo = {
total: objectInfoAndroid.total,
data: objectInfoAndroid.items,
@ -1079,14 +1105,14 @@ export default class Crunchy implements ServiceClass {
if(!objectReq.ok || !objectReq.res){
console.error('Objects Request FAILED!');
if(objectReq.error && objectReq.error.res && objectReq.error.res.body){
const objectInfo = JSON.parse(objectReq.error.res.body as string);
const objectInfo = await objectReq.error.res.json();
console.info('Body:', JSON.stringify(objectInfo, null, '\t'));
objectInfo.error = true;
return objectInfo;
}
return [];
}
objectInfo = JSON.parse(objectReq.res.body) as ObjectInfo;
objectInfo = await objectReq.res.json() as ObjectInfo;
}
if(earlyReturn){
@ -1201,16 +1227,22 @@ export default class Crunchy implements ServiceClass {
for (const mMeta of medias.data) {
console.info(`Requesting: [${mMeta.mediaId}] ${mediaName}`);
//Make sure token is up to date
// Make sure we have a media id without a : in it
const currentMediaId = (mMeta.mediaId.includes(':') ? mMeta.mediaId.split(':')[1] : mMeta.mediaId);
//Make sure token is up-to-date
await this.refreshToken(true, true);
let currentVersion;
let isPrimary = mMeta.isSubbed;
const AuthHeaders = {
const AuthHeaders: RequestInit = {
headers: {
Authorization: `Bearer ${this.token.access_token}`,
'X-Cr-Disable-Drm': 'true'
},
useProxy: true
'X-Cr-Disable-Drm': 'true',
'X-Cr-Enable-Drm': 'false',
'X-Cr-Stream-Limits': 'false',
//'X-Cr-Segment-CDN': 'all',
//'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
}
};
//Get Media GUID
@ -1239,16 +1271,16 @@ export default class Crunchy implements ServiceClass {
const compiledChapters: string[] = [];
if (options.chapters) {
//Make Chapter Request
const chapterRequest = await this.req.getData(`https://static.crunchyroll.com/skip-events/production/${mMeta.mediaId}.json`);
const chapterRequest = await this.req.getData(`https://static.crunchyroll.com/skip-events/production/${currentMediaId}.json`);
if(!chapterRequest.ok || !chapterRequest.res){
//Old Chapter Request Fallback
console.warn('Chapter request failed, attempting old API');
const oldChapterRequest = await this.req.getData(`https://static.crunchyroll.com/datalab-intro-v2/${mMeta.mediaId}.json`);
const oldChapterRequest = await this.req.getData(`https://static.crunchyroll.com/datalab-intro-v2/${currentMediaId}.json`);
if(!oldChapterRequest.ok || !oldChapterRequest.res) {
console.warn('Old Chapter API request failed');
} else {
console.info('Old Chapter request successful');
const chapterData = JSON.parse(oldChapterRequest.res.body) as CrunchyOldChapter;
const chapterData = await oldChapterRequest.res.json() as CrunchyOldChapter;
//Generate Timestamps
const startTime = new Date(0), endTime = new Date(0);
@ -1278,7 +1310,7 @@ export default class Crunchy implements ServiceClass {
} else {
//Chapter request succeeded, now let's parse them
console.info('Chapter request successful');
const chapterData = JSON.parse(chapterRequest.res.body) as CrunchyChapters;
const chapterData = await chapterRequest.res.json() as CrunchyChapters;
const chapters: CrunchyChapter[] = [];
//Make a format more usable for the crunchy chapters
@ -1290,6 +1322,14 @@ export default class Crunchy implements ServiceClass {
if (chapters.length > 0) {
chapters.sort((a, b) => a.start - b.start);
//Check if chapters has an intro
if (!(chapters.find(c => c.type === 'intro') || chapters.find(c => c.type === 'recap'))) {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Episode`
);
}
//Loop through all the chapters
for (const chapter of chapters) {
if (typeof chapter.start == 'undefined' || typeof chapter.end == 'undefined') continue;
@ -1331,92 +1371,40 @@ export default class Crunchy implements ServiceClass {
}
}
let pbData = { total: 0, data: {}, meta: {} } as PlaybackData;
if (this.api == 'android') {
const videoStreamsReq = [
api.beta_cms,
`${this.cmsToken.cms.bucket}/videos/${mediaId}/streams`,
'?',
new URLSearchParams({
'force_locale': '',
'preferred_audio_language': 'ja-JP',
'locale': this.locale,
'Policy': this.cmsToken.cms.policy,
'Signature': this.cmsToken.cms.signature,
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
}),
].join('');
const pbData = { total: 0, data: [{}], meta: {} } as PlaybackData;
let playbackReq = await this.req.getData(videoStreamsReq as string, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Request Stream URLs FAILED! Attempting fallback');
const videoStreamsReq = [
domain.api_beta,
mMeta.playback,
'?',
new URLSearchParams({
'force_locale': '',
'preferred_audio_language': 'ja-JP',
'locale': this.locale,
'Policy': this.cmsToken.cms.policy,
'Signature': this.cmsToken.cms.signature,
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
}),
].join('');
playbackReq = await this.req.getData(videoStreamsReq as string, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Fallback Request Stream URLs FAILED!');
return undefined;
let playStream: CrunchyPlayStream | null = null;
if (options.cstream !== 'none') {
const playbackReq = await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${currentVersion ? currentVersion.guid : currentMediaId}/${CrunchyPlayStreams[options.cstream]}/play`, AuthHeaders);
if (!playbackReq.ok || !playbackReq.res) {
console.error('Non-DRM Request Stream URLs FAILED!');
} else {
playStream = await playbackReq.res.json() as CrunchyPlayStream;
const derivedPlaystreams = {} as CrunchyStreams;
for (const hardsub in playStream.hardSubs) {
const stream = playStream.hardSubs[hardsub];
derivedPlaystreams[hardsub] = {
url: stream.url,
'hardsub_locale': stream.hlang
};
}
}
const pbDataAndroid = JSON.parse(playbackReq.res.body) as CrunchyAndroidStreams;
pbData = {
total: 0,
data: [pbDataAndroid.streams],
meta: {
audio_locale: pbDataAndroid.audio_locale,
bifs: pbDataAndroid.bifs,
captions: pbDataAndroid.captions,
closed_captions: pbDataAndroid.closed_captions,
media_id: pbDataAndroid.media_id,
subtitles: pbDataAndroid.subtitles,
versions: pbDataAndroid.versions
}
};
} else {
let playbackReq = await this.req.getData(`${api.cms}/videos/${mediaId}/streams`, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Request Stream URLs FAILED! Attempting fallback');
playbackReq = await this.req.getData(`${domain.api_beta}${mMeta.playback}`, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Fallback Request Stream URLs FAILED!');
return undefined;
}
}
pbData = JSON.parse(playbackReq.res.body) as PlaybackData;
}
const playbackReq = await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${currentVersion?.guid}/console/switch/play`, AuthHeaders);
if(!playbackReq.ok || !playbackReq.res){
console.error('Non-DRM Request Stream URLs FAILED!');
} else {
const playStream = JSON.parse(playbackReq.res.body) as CrunchyPlayStream;
const derivedPlaystreams = {} as CrunchyStreams;
for (const hardsub in playStream.hardSubs) {
const stream = playStream.hardSubs[hardsub];
derivedPlaystreams[hardsub] = {
url: stream.url,
'hardsub_locale': stream.hlang
derivedPlaystreams[''] = {
url: playStream.url,
hardsub_locale: ''
};
pbData.meta = {
audio_locale: playStream.audioLocale,
bifs: [playStream.bifs],
captions: playStream.captions,
closed_captions: playStream.captions,
media_id: playStream.assetId,
subtitles: playStream.subtitles,
versions: playStream.versions
};
pbData.data[0][`adaptive_${options.cstream}_${playStream.url.includes('m3u8') ? 'hls' : 'dash'}_drm`] = {
...derivedPlaystreams
};
}
derivedPlaystreams[''] = {
url: playStream.url,
hardsub_locale: ''
};
pbData.data[0]['adaptive_switch_dash'] = {
...derivedPlaystreams
};
}
variables.push(...([
@ -1443,7 +1431,7 @@ export default class Crunchy implements ServiceClass {
console.warn('Decryption not enabled!');
}
for(const s of Object.keys(pbStreams)){
for (const s of Object.keys(pbStreams)) {
if (
(s.match(/hls/) || s.match(/dash/))
&& !(s.match(/hls/) && s.match(/drm/))
@ -1466,7 +1454,7 @@ export default class Crunchy implements ServiceClass {
}
}
if(streams.length < 1){
if (streams.length < 1) {
console.warn('No full streams found!');
return undefined;
}
@ -1495,7 +1483,7 @@ export default class Crunchy implements ServiceClass {
if(s.hardsub_lang == '-'){
return false;
}
return s.hardsub_lang == options.hslang ? true : false;
return s.hardsub_lang == options.hslang;
});
}
else{
@ -1505,13 +1493,9 @@ export default class Crunchy implements ServiceClass {
}
dlFailed = true;
}
}
else{
} else {
streams = streams.filter((s) => {
if(s.hardsub_lang != '-'){
return false;
}
return true;
return s.hardsub_lang == '-';
});
if(streams.length < 1){
console.warn('Raw streams not available!');
@ -1526,7 +1510,7 @@ export default class Crunchy implements ServiceClass {
let curStream:
undefined|typeof streams[0]
= undefined;
if(!dlFailed){
if (!dlFailed) {
options.kstream = typeof options.kstream == 'number' ? options.kstream : 1;
options.kstream = options.kstream > streams.length ? 1 : options.kstream;
@ -1543,15 +1527,30 @@ export default class Crunchy implements ServiceClass {
let tsFile = undefined;
// Delete the stream if it's not needed
if (options.novids && options.noaudio) {
if (playStream) {
await this.refreshToken(true, true);
await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/token/${currentVersion ? currentVersion.guid : currentMediaId}/${playStream.token}`, {...{method: 'DELETE'}, ...AuthHeaders});
}
}
if(!dlFailed && curStream !== undefined && !(options.novids && options.noaudio)){
const streamPlaylistsReq = await this.req.getData(curStream.url, AuthHeaders);
if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
dlFailed = true;
} else {
if (streamPlaylistsReq.res.body.match('MPD')) {
const streamPlaylistBody = await streamPlaylistsReq.res.text();
if (streamPlaylistBody.match('MPD')) {
//We have the stream, so go ahead and delete the active stream
if (playStream) {
await this.refreshToken(true, true);
await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/token/${currentVersion ? currentVersion.guid : currentMediaId}/${playStream.token}`, {...{method: 'DELETE'}, ...AuthHeaders});
}
//Parse MPD Playlists
const streamPlaylists = parse(streamPlaylistsReq.res.body, langsData.findLang(langsData.fixLanguageTag(pbData.meta.audio_locale as string) || ''), curStream.url.match(/.*\.urlset\//)[0]);
const streamPlaylists = await parse(streamPlaylistBody, langsData.findLang(langsData.fixLanguageTag(pbData.meta.audio_locale as string) || ''), curStream.url.match(/.*\.urlset\//)[0]);
//Get name of CDNs/Servers
const streamServers = Object.keys(streamPlaylists);
@ -1626,6 +1625,8 @@ export default class Crunchy implements ServiceClass {
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + (mMeta.lang?.name || lang.name), variables, options.numbers, options.override).join(path.sep);
const tempFile = parseFileName(`temp-${currentVersion ? currentVersion.guid : currentMediaId}`, 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];
@ -1651,7 +1652,7 @@ export default class Crunchy implements ServiceClass {
segments: chosenVideoSegments.segments
};
const videoDownload = await new streamdl({
output: `${tsFile}.video.enc.ts`,
output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.m4s` : `${tsFile}.video.m4s`,
timeout: options.timeout,
m3u8json: videoJson,
// baseurl: chunkPlaylist.baseUrl,
@ -1693,7 +1694,7 @@ export default class Crunchy implements ServiceClass {
segments: chosenAudioSegments.segments
};
const audioDownload = await new streamdl({
output: `${tsFile}.audio.enc.ts`,
output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.m4s` : `${tsFile}.audio.m4s`,
timeout: options.timeout,
m3u8json: audioJson,
// baseurl: chunkPlaylist.baseUrl,
@ -1726,7 +1727,7 @@ export default class Crunchy implements ServiceClass {
const sessionId = new Date().getUTCMilliseconds().toString().padStart(3, '0') + process.hrtime.bigint().toString().slice(0, 13);
console.info('Decryption Needed, attempting to decrypt');
const decReq = await this.req.getData('https://pl.crunchyroll.com/drm/v1/auth', {
const decReq = await this.req.getData(`${api.drm}`, {
'method': 'POST',
'body': JSON.stringify({
'accounting_id': 'crunchyroll',
@ -1736,10 +1737,10 @@ export default class Crunchy implements ServiceClass {
})
});
if(!decReq.ok || !decReq.res){
console.error('Request to DRM Authentication failed:', decReq.error?.code, decReq.error?.message);
console.error('Request to DRM Authentication failed:', decReq.error?.res.status, decReq.error?.message);
return undefined;
}
const authData = JSON.parse(decReq.res.body) as {'custom_data': string, 'token': string};
const authData = await decReq.res.json() as {'custom_data': string, 'token': string};
const encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://lic.drmtoday.com/license-proxy-widevine/cenc/', {
'dt-custom-data': authData.custom_data,
'x-dt-auth-token': authData.token
@ -1755,8 +1756,8 @@ export default class Crunchy implements ServiceClass {
if (this.cfg.bin.mp4decrypt) {
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
const commandVideo = commandBase+`"${tsFile}.video.enc.ts" "${tsFile}.video.ts"`;
const commandAudio = commandBase+`"${tsFile}.audio.enc.ts" "${tsFile}.audio.ts"`;
const commandVideo = commandBase+`"${tempTsFile}.video.enc.m4s" "${tempTsFile}.video.m4s"`;
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.m4s" "${tempTsFile}.audio.m4s"`;
if (videoDownloaded) {
console.info('Started decrypting video');
@ -1764,15 +1765,17 @@ export default class Crunchy implements ServiceClass {
if (!decryptVideo.isOk) {
console.error(decryptVideo.err);
console.error(`Decryption failed with exit code ${decryptVideo.err.code}`);
fs.renameSync(`${tempTsFile}.video.enc.m4s`, `${tsFile}.video.enc.m4s`);
return undefined;
} else {
console.info('Decryption done for video');
if (!options.nocleanup) {
fs.removeSync(`${tsFile}.video.enc.ts`);
fs.removeSync(`${tempTsFile}.video.enc.m4s`);
}
fs.renameSync(`${tempTsFile}.video.m4s`, `${tsFile}.video.m4s`);
files.push({
type: 'Video',
path: `${tsFile}.video.ts`,
path: `${tsFile}.video.m4s`,
lang: lang,
isPrimary: isPrimary
});
@ -1785,14 +1788,16 @@ export default class Crunchy implements ServiceClass {
if (!decryptAudio.isOk) {
console.error(decryptAudio.err);
console.error(`Decryption failed with exit code ${decryptAudio.err.code}`);
fs.renameSync(`${tempTsFile}.audio.enc.m4s`, `${tsFile}.audio.enc.m4s`);
return undefined;
} else {
if (!options.nocleanup) {
fs.removeSync(`${tsFile}.audio.enc.ts`);
fs.removeSync(`${tempTsFile}.audio.enc.m4s`);
}
fs.renameSync(`${tempTsFile}.audio.m4s`, `${tsFile}.audio.m4s`);
files.push({
type: 'Audio',
path: `${tsFile}.audio.ts`,
path: `${tsFile}.audio.m4s`,
lang: lang,
isPrimary: isPrimary
});
@ -1803,21 +1808,25 @@ export default class Crunchy implements ServiceClass {
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
}
} else {
files.push({
type: 'Video',
path: `${tsFile}.video.enc.ts`,
lang: lang,
isPrimary: isPrimary
});
files.push({
type: 'Audio',
path: `${tsFile}.audio.enc.ts`,
lang: lang,
isPrimary: isPrimary
});
if (videoDownloaded) {
files.push({
type: 'Video',
path: `${tsFile}.video.m4s`,
lang: lang,
isPrimary: isPrimary
});
}
if (audioDownloaded) {
files.push({
type: 'Audio',
path: `${tsFile}.audio.m4s`,
lang: lang,
isPrimary: isPrimary
});
}
}
} else if (!options.novids) {
const streamPlaylists = m3u8(streamPlaylistsReq.res.body);
const streamPlaylists = m3u8(streamPlaylistBody);
const plServerList: string[] = [],
plStreams: Record<string, Record<string, string>> = {},
plQuality: {
@ -1861,7 +1870,7 @@ export default class Crunchy implements ServiceClass {
// 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 qualityStrRegx = new RegExp(qualityStrAdd.replace(/([:()/])/g, '\\$1'), 'm');
const qualityStrMatch = !plQuality.map(a => a.str).join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plQuality.push({
@ -1924,7 +1933,14 @@ export default class Crunchy implements ServiceClass {
console.error('CAN\'T FETCH VIDEO PLAYLIST!');
dlFailed = true;
} else {
const chunkPlaylist = m3u8(chunkPage.res.body);
// We have the stream, so go ahead and delete the active stream
if (playStream) {
await this.refreshToken(true, true);
await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/token/${currentVersion ? currentVersion.guid : currentMediaId}/${playStream.token}`, {...{method: 'DELETE'}, ...AuthHeaders});
}
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})`;
@ -2025,7 +2041,7 @@ export default class Crunchy implements ServiceClass {
const subsData = Object.values(pbData.meta.subtitles);
const capsData = Object.values(pbData.meta.closed_captions);
const subsDataMapped = subsData.map((s) => {
const subLang = langsData.fixAndFindCrLC(s.locale);
const subLang = langsData.fixAndFindCrLC(s.language);
return {
...s,
isCC: false,
@ -2034,7 +2050,7 @@ export default class Crunchy implements ServiceClass {
};
}).concat(
capsData.map((s) => {
const subLang = langsData.fixAndFindCrLC(s.locale);
const subLang = langsData.fixAndFindCrLC(s.language);
return {
...s,
isCC: true,
@ -2064,15 +2080,15 @@ export default class Crunchy implements ServiceClass {
if(options.dlsubs.includes('all') || options.dlsubs.includes(langItem.locale)){
const subsAssReq = await this.req.getData(subsItem.url);
if(subsAssReq.ok && subsAssReq.res){
let sBody;
let sBody = await subsAssReq.res.text();
if (subsItem.format == 'vtt') {
const chosenFontSize = options.originalFontSize ? undefined : options.fontSize;
if (!options.originalFontSize) subsAssReq.res.body = subsAssReq.res.body.replace(/( font-size:.+?;)/g, '').replace(/(font-size:.+?;)/g, '');
sBody = vtt2ass(undefined, chosenFontSize, subsAssReq.res.body, '', undefined, options.fontName);
if (!options.originalFontSize) sBody = sBody.replace(/( font-size:.+?;)/g, '').replace(/(font-size:.+?;)/g, '');
sBody = vtt2ass(undefined, chosenFontSize, sBody, '', undefined, options.fontName);
sxData.fonts = fontsData.assFonts(sBody) as Font[];
sxData.file = sxData.file.replace('.vtt','.ass');
} else {
sBody = '\ufeff' + subsAssReq.res.body;
sBody = '\ufeff' + sBody;
const sBodySplit = sBody.split('\r\n');
sBodySplit.splice(2, 0, 'ScaledBorderAndShadow: yes');
sBody = sBodySplit.join('\r\n');
@ -2098,10 +2114,10 @@ export default class Crunchy implements ServiceClass {
else{
console.warn('Can\'t find urls for subtitles!');
}
}
else{
} else{
console.info('Subtitles downloading skipped!');
}
await this.sleep(options.waittime);
}
return {
@ -2121,8 +2137,6 @@ export default class Crunchy implements ServiceClass {
}
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,
@ -2130,8 +2144,6 @@ export default class Crunchy implements ServiceClass {
}) : [],
skipSubMux: options.skipSubMux,
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,
@ -2139,12 +2151,6 @@ export default class Crunchy implements ServiceClass {
}) : [],
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');
if (a.type === 'Chapters')
throw new Error('Never');
return {
file: a.path,
language: a.language,
@ -2156,20 +2162,12 @@ export default class Crunchy implements ServiceClass {
keepAllVideos: options.keepAllVideos,
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 => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
if (a.type === 'Subtitle')
throw new Error('Never');
return {
path: a.path,
lang: a.lang
@ -2463,7 +2461,7 @@ export default class Crunchy implements ServiceClass {
return;
}
// parse data
const seasonsList = JSON.parse(seriesSeasonListReq.res.body) as SeriesSearch;
const seasonsList = await seriesSeasonListReq.res.json() as SeriesSearch;
if(seasonsList.total < 1){
console.info('Series is empty!');
return;
@ -2490,9 +2488,9 @@ export default class Crunchy implements ServiceClass {
console.error('Show Request FAILED!');
return;
}
const showInfo = JSON.parse(showInfoReq.res.body);
const showInfo = await showInfoReq.res.json();
if (log)
this.logObject(showInfo, 0);
await this.logObject(showInfo, 0);
let episodeList = { total: 0, data: [], meta: {} } as CrunchyEpisodeList;
//get episode info
@ -2517,7 +2515,7 @@ export default class Crunchy implements ServiceClass {
return;
}
//CrunchyEpisodeList
const episodeListAndroid = JSON.parse(reqEpsList.res.body) as CrunchyAndroidEpisodes;
const episodeListAndroid = await reqEpsList.res.json() as CrunchyAndroidEpisodes;
episodeList = {
total: episodeListAndroid.total,
data: episodeListAndroid.items,
@ -2530,7 +2528,7 @@ export default class Crunchy implements ServiceClass {
return;
}
//CrunchyEpisodeList
episodeList = JSON.parse(reqEpsList.res.body) as CrunchyEpisodeList;
episodeList = await reqEpsList.res.json() as CrunchyEpisodeList;
}
if(episodeList.total < 1){

View file

@ -1,10 +1,10 @@
# multi-downloader-nx (4.6.2v)
# multi-downloader-nx (5.1.5v)
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).
## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *Hidive*, or *Crunchyroll*.
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.
@ -38,13 +38,13 @@ Set the password to use for the authentication. If not provided, you will be pro
#### `--silentAuth`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Funimation, Crunchyroll | `--silentAuth ` | `boolean` | `No`| `NaN` | `false`| `silentAuth: ` |
| Crunchyroll | `--silentAuth ` | `boolean` | `No`| `NaN` | `false`| `silentAuth: ` |
Authenticate every time the script runs. Use at your own risk.
#### `--token`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--token ${token}` | `string` | `No`| `NaN` | `undefined`| `token: ` |
| 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
@ -58,7 +58,7 @@ Use this command to download all the fonts and add them to the muxed **mkv** fil
#### `--fontName`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Funimation, Hidive | `--fontName ${fontName}` | `string` | `No`| `NaN` | `NaN` |
| Hidive, AnimationDigitalNetwork | `--fontName ${fontName}` | `string` | `No`| `NaN` | `NaN` |
Set the font to use in subtiles
### Search
@ -83,7 +83,7 @@ The output is organized in pages. Use this command to output the items for the g
#### `--locale`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--locale ${locale}` | `string` | `No`| `NaN` | [`''`, `en-US`, `en-IN`, `es-LA`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr-FR`, `de-DE`, `ar-ME`, `ar-SA`, `it-IT`, `ru-RU`, `tr-TR`, `hi-IN`, `zh-CN`, `zh-TW`, `ko-KR`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`] | `en-US`| `locale: ` |
| 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: ` |
Set the local that will be used for the API.
#### `--new`
@ -135,7 +135,7 @@ Set the quality level. Use 0 to use the maximum quality.
#### `--dlVideoOnce`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--dlVideoOnce ` | `boolean` | `No`| `NaN` | `false`| `dlVideoOnce: ` |
| 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.
@ -144,7 +144,7 @@ This will speed up the download speed, if multiple languages are selected.
#### `--chapters`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll | `--chapters ` | `boolean` | `No`| `NaN` | `true`| `chapters: ` |
| Crunchyroll, AnimationDigitalNetwork | `--chapters ` | `boolean` | `No`| `NaN` | `true`| `chapters: ` |
Will fetch the chapters and add them into the final video.
Currently only works with mkvmerge.
@ -155,13 +155,6 @@ Currently only works with mkvmerge.
If set to Android, it has lower quality, but Non-DRM streams,
If set to Web, it has a higher quality adaptive stream, but everything is DRM.
#### `--hdapi`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Hidive | `--hdapi ` | `string` | `No`| `NaN` | [`old`, `new`] | `new`| `hdapi: ` |
If set to Old, it has lower quality, but Non-DRM streams, but some people can't use it,
If set to New, it has a higher quality stream, but everything is DRM.
#### `--removeBumpers`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
@ -178,7 +171,7 @@ If selected, it will prefer to keep the original Font Size defined by the servic
#### `-x`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| Crunchyroll, Funimation | `-x ${server}` | `number` | `No`| `--server` | [`1`, `2`, `3`, `4`] | `1`| `x: ` |
| Crunchyroll | `-x ${server}` | `number` | `No`| `--server` | [`1`, `2`, `3`, `4`] | `1`| `x: ` |
Select the server to use
#### `--kstream`
@ -187,20 +180,25 @@ Select the server to use
| Crunchyroll | `--kstream ${stream}` | `number` | `No`| `-k` | [`1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`] | `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`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `ja`] | `none`| `hslang: ` |
| Crunchyroll | `--hslang ${hslang}` | `string` | `No`| `NaN` | [`none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `zh-HK`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `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`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `ja`] | `all`| `dlsubs: ` |
| All | `--dlsubs ${sub1} ${sub2}` | `array` | `No`| `NaN` | [`all`, `none`, `en`, `en-IN`, `es-419`, `es-ES`, `pt-BR`, `pt-PT`, `fr`, `de`, `ar`, `it`, `ru`, `tr`, `hi`, `zh`, `zh-CN`, `zh-TW`, `zh-HK`, `ko`, `ca-ES`, `pl-PL`, `th-TH`, `ta-IN`, `ms-MY`, `vi-VN`, `id-ID`, `te-IN`, `ja`] | `all`| `dlsubs: ` |
Download subtitles by language tag (space-separated)
Funi Only: zh
Crunchy Only: en-IN, es-419, es-ES, pt-PT, fr, de, ar, ar, it, ru, tr, hi, zh-CN, zh-TW, ko, ca-ES, pl-PL, th-TH, ta-IN, ms-MY, vi-VN, id-ID, te-IN
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
#### `--novids`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
@ -210,7 +208,7 @@ Skip downloading videos
#### `--noaudio`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **cli-default Entry**
| --- | --- | --- | --- | --- | ---|
| Funimation | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
| Crunchyroll, Hidive | `--noaudio ` | `boolean` | `No`| `NaN` | `NaN` |
Skip downloading audio
#### `--nosubs`
@ -222,11 +220,10 @@ 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`, `kor`, `cat`, `pol`, `tha`, `tam`, `may`, `vie`, `ind`, `tel`, `jpn`] | `jpn`| `dubLang: ` |
| All | `--dubLang ${dub1} ${dub2}` | `array` | `No`| `NaN` | [`eng`, `spa`, `spa-419`, `spa-ES`, `por`, `fra`, `deu`, `ara-ME`, `ara`, `ita`, `rus`, `tur`, `hin`, `cmn`, `zho`, `chi`, `zh-HK`, `kor`, `cat`, `pol`, `tha`, `tam`, `may`, `vie`, `ind`, `tel`, `jpn`] | `jpn`| `dubLang: ` |
Set the language to download:
Funi Only: cmn
Crunchy Only: eng, spa-419, spa-ES, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, zho, chi, kor, cat, pol, tha, tam, may, vie, ind, tel
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
#### `--all`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
@ -267,7 +264,7 @@ Set the time the program waits between downloads. Set in millisecods
#### `--simul`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | ---|
| Funimation, Hidive | `--simul ` | `boolean` | `No`| `NaN` | `false`| `simul: ` |
| Hidive | `--simul ` | `boolean` | `No`| `NaN` | `false`| `simul: ` |
Force downloading simulcast version instead of uncut version (if available).
#### `--but`
@ -376,14 +373,14 @@ Set the options given to ffmpeg
| All | `--defaultAudio ${args}` | `string` | `No`| `NaN` | `eng`| `defaultAudio: ` |
Set the default audio track by language code
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, 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, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
Possible Values: eng, eng, spa, spa-419, spa-ES, por, por, fra, deu, ara-ME, ara, ita, rus, tur, hin, cmn, zho, chi, zh-HK, kor, cat, pol, tha, tam, may, vie, ind, tel, jpn
### Filename Template
#### `--fileName`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Default** |**cli-default Entry**
@ -431,7 +428,7 @@ Debug mode (tokens may be revealed in the console output)
#### `--service`
| **Service** | **Usage** | **Type** | **Required** | **Alias** | **Choices** | **Default** |**cli-default Entry**
| --- | --- | --- | --- | --- | --- | --- | ---|
| All | `--service ${service}` | `string` | `Yes`| `NaN` | [`funi`, `crunchy`, `hidive`] | ``| `service: ` |
| All | `--service ${service}` | `string` | `Yes`| `NaN` | [`crunchy`, `hidive`, `ao`, `adn`] | ``| `service: ` |
Set the service you want to use
#### `--update`

View file

@ -2,11 +2,11 @@
[![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 *Funimation*, *Crunchyroll*, and *Hidive*.
This downloader can download anime from different sites. Currently supported are *Crunchyroll*, *Hidive*, *AnimeOnegai*, and *AnimationDigitalNetwork*.
## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *Crunchyroll*, or *Hidive*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application.
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.
## Dependencies
@ -60,20 +60,39 @@ AniDL --service {ServiceName} -s {SeasonID} -e {EpisodeNumber}
Dependencies that are only required for running from code. These are not required if you are using the prebuilt binaries.
* NodeJS >= 14.6.0 (https://nodejs.org/)
* NodeJS >= 18.0.0 (https://nodejs.org/)
* NPM >= 6.9.0 (https://www.npmjs.org/)
* PNPM >= 7.0.0 (https://pnpm.io/)
### Build Instructions
### 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)
First clone this repo `git clone https://github.com/anidl/multi-downloader-nx.git`.
`cd` into the cloned directory and run `pnpm i`.
Afterwards run `pnpm run tsc false [true if you want gui, false otherwise]`.
`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.
If you want the `js` files you are done. Just `cd` into the `lib` folder, and run `node index.js --help` to get started with the CLI, or run `node gui.js` to run the GUI
### 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.

64
eslint.config.mjs Normal file
View file

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

923
funi.ts
View file

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

3
gui/react/.babelrc Normal file
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -23,11 +23,11 @@ const Layout: React.FC = () => {
maxWidth: '93rem',
maxHeight: '3rem'
//backgroundColor: '#ffffff',
}}>
}}>
<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>
<Button variant="contained" startIcon={<Folder />} onClick={() => messageHandler?.openFolder('content')} sx={{ height: '37px' }}>Open Output Directory</Button>
<Button variant="contained" startIcon={<ClearAll />} onClick={() => messageHandler?.clearQueue() } sx={{ height: '37px' }}>Clear Queue</Button>
<AddToQueue />
<StartQueueButton />
</Box>

View file

@ -11,8 +11,8 @@ const makeTheme = (mode: 'dark'|'light') : Partial<Theme> => {
const Style: FCWithChildren = ({children}) => {
return <ThemeProvider theme={makeTheme('dark')}>
<Box sx={{ }}/>
{children}
<Box sx={{ }}/>
{children}
</ThemeProvider>;
};

View file

@ -86,240 +86,240 @@ const DownloadSelector: React.FC<DownloadSelectorProps> = ({ onFinish }) => {
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={{
width: '50rem',
height: '21rem',
margin: '10px',
display: 'flex',
flexDirection: 'column',
gap: '1px'
justifyContent: 'space-between',
//backgroundColor: '#ffffff30',
}}>
<Box sx={{
borderColor: '#595959',
borderStyle: 'solid',
borderWidth: '1px',
borderRadius: '5px',
//backgroundColor: '#ff4567',
width: '15rem',
height: '3.5rem',
display: 'flex',
'&:hover' : {
borderColor: '#ffffff',
},
flexDirection: 'column',
alignItems: 'center',
gap: '0.7rem',
//backgroundColor: '#ff000030'
}}>
<InputBase sx={{
ml: 2,
flex: 1,
}}
disabled={store.downloadOptions.all} value={store.downloadOptions.e} required onChange={e => {
<Typography sx={{fontSize: '1.4rem'}}>
General Options
</Typography>
<TextField value={store.downloadOptions.id} required onChange={e => {
dispatch({
type: 'downloadOptions',
payload: { ...store.downloadOptions, e: e.target.value }
payload: { ...store.downloadOptions, id: 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>
}} 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>
<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',
<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'}}>
}}>
<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
/>
</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' ? '' :
<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={{
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>
<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>
<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>
</Typography>
} arrow placement='top'>
<InfoOutlinedIcon sx={{
transition: '100ms',
ml: '0.35rem',
mr: '0.65rem',
'&:hover' : {
color: '#ffffff30',
}
}} />
</Tooltip>
</Box>
</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>
<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>
</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>
<LoadingButton sx={{ margin: '15px', textTransform: 'none' }} loading={loading} onClick={addToQueue} variant='contained'>Add to Queue</LoadingButton>
</Box>;
};

View file

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

View file

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

View file

@ -17,113 +17,9 @@ const Queue: React.FC = () => {
return data || queue.length > 0 ? <>
{data && <>
<Box sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center',
}}>
<Box sx={{
marginTop: '2rem',
marginBottom: '1rem',
height: '12rem',
width: '93vw',
maxWidth: '93rem',
backgroundColor: '#282828',
boxShadow: '0px 0px 50px #00000090',
borderRadius: '10px',
display: 'flex',
transition: '250ms'
}}>
<img style={{
borderRadius: '5px',
margin: '5px',
boxShadow: '0px 0px 10px #00000090',
userSelect: 'none',
}}
src={data.downloadInfo.image} height='auto' width='auto' alt="Thumbnail" />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center'
}}>
<Box sx={{
display: 'flex',
}}>
<Box sx={{
//backgroundColor: '#ff0000',
width: '70%',
marginLeft: '10px'
}}>
<Box sx={{
flexDirection: 'column',
display: 'flex',
justifyContent: 'space-between',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
{data.downloadInfo.parent.title}
</Typography>
<Typography color='text.primary' sx={{
fontSize: '1.2rem',
}}>
{data.downloadInfo.title}
</Typography>
</Box>
</Box>
<Box sx={{
//backgroundColor: '#00ff00',
width: '30%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}>
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
}}>
Downloading: {data.downloadInfo.language.name}
</Typography>
</Box>
</Box>
<Box sx={{
height: '50%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
//backgroundColor: '#0000ff',
}}>
<LinearProgress variant='determinate'
sx={{
height: '20px',
width: '97.53%',
margin: '10px',
boxShadow: '0px 0px 10px #00000090',
borderRadius: '10px',
}} value={(typeof data.progress.percent === 'string' ? parseInt(data.progress.percent) : data.progress.percent)}
/>
<Box>
<Typography color='text.primary'
sx={{
fontSize: '1.3rem',
}}>
{data.progress.cur} / {(data.progress.total)} parts ({data.progress.percent}% | {formatTime(data.progress.time)} | {(data.progress.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s | {(data.progress.bytes / 1024 / 1024).toFixed(2)}MB)
</Typography>
</Box>
</Box>
</Box>
</Box>
</Box>
</>
}
{
current && !data && <>
<Box sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center',
}}>
@ -137,104 +33,208 @@ const Queue: React.FC = () => {
boxShadow: '0px 0px 50px #00000090',
borderRadius: '10px',
display: 'flex',
overflow: 'hidden',
transition: '250ms'
}}>
<img style={{
}}>
<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'
src={data.downloadInfo.image} height='auto' width='auto' alt="Thumbnail" />
<Box
sx={{
fontSize: '1.3rem',
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center'
}}>
0 / ? parts (0% | XX:XX | 0 MB/s | 0MB)
</Typography>
<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>
</Box>
</Box>
</>
}
{queue.map((queueItem, index, { length }) => {
@ -255,7 +255,7 @@ const Queue: React.FC = () => {
borderRadius: '10px',
display: 'flex',
overflow: 'hidden',
}}>
}}>
<img style={{
borderRadius: '5px',
margin: '5px',
@ -269,102 +269,102 @@ const Queue: React.FC = () => {
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',
}}>
<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',
<Typography color='text.primary' sx={{
fontSize: '1.8rem',
overflow: 'hidden',
whiteSpace: 'nowrap',
justifyContent: 'space-between',
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',
}}>
<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'
</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',
}
}}>
onClick={() => {
msg.removeFromQueue(index);
}}
sx={{
backgroundColor: '#ff573a25',
height: '40px',
transition: '250ms',
'&:hover' : {
backgroundColor: '#ff573a',
}
}}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
;
})}
</> : <Box sx={{
@ -383,12 +383,12 @@ const Queue: React.FC = () => {
<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>
@ -396,12 +396,12 @@ const Queue: React.FC = () => {
<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>

View file

@ -2,33 +2,38 @@ import { Box, Button, Menu, MenuItem, Typography } from '@mui/material';
import React from 'react';
import { messageChannelContext } from '../../provider/MessageChannel';
import useStore from '../../hooks/useStore';
import { StoreState } from '../../provider/Store'
import { StoreState } from '../../provider/Store';
const MenuBar: React.FC = () => {
const [ openMenu, setMenuOpen ] = React.useState<'settings'|'help'|undefined>();
const [ openMenu, setMenuOpen ] = React.useState<'settings'|'help'|undefined>();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [store, dispatch] = useStore();
const messageChannel = React.useContext(messageChannelContext);
const getVersion = async() => {
dispatch({
type: 'version',
payload: await messageChannel?.version()
});
}
getVersion();
React.useEffect(() => {
(async () => {
if (!messageChannel || store.version !== '')
return;
dispatch({
type: 'version',
payload: await messageChannel.version()
});
})();
}, [messageChannel]);
const transformService = (service: StoreState['service']) => {
switch(service) {
case 'crunchy':
return "Crunchyroll"
case 'funi':
return "Funimation"
case "hidive":
return "Hidive"
case 'crunchy':
return 'Crunchyroll';
case 'hidive':
return 'Hidive';
case 'ao':
return 'AnimeOnegai';
case 'adn':
return 'AnimationDigitalNetwork';
}
}
};
const msg = React.useContext(messageChannelContext);
@ -46,12 +51,12 @@ const MenuBar: React.FC = () => {
return <Box sx={{ display: 'flex', marginBottom: '1rem', width: '100%', alignItems: 'center' }}>
<Box sx={{ position: 'relative', left: '0%', width: '50%'}}>
<Button onClick={(e) => handleClick(e, 'settings')}>
<Button onClick={(e) => handleClick(e, 'settings')}>
Settings
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
Help
</Button>
</Button>
</Box>
<Menu open={openMenu === 'settings'} anchorEl={anchorEl} onClose={handleClose}>
<MenuItem onClick={() => {
@ -84,7 +89,7 @@ const MenuBar: React.FC = () => {
msg.openURL('https://github.com/anidl/multi-downloader-nx');
handleClose();
}}>
GitHub
GitHub
</MenuItem>
<MenuItem onClick={() => {
msg.openURL('https://github.com/anidl/multi-downloader-nx/issues/new?assignees=AnimeDL,AnidlSupport&labels=bug&template=bug.yml&title=BUG');
@ -116,4 +121,4 @@ const MenuBar: React.FC = () => {
</Box>;
};
export default MenuBar;
export default MenuBar;

View file

@ -11,7 +11,7 @@ import Store from './provider/Store';
import ErrorHandler from './provider/ErrorHandler';
import QueueProvider from './provider/QueueProvider';
document.body.style.backgroundColor = "rgb(0, 30, 60)";
document.body.style.backgroundColor = 'rgb(0, 30, 60)';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import {Divider, Box, Button, Typography, Avatar} from '@mui/material';
import useStore from '../hooks/useStore';
import { StoreState } from './Store';
type Services = 'funi'|'crunchy'|'hidive';
type Services = 'crunchy'|'hidive'|'ao'|'adn';
export const serviceContext = React.createContext<Services|undefined>(undefined);
@ -21,9 +21,10 @@ const ServiceProvider: FCWithChildren = ({ children }) => {
<Box sx={{ justifyContent: 'center', alignItems: 'center', display: 'flex', flexDirection: 'column', position: 'relative', top: '40vh'}}>
<Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please select your service</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button size='large' variant="contained" onClick={() => setService('funi')} startIcon={<Avatar src={'https://static.funimation.com/static/img/favicon.ico'} />}>Funimation</Button>
<Button size='large' variant="contained" onClick={() => setService('crunchy')} startIcon={<Avatar src={'https://static.crunchyroll.com/cxweb/assets/img/favicons/favicon-32x32.png'} />}>Crunchyroll</Button>
<Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://static.diceplatform.com/prod/original/dce.hidive/settings/HIDIVE_AppLogo_1024x1024.0G0vK.jpg'} />}>Hidive</Button>
<Button size='large' variant="contained" onClick={() => setService('ao')} startIcon={<Avatar src={'https://www.animeonegai.com/assets/img/anime/general/ao3-favicon.png'} />}>AnimeOnegai</Button>
<Button size='large' variant="contained" onClick={() => setService('adn')} startIcon={<Avatar src={'https://animationdigitalnetwork.de/favicon.ico'} />}>AnimationDigitalNetwork</Button>
</Box>
</Box>
: <serviceContext.Provider value={service}>

View file

@ -21,7 +21,7 @@ export type DownloadOptions = {
export type StoreState = {
episodeListing: Episode[];
downloadOptions: DownloadOptions,
service: 'crunchy'|'funi'|'hidive'|undefined,
service: 'crunchy'|'hidive'|'ao'|'adn'|undefined,
version: string,
}

View file

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

View file

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

View file

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

139
gui/server/services/adn.ts Normal file
View file

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

View file

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

View file

@ -111,7 +111,7 @@ class CrunchyHandler extends Base implements MessageHandler {
if (res.isOk) {
for (const select of res.value) {
if (!(await this.crunchy.downloadEpisode(select, {..._default, skipsubs: false, callbackMaker: this.makeProgressHandler.bind(this), q: data.q, fileName: data.fileName, dlsubs: data.dlsubs, dlVideoOnce: data.dlVideoOnce, force: 'y',
novids: data.novids, hslang: data.hslang || 'none' }))) {
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);

View file

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

View file

@ -13,12 +13,10 @@ class HidiveHandler extends Base implements MessageHandler {
constructor(ws: WebSocketHandler) {
super(ws);
this.hidive = new Hidive();
this.hidive.doInit();
this.initState();
}
public async auth(data: AuthData) {
await this.getAPIVersion();
return this.hidive.doAuth(data);
}
@ -27,13 +25,7 @@ class HidiveHandler extends Base implements MessageHandler {
return { isOk: true, value: undefined };
}
public async getAPIVersion() {
const _default = yargs.appArgv(this.hidive.cfg.cli, true);
this.hidive.api = _default.hdapi;
}
public async search(data: SearchData): Promise<SearchResponse> {
await this.getAPIVersion();
console.debug(`Got search options: ${JSON.stringify(data)}`);
const hidiveSearch = await this.hidive.doSearch(data);
if (!hidiveSearch.isOk) {
@ -69,46 +61,24 @@ class HidiveHandler extends Base implements MessageHandler {
if (isNaN(parse) || parse <= 0)
return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
await this.getAPIVersion();
if (this.hidive.api == 'old') {
const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.Id],
title: item.Name,
parent: {
title: item.seriesTitle,
season: parseFloat(item.SeasonNumberValue+'')+''
},
image: item.ScreenShotSmallUrl,
e: parseFloat(item.EpisodeNumberValue+'')+'',
episode: parseFloat(item.EpisodeNumberValue+'')+'',
};
}));
return true;
} else {
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.id],
title: item.title,
parent: {
title: item.seriesTitle,
season: item.episodeInformation.seasonNumber+''
},
image: item.thumbnailUrl,
e: item.episodeInformation.episodeNumber+'',
episode: item.episodeInformation.episodeNumber+'',
};
}));
return true;
}
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(item => {
return {
...data,
ids: [item.id],
title: item.title,
parent: {
title: item.seriesTitle,
season: item.episodeInformation.seasonNumber+''
},
image: item.thumbnailUrl,
e: item.episodeInformation.episodeNumber+'',
episode: item.episodeInformation.episodeNumber+'',
};
}));
return true;
}
public async listEpisodes(id: string): Promise<EpisodeListResponse> {
@ -116,73 +86,37 @@ class HidiveHandler extends Base implements MessageHandler {
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
await this.getAPIVersion();
if (this.hidive.api == 'old') {
const request = await this.hidive.listShow(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
const request = await this.hidive.listSeries(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.Episodes.map(function(item) {
const language = item.Summary.match(/^Audio: (.*)/m);
language?.shift();
const description = item.Summary.split('\r\n');
return {
e: parseFloat(item.EpisodeNumberValue+'')+'',
lang: language ? language[0].split(', ') : [],
name: item.Name,
season: parseFloat(item.SeasonNumberValue+'')+'',
seasonTitle: request.value.Name,
episode: parseFloat(item.EpisodeNumberValue+'')+'',
id: item.Id+'',
img: item.ScreenShotSmallUrl,
description: description ? description[0] : '',
time: ''
};
})};
} else {
const request = await this.hidive.listSeries(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.map(function(item) {
const description = item.description.split('\r\n');
return {
e: item.episodeInformation.episodeNumber+'',
lang: [],
name: item.title,
season: item.episodeInformation.seasonNumber+'',
seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1].title,
episode: item.episodeInformation.episodeNumber+'',
id: item.id+'',
img: item.thumbnailUrl,
description: description ? description[0] : '',
time: ''
};
})};
}
return { isOk: true, value: request.value.map(function(item) {
const description = item.description.split('\r\n');
return {
e: item.episodeInformation.episodeNumber+'',
lang: [],
name: item.title,
season: item.episodeInformation.seasonNumber+'',
seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1]?.title ?? request.series.title,
episode: item.episodeInformation.episodeNumber+'',
id: item.id+'',
img: item.thumbnailUrl,
description: description ? description[0] : '',
time: ''
};
})};
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.hidive.cfg.cli, true);
this.hidive.api = _default.hdapi;
if (this.hidive.api == 'old') {
const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
for (const ep of res.value) {
await this.hidive.getEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids });
}
} else {
const res = await this.hidive.selectSeries(parseInt(data.id), data.e, false, false);
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
for (const ep of res.value) {
await this.hidive.downloadEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids });
}
for (const ep of res.value) {
await this.hidive.downloadEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);

View file

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

922
hidive.ts

File diff suppressed because it is too large Load diff

View file

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

View file

@ -3,19 +3,22 @@ import fs from 'fs';
import path from 'path';
import { args, groups } from './module.args';
const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'all'>) => {
const transformService = (str: Array<'crunchy'|'hidive'|'ao'|'adn'|'all'>) => {
const services: string[] = [];
str.forEach(function(part) {
switch(part) {
case 'funi':
services.push('Funimation');
break;
case 'crunchy':
services.push('Crunchyroll');
break;
case 'hidive':
services.push('Hidive');
break;
case 'ao':
services.push('AnimeOnegai');
break;
case 'adn':
services.push('AnimationDigitalNetwork');
break;
case 'all':
services.push('All');
break;
@ -30,7 +33,7 @@ If you find any bugs in this documentation or in the program itself please repor
## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *Hidive*, or *Crunchyroll*.
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.

View file

@ -5,9 +5,11 @@ import modulesCleanup from 'removeNPMAbsolutePaths';
import { exec } from '@yao-pkg/pkg';
import { execSync } from 'child_process';
import { console } from './log';
import esbuild from 'esbuild';
import path from 'path';
const buildsDir = './_builds';
const nodeVer = 'node18-';
const nodeVer = 'node20-';
type BuildTypes = `${'windows'|'macos'|'linux'|'linuxstatic'|'alpine'}-${'x64'|'arm64'}`|'linuxstatic-armv7'
@ -43,10 +45,35 @@ async function buildBinary(buildType: BuildTypes, gui: boolean) {
fs.removeSync(buildDir);
}
fs.mkdirSync(buildDir);
console.info('Running esbuild');
const build = await esbuild.build({
entryPoints: [
gui ? 'gui.js' : 'index.js',
],
sourceRoot: './',
bundle: true,
platform: 'node',
format: 'cjs',
treeShaking: true,
// External source map for debugging
sourcemap: true,
// Minify and keep the original names
minify: true,
keepNames: true,
outfile: path.join(buildsDir, 'index.cjs'),
metafile: true,
external: ['cheerio']
});
if (build.errors?.length > 0) console.error(build.errors);
if (build.warnings?.length > 0) console.warn(build.warnings);
const buildConfig = [
gui ? 'gui.js' : 'index.js',
`${buildsDir}/index.cjs`,
'--target', nodeVer + buildType,
'--output', `${buildDir}/${pkg.short_name}`,
'--compress', 'GZip'
];
console.info(`[Build] Build configuration: ${buildFull}`);
try {

View file

@ -421,6 +421,12 @@ const extFn = {
if ((options.url.hostname as string).match('hidive')) {
options.headers['referrer'] = 'https://www.hidive.com/';
options.headers['origin'] = 'https://www.hidive.com';
} else if ((options.url.hostname as string).includes('animecdn')) {
options.headers = {
origin: 'https://www.animeonegai.com',
referer: 'https://www.animeonegai.com/',
range: options.headers['range']
};
}
// console.log(' - Req:', options.url.pathname);
}

View file

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

View file

@ -1,5 +1,3 @@
import { Headers } from 'got/dist/source';
// api domains
const domain = {
www: 'https://www.crunchyroll.com',
@ -26,16 +24,28 @@ export type APIType = {
collections: string
// beta api
beta_auth: string
beta_authBasic: string
beta_authBasicMob: string
authBasic: string
authBasicMob: string
authBasicSwitch: string,
beta_profile: string
beta_cmsToken: string
search: string
cms: string
beta_browse: string
beta_cms: string,
beta_authHeader: Headers,
beta_authHeaderMob: Headers,
drm: string;
/**
* Web Header
*/
crunchyAuthHeader: Record<string, string>,
/**
* Mobile Header
*/
crunchyAuthHeaderMob: Record<string, string>,
/**
* Switch Header
*/
crunchyAuthHeaderSwitch: Record<string, string>,
hd_apikey: string,
hd_devName: string,
hd_appId: string,
@ -64,16 +74,19 @@ const api: APIType = {
collections: `${domain.api}/list_collections.0.json`,
// beta api
beta_auth: `${domain.api_beta}/auth/v1/token`,
beta_authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
beta_authBasicMob: 'Basic b2VkYXJteHN0bGgxanZhd2ltbnE6OWxFaHZIWkpEMzJqdVY1ZFc5Vk9TNTdkb3BkSnBnbzE=',
authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
authBasicMob: 'Basic dXU4aG0wb2g4dHFpOWV0eXl2aGo6SDA2VnVjRnZUaDJ1dEYxM0FBS3lLNE85UTRhX3BlX1o=',
authBasicSwitch: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=',
beta_profile: `${domain.api_beta}/accounts/v1/me/profile`,
beta_cmsToken: `${domain.api_beta}/index/v2`,
search: `${domain.api_beta}/content/v2/discover/search`,
cms: `${domain.api_beta}/content/v2/cms`,
beta_browse: `${domain.api_beta}/content/v1/browse`,
beta_cms: `${domain.api_beta}/cms/v2`,
beta_authHeader: {},
beta_authHeaderMob: {},
drm: `${domain.api_beta}/drm/v1/auth`,
crunchyAuthHeader: {},
crunchyAuthHeaderMob: {},
crunchyAuthHeaderSwitch: {},
//hidive API
hd_apikey: '508efd7b42d546e19cc24f4d0b414e57e351ca73',
hd_devName: 'Android',
@ -88,11 +101,17 @@ const api: APIType = {
};
// set header
api.beta_authHeader = {
Authorization: api.beta_authBasic,
api.crunchyAuthHeader = {
Authorization: api.authBasic,
};
api.beta_authHeaderMob = {
Authorization: api.beta_authBasicMob,
api.crunchyAuthHeaderMob = {
Authorization: api.authBasicMob,
'user-agent': 'Crunchyroll/3.60.0 Android/9 okhttp/4.12.0'
};
api.crunchyAuthHeaderSwitch = {
Authorization: api.authBasicSwitch,
};
export {

View file

@ -1,6 +1,11 @@
import yargs, { Choices } from 'yargs';
import { args, AvailableMuxer, groups } from './module.args';
import { LanguageItem } from './module.langsData';
import { DownloadInfo } from '../@types/messageHandler';
import { HLSCallback } from './hls-download';
import leven from 'leven';
import { console } from './log';
import { CrunchyPlayStreams } from '../@types/enums';
let argvC: {
[x: string]: unknown;
@ -38,7 +43,8 @@ let argvC: {
extid: string | undefined;
q: number;
x: number;
kstream: number;
kstream: number;
cstream: keyof typeof CrunchyPlayStreams | 'none';
partsize: number;
hslang: string;
dlsubs: string[];
@ -61,7 +67,7 @@ let argvC: {
debug: boolean | undefined;
nocleanup: boolean;
help: boolean | undefined;
service: 'funi' | 'crunchy' | 'hidive';
service: 'crunchy' | 'hidive' | 'ao' | 'adn';
update: boolean;
fontName: string | undefined;
_: (string | number)[];
@ -69,11 +75,11 @@ let argvC: {
dlVideoOnce: boolean;
chapters: boolean;
crapi: 'android' | 'web';
hdapi: 'old' | 'new';
removeBumpers: boolean;
originalFontSize: boolean;
keepAllVideos: boolean;
syncTiming: boolean;
callbackMaker?: (data: DownloadInfo) => HLSCallback;
};
export type ArgvType = typeof argvC;
@ -111,15 +117,15 @@ const getArgv = (cfg: { [key:string]: unknown }, isGUI: boolean) => {
return cfg[key] as T;
} else
return _default;
};
};
const argv = yargs.parserConfiguration({
'duplicate-arguments-array': false,
'camel-case-expansion': false,
})
.wrap(yargs.terminalWidth())
.usage('Usage: $0 [options]')
.help(true).version(false);
.help(true);
//.strictOptions()
const data = args.map(a => {
return {
...a,
@ -141,7 +147,31 @@ const getArgv = (cfg: { [key:string]: unknown }, isGUI: boolean) => {
},
choices: item.name === 'service' && isGUI ? undefined : item.choices as unknown as Choices
});
return argv as unknown as yargs.Argv<typeof argvC>;
};
// Custom logic for suggesting corrections for misspelled options
argv.middleware((argv: Record<string, any>) => {
// List of valid options
const validOptions = [
...args.map(a => a.name),
...args.map(a => a.alias).filter(alias => alias !== undefined) as string[]
];
const unknownOptions = Object.keys(argv).filter(key => !validOptions.includes(key) && key !== '_' && key !== '$0'); // Filter out known options
const suggestedOptions: Record<string, boolean> = {};
unknownOptions.forEach(actualOption => {
const closestOption = validOptions.find(option => {
const levenVal = leven(option, actualOption);
return levenVal <= 2 && levenVal > 0;
});
if (closestOption && !suggestedOptions[closestOption]) {
suggestedOptions[closestOption] = true;
console.info(`Unknown option ${actualOption}, did you mean ${closestOption}?`);
} else if (!suggestedOptions[actualOption]) {
suggestedOptions[actualOption] = true;
console.info(`Unknown option ${actualOption}`);
}
});
});
return argv as unknown as yargs.Argv<typeof argvC>;
};

View file

@ -1,4 +1,5 @@
import { dubLanguageCodes, languages, searchLocales, subtitleLanguagesFilter } from './module.langsData';
import { aoSearchLocales, dubLanguageCodes, languages, searchLocales, subtitleLanguagesFilter } from './module.langsData';
import { CrunchyPlayStreams } from '../@types/enums';
const groups = {
'auth': 'Authentication:',
@ -29,7 +30,7 @@ const availableFilenameVars: AvailableFilenameVars[] = [
export type AvailableMuxer = 'ffmpeg' | 'mkvmerge'
export const muxer: AvailableMuxer[] = [ 'ffmpeg', 'mkvmerge' ];
type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
export type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
name: string,
group: keyof typeof groups,
type: 'boolean'|'string'|'number'|'array',
@ -41,7 +42,7 @@ type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
default: T|undefined,
name?: string
},
service: Array<'funi'|'crunchy'|'hidive'|'all'>,
service: Array<'crunchy'|'hidive'|'ao'|'adn'|'all'>,
usage: string // -(-)${name} will be added for each command,
demandOption?: true,
transformer?: (value: T) => K
@ -107,12 +108,12 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Set the service locale',
docDescribe: 'Set the local that will be used for the API.',
group: 'search',
choices: (searchLocales.filter(a => a !== undefined) as string[]),
choices: ([...searchLocales.filter(a => a !== undefined), ...aoSearchLocales.filter(a => a !== undefined)] as string[]),
default: {
default: 'en-US'
},
type: 'string',
service: ['crunchy'],
service: ['crunchy', 'ao', 'adn'],
usage: '${locale}'
},
{
@ -193,7 +194,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Download only once the video with the best selected quality',
type: 'boolean',
group: 'dl',
service: ['crunchy'],
service: ['crunchy', 'ao'],
docDescribe: 'If selected, the best selected quality will be downloaded only for the first language,'
+ '\nthen the worst video quality with the same audio quality will be downloaded for every other language.'
+ '\nBy the later merge of the videos, no quality difference will be present.'
@ -208,7 +209,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Will fetch the chapters and add them into the final video',
type: 'boolean',
group: 'dl',
service: ['crunchy'],
service: ['crunchy', 'adn'],
docDescribe: 'Will fetch the chapters and add them into the final video.'
+ '\nCurrently only works with mkvmerge.',
usage: '',
@ -230,20 +231,6 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: 'web'
}
},
{
name: 'hdapi',
describe: 'Selects the API type for Hidive',
type: 'string',
group: 'dl',
service: ['hidive'],
docDescribe: 'If set to Old, it has lower quality, but Non-DRM streams, but some people can\'t use it,'
+ '\nIf set to New, it has a higher quality stream, but everything is DRM.',
usage: '',
choices: ['old', 'new'],
default: {
default: 'new'
}
},
{
name: 'removeBumpers',
describe: 'Remove bumpers from final video',
@ -280,7 +267,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
type: 'number',
alias: 'server',
docDescribe: true,
service: ['crunchy','funi'],
service: ['crunchy'],
usage: '${server}'
},
{
@ -297,6 +284,20 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
type: 'number',
usage: '${stream}'
},
{
name: 'cstream',
group: 'dl',
alias: 'cs',
service: ['crunchy'],
type: 'string',
describe: 'Select specific crunchy play stream by device, or disable stream with "none"',
choices: [...Object.keys(CrunchyPlayStreams), 'none'],
default: {
default: 'chrome'
},
docDescribe: true,
usage: '${device}'
},
{
name: 'hslang',
group: 'dl',
@ -314,8 +315,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
name: 'dlsubs',
group: 'dl',
describe: 'Download subtitles by language tag (space-separated)'
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.locale).join(', ')}`
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.locale).join(', ')}`,
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.locale).join(', ')}`,
docDescribe: true,
service: ['all'],
type: 'array',
@ -339,7 +339,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
describe: 'Skip downloading audio',
docDescribe: true,
service: ['funi'],
service: ['crunchy', 'hidive'],
type: 'boolean',
usage: ''
},
@ -355,8 +355,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
{
name: 'dubLang',
describe: 'Set the language to download: '
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.code).join(', ')}`
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.code).join(', ')}`,
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.code).join(', ')}`,
docDescribe: true,
group: 'dl',
choices: dubLanguageCodes,
@ -439,7 +438,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl',
describe: 'Force downloading simulcast version instead of uncut version (if available).',
docDescribe: true,
service: ['funi', 'hidive'],
service: ['hidive'],
type: 'boolean',
usage: '',
default: {
@ -572,7 +571,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'util',
service: ['all'],
type: 'string',
choices: ['funi', 'crunchy', 'hidive'],
choices: ['crunchy', 'hidive', 'ao', 'adn'],
usage: '${service}',
default: {
default: ''
@ -593,7 +592,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'fonts',
describe: 'Set the font to use in subtiles',
docDescribe: true,
service: ['funi', 'hidive'],
service: ['hidive', 'adn'],
type: 'string',
usage: '${fontName}',
},
@ -677,7 +676,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Authenticate every time the script runs. Use at your own risk.',
docDescribe: true,
group: 'auth',
service: ['funi','crunchy'],
service: ['crunchy'],
type: 'boolean',
usage: '',
default: {
@ -689,7 +688,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Allows you to login with your token (Example on crunchy is Refresh Token/etp-rt cookie)',
docDescribe: true,
group: 'auth',
service: ['crunchy'],
service: ['crunchy', 'ao'],
type: 'string',
usage: '${token}',
default: {
@ -881,7 +880,6 @@ const buildDefault = () => {
};
export {
TAppArg,
getDefault,
buildDefault,
args,

View file

@ -18,16 +18,18 @@ const guiCfgFile = path.join(workingDir, 'config', 'gui');
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile');
const sessCfgFile = {
funi: path.join(workingDir, 'config', 'funi_sess'),
cr: path.join(workingDir, 'config', 'cr_sess'),
hd: path.join(workingDir, 'config', 'hd_sess')
hd: path.join(workingDir, 'config', 'hd_sess'),
ao: path.join(workingDir, 'config', 'ao_sess'),
adn: path.join(workingDir, 'config', 'adn_sess')
};
const stateFile = path.join(workingDir, 'config', 'guistate');
const tokenFile = {
funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token'),
hd: path.join(workingDir, 'config', 'hd_token'),
hdNew: path.join(workingDir, 'config', 'hd_new_token')
hdNew:path.join(workingDir, 'config', 'hd_new_token'),
ao: path.join(workingDir, 'config', 'ao_token'),
adn: path.join(workingDir, 'config', 'adn_token')
};
export const ensureConfig = () => {
@ -215,7 +217,44 @@ const saveCRToken = (data: Record<string, unknown>) => {
console.error('Can\'t save token file to disk!');
}
};
const loadADNToken = () => {
let token = loadYamlCfgFile(tokenFile.adn, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveADNToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.adn);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.adn}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadAOToken = () => {
let token = loadYamlCfgFile(tokenFile.ao, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveAOToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.ao);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.ao}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadHDSession = () => {
let session = loadYamlCfgFile(sessCfgFile.hd, true);
@ -312,33 +351,6 @@ const saveNewHDToken = (data: Record<string, unknown>) => {
}
};
const loadFuniToken = () => {
const loadedToken = loadYamlCfgFile<{
token?: string
}>(tokenFile.funi, true);
let token: false|string = false;
if (loadedToken && loadedToken.token)
token = loadedToken.token;
// info if token not set
if(!token){
console.info('[INFO] Token not set!\n');
}
return token;
};
const saveFuniToken = (data: {
token?: string
}) => {
const cfgFolder = path.dirname(tokenFile.funi);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.funi}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const cfgDir = path.join(workingDir, 'config');
const getState = (): GuiState => {
@ -373,12 +385,12 @@ const setState = (state: GuiState) => {
export {
loadBinCfg,
loadCfg,
loadFuniToken,
saveFuniToken,
saveCRSession,
loadCRSession,
saveCRToken,
loadCRToken,
saveADNToken,
loadADNToken,
saveHDSession,
loadHDSession,
saveHDToken,
@ -387,6 +399,8 @@ export {
loadNewHDToken,
saveHDProfile,
loadHDProfile,
saveAOToken,
loadAOToken,
getState,
setState,
writeYamlCfgFile,

View file

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

146
modules/module.fetch.ts Normal file
View file

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

View file

@ -35,7 +35,7 @@ function assFonts(ass: string){
styles.push(addStyle[1]);
}
}
const fontMatches = ass.matchAll(/\\fn(.*?)(?:\\|})/g);
const fontMatches = ass.matchAll(/\\fn([^\\}]+)/g);
for (const match of fontMatches) {
styles.push(match[1]);
}

View file

@ -3,35 +3,35 @@
export type LanguageItem = {
cr_locale?: string,
hd_locale?: string,
adn_locale?: string,
new_hd_locale?: string,
ao_locale?: string,
locale: string,
code: string,
name: string,
language?: string,
funi_locale?: string,
funi_name?: string,
funi_name_lagacy?: string
language?: string
}
const languages: LanguageItem[] = [
{ cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', },
{ cr_locale: 'es-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419',ao_locale: 'es',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-ES', new_hd_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' },
{ cr_locale: 'pt-BR', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'pt-BR', ao_locale: 'pt',new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'pt-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' },
{ cr_locale: 'fr-FR', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
{ cr_locale: 'fr-FR', adn_locale: 'fr', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', adn_locale: 'de', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
{ cr_locale: 'ar-ME', locale: 'ar', code: 'ara-ME', name: 'Arabic' },
{ cr_locale: 'ar-SA', hd_locale: 'Arabic', locale: 'ar', code: 'ara', name: 'Arabic (Saudi Arabia)' },
{ cr_locale: 'it-IT', hd_locale: 'Italian', locale: 'it', code: 'ita', name: 'Italian' },
{ cr_locale: 'ru-RU', hd_locale: 'Russian', locale: 'ru', code: 'rus', name: 'Russian' },
{ cr_locale: 'tr-TR', hd_locale: 'Turkish', locale: 'tr', code: 'tur', name: 'Turkish' },
{ cr_locale: 'hi-IN', locale: 'hi', code: 'hin', name: 'Hindi' },
{ funi_locale: 'zhMN', locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' },
{ locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' },
{ cr_locale: 'zh-CN', locale: 'zh-CN', code: 'zho', name: 'Chinese (Mainland China)' },
{ cr_locale: 'zh-TW', locale: 'zh-TW', code: 'chi', name: 'Chinese (Taiwan)' },
{ cr_locale: 'zh-HK', locale: 'zh-HK', code: 'zh-HK', name: 'Chinese (Hong-Kong)', language: '中文 (粵語)' },
{ cr_locale: 'ko-KR', hd_locale: 'Korean', locale: 'ko', code: 'kor', name: 'Korean' },
{ cr_locale: 'ca-ES', locale: 'ca-ES', code: 'cat', name: 'Catalan' },
{ cr_locale: 'pl-PL', locale: 'pl-PL', code: 'pol', name: 'Polish' },
@ -41,7 +41,7 @@ const languages: LanguageItem[] = [
{ cr_locale: 'vi-VN', locale: 'vi-VN', code: 'vie', name: 'Vietnamese', language: 'Tiếng Việt' },
{ cr_locale: 'id-ID', locale: 'id-ID', code: 'ind', name: 'Indonesian', language: 'Bahasa Indonesia' },
{ cr_locale: 'te-IN', locale: 'te-IN', code: 'tel', name: 'Telugu (India)', language: 'తెలుగు' },
{ cr_locale: 'ja-JP', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' },
{ cr_locale: 'ja-JP', adn_locale: 'ja', ao_locale: 'ja', hd_locale: 'Japanese', locale: 'ja', code: 'jpn', name: 'Japanese' },
];
// add en language names
@ -69,7 +69,11 @@ const subtitleLanguagesFilter = (() => {
})();
const searchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1))];
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1)), ...new Set(languages.map(l => { return l.adn_locale; }).slice(0, -1))];
})();
export const aoSearchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.ao_locale; }).slice(0, -1))];
})();
// convert

View file

@ -75,7 +75,7 @@ class Merger {
for (const [vnaIndex, vna] of vnas.entries()) {
const streamInfo = await ffprobe(vna.path, { path: bin.ffprobe as string });
const videoInfo = streamInfo.streams.filter(stream => stream.codec_type == 'video');
vnas[vnaIndex].duration = videoInfo[0].duration;
vnas[vnaIndex].duration = parseInt(videoInfo[0].duration as string);
}
//Sort videoAndAudio streams by duration (shortest first)
vnas.sort((a,b) => {

View file

@ -27,7 +27,7 @@ const usefulCookies = {
// req
class Req {
private sessCfg: string;
private service: 'cr'|'funi'|'hd';
private service: 'cr'|'hd'|'ao';
private session: Record<string, {
value: string;
expires: Date;
@ -39,7 +39,7 @@ class Req {
private cfgDir = yamlCfg.cfgDir;
private curl: boolean|string = false;
constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'funi'|'hd') {
constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'hd'|'ao') {
this.sessCfg = yamlCfg.sessCfgFile[type];
this.service = type;
}

View file

@ -1,5 +1,6 @@
import { parse as mpdParse } from 'mpd-parser';
import { LanguageItem, findLang, languages } from './module.langsData';
import { console } from './log';
type Segment = {
uri: string;
@ -7,9 +8,17 @@ type Segment = {
duration: number;
map: {
uri: string;
byterange?: {
length: number,
offset: number
};
};
number: number;
presentationTime: number;
byterange?: {
length: number,
offset: number
};
number?: number;
presentationTime?: number;
}
export type PlaylistItem = {
@ -38,19 +47,55 @@ export type MPDParsed = {
}
}
export function parse(manifest: string, language?: LanguageItem, url?: string) {
export async function parse(manifest: string, language?: LanguageItem, url?: string) {
if (!manifest.includes('BaseURL') && url) {
manifest = manifest.replace(/(<MPD*\b[^>]*>)/gm, `$1<BaseURL>${url}</BaseURL>`);
}
const parsed = mpdParse(manifest);
const ret: MPDParsed = {};
// Audio Loop
for (const item of Object.values(parsed.mediaGroups.AUDIO.audio)){
for (const playlist of item.playlists) {
const host = new URL(playlist.resolvedUri).hostname;
if (!Object.prototype.hasOwnProperty.call(ret, host))
ret[host] = { audio: [], video: [] };
if (playlist.sidx && playlist.segments.length == 0) {
const options: RequestInit = {
method: 'head'
};
if (playlist.sidx.uri.includes('animecdn')) options.headers = {
'origin': 'https://www.animeonegai.com',
'referer': 'https://www.animeonegai.com/',
};
const item = await fetch(playlist.sidx.uri, options);
if (!item.ok) console.warn(`${item.status}: ${item.statusText}, Unable to fetch byteLength for audio stream ${Math.round(playlist.attributes.BANDWIDTH/1024)}KiB/s`);
const byteLength = parseInt(item.headers.get('content-length') as string);
let currentByte = playlist.sidx.map.byterange.length;
while (currentByte <= byteLength) {
playlist.segments.push({
'duration': 0,
'map': {
'uri': playlist.resolvedUri,
'resolvedUri': playlist.resolvedUri,
'byterange': playlist.sidx.map.byterange
},
'uri': playlist.resolvedUri,
'resolvedUri': playlist.resolvedUri,
'byterange': {
'length': 500000,
'offset': currentByte
},
timeline: 0,
number: 0,
presentationTime: 0
});
currentByte = currentByte + 500000;
}
}
//Find and add audio language if it is found in the MPD
let audiolang: LanguageItem;
const foundlanguage = findLang(languages.find(a => a.code === item.language)?.cr_locale ?? 'unknown');
@ -68,10 +113,11 @@ export function parse(manifest: string, language?: LanguageItem, url?: string) {
const map_uri = segment.map.resolvedUri;
return {
duration: segment.duration,
map: { uri: map_uri },
map: { uri: map_uri, byterange: segment.map.byterange },
number: segment.number,
presentationTime: segment.presentationTime,
timeline: segment.timeline,
byterange: segment.byterange,
uri
};
})
@ -85,11 +131,46 @@ export function parse(manifest: string, language?: LanguageItem, url?: string) {
}
}
// Video Loop
for (const playlist of parsed.playlists) {
const host = new URL(playlist.resolvedUri).hostname;
if (!Object.prototype.hasOwnProperty.call(ret, host))
ret[host] = { audio: [], video: [] };
if (playlist.sidx && playlist.segments.length == 0) {
const options: RequestInit = {
method: 'head'
};
if (playlist.sidx.uri.includes('animecdn')) options.headers = {
'origin': 'https://www.animeonegai.com',
'referer': 'https://www.animeonegai.com/',
};
const item = await fetch(playlist.sidx.uri, options);
if (!item.ok) console.warn(`${item.status}: ${item.statusText}, Unable to fetch byteLength for video stream ${playlist.attributes.RESOLUTION?.height}x${playlist.attributes.RESOLUTION?.width}@${Math.round(playlist.attributes.BANDWIDTH/1024)}KiB/s`);
const byteLength = parseInt(item.headers.get('content-length') as string);
let currentByte = playlist.sidx.map.byterange.length;
while (currentByte <= byteLength) {
playlist.segments.push({
'duration': 0,
'map': {
'uri': playlist.resolvedUri,
'resolvedUri': playlist.resolvedUri,
'byterange': playlist.sidx.map.byterange
},
'uri': playlist.resolvedUri,
'resolvedUri': playlist.resolvedUri,
'byterange': {
'length': 2000000,
'offset': currentByte
},
timeline: 0,
number: 0,
presentationTime: 0
});
currentByte = currentByte + 2000000;
}
}
const pItem: VideoPlayList = {
bandwidth: playlist.attributes.BANDWIDTH,
quality: playlist.attributes.RESOLUTION!,
@ -98,10 +179,11 @@ export function parse(manifest: string, language?: LanguageItem, url?: string) {
const map_uri = segment.map.resolvedUri;
return {
duration: segment.duration,
map: { uri: map_uri },
map: { uri: map_uri, byterange: segment.map.byterange },
number: segment.number,
presentationTime: segment.presentationTime,
timeline: segment.timeline,
byterange: segment.byterange,
uri
};
})

View file

@ -126,6 +126,19 @@ function parseStyle(stylegroup: string, line: string, style: any) {
break;
}
break;
case 'text-decoration':
if (st[1] === 'underline') {
style[8] = -1;
} else {
console.warn(`vtt2ass: Unknown text-decoration value: ${st[1]}`);
}
break;
case 'right':
style[17] = 3;
break;
case 'left':
style[17] = 1;
break;
case 'font-style':
if (st[1] === 'italic') {
style[7] = -1;

View file

@ -16,7 +16,7 @@ try {
const stats = fs.statSync(file);
if (stats.size < 1024*8 && stats.isFile()) {
const fileContents = fs.readFileSync(file, {'encoding': 'utf8'});
if (fileContents.includes('-BEGIN RSA PRIVATE KEY-')) {
if (fileContents.includes('-BEGIN PRIVATE KEY-') || fileContents.includes('-BEGIN RSA PRIVATE KEY-')) {
privateKey = fs.readFileSync(file);
}
if (fileContents.includes('widevine_cdm_version')) {

View file

@ -1,13 +1,11 @@
{
"name": "multi-downloader-nx",
"short_name": "aniDL",
"version": "4.6.2",
"description": "Downloader for Crunchyroll, Funimation, or Hidive via CLI or GUI",
"version": "5.1.5",
"description": "Downloader for Crunchyroll, Hidive, AnimeOnegai, and AnimationDigitalNetwork with CLI and GUI",
"keywords": [
"download",
"downloader",
"funimation",
"funimationnow",
"hidive",
"crunchy",
"crunchyroll",
@ -42,60 +40,52 @@
},
"license": "MIT",
"dependencies": {
"@babel/core": "^7.22.9",
"@babel/plugin-syntax-flow": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.22.5",
"@types/xmldom": "^0.1.34",
"@yao-pkg/pkg": "^5.11.1",
"cheerio": "1.0.0-rc.12",
"@yao-pkg/pkg": "^5.12.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"eslint-plugin-import": "^2.27.5",
"express": "^4.18.2",
"esbuild": "^0.21.5",
"express": "^4.19.2",
"ffprobe": "^1.1.2",
"form-data": "^4.0.0",
"fs-extra": "^11.1.1",
"fs-extra": "^11.2.0",
"got": "^11.8.6",
"iso-639": "^0.2.2",
"leven": "^3.1.0",
"log4js": "^6.9.1",
"long": "^5.2.3",
"lookpath": "^1.2.2",
"m3u8-parsed": "^1.3.0",
"mpd-parser": "^1.3.0",
"open": "^8.4.2",
"protobufjs": "^7.2.5",
"protobufjs": "^7.3.2",
"sei-helper": "^3.3.0",
"typescript-eslint": "0.0.1-alpha.0",
"ws": "^8.13.0",
"xmldom": "^0.6.0",
"yaml": "^2.3.1",
"ws": "^8.17.1",
"yaml": "^2.4.5",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/ffprobe": "^1.1.4",
"@types/fs-extra": "^11.0.1",
"@types/node": "^18.15.11",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vercel/webpack-asset-relocator-loader": "^1.7.3",
"@yao-pkg/pkg": "^5.11.1",
"eslint": "^8.45.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/ffprobe": "^1.1.8",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.14.6",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react": "7.34.3",
"protoc": "^1.1.3",
"removeNPMAbsolutePaths": "^3.0.1",
"ts-node": "^10.9.1",
"ts-proto": "^1.169.1",
"typescript": "5.1.6"
"ts-node": "^10.9.2",
"ts-proto": "^1.180.0",
"typescript": "5.5.2",
"typescript-eslint": "7.13.1"
},
"scripts": {
"prestart": "pnpm run tsc test",
"start": "pnpm prestart && cd lib && node gui.js",
"gui": "cd ./gui/react/ && pnpm start",
"docs": "ts-node modules/build-docs.ts",
"tsc": "ts-node tsc.ts",
"proto:compile": "protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=\"esModuleInterop=true\" --ts_proto_opt=\"forceLong=long\" --ts_proto_opt=\"env=node\" --ts_proto_out=. modules/*.proto",
@ -113,8 +103,8 @@
"build-macos-gui": "pnpm run prebuild-gui && cd lib && node modules/build macos-x64 true",
"build-alpine-gui": "pnpm run prebuild-gui && cd lib && node modules/build alpine-x64 true",
"build-android-gui": "pnpm run prebuild-gui && cd lib && node modules/build linuxstatic-armv7 true",
"eslint": "eslint *.js modules",
"eslint-fix": "eslint *.js modules --fix",
"eslint": "npx eslint .",
"eslint-fix": "npx eslint . --fix",
"pretest": "pnpm run tsc",
"test": "pnpm run pretest && cd lib && node modules/build windows-x64 && node modules/build linuxstatic-x64 && node modules/build macos-x64"
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
import React from 'react';
import { StoreAction, StoreContext, StoreState } from '../provider/Store';
const useStore = () => {
const context = React.useContext(StoreContext as unknown as React.Context<[StoreState, React.Dispatch<StoreAction<keyof StoreState>>]>);
if (!context) {
throw new Error('useStore must be used under Store');
}
return context;
};
export default useStore;

9
tsc.ts
View file

@ -32,12 +32,9 @@ const ignore = [
'./bin/mkvtoolnix*',
'./config/token.yml$',
'./config/updates.json$',
'./config/cr_token.yml$',
'./config/funi_token.yml$',
'./config/new_hd_token.yml$',
'./config/hd_token.yml$',
'./config/hd_sess.yml$',
'./config/hd_profile.yml$',
'./config/*_token.yml$',
'./config/*_sess.yml$',
'./config/*_profile.yml$',
'*/\\.eslint*',
'*/*\\.tsx?$',
'./fonts*',