mirror of
https://github.com/wukko/cobalt.git
synced 2025-03-05 16:38:53 +01:00
Merge branch 'current' into feat/threads
This commit is contained in:
commit
bb04206e01
44 changed files with 1143 additions and 339 deletions
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
custom: https://boosty.to/wukko/donate
|
|
36
.github/ISSUE_TEMPLATE/bug-main-instance.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug-main-instance.md
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
name: main instance bug report
|
||||||
|
about: report an issue with cobalt.tools or api.cobalt.tools
|
||||||
|
title: '[short description of the bug]'
|
||||||
|
labels: main instance issue
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bug description
|
||||||
|
clear and concise description of what the issue is.
|
||||||
|
|
||||||
|
### reproduction steps
|
||||||
|
steps to reproduce the described behavior.
|
||||||
|
here's an example of what it could look like:
|
||||||
|
1. go to '...'
|
||||||
|
2. click on '....'
|
||||||
|
3. download [media type] from [service]
|
||||||
|
4. see error
|
||||||
|
|
||||||
|
### screenshots
|
||||||
|
if applicable, add screenshots or screen recordings to support your explanation.
|
||||||
|
if not, remove this section.
|
||||||
|
|
||||||
|
### links
|
||||||
|
if applicable, add links that cause the issue. more = better.
|
||||||
|
if not, remove this section.
|
||||||
|
|
||||||
|
### platform information
|
||||||
|
- OS [e.g. iOS, windows]
|
||||||
|
- browser [e.g. chrome, safari, firefox]
|
||||||
|
- version [e.g. 115]
|
||||||
|
|
||||||
|
### additional context
|
||||||
|
add any other context about the problem here if applicable.
|
||||||
|
if not, remove this section.
|
32
.github/ISSUE_TEMPLATE/bug-report.md
vendored
32
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -1,32 +1,36 @@
|
||||||
---
|
---
|
||||||
name: bug report
|
name: bug report
|
||||||
about: report an issue with downloads or something else
|
about: report a global issue with the cobalt codebase
|
||||||
title: ''
|
title: '[short description of the bug]'
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**bug description**
|
### bug description
|
||||||
a clear and concise description of what the bug is.
|
clear and concise description of what the issue is.
|
||||||
|
|
||||||
**reproduction steps**
|
### reproduction steps
|
||||||
steps to reproduce the behavior:
|
steps to reproduce the described behavior.
|
||||||
|
here's an example of what it could look like:
|
||||||
1. go to '...'
|
1. go to '...'
|
||||||
2. click on '....'
|
2. click on '....'
|
||||||
3. download this video: **[link here]**
|
3. download [media type] from [service]
|
||||||
4. see error
|
4. see error
|
||||||
|
|
||||||
**screenshots**
|
### screenshots
|
||||||
if applicable, add screenshots or screen recordings to help explain your problem.
|
if applicable, add screenshots or screen recordings to support your explanation.
|
||||||
|
if not, remove this section.
|
||||||
|
|
||||||
**links**
|
### links
|
||||||
if applicable, add links that cause the issue. more = better.
|
if applicable, add links that cause the issue. more = better.
|
||||||
|
if not, remove this section.
|
||||||
|
|
||||||
**platform**
|
### platform information
|
||||||
- OS [e.g. iOS, windows]
|
- OS [e.g. iOS, windows]
|
||||||
- browser [e.g. chrome, safari, firefox]
|
- browser [e.g. chrome, safari, firefox]
|
||||||
- version [e.g. 115]
|
- version [e.g. 115]
|
||||||
|
|
||||||
**additional context**
|
### additional context
|
||||||
add any other context about the problem here.
|
add any other context about the problem here if applicable.
|
||||||
|
if not, remove this section.
|
||||||
|
|
14
.github/ISSUE_TEMPLATE/feature-request.md
vendored
14
.github/ISSUE_TEMPLATE/feature-request.md
vendored
|
@ -1,17 +1,15 @@
|
||||||
---
|
---
|
||||||
name: feature request
|
name: feature request
|
||||||
about: suggest a feature for cobalt
|
about: suggest a feature for cobalt
|
||||||
title: ''
|
title: '[short feature request description]'
|
||||||
labels: feature request
|
labels: feature request
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**describe the feature you'd like to see**
|
### describe the feature you'd like to see
|
||||||
a clear and concise description of what you want to happen.
|
clear and concise description of the feature you want to see in cobalt.
|
||||||
|
|
||||||
**describe alternatives you've considered**
|
### additional context
|
||||||
a clear and concise description of any alternative solutions or features you've considered.
|
if applicable, add any other context or screenshots related to the feature request here.
|
||||||
|
if not, remove this section.
|
||||||
**additional context**
|
|
||||||
add any other context or screenshots about the feature request here.
|
|
||||||
|
|
12
.github/ISSUE_TEMPLATE/hosting-help.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/hosting-help.md
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
name: instance hosting help
|
||||||
|
about: ask any question regarding cobalt instance hosting
|
||||||
|
title: '[short description of the problem]'
|
||||||
|
labels: instance hosting help
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### problem description
|
||||||
|
describe what issue you're having, clearly and concisely.
|
||||||
|
support your description with screenshots/links/etc when needed.
|
18
.github/ISSUE_TEMPLATE/service-request.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/service-request.md
vendored
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
name: service request
|
||||||
|
about: request service support in cobalt
|
||||||
|
title: 'add support for [service name]'
|
||||||
|
labels: service request
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### service name & description
|
||||||
|
provide the service name and brief description of what it is.
|
||||||
|
|
||||||
|
### link samples for the service you'd like cobalt to support
|
||||||
|
list of links that cobalt should recognize.
|
||||||
|
could be regular video link, shared video link, mobile video link, shortened link, etc.
|
||||||
|
|
||||||
|
### additional context
|
||||||
|
any additional context or screenshots should go here. if there aren't any, just remove this part.
|
2
.github/test.sh
vendored
2
.github/test.sh
vendored
|
@ -18,7 +18,7 @@ test_api() {
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}')
|
-d '{"url":"https://vine.co/v/huwVJIEJW50", "isAudioOnly": true}')
|
||||||
|
|
||||||
echo "$API_RESPONSE"
|
echo "$API_RESPONSE"
|
||||||
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
||||||
|
|
22
.github/workflows/fast-forward.yml
vendored
Normal file
22
.github/workflows/fast-forward.yml
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
name: fast-forward
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created, edited]
|
||||||
|
jobs:
|
||||||
|
fast-forward:
|
||||||
|
# Only run if the comment contains the /fast-forward command.
|
||||||
|
if: ${{ contains(github.event.comment.body, '/fast-forward')
|
||||||
|
&& github.event.issue.pull_request }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Fast forwarding
|
||||||
|
uses: sequoia-pgp/fast-forward@v1
|
||||||
|
with:
|
||||||
|
merge: true
|
||||||
|
comment: 'on-error'
|
24
.github/workflows/test.yml
vendored
24
.github/workflows/test.yml
vendored
|
@ -36,3 +36,27 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Run test script
|
- name: Run test script
|
||||||
run: .github/test.sh api
|
run: .github/test.sh api
|
||||||
|
|
||||||
|
check-services:
|
||||||
|
name: test service functionality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
services: ${{ steps.checkServices.outputs.service_list }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- id: checkServices
|
||||||
|
run: npm ci && echo "service_list=$(node src/util/test-ci get-services)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
test-services:
|
||||||
|
needs: check-services
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service: ${{ fromJson(needs.check-services.outputs.services) }}
|
||||||
|
name: "test service: ${{ matrix.service }}"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: npm ci && node src/util/test-ci run-tests-for ${{ matrix.service }}
|
39
CONTRIBUTING.md
Normal file
39
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# contributing to cobalt
|
||||||
|
if you're reading this, you are probably interested in contributing to cobalt, which we are very thankful for :3
|
||||||
|
|
||||||
|
this document serves as a guide to help you make contributions that we can merge into the cobalt codebase.
|
||||||
|
|
||||||
|
## translations
|
||||||
|
currently, we are **not accepting** translations of cobalt. this is because we are making significant changes to the frontend, and the currently used localization structure is being completely reworked. if this changes, this document will be updated.
|
||||||
|
|
||||||
|
## adding features or support for services
|
||||||
|
before putting in the effort to implement a feature, it's worth considering whether it would be appropriate to add it to cobalt. the cobalt api is built to assist people **only with downloading freely accessible content**. other functionality, such as:
|
||||||
|
- downloading paid / not publicly accessible content
|
||||||
|
- downloading content protected by DRM
|
||||||
|
- scraping unrelated information & exposing it outside of file metadata
|
||||||
|
|
||||||
|
will not be reviewed or merged.
|
||||||
|
|
||||||
|
if you plan on adding a feature or support for a service, but are unsure whether it would be appropriate, it's best to open an issue and discuss it beforehand.
|
||||||
|
|
||||||
|
## git
|
||||||
|
when contributing code to cobalt, there are a few guidelines in place to ensure that the code history is readable and comprehensible.
|
||||||
|
|
||||||
|
### clean commit messages
|
||||||
|
internally, we use a format similar to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - the first part signifies which part of the code you are changing (the *scope*), and the second part explains the change. for inspiration on how to write appropriate commit titles, you can take a look at the [commit history](https://github.com/imputnet/cobalt/commits/).
|
||||||
|
|
||||||
|
the scope is not strictly defined, you can write whatever you find most fitting for the particular change. suppose you are changing a small part of a more significant part of the codebase. in that case, you can specify both the larger and smaller scopes in the commit message for clarity (e.g., if you were changing something in internal streams, the commit could be something like `stream/internal: fix object not being handled properly`).
|
||||||
|
|
||||||
|
if you think a change deserves further explanation, we encourage you to write a short explanation in the commit message ([example](https://github.com/imputnet/cobalt/commit/d2e5b6542f71f3809ba94d56c26f382b5cb62762)), which will save both you and us time having to enquire about the change, and you explaining the reason behind it.
|
||||||
|
|
||||||
|
if your contribution has uninformative commit titles, you may be asked to interactively rebase your branch and amend each commit to include a meaningful title.
|
||||||
|
|
||||||
|
### clean commit history
|
||||||
|
if your branch is out of date and/or has some merge conflicts with the `current` branch, you should **rebase** it instead of merging. this prevents meaningless merge commits from being included in your branch, which would then end up in the cobalt git history.
|
||||||
|
|
||||||
|
if you find a mistake or bug in your code before it's merged or reviewed, instead of making a brand new commit to fix it, it would be preferable to amend that specific commit where the mistake was first introduced. this also helps us easily revert a commit if we discover that it introduced a bug or some unwanted behavior.
|
||||||
|
- if the commit you are fixing is the latest one, you can add your files to staging and then use `git commit --amend` to apply the change.
|
||||||
|
- if the commit is somewhere deeper in your branch, you can use `git commit --fixup=HASH`, where *`HASH`* is the commit you are fixing.
|
||||||
|
- afterward, you must interactively rebase your branch with `git rebase -i current --autosquash`.
|
||||||
|
this will open up an editor, but you don't need to do anything else except save the file and exit.
|
||||||
|
- once you do either of these things, you will need to do a **force push** to your branch with `git push --force-with-lease`.
|
|
@ -19,11 +19,13 @@ this list is not final and keeps expanding over time. if support for a service y
|
||||||
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
|
| facebook videos | ✅ | ❌ | ❌ | ➖ | ➖ |
|
||||||
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
||||||
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
|
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||||
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| snapchat stories & spotlights | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
||||||
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| threads posts | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| threads posts | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
|
@ -46,8 +48,10 @@ this list is not final and keeps expanding over time. if support for a service y
|
||||||
| service | notes or features |
|
| service | notes or features |
|
||||||
| :-------- | :----- |
|
| :-------- | :----- |
|
||||||
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
||||||
|
| facebook | supports public accessible videos content only. |
|
||||||
| pinterest | supports photos, gifs, videos and stories. |
|
| pinterest | supports photos, gifs, videos and stories. |
|
||||||
| reddit | supports gifs and videos. |
|
| reddit | supports gifs and videos. |
|
||||||
|
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
|
||||||
| rutube | supports yappy & private links. |
|
| rutube | supports yappy & private links. |
|
||||||
| soundcloud | supports private links. |
|
| soundcloud | supports private links. |
|
||||||
| threads | supports photos and videos. lets you pick what to save from multi-media posts. |
|
| threads | supports photos and videos. lets you pick what to save from multi-media posts. |
|
||||||
|
|
|
@ -49,9 +49,9 @@ item type: `object`
|
||||||
|
|
||||||
| key | type | variables | description |
|
| key | type | variables | description |
|
||||||
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
|
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
|
||||||
| `type` | `string` | `video` | used only if `pickerType`is `various`. |
|
| `type` | `string` | `video / photo / gif` | used only if `pickerType` is `various` |
|
||||||
| `url` | `string` | direct link to a file or a link to cobalt's live render | |
|
| `url` | `string` | direct link to a file or a link to cobalt's live render | |
|
||||||
| `thumb` | `string` | item thumbnail that's displayed in the picker | used only for `video` type. |
|
| `thumb` | `string` | item thumbnail that's displayed in the picker | used for `video` and `gif` types |
|
||||||
|
|
||||||
## GET: `/api/stream`
|
## GET: `/api/stream`
|
||||||
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
|
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
|
||||||
|
@ -73,5 +73,5 @@ response body type: `application/json`
|
||||||
| `branch` | `string` | git branch |
|
| `branch` | `string` | git branch |
|
||||||
| `name` | `string` | server name |
|
| `name` | `string` | server name |
|
||||||
| `url` | `string` | server url |
|
| `url` | `string` | server url |
|
||||||
| `cors` | `int` | cors status |
|
| `cors` | `number` | cors status |
|
||||||
| `startTime` | `string` | server start time |
|
| `startTime` | `string` | server start time |
|
||||||
|
|
|
@ -56,8 +56,9 @@ sudo service nscd start
|
||||||
| `API_LISTEN_ADDRESS` | `0.0.0.0` | `127.0.0.1` | changes address from which api server is accessible. **if you are using docker, you usually don't need to configure this.** |
|
| `API_LISTEN_ADDRESS` | `0.0.0.0` | `127.0.0.1` | changes address from which api server is accessible. **if you are using docker, you usually don't need to configure this.** |
|
||||||
| `API_URL` | ➖ | `https://api.cobalt.tools/` | changes url from which api server is accessible. <br> ***REQUIRED TO RUN THE API***. |
|
| `API_URL` | ➖ | `https://api.cobalt.tools/` | changes url from which api server is accessible. <br> ***REQUIRED TO RUN THE API***. |
|
||||||
| `API_NAME` | `unknown` | `ams-1` | api server name that is shown in `/api/serverInfo`. |
|
| `API_NAME` | `unknown` | `ams-1` | api server name that is shown in `/api/serverInfo`. |
|
||||||
|
| `API_EXTERNAL_PROXY` | ➖ | `http://user:password@127.0.0.1:8080`| url of the proxy that will be passed to [`ProxyAgent`](https://undici.nodejs.org/#/docs/api/ProxyAgent) and used for all external requests. HTTP(S) only. |
|
||||||
| `CORS_WILDCARD` | `1` | `0` | toggles cross-origin resource sharing. <br> `0`: disabled. `1`: enabled. |
|
| `CORS_WILDCARD` | `1` | `0` | toggles cross-origin resource sharing. <br> `0`: disabled. `1`: enabled. |
|
||||||
| `CORS_URL` | not used | `https://cobalt.tools/` | cross-origin resource sharing url. api will be available only from this url if `CORS_WILDCARD` is set to `0`. |
|
| `CORS_URL` | not used | `https://cobalt.tools` | cross-origin resource sharing url. api will be available only from this url if `CORS_WILDCARD` is set to `0`. |
|
||||||
| `COOKIE_PATH` | not used | `/cookies.json` | path for cookie file relative to main folder. |
|
| `COOKIE_PATH` | not used | `/cookies.json` | path for cookie file relative to main folder. |
|
||||||
| `PROCESSING_PRIORITY` | not used | `10` | changes `nice` value* for ffmpeg subprocess. available only on unix systems. |
|
| `PROCESSING_PRIORITY` | not used | `10` | changes `nice` value* for ffmpeg subprocess. available only on unix systems. |
|
||||||
| `FREEBIND_CIDR` | ➖ | `2001:db8::/32` | IPv6 prefix used for randomly assigning addresses to cobalt requests. only supported on linux systems. see below for more info. |
|
| `FREEBIND_CIDR` | ➖ | `2001:db8::/32` | IPv6 prefix used for randomly assigning addresses to cobalt requests. only supported on linux systems. see below for more info. |
|
||||||
|
|
27
package-lock.json
generated
27
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "cobalt",
|
"name": "cobalt",
|
||||||
"version": "7.14.4",
|
"version": "7.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cobalt",
|
"name": "cobalt",
|
||||||
"version": "7.14.4",
|
"version": "7.15",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-disposition-header": "0.6.0",
|
"content-disposition-header": "0.6.0",
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
"set-cookie-parser": "2.6.0",
|
"set-cookie-parser": "2.6.0",
|
||||||
"undici": "^5.19.1",
|
"undici": "^5.19.1",
|
||||||
"url-pattern": "1.0.3",
|
"url-pattern": "1.0.3",
|
||||||
"youtubei.js": "^9.3.0"
|
"youtubei.js": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
@ -73,9 +73,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.11.3",
|
"version": "8.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
||||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
@ -683,9 +683,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jintr": {
|
"node_modules/jintr": {
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jintr/-/jintr-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg==",
|
"integrity": "sha512-RiVlevxttZ4eHEYB2dXKXDXluzHfRuw0DJQGsYuKCc5IvZj5/GbOakeqVX+Bar/G9kTty9xDJREcxukurkmYLA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/LuanRT"
|
"https://github.com/sponsors/LuanRT"
|
||||||
],
|
],
|
||||||
|
@ -1123,14 +1123,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/youtubei.js": {
|
"node_modules/youtubei.js": {
|
||||||
"version": "9.4.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-10.2.0.tgz",
|
||||||
"integrity": "sha512-8plCOZD2WabqWSEgZU3RjzigIIeR7sF028EERJENYrC9xO/6awpLMZfeoE1gNrNEbKcA+bzbMvonqlvBdxGdKg==",
|
"integrity": "sha512-JLKW9AHQ1qrTwBbre1aDkH8UJFmNcc4+kOSaVou5jSY7AzfFPFJK0yvX6afnLst0UVC9wfXHrLiNx93sutVErA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/LuanRT"
|
"https://github.com/sponsors/LuanRT"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jintr": "^1.1.0",
|
"jintr": "^2.0.0",
|
||||||
"tslib": "^2.5.0",
|
"tslib": "^2.5.0",
|
||||||
"undici": "^5.19.1"
|
"undici": "^5.19.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "cobalt",
|
"name": "cobalt",
|
||||||
"description": "save what you love",
|
"description": "save what you love",
|
||||||
"version": "7.14.4",
|
"version": "7.15",
|
||||||
"author": "imput",
|
"author": "imput",
|
||||||
"exports": "./src/cobalt.js",
|
"exports": "./src/cobalt.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
@ -40,7 +40,7 @@
|
||||||
"set-cookie-parser": "2.6.0",
|
"set-cookie-parser": "2.6.0",
|
||||||
"undici": "^5.19.1",
|
"undici": "^5.19.1",
|
||||||
"url-pattern": "1.0.3",
|
"url-pattern": "1.0.3",
|
||||||
"youtubei.js": "^9.3.0"
|
"youtubei.js": "^10.2.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"freebind": "^0.2.2"
|
"freebind": "^0.2.2"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
||||||
|
|
||||||
import { env, version } from "../modules/config.js";
|
import { env, version } from "../modules/config.js";
|
||||||
|
|
||||||
|
@ -26,7 +27,7 @@ const corsConfig = env.corsWildcard ? {} : {
|
||||||
export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
const startTimestamp = startTime.getTime();
|
const startTimestamp = startTime.getTime();
|
||||||
|
|
||||||
const serverInfo = {
|
const serverInfo = {
|
||||||
version: version,
|
version: version,
|
||||||
commit: gitCommit,
|
commit: gitCommit,
|
||||||
|
@ -81,38 +82,23 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
try {
|
try {
|
||||||
decodeURIComponent(req.path)
|
decodeURIComponent(req.path)
|
||||||
} catch {
|
} catch {
|
||||||
return res.redirect('/')
|
return res.redirect('/')
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/json', express.json({
|
app.use('/api/json', express.json({ limit: 1024 }));
|
||||||
verify: (req, res, buf) => {
|
app.use('/api/json', (err, _, res, next) => {
|
||||||
if (String(req.header('Accept')) === "application/json") {
|
if (err) {
|
||||||
if (buf.length > 720) throw new Error();
|
|
||||||
JSON.parse(buf);
|
|
||||||
} else {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// handle express.json errors properly (https://github.com/expressjs/express/issues/4065)
|
|
||||||
app.use('/api/json', (err, req, res, next) => {
|
|
||||||
let errorText = "invalid json body";
|
|
||||||
const acceptHeader = String(req.header('Accept')) !== "application/json";
|
|
||||||
|
|
||||||
if (err || acceptHeader) {
|
|
||||||
if (acceptHeader) errorText = "invalid accept header";
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
text: errorText
|
text: "invalid json body"
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/json', async (req, res) => {
|
app.post('/api/json', async (req, res) => {
|
||||||
const request = req.body;
|
const request = req.body;
|
||||||
|
@ -123,6 +109,10 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
res.status(status).json(body);
|
res.status(status).json(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!acceptRegex.test(req.header('Accept'))) {
|
||||||
|
return fail('ErrorInvalidAcceptHeader');
|
||||||
|
}
|
||||||
|
|
||||||
if (!acceptRegex.test(req.header('Content-Type'))) {
|
if (!acceptRegex.test(req.header('Content-Type'))) {
|
||||||
return fail('ErrorInvalidContentType');
|
return fail('ErrorInvalidContentType');
|
||||||
}
|
}
|
||||||
|
@ -196,10 +186,10 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
streamInfo.headers = {
|
streamInfo.headers = new Map([
|
||||||
...streamInfo.headers,
|
...(streamInfo.headers || []),
|
||||||
...req.headers
|
...Object.entries(req.headers)
|
||||||
};
|
]);
|
||||||
|
|
||||||
return stream(res, { type: 'internal', ...streamInfo });
|
return stream(res, { type: 'internal', ...streamInfo });
|
||||||
})
|
})
|
||||||
|
@ -219,6 +209,14 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
randomizeCiphers();
|
randomizeCiphers();
|
||||||
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
|
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
|
||||||
|
|
||||||
|
if (env.externalProxy) {
|
||||||
|
if (env.freebindCIDR) {
|
||||||
|
throw new Error('Freebind is not available when external proxy is enabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(env.apiPort, env.listenAddress, () => {
|
app.listen(env.apiPort, env.listenAddress, () => {
|
||||||
console.log(`\n` +
|
console.log(`\n` +
|
||||||
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
||||||
|
|
|
@ -26,45 +26,46 @@ export async function runWeb(express, app, gitCommit, gitBranch, __dirname) {
|
||||||
|
|
||||||
app.get('/onDemand', (req, res) => {
|
app.get('/onDemand', (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.query.blockId) {
|
if (typeof req.query.blockId !== 'string') {
|
||||||
let blockId = req.query.blockId.slice(0, 3);
|
|
||||||
let blockData;
|
|
||||||
switch(blockId) {
|
|
||||||
// changelog history
|
|
||||||
case "0":
|
|
||||||
let history = changelogHistory();
|
|
||||||
if (history) {
|
|
||||||
blockData = createResponse("success", { t: history })
|
|
||||||
} else {
|
|
||||||
blockData = createResponse("error", {
|
|
||||||
t: "couldn't render this block, please try again!"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
// celebrations emoji
|
|
||||||
case "1":
|
|
||||||
let celebration = celebrationsEmoji();
|
|
||||||
if (celebration) {
|
|
||||||
blockData = createResponse("success", { t: celebration })
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
blockData = createResponse("error", {
|
|
||||||
t: "couldn't find a block with this id"
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (blockData?.body) {
|
|
||||||
return res.status(blockData.status).json(blockData.body);
|
|
||||||
} else {
|
|
||||||
return res.status(204).end();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
text: "couldn't render this block, please try again!"
|
text: "couldn't render this block, please try again!"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockId = req.query.blockId.slice(0, 3);
|
||||||
|
let blockData;
|
||||||
|
switch(blockId) {
|
||||||
|
// changelog history
|
||||||
|
case "0":
|
||||||
|
let history = changelogHistory();
|
||||||
|
if (history) {
|
||||||
|
blockData = createResponse("success", { t: history })
|
||||||
|
} else {
|
||||||
|
blockData = createResponse("error", {
|
||||||
|
t: "couldn't render this block, please try again!"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// celebrations emoji
|
||||||
|
case "1":
|
||||||
|
let celebration = celebrationsEmoji();
|
||||||
|
if (celebration) {
|
||||||
|
blockData = createResponse("success", { t: celebration })
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
blockData = createResponse("error", {
|
||||||
|
t: "couldn't find a block with this id"
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockData?.body) {
|
||||||
|
return res.status(blockData.status).json(blockData.body);
|
||||||
|
} else {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|
|
@ -159,6 +159,7 @@
|
||||||
"UpdateOneMillion": "1 million users and blazing speed",
|
"UpdateOneMillion": "1 million users and blazing speed",
|
||||||
"ErrorYTAgeRestrict": "this youtube video is age-restricted, so i can't see it. try another one!",
|
"ErrorYTAgeRestrict": "this youtube video is age-restricted, so i can't see it. try another one!",
|
||||||
"ErrorYTLogin": "couldn't get this youtube video because it requires an account to view.\n\nthis limitation is done by google to seemingly stop scraping, affecting all 3rd party tools and even their own clients.\n\ntry again, but if issue persists, {ContactLink}.",
|
"ErrorYTLogin": "couldn't get this youtube video because it requires an account to view.\n\nthis limitation is done by google to seemingly stop scraping, affecting all 3rd party tools and even their own clients.\n\ntry again, but if issue persists, {ContactLink}.",
|
||||||
"ErrorYTRateLimit": "i got rate limited by youtube. try again in a few seconds, but if issue persists, {ContactLink}."
|
"ErrorYTRateLimit": "i got rate limited by youtube. try again in a few seconds, but if issue persists, {ContactLink}.",
|
||||||
|
"ErrorInvalidAcceptHeader": "invalid accept header"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,9 @@ const
|
||||||
|
|
||||||
processingPriority: process.platform !== 'win32'
|
processingPriority: process.platform !== 'win32'
|
||||||
&& process.env.PROCESSING_PRIORITY
|
&& process.env.PROCESSING_PRIORITY
|
||||||
&& parseInt(process.env.PROCESSING_PRIORITY)
|
&& parseInt(process.env.PROCESSING_PRIORITY),
|
||||||
|
|
||||||
|
externalProxy: process.env.API_EXTERNAL_PROXY,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
|
|
@ -24,8 +24,10 @@ import streamable from "./services/streamable.js";
|
||||||
import twitch from "./services/twitch.js";
|
import twitch from "./services/twitch.js";
|
||||||
import rutube from "./services/rutube.js";
|
import rutube from "./services/rutube.js";
|
||||||
import dailymotion from "./services/dailymotion.js";
|
import dailymotion from "./services/dailymotion.js";
|
||||||
|
import snapchat from "./services/snapchat.js";
|
||||||
import loom from "./services/loom.js";
|
import loom from "./services/loom.js";
|
||||||
import threads from "./services/threads.js";
|
import threads from "./services/threads.js";
|
||||||
|
import facebook from "./services/facebook.js";
|
||||||
|
|
||||||
let freebind;
|
let freebind;
|
||||||
|
|
||||||
|
@ -189,6 +191,12 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
case "dailymotion":
|
case "dailymotion":
|
||||||
r = await dailymotion(patternMatch);
|
r = await dailymotion(patternMatch);
|
||||||
break;
|
break;
|
||||||
|
case "snapchat":
|
||||||
|
r = await snapchat({
|
||||||
|
hostname: url.hostname,
|
||||||
|
...patternMatch
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "loom":
|
case "loom":
|
||||||
r = await loom({
|
r = await loom({
|
||||||
id: patternMatch.id
|
id: patternMatch.id
|
||||||
|
@ -199,7 +207,12 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
...patternMatch,
|
...patternMatch,
|
||||||
quality: obj.vQuality,
|
quality: obj.vQuality,
|
||||||
dispatcher
|
dispatcher
|
||||||
})
|
});
|
||||||
|
break;
|
||||||
|
case "facebook":
|
||||||
|
r = await facebook({
|
||||||
|
...patternMatch
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
else if (r.isGif && toGif) action = "gif";
|
else if (r.isGif && toGif) action = "gif";
|
||||||
else if (isAudioMuted) action = "muteVideo";
|
else if (isAudioMuted) action = "muteVideo";
|
||||||
else if (isAudioOnly) action = "audio";
|
else if (isAudioOnly) action = "audio";
|
||||||
else if (r.isM3U8) action = "singleM3U8";
|
else if (r.isM3U8) action = "m3u8";
|
||||||
else action = "video";
|
else action = "video";
|
||||||
|
|
||||||
if (action === "picker" || action === "audio") {
|
if (action === "picker" || action === "audio") {
|
||||||
|
@ -48,13 +48,19 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
params = { type: "gif" }
|
params = { type: "gif" }
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "singleM3U8":
|
case "m3u8":
|
||||||
params = { type: "remux" }
|
params = {
|
||||||
|
type: Array.isArray(r.urls) ? "render" : "remux"
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "muteVideo":
|
case "muteVideo":
|
||||||
|
let muteType = "mute";
|
||||||
|
if (Array.isArray(r.urls) && !r.isM3U8) {
|
||||||
|
muteType = "bridge";
|
||||||
|
}
|
||||||
params = {
|
params = {
|
||||||
type: Array.isArray(r.urls) ? "bridge" : "mute",
|
type: muteType,
|
||||||
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
|
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
|
||||||
mute: true
|
mute: true
|
||||||
}
|
}
|
||||||
|
@ -68,6 +74,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
case "instagram":
|
case "instagram":
|
||||||
case "twitter":
|
case "twitter":
|
||||||
case "threads":
|
case "threads":
|
||||||
|
case "snapchat":
|
||||||
params = { picker: r.picker };
|
params = { picker: r.picker };
|
||||||
break;
|
break;
|
||||||
case "tiktok":
|
case "tiktok":
|
||||||
|
@ -125,11 +132,13 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
params = { type: "bridge" };
|
params = { type: "bridge" };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "facebook":
|
||||||
case "vine":
|
case "vine":
|
||||||
case "instagram":
|
case "instagram":
|
||||||
case "tumblr":
|
case "tumblr":
|
||||||
case "pinterest":
|
case "pinterest":
|
||||||
case "streamable":
|
case "streamable":
|
||||||
|
case "snapchat":
|
||||||
case "loom":
|
case "loom":
|
||||||
case "threads":
|
case "threads":
|
||||||
responseType = "redirect";
|
responseType = "redirect";
|
||||||
|
|
56
src/modules/processing/services/facebook.js
Normal file
56
src/modules/processing/services/facebook.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': genericUserAgent,
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
|
'Sec-Fetch-Site': 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveUrl = (url) => {
|
||||||
|
return fetch(url, { headers })
|
||||||
|
.then(r => {
|
||||||
|
if (r.headers.get('location')) {
|
||||||
|
return decodeURIComponent(r.headers.get('location'));
|
||||||
|
}
|
||||||
|
if (r.headers.get('link')) {
|
||||||
|
const linkMatch = r.headers.get('link').match(/<(.*?)\/>/);
|
||||||
|
return decodeURIComponent(linkMatch[1]);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function({ id, shareType, shortLink }) {
|
||||||
|
let url = `https://web.facebook.com/i/videos/${id}`;
|
||||||
|
|
||||||
|
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
||||||
|
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`);
|
||||||
|
|
||||||
|
const html = await fetch(url, { headers })
|
||||||
|
.then(r => r.text())
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!html) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
const urls = [];
|
||||||
|
const hd = html.match('"browser_native_hd_url":(".*?")');
|
||||||
|
const sd = html.match('"browser_native_sd_url":(".*?")');
|
||||||
|
|
||||||
|
if (hd?.[1]) urls.push(JSON.parse(hd[1]));
|
||||||
|
if (sd?.[1]) urls.push(JSON.parse(sd[1]));
|
||||||
|
|
||||||
|
if (!urls.length) {
|
||||||
|
return { error: 'ErrorEmptyDownload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFilename = `facebook_${id || shortLink}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: urls[0],
|
||||||
|
filename: `${baseFilename}.mp4`,
|
||||||
|
audioFilename: `${baseFilename}_audio`,
|
||||||
|
};
|
||||||
|
}
|
|
@ -256,11 +256,11 @@ export default function(obj) {
|
||||||
if (!media_id && cookie) media_id = await getMediaId(id, { cookie });
|
if (!media_id && cookie) media_id = await getMediaId(id, { cookie });
|
||||||
|
|
||||||
// mobile api (bearer)
|
// mobile api (bearer)
|
||||||
if (media_id && token) data = await requestMobileApi(id, { token });
|
if (media_id && token) data = await requestMobileApi(media_id, { token });
|
||||||
|
|
||||||
// mobile api (no cookie, cookie)
|
// mobile api (no cookie, cookie)
|
||||||
if (!data && media_id) data = await requestMobileApi(id);
|
if (media_id && !data) data = await requestMobileApi(media_id);
|
||||||
if (!data && media_id && cookie) data = await requestMobileApi(id, { cookie });
|
if (media_id && cookie && !data) data = await requestMobileApi(media_id, { cookie });
|
||||||
|
|
||||||
// html embed (no cookie, cookie)
|
// html embed (no cookie, cookie)
|
||||||
if (!data) data = await requestHTML(id);
|
if (!data) data = await requestHTML(id);
|
||||||
|
|
|
@ -20,14 +20,15 @@ export default async function(o) {
|
||||||
}).then(r => r.text()).catch(() => {});
|
}).then(r => r.text()).catch(() => {});
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html) return { error: 'ErrorCouldntFetch' };
|
||||||
if (!html.includes(`<div data-module="OKVideo" data-options="{`)) {
|
|
||||||
|
let videoData = html.match(/<div data-module="OKVideo" .*? data-options="({.*?})"( .*?)>/)
|
||||||
|
?.[1]
|
||||||
|
?.replaceAll(""", '"');
|
||||||
|
|
||||||
|
if (!videoData) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: 'ErrorEmptyDownload' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoData = html.split(`<div data-module="OKVideo" data-options="`)[1]
|
|
||||||
.split('" data-')[0]
|
|
||||||
.replaceAll(""", '"');
|
|
||||||
|
|
||||||
videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
|
videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
|
||||||
|
|
||||||
if (videoData.provider !== "UPLOADED_ODKL")
|
if (videoData.provider !== "UPLOADED_ODKL")
|
||||||
|
@ -44,7 +45,7 @@ export default async function(o) {
|
||||||
|
|
||||||
let fileMetadata = {
|
let fileMetadata = {
|
||||||
title: cleanString(videoData.movie.title.trim()),
|
title: cleanString(videoData.movie.title.trim()),
|
||||||
author: cleanString(videoData.author.name.trim()),
|
author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestVideo) return {
|
if (bestVideo) return {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { genericUserAgent } from "../../config.js";
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
|
||||||
const videoRegex = /"url":"(https:\/\/v1.pinimg.com\/videos\/.*?)"/g;
|
const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
|
||||||
const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
|
const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
|
||||||
|
|
||||||
export default async function(o) {
|
export default async function(o) {
|
||||||
|
|
|
@ -10,6 +10,8 @@ async function requestJSON(url) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delta = (a, b) => Math.abs(a - b);
|
||||||
|
|
||||||
export default async function(obj) {
|
export default async function(obj) {
|
||||||
if (obj.yappyId) {
|
if (obj.yappyId) {
|
||||||
const yappy = await requestJSON(
|
const yappy = await requestJSON(
|
||||||
|
@ -25,7 +27,7 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const quality = obj.quality === "max" ? "9000" : obj.quality;
|
const quality = Number(obj.quality) || 9000;
|
||||||
|
|
||||||
const requestURL = new URL(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`);
|
const requestURL = new URL(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`);
|
||||||
if (obj.key) requestURL.searchParams.set('p', obj.key);
|
if (obj.key) requestURL.searchParams.set('p', obj.key);
|
||||||
|
@ -45,12 +47,16 @@ export default async function(obj) {
|
||||||
|
|
||||||
if (!m3u8) return { error: 'ErrorCouldntFetch' };
|
if (!m3u8) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
m3u8 = HLS.parse(m3u8).variants.sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
|
m3u8 = HLS.parse(m3u8).variants;
|
||||||
|
|
||||||
let bestQuality = m3u8[0];
|
const matchingQuality = m3u8.reduce((prev, next) => {
|
||||||
if (Number(quality) < bestQuality.resolution.height) {
|
const diff = {
|
||||||
bestQuality = m3u8.find((i) => (Number(quality) === i.resolution.height));
|
prev: delta(quality, prev.resolution.height),
|
||||||
}
|
next: delta(quality, next.resolution.height)
|
||||||
|
};
|
||||||
|
|
||||||
|
return diff.prev < diff.next ? prev : next;
|
||||||
|
});
|
||||||
|
|
||||||
const fileMetadata = {
|
const fileMetadata = {
|
||||||
title: cleanString(play.title.trim()),
|
title: cleanString(play.title.trim()),
|
||||||
|
@ -58,15 +64,15 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
urls: bestQuality.uri,
|
urls: matchingQuality.uri,
|
||||||
isM3U8: true,
|
isM3U8: true,
|
||||||
filenameAttributes: {
|
filenameAttributes: {
|
||||||
service: "rutube",
|
service: "rutube",
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
title: fileMetadata.title,
|
title: fileMetadata.title,
|
||||||
author: fileMetadata.artist,
|
author: fileMetadata.artist,
|
||||||
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
resolution: `${matchingQuality.resolution.width}x${matchingQuality.resolution.height}`,
|
||||||
qualityLabel: `${bestQuality.resolution.height}p`,
|
qualityLabel: `${matchingQuality.resolution.height}p`,
|
||||||
extension: "mp4"
|
extension: "mp4"
|
||||||
},
|
},
|
||||||
fileMetadata: fileMetadata
|
fileMetadata: fileMetadata
|
||||||
|
|
96
src/modules/processing/services/snapchat.js
Normal file
96
src/modules/processing/services/snapchat.js
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
import { getRedirectingURL } from "../../sub/utils.js";
|
||||||
|
import { extract, normalizeURL } from "../url.js";
|
||||||
|
|
||||||
|
const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="(https:\/\/cf-st\.sc-cdn\.net\/d\/[\w.?=]+&uc=\d+)" as="video"\/>/;
|
||||||
|
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
||||||
|
|
||||||
|
async function getSpotlight(id) {
|
||||||
|
const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
|
||||||
|
headers: { 'User-Agent': genericUserAgent }
|
||||||
|
}).then((r) => r.text()).catch(() => null);
|
||||||
|
if (!html) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
|
||||||
|
if (videoURL) {
|
||||||
|
return {
|
||||||
|
urls: videoURL,
|
||||||
|
filename: `snapchat_${id}.mp4`,
|
||||||
|
audioFilename: `snapchat_${id}_audio`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStory(username, storyId) {
|
||||||
|
const html = await fetch(`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`, {
|
||||||
|
headers: { 'User-Agent': genericUserAgent }
|
||||||
|
}).then((r) => r.text()).catch(() => null);
|
||||||
|
if (!html) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
||||||
|
if (nextDataString) {
|
||||||
|
const data = JSON.parse(nextDataString);
|
||||||
|
const storyIdParam = data.query.profileParams[1];
|
||||||
|
|
||||||
|
if (storyIdParam && data.props.pageProps.story) {
|
||||||
|
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
|
||||||
|
if (story) {
|
||||||
|
if (story.snapMediaType === 0) {
|
||||||
|
return {
|
||||||
|
urls: story.snapUrls.mediaUrl,
|
||||||
|
isPhoto: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: story.snapUrls.mediaUrl,
|
||||||
|
filename: `snapchat_${storyId}.mp4`,
|
||||||
|
audioFilename: `snapchat_${storyId}_audio`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStory = data.props.pageProps.curatedHighlights[0];
|
||||||
|
if (defaultStory) {
|
||||||
|
return {
|
||||||
|
picker: defaultStory.snapList.map((snap) => ({
|
||||||
|
type: snap.snapMediaType === 0 ? 'photo' : 'video',
|
||||||
|
url: snap.snapUrls.mediaUrl,
|
||||||
|
thumb: snap.snapUrls.mediaPreviewUrl.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function (obj) {
|
||||||
|
let params = obj;
|
||||||
|
if (obj.hostname === 't.snapchat.com' && obj.shortLink) {
|
||||||
|
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
|
||||||
|
|
||||||
|
if (!link?.startsWith('https://www.snapchat.com/')) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractResult = extract(normalizeURL(link));
|
||||||
|
if (extractResult?.host !== 'snapchat') {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
params = extractResult.patternMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.spotlightId) {
|
||||||
|
const result = await getSpotlight(params.spotlightId);
|
||||||
|
if (result) return result;
|
||||||
|
} else if (params.username) {
|
||||||
|
const result = await getStory(params.username, params.storyId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
|
@ -12,17 +12,19 @@ async function findClientID() {
|
||||||
let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
|
let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
|
||||||
|
|
||||||
if (cachedID.version === scVersion) return cachedID.id;
|
if (cachedID.version === scVersion) return cachedID.id;
|
||||||
|
|
||||||
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
|
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
|
||||||
let clientid;
|
let clientid;
|
||||||
for (let script of scripts) {
|
for (let script of scripts) {
|
||||||
let url = script[1];
|
let url = script[1];
|
||||||
|
|
||||||
if (url && !url.startsWith('https://a-v2.sndcdn.com')) return;
|
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let scrf = await fetch(url).then(r => r.text()).catch(() => {});
|
let scrf = await fetch(url).then(r => r.text()).catch(() => {});
|
||||||
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
|
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
|
||||||
|
|
||||||
if (id && typeof id[0] === 'string') {
|
if (id && typeof id[0] === 'string') {
|
||||||
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
|
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -147,7 +147,6 @@ export default async function({ id, index, toGif, dispatcher }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
||||||
media = media?.filter(m => m.video_info?.variants?.length);
|
|
||||||
|
|
||||||
// check if there's a video at given index (/video/<index>)
|
// check if there's a video at given index (/video/<index>)
|
||||||
if (index >= 0 && index < media?.length) {
|
if (index >= 0 && index < media?.length) {
|
||||||
|
@ -159,18 +158,38 @@ export default async function({ id, index, toGif, dispatcher }) {
|
||||||
case 0:
|
case 0:
|
||||||
return { error: 'ErrorNoVideosInTweet' };
|
return { error: 'ErrorNoVideosInTweet' };
|
||||||
case 1:
|
case 1:
|
||||||
|
if (media[0].type === "photo") {
|
||||||
|
return {
|
||||||
|
type: "normal",
|
||||||
|
isPhoto: true,
|
||||||
|
urls: `${media[0].media_url_https}?name=4096x4096`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: needsFixing(media[0]) ? "remux" : "normal",
|
type: needsFixing(media[0]) ? "remux" : "normal",
|
||||||
urls: bestQuality(media[0].video_info.variants),
|
urls: bestQuality(media[0].video_info.variants),
|
||||||
filename: `twitter_${id}.mp4`,
|
filename: `twitter_${id}.mp4`,
|
||||||
audioFilename: `twitter_${id}_audio`,
|
audioFilename: `twitter_${id}_audio`,
|
||||||
isGif: media[0].type === "animated_gif"
|
isGif: media[0].type === "animated_gif"
|
||||||
};
|
}
|
||||||
default:
|
default:
|
||||||
const picker = media.map((content, i) => {
|
const picker = media.map((content, i) => {
|
||||||
|
if (content.type === "photo") {
|
||||||
|
let url = `${content.media_url_https}?name=4096x4096`;
|
||||||
|
return {
|
||||||
|
type: "photo",
|
||||||
|
url,
|
||||||
|
thumb: url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let url = bestQuality(content.video_info.variants);
|
let url = bestQuality(content.video_info.variants);
|
||||||
const shouldRenderGif = content.type === 'animated_gif' && toGif;
|
const shouldRenderGif = content.type === 'animated_gif' && toGif;
|
||||||
|
|
||||||
|
let type = "video";
|
||||||
|
if (shouldRenderGif) type = "gif";
|
||||||
|
|
||||||
if (needsFixing(content) || shouldRenderGif) {
|
if (needsFixing(content) || shouldRenderGif) {
|
||||||
url = createStream({
|
url = createStream({
|
||||||
service: 'twitter',
|
service: 'twitter',
|
||||||
|
@ -181,9 +200,9 @@ export default async function({ id, index, toGif, dispatcher }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'video',
|
type,
|
||||||
url,
|
url,
|
||||||
thumb: content.media_url_https,
|
thumb: content.media_url_https
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { picker };
|
return { picker };
|
||||||
|
|
|
@ -1,109 +1,169 @@
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { cleanString } from '../../sub/utils.js';
|
import { cleanString, merge } from '../../sub/utils.js';
|
||||||
|
|
||||||
|
import HLS from "hls-parser";
|
||||||
|
|
||||||
const resolutionMatch = {
|
const resolutionMatch = {
|
||||||
"3840": "2160",
|
"3840": 2160,
|
||||||
"2732": "1440",
|
"2732": 1440,
|
||||||
"2560": "1440",
|
"2560": 1440,
|
||||||
"2048": "1080",
|
"2048": 1080,
|
||||||
"1920": "1080",
|
"1920": 1080,
|
||||||
"1366": "720",
|
"1366": 720,
|
||||||
"1280": "720",
|
"1280": 720,
|
||||||
"960": "480",
|
"960": 480,
|
||||||
"640": "360",
|
"640": 360,
|
||||||
"426": "240"
|
"426": 240
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityMatch = {
|
const requestApiInfo = (videoId, password) => {
|
||||||
"2160": "4K",
|
if (password) {
|
||||||
"1440": "2K",
|
videoId += `:${password}`
|
||||||
"480": "540",
|
}
|
||||||
|
|
||||||
"4K": "2160",
|
return fetch(
|
||||||
"2K": "1440",
|
`https://api.vimeo.com/videos/${videoId}`,
|
||||||
"540": "480"
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.vimeo.*+json; version=3.4.2',
|
||||||
|
'User-Agent': 'Vimeo/10.19.0 (com.vimeo; build:101900.57.0; iOS 17.5.1) Alamofire/5.9.0 VimeoNetworking/5.0.0',
|
||||||
|
Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==',
|
||||||
|
'Accept-Language': 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(a => a.json())
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function(obj) {
|
const compareQuality = (rendition, requestedQuality) => {
|
||||||
let quality = obj.quality === "max" ? "9000" : obj.quality;
|
const quality = parseInt(rendition);
|
||||||
if (!quality || obj.isAudioOnly) quality = "9000";
|
return Math.abs(quality - requestedQuality);
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(`https://player.vimeo.com/video/${obj.id}/config`);
|
const getDirectLink = (data, quality) => {
|
||||||
if (obj.password) {
|
if (!data.files) return;
|
||||||
url.searchParams.set('h', obj.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
let api = await fetch(url)
|
const match = data.files
|
||||||
.then(r => r.json())
|
.filter(f => f.rendition?.endsWith('p'))
|
||||||
.catch(() => {});
|
.reduce((prev, next) => {
|
||||||
if (!api) return { error: 'ErrorCouldntFetch' };
|
const delta = {
|
||||||
|
prev: compareQuality(prev.rendition, quality),
|
||||||
|
next: compareQuality(next.rendition, quality)
|
||||||
|
};
|
||||||
|
|
||||||
let downloadType = "dash";
|
return delta.prev < delta.next ? prev : next;
|
||||||
|
});
|
||||||
|
|
||||||
if (!obj.isAudioOnly && JSON.stringify(api).includes('"progressive":[{'))
|
if (!match) return;
|
||||||
downloadType = "progressive";
|
|
||||||
|
|
||||||
let fileMetadata = {
|
|
||||||
title: cleanString(api.video.title.trim()),
|
|
||||||
artist: cleanString(api.video.owner.name.trim()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadType !== "dash") {
|
|
||||||
if (qualityMatch[quality]) quality = qualityMatch[quality];
|
|
||||||
let all = api.request.files.progressive.sort((a, b) => Number(b.width) - Number(a.width));
|
|
||||||
let best = all[0];
|
|
||||||
|
|
||||||
let bestQuality = all[0].quality.split('p')[0];
|
|
||||||
if (qualityMatch[bestQuality]) {
|
|
||||||
bestQuality = qualityMatch[bestQuality]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(quality) < Number(bestQuality)) {
|
|
||||||
best = all.find(i => i.quality.split('p')[0] === quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!best) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: best.url,
|
|
||||||
audioFilename: `vimeo_${obj.id}_audio`,
|
|
||||||
filename: `vimeo_${obj.id}_${best.width}x${best.height}.mp4`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (api.video.duration > env.durationLimit)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let masterJSONURL = api.request.files.dash.cdns.akfire_interconnect_quic.url;
|
|
||||||
let masterJSON = await fetch(masterJSONURL).then(r => r.json()).catch(() => {});
|
|
||||||
|
|
||||||
if (!masterJSON) return { error: 'ErrorCouldntFetch' };
|
|
||||||
if (!masterJSON.video) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
let masterJSON_Video = masterJSON.video
|
|
||||||
.sort((a, b) => Number(b.width) - Number(a.width))
|
|
||||||
.filter(a => ["dash", "mp42"].includes(a.format));
|
|
||||||
|
|
||||||
let bestVideo = masterJSON_Video[0];
|
|
||||||
if (Number(quality) < Number(resolutionMatch[bestVideo.width])) {
|
|
||||||
bestVideo = masterJSON_Video.find(i => resolutionMatch[i.width] === quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
let masterM3U8 = `${masterJSONURL.split("/sep/")[0]}/sep/video/${bestVideo.id}/master.m3u8`;
|
|
||||||
const fallbackResolution = bestVideo.height > bestVideo.width ? bestVideo.width : bestVideo.height;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
urls: masterM3U8,
|
urls: match.link,
|
||||||
isM3U8: true,
|
|
||||||
fileMetadata: fileMetadata,
|
|
||||||
filenameAttributes: {
|
filenameAttributes: {
|
||||||
service: "vimeo",
|
resolution: `${match.width}x${match.height}`,
|
||||||
id: obj.id,
|
qualityLabel: match.rendition,
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist,
|
|
||||||
resolution: `${bestVideo.width}x${bestVideo.height}`,
|
|
||||||
qualityLabel: `${resolutionMatch[bestVideo.width] || fallbackResolution}p`,
|
|
||||||
extension: "mp4"
|
extension: "mp4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getHLS = async (configURL, obj) => {
|
||||||
|
if (!configURL) return;
|
||||||
|
|
||||||
|
const api = await fetch(configURL)
|
||||||
|
.then(r => r.json())
|
||||||
|
.catch(() => {});
|
||||||
|
if (!api) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
if (api.video?.duration > env.durationLimit) {
|
||||||
|
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;
|
||||||
|
if (!urlMasterHLS) return { error: 'ErrorCouldntFetch' }
|
||||||
|
|
||||||
|
const masterHLS = await fetch(urlMasterHLS)
|
||||||
|
.then(r => r.text())
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (!masterHLS) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
const variants = HLS.parse(masterHLS)?.variants?.sort(
|
||||||
|
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
|
||||||
|
);
|
||||||
|
if (!variants || variants.length === 0) return { error: 'ErrorEmptyDownload' };
|
||||||
|
|
||||||
|
let bestQuality;
|
||||||
|
|
||||||
|
if (obj.quality < resolutionMatch[variants[0]?.resolution?.width]) {
|
||||||
|
bestQuality = variants.find(v =>
|
||||||
|
(obj.quality === resolutionMatch[v.resolution.width])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestQuality) bestQuality = variants[0];
|
||||||
|
|
||||||
|
const expandLink = (path) => {
|
||||||
|
return new URL(path, urlMasterHLS).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
let urls = expandLink(bestQuality.uri);
|
||||||
|
|
||||||
|
const audioPath = bestQuality?.audio[0]?.uri;
|
||||||
|
if (audioPath) {
|
||||||
|
urls = [
|
||||||
|
urls,
|
||||||
|
expandLink(audioPath)
|
||||||
|
]
|
||||||
|
} else if (obj.isAudioOnly) {
|
||||||
|
return { error: 'ErrorEmptyDownload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls,
|
||||||
|
isM3U8: true,
|
||||||
|
filenameAttributes: {
|
||||||
|
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
||||||
|
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
|
||||||
|
extension: "mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function(obj) {
|
||||||
|
let quality = obj.quality === "max" ? 9000 : Number(obj.quality);
|
||||||
|
if (quality < 240) quality = 240;
|
||||||
|
if (!quality || obj.isAudioOnly) quality = 9000;
|
||||||
|
|
||||||
|
const info = await requestApiInfo(obj.id, obj.password);
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (obj.isAudioOnly) {
|
||||||
|
response = await getHLS(info.config_url, { ...obj, quality });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) response = getDirectLink(info, quality);
|
||||||
|
if (!response) response = { error: 'ErrorEmptyDownload' };
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileMetadata = {
|
||||||
|
title: cleanString(info.name),
|
||||||
|
artist: cleanString(info.user.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
return merge(
|
||||||
|
{
|
||||||
|
fileMetadata,
|
||||||
|
filenameAttributes: {
|
||||||
|
service: "vimeo",
|
||||||
|
id: obj.id,
|
||||||
|
title: fileMetadata.title,
|
||||||
|
author: fileMetadata.artist,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,25 +1,27 @@
|
||||||
import { Innertube, Session } from 'youtubei.js';
|
import { fetch } from "undici";
|
||||||
import { env } from '../../config.js';
|
|
||||||
import { cleanString } from '../../sub/utils.js';
|
import { Innertube, Session } from "youtubei.js";
|
||||||
import { fetch } from 'undici'
|
|
||||||
import { getCookie, updateCookieValues } from '../cookie/manager.js'
|
import { env } from "../../config.js";
|
||||||
|
import { cleanString } from "../../sub/utils.js";
|
||||||
|
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
||||||
|
|
||||||
const ytBase = Innertube.create().catch(e => e);
|
const ytBase = Innertube.create().catch(e => e);
|
||||||
|
|
||||||
const codecMatch = {
|
const codecMatch = {
|
||||||
h264: {
|
h264: {
|
||||||
codec: "avc1",
|
videoCodec: "avc1",
|
||||||
aCodec: "mp4a",
|
audioCodec: "mp4a",
|
||||||
container: "mp4"
|
container: "mp4"
|
||||||
},
|
},
|
||||||
av1: {
|
av1: {
|
||||||
codec: "av01",
|
videoCodec: "av01",
|
||||||
aCodec: "mp4a",
|
audioCodec: "mp4a",
|
||||||
container: "mp4"
|
container: "mp4"
|
||||||
},
|
},
|
||||||
vp9: {
|
vp9: {
|
||||||
codec: "vp9",
|
videoCodec: "vp9",
|
||||||
aCodec: "opus",
|
audioCodec: "opus",
|
||||||
container: "webm"
|
container: "webm"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,20 +30,21 @@ const transformSessionData = (cookie) => {
|
||||||
if (!cookie)
|
if (!cookie)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const values = cookie.values();
|
const values = { ...cookie.values() };
|
||||||
const REQUIRED_VALUES = [
|
const REQUIRED_VALUES = [ 'access_token', 'refresh_token' ];
|
||||||
'access_token', 'refresh_token',
|
|
||||||
'client_id', 'client_secret',
|
|
||||||
'expires'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
|
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
...values,
|
if (values.expires) {
|
||||||
expires: new Date(values.expires),
|
values.expiry_date = values.expires;
|
||||||
};
|
delete values.expires;
|
||||||
|
} else if (!values.expiry_date) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloneInnertube = async (customFetch) => {
|
const cloneInnertube = async (customFetch) => {
|
||||||
|
@ -70,14 +73,19 @@ const cloneInnertube = async (customFetch) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.logged_in) {
|
if (session.logged_in) {
|
||||||
await session.oauth.refreshIfRequired();
|
if (session.oauth.shouldRefreshToken()) {
|
||||||
const oldExpiry = new Date(cookie.values().expires);
|
await session.oauth.refreshAccessToken();
|
||||||
const newExpiry = session.oauth.credentials.expires;
|
}
|
||||||
|
|
||||||
|
const cookieValues = cookie.values();
|
||||||
|
const oldExpiry = new Date(cookieValues.expiry_date);
|
||||||
|
const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
|
||||||
|
|
||||||
if (oldExpiry.getTime() !== newExpiry.getTime()) {
|
if (oldExpiry.getTime() !== newExpiry.getTime()) {
|
||||||
updateCookieValues(cookie, {
|
updateCookieValues(cookie, {
|
||||||
...session.oauth.credentials,
|
...session.oauth.client_id,
|
||||||
expires: session.oauth.credentials.expires.toISOString()
|
...session.oauth.oauth2_tokens,
|
||||||
|
expiry_date: newExpiry.toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,11 +96,16 @@ const cloneInnertube = async (customFetch) => {
|
||||||
|
|
||||||
export default async function(o) {
|
export default async function(o) {
|
||||||
const yt = await cloneInnertube(
|
const yt = await cloneInnertube(
|
||||||
(input, init) => fetch(input, { ...init, dispatcher: o.dispatcher })
|
(input, init) => fetch(input, {
|
||||||
|
...init,
|
||||||
|
dispatcher: o.dispatcher
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let info, isDubbed, format = o.format || "h264";
|
const quality = o.quality === "max" ? "9000" : o.quality;
|
||||||
let quality = o.quality === "max" ? "9000" : o.quality; // 9000(p) - max quality
|
|
||||||
|
let info, isDubbed,
|
||||||
|
format = o.format || "h264";
|
||||||
|
|
||||||
function qual(i) {
|
function qual(i) {
|
||||||
if (!i.quality_label) {
|
if (!i.quality_label) {
|
||||||
|
@ -115,6 +128,7 @@ export default async function(o) {
|
||||||
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
|
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
|
||||||
|
|
||||||
const playability = info.playability_status;
|
const playability = info.playability_status;
|
||||||
|
const basicInfo = info.basic_info;
|
||||||
|
|
||||||
if (playability.status === 'LOGIN_REQUIRED') {
|
if (playability.status === 'LOGIN_REQUIRED') {
|
||||||
if (playability.reason.endsWith('bot')) {
|
if (playability.reason.endsWith('bot')) {
|
||||||
|
@ -129,11 +143,11 @@ export default async function(o) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playability.status !== 'OK') return { error: 'ErrorYTUnavailable' };
|
if (playability.status !== 'OK') return { error: 'ErrorYTUnavailable' };
|
||||||
if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' };
|
if (basicInfo.is_live) return { error: 'ErrorLiveVideo' };
|
||||||
|
|
||||||
// return a critical error if returned video is "Video Not Available"
|
// return a critical error if returned video is "Video Not Available"
|
||||||
// or a similar stub by youtube
|
// or a similar stub by youtube
|
||||||
if (info.basic_info.id !== o.id) {
|
if (basicInfo.id !== o.id) {
|
||||||
return {
|
return {
|
||||||
error: 'ErrorCantConnectToServiceAPI',
|
error: 'ErrorCantConnectToServiceAPI',
|
||||||
critical: true
|
critical: true
|
||||||
|
@ -142,11 +156,16 @@ export default async function(o) {
|
||||||
|
|
||||||
let bestQuality, hasAudio;
|
let bestQuality, hasAudio;
|
||||||
|
|
||||||
const filterByCodec = (formats) => formats.filter(e =>
|
const filterByCodec = (formats) =>
|
||||||
e.mime_type.includes(codecMatch[format].codec) || e.mime_type.includes(codecMatch[format].aCodec)
|
formats
|
||||||
).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
.filter(e =>
|
||||||
|
e.mime_type.includes(codecMatch[format].videoCodec)
|
||||||
|
|| e.mime_type.includes(codecMatch[format].audioCodec)
|
||||||
|
)
|
||||||
|
.sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
||||||
|
|
||||||
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
|
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
|
||||||
|
|
||||||
if (adaptive_formats.length === 0 && format === "vp9") {
|
if (adaptive_formats.length === 0 && format === "vp9") {
|
||||||
format = "h264"
|
format = "h264"
|
||||||
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
|
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
|
||||||
|
@ -156,27 +175,43 @@ export default async function(o) {
|
||||||
hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
|
hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
|
||||||
|
|
||||||
if (bestQuality) bestQuality = qual(bestQuality);
|
if (bestQuality) bestQuality = qual(bestQuality);
|
||||||
if (!bestQuality && !o.isAudioOnly || !hasAudio) return { error: 'ErrorYTTryOtherCodec' };
|
|
||||||
if (info.basic_info.duration > env.durationLimit) return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let checkBestAudio = (i) => (i.has_audio && !i.has_video),
|
if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
|
||||||
audio = adaptive_formats.find(i => checkBestAudio(i) && !i.is_dubbed);
|
return { error: 'ErrorYTTryOtherCodec' };
|
||||||
|
|
||||||
|
if (basicInfo.duration > env.durationLimit)
|
||||||
|
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
||||||
|
|
||||||
|
const checkBestAudio = (i) => (i.has_audio && !i.has_video);
|
||||||
|
|
||||||
|
let audio = adaptive_formats.find(i =>
|
||||||
|
checkBestAudio(i) && i.is_original
|
||||||
|
);
|
||||||
|
|
||||||
if (o.dubLang) {
|
if (o.dubLang) {
|
||||||
let dubbedAudio = adaptive_formats.find(i =>
|
let dubbedAudio = adaptive_formats.find(i =>
|
||||||
checkBestAudio(i) && i.language === o.dubLang && i.audio_track && !i.audio_track.audio_is_default
|
checkBestAudio(i)
|
||||||
);
|
&& i.language === o.dubLang
|
||||||
|
&& i.audio_track
|
||||||
|
)
|
||||||
|
|
||||||
if (dubbedAudio) {
|
if (dubbedAudio) {
|
||||||
audio = dubbedAudio;
|
audio = dubbedAudio;
|
||||||
isDubbed = true
|
isDubbed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let fileMetadata = {
|
|
||||||
title: cleanString(info.basic_info.title.trim()),
|
if (!audio) {
|
||||||
artist: cleanString(info.basic_info.author.replace("- Topic", "").trim()),
|
audio = adaptive_formats.find(i => checkBestAudio(i));
|
||||||
}
|
}
|
||||||
if (info.basic_info.short_description && info.basic_info.short_description.startsWith("Provided to YouTube by")) {
|
|
||||||
let descItems = info.basic_info.short_description.split("\n\n");
|
let fileMetadata = {
|
||||||
|
title: cleanString(basicInfo.title.trim()),
|
||||||
|
artist: cleanString(basicInfo.author.replace("- Topic", "").trim()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
|
||||||
|
let descItems = basicInfo.short_description.split("\n\n");
|
||||||
fileMetadata.album = descItems[2];
|
fileMetadata.album = descItems[2];
|
||||||
fileMetadata.copyright = descItems[3];
|
fileMetadata.copyright = descItems[3];
|
||||||
if (descItems[4].startsWith("Released on:")) {
|
if (descItems[4].startsWith("Released on:")) {
|
||||||
|
@ -192,19 +227,23 @@ export default async function(o) {
|
||||||
youtubeDubName: isDubbed ? o.dubLang : false
|
youtubeDubName: isDubbed ? o.dubLang : false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAudio && o.isAudioOnly) return {
|
if (audio && o.isAudioOnly) return {
|
||||||
type: "render",
|
type: "render",
|
||||||
isAudioOnly: true,
|
isAudioOnly: true,
|
||||||
urls: audio.decipher(yt.session.player),
|
urls: audio.decipher(yt.session.player),
|
||||||
filenameAttributes: filenameAttributes,
|
filenameAttributes: filenameAttributes,
|
||||||
fileMetadata: fileMetadata,
|
fileMetadata: fileMetadata,
|
||||||
bestAudio: format === "h264" ? 'm4a' : 'opus'
|
bestAudio: format === "h264" ? "m4a" : "opus"
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
|
const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
|
||||||
checkSingle = i => qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].codec),
|
checkSingle = i =>
|
||||||
checkRender = i => qual(i) === matchingQuality && i.has_video && !i.has_audio;
|
qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec),
|
||||||
|
checkRender = i =>
|
||||||
|
qual(i) === matchingQuality && i.has_video && !i.has_audio;
|
||||||
|
|
||||||
let match, type, urls;
|
let match, type, urls;
|
||||||
|
|
||||||
if (!o.isAudioOnly && !o.isAudioMuted && format === 'h264') {
|
if (!o.isAudioOnly && !o.isAudioMuted && format === 'h264') {
|
||||||
match = info.streaming_data.formats.find(checkSingle);
|
match = info.streaming_data.formats.find(checkSingle);
|
||||||
type = "bridge";
|
type = "bridge";
|
||||||
|
@ -212,10 +251,14 @@ export default async function(o) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const video = adaptive_formats.find(checkRender);
|
const video = adaptive_formats.find(checkRender);
|
||||||
if (!match && video) {
|
|
||||||
|
if (!match && video && audio) {
|
||||||
match = video;
|
match = video;
|
||||||
type = "render";
|
type = "render";
|
||||||
urls = [video.decipher(yt.session.player), audio.decipher(yt.session.player)];
|
urls = [
|
||||||
|
video.decipher(yt.session.player),
|
||||||
|
audio.decipher(yt.session.player)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
@ -226,7 +269,7 @@ export default async function(o) {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
urls,
|
urls,
|
||||||
filenameAttributes,
|
filenameAttributes,
|
||||||
fileMetadata
|
fileMetadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
"vk": {
|
"vk": {
|
||||||
"alias": "vk video & clips",
|
"alias": "vk video & clips",
|
||||||
"patterns": ["video:userId_:videoId", "clip:userId_:videoId", "clips:duplicate?z=clip:userId_:videoId"],
|
"patterns": ["video:userId_:videoId", "clip:userId_:videoId", "clips:duplicate?z=clip:userId_:videoId"],
|
||||||
|
"subdomains": ["m"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"ok": {
|
"ok": {
|
||||||
|
@ -113,6 +114,12 @@
|
||||||
"patterns": ["video/:id"],
|
"patterns": ["video/:id"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"alias": "snapchat stories & spotlights",
|
||||||
|
"subdomains": ["t", "story"],
|
||||||
|
"patterns": [":shortLink", "spotlight/:spotlightId", "add/:username/:storyId", "u/:username/:storyId", "add/:username", "u/:username"],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"loom": {
|
"loom": {
|
||||||
"alias": "loom videos",
|
"alias": "loom videos",
|
||||||
"patterns": ["share/:id"],
|
"patterns": ["share/:id"],
|
||||||
|
@ -123,6 +130,19 @@
|
||||||
"tld": "net",
|
"tld": "net",
|
||||||
"patterns": [":user/post/:id"],
|
"patterns": [":user/post/:id"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"alias": "facebook videos",
|
||||||
|
"altDomains": ["fb.watch"],
|
||||||
|
"subdomains": ["web"],
|
||||||
|
"patterns": [
|
||||||
|
"_shortLink/:shortLink",
|
||||||
|
":username/videos/:caption/:id",
|
||||||
|
":username/videos/:id",
|
||||||
|
"reel/:id",
|
||||||
|
"share/:shareType/:id"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export const testers = {
|
export const testers = {
|
||||||
"bilibili": (patternMatch) =>
|
"bilibili": (patternMatch) =>
|
||||||
patternMatch.comId?.length <= 12 || patternMatch.comShortLink?.length <= 16
|
patternMatch.comId?.length <= 12 || patternMatch.comShortLink?.length <= 16
|
||||||
|| patternMatch.tvId?.length <= 24,
|
|| patternMatch.tvId?.length <= 24,
|
||||||
|
|
||||||
|
@ -27,9 +27,14 @@ export const testers = {
|
||||||
patternMatch.id?.length === 32 || patternMatch.yappyId?.length === 32,
|
patternMatch.id?.length === 32 || patternMatch.yappyId?.length === 32,
|
||||||
|
|
||||||
"soundcloud": (patternMatch) =>
|
"soundcloud": (patternMatch) =>
|
||||||
(patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255)
|
(patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255)
|
||||||
|| patternMatch.shortLink?.length <= 32,
|
|| patternMatch.shortLink?.length <= 32,
|
||||||
|
|
||||||
|
"snapchat": (patternMatch) =>
|
||||||
|
(patternMatch.username?.length <= 32 && (!patternMatch.storyId || patternMatch.storyId?.length <= 255))
|
||||||
|
|| patternMatch.spotlightId?.length <= 255
|
||||||
|
|| patternMatch.shortLink?.length <= 16,
|
||||||
|
|
||||||
"streamable": (patternMatch) =>
|
"streamable": (patternMatch) =>
|
||||||
patternMatch.id?.length === 6,
|
patternMatch.id?.length === 6,
|
||||||
|
|
||||||
|
@ -61,4 +66,11 @@ export const testers = {
|
||||||
|
|
||||||
"youtube": (patternMatch) =>
|
"youtube": (patternMatch) =>
|
||||||
patternMatch.id?.length <= 11,
|
patternMatch.id?.length <= 11,
|
||||||
|
|
||||||
|
"facebook": (patternMatch) =>
|
||||||
|
patternMatch.shortLink?.length <= 11
|
||||||
|
|| patternMatch.username?.length <= 30
|
||||||
|
|| patternMatch.caption?.length <= 255
|
||||||
|
|| patternMatch.id?.length <= 20 && !patternMatch.shareType
|
||||||
|
|| patternMatch.id?.length <= 20 && patternMatch.shareType?.length === 1,
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,7 +64,17 @@ function aliasURL(url) {
|
||||||
if (url.hostname === 'dai.ly' && parts.length === 2) {
|
if (url.hostname === 'dai.ly' && parts.length === 2) {
|
||||||
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
|
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "facebook":
|
||||||
|
case "fb":
|
||||||
|
if (url.searchParams.get('v')) {
|
||||||
|
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
|
||||||
|
}
|
||||||
|
if (url.hostname === 'fb.watch') {
|
||||||
|
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ddinstagram":
|
case "ddinstagram":
|
||||||
if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {
|
if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {
|
||||||
url.hostname = 'instagram.com';
|
url.hostname = 'instagram.com';
|
||||||
|
|
|
@ -23,6 +23,10 @@ function transformObject(streamInfo, hlsObject) {
|
||||||
|
|
||||||
hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
|
hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
|
||||||
|
|
||||||
|
if (hlsObject.map) {
|
||||||
|
hlsObject.map = transformObject(streamInfo, hlsObject.map);
|
||||||
|
}
|
||||||
|
|
||||||
return hlsObject;
|
return hlsObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { request } from 'undici';
|
import { request } from 'undici';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { assert } from 'console';
|
import { closeRequest, getHeaders, pipe } from './shared.js';
|
||||||
import { getHeaders, pipe } from './shared.js';
|
|
||||||
import { handleHlsPlaylist, isHlsRequest } from './internal-hls.js';
|
import { handleHlsPlaylist, isHlsRequest } from './internal-hls.js';
|
||||||
|
|
||||||
const CHUNK_SIZE = BigInt(8e6); // 8 MB
|
const CHUNK_SIZE = BigInt(8e6); // 8 MB
|
||||||
|
@ -27,7 +26,7 @@ async function* readChunks(streamInfo, size) {
|
||||||
const received = BigInt(chunk.headers['content-length']);
|
const received = BigInt(chunk.headers['content-length']);
|
||||||
|
|
||||||
if (received < expected / 2n) {
|
if (received < expected / 2n) {
|
||||||
streamInfo.controller.abort();
|
closeRequest(streamInfo.controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const data of chunk.body) {
|
for await (const data of chunk.body) {
|
||||||
|
@ -36,73 +35,88 @@ async function* readChunks(streamInfo, size) {
|
||||||
|
|
||||||
read += received;
|
read += received;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function chunkedStream(streamInfo, size) {
|
|
||||||
assert(streamInfo.controller instanceof AbortController);
|
|
||||||
const stream = Readable.from(readChunks(streamInfo, size));
|
|
||||||
return stream;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleYoutubeStream(streamInfo, res) {
|
async function handleYoutubeStream(streamInfo, res) {
|
||||||
|
const { signal } = streamInfo.controller;
|
||||||
|
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const req = await fetch(streamInfo.url, {
|
const req = await fetch(streamInfo.url, {
|
||||||
headers: getHeaders('youtube'),
|
headers: getHeaders('youtube'),
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
dispatcher: streamInfo.dispatcher,
|
dispatcher: streamInfo.dispatcher,
|
||||||
signal: streamInfo.controller.signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
streamInfo.url = req.url;
|
streamInfo.url = req.url;
|
||||||
const size = BigInt(req.headers.get('content-length'));
|
const size = BigInt(req.headers.get('content-length'));
|
||||||
|
|
||||||
if (req.status !== 200 || !size) {
|
if (req.status !== 200 || !size) {
|
||||||
return res.end();
|
return cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = chunkedStream(streamInfo, size);
|
const generator = readChunks(streamInfo, size);
|
||||||
|
|
||||||
|
const abortGenerator = () => {
|
||||||
|
generator.return();
|
||||||
|
signal.removeEventListener('abort', abortGenerator);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortGenerator);
|
||||||
|
|
||||||
|
const stream = Readable.from(generator);
|
||||||
|
|
||||||
for (const headerName of ['content-type', 'content-length']) {
|
for (const headerName of ['content-type', 'content-length']) {
|
||||||
const headerValue = req.headers.get(headerName);
|
const headerValue = req.headers.get(headerName);
|
||||||
if (headerValue) res.setHeader(headerName, headerValue);
|
if (headerValue) res.setHeader(headerName, headerValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
pipe(stream, res, () => res.end());
|
pipe(stream, res, cleanup);
|
||||||
} catch {
|
} catch {
|
||||||
res.end();
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function internalStream(streamInfo, res) {
|
async function handleGenericStream(streamInfo, res) {
|
||||||
if (streamInfo.service === 'youtube') {
|
const { signal } = streamInfo.controller;
|
||||||
return handleYoutubeStream(streamInfo, res);
|
const cleanup = () => res.end();
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const req = await request(streamInfo.url, {
|
const req = await request(streamInfo.url, {
|
||||||
headers: {
|
headers: {
|
||||||
...streamInfo.headers,
|
...Object.fromEntries(streamInfo.headers),
|
||||||
host: undefined
|
host: undefined
|
||||||
},
|
},
|
||||||
dispatcher: streamInfo.dispatcher,
|
dispatcher: streamInfo.dispatcher,
|
||||||
signal: streamInfo.controller.signal,
|
signal,
|
||||||
maxRedirections: 16
|
maxRedirections: 16
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(req.statusCode);
|
res.status(req.statusCode);
|
||||||
|
req.body.on('error', () => {});
|
||||||
|
|
||||||
for (const [ name, value ] of Object.entries(req.headers))
|
for (const [ name, value ] of Object.entries(req.headers))
|
||||||
res.setHeader(name, value)
|
res.setHeader(name, value)
|
||||||
|
|
||||||
if (req.statusCode < 200 || req.statusCode > 299)
|
if (req.statusCode < 200 || req.statusCode > 299)
|
||||||
return res.end();
|
return cleanup();
|
||||||
|
|
||||||
if (isHlsRequest(req)) {
|
if (isHlsRequest(req)) {
|
||||||
await handleHlsPlaylist(streamInfo, req, res);
|
await handleHlsPlaylist(streamInfo, req, res);
|
||||||
} else {
|
} else {
|
||||||
pipe(req.body, res, () => res.end());
|
pipe(req.body, res, cleanup);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
streamInfo.controller.abort();
|
closeRequest(streamInfo.controller);
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function internalStream(streamInfo, res) {
|
||||||
|
if (streamInfo.service === 'youtube') {
|
||||||
|
return handleYoutubeStream(streamInfo, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleGenericStream(streamInfo, res);
|
||||||
}
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { setMaxListeners } from "node:events";
|
||||||
|
|
||||||
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
|
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { strict as assert } from "assert";
|
import { strict as assert } from "assert";
|
||||||
|
import { closeRequest } from "./shared.js";
|
||||||
|
|
||||||
// optional dependency
|
// optional dependency
|
||||||
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
|
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
|
||||||
|
@ -78,16 +80,36 @@ export function createInternalStream(url, obj = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamID = nanoid();
|
const streamID = nanoid();
|
||||||
|
let controller = obj.controller;
|
||||||
|
|
||||||
|
if (!controller) {
|
||||||
|
controller = new AbortController();
|
||||||
|
setMaxListeners(Infinity, controller.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers;
|
||||||
|
if (obj.headers) {
|
||||||
|
headers = new Map(Object.entries(obj.headers));
|
||||||
|
}
|
||||||
|
|
||||||
internalStreamCache[streamID] = {
|
internalStreamCache[streamID] = {
|
||||||
url,
|
url,
|
||||||
service: obj.service,
|
service: obj.service,
|
||||||
headers: obj.headers,
|
headers,
|
||||||
controller: new AbortController(),
|
controller,
|
||||||
dispatcher
|
dispatcher
|
||||||
};
|
};
|
||||||
|
|
||||||
let streamLink = new URL('/api/istream', `http://127.0.0.1:${env.apiPort}`);
|
let streamLink = new URL('/api/istream', `http://127.0.0.1:${env.apiPort}`);
|
||||||
streamLink.searchParams.set('id', streamID);
|
streamLink.searchParams.set('id', streamID);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
destroyInternalStream(streamLink);
|
||||||
|
controller.signal.removeEventListener('abort', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.signal.addEventListener('abort', cleanup);
|
||||||
|
|
||||||
return streamLink.toString();
|
return streamLink.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +122,7 @@ export function destroyInternalStream(url) {
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
if (internalStreamCache[id]) {
|
if (internalStreamCache[id]) {
|
||||||
internalStreamCache[id].controller.abort();
|
closeRequest(internalStreamCache[id].controller);
|
||||||
delete internalStreamCache[id];
|
delete internalStreamCache[id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,10 @@ const serviceHeaders = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeRequest(controller) {
|
||||||
|
try { controller.abort() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeResponse(res) {
|
export function closeResponse(res) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.sendStatus(500);
|
res.sendStatus(500);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
|
||||||
import { metadataManager } from "../sub/utils.js";
|
import { metadataManager } from "../sub/utils.js";
|
||||||
import { destroyInternalStream } from "./manage.js";
|
import { destroyInternalStream } from "./manage.js";
|
||||||
import { env, ffmpegArgs, hlsExceptions } from "../config.js";
|
import { env, ffmpegArgs, hlsExceptions } from "../config.js";
|
||||||
import { getHeaders, closeResponse, pipe } from "./shared.js";
|
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
||||||
|
|
||||||
function toRawHeaders(headers) {
|
function toRawHeaders(headers) {
|
||||||
return Object.entries(headers)
|
return Object.entries(headers)
|
||||||
|
@ -14,10 +14,6 @@ function toRawHeaders(headers) {
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRequest(controller) {
|
|
||||||
try { controller.abort() } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function killProcess(p) {
|
function killProcess(p) {
|
||||||
// ask the process to terminate itself gracefully
|
// ask the process to terminate itself gracefully
|
||||||
p?.kill('SIGTERM');
|
p?.kill('SIGTERM');
|
||||||
|
@ -29,7 +25,7 @@ function killProcess(p) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommand(args) {
|
function getCommand(args) {
|
||||||
if (!isNaN(env.processingPriority)) {
|
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
|
||||||
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
|
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
|
||||||
}
|
}
|
||||||
return [ffmpeg, args]
|
return [ffmpeg, args]
|
||||||
|
@ -96,6 +92,10 @@ export function streamLiveRender(streamInfo, res) {
|
||||||
|
|
||||||
args = args.concat(ffmpegArgs[format]);
|
args = args.concat(ffmpegArgs[format]);
|
||||||
|
|
||||||
|
if (hlsExceptions.includes(streamInfo.service)) {
|
||||||
|
args.push('-bsf:a', 'aac_adtstoasc')
|
||||||
|
}
|
||||||
|
|
||||||
if (streamInfo.metadata) {
|
if (streamInfo.metadata) {
|
||||||
args = args.concat(metadataManager(streamInfo.metadata))
|
args = args.concat(metadataManager(streamInfo.metadata))
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,3 +44,24 @@ export function cleanHTML(html) {
|
||||||
clean = clean.replace(/\n/g, '');
|
clean = clean.replace(/\n/g, '');
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRedirectingURL(url) {
|
||||||
|
return fetch(url, { redirect: 'manual' }).then((r) => {
|
||||||
|
if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
|
||||||
|
return r.headers.get('location');
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(a, b) {
|
||||||
|
for (const k of Object.keys(b)) {
|
||||||
|
if (Array.isArray(b[k])) {
|
||||||
|
a[k] = [...(a[k] ?? []), ...b[k]];
|
||||||
|
} else if (typeof b[k] === 'object') {
|
||||||
|
a[k] = merge(a[k], b[k]);
|
||||||
|
} else {
|
||||||
|
a[k] = b[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
42
src/modules/test.js
Normal file
42
src/modules/test.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { normalizeRequest } from "../modules/processing/request.js";
|
||||||
|
import match from "./processing/match.js";
|
||||||
|
import { extract } from "./processing/url.js";
|
||||||
|
|
||||||
|
export async function runTest(url, params, expect) {
|
||||||
|
const normalized = normalizeRequest({ url, ...params });
|
||||||
|
if (!normalized) {
|
||||||
|
throw "invalid request";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = extract(normalized.url);
|
||||||
|
if (parsed === null) {
|
||||||
|
throw `invalid url: ${normalized.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await match(
|
||||||
|
parsed.host, parsed.patternMatch, "en", normalized
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = [];
|
||||||
|
if (expect.status !== result.body.status) {
|
||||||
|
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
||||||
|
error.push(`status mismatch: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect.code !== result.status) {
|
||||||
|
const detail = `${expect.code} (expected) != ${result.status} (actual)`;
|
||||||
|
error.push(`status code mismatch: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.length) {
|
||||||
|
if (result.body.text) {
|
||||||
|
error.push(`error message: ${result.body.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.body.status === 'stream') {
|
||||||
|
// TODO: stream testing
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,9 +20,9 @@ tube.session.once(
|
||||||
);
|
);
|
||||||
|
|
||||||
tube.session.once('auth-error', (err) => bail('An error occurred:', err));
|
tube.session.once('auth-error', (err) => bail('An error occurred:', err));
|
||||||
tube.session.once('auth', ({ status, credentials, ...rest }) => {
|
tube.session.once('auth', ({ credentials }) => {
|
||||||
if (status !== 'SUCCESS') {
|
if (!credentials.access_token) {
|
||||||
bail('something went wrong', rest);
|
bail('something went wrong');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
81
src/util/test-ci.js
Normal file
81
src/util/test-ci.js
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { env } from "../modules/config.js";
|
||||||
|
import { runTest } from "../modules/test.js";
|
||||||
|
import { loadLoc } from "../localization/manager.js";
|
||||||
|
import { loadJSON } from "../modules/sub/loadFromFs.js";
|
||||||
|
import { Red, Bright } from "../modules/sub/consoleText.js";
|
||||||
|
|
||||||
|
const tests = loadJSON('./src/util/tests.json');
|
||||||
|
const services = loadJSON('./src/modules/processing/servicesConfig.json');
|
||||||
|
|
||||||
|
// services that are known to frequently fail due to external
|
||||||
|
// factors (e.g. rate limiting)
|
||||||
|
const finnicky = new Set(['bilibili', 'instagram', 'youtube'])
|
||||||
|
|
||||||
|
const action = process.argv[2];
|
||||||
|
switch (action) {
|
||||||
|
case "get-services":
|
||||||
|
const fromConfig = Object.keys(services.config);
|
||||||
|
|
||||||
|
const missingTests = fromConfig.filter(
|
||||||
|
service => !tests[service] || tests[service].length === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingTests.length) {
|
||||||
|
console.error('services have no tests:', missingTests);
|
||||||
|
console.log('[]');
|
||||||
|
process.exitCode = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(fromConfig));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "run-tests-for":
|
||||||
|
const service = process.argv[3];
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
if (!tests[service]) {
|
||||||
|
console.error('no such service:', service);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadLoc();
|
||||||
|
env.streamLifespan = 10000;
|
||||||
|
env.apiURL = 'http://x';
|
||||||
|
|
||||||
|
for (const test of tests[service]) {
|
||||||
|
const { name, url, params, expected } = test;
|
||||||
|
const canFail = test.canFail || finnicky.has(service);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest(url, params, expected);
|
||||||
|
console.log(`${service}/${name}: ok`);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
failed = !canFail;
|
||||||
|
|
||||||
|
let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL'));
|
||||||
|
if (canFail && process.env.GITHUB_ACTION) {
|
||||||
|
console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`${service}/${name}: ${failText}`);
|
||||||
|
const errorString = e.toString().split('\n');
|
||||||
|
let c = '┃';
|
||||||
|
errorString.forEach((line, index) => {
|
||||||
|
line = line.replace('!=', Red('!='));
|
||||||
|
|
||||||
|
if (index === errorString.length - 1) {
|
||||||
|
c = '┗';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(` ${c}`, line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = Number(failed);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('invalid action:', action);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"twitter": [{
|
"twitter": [{
|
||||||
"name": "regular video",
|
"name": "regular video",
|
||||||
"url": "https://twitter.com/TwitterSpaces/status/1526955853743546372?s=20",
|
"url": "https://twitter.com/X/status/1697304622749086011",
|
||||||
"params": {
|
"params": {
|
||||||
"aFormat": "mp3",
|
"aFormat": "mp3",
|
||||||
"isAudioOnly": false,
|
"isAudioOnly": false,
|
||||||
|
@ -11,10 +11,9 @@
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
"name": "video with mobile web mediaviewer",
|
"name": "video with mobile web mediaviewer",
|
||||||
"url": "https://twitter.com/XSpaces/status/1526955853743546372/mediaViewer?currentTweet=1526955853743546372¤tTweetUser=XSpaces¤tTweet=1526955853743546372¤tTweetUser=XSpaces",
|
"url": "https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011¤tTweetUser=X¤tTweet=1697304622749086011¤tTweetUser=X",
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
@ -34,7 +33,7 @@
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "mixed media (image + gif)",
|
"name": "mixed media (image + gif)",
|
||||||
"url": "https://twitter.com/Twitter/status/1580661436132757506?s=20",
|
"url": "https://twitter.com/sky_mj26/status/1807756010712428565",
|
||||||
"params": {
|
"params": {
|
||||||
"aFormat": "mp3",
|
"aFormat": "mp3",
|
||||||
"isAudioOnly": false,
|
"isAudioOnly": false,
|
||||||
|
@ -42,7 +41,7 @@
|
||||||
},
|
},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "picker"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "picker: mixed media (3 videos)",
|
"name": "picker: mixed media (3 videos)",
|
||||||
|
@ -113,9 +112,10 @@
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "age-restricted video",
|
"name": "age restricted video",
|
||||||
"url": "https://twitter.com/FckyeahCharli/status/1650987582749065220",
|
"url": "https://twitter.com/FckyeahCharli/status/1650987582749065220",
|
||||||
"params": {},
|
"params": {},
|
||||||
|
"canFail": true,
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
|
@ -136,6 +136,22 @@
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
"name": "post with 1 image",
|
||||||
|
"url": "https://x.com/PopCrave/status/1815960083475423235",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "post with 4 images",
|
||||||
|
"url": "https://x.com/PopCrave/status/1816260887147114696",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "picker"
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "retweeted video, isAudioOnly",
|
"name": "retweeted video, isAudioOnly",
|
||||||
"url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg",
|
"url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg",
|
||||||
|
@ -650,23 +666,23 @@
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "1080p dash parcel",
|
"name": "1080p dash parcel",
|
||||||
"url": "https://vimeo.com/774694040",
|
"url": "https://vimeo.com/967252742",
|
||||||
"params": {
|
"params": {
|
||||||
"vQuality": "1440"
|
"vQuality": "1440"
|
||||||
},
|
},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "stream"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "720p dash parcel",
|
"name": "720p dash parcel",
|
||||||
"url": "https://vimeo.com/774694040",
|
"url": "https://vimeo.com/967252742",
|
||||||
"params": {
|
"params": {
|
||||||
"vQuality": "360"
|
"vQuality": "360"
|
||||||
},
|
},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "stream"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "private video",
|
"name": "private video",
|
||||||
|
@ -674,7 +690,15 @@
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "stream"
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "mature video",
|
||||||
|
"url": "https://vimeo.com/973212054",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
"reddit": [{
|
"reddit": [{
|
||||||
|
@ -947,7 +971,7 @@
|
||||||
}],
|
}],
|
||||||
"streamable": [{
|
"streamable": [{
|
||||||
"name": "regular video",
|
"name": "regular video",
|
||||||
"url": "https://streamable.com/03r3c2",
|
"url": "https://streamable.com/p9cln4",
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
@ -963,7 +987,7 @@
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "regular video (isAudioOnly)",
|
"name": "regular video (isAudioOnly)",
|
||||||
"url": "https://streamable.com/03r3c2",
|
"url": "https://streamable.com/p9cln4",
|
||||||
"params": {
|
"params": {
|
||||||
"isAudioOnly": true
|
"isAudioOnly": true
|
||||||
},
|
},
|
||||||
|
@ -973,7 +997,7 @@
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "regular video (isAudioMuted)",
|
"name": "regular video (isAudioMuted)",
|
||||||
"url": "https://streamable.com/03r3c2",
|
"url": "https://streamable.com/p9cln4",
|
||||||
"params": {
|
"params": {
|
||||||
"isAudioMuted": true
|
"isAudioMuted": true
|
||||||
},
|
},
|
||||||
|
@ -1042,8 +1066,8 @@
|
||||||
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
|
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 400,
|
||||||
"status": "stream"
|
"status": "error"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "vertical video",
|
"name": "vertical video",
|
||||||
|
@ -1055,7 +1079,8 @@
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "yappy",
|
"name": "yappy",
|
||||||
"url": "https://rutube.ru/yappy/f1771e86294748eea8ecb2ac88e55740/",
|
"url": "https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/",
|
||||||
|
"canFail": true,
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
@ -1132,6 +1157,31 @@
|
||||||
"status": "stream"
|
"status": "stream"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
"snapchat": [{
|
||||||
|
"name": "spotlight",
|
||||||
|
"url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shortlinked spotlight",
|
||||||
|
"url": "https://t.snapchat.com/4ZsiBLDi",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "story",
|
||||||
|
"url": "https://www.snapchat.com/add/bazerkmakane",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "picker"
|
||||||
|
}
|
||||||
|
}],
|
||||||
"loom": [{
|
"loom": [{
|
||||||
"name": "1080p video",
|
"name": "1080p video",
|
||||||
"url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761",
|
"url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761",
|
||||||
|
@ -1156,9 +1206,68 @@
|
||||||
"params": {
|
"params": {
|
||||||
"isAudioOnly": true
|
"isAudioOnly": true
|
||||||
},
|
},
|
||||||
|
"expected": {
|
||||||
|
"code": 400,
|
||||||
|
"status": "error"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"facebook": [{
|
||||||
|
"name": "direct video with username and id",
|
||||||
|
"url": "https://web.facebook.com/100048111287134/videos/1157798148685638/",
|
||||||
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "stream"
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "direct video with id as query param",
|
||||||
|
"url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "direct video with caption",
|
||||||
|
"url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shortlink video",
|
||||||
|
"url": "https://fb.watch/r1K6XHMfGT/",
|
||||||
|
"canFail": true,
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "reel video",
|
||||||
|
"url": "https://web.facebook.com/reel/730293269054758",
|
||||||
|
"canFail": true,
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shared video link",
|
||||||
|
"url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shared video link v2",
|
||||||
|
"url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
"threads": [{
|
"threads": [{
|
||||||
|
|
Loading…
Reference in a new issue