cobalt 10: svelte web app, improved backend (#719)
21
.dockerignore
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# OS directory info files
|
||||||
|
.DS_Store
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# static build
|
||||||
|
build
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
cookies.json
|
||||||
|
|
||||||
|
# docker
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# ide
|
||||||
|
.vscode
|
36
.github/test.sh
vendored
|
@ -13,19 +13,20 @@ waitport() {
|
||||||
|
|
||||||
test_api() {
|
test_api() {
|
||||||
waitport 3000
|
waitport 3000
|
||||||
curl -m 3 http://localhost:3000/api/serverInfo
|
curl -m 3 http://localhost:3000/
|
||||||
API_RESPONSE=$(curl -m 3 http://localhost:3000/api/json \
|
API_RESPONSE=$(curl -m 3 http://localhost:3000/ \
|
||||||
-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://vine.co/v/huwVJIEJW50", "isAudioOnly": true}')
|
-d '{"url":"https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894"}')
|
||||||
|
|
||||||
echo "$API_RESPONSE"
|
echo "API_RESPONSE=$API_RESPONSE"
|
||||||
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
||||||
STREAM_URL=$(echo "$API_RESPONSE" | jq -r .url)
|
STREAM_URL=$(echo "$API_RESPONSE" | jq -r .url)
|
||||||
[ "$STATUS" = stream ] || exit 1;
|
[ "$STATUS" = tunnel ] || exit 1;
|
||||||
|
S=$(curl -I -m 3 "$STREAM_URL")
|
||||||
|
|
||||||
CONTENT_LENGTH=$(curl -I -m 3 "$STREAM_URL" \
|
CONTENT_LENGTH=$(echo "$S" \
|
||||||
| grep -i content-length \
|
| grep -i content-length \
|
||||||
| cut -d' ' -f2 \
|
| cut -d' ' -f2 \
|
||||||
| tr -d '\r')
|
| tr -d '\r')
|
||||||
|
@ -37,34 +38,27 @@ test_api() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
test_web() {
|
|
||||||
waitport 3001
|
|
||||||
curl -m 3 http://127.0.0.1:3001/onDemand?blockId=0 \
|
|
||||||
| grep -q '"status":"success"'
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_api() {
|
setup_api() {
|
||||||
export API_PORT=3000
|
export API_PORT=3000
|
||||||
export API_URL=http://localhost:3000
|
export API_URL=http://localhost:3000
|
||||||
timeout 10 npm run start
|
timeout 10 pnpm run --prefix api start &
|
||||||
|
API_PID=$!
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_web() {
|
setup_web() {
|
||||||
export WEB_PORT=3001
|
pnpm run --prefix web build
|
||||||
export WEB_URL=http://localhost:3001
|
|
||||||
export API_URL=http://localhost:3000
|
|
||||||
timeout 5 npm run start
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
npm i
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
if [ "$1" = "api" ]; then
|
if [ "$1" = "api" ]; then
|
||||||
setup_api &
|
setup_api
|
||||||
test_api
|
test_api
|
||||||
|
[ "$API_PID" != "" ] \
|
||||||
|
&& kill "$API_PID"
|
||||||
elif [ "$1" = "web" ]; then
|
elif [ "$1" = "web" ]; then
|
||||||
setup_web &
|
setup_web
|
||||||
test_web
|
|
||||||
else
|
else
|
||||||
echo "usage: $0 <api/web>" >&2
|
echo "usage: $0 <api/web>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
33
.github/workflows/test-services.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
name: Run service tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- api/**
|
||||||
|
- packages/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-services:
|
||||||
|
name: test service functionality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
services: ${{ steps.checkServices.outputs.service_list }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- id: checkServices
|
||||||
|
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- run: pnpm i --frozen-lockfile && node api/src/util/test-ci run-tests-for ${{ matrix.service }}
|
52
.github/workflows/test.yml
vendored
|
@ -3,60 +3,32 @@ name: Run tests
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [ current ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-lockfile:
|
check-lockfile:
|
||||||
name: check lockfile correctness
|
name: check lockfile correctness
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- name: Check that lockfile does not need an update
|
- name: Check that lockfile does not need an update
|
||||||
run: |
|
run: pnpm install --frozen-lockfile
|
||||||
cp package-lock.json before.json
|
|
||||||
npm ci
|
|
||||||
npm i --package-lock-only
|
|
||||||
diff before.json package-lock.json
|
|
||||||
|
|
||||||
test-web:
|
test-web:
|
||||||
name: web sanity check
|
name: web sanity check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Run test script
|
with:
|
||||||
run: .github/test.sh web
|
node-version: 'lts/*'
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- run: .github/test.sh web
|
||||||
|
|
||||||
test-api:
|
test-api:
|
||||||
name: api sanity check
|
name: api sanity check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- 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 }}
|
|
18
.gitignore
vendored
|
@ -1,21 +1,21 @@
|
||||||
# os stuff
|
# OS directory info files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# npm
|
# node
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# static build
|
||||||
|
build
|
||||||
|
|
||||||
# secrets
|
# secrets
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
cookies.json
|
||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
# vscode
|
# ide
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
# cookie file
|
|
||||||
cookies.json
|
|
||||||
|
|
||||||
# page build
|
|
||||||
build
|
|
||||||
|
|
30
Dockerfile
|
@ -1,15 +1,25 @@
|
||||||
FROM node:18-bullseye-slim
|
FROM node:20-bullseye-slim AS base
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y python3 build-essential
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
RUN pnpm deploy --filter=@imput/cobalt-api --prod /prod/api
|
||||||
|
|
||||||
|
FROM base AS api
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY --from=build /prod/api /app
|
||||||
|
COPY --from=build /app/.git /app/.git
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y git python3 build-essential && \
|
|
||||||
npm ci && \
|
|
||||||
npm cache clean --force && \
|
|
||||||
apt purge --autoremove -y python3 build-essential && \
|
|
||||||
rm -rf ~/.cache/ /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
EXPOSE 9000
|
EXPOSE 9000
|
||||||
CMD [ "node", "src/cobalt" ]
|
CMD [ "node", "src/cobalt" ]
|
118
README.md
|
@ -1,41 +1,56 @@
|
||||||
# cobalt
|
<div align="center">
|
||||||
best way to save what you love: [cobalt.tools](https://cobalt.tools/)
|
<br/>
|
||||||
|
<p>
|
||||||
|
<img src="web/static/favicon.png" title="cobalt" alt="cobalt logo" width="100" />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
best way to save what you love
|
||||||
|
<br/>
|
||||||
|
<a href="https://cobalt.tools">
|
||||||
|
cobalt.tools
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://discord.gg/pQPt8HBUPu">
|
||||||
|
💬 community discord server
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/justusecobalt">
|
||||||
|
🐦 twitter/x
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<br/>
|
||||||
|
</div>
|
||||||
|
|
||||||
![cobalt logo with repeated logo (double arrow) pattern background](/src/front/icons/pattern.png "cobalt logo with repeated logo (double arrow) pattern background")
|
cobalt is a media downloader that doesn't piss you off. it's fast, friendly, and doesn't have any bullshit that modern web is filled with: ***no ads, trackers, or paywalls***.
|
||||||
|
|
||||||
[💬 community discord server](https://discord.gg/pQPt8HBUPu)
|
|
||||||
[🐦 twitter/x](https://x.com/justusecobalt)
|
|
||||||
|
|
||||||
## what's cobalt?
|
|
||||||
cobalt is a media downloader that doesn't piss you off. it's fast, friendly, and doesn't have any bullshit that modern web is filled with: ***no ads, trackers, or invasive analytics***.
|
|
||||||
|
|
||||||
paste the link, get the file, move on. it's that simple. just how it should be.
|
paste the link, get the file, move on. it's that simple. just how it should be.
|
||||||
|
|
||||||
## supported services
|
### supported services
|
||||||
this list is not final and keeps expanding over time. if support for a service you want is missing, create an issue (or a pull request 👀).
|
this list is not final and keeps expanding over time. if support for a service you want is missing, create an issue (or a pull request 👀).
|
||||||
|
|
||||||
| service | video + audio | only audio | only video | metadata | rich file names |
|
| service | video + audio | only audio | only video | metadata | rich file names |
|
||||||
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
|
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
|
||||||
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| bilibili | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| bluesky | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| facebook videos | ✅ | ❌ | ❌ | ➖ | ➖ |
|
| instagram | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
| facebook | ✅ | ❌ | ✅ | ➖ | ➖ |
|
||||||
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
|
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
||||||
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||||
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| snapchat stories & spotlights | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
||||||
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| vine archive | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
|
| vine | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| youtube videos, shorts & music | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
| emoji | meaning |
|
| emoji | meaning |
|
||||||
| :-----: | :---------------------- |
|
| :-----: | :---------------------- |
|
||||||
|
@ -58,46 +73,17 @@ this list is not final and keeps expanding over time. if support for a service y
|
||||||
| vimeo | audio downloads are only available for dash. |
|
| vimeo | audio downloads are only available for dash. |
|
||||||
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
|
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
|
||||||
|
|
||||||
## cobalt api
|
### partners
|
||||||
cobalt has an open api that you can use in your projects *for free~*. it's easy and straightforward to use, [check out the docs](/docs/api.md) to learn how to use it.
|
|
||||||
|
|
||||||
✅ you can use the main api instance ([api.cobalt.tools](https://api.cobalt.tools/)) in your **personal** projects.
|
|
||||||
❌ you cannot use the free api commercially (anywhere that's gated behind paywalls or ads). host your own instance for this.
|
|
||||||
|
|
||||||
we reserve the right to restrict abusive/excessive access to the main instance api.
|
|
||||||
|
|
||||||
## how to run your own instance
|
|
||||||
if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
|
|
||||||
it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes.
|
|
||||||
|
|
||||||
## partners
|
|
||||||
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), all main instances are currently hosted on their network :)
|
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), all main instances are currently hosted on their network :)
|
||||||
|
|
||||||
## ethics and disclaimer
|
### ethics and disclaimer
|
||||||
cobalt is a tool for easing content downloads from internet and takes ***zero liability***. you are responsible for what you download, how you use and distribute that content. please be mindful when using content of others and always credit original creators. fair use and credits benefit everyone.
|
cobalt is a tool for easing content downloads from internet and takes ***zero liability***. you are responsible for what you download, how you use and distribute that content. please be mindful when using content of others and always credit original creators. fair use and credits benefit everyone.
|
||||||
|
|
||||||
cobalt is ***NOT*** a piracy tool and cannot be used as such. it can only download free, publicly accessible content. such content can be easily downloaded through any browser's dev tools. pressing one button is easier, so i made a convenient, ad-less tool for such repeated actions.
|
cobalt is ***NOT*** a piracy tool and cannot be used as such. it can only download free, publicly accessible content. such content can be easily downloaded through any browser's dev tools. pressing one button is easier, so i made a convenient, ad-less tool for such repeated actions.
|
||||||
|
|
||||||
## cobalt license
|
### cobalt license
|
||||||
cobalt code is licensed under [AGPL-3.0](/LICENSE).
|
for relevant licensing information, see the [api](api/README.md) and [web](web/README.md) READMEs.
|
||||||
|
unless specified otherwise, the remainder of this repository is licensed under [AGPL-3.0](LICENSE).
|
||||||
cobalt branding, mascots, and other related assets included in the repo are ***copyrighted*** and not covered by the AGPL-3.0 license. you ***cannot*** use them under same terms.
|
|
||||||
|
|
||||||
you are allowed to host an ***unmodified*** instance of cobalt with branding, but this ***does not*** give you permission to use it anywhere else, or make derivatives of it in any way.
|
|
||||||
|
|
||||||
### notes:
|
|
||||||
- mascots and other assets are a part of the branding.
|
|
||||||
|
|
||||||
- when making an alternative version of the project, please replace or remove all branding (including the name).
|
|
||||||
|
|
||||||
- you **must** link the original repo when using any parts of code (such as using separate processing modules in your project) or forking the project.
|
|
||||||
|
|
||||||
- if you make a modified version of cobalt, the codebase **must** be published under the same license (according to AGPL-3.0).
|
|
||||||
|
|
||||||
## 3rd party licenses
|
|
||||||
- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
|
|
||||||
- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
|
|
||||||
- many update banners were taken from [tenor.com](https://tenor.com/).
|
|
||||||
|
|
||||||
## acknowledgements
|
## acknowledgements
|
||||||
### ffmpeg
|
### ffmpeg
|
||||||
|
|
661
api/LICENSE
Normal file
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
save what you love with cobalt.
|
||||||
|
Copyright (C) 2024 imput
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
22
api/README.md
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# cobalt api
|
||||||
|
|
||||||
|
## license
|
||||||
|
cobalt api code is licensed under [AGPL-3.0](LICENSE).
|
||||||
|
|
||||||
|
this license allows you to modify, distribute and use the code for any purpose
|
||||||
|
as long as you:
|
||||||
|
- give appropriate credit to the original repo when using or modifying any parts of the code,
|
||||||
|
- provide a link to the license and indicate if changes to the code were made, and
|
||||||
|
- release the code under the **same license**
|
||||||
|
|
||||||
|
## running your own instance
|
||||||
|
if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
|
||||||
|
it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes.
|
||||||
|
|
||||||
|
## accessing the api
|
||||||
|
currently, there is no publicly accessible main api. we plan on providing a public api for
|
||||||
|
cobalt 10 in some form in the future. we recommend deploying your own instance if you wish
|
||||||
|
to use the latest api. you can access [the documentation](/docs/api.md) for it here.
|
||||||
|
|
||||||
|
if you are looking for the documentation for the old (7.x) api, you can find
|
||||||
|
it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
|
49
api/package.json
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "@imput/cobalt-api",
|
||||||
|
"description": "save what you love",
|
||||||
|
"version": "10.0.0",
|
||||||
|
"author": "imput",
|
||||||
|
"exports": "./src/cobalt.js",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/cobalt",
|
||||||
|
"setup": "node src/util/setup",
|
||||||
|
"test": "node src/util/test",
|
||||||
|
"token:youtube": "node src/util/generate-youtube-tokens"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/imputnet/cobalt.git"
|
||||||
|
},
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/imputnet/cobalt/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/imputnet/cobalt#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"@imput/version-info": "workspace:^",
|
||||||
|
"content-disposition-header": "0.6.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.0.1",
|
||||||
|
"esbuild": "^0.14.51",
|
||||||
|
"express": "^4.18.1",
|
||||||
|
"express-rate-limit": "^6.3.0",
|
||||||
|
"ffmpeg-static": "^5.1.0",
|
||||||
|
"hls-parser": "^0.10.7",
|
||||||
|
"ipaddr.js": "2.1.0",
|
||||||
|
"nanoid": "^4.0.2",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"psl": "1.9.0",
|
||||||
|
"set-cookie-parser": "2.6.0",
|
||||||
|
"undici": "^5.19.1",
|
||||||
|
"url-pattern": "1.0.3",
|
||||||
|
"youtubei.js": "^10.3.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"freebind": "^0.2.2"
|
||||||
|
}
|
||||||
|
}
|
27
api/src/cobalt.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import { env } from "./config.js"
|
||||||
|
import { Bright, Green, Red } from "./misc/console-text.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename).slice(0, -4);
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
|
if (env.apiURL) {
|
||||||
|
const { runAPI } = await import('./core/api.js');
|
||||||
|
runAPI(express, app, __dirname)
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
Red(`cobalt wasn't configured yet or configuration is invalid.\n`)
|
||||||
|
+ Bright(`please run the setup script to fix this: `)
|
||||||
|
+ Green(`npm run setup`)
|
||||||
|
)
|
||||||
|
}
|
51
api/src/config.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { getVersion } from "@imput/version-info";
|
||||||
|
import { services } from "./processing/service-config.js";
|
||||||
|
|
||||||
|
const version = await getVersion();
|
||||||
|
|
||||||
|
const disabledServices = process.env.DISABLED_SERVICES?.split(',') || [];
|
||||||
|
const enabledServices = new Set(Object.keys(services).filter(e => {
|
||||||
|
if (!disabledServices.includes(e)) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
apiURL: process.env.API_URL || '',
|
||||||
|
apiPort: process.env.API_PORT || 9000,
|
||||||
|
|
||||||
|
listenAddress: process.env.API_LISTEN_ADDRESS,
|
||||||
|
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
|
||||||
|
|
||||||
|
corsWildcard: process.env.CORS_WILDCARD !== '0',
|
||||||
|
corsURL: process.env.CORS_URL,
|
||||||
|
|
||||||
|
cookiePath: process.env.COOKIE_PATH,
|
||||||
|
|
||||||
|
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
|
||||||
|
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
|
||||||
|
|
||||||
|
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
|
||||||
|
streamLifespan: 90,
|
||||||
|
|
||||||
|
processingPriority: process.platform !== 'win32'
|
||||||
|
&& process.env.PROCESSING_PRIORITY
|
||||||
|
&& parseInt(process.env.PROCESSING_PRIORITY),
|
||||||
|
|
||||||
|
externalProxy: process.env.API_EXTERNAL_PROXY,
|
||||||
|
|
||||||
|
turnstileSecret: process.env.TURNSTILE_SECRET,
|
||||||
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
|
jwtLifetime: process.env.JWT_EXPIRY || 120,
|
||||||
|
|
||||||
|
enabledServices,
|
||||||
|
}
|
||||||
|
|
||||||
|
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
||||||
|
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
|
||||||
|
|
||||||
|
export {
|
||||||
|
env,
|
||||||
|
genericUserAgent,
|
||||||
|
cobaltUserAgent,
|
||||||
|
}
|
326
api/src/core/api.js
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
import cors from "cors";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
||||||
|
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
|
||||||
|
|
||||||
|
import jwt from "../security/jwt.js";
|
||||||
|
import stream from "../stream/stream.js";
|
||||||
|
import match from "../processing/match.js";
|
||||||
|
|
||||||
|
import { env } from "../config.js";
|
||||||
|
import { extract } from "../processing/url.js";
|
||||||
|
import { languageCode } from "../misc/utils.js";
|
||||||
|
import { Bright, Cyan } from "../misc/console-text.js";
|
||||||
|
import { generateHmac, generateSalt } from "../misc/crypto.js";
|
||||||
|
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||||
|
import { verifyTurnstileToken } from "../security/turnstile.js";
|
||||||
|
import { friendlyServiceName } from "../processing/service-alias.js";
|
||||||
|
import { verifyStream, getInternalStream } from "../stream/manage.js";
|
||||||
|
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
||||||
|
|
||||||
|
const git = {
|
||||||
|
branch: await getBranch(),
|
||||||
|
commit: await getCommit(),
|
||||||
|
remote: await getRemote(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await getVersion();
|
||||||
|
|
||||||
|
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
|
||||||
|
|
||||||
|
const ipSalt = generateSalt();
|
||||||
|
const corsConfig = env.corsWildcard ? {} : {
|
||||||
|
origin: env.corsURL,
|
||||||
|
optionsSuccessStatus: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (res, code, context) => {
|
||||||
|
const { status, body } = createResponse("error", { code, context });
|
||||||
|
res.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runAPI = (express, app, __dirname) => {
|
||||||
|
const startTime = new Date();
|
||||||
|
const startTimestamp = startTime.getTime();
|
||||||
|
|
||||||
|
const serverInfo = JSON.stringify({
|
||||||
|
cobalt: {
|
||||||
|
version: version,
|
||||||
|
url: env.apiURL,
|
||||||
|
startTime: `${startTimestamp}`,
|
||||||
|
durationLimit: env.durationLimit,
|
||||||
|
services: [...env.enabledServices].map(e => {
|
||||||
|
return friendlyServiceName(e);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
git,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: env.rateLimitWindow * 1000,
|
||||||
|
max: env.rateLimitMax,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: req => {
|
||||||
|
if (req.authorized) {
|
||||||
|
return generateHmac(req.header("Authorization"), ipSalt);
|
||||||
|
}
|
||||||
|
return generateHmac(getIP(req), ipSalt);
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
const { status, body } = createResponse("error", {
|
||||||
|
code: "error.api.rate_exceeded",
|
||||||
|
context: {
|
||||||
|
limit: env.rateLimitWindow
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.status(status).json(body);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiLimiterStream = rateLimit({
|
||||||
|
windowMs: env.rateLimitWindow * 1000,
|
||||||
|
max: env.rateLimitMax,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: req => generateHmac(getIP(req), ipSalt),
|
||||||
|
handler: (req, res) => {
|
||||||
|
return res.sendStatus(429)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.set('trust proxy', ['loopback', 'uniquelocal']);
|
||||||
|
|
||||||
|
app.use('/', cors({
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
exposedHeaders: [
|
||||||
|
'Ratelimit-Limit',
|
||||||
|
'Ratelimit-Policy',
|
||||||
|
'Ratelimit-Remaining',
|
||||||
|
'Ratelimit-Reset'
|
||||||
|
],
|
||||||
|
...corsConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/', apiLimiter);
|
||||||
|
app.use('/tunnel', apiLimiterStream);
|
||||||
|
|
||||||
|
app.post('/', (req, res, next) => {
|
||||||
|
if (!env.turnstileSecret || !env.jwtSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authorization = req.header("Authorization");
|
||||||
|
if (!authorization) {
|
||||||
|
return fail(res, "error.api.auth.jwt.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorization.startsWith("Bearer ") || authorization.length > 256) {
|
||||||
|
return fail(res, "error.api.auth.jwt.invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyJwt = jwt.verify(
|
||||||
|
authorization.split("Bearer ", 2)[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verifyJwt) {
|
||||||
|
return fail(res, "error.api.auth.jwt.invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acceptRegex.test(req.header('Accept'))) {
|
||||||
|
return fail(res, "error.api.header.accept");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acceptRegex.test(req.header('Content-Type'))) {
|
||||||
|
return fail(res, "error.api.header.content_type");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.authorized = true;
|
||||||
|
} catch {
|
||||||
|
return fail(res, "error.api.generic");
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/', express.json({ limit: 1024 }));
|
||||||
|
app.use('/', (err, _, res, next) => {
|
||||||
|
if (err) {
|
||||||
|
const { status, body } = createResponse("error", {
|
||||||
|
code: "error.api.invalid_body",
|
||||||
|
});
|
||||||
|
return res.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/session", async (req, res) => {
|
||||||
|
if (!env.turnstileSecret || !env.jwtSecret) {
|
||||||
|
return fail(res, "error.api.auth.not_configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnstileResponse = req.header("cf-turnstile-response");
|
||||||
|
|
||||||
|
if (!turnstileResponse) {
|
||||||
|
return fail(res, "error.api.auth.turnstile.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnstileResult = await verifyTurnstileToken(
|
||||||
|
turnstileResponse,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!turnstileResult) {
|
||||||
|
return fail(res, "error.api.auth.turnstile.invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.json(jwt.generate());
|
||||||
|
} catch {
|
||||||
|
return fail(res, "error.api.generic");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/', async (req, res) => {
|
||||||
|
const request = req.body;
|
||||||
|
const lang = languageCode(req);
|
||||||
|
|
||||||
|
if (!request.url) {
|
||||||
|
return fail(res, "error.api.link.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.youtubeDubBrowserLang) {
|
||||||
|
request.youtubeDubLang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
||||||
|
if (!success) {
|
||||||
|
return fail(res, "error.api.invalid_body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = extract(normalizedRequest.url);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return fail(res, "error.api.link.invalid");
|
||||||
|
}
|
||||||
|
if ("error" in parsed) {
|
||||||
|
let context;
|
||||||
|
if (parsed?.context) {
|
||||||
|
context = parsed.context;
|
||||||
|
}
|
||||||
|
return fail(res, `error.api.${parsed.error}`, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await match({
|
||||||
|
host: parsed.host,
|
||||||
|
patternMatch: parsed.patternMatch,
|
||||||
|
params: normalizedRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
} catch {
|
||||||
|
fail(res, "error.api.generic");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/tunnel', (req, res) => {
|
||||||
|
const id = String(req.query.id);
|
||||||
|
const exp = String(req.query.exp);
|
||||||
|
const sig = String(req.query.sig);
|
||||||
|
const sec = String(req.query.sec);
|
||||||
|
const iv = String(req.query.iv);
|
||||||
|
|
||||||
|
const checkQueries = id && exp && sig && sec && iv;
|
||||||
|
const checkBaseLength = id.length === 21 && exp.length === 13;
|
||||||
|
const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;
|
||||||
|
|
||||||
|
if (!checkQueries || !checkBaseLength || !checkSafeLength) {
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.p) {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamInfo = verifyStream(id, sig, exp, sec, iv);
|
||||||
|
if (!streamInfo?.service) {
|
||||||
|
return res.status(streamInfo.status).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.type === 'proxy') {
|
||||||
|
streamInfo.range = req.headers['range'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream(res, streamInfo);
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/itunnel', (req, res) => {
|
||||||
|
if (!req.ip.endsWith('127.0.0.1')) {
|
||||||
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(req.query.id).length !== 21) {
|
||||||
|
return res.sendStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamInfo = getInternalStream(req.query.id);
|
||||||
|
if (!streamInfo) {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamInfo.headers = new Map([
|
||||||
|
...(streamInfo.headers || []),
|
||||||
|
...Object.entries(req.headers)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return stream(res, { type: 'internal', ...streamInfo });
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', (_, res) => {
|
||||||
|
res.type('json');
|
||||||
|
res.status(200).send(serverInfo);
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/favicon.ico', (req, res) => {
|
||||||
|
res.status(404).end();
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/*', (req, res) => {
|
||||||
|
res.redirect('/');
|
||||||
|
})
|
||||||
|
|
||||||
|
// handle all express errors
|
||||||
|
app.use((_, __, res, ___) => {
|
||||||
|
return fail(res, "error.api.generic");
|
||||||
|
})
|
||||||
|
|
||||||
|
randomizeCiphers();
|
||||||
|
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, () => {
|
||||||
|
console.log(`\n` +
|
||||||
|
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
|
||||||
|
|
||||||
|
"~~~~~~\n" +
|
||||||
|
Bright("version: ") + version + "\n" +
|
||||||
|
Bright("commit: ") + git.commit + "\n" +
|
||||||
|
Bright("branch: ") + git.branch + "\n" +
|
||||||
|
Bright("remote: ") + git.remote + "\n" +
|
||||||
|
Bright("start time: ") + startTime.toUTCString() + "\n" +
|
||||||
|
"~~~~~~\n" +
|
||||||
|
|
||||||
|
Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
|
||||||
|
Bright("port: ") + env.apiPort + "\n"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
20
api/src/misc/load-from-fs.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'../../'
|
||||||
|
);
|
||||||
|
|
||||||
|
export function loadFile(path) {
|
||||||
|
return fs.readFileSync(join(root, path), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadJSON(path) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(loadFile(path))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { normalizeRequest } from "../modules/processing/request.js";
|
import { normalizeRequest } from "../processing/request.js";
|
||||||
import match from "./processing/match.js";
|
import match from "../processing/match.js";
|
||||||
import { extract } from "./processing/url.js";
|
import { extract } from "../processing/url.js";
|
||||||
|
|
||||||
export async function runTest(url, params, expect) {
|
export async function runTest(url, params, expect) {
|
||||||
const normalized = normalizeRequest({ url, ...params });
|
const { success, data: normalized } = await normalizeRequest({ url, ...params });
|
||||||
if (!normalized) {
|
if (!success) {
|
||||||
throw "invalid request";
|
throw "invalid request";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,9 +13,11 @@ export async function runTest(url, params, expect) {
|
||||||
throw `invalid url: ${normalized.url}`;
|
throw `invalid url: ${normalized.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await match(
|
const result = await match({
|
||||||
parsed.host, parsed.patternMatch, "en", normalized
|
host: parsed.host,
|
||||||
);
|
patternMatch: parsed.patternMatch,
|
||||||
|
params: normalized,
|
||||||
|
});
|
||||||
|
|
||||||
let error = [];
|
let error = [];
|
||||||
if (expect.status !== result.body.status) {
|
if (expect.status !== result.body.status) {
|
||||||
|
@ -36,7 +38,7 @@ export async function runTest(url, params, expect) {
|
||||||
throw error.join('\n');
|
throw error.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.body.status === 'stream') {
|
if (result.body.status === 'tunnel') {
|
||||||
// TODO: stream testing
|
// TODO: stream testing
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import Cookie from './cookie.js';
|
import Cookie from './cookie.js';
|
||||||
import { readFile, writeFile } from 'fs/promises';
|
import { readFile, writeFile } from 'fs/promises';
|
||||||
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
|
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
|
||||||
import { env } from '../../../modules/config.js'
|
import { env } from '../../config.js';
|
||||||
|
|
||||||
const WRITE_INTERVAL = 60000,
|
const WRITE_INTERVAL = 60000,
|
||||||
cookiePath = env.cookiePath,
|
cookiePath = env.cookiePath,
|
48
api/src/processing/create-filename.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
export default (f, style, isAudioOnly, isAudioMuted) => {
|
||||||
|
let filename = '';
|
||||||
|
|
||||||
|
let infoBase = [f.service, f.id];
|
||||||
|
|
||||||
|
let classicTags = infoBase.concat([
|
||||||
|
f.resolution,
|
||||||
|
f.youtubeFormat,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let basicTags = [f.qualityLabel, f.youtubeFormat];
|
||||||
|
|
||||||
|
const title = `${f.title} - ${f.author}`;
|
||||||
|
|
||||||
|
if (isAudioMuted) {
|
||||||
|
classicTags.push("mute");
|
||||||
|
basicTags.push("mute");
|
||||||
|
} else if (f.youtubeDubName) {
|
||||||
|
classicTags.push(f.youtubeDubName);
|
||||||
|
basicTags.push(f.youtubeDubName);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (style) {
|
||||||
|
default:
|
||||||
|
case "classic":
|
||||||
|
if (isAudioOnly) {
|
||||||
|
if (f.youtubeDubName) {
|
||||||
|
infoBase.push(f.youtubeDubName);
|
||||||
|
}
|
||||||
|
return `${infoBase.join("_")}_audio`;
|
||||||
|
}
|
||||||
|
filename = classicTags.join("_");
|
||||||
|
break;
|
||||||
|
case "basic":
|
||||||
|
if (isAudioOnly) return title;
|
||||||
|
filename = `${title} (${basicTags.join(", ")})`;
|
||||||
|
break;
|
||||||
|
case "pretty":
|
||||||
|
if (isAudioOnly) return `${title} (${infoBase[0]})`;
|
||||||
|
filename = `${title} (${[...basicTags, infoBase[0]].join(", ")})`;
|
||||||
|
break;
|
||||||
|
case "nerdy":
|
||||||
|
if (isAudioOnly) return `${title} (${infoBase.join(", ")})`;
|
||||||
|
filename = `${title} (${basicTags.concat(infoBase).join(", ")})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return `${filename}.${f.extension}`;
|
||||||
|
}
|
|
@ -1,71 +1,75 @@
|
||||||
import { audioIgnore, services, supportedAudio } from "../config.js";
|
import createFilename from "./create-filename.js";
|
||||||
import { createResponse } from "../processing/request.js";
|
|
||||||
import loc from "../../localization/manager.js";
|
import { createResponse } from "./request.js";
|
||||||
import createFilename from "./createFilename.js";
|
import { audioIgnore } from "./service-config.js";
|
||||||
import { createStream } from "../stream/manage.js";
|
import { createStream } from "../stream/manage.js";
|
||||||
|
|
||||||
export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern, toGif, requestIP) {
|
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
|
||||||
let action,
|
let action,
|
||||||
responseType = "stream",
|
responseType = "tunnel",
|
||||||
defaultParams = {
|
defaultParams = {
|
||||||
u: r.urls,
|
u: r.urls,
|
||||||
headers: r.headers,
|
headers: r.headers,
|
||||||
service: host,
|
service: host,
|
||||||
filename: r.filenameAttributes ?
|
filename: r.filenameAttributes ?
|
||||||
createFilename(r.filenameAttributes, filenamePattern, isAudioOnly, isAudioMuted) : r.filename,
|
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
|
||||||
fileMetadata: !disableMetadata ? r.fileMetadata : false,
|
fileMetadata: !disableMetadata ? r.fileMetadata : false,
|
||||||
requestIP
|
requestIP
|
||||||
},
|
},
|
||||||
params = {},
|
params = {};
|
||||||
audioFormat = String(userFormat);
|
|
||||||
|
|
||||||
if (r.isPhoto) action = "photo";
|
if (r.isPhoto) action = "photo";
|
||||||
else if (r.picker) action = "picker"
|
else if (r.picker) action = "picker"
|
||||||
else if (r.isGif && toGif) action = "gif";
|
else if (r.isGif && twitterGif) action = "gif";
|
||||||
else if (isAudioMuted) action = "muteVideo";
|
|
||||||
else if (isAudioOnly) action = "audio";
|
else if (isAudioOnly) action = "audio";
|
||||||
|
else if (isAudioMuted) action = "muteVideo";
|
||||||
else if (r.isM3U8) action = "m3u8";
|
else if (r.isM3U8) action = "m3u8";
|
||||||
else action = "video";
|
else action = "video";
|
||||||
|
|
||||||
if (action === "picker" || action === "audio") {
|
if (action === "picker" || action === "audio") {
|
||||||
if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
|
if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
|
||||||
defaultParams.isAudioOnly = true;
|
|
||||||
defaultParams.audioFormat = audioFormat;
|
defaultParams.audioFormat = audioFormat;
|
||||||
}
|
}
|
||||||
if (isAudioMuted && !r.filenameAttributes) {
|
|
||||||
defaultParams.filename = r.filename.replace('.', '_mute.')
|
if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) {
|
||||||
|
const parts = r.filename.split(".");
|
||||||
|
const ext = parts.pop();
|
||||||
|
|
||||||
|
defaultParams.filename = `${parts.join(".")}_mute.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
default:
|
default:
|
||||||
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') });
|
return createResponse("error", {
|
||||||
|
code: "error.api.fetch.empty"
|
||||||
|
});
|
||||||
|
|
||||||
case "photo":
|
case "photo":
|
||||||
responseType = "redirect";
|
responseType = "redirect";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "gif":
|
case "gif":
|
||||||
params = { type: "gif" }
|
params = { type: "gif" };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "m3u8":
|
case "m3u8":
|
||||||
params = {
|
params = {
|
||||||
type: Array.isArray(r.urls) ? "render" : "remux"
|
type: Array.isArray(r.urls) ? "merge" : "remux"
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "muteVideo":
|
case "muteVideo":
|
||||||
let muteType = "mute";
|
let muteType = "mute";
|
||||||
if (Array.isArray(r.urls) && !r.isM3U8) {
|
if (Array.isArray(r.urls) && !r.isM3U8) {
|
||||||
muteType = "bridge";
|
muteType = "proxy";
|
||||||
}
|
}
|
||||||
params = {
|
params = {
|
||||||
type: muteType,
|
type: muteType,
|
||||||
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
|
u: Array.isArray(r.urls) ? r.urls[0] : r.urls
|
||||||
mute: true
|
|
||||||
}
|
}
|
||||||
if (host === "reddit" && r.typeId === "redirect")
|
if (host === "reddit" && r.typeId === "redirect") {
|
||||||
responseType = "redirect";
|
responseType = "redirect";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "picker":
|
case "picker":
|
||||||
|
@ -74,13 +78,15 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
case "instagram":
|
case "instagram":
|
||||||
case "twitter":
|
case "twitter":
|
||||||
case "snapchat":
|
case "snapchat":
|
||||||
|
case "bsky":
|
||||||
params = { picker: r.picker };
|
params = { picker: r.picker };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tiktok":
|
case "tiktok":
|
||||||
let audioStreamType = "render";
|
let audioStreamType = "audio";
|
||||||
if (r.bestAudio === "mp3" && (audioFormat === "mp3" || audioFormat === "best")) {
|
if (r.bestAudio === "mp3" && audioFormat === "best") {
|
||||||
audioFormat = "mp3";
|
audioFormat = "mp3";
|
||||||
audioStreamType = "bridge"
|
audioStreamType = "proxy"
|
||||||
}
|
}
|
||||||
params = {
|
params = {
|
||||||
picker: r.picker,
|
picker: r.picker,
|
||||||
|
@ -92,27 +98,30 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
filename: r.audioFilename,
|
filename: r.audioFilename,
|
||||||
isAudioOnly: true,
|
isAudioOnly: true,
|
||||||
audioFormat,
|
audioFormat,
|
||||||
}),
|
})
|
||||||
copy: audioFormat === "best"
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "video":
|
case "video":
|
||||||
switch (host) {
|
switch (host) {
|
||||||
case "bilibili":
|
case "bilibili":
|
||||||
params = { type: "render" };
|
params = { type: "merge" };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "youtube":
|
case "youtube":
|
||||||
params = { type: r.type };
|
params = { type: r.type };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "reddit":
|
case "reddit":
|
||||||
responseType = r.typeId;
|
responseType = r.typeId;
|
||||||
params = { type: r.type };
|
params = { type: r.type };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vimeo":
|
case "vimeo":
|
||||||
if (Array.isArray(r.urls)) {
|
if (Array.isArray(r.urls)) {
|
||||||
params = { type: "render" }
|
params = { type: "merge" }
|
||||||
} else {
|
} else {
|
||||||
responseType = "redirect";
|
responseType = "redirect";
|
||||||
}
|
}
|
||||||
|
@ -128,7 +137,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
|
|
||||||
case "vk":
|
case "vk":
|
||||||
case "tiktok":
|
case "tiktok":
|
||||||
params = { type: "bridge" };
|
params = { type: "proxy" };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "facebook":
|
case "facebook":
|
||||||
|
@ -145,58 +154,56 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "audio":
|
case "audio":
|
||||||
if (audioIgnore.includes(host)
|
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
|
||||||
|| (host === "reddit" && r.typeId === "redirect")) {
|
return createResponse("error", {
|
||||||
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') })
|
code: "error.api.fetch.empty"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let processType = "render",
|
let processType = "audio";
|
||||||
copy = false;
|
let copy = false;
|
||||||
|
|
||||||
if (!supportedAudio.includes(audioFormat)) {
|
if (audioFormat === "best") {
|
||||||
audioFormat = "best"
|
const serviceBestAudio = r.bestAudio;
|
||||||
}
|
|
||||||
|
|
||||||
const serviceBestAudio = r.bestAudio || services[host]["bestAudio"];
|
if (serviceBestAudio) {
|
||||||
const isBestAudio = audioFormat === "best";
|
audioFormat = serviceBestAudio;
|
||||||
const isBestOrMp3 = isBestAudio || audioFormat === "mp3";
|
processType = "proxy";
|
||||||
const isBestAudioDefined = isBestAudio && serviceBestAudio;
|
|
||||||
const isBestHostAudio = serviceBestAudio && (audioFormat === serviceBestAudio);
|
|
||||||
|
|
||||||
const isTumblrAudio = host === "tumblr" && !r.filename;
|
if (host === "soundcloud") {
|
||||||
const isSoundCloud = host === "soundcloud";
|
processType = "audio";
|
||||||
const isTiktok = host === "tiktok";
|
copy = true;
|
||||||
|
}
|
||||||
if (isBestAudioDefined || isBestHostAudio) {
|
} else {
|
||||||
audioFormat = serviceBestAudio;
|
audioFormat = "m4a";
|
||||||
processType = "bridge";
|
copy = true;
|
||||||
if (isSoundCloud || (isTiktok && audioFormat === "m4a")) {
|
|
||||||
processType = "render"
|
|
||||||
copy = true
|
|
||||||
}
|
}
|
||||||
} else if (isBestAudio && !isSoundCloud) {
|
|
||||||
audioFormat = "m4a";
|
|
||||||
copy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTumblrAudio && isBestOrMp3) {
|
|
||||||
audioFormat = "mp3";
|
|
||||||
processType = "bridge"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.isM3U8 || host === "vimeo") {
|
if (r.isM3U8 || host === "vimeo") {
|
||||||
copy = false;
|
copy = false;
|
||||||
processType = "render"
|
processType = "audio";
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
type: processType,
|
type: processType,
|
||||||
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
|
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
|
||||||
audioFormat: audioFormat,
|
|
||||||
copy: copy
|
audioBitrate,
|
||||||
|
audioCopy: copy,
|
||||||
|
audioFormat,
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (defaultParams.filename && (action === "picker" || action === "audio")) {
|
||||||
|
defaultParams.filename += `.${audioFormat}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alwaysProxy && responseType === "redirect") {
|
||||||
|
responseType = "tunnel";
|
||||||
|
params.type = "proxy";
|
||||||
|
}
|
||||||
|
|
||||||
return createResponse(responseType, {...defaultParams, ...params})
|
return createResponse(responseType, {...defaultParams, ...params})
|
||||||
}
|
}
|
|
@ -1,11 +1,12 @@
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
|
|
||||||
import { env } from '../config.js';
|
import { env } from "../config.js";
|
||||||
import { createResponse } from "../processing/request.js";
|
import { createResponse } from "../processing/request.js";
|
||||||
import loc from "../../localization/manager.js";
|
|
||||||
|
|
||||||
import { testers } from "./servicesPatternTesters.js";
|
import { testers } from "./service-patterns.js";
|
||||||
import matchActionDecider from "./matchActionDecider.js";
|
import matchAction from "./match-action.js";
|
||||||
|
|
||||||
|
import { friendlyServiceName } from "./service-alias.js";
|
||||||
|
|
||||||
import bilibili from "./services/bilibili.js";
|
import bilibili from "./services/bilibili.js";
|
||||||
import reddit from "./services/reddit.js";
|
import reddit from "./services/reddit.js";
|
||||||
|
@ -27,11 +28,12 @@ import dailymotion from "./services/dailymotion.js";
|
||||||
import snapchat from "./services/snapchat.js";
|
import snapchat from "./services/snapchat.js";
|
||||||
import loom from "./services/loom.js";
|
import loom from "./services/loom.js";
|
||||||
import facebook from "./services/facebook.js";
|
import facebook from "./services/facebook.js";
|
||||||
|
import bluesky from "./services/bluesky.js";
|
||||||
|
|
||||||
let freebind;
|
let freebind;
|
||||||
|
|
||||||
export default async function(host, patternMatch, lang, obj) {
|
export default async function({ host, patternMatch, params }) {
|
||||||
const { url } = obj;
|
const { url } = params;
|
||||||
assert(url instanceof URL);
|
assert(url instanceof URL);
|
||||||
let dispatcher, requestIP;
|
let dispatcher, requestIP;
|
||||||
|
|
||||||
|
@ -46,17 +48,20 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let r,
|
let r,
|
||||||
isAudioOnly = !!obj.isAudioOnly,
|
isAudioOnly = params.downloadMode === "audio",
|
||||||
disableMetadata = !!obj.disableMetadata;
|
isAudioMuted = params.downloadMode === "mute";
|
||||||
|
|
||||||
if (!testers[host]) {
|
if (!testers[host]) {
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
t: loc(lang, 'ErrorUnsupported')
|
code: "error.api.service.unsupported"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!(testers[host](patternMatch))) {
|
if (!(testers[host](patternMatch))) {
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
t: loc(lang, 'ErrorBrokenLink', host)
|
code: "error.api.link.unsupported",
|
||||||
|
context: {
|
||||||
|
service: friendlyServiceName(host),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,45 +70,52 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
r = await twitter({
|
r = await twitter({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
index: patternMatch.index - 1,
|
index: patternMatch.index - 1,
|
||||||
toGif: !!obj.twitterGif,
|
toGif: !!params.twitterGif,
|
||||||
|
alwaysProxy: params.alwaysProxy,
|
||||||
dispatcher
|
dispatcher
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vk":
|
case "vk":
|
||||||
r = await vk({
|
r = await vk({
|
||||||
userId: patternMatch.userId,
|
userId: patternMatch.userId,
|
||||||
videoId: patternMatch.videoId,
|
videoId: patternMatch.videoId,
|
||||||
quality: obj.vQuality
|
quality: params.videoQuality
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ok":
|
case "ok":
|
||||||
r = await ok({
|
r = await ok({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
quality: obj.vQuality
|
quality: params.videoQuality
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "bilibili":
|
case "bilibili":
|
||||||
r = await bilibili(patternMatch);
|
r = await bilibili(patternMatch);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "youtube":
|
case "youtube":
|
||||||
let fetchInfo = {
|
let fetchInfo = {
|
||||||
id: patternMatch.id.slice(0, 11),
|
id: patternMatch.id.slice(0, 11),
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
format: obj.vCodec,
|
format: params.youtubeVideoCodec,
|
||||||
isAudioOnly: isAudioOnly,
|
isAudioOnly,
|
||||||
isAudioMuted: obj.isAudioMuted,
|
isAudioMuted,
|
||||||
dubLang: obj.dubLang,
|
dubLang: params.youtubeDubLang,
|
||||||
dispatcher
|
dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.hostname === 'music.youtube.com' || isAudioOnly === true) {
|
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
||||||
fetchInfo.quality = "max";
|
fetchInfo.quality = "max";
|
||||||
fetchInfo.format = "vp9";
|
fetchInfo.format = "vp9";
|
||||||
fetchInfo.isAudioOnly = true
|
fetchInfo.isAudioOnly = true;
|
||||||
|
fetchInfo.isAudioMuted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
r = await youtube(fetchInfo);
|
r = await youtube(fetchInfo);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "reddit":
|
case "reddit":
|
||||||
r = await reddit({
|
r = await reddit({
|
||||||
sub: patternMatch.sub,
|
sub: patternMatch.sub,
|
||||||
|
@ -111,15 +123,18 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
user: patternMatch.user
|
user: patternMatch.user
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tiktok":
|
case "tiktok":
|
||||||
r = await tiktok({
|
r = await tiktok({
|
||||||
postId: patternMatch.postId,
|
postId: patternMatch.postId,
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
fullAudio: obj.isTTFullAudio,
|
fullAudio: params.tiktokFullAudio,
|
||||||
isAudioOnly: isAudioOnly,
|
isAudioOnly,
|
||||||
h265: obj.tiktokH265
|
h265: params.tiktokH265,
|
||||||
|
alwaysProxy: params.alwaysProxy,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tumblr":
|
case "tumblr":
|
||||||
r = await tumblr({
|
r = await tumblr({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
|
@ -127,116 +142,169 @@ export default async function(host, patternMatch, lang, obj) {
|
||||||
url
|
url
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vimeo":
|
case "vimeo":
|
||||||
r = await vimeo({
|
r = await vimeo({
|
||||||
id: patternMatch.id.slice(0, 11),
|
id: patternMatch.id.slice(0, 11),
|
||||||
password: patternMatch.password,
|
password: patternMatch.password,
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
isAudioOnly: isAudioOnly
|
isAudioOnly,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "soundcloud":
|
case "soundcloud":
|
||||||
isAudioOnly = true;
|
isAudioOnly = true;
|
||||||
|
isAudioMuted = false;
|
||||||
r = await soundcloud({
|
r = await soundcloud({
|
||||||
url,
|
url,
|
||||||
author: patternMatch.author,
|
author: patternMatch.author,
|
||||||
song: patternMatch.song,
|
song: patternMatch.song,
|
||||||
format: obj.aFormat,
|
format: params.audioFormat,
|
||||||
shortLink: patternMatch.shortLink || false,
|
shortLink: patternMatch.shortLink || false,
|
||||||
accessKey: patternMatch.accessKey || false
|
accessKey: patternMatch.accessKey || false
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "instagram":
|
case "instagram":
|
||||||
r = await instagram({
|
r = await instagram({
|
||||||
...patternMatch,
|
...patternMatch,
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
|
alwaysProxy: params.alwaysProxy,
|
||||||
dispatcher
|
dispatcher
|
||||||
})
|
})
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vine":
|
case "vine":
|
||||||
r = await vine({
|
r = await vine({
|
||||||
id: patternMatch.id
|
id: patternMatch.id
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "pinterest":
|
case "pinterest":
|
||||||
r = await pinterest({
|
r = await pinterest({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
shortLink: patternMatch.shortLink || false
|
shortLink: patternMatch.shortLink || false
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "streamable":
|
case "streamable":
|
||||||
r = await streamable({
|
r = await streamable({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
isAudioOnly: isAudioOnly,
|
isAudioOnly,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "twitch":
|
case "twitch":
|
||||||
r = await twitch({
|
r = await twitch({
|
||||||
clipId: patternMatch.clip || false,
|
clipId: patternMatch.clip || false,
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
isAudioOnly: obj.isAudioOnly
|
isAudioOnly,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "rutube":
|
case "rutube":
|
||||||
r = await rutube({
|
r = await rutube({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
yappyId: patternMatch.yappyId,
|
yappyId: patternMatch.yappyId,
|
||||||
key: patternMatch.key,
|
key: patternMatch.key,
|
||||||
quality: obj.vQuality,
|
quality: params.videoQuality,
|
||||||
isAudioOnly: isAudioOnly
|
isAudioOnly,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "dailymotion":
|
case "dailymotion":
|
||||||
r = await dailymotion(patternMatch);
|
r = await dailymotion(patternMatch);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "snapchat":
|
case "snapchat":
|
||||||
r = await snapchat({
|
r = await snapchat({
|
||||||
hostname: url.hostname,
|
...patternMatch,
|
||||||
...patternMatch
|
alwaysProxy: params.alwaysProxy,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "loom":
|
case "loom":
|
||||||
r = await loom({
|
r = await loom({
|
||||||
id: patternMatch.id
|
id: patternMatch.id
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "facebook":
|
case "facebook":
|
||||||
r = await facebook({
|
r = await facebook({
|
||||||
...patternMatch
|
...patternMatch
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "bsky":
|
||||||
|
r = await bluesky({
|
||||||
|
...patternMatch,
|
||||||
|
alwaysProxy: params.alwaysProxy
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
t: loc(lang, 'ErrorUnsupported')
|
code: "error.api.service.unsupported"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.isAudioOnly) isAudioOnly = true;
|
if (r.isAudioOnly) {
|
||||||
let isAudioMuted = isAudioOnly ? false : obj.isAudioMuted;
|
isAudioOnly = true;
|
||||||
|
isAudioMuted = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (r.error && r.critical) {
|
if (r.error && r.critical) {
|
||||||
return createResponse("critical", {
|
return createResponse("critical", {
|
||||||
t: loc(lang, r.error)
|
code: `error.api.${r.error}`,
|
||||||
})
|
|
||||||
}
|
|
||||||
if (r.error) {
|
|
||||||
return createResponse("error", {
|
|
||||||
t: Array.isArray(r.error)
|
|
||||||
? loc(lang, r.error[0], r.error[1])
|
|
||||||
: loc(lang, r.error)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchActionDecider(
|
if (r.error) {
|
||||||
r, host, obj.aFormat, isAudioOnly,
|
let context;
|
||||||
lang, isAudioMuted, disableMetadata,
|
switch(r.error) {
|
||||||
obj.filenamePattern, obj.twitterGif,
|
case "content.too_long":
|
||||||
requestIP
|
context = {
|
||||||
)
|
limit: env.durationLimit / 60,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fetch.fail":
|
||||||
|
case "fetch.rate":
|
||||||
|
case "fetch.critical":
|
||||||
|
case "link.unsupported":
|
||||||
|
case "content.video.unavailable":
|
||||||
|
context = {
|
||||||
|
service: friendlyServiceName(host),
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createResponse("error", {
|
||||||
|
code: `error.api.${r.error}`,
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchAction({
|
||||||
|
r,
|
||||||
|
host,
|
||||||
|
audioFormat: params.audioFormat,
|
||||||
|
isAudioOnly,
|
||||||
|
isAudioMuted,
|
||||||
|
disableMetadata: params.disableMetadata,
|
||||||
|
filenameStyle: params.filenameStyle,
|
||||||
|
twitterGif: params.twitterGif,
|
||||||
|
requestIP,
|
||||||
|
audioBitrate: params.audioBitrate,
|
||||||
|
alwaysProxy: params.alwaysProxy,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
t: loc(lang, 'ErrorBadFetch', host)
|
code: "error.api.fetch.critical",
|
||||||
|
context: {
|
||||||
|
service: friendlyServiceName(host),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
97
api/src/processing/request.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import ipaddr from "ipaddr.js";
|
||||||
|
|
||||||
|
import { createStream } from "../stream/manage.js";
|
||||||
|
import { apiSchema } from "./schema.js";
|
||||||
|
|
||||||
|
export function createResponse(responseType, responseData) {
|
||||||
|
const internalError = (code) => {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
status: "error",
|
||||||
|
error: {
|
||||||
|
code: code || "error.api.fetch.critical",
|
||||||
|
},
|
||||||
|
critical: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let status = 200,
|
||||||
|
response = {};
|
||||||
|
|
||||||
|
if (responseType === "error") {
|
||||||
|
status = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (responseType) {
|
||||||
|
case "error":
|
||||||
|
response = {
|
||||||
|
error: {
|
||||||
|
code: responseData?.code,
|
||||||
|
context: responseData?.context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "redirect":
|
||||||
|
response = {
|
||||||
|
url: responseData?.u,
|
||||||
|
filename: responseData?.filename
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tunnel":
|
||||||
|
response = {
|
||||||
|
url: createStream(responseData),
|
||||||
|
filename: responseData?.filename
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "picker":
|
||||||
|
response = {
|
||||||
|
picker: responseData?.picker,
|
||||||
|
audio: responseData?.u,
|
||||||
|
audioFilename: responseData?.filename
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "critical":
|
||||||
|
return internalError(responseData?.code);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw "unreachable"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
status: responseType,
|
||||||
|
...response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return internalError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequest(request) {
|
||||||
|
return apiSchema.safeParseAsync(request).catch(() => (
|
||||||
|
{ success: false }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIP(req) {
|
||||||
|
const strippedIP = req.ip.replace(/^::ffff:/, '');
|
||||||
|
const ip = ipaddr.parse(strippedIP);
|
||||||
|
if (ip.kind() === 'ipv4') {
|
||||||
|
return strippedIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = 56;
|
||||||
|
const v6Bytes = ip.toByteArray();
|
||||||
|
v6Bytes.fill(0, prefix / 8);
|
||||||
|
|
||||||
|
return ipaddr.fromByteArray(v6Bytes).toString();
|
||||||
|
}
|
47
api/src/processing/schema.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { normalizeURL } from "./url.js";
|
||||||
|
import { verifyLanguageCode } from "../misc/utils.js";
|
||||||
|
|
||||||
|
export const apiSchema = z.object({
|
||||||
|
url: z.string()
|
||||||
|
.min(1)
|
||||||
|
.transform(url => normalizeURL(url)),
|
||||||
|
|
||||||
|
audioBitrate: z.enum(
|
||||||
|
["320", "256", "128", "96", "64", "8"]
|
||||||
|
).default("128"),
|
||||||
|
|
||||||
|
audioFormat: z.enum(
|
||||||
|
["best", "mp3", "ogg", "wav", "opus"]
|
||||||
|
).default("mp3"),
|
||||||
|
|
||||||
|
downloadMode: z.enum(
|
||||||
|
["auto", "audio", "mute"]
|
||||||
|
).default("auto"),
|
||||||
|
|
||||||
|
filenameStyle: z.enum(
|
||||||
|
["classic", "pretty", "basic", "nerdy"]
|
||||||
|
).default("classic"),
|
||||||
|
|
||||||
|
youtubeVideoCodec: z.enum(
|
||||||
|
["h264", "av1", "vp9"]
|
||||||
|
).default("h264"),
|
||||||
|
|
||||||
|
videoQuality: z.enum(
|
||||||
|
["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
|
||||||
|
).default("1080"),
|
||||||
|
|
||||||
|
youtubeDubLang: z.string()
|
||||||
|
.length(2)
|
||||||
|
.transform(verifyLanguageCode)
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
alwaysProxy: z.boolean().default(false),
|
||||||
|
disableMetadata: z.boolean().default(false),
|
||||||
|
tiktokFullAudio: z.boolean().default(false),
|
||||||
|
tiktokH265: z.boolean().default(false),
|
||||||
|
twitterGif: z.boolean().default(true),
|
||||||
|
youtubeDubBrowserLang: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict();
|
10
api/src/processing/service-alias.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
const friendlyNames = {
|
||||||
|
bsky: "bluesky",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const friendlyServiceName = (service) => {
|
||||||
|
if (service in friendlyNames) {
|
||||||
|
return friendlyNames[service];
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
}
|
182
api/src/processing/service-config.js
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import UrlPattern from "url-pattern";
|
||||||
|
|
||||||
|
export const audioIgnore = ["vk", "ok", "loom"];
|
||||||
|
export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"];
|
||||||
|
|
||||||
|
export const services = {
|
||||||
|
bilibili: {
|
||||||
|
patterns: [
|
||||||
|
"video/:comId",
|
||||||
|
"_shortLink/:comShortLink",
|
||||||
|
"_tv/:lang/video/:tvId",
|
||||||
|
"_tv/video/:tvId"
|
||||||
|
],
|
||||||
|
subdomains: ["m"],
|
||||||
|
},
|
||||||
|
bsky: {
|
||||||
|
patterns: [
|
||||||
|
"profile/:user/post/:post"
|
||||||
|
],
|
||||||
|
tld: "app",
|
||||||
|
},
|
||||||
|
dailymotion: {
|
||||||
|
patterns: ["video/:id"],
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
patterns: [
|
||||||
|
"_shortLink/:shortLink",
|
||||||
|
":username/videos/:caption/:id",
|
||||||
|
":username/videos/:id",
|
||||||
|
"reel/:id",
|
||||||
|
"share/:shareType/:id"
|
||||||
|
],
|
||||||
|
subdomains: ["web"],
|
||||||
|
altDomains: ["fb.watch"],
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
patterns: [
|
||||||
|
"reels/:postId",
|
||||||
|
":username/reel/:postId",
|
||||||
|
"reel/:postId",
|
||||||
|
"p/:postId",
|
||||||
|
":username/p/:postId",
|
||||||
|
"tv/:postId",
|
||||||
|
"stories/:username/:storyId"
|
||||||
|
],
|
||||||
|
altDomains: ["ddinstagram.com"],
|
||||||
|
},
|
||||||
|
loom: {
|
||||||
|
patterns: ["share/:id"],
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
patterns: [
|
||||||
|
"video/:id",
|
||||||
|
"videoembed/:id"
|
||||||
|
],
|
||||||
|
tld: "ru",
|
||||||
|
},
|
||||||
|
pinterest: {
|
||||||
|
patterns: [
|
||||||
|
"pin/:id",
|
||||||
|
"pin/:id/:garbage",
|
||||||
|
"url_shortener/:shortLink"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reddit: {
|
||||||
|
patterns: [
|
||||||
|
"r/:sub/comments/:id/:title",
|
||||||
|
"user/:user/comments/:id/:title"
|
||||||
|
],
|
||||||
|
subdomains: "*",
|
||||||
|
},
|
||||||
|
rutube: {
|
||||||
|
patterns: [
|
||||||
|
"video/:id",
|
||||||
|
"play/embed/:id",
|
||||||
|
"shorts/:id",
|
||||||
|
"yappy/:yappyId",
|
||||||
|
"video/private/:id?p=:key",
|
||||||
|
"video/private/:id"
|
||||||
|
],
|
||||||
|
tld: "ru",
|
||||||
|
},
|
||||||
|
snapchat: {
|
||||||
|
patterns: [
|
||||||
|
":shortLink",
|
||||||
|
"spotlight/:spotlightId",
|
||||||
|
"add/:username/:storyId",
|
||||||
|
"u/:username/:storyId",
|
||||||
|
"add/:username",
|
||||||
|
"u/:username",
|
||||||
|
"t/:shortLink",
|
||||||
|
],
|
||||||
|
subdomains: ["t", "story"],
|
||||||
|
},
|
||||||
|
soundcloud: {
|
||||||
|
patterns: [
|
||||||
|
":author/:song/s-:accessKey",
|
||||||
|
":author/:song",
|
||||||
|
":shortLink"
|
||||||
|
],
|
||||||
|
subdomains: ["on", "m"],
|
||||||
|
},
|
||||||
|
streamable: {
|
||||||
|
patterns: [
|
||||||
|
":id",
|
||||||
|
"o/:id",
|
||||||
|
"e/:id",
|
||||||
|
"s/:id"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
patterns: [
|
||||||
|
":user/video/:postId",
|
||||||
|
":id",
|
||||||
|
"t/:id",
|
||||||
|
":user/photo/:postId",
|
||||||
|
"v/:id.html"
|
||||||
|
],
|
||||||
|
subdomains: ["vt", "vm", "m"],
|
||||||
|
},
|
||||||
|
tumblr: {
|
||||||
|
patterns: [
|
||||||
|
"post/:id",
|
||||||
|
"blog/view/:user/:id",
|
||||||
|
":user/:id",
|
||||||
|
":user/:id/:trackingId"
|
||||||
|
],
|
||||||
|
subdomains: "*",
|
||||||
|
},
|
||||||
|
twitch: {
|
||||||
|
patterns: [":channel/clip/:clip"],
|
||||||
|
tld: "tv",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
patterns: [
|
||||||
|
":user/status/:id",
|
||||||
|
":user/status/:id/video/:index",
|
||||||
|
":user/status/:id/photo/:index",
|
||||||
|
":user/status/:id/mediaviewer",
|
||||||
|
":user/status/:id/mediaViewer"
|
||||||
|
],
|
||||||
|
subdomains: ["mobile"],
|
||||||
|
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
|
||||||
|
},
|
||||||
|
vine: {
|
||||||
|
patterns: ["v/:id"],
|
||||||
|
tld: "co",
|
||||||
|
},
|
||||||
|
vimeo: {
|
||||||
|
patterns: [
|
||||||
|
":id",
|
||||||
|
"video/:id",
|
||||||
|
":id/:password",
|
||||||
|
"/channels/:user/:id"
|
||||||
|
],
|
||||||
|
subdomains: ["player"],
|
||||||
|
},
|
||||||
|
vk: {
|
||||||
|
patterns: [
|
||||||
|
"video:userId_:videoId",
|
||||||
|
"clip:userId_:videoId",
|
||||||
|
"clips:duplicate?z=clip:userId_:videoId"
|
||||||
|
],
|
||||||
|
subdomains: ["m"],
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
patterns: [
|
||||||
|
"watch?v=:id",
|
||||||
|
"embed/:id",
|
||||||
|
"watch/:id"
|
||||||
|
],
|
||||||
|
subdomains: ["music", "m"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.values(services).forEach(service => {
|
||||||
|
service.patterns = service.patterns.map(
|
||||||
|
pattern => new UrlPattern(pattern, {
|
||||||
|
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
76
api/src/processing/service-patterns.js
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
export const testers = {
|
||||||
|
"bilibili": pattern =>
|
||||||
|
pattern.comId?.length <= 12 || pattern.comShortLink?.length <= 16
|
||||||
|
|| pattern.tvId?.length <= 24,
|
||||||
|
|
||||||
|
"dailymotion": pattern => pattern.id?.length <= 32,
|
||||||
|
|
||||||
|
"instagram": pattern =>
|
||||||
|
pattern.postId?.length <= 12
|
||||||
|
|| (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
|
||||||
|
|
||||||
|
"loom": pattern =>
|
||||||
|
pattern.id?.length <= 32,
|
||||||
|
|
||||||
|
"ok": pattern =>
|
||||||
|
pattern.id?.length <= 16,
|
||||||
|
|
||||||
|
"pinterest": pattern =>
|
||||||
|
pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
|
||||||
|
|
||||||
|
"reddit": pattern =>
|
||||||
|
(pattern.sub?.length <= 22 && pattern.id?.length <= 10)
|
||||||
|
|| (pattern.user?.length <= 22 && pattern.id?.length <= 10),
|
||||||
|
|
||||||
|
"rutube": pattern =>
|
||||||
|
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
|
||||||
|
pattern.id?.length === 32 || pattern.yappyId?.length === 32,
|
||||||
|
|
||||||
|
"soundcloud": pattern =>
|
||||||
|
(pattern.author?.length <= 255 && pattern.song?.length <= 255)
|
||||||
|
|| pattern.shortLink?.length <= 32,
|
||||||
|
|
||||||
|
"snapchat": pattern =>
|
||||||
|
(pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255))
|
||||||
|
|| pattern.spotlightId?.length <= 255
|
||||||
|
|| pattern.shortLink?.length <= 16,
|
||||||
|
|
||||||
|
"streamable": pattern =>
|
||||||
|
pattern.id?.length === 6,
|
||||||
|
|
||||||
|
"tiktok": pattern =>
|
||||||
|
pattern.postId?.length <= 21 || pattern.id?.length <= 13,
|
||||||
|
|
||||||
|
"tumblr": pattern =>
|
||||||
|
pattern.id?.length < 21
|
||||||
|
|| (pattern.id?.length < 21 && pattern.user?.length <= 32),
|
||||||
|
|
||||||
|
"twitch": pattern =>
|
||||||
|
pattern.channel && pattern.clip?.length <= 100,
|
||||||
|
|
||||||
|
"twitter": pattern =>
|
||||||
|
pattern.id?.length < 20,
|
||||||
|
|
||||||
|
"vimeo": pattern =>
|
||||||
|
pattern.id?.length <= 11
|
||||||
|
&& (!pattern.password || pattern.password.length < 16),
|
||||||
|
|
||||||
|
"vine": pattern =>
|
||||||
|
pattern.id?.length <= 12,
|
||||||
|
|
||||||
|
"vk": pattern =>
|
||||||
|
pattern.userId?.length <= 10 && pattern.videoId?.length <= 10,
|
||||||
|
|
||||||
|
"youtube": pattern =>
|
||||||
|
pattern.id?.length <= 11,
|
||||||
|
|
||||||
|
"facebook": pattern =>
|
||||||
|
pattern.shortLink?.length <= 11
|
||||||
|
|| pattern.username?.length <= 30
|
||||||
|
|| pattern.caption?.length <= 255
|
||||||
|
|| pattern.id?.length <= 20 && !pattern.shareType
|
||||||
|
|| pattern.id?.length <= 20 && pattern.shareType?.length === 1,
|
||||||
|
|
||||||
|
"bsky": pattern =>
|
||||||
|
pattern.user?.length <= 128 && pattern.post?.length <= 128,
|
||||||
|
}
|
|
@ -30,22 +30,29 @@ function extractBestQuality(dashData) {
|
||||||
|
|
||||||
async function com_download(id) {
|
async function com_download(id) {
|
||||||
let html = await fetch(`https://bilibili.com/video/${id}`, {
|
let html = await fetch(`https://bilibili.com/video/${id}`, {
|
||||||
headers: { "user-agent": genericUserAgent }
|
headers: {
|
||||||
}).then(r => r.text()).catch(() => {});
|
"user-agent": genericUserAgent
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
}
|
||||||
|
})
|
||||||
|
.then(r => r.text())
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
return { error: "fetch.fail" }
|
||||||
|
}
|
||||||
|
|
||||||
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
|
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
|
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
|
||||||
if (streamData.data.timelength > env.durationLimit * 1000) {
|
if (streamData.data.timelength > env.durationLimit * 1000) {
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ video, audio ] = extractBestQuality(streamData.data.dash);
|
const [ video, audio ] = extractBestQuality(streamData.data.dash);
|
||||||
if (!video || !audio) {
|
if (!video || !audio) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -66,7 +73,7 @@ async function tv_download(id) {
|
||||||
|
|
||||||
const { data } = await fetch(url).then(a => a.json());
|
const { data } = await fetch(url).then(a => a.json());
|
||||||
if (!data?.playurl?.video) {
|
if (!data?.playurl?.video) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ video, audio ] = extractBestQuality({
|
const [ video, audio ] = extractBestQuality({
|
||||||
|
@ -76,11 +83,11 @@ async function tv_download(id) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!video || !audio) {
|
if (!video || !audio) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (video.duration > env.durationLimit * 1000) {
|
if (video.duration > env.durationLimit * 1000) {
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -101,5 +108,5 @@ export default async function({ comId, tvId, comShortLink }) {
|
||||||
return tv_download(tvId);
|
return tv_download(tvId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
93
api/src/processing/services/bluesky.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import HLS from "hls-parser";
|
||||||
|
import { cobaltUserAgent } from "../../config.js";
|
||||||
|
import { createStream } from "../../stream/manage.js";
|
||||||
|
|
||||||
|
const extractVideo = async ({ getPost, filename }) => {
|
||||||
|
const urlMasterHLS = getPost?.thread?.post?.embed?.playlist;
|
||||||
|
if (!urlMasterHLS) return { error: "fetch.empty" };
|
||||||
|
if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
|
||||||
|
|
||||||
|
const masterHLS = await fetch(urlMasterHLS)
|
||||||
|
.then(r => {
|
||||||
|
if (r.status !== 200) return;
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (!masterHLS) return { error: "fetch.empty" };
|
||||||
|
|
||||||
|
const video = HLS.parse(masterHLS)
|
||||||
|
?.variants
|
||||||
|
?.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
|
||||||
|
|
||||||
|
const videoURL = new URL(video.uri, urlMasterHLS).toString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: videoURL,
|
||||||
|
filename: `${filename}.mp4`,
|
||||||
|
audioFilename: `${filename}_audio`,
|
||||||
|
isM3U8: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractImages = ({ getPost, filename, alwaysProxy }) => {
|
||||||
|
const images = getPost?.thread?.post?.embed?.images;
|
||||||
|
|
||||||
|
if (!images || images.length === 0) {
|
||||||
|
return { error: "fetch.empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images.length === 1) return {
|
||||||
|
urls: images[0].fullsize,
|
||||||
|
isPhoto: true,
|
||||||
|
filename: `${filename}.jpg`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const picker = images.map((image, i) => {
|
||||||
|
let url = image.fullsize;
|
||||||
|
let proxiedImage = createStream({
|
||||||
|
service: "bluesky",
|
||||||
|
type: "proxy",
|
||||||
|
u: url,
|
||||||
|
filename: `${filename}_${i + 1}.jpg`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alwaysProxy) url = proxiedImage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "photo",
|
||||||
|
url,
|
||||||
|
thumb: proxiedImage,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { picker };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ({ user, post, alwaysProxy }) {
|
||||||
|
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
|
||||||
|
apiEndpoint.searchParams.set(
|
||||||
|
"uri",
|
||||||
|
`at://${user}/app.bsky.feed.post/${post}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPost = await fetch(apiEndpoint, {
|
||||||
|
headers: {
|
||||||
|
"user-agent": cobaltUserAgent
|
||||||
|
}
|
||||||
|
}).then(r => r.json()).catch(() => {});
|
||||||
|
|
||||||
|
if (!getPost || getPost?.error) return { error: "fetch.empty" };
|
||||||
|
|
||||||
|
const embedType = getPost?.thread?.post?.embed?.$type;
|
||||||
|
const filename = `bluesky_${user}_${post}`;
|
||||||
|
|
||||||
|
if (embedType === "app.bsky.embed.video#view") {
|
||||||
|
return extractVideo({ getPost, filename });
|
||||||
|
}
|
||||||
|
if (embedType === "app.bsky.embed.images#view") {
|
||||||
|
return extractImages({ getPost, filename, alwaysProxy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: "fetch.empty" };
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import HLSParser from 'hls-parser';
|
import HLSParser from "hls-parser";
|
||||||
import { env } from '../../config.js';
|
import { env } from "../../config.js";
|
||||||
|
|
||||||
let _token;
|
let _token;
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ const getToken = async () => {
|
||||||
|
|
||||||
export default async function({ id }) {
|
export default async function({ id }) {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
if (!token) return { error: 'ErrorSomethingWentWrong' };
|
if (!token) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const req = await fetch('https://graphql.api.dailymotion.com/',
|
const req = await fetch('https://graphql.api.dailymotion.com/',
|
||||||
{
|
{
|
||||||
|
@ -70,20 +70,20 @@ export default async function({ id }) {
|
||||||
const media = req?.data?.media;
|
const media = req?.data?.media;
|
||||||
|
|
||||||
if (media?.__typename !== 'Video' || !media.hlsURL) {
|
if (media?.__typename !== 'Video' || !media.hlsURL) {
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.duration > env.durationLimit) {
|
if (media.duration > env.durationLimit) {
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
|
const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
|
||||||
if (!manifest) return { error: 'ErrorSomethingWentWrong' };
|
if (!manifest) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const bestQuality = HLSParser.parse(manifest).variants
|
const bestQuality = HLSParser.parse(manifest).variants
|
||||||
.filter(v => v.codecs.includes('avc1'))
|
.filter(v => v.codecs.includes('avc1'))
|
||||||
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
|
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
|
||||||
if (!bestQuality) return { error: 'ErrorEmptyDownload' }
|
if (!bestQuality) return { error: "fetch.empty" }
|
||||||
|
|
||||||
const fileMetadata = {
|
const fileMetadata = {
|
||||||
title: media.title,
|
title: media.title,
|
|
@ -33,7 +33,8 @@ export default async function({ id, shareType, shortLink }) {
|
||||||
.then(r => r.text())
|
.then(r => r.text())
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html && shortLink) return { error: "fetch.short_link" }
|
||||||
|
if (!html) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const urls = [];
|
const urls = [];
|
||||||
const hd = html.match('"browser_native_hd_url":(".*?")');
|
const hd = html.match('"browser_native_hd_url":(".*?")');
|
||||||
|
@ -43,7 +44,7 @@ export default async function({ id, shareType, shortLink }) {
|
||||||
if (sd?.[1]) urls.push(JSON.parse(sd[1]));
|
if (sd?.[1]) urls.push(JSON.parse(sd[1]));
|
||||||
|
|
||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseFilename = `facebook_${id || shortLink}`;
|
const baseFilename = `facebook_${id || shortLink}`;
|
|
@ -1,5 +1,5 @@
|
||||||
import { createStream } from "../../stream/manage.js";
|
|
||||||
import { genericUserAgent } from "../../config.js";
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
import { createStream } from "../../stream/manage.js";
|
||||||
import { getCookie, updateCookie } from "../cookie/manager.js";
|
import { getCookie, updateCookie } from "../cookie/manager.js";
|
||||||
|
|
||||||
const commonHeaders = {
|
const commonHeaders = {
|
||||||
|
@ -163,23 +163,34 @@ export default function(obj) {
|
||||||
?.[0];
|
?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOldPost(data, id) {
|
function extractOldPost(data, id, alwaysProxy) {
|
||||||
const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
|
const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
|
||||||
if (sidecar) {
|
if (sidecar) {
|
||||||
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
||||||
.map(e => {
|
.map((e, i) => {
|
||||||
const type = e.node?.is_video ? "video" : "photo";
|
const type = e.node?.is_video ? "video" : "photo";
|
||||||
const url = type === "video" ? e.node?.video_url : e.node?.display_url;
|
const url = type === "video" ? e.node?.video_url : e.node?.display_url;
|
||||||
|
|
||||||
|
let itemExt = type === "video" ? "mp4" : "jpg";
|
||||||
|
|
||||||
|
let proxyFile;
|
||||||
|
if (alwaysProxy) proxyFile = createStream({
|
||||||
|
service: "instagram",
|
||||||
|
type: "proxy",
|
||||||
|
u: url,
|
||||||
|
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type, url,
|
type,
|
||||||
|
url: proxyFile || url,
|
||||||
/* thumbnails have `Cross-Origin-Resource-Policy`
|
/* thumbnails have `Cross-Origin-Resource-Policy`
|
||||||
** set to `same-origin`, so we need to proxy them */
|
** set to `same-origin`, so we need to proxy them */
|
||||||
thumb: createStream({
|
thumb: createStream({
|
||||||
service: "instagram",
|
service: "instagram",
|
||||||
type: "default",
|
type: "proxy",
|
||||||
u: e.node?.display_url,
|
u: e.node?.display_url,
|
||||||
filename: "image.jpg"
|
filename: `instagram_${id}_${i + 1}.jpg`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -199,29 +210,40 @@ export default function(obj) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractNewPost(data, id) {
|
function extractNewPost(data, id, alwaysProxy) {
|
||||||
const carousel = data.carousel_media;
|
const carousel = data.carousel_media;
|
||||||
if (carousel) {
|
if (carousel) {
|
||||||
const picker = carousel.filter(e => e?.image_versions2)
|
const picker = carousel.filter(e => e?.image_versions2)
|
||||||
.map(e => {
|
.map((e, i) => {
|
||||||
const type = e.video_versions ? "video" : "photo";
|
const type = e.video_versions ? "video" : "photo";
|
||||||
const imageUrl = e.image_versions2.candidates[0].url;
|
const imageUrl = e.image_versions2.candidates[0].url;
|
||||||
|
|
||||||
let url = imageUrl;
|
let url = imageUrl;
|
||||||
if (type === 'video') {
|
let itemExt = type === "video" ? "mp4" : "jpg";
|
||||||
|
|
||||||
|
if (type === "video") {
|
||||||
const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a);
|
const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a);
|
||||||
url = video.url;
|
url = video.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let proxyFile;
|
||||||
|
if (alwaysProxy) proxyFile = createStream({
|
||||||
|
service: "instagram",
|
||||||
|
type: "proxy",
|
||||||
|
u: url,
|
||||||
|
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type, url,
|
type,
|
||||||
|
url: proxyFile || url,
|
||||||
/* thumbnails have `Cross-Origin-Resource-Policy`
|
/* thumbnails have `Cross-Origin-Resource-Policy`
|
||||||
** set to `same-origin`, so we need to proxy them */
|
** set to `same-origin`, so we need to always proxy them */
|
||||||
thumb: createStream({
|
thumb: createStream({
|
||||||
service: "instagram",
|
service: "instagram",
|
||||||
type: "default",
|
type: "proxy",
|
||||||
u: imageUrl,
|
u: imageUrl,
|
||||||
filename: "image.jpg"
|
filename: `instagram_${id}_${i + 1}.jpg`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -237,12 +259,13 @@ export default function(obj) {
|
||||||
} else if (data.image_versions2?.candidates) {
|
} else if (data.image_versions2?.candidates) {
|
||||||
return {
|
return {
|
||||||
urls: data.image_versions2.candidates[0].url,
|
urls: data.image_versions2.candidates[0].url,
|
||||||
isPhoto: true
|
isPhoto: true,
|
||||||
|
filename: `instagram_${id}.jpg`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPost(id) {
|
async function getPost(id, alwaysProxy) {
|
||||||
let data, result;
|
let data, result;
|
||||||
try {
|
try {
|
||||||
const cookie = getCookie('instagram');
|
const cookie = getCookie('instagram');
|
||||||
|
@ -271,16 +294,16 @@ export default function(obj) {
|
||||||
if (!data && cookie) data = await requestGQL(id, cookie);
|
if (!data && cookie) data = await requestGQL(id, cookie);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (!data) return { error: 'ErrorCouldntFetch' };
|
if (!data) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (data?.gql_data) {
|
if (data?.gql_data) {
|
||||||
result = extractOldPost(data, id)
|
result = extractOldPost(data, id, alwaysProxy)
|
||||||
} else {
|
} else {
|
||||||
result = extractNewPost(data, id)
|
result = extractNewPost(data, id, alwaysProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usernameToId(username, cookie) {
|
async function usernameToId(username, cookie) {
|
||||||
|
@ -295,10 +318,10 @@ export default function(obj) {
|
||||||
|
|
||||||
async function getStory(username, id) {
|
async function getStory(username, id) {
|
||||||
const cookie = getCookie('instagram');
|
const cookie = getCookie('instagram');
|
||||||
if (!cookie) return { error: 'ErrorUnsupported' };
|
if (!cookie) return { error: "link.unsupported" };
|
||||||
|
|
||||||
const userId = await usernameToId(username, cookie);
|
const userId = await usernameToId(username, cookie);
|
||||||
if (!userId) return { error: 'ErrorEmptyDownload' };
|
if (!userId) return { error: "fetch.empty" };
|
||||||
|
|
||||||
const dtsgId = await findDtsgId(cookie);
|
const dtsgId = await findDtsgId(cookie);
|
||||||
|
|
||||||
|
@ -320,7 +343,7 @@ export default function(obj) {
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const item = media.items.find(m => m.pk === id);
|
const item = media.items.find(m => m.pk === id);
|
||||||
if (!item) return { error: 'ErrorEmptyDownload' };
|
if (!item) return { error: "fetch.empty" };
|
||||||
|
|
||||||
if (item.video_versions) {
|
if (item.video_versions) {
|
||||||
const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
|
const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
|
||||||
|
@ -338,12 +361,12 @@ export default function(obj) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorUnsupported' };
|
return { error: "link.unsupported" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { postId, storyId, username } = obj;
|
const { postId, storyId, username, alwaysProxy } = obj;
|
||||||
if (postId) return getPost(postId);
|
if (postId) return getPost(postId, alwaysProxy);
|
||||||
if (username && storyId) return getStory(username, storyId);
|
if (username && storyId) return getStory(username, storyId);
|
||||||
|
|
||||||
return { error: 'ErrorUnsupported' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
|
@ -23,7 +23,7 @@ export default async function({ id }) {
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
.then(r => r.status === 200 ? r.json() : false)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!gql) return { error: 'ErrorEmptyDownload' };
|
if (!gql) return { error: "fetch.empty" };
|
||||||
|
|
||||||
const videoUrl = gql?.url;
|
const videoUrl = gql?.url;
|
||||||
|
|
||||||
|
@ -35,5 +35,5 @@ export default async function({ id }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { genericUserAgent, env } from "../../config.js";
|
import { genericUserAgent, env } from "../../config.js";
|
||||||
import { cleanString } from "../../sub/utils.js";
|
import { cleanString } from "../../misc/utils.js";
|
||||||
|
|
||||||
const resolutions = {
|
const resolutions = {
|
||||||
"ultra": "2160",
|
"ultra": "2160",
|
||||||
|
@ -19,26 +19,26 @@ export default async function(o) {
|
||||||
headers: { "user-agent": genericUserAgent }
|
headers: { "user-agent": genericUserAgent }
|
||||||
}).then(r => r.text()).catch(() => {});
|
}).then(r => r.text()).catch(() => {});
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let videoData = html.match(/<div data-module="OKVideo" .*? data-options="({.*?})"( .*?)>/)
|
let videoData = html.match(/<div data-module="OKVideo" .*? data-options="({.*?})"( .*?)>/)
|
||||||
?.[1]
|
?.[1]
|
||||||
?.replaceAll(""", '"');
|
?.replaceAll(""", '"');
|
||||||
|
|
||||||
if (!videoData) {
|
if (!videoData) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
return { error: 'ErrorUnsupported' };
|
return { error: "link.unsupported" };
|
||||||
|
|
||||||
if (videoData.movie.is_live)
|
if (videoData.movie.is_live)
|
||||||
return { error: 'ErrorLiveVideo' };
|
return { error: "content.video.live" };
|
||||||
|
|
||||||
if (videoData.movie.duration > env.durationLimit)
|
if (videoData.movie.duration > env.durationLimit)
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
|
|
||||||
let videos = videoData.videos.filter(v => !v.disallowed);
|
let videos = videoData.videos.filter(v => !v.disallowed);
|
||||||
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
|
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
|
||||||
|
@ -61,5 +61,5 @@ export default async function(o) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
|
@ -12,15 +12,15 @@ export default async function(o) {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
if (id.includes("--")) id = id.split("--")[1];
|
if (id.includes("--")) id = id.split("--")[1];
|
||||||
if (!id) return { error: 'ErrorCouldntFetch' };
|
if (!id) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let html = await fetch(`https://www.pinterest.com/pin/${id}/`, {
|
const html = await fetch(`https://www.pinterest.com/pin/${id}/`, {
|
||||||
headers: { "user-agent": genericUserAgent }
|
headers: { "user-agent": genericUserAgent }
|
||||||
}).then(r => r.text()).catch(() => {});
|
}).then(r => r.text()).catch(() => {});
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let videoLink = [...html.matchAll(videoRegex)]
|
const videoLink = [...html.matchAll(videoRegex)]
|
||||||
.map(([, link]) => link)
|
.map(([, link]) => link)
|
||||||
.find(a => a.endsWith('.mp4') && a.includes('720p'));
|
.find(a => a.endsWith('.mp4') && a.includes('720p'));
|
||||||
|
|
||||||
|
@ -30,14 +30,17 @@ export default async function(o) {
|
||||||
audioFilename: `pinterest_${o.id}_audio`
|
audioFilename: `pinterest_${o.id}_audio`
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageLink = [...html.matchAll(imageRegex)]
|
const imageLink = [...html.matchAll(imageRegex)]
|
||||||
.map(([, link]) => link)
|
.map(([, link]) => link)
|
||||||
.find(a => a.endsWith('.jpg') || a.endsWith('.gif'));
|
.find(a => a.endsWith('.jpg') || a.endsWith('.gif'));
|
||||||
|
|
||||||
|
const imageType = imageLink.endsWith(".gif") ? "gif" : "jpg"
|
||||||
|
|
||||||
if (imageLink) return {
|
if (imageLink) return {
|
||||||
urls: imageLink,
|
urls: imageLink,
|
||||||
isPhoto: true
|
isPhoto: true,
|
||||||
|
filename: `pinterest_${o.id}.${imageType}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
|
@ -67,20 +67,25 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
).then(r => r.json()).catch(() => {});
|
).then(r => r.json()).catch(() => {});
|
||||||
|
|
||||||
if (!data || !Array.isArray(data)) return { error: 'ErrorCouldntFetch' };
|
if (!data || !Array.isArray(data)) {
|
||||||
|
return { error: "fetch.fail" }
|
||||||
|
}
|
||||||
|
|
||||||
data = data[0]?.data?.children[0]?.data;
|
data = data[0]?.data?.children[0]?.data;
|
||||||
|
|
||||||
|
const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
|
||||||
|
|
||||||
if (data?.url?.endsWith('.gif')) return {
|
if (data?.url?.endsWith('.gif')) return {
|
||||||
typeId: "redirect",
|
typeId: "redirect",
|
||||||
urls: data.url
|
urls: data.url,
|
||||||
|
filename: `reddit_${id}.gif`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.secure_media?.reddit_video)
|
if (!data.secure_media?.reddit_video)
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
|
|
||||||
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
|
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
|
|
||||||
let audio = false,
|
let audio = false,
|
||||||
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
|
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
|
||||||
|
@ -107,16 +112,14 @@ export default async function(obj) {
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
|
|
||||||
|
|
||||||
if (!audio) return {
|
if (!audio) return {
|
||||||
typeId: "redirect",
|
typeId: "redirect",
|
||||||
urls: video
|
urls: video
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
typeId: "stream",
|
typeId: "tunnel",
|
||||||
type: "render",
|
type: "merge",
|
||||||
urls: [video, audioFileLink],
|
urls: [video, audioFileLink],
|
||||||
audioFilename: `reddit_${id}_audio`,
|
audioFilename: `reddit_${id}_audio`,
|
||||||
filename: `reddit_${id}.mp4`
|
filename: `reddit_${id}.mp4`
|
|
@ -1,7 +1,7 @@
|
||||||
import HLS from 'hls-parser';
|
import HLS from "hls-parser";
|
||||||
|
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { cleanString } from '../../sub/utils.js';
|
import { cleanString } from "../../misc/utils.js";
|
||||||
|
|
||||||
async function requestJSON(url) {
|
async function requestJSON(url) {
|
||||||
try {
|
try {
|
||||||
|
@ -18,7 +18,7 @@ export default async function(obj) {
|
||||||
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
|
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
|
||||||
)
|
)
|
||||||
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
|
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
|
||||||
if (!yappyURL) return { error: 'ErrorEmptyDownload' };
|
if (!yappyURL) return { error: "fetch.empty" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
urls: yappyURL,
|
urls: yappyURL,
|
||||||
|
@ -33,19 +33,19 @@ export default async function(obj) {
|
||||||
if (obj.key) requestURL.searchParams.set('p', obj.key);
|
if (obj.key) requestURL.searchParams.set('p', obj.key);
|
||||||
|
|
||||||
const play = await requestJSON(requestURL);
|
const play = await requestJSON(requestURL);
|
||||||
if (!play) return { error: 'ErrorCouldntFetch' };
|
if (!play) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (play.detail || !play.video_balancer) return { error: 'ErrorEmptyDownload' };
|
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
|
||||||
if (play.live_streams?.hls) return { error: 'ErrorLiveVideo' };
|
if (play.live_streams?.hls) return { error: "content.video.live" };
|
||||||
|
|
||||||
if (play.duration > env.durationLimit * 1000)
|
if (play.duration > env.durationLimit * 1000)
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
|
|
||||||
let m3u8 = await fetch(play.video_balancer.m3u8)
|
let m3u8 = await fetch(play.video_balancer.m3u8)
|
||||||
.then(r => r.text())
|
.then(r => r.text())
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!m3u8) return { error: 'ErrorCouldntFetch' };
|
if (!m3u8) return { error: "fetch.fail" };
|
||||||
|
|
||||||
m3u8 = HLS.parse(m3u8).variants;
|
m3u8 = HLS.parse(m3u8).variants;
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
import { genericUserAgent } from "../../config.js";
|
|
||||||
import { getRedirectingURL } from "../../sub/utils.js";
|
|
||||||
import { extract, normalizeURL } from "../url.js";
|
import { extract, normalizeURL } from "../url.js";
|
||||||
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
import { createStream } from "../../stream/manage.js";
|
||||||
|
import { getRedirectingURL } from "../../misc/utils.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 SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
||||||
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
||||||
|
|
||||||
async function getSpotlight(id) {
|
async function getSpotlight(id) {
|
||||||
const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
|
const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
|
||||||
headers: { 'User-Agent': genericUserAgent }
|
headers: { 'user-agent': genericUserAgent }
|
||||||
}).then((r) => r.text()).catch(() => null);
|
}).then((r) => r.text()).catch(() => null);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
|
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
|
||||||
if (videoURL) {
|
|
||||||
|
if (videoURL && new URL(videoURL).hostname.endsWith(".sc-cdn.net")) {
|
||||||
return {
|
return {
|
||||||
urls: videoURL,
|
urls: videoURL,
|
||||||
filename: `snapchat_${id}.mp4`,
|
filename: `snapchat_${id}.mp4`,
|
||||||
|
@ -23,12 +26,16 @@ async function getSpotlight(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStory(username, storyId) {
|
async function getStory(username, storyId, alwaysProxy) {
|
||||||
const html = await fetch(`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`, {
|
const html = await fetch(
|
||||||
headers: { 'User-Agent': genericUserAgent }
|
`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`,
|
||||||
}).then((r) => r.text()).catch(() => null);
|
{ headers: { 'user-agent': genericUserAgent } }
|
||||||
|
)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
||||||
|
@ -42,6 +49,7 @@ async function getStory(username, storyId) {
|
||||||
if (story.snapMediaType === 0) {
|
if (story.snapMediaType === 0) {
|
||||||
return {
|
return {
|
||||||
urls: story.snapUrls.mediaUrl,
|
urls: story.snapUrls.mediaUrl,
|
||||||
|
filename: `snapchat_${storyId}.jpg`,
|
||||||
isPhoto: true
|
isPhoto: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,11 +65,33 @@ async function getStory(username, storyId) {
|
||||||
const defaultStory = data.props.pageProps.curatedHighlights[0];
|
const defaultStory = data.props.pageProps.curatedHighlights[0];
|
||||||
if (defaultStory) {
|
if (defaultStory) {
|
||||||
return {
|
return {
|
||||||
picker: defaultStory.snapList.map((snap) => ({
|
picker: defaultStory.snapList.map(snap => {
|
||||||
type: snap.snapMediaType === 0 ? 'photo' : 'video',
|
const snapType = snap.snapMediaType === 0 ? "photo" : "video";
|
||||||
url: snap.snapUrls.mediaUrl,
|
const snapExt = snapType === "video" ? "mp4" : "jpg";
|
||||||
thumb: snap.snapUrls.mediaPreviewUrl.value
|
let snapUrl = snap.snapUrls.mediaUrl;
|
||||||
}))
|
|
||||||
|
const proxy = createStream({
|
||||||
|
service: "snapchat",
|
||||||
|
type: "proxy",
|
||||||
|
u: snapUrl,
|
||||||
|
filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let thumbProxy;
|
||||||
|
if (snapType === "video") thumbProxy = createStream({
|
||||||
|
service: "snapchat",
|
||||||
|
type: "proxy",
|
||||||
|
u: snap.snapUrls.mediaPreviewUrl.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alwaysProxy) snapUrl = proxy;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: snapType,
|
||||||
|
url: snapUrl,
|
||||||
|
thumb: thumbProxy || proxy,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -69,16 +99,16 @@ async function getStory(username, storyId) {
|
||||||
|
|
||||||
export default async function (obj) {
|
export default async function (obj) {
|
||||||
let params = obj;
|
let params = obj;
|
||||||
if (obj.hostname === 't.snapchat.com' && obj.shortLink) {
|
if (obj.shortLink) {
|
||||||
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
|
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
|
||||||
|
|
||||||
if (!link?.startsWith('https://www.snapchat.com/')) {
|
if (!link?.startsWith('https://www.snapchat.com/')) {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.short_link" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractResult = extract(normalizeURL(link));
|
const extractResult = extract(normalizeURL(link));
|
||||||
if (extractResult?.host !== 'snapchat') {
|
if (extractResult?.host !== 'snapchat') {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.short_link" };
|
||||||
}
|
}
|
||||||
|
|
||||||
params = extractResult.patternMatch;
|
params = extractResult.patternMatch;
|
||||||
|
@ -88,9 +118,9 @@ export default async function (obj) {
|
||||||
const result = await getSpotlight(params.spotlightId);
|
const result = await getSpotlight(params.spotlightId);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
} else if (params.username) {
|
} else if (params.username) {
|
||||||
const result = await getStory(params.username, params.storyId);
|
const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { cleanString } from "../../sub/utils.js";
|
import { cleanString } from "../../misc/utils.js";
|
||||||
|
|
||||||
const cachedID = {
|
const cachedID = {
|
||||||
version: '',
|
version: '',
|
||||||
|
@ -39,7 +39,7 @@ async function findClientID() {
|
||||||
|
|
||||||
export default async function(obj) {
|
export default async function(obj) {
|
||||||
let clientId = await findClientID();
|
let clientId = await findClientID();
|
||||||
if (!clientId) return { error: 'ErrorSoundCloudNoClientId' };
|
if (!clientId) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let link;
|
let link;
|
||||||
if (obj.url.hostname === 'on.soundcloud.com' && obj.shortLink) {
|
if (obj.url.hostname === 'on.soundcloud.com' && obj.shortLink) {
|
||||||
|
@ -54,15 +54,16 @@ export default async function(obj) {
|
||||||
link = `https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`
|
link = `https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!link) return { error: 'ErrorCouldntFetch' };
|
if (!link && obj.shortLink) return { error: "fetch.short_link" };
|
||||||
|
if (!link) return { error: "link.unsupported" };
|
||||||
|
|
||||||
let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`)
|
let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`)
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
.then(r => r.status === 200 ? r.json() : false)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!json) return { error: 'ErrorCouldntFetch' };
|
if (!json) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (!json.media.transcodings) return { error: 'ErrorEmptyDownload' };
|
if (!json.media.transcodings) return { error: "fetch.empty" };
|
||||||
|
|
||||||
let bestAudio = "opus",
|
let bestAudio = "opus",
|
||||||
selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"),
|
selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"),
|
||||||
|
@ -78,15 +79,16 @@ export default async function(obj) {
|
||||||
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
|
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
|
||||||
|
|
||||||
if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
|
if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
|
|
||||||
if (json.duration > env.durationLimit * 1000)
|
if (json.duration > env.durationLimit * 1000) {
|
||||||
return { error: ['ErrorLengthAudioConvert', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
|
}
|
||||||
|
|
||||||
let file = await fetch(fileUrl)
|
let file = await fetch(fileUrl)
|
||||||
.then(async r => (await r.json()).url)
|
.then(async r => (await r.json()).url)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
if (!file) return { error: 'ErrorCouldntFetch' };
|
if (!file) return { error: "fetch.empty" };
|
||||||
|
|
||||||
let fileMetadata = {
|
let fileMetadata = {
|
||||||
title: cleanString(json.title.trim()),
|
title: cleanString(json.title.trim()),
|
|
@ -3,9 +3,9 @@ export default async function(obj) {
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
.then(r => r.status === 200 ? r.json() : false)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!video) return { error: 'ErrorEmptyDownload' };
|
if (!video) return { error: "fetch.empty" };
|
||||||
|
|
||||||
let best = video.files['mp4-mobile'];
|
let best = video.files["mp4-mobile"];
|
||||||
if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) {
|
if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) {
|
||||||
best = video.files.mp4;
|
best = video.files.mp4;
|
||||||
}
|
}
|
||||||
|
@ -18,5 +18,5 @@ export default async function(obj) {
|
||||||
title: video.title
|
title: video.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.fail" }
|
||||||
}
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
|
import Cookie from "../cookie/cookie.js";
|
||||||
|
|
||||||
|
import { extract } from "../url.js";
|
||||||
import { genericUserAgent } from "../../config.js";
|
import { genericUserAgent } from "../../config.js";
|
||||||
import { updateCookie } from "../cookie/manager.js";
|
import { updateCookie } from "../cookie/manager.js";
|
||||||
import { extract } from "../url.js";
|
import { createStream } from "../../stream/manage.js";
|
||||||
import Cookie from "../cookie/cookie.js";
|
|
||||||
|
|
||||||
const shortDomain = "https://vt.tiktok.com/";
|
const shortDomain = "https://vt.tiktok.com/";
|
||||||
|
|
||||||
|
@ -17,7 +19,7 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
}).then(r => r.text()).catch(() => {});
|
}).then(r => r.text()).catch(() => {});
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (html.startsWith('<a href="https://')) {
|
if (html.startsWith('<a href="https://')) {
|
||||||
const extractedURL = html.split('<a href="')[1].split('?')[0];
|
const extractedURL = html.split('<a href="')[1].split('?')[0];
|
||||||
|
@ -25,7 +27,7 @@ export default async function(obj) {
|
||||||
postId = patternMatch.postId
|
postId = patternMatch.postId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!postId) return { error: 'ErrorCantGetID' };
|
if (!postId) return { error: "fetch.short_link" };
|
||||||
|
|
||||||
// should always be /video/, even for photos
|
// should always be /video/, even for photos
|
||||||
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
|
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
|
||||||
|
@ -46,12 +48,12 @@ export default async function(obj) {
|
||||||
const data = JSON.parse(json)
|
const data = JSON.parse(json)
|
||||||
detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
|
detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
|
||||||
} catch {
|
} catch {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let video, videoFilename, audioFilename, audio, images,
|
let video, videoFilename, audioFilename, audio, images,
|
||||||
filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
|
filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
|
||||||
bestAudio = 'm4a';
|
bestAudio; // will get defaulted to m4a later on in match-action
|
||||||
|
|
||||||
images = detail.imagePost?.images;
|
images = detail.imagePost?.images;
|
||||||
|
|
||||||
|
@ -96,7 +98,19 @@ export default async function(obj) {
|
||||||
if (images) {
|
if (images) {
|
||||||
let imageLinks = images
|
let imageLinks = images
|
||||||
.map(i => i.imageURL.urlList.find(p => p.includes(".jpeg?")))
|
.map(i => i.imageURL.urlList.find(p => p.includes(".jpeg?")))
|
||||||
.map(url => ({ url }));
|
.map((url, i) => {
|
||||||
|
if (obj.alwaysProxy) url = createStream({
|
||||||
|
service: "tiktok",
|
||||||
|
type: "proxy",
|
||||||
|
u: url,
|
||||||
|
filename: `${filenameBase}_photo_${i + 1}.jpg`
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "photo",
|
||||||
|
url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
picker: imageLinks,
|
picker: imageLinks,
|
|
@ -22,7 +22,7 @@ export default async function(input) {
|
||||||
let { subdomain } = psl.parse(input.url.hostname);
|
let { subdomain } = psl.parse(input.url.hostname);
|
||||||
|
|
||||||
if (subdomain?.includes('.')) {
|
if (subdomain?.includes('.')) {
|
||||||
return { error: ['ErrorBrokenLink', 'tumblr'] }
|
return { error: "link.unsupported" };
|
||||||
} else if (subdomain === 'www' || subdomain === 'at') {
|
} else if (subdomain === 'www' || subdomain === 'at') {
|
||||||
subdomain = undefined
|
subdomain = undefined
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ export default async function(input) {
|
||||||
const data = await request(domain, input.id);
|
const data = await request(domain, input.id);
|
||||||
|
|
||||||
const element = data?.response?.timeline?.elements?.[0];
|
const element = data?.response?.timeline?.elements?.[0];
|
||||||
if (!element) return { error: 'ErrorEmptyDownload' };
|
if (!element) return { error: "fetch.empty" };
|
||||||
|
|
||||||
const contents = [
|
const contents = [
|
||||||
...element.content,
|
...element.content,
|
||||||
|
@ -53,7 +53,8 @@ export default async function(input) {
|
||||||
title: fileMetadata.title,
|
title: fileMetadata.title,
|
||||||
author: fileMetadata.artist
|
author: fileMetadata.artist
|
||||||
},
|
},
|
||||||
isAudioOnly: true
|
isAudioOnly: true,
|
||||||
|
bestAudio: "mp3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,5 +67,5 @@ export default async function(input) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "link.unsupported" }
|
||||||
}
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { cleanString } from '../../sub/utils.js';
|
import { cleanString } from '../../misc/utils.js';
|
||||||
|
|
||||||
const gqlURL = "https://gql.twitch.tv/gql";
|
const gqlURL = "https://gql.twitch.tv/gql";
|
||||||
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
||||||
|
|
||||||
export default async function (obj) {
|
export default async function (obj) {
|
||||||
let req_metadata = await fetch(gqlURL, {
|
const req_metadata = await fetch(gqlURL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: clientIdHead,
|
headers: clientIdHead,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
@ -30,16 +30,19 @@ export default async function (obj) {
|
||||||
}`
|
}`
|
||||||
})
|
})
|
||||||
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
||||||
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let clipMetadata = req_metadata.data.clip;
|
if (!req_metadata) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (clipMetadata.durationSeconds > env.durationLimit)
|
const clipMetadata = req_metadata.data.clip;
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster)
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
let req_token = await fetch(gqlURL, {
|
if (clipMetadata.durationSeconds > env.durationLimit) {
|
||||||
|
return { error: "content.too_long" };
|
||||||
|
}
|
||||||
|
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) {
|
||||||
|
return { error: "fetch.empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const req_token = await fetch(gqlURL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: clientIdHead,
|
headers: clientIdHead,
|
||||||
body: JSON.stringify([
|
body: JSON.stringify([
|
||||||
|
@ -58,13 +61,13 @@ export default async function (obj) {
|
||||||
])
|
])
|
||||||
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
||||||
|
|
||||||
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
if (!req_token) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let formats = clipMetadata.videoQualities;
|
const formats = clipMetadata.videoQualities;
|
||||||
let format = formats.find(f => f.quality === obj.quality) || formats[0];
|
const format = formats.find(f => f.quality === obj.quality) || formats[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "bridge",
|
type: "proxy",
|
||||||
urls: `${format.sourceURL}?${new URLSearchParams({
|
urls: `${format.sourceURL}?${new URLSearchParams({
|
||||||
sig: req_token[0].data.clip.playbackAccessToken.signature,
|
sig: req_token[0].data.clip.playbackAccessToken.signature,
|
||||||
token: req_token[0].data.clip.playbackAccessToken.value
|
token: req_token[0].data.clip.playbackAccessToken.value
|
|
@ -101,11 +101,11 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function({ id, index, toGif, dispatcher }) {
|
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
||||||
const cookie = await getCookie('twitter');
|
const cookie = await getCookie('twitter');
|
||||||
|
|
||||||
let guestToken = await getGuestToken(dispatcher);
|
let guestToken = await getGuestToken(dispatcher);
|
||||||
if (!guestToken) return { error: 'ErrorCouldntFetch' };
|
if (!guestToken) return { error: "fetch.fail" };
|
||||||
|
|
||||||
let tweet = await requestTweet(dispatcher, id, guestToken);
|
let tweet = await requestTweet(dispatcher, id, guestToken);
|
||||||
|
|
||||||
|
@ -119,22 +119,26 @@ export default async function({ id, index, toGif, dispatcher }) {
|
||||||
|
|
||||||
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
|
|
||||||
|
if (!tweetTypename) {
|
||||||
|
return { error: "fetch.empty" }
|
||||||
|
}
|
||||||
|
|
||||||
if (tweetTypename === "TweetUnavailable") {
|
if (tweetTypename === "TweetUnavailable") {
|
||||||
const reason = tweet?.data?.tweetResult?.result?.reason;
|
const reason = tweet?.data?.tweetResult?.result?.reason;
|
||||||
switch(reason) {
|
switch(reason) {
|
||||||
case "Protected":
|
case "Protected":
|
||||||
return { error: 'ErrorTweetProtected' }
|
return { error: "content.post.private" }
|
||||||
case "NsfwLoggedOut":
|
case "NsfwLoggedOut":
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
||||||
tweet = await tweet.json();
|
tweet = await tweet.json();
|
||||||
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
} else return { error: 'ErrorTweetNSFW' }
|
} else return { error: "content.post.age" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
|
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
|
||||||
return { error: 'ErrorTweetUnavailable' }
|
return { error: "content.post.unavailable" }
|
||||||
}
|
}
|
||||||
|
|
||||||
let tweetResult = tweet.data.tweetResult.result,
|
let tweetResult = tweet.data.tweetResult.result,
|
||||||
|
@ -153,56 +157,77 @@ export default async function({ id, index, toGif, dispatcher }) {
|
||||||
media = [media[index]]
|
media = [media[index]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
|
||||||
|
|
||||||
|
const proxyMedia = (u, filename) => createStream({
|
||||||
|
service: "twitter",
|
||||||
|
type: "proxy",
|
||||||
|
u, filename,
|
||||||
|
})
|
||||||
|
|
||||||
switch (media?.length) {
|
switch (media?.length) {
|
||||||
case undefined:
|
case undefined:
|
||||||
case 0:
|
case 0:
|
||||||
return { error: 'ErrorNoVideosInTweet' };
|
return {
|
||||||
|
error: "fetch.empty"
|
||||||
|
}
|
||||||
case 1:
|
case 1:
|
||||||
if (media[0].type === "photo") {
|
if (media[0].type === "photo") {
|
||||||
return {
|
return {
|
||||||
type: "normal",
|
type: "proxy",
|
||||||
isPhoto: true,
|
isPhoto: true,
|
||||||
|
filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
|
||||||
urls: `${media[0].media_url_https}?name=4096x4096`
|
urls: `${media[0].media_url_https}?name=4096x4096`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: needsFixing(media[0]) ? "remux" : "normal",
|
type: needsFixing(media[0]) ? "remux" : "proxy",
|
||||||
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 proxyThumb = (url, i) =>
|
||||||
|
proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
|
||||||
|
|
||||||
const picker = media.map((content, i) => {
|
const picker = media.map((content, i) => {
|
||||||
if (content.type === "photo") {
|
if (content.type === "photo") {
|
||||||
let url = `${content.media_url_https}?name=4096x4096`;
|
let url = `${content.media_url_https}?name=4096x4096`;
|
||||||
|
let proxiedImage = proxyThumb(url, i);
|
||||||
|
|
||||||
|
if (alwaysProxy) url = proxiedImage;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "photo",
|
type: "photo",
|
||||||
url,
|
url,
|
||||||
thumb: url,
|
thumb: proxiedImage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
const videoFilename = `twitter_${id}_${i + 1}.mp4`;
|
||||||
|
|
||||||
let type = "video";
|
let type = "video";
|
||||||
if (shouldRenderGif) type = "gif";
|
if (shouldRenderGif) type = "gif";
|
||||||
|
|
||||||
if (needsFixing(content) || shouldRenderGif) {
|
if (needsFixing(content) || shouldRenderGif) {
|
||||||
url = createStream({
|
url = createStream({
|
||||||
service: 'twitter',
|
service: "twitter",
|
||||||
type: shouldRenderGif ? 'gif' : 'remux',
|
type: shouldRenderGif ? "gif" : "remux",
|
||||||
u: url,
|
u: url,
|
||||||
filename: `twitter_${id}_${i + 1}.mp4`
|
filename: videoFilename,
|
||||||
})
|
})
|
||||||
|
} else if (alwaysProxy) {
|
||||||
|
url = proxyMedia(url, videoFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
url,
|
url,
|
||||||
thumb: content.media_url_https
|
thumb: proxyThumb(content.media_url_https, i),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { picker };
|
return { picker };
|
|
@ -1,8 +1,8 @@
|
||||||
import { env } from "../../config.js";
|
|
||||||
import { cleanString, merge } from '../../sub/utils.js';
|
|
||||||
|
|
||||||
import HLS from "hls-parser";
|
import HLS from "hls-parser";
|
||||||
|
|
||||||
|
import { env } from "../../config.js";
|
||||||
|
import { cleanString, merge } from '../../misc/utils.js';
|
||||||
|
|
||||||
const resolutionMatch = {
|
const resolutionMatch = {
|
||||||
"3840": 2160,
|
"3840": 2160,
|
||||||
"2732": 1440,
|
"2732": 1440,
|
||||||
|
@ -63,7 +63,8 @@ const getDirectLink = (data, quality) => {
|
||||||
resolution: `${match.width}x${match.height}`,
|
resolution: `${match.width}x${match.height}`,
|
||||||
qualityLabel: match.rendition,
|
qualityLabel: match.rendition,
|
||||||
extension: "mp4"
|
extension: "mp4"
|
||||||
}
|
},
|
||||||
|
bestAudio: "mp3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,25 +74,25 @@ const getHLS = async (configURL, obj) => {
|
||||||
const api = await fetch(configURL)
|
const api = await fetch(configURL)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
if (!api) return { error: 'ErrorCouldntFetch' };
|
if (!api) return { error: "fetch.fail" };
|
||||||
|
|
||||||
if (api.video?.duration > env.durationLimit) {
|
if (api.video?.duration > env.durationLimit) {
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;
|
const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;
|
||||||
if (!urlMasterHLS) return { error: 'ErrorCouldntFetch' }
|
if (!urlMasterHLS) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const masterHLS = await fetch(urlMasterHLS)
|
const masterHLS = await fetch(urlMasterHLS)
|
||||||
.then(r => r.text())
|
.then(r => r.text())
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!masterHLS) return { error: 'ErrorCouldntFetch' };
|
if (!masterHLS) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const variants = HLS.parse(masterHLS)?.variants?.sort(
|
const variants = HLS.parse(masterHLS)?.variants?.sort(
|
||||||
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
|
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
|
||||||
);
|
);
|
||||||
if (!variants || variants.length === 0) return { error: 'ErrorEmptyDownload' };
|
if (!variants || variants.length === 0) return { error: "fetch.empty" };
|
||||||
|
|
||||||
let bestQuality;
|
let bestQuality;
|
||||||
|
|
||||||
|
@ -116,7 +117,7 @@ const getHLS = async (configURL, obj) => {
|
||||||
expandLink(audioPath)
|
expandLink(audioPath)
|
||||||
]
|
]
|
||||||
} else if (obj.isAudioOnly) {
|
} else if (obj.isAudioOnly) {
|
||||||
return { error: 'ErrorEmptyDownload' };
|
return { error: "fetch.empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -126,7 +127,8 @@ const getHLS = async (configURL, obj) => {
|
||||||
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
||||||
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
|
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
|
||||||
extension: "mp4"
|
extension: "mp4"
|
||||||
}
|
},
|
||||||
|
bestAudio: "mp3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -143,7 +145,7 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) response = getDirectLink(info, quality);
|
if (!response) response = getDirectLink(info, quality);
|
||||||
if (!response) response = { error: 'ErrorEmptyDownload' };
|
if (!response) response = { error: "fetch.empty" };
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
return response;
|
return response;
|
|
@ -3,7 +3,7 @@ export default async function(obj) {
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (!post) return { error: 'ErrorEmptyDownload' };
|
if (!post) return { error: "fetch.empty" };
|
||||||
|
|
||||||
if (post.videoUrl) return {
|
if (post.videoUrl) return {
|
||||||
urls: post.videoUrl.replace("http://", "https://"),
|
urls: post.videoUrl.replace("http://", "https://"),
|
||||||
|
@ -11,5 +11,5 @@ export default async function(obj) {
|
||||||
audioFilename: `vine_${obj.id}_audio`
|
audioFilename: `vine_${obj.id}_audio`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { cleanString } from "../../misc/utils.js";
|
||||||
import { genericUserAgent, env } from "../../config.js";
|
import { genericUserAgent, env } from "../../config.js";
|
||||||
import { cleanString } from "../../sub/utils.js";
|
|
||||||
|
|
||||||
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
|
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
|
||||||
|
|
||||||
|
@ -7,22 +7,30 @@ export default async function(o) {
|
||||||
let html, url, quality = o.quality === "max" ? 2160 : o.quality;
|
let html, url, quality = o.quality === "max" ? 2160 : o.quality;
|
||||||
|
|
||||||
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
|
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
|
||||||
headers: { "user-agent": genericUserAgent }
|
headers: {
|
||||||
}).then(r => r.arrayBuffer()).catch(() => {});
|
"user-agent": genericUserAgent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(r => r.arrayBuffer())
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
if (!html) return { error: "fetch.fail" };
|
||||||
|
|
||||||
// decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
|
// decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
|
||||||
let decoder = new TextDecoder('windows-1251');
|
let decoder = new TextDecoder('windows-1251');
|
||||||
html = decoder.decode(html);
|
html = decoder.decode(html);
|
||||||
|
|
||||||
if (!html.includes(`{"lang":`)) return { error: 'ErrorEmptyDownload' };
|
if (!html.includes(`{"lang":`)) return { error: "fetch.empty" };
|
||||||
|
|
||||||
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
|
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
|
||||||
|
|
||||||
if (Number(js.mvData.is_active_live) !== 0) return { error: 'ErrorLiveVideo' };
|
if (Number(js.mvData.is_active_live) !== 0) {
|
||||||
if (js.mvData.duration > env.durationLimit)
|
return { error: "content.video.live" };
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
}
|
||||||
|
|
||||||
|
if (js.mvData.duration > env.durationLimit) {
|
||||||
|
return { error: "content.too_long" };
|
||||||
|
}
|
||||||
|
|
||||||
for (let i in resolutions) {
|
for (let i in resolutions) {
|
||||||
if (js.player.params[0][`url${resolutions[i]}`]) {
|
if (js.player.params[0][`url${resolutions[i]}`]) {
|
||||||
|
@ -51,5 +59,5 @@ export default async function(o) {
|
||||||
extension: "mp4"
|
extension: "mp4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { error: 'ErrorEmptyDownload' }
|
return { error: "fetch.empty" }
|
||||||
}
|
}
|
|
@ -3,7 +3,7 @@ import { fetch } from "undici";
|
||||||
import { Innertube, Session } from "youtubei.js";
|
import { Innertube, Session } from "youtubei.js";
|
||||||
|
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { cleanString } from "../../sub/utils.js";
|
import { cleanString } from "../../misc/utils.js";
|
||||||
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
||||||
|
|
||||||
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
||||||
|
@ -110,7 +110,9 @@ export default async function(o) {
|
||||||
);
|
);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
if (e.message?.endsWith("decipher algorithm")) {
|
if (e.message?.endsWith("decipher algorithm")) {
|
||||||
return { error: "ErrorYoutubeDecipher" }
|
return { error: "youtube.decipher" }
|
||||||
|
} else if (e.message?.includes("refresh access token")) {
|
||||||
|
return { error: "youtube.token_expired" }
|
||||||
} else throw e;
|
} else throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,44 +132,60 @@ export default async function(o) {
|
||||||
try {
|
try {
|
||||||
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
|
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
if (e?.message === 'This video is unavailable') {
|
if (e?.info?.reason === "This video is private") {
|
||||||
return { error: 'ErrorCouldntFetch' };
|
return { error: "content.video.private" };
|
||||||
|
} else if (e?.message === "This video is unavailable") {
|
||||||
|
return { error: "content.video.unavailable" };
|
||||||
} else {
|
} else {
|
||||||
return { error: 'ErrorCantConnectToServiceAPI' };
|
return { error: "fetch.fail" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
|
if (!info) return { error: "fetch.fail" };
|
||||||
|
|
||||||
const playability = info.playability_status;
|
const playability = info.playability_status;
|
||||||
const basicInfo = info.basic_info;
|
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")) {
|
||||||
return { error: 'ErrorYTLogin' }
|
return { error: "youtube.login" }
|
||||||
}
|
}
|
||||||
if (playability.reason.endsWith('age')) {
|
if (playability.reason.endsWith("age")) {
|
||||||
return { error: 'ErrorYTAgeRestrict' }
|
return { error: "content.video.age" }
|
||||||
|
}
|
||||||
|
if (playability?.error_screen?.reason?.text === "Private video") {
|
||||||
|
return { error: "content.video.private" }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (playability.status === "UNPLAYABLE" && playability.reason.endsWith('request limit.')) {
|
|
||||||
return { error: 'ErrorYTRateLimit' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playability.status !== 'OK') return { error: 'ErrorYTUnavailable' };
|
if (playability.status === "UNPLAYABLE") {
|
||||||
if (basicInfo.is_live) return { error: 'ErrorLiveVideo' };
|
if (playability?.reason?.endsWith("request limit.")) {
|
||||||
|
return { error: "fetch.rate" }
|
||||||
|
}
|
||||||
|
if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
|
||||||
|
return { error: "content.video.region" }
|
||||||
|
}
|
||||||
|
if (playability?.error_screen?.reason?.text === "Private video") {
|
||||||
|
return { error: "content.video.private" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playability.status !== "OK") {
|
||||||
|
return { error: "content.video.unavailable" };
|
||||||
|
}
|
||||||
|
if (basicInfo.is_live) {
|
||||||
|
return { error: "content.video.live" };
|
||||||
|
}
|
||||||
|
|
||||||
// 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 (basicInfo.id !== o.id) {
|
if (basicInfo.id !== o.id) {
|
||||||
return {
|
return {
|
||||||
error: 'ErrorCantConnectToServiceAPI',
|
error: "fetch.fail",
|
||||||
critical: true
|
critical: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestQuality, hasAudio;
|
|
||||||
|
|
||||||
const filterByCodec = (formats) =>
|
const filterByCodec = (formats) =>
|
||||||
formats
|
formats
|
||||||
.filter(e =>
|
.filter(e =>
|
||||||
|
@ -183,16 +201,18 @@ export default async function(o) {
|
||||||
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
|
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
|
||||||
}
|
}
|
||||||
|
|
||||||
bestQuality = adaptive_formats.find(i => i.has_video && i.content_length);
|
let bestQuality;
|
||||||
hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
|
|
||||||
|
|
||||||
if (bestQuality) bestQuality = qual(bestQuality);
|
const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length);
|
||||||
|
const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
|
||||||
|
|
||||||
|
if (bestVideo) bestQuality = qual(bestVideo);
|
||||||
|
|
||||||
if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
|
if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
|
||||||
return { error: 'ErrorYTTryOtherCodec' };
|
return { error: "youtube.codec" };
|
||||||
|
|
||||||
if (basicInfo.duration > env.durationLimit)
|
if (basicInfo.duration > env.durationLimit)
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
return { error: "content.too_long" };
|
||||||
|
|
||||||
const checkBestAudio = (i) => (i.has_audio && !i.has_video);
|
const checkBestAudio = (i) => (i.has_audio && !i.has_video);
|
||||||
|
|
||||||
|
@ -207,7 +227,7 @@ export default async function(o) {
|
||||||
&& i.audio_track
|
&& i.audio_track
|
||||||
)
|
)
|
||||||
|
|
||||||
if (dubbedAudio) {
|
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
|
||||||
audio = dubbedAudio;
|
audio = dubbedAudio;
|
||||||
isDubbed = true;
|
isDubbed = true;
|
||||||
}
|
}
|
||||||
|
@ -240,7 +260,7 @@ export default async function(o) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audio && o.isAudioOnly) return {
|
if (audio && o.isAudioOnly) return {
|
||||||
type: "render",
|
type: "audio",
|
||||||
isAudioOnly: true,
|
isAudioOnly: true,
|
||||||
urls: audio.decipher(yt.session.player),
|
urls: audio.decipher(yt.session.player),
|
||||||
filenameAttributes: filenameAttributes,
|
filenameAttributes: filenameAttributes,
|
||||||
|
@ -256,9 +276,10 @@ export default async function(o) {
|
||||||
|
|
||||||
let match, type, urls;
|
let match, type, urls;
|
||||||
|
|
||||||
if (!o.isAudioOnly && !o.isAudioMuted && format === 'h264') {
|
// prefer good premuxed videos if available
|
||||||
|
if (!o.isAudioOnly && !o.isAudioMuted && format === "h264" && bestVideo.fps <= 30) {
|
||||||
match = info.streaming_data.formats.find(checkSingle);
|
match = info.streaming_data.formats.find(checkSingle);
|
||||||
type = "bridge";
|
type = "proxy";
|
||||||
urls = match?.decipher(yt.session.player);
|
urls = match?.decipher(yt.session.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -266,7 +287,7 @@ export default async function(o) {
|
||||||
|
|
||||||
if (!match && video && audio) {
|
if (!match && video && audio) {
|
||||||
match = video;
|
match = video;
|
||||||
type = "render";
|
type = "merge";
|
||||||
urls = [
|
urls = [
|
||||||
video.decipher(yt.session.player),
|
video.decipher(yt.session.player),
|
||||||
audio.decipher(yt.session.player)
|
audio.decipher(yt.session.player)
|
||||||
|
@ -286,5 +307,5 @@ export default async function(o) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'ErrorYTTryOtherCodec' }
|
return { error: "fetch.fail" }
|
||||||
}
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
import { services } from "../config.js";
|
|
||||||
import { strict as assert } from "node:assert";
|
|
||||||
import psl from "psl";
|
import psl from "psl";
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
|
||||||
|
import { env } from "../config.js";
|
||||||
|
import { services } from "./service-config.js";
|
||||||
|
import { friendlyServiceName } from "./service-alias.js";
|
||||||
|
|
||||||
function aliasURL(url) {
|
function aliasURL(url) {
|
||||||
assert(url instanceof URL);
|
assert(url instanceof URL);
|
||||||
|
@ -54,6 +57,7 @@ function aliasURL(url) {
|
||||||
url = new URL(`https://bilibili.com/_tv${url.pathname}`);
|
url = new URL(`https://bilibili.com/_tv${url.pathname}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "b23":
|
case "b23":
|
||||||
if (url.hostname === 'b23.tv' && parts.length === 2) {
|
if (url.hostname === 'b23.tv' && parts.length === 2) {
|
||||||
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
|
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
|
||||||
|
@ -64,6 +68,7 @@ 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]}`)
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "facebook":
|
case "facebook":
|
||||||
case "fb":
|
case "fb":
|
||||||
|
@ -159,8 +164,12 @@ export function extract(url) {
|
||||||
|
|
||||||
const host = getHostIfValid(url);
|
const host = getHostIfValid(url);
|
||||||
|
|
||||||
if (!host || !services[host].enabled) {
|
if (!host) {
|
||||||
return null;
|
return { error: "link.invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.enabledServices.has(host)) {
|
||||||
|
return { error: "service.disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let patternMatch;
|
let patternMatch;
|
||||||
|
@ -175,7 +184,12 @@ export function extract(url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!patternMatch) {
|
if (!patternMatch) {
|
||||||
return null;
|
return {
|
||||||
|
error: "link.unsupported",
|
||||||
|
context: {
|
||||||
|
service: friendlyServiceName(host),
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { host, patternMatch };
|
return { host, patternMatch };
|
59
api/src/security/jwt.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { createHmac } from "crypto";
|
||||||
|
|
||||||
|
import { env } from "../config.js";
|
||||||
|
|
||||||
|
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
|
||||||
|
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
|
||||||
|
|
||||||
|
const makeHmac = (header, payload) =>
|
||||||
|
createHmac("sha256", env.jwtSecret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest("base64url");
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
|
||||||
|
|
||||||
|
const header = toBase64URL(JSON.stringify({
|
||||||
|
alg: "HS256",
|
||||||
|
typ: "JWT"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payload = toBase64URL(JSON.stringify({
|
||||||
|
jti: nanoid(8),
|
||||||
|
exp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const signature = makeHmac(header, payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: `${header}.${payload}.${signature}`,
|
||||||
|
exp: env.jwtLifetime - 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verify = (jwt) => {
|
||||||
|
const [header, payload, signature] = jwt.split(".", 3);
|
||||||
|
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||||
|
|
||||||
|
if ([header, payload, signature].join('.') !== jwt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifySignature = makeHmac(header, payload);
|
||||||
|
|
||||||
|
if (verifySignature !== signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
generate,
|
||||||
|
verify,
|
||||||
|
}
|
19
api/src/security/turnstile.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { env } from "../config.js";
|
||||||
|
|
||||||
|
export const verifyTurnstileToken = async (turnstileResponse, ip) => {
|
||||||
|
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
secret: env.turnstileSecret,
|
||||||
|
response: turnstileResponse,
|
||||||
|
remoteip: ip,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return !!result?.success;
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { createInternalStream } from './manage.js';
|
import HLS from "hls-parser";
|
||||||
import HLS from 'hls-parser';
|
import { createInternalStream } from "./manage.js";
|
||||||
|
|
||||||
function getURL(url) {
|
function getURL(url) {
|
||||||
try {
|
try {
|
|
@ -1,7 +1,7 @@
|
||||||
import { request } from 'undici';
|
import { request } from "undici";
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from "node:stream";
|
||||||
import { closeRequest, getHeaders, pipe } from './shared.js';
|
import { closeRequest, 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
|
||||||
const min = (a, b) => a < b ? a : b;
|
const min = (a, b) => a < b ? a : b;
|
|
@ -1,12 +1,13 @@
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { strict as assert } from "assert";
|
||||||
import { setMaxListeners } from "node:events";
|
import { setMaxListeners } from "node:events";
|
||||||
|
|
||||||
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 { closeRequest } from "./shared.js";
|
import { closeRequest } from "./shared.js";
|
||||||
|
import { decryptStream, encryptStream, generateHmac } from "../misc/crypto.js";
|
||||||
|
|
||||||
// optional dependency
|
// optional dependency
|
||||||
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
|
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
|
||||||
|
@ -21,7 +22,7 @@ streamCache.on("expired", (key) => {
|
||||||
streamCache.del(key);
|
streamCache.del(key);
|
||||||
})
|
})
|
||||||
|
|
||||||
const internalStreamCache = {};
|
const internalStreamCache = new Map();
|
||||||
const hmacSalt = randomBytes(64).toString('hex');
|
const hmacSalt = randomBytes(64).toString('hex');
|
||||||
|
|
||||||
export function createStream(obj) {
|
export function createStream(obj) {
|
||||||
|
@ -36,13 +37,15 @@ export function createStream(obj) {
|
||||||
urls: obj.u,
|
urls: obj.u,
|
||||||
service: obj.service,
|
service: obj.service,
|
||||||
filename: obj.filename,
|
filename: obj.filename,
|
||||||
audioFormat: obj.audioFormat,
|
|
||||||
isAudioOnly: !!obj.isAudioOnly,
|
requestIP: obj.requestIP,
|
||||||
headers: obj.headers,
|
headers: obj.headers,
|
||||||
copy: !!obj.copy,
|
|
||||||
mute: !!obj.mute,
|
|
||||||
metadata: obj.fileMetadata || false,
|
metadata: obj.fileMetadata || false,
|
||||||
requestIP: obj.requestIP
|
|
||||||
|
audioBitrate: obj.audioBitrate,
|
||||||
|
audioCopy: !!obj.audioCopy,
|
||||||
|
audioFormat: obj.audioFormat,
|
||||||
};
|
};
|
||||||
|
|
||||||
streamCache.set(
|
streamCache.set(
|
||||||
|
@ -50,7 +53,7 @@ export function createStream(obj) {
|
||||||
encryptStream(streamData, iv, secret)
|
encryptStream(streamData, iv, secret)
|
||||||
)
|
)
|
||||||
|
|
||||||
let streamLink = new URL('/api/stream', env.apiURL);
|
let streamLink = new URL('/tunnel', env.apiURL);
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
'id': streamID,
|
'id': streamID,
|
||||||
|
@ -68,7 +71,7 @@ export function createStream(obj) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInternalStream(id) {
|
export function getInternalStream(id) {
|
||||||
return internalStreamCache[id];
|
return internalStreamCache.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInternalStream(url, obj = {}) {
|
export function createInternalStream(url, obj = {}) {
|
||||||
|
@ -92,15 +95,15 @@ export function createInternalStream(url, obj = {}) {
|
||||||
headers = new Map(Object.entries(obj.headers));
|
headers = new Map(Object.entries(obj.headers));
|
||||||
}
|
}
|
||||||
|
|
||||||
internalStreamCache[streamID] = {
|
internalStreamCache.set(streamID, {
|
||||||
url,
|
url,
|
||||||
service: obj.service,
|
service: obj.service,
|
||||||
headers,
|
headers,
|
||||||
controller,
|
controller,
|
||||||
dispatcher
|
dispatcher
|
||||||
};
|
});
|
||||||
|
|
||||||
let streamLink = new URL('/api/istream', `http://127.0.0.1:${env.apiPort}`);
|
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.apiPort}`);
|
||||||
streamLink.searchParams.set('id', streamID);
|
streamLink.searchParams.set('id', streamID);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
@ -121,9 +124,9 @@ export function destroyInternalStream(url) {
|
||||||
|
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
if (internalStreamCache[id]) {
|
if (internalStreamCache.has(id)) {
|
||||||
closeRequest(internalStreamCache[id].controller);
|
closeRequest(getInternalStream(id)?.controller);
|
||||||
delete internalStreamCache[id];
|
internalStreamCache.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
33
api/src/stream/stream.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import stream from "./types.js";
|
||||||
|
|
||||||
|
import { closeResponse } from "./shared.js";
|
||||||
|
import { internalStream } from "./internal.js";
|
||||||
|
|
||||||
|
export default async function(res, streamInfo) {
|
||||||
|
try {
|
||||||
|
switch (streamInfo.type) {
|
||||||
|
case "proxy":
|
||||||
|
return await stream.proxy(streamInfo, res);
|
||||||
|
|
||||||
|
case "internal":
|
||||||
|
return internalStream(streamInfo, res);
|
||||||
|
|
||||||
|
case "merge":
|
||||||
|
return stream.merge(streamInfo, res);
|
||||||
|
|
||||||
|
case "remux":
|
||||||
|
case "mute":
|
||||||
|
return stream.remux(streamInfo, res);
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
return stream.convertAudio(streamInfo, res);
|
||||||
|
|
||||||
|
case "gif":
|
||||||
|
return stream.convertGif(streamInfo, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResponse(res);
|
||||||
|
} catch {
|
||||||
|
closeResponse(res);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,35 +3,42 @@ import ffmpeg from "ffmpeg-static";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { create as contentDisposition } from "content-disposition-header";
|
import { create as contentDisposition } from "content-disposition-header";
|
||||||
|
|
||||||
import { metadataManager } from "../sub/utils.js";
|
import { env } from "../config.js";
|
||||||
|
import { metadataManager } from "../misc/utils.js";
|
||||||
import { destroyInternalStream } from "./manage.js";
|
import { destroyInternalStream } from "./manage.js";
|
||||||
import { env, ffmpegArgs, hlsExceptions } from "../config.js";
|
import { hlsExceptions } from "../processing/service-config.js";
|
||||||
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
||||||
|
|
||||||
function toRawHeaders(headers) {
|
const ffmpegArgs = {
|
||||||
|
webm: ["-c:v", "copy", "-c:a", "copy"],
|
||||||
|
mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
|
||||||
|
m4a: ["-movflags", "frag_keyframe+empty_moov"],
|
||||||
|
gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRawHeaders = (headers) => {
|
||||||
return Object.entries(headers)
|
return Object.entries(headers)
|
||||||
.map(([key, value]) => `${key}: ${value}\r\n`)
|
.map(([key, value]) => `${key}: ${value}\r\n`)
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function killProcess(p) {
|
const killProcess = (p) => {
|
||||||
// ask the process to terminate itself gracefully
|
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
|
||||||
p?.kill('SIGTERM');
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (p?.exitCode === null)
|
if (p?.exitCode === null)
|
||||||
// brutally murder the process if it didn't quit
|
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
|
||||||
p?.kill('SIGKILL');
|
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommand(args) {
|
const getCommand = (args) => {
|
||||||
if (typeof env.processingPriority === 'number' && !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]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamDefault(streamInfo, res) {
|
const proxy = async (streamInfo, res) => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
closeRequest(abortController),
|
closeRequest(abortController),
|
||||||
|
@ -40,19 +47,21 @@ export async function streamDefault(streamInfo, res) {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let filename = streamInfo.filename;
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
if (streamInfo.isAudioOnly) {
|
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
|
||||||
filename = `${streamInfo.filename}.${streamInfo.audioFormat}`
|
|
||||||
}
|
|
||||||
res.setHeader('Content-disposition', contentDisposition(filename));
|
|
||||||
|
|
||||||
const { body: stream, headers } = await request(streamInfo.urls, {
|
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
|
||||||
headers: getHeaders(streamInfo.service),
|
headers: {
|
||||||
|
...getHeaders(streamInfo.service),
|
||||||
|
Range: streamInfo.range
|
||||||
|
},
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
maxRedirections: 16
|
maxRedirections: 16
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const headerName of ['content-type', 'content-length']) {
|
res.status(statusCode);
|
||||||
|
|
||||||
|
for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
|
||||||
if (headers[headerName]) {
|
if (headers[headerName]) {
|
||||||
res.setHeader(headerName, headers[headerName]);
|
res.setHeader(headerName, headers[headerName]);
|
||||||
}
|
}
|
||||||
|
@ -64,7 +73,7 @@ export async function streamDefault(streamInfo, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamLiveRender(streamInfo, res) {
|
const merge = (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
|
@ -124,61 +133,7 @@ export function streamLiveRender(streamInfo, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamAudioOnly(streamInfo, res) {
|
const remux = (streamInfo, res) => {
|
||||||
let process;
|
|
||||||
const shutdown = () => (
|
|
||||||
killProcess(process),
|
|
||||||
closeResponse(res),
|
|
||||||
destroyInternalStream(streamInfo.urls)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let args = [
|
|
||||||
'-loglevel', '-8',
|
|
||||||
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (streamInfo.service === "twitter") {
|
|
||||||
args.push('-seekable', '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
'-i', streamInfo.urls,
|
|
||||||
'-vn'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (streamInfo.metadata) {
|
|
||||||
args = args.concat(metadataManager(streamInfo.metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
args = args.concat(ffmpegArgs[streamInfo.copy ? 'copy' : 'audio']);
|
|
||||||
if (ffmpegArgs[streamInfo.audioFormat]) {
|
|
||||||
args = args.concat(ffmpegArgs[streamInfo.audioFormat])
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
|
|
||||||
|
|
||||||
process = spawn(...getCommand(args), {
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: [
|
|
||||||
'inherit', 'inherit', 'inherit',
|
|
||||||
'pipe'
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [,,, muxOutput] = process.stdio;
|
|
||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
res.setHeader('Content-Disposition', contentDisposition(`${streamInfo.filename}.${streamInfo.audioFormat}`));
|
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
|
||||||
res.on('finish', shutdown);
|
|
||||||
} catch {
|
|
||||||
shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamVideoOnly(streamInfo, res) {
|
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
|
@ -198,15 +153,18 @@ export function streamVideoOnly(streamInfo, res) {
|
||||||
|
|
||||||
args.push(
|
args.push(
|
||||||
'-i', streamInfo.urls,
|
'-i', streamInfo.urls,
|
||||||
'-c', 'copy'
|
'-c:v', 'copy',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (streamInfo.mute) {
|
if (streamInfo.type === "mute") {
|
||||||
args.push('-an')
|
args.push('-an');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hlsExceptions.includes(streamInfo.service)) {
|
if (hlsExceptions.includes(streamInfo.service)) {
|
||||||
args.push('-bsf:a', 'aac_adtstoasc')
|
if (streamInfo.type !== "mute") {
|
||||||
|
args.push('-c:a', 'aac')
|
||||||
|
}
|
||||||
|
args.push('-bsf:a', 'aac_adtstoasc');
|
||||||
}
|
}
|
||||||
|
|
||||||
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
||||||
|
@ -238,7 +196,74 @@ export function streamVideoOnly(streamInfo, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertToGif(streamInfo, res) {
|
const convertAudio = (streamInfo, res) => {
|
||||||
|
let process;
|
||||||
|
const shutdown = () => (
|
||||||
|
killProcess(process),
|
||||||
|
closeResponse(res),
|
||||||
|
destroyInternalStream(streamInfo.urls)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let args = [
|
||||||
|
'-loglevel', '-8',
|
||||||
|
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (streamInfo.service === "twitter") {
|
||||||
|
args.push('-seekable', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-i', streamInfo.urls,
|
||||||
|
'-vn'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (streamInfo.audioCopy) {
|
||||||
|
args.push("-c:a", "copy")
|
||||||
|
} else {
|
||||||
|
args.push("-b:a", `${streamInfo.audioBitrate}k`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.audioFormat === "mp3" && streamInfo.audioBitrate === "8") {
|
||||||
|
args.push("-ar", "12000");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.audioFormat === "opus") {
|
||||||
|
args.push("-vbr", "off")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpegArgs[streamInfo.audioFormat]) {
|
||||||
|
args = args.concat(ffmpegArgs[streamInfo.audioFormat])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.metadata) {
|
||||||
|
args = args.concat(metadataManager(streamInfo.metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
|
||||||
|
|
||||||
|
process = spawn(...getCommand(args), {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: [
|
||||||
|
'inherit', 'inherit', 'inherit',
|
||||||
|
'pipe'
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [,,, muxOutput] = process.stdio;
|
||||||
|
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
|
||||||
|
pipe(muxOutput, res, shutdown);
|
||||||
|
res.on('finish', shutdown);
|
||||||
|
} catch {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertGif = (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (killProcess(process), closeResponse(res));
|
const shutdown = () => (killProcess(process), closeResponse(res));
|
||||||
|
|
||||||
|
@ -252,7 +277,7 @@ export function convertToGif(streamInfo, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push('-i', streamInfo.urls);
|
args.push('-i', streamInfo.urls);
|
||||||
args = args.concat(ffmpegArgs["gif"]);
|
args = args.concat(ffmpegArgs.gif);
|
||||||
args.push('-f', "gif", 'pipe:3');
|
args.push('-f', "gif", 'pipe:3');
|
||||||
|
|
||||||
process = spawn(...getCommand(args), {
|
process = spawn(...getCommand(args), {
|
||||||
|
@ -276,3 +301,11 @@ export function convertToGif(streamInfo, res) {
|
||||||
shutdown();
|
shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
proxy,
|
||||||
|
merge,
|
||||||
|
remux,
|
||||||
|
convertAudio,
|
||||||
|
convertGif,
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { Innertube } from 'youtubei.js';
|
import { Innertube } from 'youtubei.js';
|
||||||
import { Red } from '../modules/sub/consoleText.js'
|
import { Red } from '../misc/console-text.js'
|
||||||
|
|
||||||
const bail = (...msg) => {
|
const bail = (...msg) => {
|
||||||
console.error(...msg);
|
console.error(...msg);
|
|
@ -1,7 +1,7 @@
|
||||||
import { existsSync, unlinkSync, appendFileSync } from "fs";
|
import { existsSync, unlinkSync, appendFileSync } from "fs";
|
||||||
import { createInterface } from "readline";
|
import { createInterface } from "readline";
|
||||||
import { Cyan, Bright } from "./sub/consoleText.js";
|
import { Cyan, Bright } from "./misc/console-text.js";
|
||||||
import { loadJSON } from "./sub/loadFromFs.js";
|
import { loadJSON } from "./misc/load-from-fs.js";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
const { version } = loadJSON("./package.json");
|
const { version } = loadJSON("./package.json");
|
|
@ -1,20 +1,21 @@
|
||||||
import { env } from "../modules/config.js";
|
import { env } from "../config.js";
|
||||||
import { runTest } from "../modules/test.js";
|
import { runTest } from "../misc/run-test.js";
|
||||||
import { loadLoc } from "../localization/manager.js";
|
import { loadJSON } from "../misc/load-from-fs.js";
|
||||||
import { loadJSON } from "../modules/sub/loadFromFs.js";
|
import { Red, Bright } from "../misc/console-text.js";
|
||||||
import { Red, Bright } from "../modules/sub/consoleText.js";
|
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||||
|
|
||||||
|
import { services } from "../processing/service-config.js";
|
||||||
|
|
||||||
const tests = loadJSON('./src/util/tests.json');
|
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
|
// services that are known to frequently fail due to external
|
||||||
// factors (e.g. rate limiting)
|
// factors (e.g. rate limiting)
|
||||||
const finnicky = new Set(['bilibili', 'instagram', 'youtube'])
|
const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube'])
|
||||||
|
|
||||||
const action = process.argv[2];
|
const action = process.argv[2];
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "get-services":
|
case "get-services":
|
||||||
const fromConfig = Object.keys(services.config);
|
const fromConfig = Object.keys(services);
|
||||||
|
|
||||||
const missingTests = fromConfig.filter(
|
const missingTests = fromConfig.filter(
|
||||||
service => !tests[service] || tests[service].length === 0
|
service => !tests[service] || tests[service].length === 0
|
||||||
|
@ -38,9 +39,9 @@ switch (action) {
|
||||||
console.error('no such service:', service);
|
console.error('no such service:', service);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadLoc();
|
|
||||||
env.streamLifespan = 10000;
|
env.streamLifespan = 10000;
|
||||||
env.apiURL = 'http://x';
|
env.apiURL = 'http://x';
|
||||||
|
randomizeCiphers();
|
||||||
|
|
||||||
for (const test of tests[service]) {
|
for (const test of tests[service]) {
|
||||||
const { name, url, params, expected } = test;
|
const { name, url, params, expected } = test;
|
|
@ -1,12 +1,11 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import "../modules/sub/alias-envs.js";
|
|
||||||
|
|
||||||
import { services } from "../modules/config.js";
|
import { services } from "../processing/service-config.js";
|
||||||
import { extract } from "../modules/processing/url.js";
|
import { extract } from "../processing/url.js";
|
||||||
import match from "../modules/processing/match.js";
|
import match from "../processing/match.js";
|
||||||
import { loadJSON } from "../modules/sub/loadFromFs.js";
|
import { loadJSON } from "../misc/load-from-fs.js";
|
||||||
import { normalizeRequest } from "../modules/processing/request.js";
|
import { normalizeRequest } from "../processing/request.js";
|
||||||
import { env } from "../modules/config.js";
|
import { env } from "../config.js";
|
||||||
|
|
||||||
env.apiURL = 'http://localhost:9000'
|
env.apiURL = 'http://localhost:9000'
|
||||||
let tests = loadJSON('./src/util/tests.json');
|
let tests = loadJSON('./src/util/tests.json');
|
||||||
|
@ -35,14 +34,20 @@ for (let i in services) {
|
||||||
let params = {...{url: test.url}, ...test.params};
|
let params = {...{url: test.url}, ...test.params};
|
||||||
console.log(params);
|
console.log(params);
|
||||||
|
|
||||||
let chck = normalizeRequest(params);
|
let chck = await normalizeRequest(params);
|
||||||
if (chck) {
|
if (chck.success) {
|
||||||
|
chck = chck.data;
|
||||||
|
|
||||||
const parsed = extract(chck.url);
|
const parsed = extract(chck.url);
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
throw `Invalid URL: ${chck.url}`
|
throw `Invalid URL: ${chck.url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
let j = await match(parsed.host, parsed.patternMatch, "en", chck);
|
let j = await match({
|
||||||
|
host: parsed.host,
|
||||||
|
patternMatch: parsed.patternMatch,
|
||||||
|
params: chck,
|
||||||
|
});
|
||||||
console.log('\nReceived:');
|
console.log('\nReceived:');
|
||||||
console.log(j)
|
console.log(j)
|
||||||
if (j.status === test.expected.code && j.body.status === test.expected.status) {
|
if (j.status === test.expected.code && j.body.status === test.expected.status) {
|
1461
api/src/util/tests.json
Normal file
131
docs/api.md
|
@ -1,77 +1,110 @@
|
||||||
# cobalt api documentation
|
# cobalt api documentation
|
||||||
this document provides info about methods and acceptable variables for all cobalt api requests.
|
this document provides info about methods and acceptable variables for all cobalt api requests.
|
||||||
|
|
||||||
```
|
> if you are looking for the documentation for the old (7.x) api, you can find
|
||||||
👍 you can use api.cobalt.tools in your projects for free, just don't be an asshole.
|
> it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
|
||||||
```
|
<!-- TODO: authorization -->
|
||||||
|
|
||||||
## POST: `/api/json`
|
## POST: `/`
|
||||||
cobalt's main processing endpoint.
|
cobalt's main processing endpoint.
|
||||||
|
|
||||||
request body type: `application/json`
|
request body type: `application/json`
|
||||||
response body type: `application/json`
|
response body type: `application/json`
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ you must include Accept and Content-Type headers with every POST /api/json request.
|
⚠️ you must include Accept and Content-Type headers with every `POST /` request.
|
||||||
|
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
### request body variables
|
### request body
|
||||||
| key | type | variables | default | description |
|
| key | type | expected value(s) | default | description |
|
||||||
|:------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
|
|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
|
||||||
| `url` | `string` | URL encoded as URI | `null` | **must** be included in every request. |
|
| `url` | `string` | URL to download | -- | **must** be included in every request. |
|
||||||
| `vCodec` | `string` | `h264 / av1 / vp9` | `h264` | applies only to youtube downloads. `h264` is recommended for phones. |
|
| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
|
||||||
| `vQuality` | `string` | `144 / ... / 2160 / max` | `720` | `720` quality is recommended for phones. |
|
| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
|
||||||
| `aFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
|
| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. |
|
||||||
| `filenamePattern` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
|
| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
|
||||||
| `isAudioOnly` | `boolean` | `true / false` | `false` | |
|
| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. |
|
||||||
| `isTTFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
|
||||||
| `isAudioMuted` | `boolean` | `true / false` | `false` | disables audio track in video downloads. |
|
| `youtubeDubLang` | `string` | `en / ru / cs / ja / ...` | -- | specifies the language of audio to download, when the youtube video is dubbed |
|
||||||
| `dubLang` | `boolean` | `true / false` | `false` | backend uses Accept-Language header for youtube video audio tracks when `true`. |
|
| `youtubeDubBrowserLang` | `boolean` | `true / false` | `false` | uses value from the Accept-Language header for `youtubeDubLang`. |
|
||||||
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
|
||||||
| `twitterGif` | `boolean` | `true / false` | `false` | changes whether twitter gifs are converted to .gif |
|
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
||||||
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
|
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
||||||
|
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
|
||||||
|
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
|
||||||
|
|
||||||
### response body variables
|
### response
|
||||||
| key | type | variables |
|
the response will always be a JSON object containing the `status` key, which will be one of:
|
||||||
|
- `error` - something went wrong
|
||||||
|
- `picker` - we have multiple items to choose from
|
||||||
|
- `redirect` - you are being redirected to the direct service URL
|
||||||
|
- `tunnel` - cobalt is proxying the download for you
|
||||||
|
|
||||||
|
### tunnel/redirect response
|
||||||
|
| key | type | values |
|
||||||
|:-------------|:---------|:------------------------------------------------------------|
|
|:-------------|:---------|:------------------------------------------------------------|
|
||||||
| `status` | `string` | `error / redirect / stream / success / rate-limit / picker` |
|
| `status` | `string` | `tunnel / redirect` |
|
||||||
| `text` | `string` | various text, mostly used for errors |
|
| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
|
||||||
| `url` | `string` | direct link to a file or a link to cobalt's live render |
|
| `filename` | `string` | cobalt-generated filename for the file being downloaded |
|
||||||
| `pickerType` | `string` | `various / images` |
|
|
||||||
| `picker` | `array` | array of picker items |
|
|
||||||
| `audio` | `string` | direct link to a file or a link to cobalt's live render |
|
|
||||||
|
|
||||||
### picker item variables
|
### picker response
|
||||||
item type: `object`
|
| key | type | values |
|
||||||
|
|:----------------|:---------|:-------------------------------------------------------------------------------------------------|
|
||||||
|
| `status` | `string` | `picker` |
|
||||||
|
| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio |
|
||||||
|
| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists |
|
||||||
|
| `picker` | `array` | array of objects containing the individual media |
|
||||||
|
|
||||||
| key | type | variables | description |
|
#### picker object
|
||||||
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
|
| key | type | values |
|
||||||
| `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 | |
|
| `type` | `string` | `photo` / `video` / `gif` |
|
||||||
| `thumb` | `string` | item thumbnail that's displayed in the picker | used for `video` and `gif` types |
|
| `url` | `string` | |
|
||||||
|
| `thumb` | `string` | **optional** thumbnail url |
|
||||||
|
|
||||||
## GET: `/api/stream`
|
### error response
|
||||||
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
|
| key | type | values |
|
||||||
from a successful call to `/api/json`. however, the parameters passed to it are **opaque**
|
|:-------------|:---------|:------------------------------------------------------------|
|
||||||
and **unmodifiable** from your (the api client's) perspective, and can change between versions.
|
| `status` | `string` | `error` |
|
||||||
|
| `error` | `object` | contains more context about the error |
|
||||||
|
|
||||||
therefore you don't need to worry about what they mean - but if you really want to know, you can
|
#### error object
|
||||||
[read the source code](/src/modules/stream/manage.js).
|
| key | type | values |
|
||||||
|
|:-------------|:---------|:------------------------------------------------------------|
|
||||||
|
| `code` | `string` | machine-readable error code explaining the failure reason |
|
||||||
|
| `context` | `object` | **optional** container for providing more context |
|
||||||
|
|
||||||
## GET: `/api/serverInfo`
|
#### error.context object
|
||||||
|
| key | type | values |
|
||||||
|
|:-------------|:---------|:---------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `service` | `string` | **optional**, stating which service was being downloaded from |
|
||||||
|
| `limit` | `number` | **optional** number providing the ratelimit maximum number of requests, or maximum downloadable video duration |
|
||||||
|
|
||||||
|
## GET: `/`
|
||||||
returns current basic server info.
|
returns current basic server info.
|
||||||
response body type: `application/json`
|
response body type: `application/json`
|
||||||
|
|
||||||
### response body variables
|
### response body
|
||||||
|
| key | type | variables |
|
||||||
|
|:------------|:---------|:---------------------------------------------------------|
|
||||||
|
| `cobalt` | `object` | information about the cobalt instance |
|
||||||
|
| `git` | `object` | information about the codebase that is currently running |
|
||||||
|
|
||||||
|
#### cobalt object
|
||||||
|
| key | type | description |
|
||||||
|
|:----------------|:-----------|:-----------------------------------------------|
|
||||||
|
| `version` | `string` | current version |
|
||||||
|
| `url` | `string` | server url |
|
||||||
|
| `startTime` | `string` | server start time in unix milliseconds |
|
||||||
|
| `durationLimit` | `number` | maximum downloadable video length in seconds |
|
||||||
|
| `services` | `string[]` | array of services which this instance supports |
|
||||||
|
|
||||||
|
#### git object
|
||||||
| key | type | variables |
|
| key | type | variables |
|
||||||
|:------------|:---------|:------------------|
|
|:------------|:---------|:------------------|
|
||||||
| `version` | `string` | cobalt version |
|
| `commit` | `string` | commit hash |
|
||||||
| `commit` | `string` | git commit |
|
|
||||||
| `branch` | `string` | git branch |
|
| `branch` | `string` | git branch |
|
||||||
| `name` | `string` | server name |
|
| `remote` | `string` | git remote |
|
||||||
| `url` | `string` | server url |
|
|
||||||
| `cors` | `number` | cors status |
|
|
||||||
| `startTime` | `string` | server start time |
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
version: '3.5'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cobalt-api:
|
cobalt-api:
|
||||||
image: ghcr.io/imputnet/cobalt:7
|
image: ghcr.io/imputnet/cobalt:7
|
||||||
|
@ -31,30 +29,6 @@ services:
|
||||||
#volumes:
|
#volumes:
|
||||||
#- ./cookies.json:/cookies.json
|
#- ./cookies.json:/cookies.json
|
||||||
|
|
||||||
cobalt-web:
|
|
||||||
image: ghcr.io/imputnet/cobalt:7
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: cobalt-web
|
|
||||||
|
|
||||||
init: true
|
|
||||||
|
|
||||||
# if container doesn't run detached on your machine, uncomment the next line
|
|
||||||
#tty: true
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- 9001:9001/tcp
|
|
||||||
# if you're using a reverse proxy, uncomment the next line and remove the one above (9001:9001/tcp):
|
|
||||||
#- 127.0.0.1:9001:9001
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# replace https://cobalt.tools/ with your instance's target url in same format
|
|
||||||
WEB_URL: "https://cobalt.tools/"
|
|
||||||
# replace https://api.cobalt.tools/ with preferred api instance url
|
|
||||||
API_URL: "https://api.cobalt.tools/"
|
|
||||||
|
|
||||||
labels:
|
|
||||||
- com.centurylinklabs.watchtower.scope=cobalt
|
|
||||||
|
|
||||||
# update the cobalt image automatically with watchtower
|
# update the cobalt image automatically with watchtower
|
||||||
watchtower:
|
watchtower:
|
||||||
image: ghcr.io/containrrr/watchtower
|
image: ghcr.io/containrrr/watchtower
|
||||||
|
|
|
@ -73,15 +73,3 @@ setting a `FREEBIND_CIDR` allows cobalt to pick a random IP for every download a
|
||||||
requests it makes for that particular download. to use freebind in cobalt, you need to follow its [setup instructions](https://github.com/imputnet/freebind.js?tab=readme-ov-file#setup) first. if you configure this option while running cobalt
|
requests it makes for that particular download. to use freebind in cobalt, you need to follow its [setup instructions](https://github.com/imputnet/freebind.js?tab=readme-ov-file#setup) first. if you configure this option while running cobalt
|
||||||
in a docker container, you also need to set the `API_LISTEN_ADDRESS` env to `127.0.0.1`, and set
|
in a docker container, you also need to set the `API_LISTEN_ADDRESS` env to `127.0.0.1`, and set
|
||||||
`network_mode` for the container to `host`.
|
`network_mode` for the container to `host`.
|
||||||
|
|
||||||
### variables for web
|
|
||||||
| variable name | default | example | description |
|
|
||||||
|:---------------------|:----------------------------|:----------------------------|:--------------------------------------------------------------------------------------|
|
|
||||||
| `WEB_PORT` | `9001` | `9001` | changes port from which frontend server is accessible. |
|
|
||||||
| `WEB_URL` | ➖ | `https://cobalt.tools/` | changes url from which frontend server is accessible. <br> ***REQUIRED TO RUN WEB***. |
|
|
||||||
| `API_URL` | `https://api.cobalt.tools/` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. |
|
|
||||||
| `SHOW_SPONSORS` | `0` | `1` | toggles sponsor list in about popup. <br> `0`: disabled. `1`: enabled. |
|
|
||||||
| `IS_BETA` | `0` | `1` | toggles beta tag next to cobalt logo. <br> `0`: disabled. `1`: enabled. |
|
|
||||||
| `PLAUSIBLE_HOSTNAME` | ➖ | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. |
|
|
||||||
|
|
||||||
\* don't use plausible.io as receiver backend unless you paid for their cloud service. use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed.
|
|
||||||
|
|
1142
package-lock.json
generated
45
package.json
|
@ -1,48 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "cobalt",
|
"name": "cobalt",
|
||||||
"description": "save what you love",
|
"packageManager": "pnpm@9.6.0",
|
||||||
"version": "7.15",
|
|
||||||
"author": "imput",
|
|
||||||
"exports": "./src/cobalt.js",
|
|
||||||
"type": "module",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"pnpm": ">=9"
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/cobalt",
|
|
||||||
"setup": "node src/modules/setup",
|
|
||||||
"test": "node src/util/test",
|
|
||||||
"build": "node src/modules/buildStatic",
|
|
||||||
"token:youtube": "node src/util/generate-youtube-tokens"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/imputnet/cobalt.git"
|
|
||||||
},
|
|
||||||
"license": "AGPL-3.0",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/imputnet/cobalt/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/imputnet/cobalt#readme",
|
|
||||||
"dependencies": {
|
|
||||||
"content-disposition-header": "0.6.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.0.1",
|
|
||||||
"esbuild": "^0.14.51",
|
|
||||||
"express": "^4.18.1",
|
|
||||||
"express-rate-limit": "^6.3.0",
|
|
||||||
"ffmpeg-static": "^5.1.0",
|
|
||||||
"hls-parser": "^0.10.7",
|
|
||||||
"ipaddr.js": "2.1.0",
|
|
||||||
"nanoid": "^4.0.2",
|
|
||||||
"node-cache": "^5.1.2",
|
|
||||||
"psl": "1.9.0",
|
|
||||||
"set-cookie-parser": "2.6.0",
|
|
||||||
"undici": "^5.19.1",
|
|
||||||
"url-pattern": "1.0.3",
|
|
||||||
"youtubei.js": "^10.3.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"freebind": "^0.2.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
2
packages/api-client/.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# Ignore artifacts:
|
||||||
|
dist
|
6
packages/api-client/.prettierrc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
21
packages/api-client/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 imput
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
15
packages/api-client/package.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "@imput/cobalt-client",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "imput <meow@imput.net>",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "3.3.3",
|
||||||
|
"tsup": "^8.2.4",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
14
packages/api-client/tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"include": ["src"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
}
|
||||||
|
}
|
6
packages/version-info/index.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
declare module "@imput/version-info" {
|
||||||
|
export function getCommit(): Promise<string | undefined>;
|
||||||
|
export function getBranch(): Promise<string | undefined>;
|
||||||
|
export function getRemote(): Promise<string>;
|
||||||
|
export function getVersion(): Promise<string>;
|
||||||
|
}
|
78
packages/version-info/index.js
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, parse } from 'node:path';
|
||||||
|
import { cwd } from 'node:process';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const findFile = (file) => {
|
||||||
|
let dir = cwd();
|
||||||
|
|
||||||
|
while (dir !== parse(dir).root) {
|
||||||
|
if (existsSync(join(dir, file))) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = join(dir, '../');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = findFile('.git');
|
||||||
|
const pack = findFile('package.json');
|
||||||
|
|
||||||
|
const readGit = (filename) => {
|
||||||
|
if (!root) {
|
||||||
|
throw 'no git repository root found';
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFile(join(root, filename), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCommit = async () => {
|
||||||
|
return (await readGit('.git/logs/HEAD'))
|
||||||
|
?.split('\n')
|
||||||
|
?.filter(String)
|
||||||
|
?.pop()
|
||||||
|
?.split(' ')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBranch = async () => {
|
||||||
|
if (process.env.CF_PAGES_BRANCH) {
|
||||||
|
return process.env.CF_PAGES_BRANCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await readGit('.git/HEAD'))
|
||||||
|
?.replace(/^ref: refs\/heads\//, '')
|
||||||
|
?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRemote = async () => {
|
||||||
|
let remote = (await readGit('.git/config'))
|
||||||
|
?.split('\n')
|
||||||
|
?.find(line => line.includes('url = '))
|
||||||
|
?.split('url = ')[1];
|
||||||
|
|
||||||
|
if (remote?.startsWith('git@')) {
|
||||||
|
remote = remote.split(':')[1];
|
||||||
|
} else if (remote?.startsWith('http')) {
|
||||||
|
remote = new URL(remote).pathname.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
remote = remote?.replace(/\.git$/, '');
|
||||||
|
|
||||||
|
if (!remote) {
|
||||||
|
throw 'could not parse remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVersion = async () => {
|
||||||
|
if (!pack) {
|
||||||
|
throw 'no package root found';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version } = JSON.parse(
|
||||||
|
await readFile(join(pack, 'package.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
18
packages/version-info/package.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@imput/version-info",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "helper package for cobalt that provides commit info & version from package file.",
|
||||||
|
"main": "index.js",
|
||||||
|
"types": "index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/imputnet/cobalt.git"
|
||||||
|
},
|
||||||
|
"author": "imput",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/imputnet/cobalt/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/imputnet/cobalt#readme"
|
||||||
|
}
|
4192
pnpm-lock.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
packages:
|
||||||
|
- "api"
|
||||||
|
- "web"
|
||||||
|
- "packages/*"
|
|
@ -1,38 +0,0 @@
|
||||||
import "dotenv/config";
|
|
||||||
import "./modules/sub/alias-envs.js";
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
|
|
||||||
import { Bright, Green, Red } from "./modules/sub/consoleText.js";
|
|
||||||
import { getCurrentBranch, shortCommit } from "./modules/sub/currentCommit.js";
|
|
||||||
import { loadLoc } from "./localization/manager.js";
|
|
||||||
import { mode } from "./modules/config.js"
|
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
const gitCommit = shortCommit();
|
|
||||||
const gitBranch = getCurrentBranch();
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename).slice(0, -4);
|
|
||||||
|
|
||||||
app.disable('x-powered-by');
|
|
||||||
|
|
||||||
await loadLoc();
|
|
||||||
|
|
||||||
if (mode === 'API') {
|
|
||||||
const { runAPI } = await import('./core/api.js');
|
|
||||||
runAPI(express, app, gitCommit, gitBranch, __dirname)
|
|
||||||
} else if (mode === 'WEB') {
|
|
||||||
const { runWeb } = await import('./core/web.js');
|
|
||||||
await runWeb(express, app, gitCommit, gitBranch, __dirname)
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
Red(`cobalt wasn't configured yet or configuration is invalid.\n`)
|
|
||||||
+ Bright(`please run the setup script to fix this: `)
|
|
||||||
+ Green(`npm run setup`)
|
|
||||||
)
|
|
||||||
}
|
|
103
src/config.json
|
@ -1,103 +0,0 @@
|
||||||
{
|
|
||||||
"genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
|
|
||||||
"authorInfo": {
|
|
||||||
"support": {
|
|
||||||
"default": {
|
|
||||||
"email": {
|
|
||||||
"emoji": "📧",
|
|
||||||
"url": "mailto:support@cobalt.tools",
|
|
||||||
"name": "support@cobalt.tools"
|
|
||||||
},
|
|
||||||
"twitter": {
|
|
||||||
"emoji": "🐦",
|
|
||||||
"url": "https://twitter.com/justusecobalt",
|
|
||||||
"name": "@justusecobalt"
|
|
||||||
},
|
|
||||||
"discord": {
|
|
||||||
"emoji": "👾",
|
|
||||||
"url": "https://discord.gg/pQPt8HBUPu",
|
|
||||||
"name": "cobalt discord server"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"telegram": {
|
|
||||||
"emoji": "📬",
|
|
||||||
"url": "https://t.me/justusecobalt_ru",
|
|
||||||
"name": "канал в telegram"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"emoji": "📧",
|
|
||||||
"url": "mailto:support@cobalt.tools",
|
|
||||||
"name": "support@cobalt.tools"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"donations": {
|
|
||||||
"crypto": {
|
|
||||||
"monero": "4B1SNB6s8Pq1hxjNeKPEe8Qa8EP3zdL16Sqsa7QDoJcUecKQzEj9BMxWnEnTGu12doKLJBKRDUqnn6V9qfSdXpXi3Nw5Uod",
|
|
||||||
"litecoin": "ltc1qvp0xhrk2m7pa6p6z844qcslfyxv4p3vf95rhna",
|
|
||||||
"ethereum": "0x4B4cF23051c78c7A7E0eA09d39099621c46bc302",
|
|
||||||
"usdt-erc20": "0x4B4cF23051c78c7A7E0eA09d39099621c46bc302",
|
|
||||||
"usdt-trc20": "TVbx7YT3rBfu931Gxko6pRfXtedYqbgnBB",
|
|
||||||
"bitcoin": "bc1qlvcnlnyzfsgnuxyxsv3k0p0q0yln0azjpadyx4",
|
|
||||||
"bitcoin-alt": "18PKf6N2cHrmSzz9ZzTSvDd2jAkqGC7SxA",
|
|
||||||
"ton": "UQA3SO-hHZq1oCCT--u6or6ollB8fd2o52aD8mXiLk9iDZd3"
|
|
||||||
},
|
|
||||||
"links": {
|
|
||||||
"boosty": "https://boosty.to/wukko/donate"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"links": {
|
|
||||||
"saveToGalleryShortcut": "https://www.icloud.com/shortcuts/14e9aebf04b24156acc34ceccf7e6fcd",
|
|
||||||
"saveToFilesShortcut": "https://www.icloud.com/shortcuts/2134cd9d4d6b41448b2201f933542b2e",
|
|
||||||
"statusPage": "https://status.cobalt.tools/",
|
|
||||||
"troubleshootingGuide": "https://github.com/imputnet/cobalt/blob/current/docs/troubleshooting.md"
|
|
||||||
},
|
|
||||||
"celebrations": {
|
|
||||||
"01-01": "🎄",
|
|
||||||
"02-17": "😺",
|
|
||||||
"02-22": "😺",
|
|
||||||
"03-01": "😺",
|
|
||||||
"03-08": "💪",
|
|
||||||
"05-26": "🎂",
|
|
||||||
"08-08": "😺",
|
|
||||||
"08-26": "🐶",
|
|
||||||
"10-29": "😺",
|
|
||||||
"10-30": "🎃",
|
|
||||||
"10-31": "🎃",
|
|
||||||
"11-01": "🕯️",
|
|
||||||
"11-02": "🕯️",
|
|
||||||
"12-20": "🎄",
|
|
||||||
"12-21": "🎄",
|
|
||||||
"12-22": "🎄",
|
|
||||||
"12-23": "🎄",
|
|
||||||
"12-24": "🎄",
|
|
||||||
"12-25": "🎄",
|
|
||||||
"12-26": "🎄",
|
|
||||||
"12-27": "🎄",
|
|
||||||
"12-28": "🎄",
|
|
||||||
"12-29": "🎄",
|
|
||||||
"12-30": "🎄",
|
|
||||||
"12-31": "🎄"
|
|
||||||
},
|
|
||||||
"supportedAudio": ["mp3", "ogg", "wav", "opus"],
|
|
||||||
"ffmpegArgs": {
|
|
||||||
"webm": ["-c:v", "copy", "-c:a", "copy"],
|
|
||||||
"mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
|
|
||||||
"copy": ["-c:a", "copy"],
|
|
||||||
"audio": ["-ar", "48000", "-ac", "2", "-b:a", "320k"],
|
|
||||||
"m4a": ["-movflags", "frag_keyframe+empty_moov"],
|
|
||||||
"gif": ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
|
|
||||||
},
|
|
||||||
"sponsors": [{
|
|
||||||
"name": "royale",
|
|
||||||
"fullName": "RoyaleHosting",
|
|
||||||
"url": "https://royalehosting.net/?partner=cobalt",
|
|
||||||
"logo": {
|
|
||||||
"width": 605,
|
|
||||||
"height": 136,
|
|
||||||
"scale": 5
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
228
src/core/api.js
|
@ -1,228 +0,0 @@
|
||||||
import cors from "cors";
|
|
||||||
import rateLimit from "express-rate-limit";
|
|
||||||
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
|
||||||
|
|
||||||
import { env, version } from "../modules/config.js";
|
|
||||||
|
|
||||||
import { generateHmac, generateSalt } from "../modules/sub/crypto.js";
|
|
||||||
import { Bright, Cyan } from "../modules/sub/consoleText.js";
|
|
||||||
import { languageCode } from "../modules/sub/utils.js";
|
|
||||||
import loc from "../localization/manager.js";
|
|
||||||
|
|
||||||
import { createResponse, normalizeRequest, getIP } from "../modules/processing/request.js";
|
|
||||||
import { verifyStream, getInternalStream } from "../modules/stream/manage.js";
|
|
||||||
import { randomizeCiphers } from '../modules/sub/randomize-ciphers.js';
|
|
||||||
import { extract } from "../modules/processing/url.js";
|
|
||||||
import match from "../modules/processing/match.js";
|
|
||||||
import stream from "../modules/stream/stream.js";
|
|
||||||
|
|
||||||
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
|
|
||||||
|
|
||||||
const ipSalt = generateSalt();
|
|
||||||
const corsConfig = env.corsWildcard ? {} : {
|
|
||||||
origin: env.corsURL,
|
|
||||||
optionsSuccessStatus: 200
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
|
||||||
const startTime = new Date();
|
|
||||||
const startTimestamp = startTime.getTime();
|
|
||||||
|
|
||||||
const serverInfo = {
|
|
||||||
version: version,
|
|
||||||
commit: gitCommit,
|
|
||||||
branch: gitBranch,
|
|
||||||
name: env.apiName,
|
|
||||||
url: env.apiURL,
|
|
||||||
cors: Number(env.corsWildcard),
|
|
||||||
startTime: `${startTimestamp}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: env.rateLimitWindow * 1000,
|
|
||||||
max: env.rateLimitMax,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: req => generateHmac(getIP(req), ipSalt),
|
|
||||||
handler: (req, res) => {
|
|
||||||
return res.status(429).json({
|
|
||||||
"status": "rate-limit",
|
|
||||||
"text": loc(languageCode(req), 'ErrorRateLimit', env.rateLimitWindow)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const apiLimiterStream = rateLimit({
|
|
||||||
windowMs: env.rateLimitWindow * 1000,
|
|
||||||
max: env.rateLimitMax,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: req => generateHmac(getIP(req), ipSalt),
|
|
||||||
handler: (req, res) => {
|
|
||||||
return res.sendStatus(429)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.set('trust proxy', ['loopback', 'uniquelocal']);
|
|
||||||
|
|
||||||
app.use('/api', cors({
|
|
||||||
methods: ['GET', 'POST'],
|
|
||||||
exposedHeaders: [
|
|
||||||
'Ratelimit-Limit',
|
|
||||||
'Ratelimit-Policy',
|
|
||||||
'Ratelimit-Remaining',
|
|
||||||
'Ratelimit-Reset'
|
|
||||||
],
|
|
||||||
...corsConfig,
|
|
||||||
}))
|
|
||||||
|
|
||||||
app.use('/api/json', apiLimiter);
|
|
||||||
app.use('/api/stream', apiLimiterStream);
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
try {
|
|
||||||
decodeURIComponent(req.path)
|
|
||||||
} catch {
|
|
||||||
return res.redirect('/')
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use('/api/json', express.json({ limit: 1024 }));
|
|
||||||
app.use('/api/json', (err, _, res, next) => {
|
|
||||||
if (err) {
|
|
||||||
return res.status(400).json({
|
|
||||||
status: "error",
|
|
||||||
text: "invalid json body"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/json', async (req, res) => {
|
|
||||||
const request = req.body;
|
|
||||||
const lang = languageCode(req);
|
|
||||||
|
|
||||||
const fail = (t) => {
|
|
||||||
const { status, body } = createResponse("error", { t: loc(lang, t) });
|
|
||||||
res.status(status).json(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!acceptRegex.test(req.header('Accept'))) {
|
|
||||||
return fail('ErrorInvalidAcceptHeader');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!acceptRegex.test(req.header('Content-Type'))) {
|
|
||||||
return fail('ErrorInvalidContentType');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!request.url) {
|
|
||||||
return fail('ErrorNoLink');
|
|
||||||
}
|
|
||||||
|
|
||||||
request.dubLang = request.dubLang ? lang : false;
|
|
||||||
const normalizedRequest = normalizeRequest(request);
|
|
||||||
if (!normalizedRequest) {
|
|
||||||
return fail('ErrorCantProcess');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = extract(normalizedRequest.url);
|
|
||||||
if (parsed === null) {
|
|
||||||
return fail('ErrorUnsupported');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await match(
|
|
||||||
parsed.host, parsed.patternMatch, lang, normalizedRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(result.status).json(result.body);
|
|
||||||
} catch {
|
|
||||||
fail('ErrorSomethingWentWrong');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/stream', (req, res) => {
|
|
||||||
const id = String(req.query.id);
|
|
||||||
const exp = String(req.query.exp);
|
|
||||||
const sig = String(req.query.sig);
|
|
||||||
const sec = String(req.query.sec);
|
|
||||||
const iv = String(req.query.iv);
|
|
||||||
|
|
||||||
const checkQueries = id && exp && sig && sec && iv;
|
|
||||||
const checkBaseLength = id.length === 21 && exp.length === 13;
|
|
||||||
const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;
|
|
||||||
|
|
||||||
if (!checkQueries || !checkBaseLength || !checkSafeLength) {
|
|
||||||
return res.sendStatus(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// rate limit probe, will not return json after 8.0
|
|
||||||
if (req.query.p) {
|
|
||||||
return res.status(200).json({
|
|
||||||
status: "continue"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamInfo = verifyStream(id, sig, exp, sec, iv);
|
|
||||||
if (!streamInfo?.service) {
|
|
||||||
return res.sendStatus(streamInfo.status);
|
|
||||||
}
|
|
||||||
return stream(res, streamInfo);
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/istream', (req, res) => {
|
|
||||||
if (!req.ip.endsWith('127.0.0.1')) {
|
|
||||||
return res.sendStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(req.query.id).length !== 21) {
|
|
||||||
return res.sendStatus(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamInfo = getInternalStream(req.query.id);
|
|
||||||
if (!streamInfo) {
|
|
||||||
return res.sendStatus(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
streamInfo.headers = new Map([
|
|
||||||
...(streamInfo.headers || []),
|
|
||||||
...Object.entries(req.headers)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return stream(res, { type: 'internal', ...streamInfo });
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/serverInfo', (_, res) => {
|
|
||||||
return res.status(200).json(serverInfo);
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/favicon.ico', (req, res) => {
|
|
||||||
res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/*', (req, res) => {
|
|
||||||
res.redirect('/api/serverInfo')
|
|
||||||
})
|
|
||||||
|
|
||||||
randomizeCiphers();
|
|
||||||
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, () => {
|
|
||||||
console.log(`\n` +
|
|
||||||
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
|
||||||
`Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` +
|
|
||||||
`URL: ${Cyan(`${env.apiURL}`)}\n` +
|
|
||||||
`Port: ${env.apiPort}\n`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,97 +0,0 @@
|
||||||
|
|
||||||
import { Bright, Cyan } from "../modules/sub/consoleText.js";
|
|
||||||
import { languageCode } from "../modules/sub/utils.js";
|
|
||||||
import { version, env } from "../modules/config.js";
|
|
||||||
|
|
||||||
import { buildFront } from "../modules/build.js";
|
|
||||||
import findRendered from "../modules/pageRender/findRendered.js";
|
|
||||||
|
|
||||||
import { celebrationsEmoji } from "../modules/pageRender/elements.js";
|
|
||||||
import { changelogHistory } from "../modules/pageRender/onDemand.js";
|
|
||||||
import { createResponse } from "../modules/processing/request.js";
|
|
||||||
|
|
||||||
export async function runWeb(express, app, gitCommit, gitBranch, __dirname) {
|
|
||||||
const startTime = new Date();
|
|
||||||
const startTimestamp = Math.floor(startTime.getTime());
|
|
||||||
|
|
||||||
await buildFront(gitCommit, gitBranch);
|
|
||||||
|
|
||||||
app.use('/', express.static('./build/min'));
|
|
||||||
app.use('/', express.static('./src/front'));
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') }
|
|
||||||
next();
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/onDemand', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (typeof req.query.blockId !== 'string') {
|
|
||||||
return res.status(400).json({
|
|
||||||
status: "error",
|
|
||||||
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 {
|
|
||||||
return res.status(400).json({
|
|
||||||
status: "error",
|
|
||||||
text: "couldn't render this block, please try again!"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
return res.sendFile(`${__dirname}/${findRendered(languageCode(req))}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/favicon.ico", (req, res) => {
|
|
||||||
return res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/*", (req, res) => {
|
|
||||||
return res.redirect('/')
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(env.webPort, () => {
|
|
||||||
console.log(`\n` +
|
|
||||||
`${Cyan("cobalt")} WEB ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
|
||||||
`Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` +
|
|
||||||
`URL: ${Cyan(`${env.webURL}`)}\n` +
|
|
||||||
`Port: ${env.webPort}\n`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 21 KiB |
1265
src/front/cobalt.css
|
@ -1,708 +0,0 @@
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
|
||||||
const isIOS = ua.includes("iphone os") || (ua.includes("mac os") && navigator.maxTouchPoints > 0);
|
|
||||||
const isAndroid = ua.includes("android");
|
|
||||||
const isMobile = ua.includes("android") || isIOS;
|
|
||||||
const isSafari = ua.includes("safari/");
|
|
||||||
const isFirefox = ua.includes("firefox/");
|
|
||||||
const isOldFirefox = ua.includes("firefox/") && ua.split("firefox/")[1].split('.')[0] < 103;
|
|
||||||
|
|
||||||
const switchers = {
|
|
||||||
"theme": ["auto", "light", "dark"],
|
|
||||||
"vCodec": ["h264", "av1", "vp9"],
|
|
||||||
"vQuality": ["720", "max", "2160", "1440", "1080", "480", "360", "240", "144"],
|
|
||||||
"aFormat": ["mp3", "best", "ogg", "wav", "opus"],
|
|
||||||
"audioMode": ["false", "true"],
|
|
||||||
"filenamePattern": ["classic", "pretty", "basic", "nerdy"]
|
|
||||||
}
|
|
||||||
const checkboxes = [
|
|
||||||
"alwaysVisibleButton",
|
|
||||||
"downloadPopup",
|
|
||||||
"fullTikTokAudio",
|
|
||||||
"muteAudio",
|
|
||||||
"reduceTransparency",
|
|
||||||
"disableAnimations",
|
|
||||||
"disableMetadata",
|
|
||||||
"twitterGif",
|
|
||||||
"plausible_ignore",
|
|
||||||
"ytDub",
|
|
||||||
"tiktokH265"
|
|
||||||
]
|
|
||||||
const bottomPopups = ["error", "download"]
|
|
||||||
|
|
||||||
let store = {};
|
|
||||||
|
|
||||||
const validLink = (link) => {
|
|
||||||
try {
|
|
||||||
return /^https:/i.test(new URL(link).protocol);
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixApiUrl = (url) => {
|
|
||||||
return url.endsWith('/') ? url.slice(0, -1) : url
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiURL = fixApiUrl(defaultApiUrl);
|
|
||||||
|
|
||||||
const changeApi = (url) => {
|
|
||||||
apiURL = fixApiUrl(url);
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const eid = (id) => {
|
|
||||||
return document.getElementById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sGet = (id) =>{
|
|
||||||
return localStorage.getItem(id)
|
|
||||||
}
|
|
||||||
const sSet = (id, value) => {
|
|
||||||
localStorage.setItem(id, value)
|
|
||||||
}
|
|
||||||
const lazyGet = (key) => {
|
|
||||||
const value = sGet(key);
|
|
||||||
if (key in switchers) {
|
|
||||||
if (switchers[key][0] !== value)
|
|
||||||
return value;
|
|
||||||
} else if (checkboxes.includes(key)) {
|
|
||||||
if (value === 'true')
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeDownloadButton = (action, text) => {
|
|
||||||
switch (action) {
|
|
||||||
case "hidden": // hidden, but only visible when alwaysVisibleButton is true
|
|
||||||
eid("download-button").disabled = true
|
|
||||||
if (sGet("alwaysVisibleButton") === "true") {
|
|
||||||
eid("download-button").value = '>>'
|
|
||||||
eid("download-button").style.padding = '0 1rem'
|
|
||||||
} else {
|
|
||||||
eid("download-button").value = ''
|
|
||||||
eid("download-button").style.padding = '0'
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "disabled":
|
|
||||||
eid("download-button").disabled = true
|
|
||||||
eid("download-button").value = text
|
|
||||||
eid("download-button").style.padding = '0 1rem'
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
eid("download-button").disabled = false
|
|
||||||
eid("download-button").value = '>>'
|
|
||||||
eid("download-button").style.padding = '0 1rem'
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = () => {
|
|
||||||
let regexTest = validLink(eid("url-input-area").value);
|
|
||||||
|
|
||||||
eid("url-clear").style.display = "none";
|
|
||||||
|
|
||||||
if ((eid("url-input-area").value).length > 0) {
|
|
||||||
eid("url-clear").style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (regexTest) {
|
|
||||||
changeDownloadButton()
|
|
||||||
} else {
|
|
||||||
changeDownloadButton("hidden")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearInput = () => {
|
|
||||||
eid("url-input-area").value = '';
|
|
||||||
button();
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = (id, data) => {
|
|
||||||
let target = document.getElementById(id);
|
|
||||||
target.classList.add("text-backdrop");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
target.classList.remove("text-backdrop")
|
|
||||||
}, 600);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
navigator.clipboard.writeText(data)
|
|
||||||
} else {
|
|
||||||
navigator.clipboard.writeText(target.textContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const share = url => navigator?.share({ url }).catch(() => {});
|
|
||||||
|
|
||||||
const preferredColorScheme = () => {
|
|
||||||
let theme = "auto";
|
|
||||||
let localTheme = sGet("theme");
|
|
||||||
let isLightPreferred = false;
|
|
||||||
|
|
||||||
if (localTheme) {
|
|
||||||
theme = localTheme;
|
|
||||||
}
|
|
||||||
if (window.matchMedia) {
|
|
||||||
isLightPreferred = window.matchMedia('(prefers-color-scheme: light)').matches;
|
|
||||||
}
|
|
||||||
if (theme === "auto") {
|
|
||||||
theme = isLightPreferred ? "light" : "dark"
|
|
||||||
}
|
|
||||||
|
|
||||||
return theme
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeStatusBarColor = () => {
|
|
||||||
const theme = preferredColorScheme();
|
|
||||||
const colors = {
|
|
||||||
"dark": "#000000",
|
|
||||||
"light": "#ffffff",
|
|
||||||
"dark-popup": "#151515",
|
|
||||||
"light-popup": "#ebebeb"
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = store.isPopupOpen ? "dark-popup" : "dark";
|
|
||||||
|
|
||||||
if (theme === "light") {
|
|
||||||
state = store.isPopupOpen ? "light-popup" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', colors[state]);
|
|
||||||
}
|
|
||||||
const detectColorScheme = () => {
|
|
||||||
document.documentElement.setAttribute("data-theme", preferredColorScheme());
|
|
||||||
changeStatusBarColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
|
|
||||||
changeStatusBarColor()
|
|
||||||
detectColorScheme()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFilenamePreview = () => {
|
|
||||||
let videoFilePreview = ``;
|
|
||||||
let audioFilePreview = ``;
|
|
||||||
let resMatch = {
|
|
||||||
"max": "3840x2160",
|
|
||||||
"2160": "3840x2160",
|
|
||||||
"1440": "2560x1440",
|
|
||||||
"1080": "1920x1080",
|
|
||||||
"720": "1280x720",
|
|
||||||
"480": "854x480",
|
|
||||||
"360": "640x360",
|
|
||||||
}
|
|
||||||
|
|
||||||
switch(sGet("filenamePattern")) {
|
|
||||||
case "classic":
|
|
||||||
videoFilePreview = `youtube_dQw4w9WgXcQ_${resMatch[sGet('vQuality')]}_${sGet('vCodec')}`
|
|
||||||
+ `${sGet("muteAudio") === "true" ? "_mute" : ""}`
|
|
||||||
+ `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`;
|
|
||||||
audioFilePreview = `youtube_dQw4w9WgXcQ_audio`
|
|
||||||
+ `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`;
|
|
||||||
break;
|
|
||||||
case "basic":
|
|
||||||
videoFilePreview = `${loc.FilenamePreviewVideoTitle} `
|
|
||||||
+ `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, `
|
|
||||||
+ `${sGet('vCodec')}${sGet("muteAudio") === "true" ? ", mute" : ""})`
|
|
||||||
+ `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`;
|
|
||||||
audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor}`
|
|
||||||
+ `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`;
|
|
||||||
break;
|
|
||||||
case "pretty":
|
|
||||||
videoFilePreview = `${loc.FilenamePreviewVideoTitle} `
|
|
||||||
+ `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, `
|
|
||||||
+ `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube)`
|
|
||||||
+ `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`;
|
|
||||||
audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} (soundcloud)`
|
|
||||||
+ `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`;
|
|
||||||
break;
|
|
||||||
case "nerdy":
|
|
||||||
videoFilePreview = `${loc.FilenamePreviewVideoTitle} `
|
|
||||||
+ `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, `
|
|
||||||
+ `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube, dQw4w9WgXcQ)`
|
|
||||||
+ `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`;
|
|
||||||
audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} `
|
|
||||||
+ `(soundcloud, 1242868615)`
|
|
||||||
+ `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
eid("video-filename-text").innerHTML = videoFilePreview
|
|
||||||
eid("audio-filename-text").innerHTML = audioFilePreview
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeTab = (evnt, tabId, tabClass) => {
|
|
||||||
if (tabId === "tab-settings-other") updateFilenamePreview();
|
|
||||||
|
|
||||||
let tabcontent = document.getElementsByClassName(`tab-content-${tabClass}`);
|
|
||||||
let tablinks = document.getElementsByClassName(`tab-${tabClass}`);
|
|
||||||
|
|
||||||
for (let i = 0; i < tabcontent.length; i++) {
|
|
||||||
tabcontent[i].dataset.enabled = "false";
|
|
||||||
}
|
|
||||||
for (let i = 0; i < tablinks.length; i++) {
|
|
||||||
tablinks[i].dataset.enabled = "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
evnt.currentTarget.dataset.enabled = "true";
|
|
||||||
eid(tabId).dataset.enabled = "true";
|
|
||||||
eid(tabId).parentElement.scrollTop = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandCollapsible = (evnt) => {
|
|
||||||
let classlist = evnt.currentTarget.parentNode.classList;
|
|
||||||
let c = "expanded";
|
|
||||||
!classlist.contains(c) ? classlist.add(c) : classlist.remove(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideAllPopups = () => {
|
|
||||||
let filter = document.getElementsByClassName('popup');
|
|
||||||
for (let i = 0; i < filter.length; i++) {
|
|
||||||
filter[i].classList.remove("visible");
|
|
||||||
}
|
|
||||||
eid("popup-backdrop").classList.remove("visible");
|
|
||||||
store.isPopupOpen = false;
|
|
||||||
|
|
||||||
// clear the picker
|
|
||||||
eid("picker-holder").innerHTML = '';
|
|
||||||
eid("picker-download").href = '/';
|
|
||||||
eid("picker-download").classList.remove("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
const popup = (type, action, text) => {
|
|
||||||
if (action === 1) {
|
|
||||||
hideAllPopups(); // hide the previous popup before showing a new one
|
|
||||||
store.isPopupOpen = true;
|
|
||||||
|
|
||||||
// if not a small popup, update status bar color to match the popup header
|
|
||||||
if (!bottomPopups.includes(type)) changeStatusBarColor();
|
|
||||||
switch (type) {
|
|
||||||
case "about":
|
|
||||||
let tabId = "about";
|
|
||||||
if (text) tabId = text;
|
|
||||||
eid(`tab-button-${type}-${tabId}`).click();
|
|
||||||
break;
|
|
||||||
case "settings":
|
|
||||||
eid(`tab-button-${type}-video`).click();
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
eid("desc-error").innerHTML = text;
|
|
||||||
break;
|
|
||||||
case "download":
|
|
||||||
eid("pd-download").href = text;
|
|
||||||
eid("pd-copy").setAttribute("onClick", `copy('pd-copy', '${text}')`);
|
|
||||||
eid("pd-share").setAttribute("onClick", `share('${text}')`);
|
|
||||||
if (navigator.canShare) eid("pd-share").style.display = "flex";
|
|
||||||
break;
|
|
||||||
case "picker":
|
|
||||||
eid("picker-title").innerHTML = loc.MediaPickerTitle;
|
|
||||||
eid("picker-subtitle").innerHTML = isMobile ? loc.MediaPickerExplanationPhone : loc.MediaPickerExplanationPC;
|
|
||||||
|
|
||||||
switch (text.type) {
|
|
||||||
case "images":
|
|
||||||
eid("picker-holder").classList.remove("various");
|
|
||||||
|
|
||||||
eid("picker-download").href = text.audio;
|
|
||||||
eid("picker-download").classList.add("visible");
|
|
||||||
|
|
||||||
for (let i in text.arr) {
|
|
||||||
eid("picker-holder").innerHTML +=
|
|
||||||
`<a class="picker-image-container" ${
|
|
||||||
isIOS ? `onClick="share('${text.arr[i]["url"]}')"` : `href="${text.arr[i]["url"]}" target="_blank"`
|
|
||||||
}>` +
|
|
||||||
`<img class="picker-image" src="${text.arr[i]["url"]}" onerror="this.parentNode.style.display='none'">` +
|
|
||||||
`</a>`
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
eid("picker-holder").classList.add("various");
|
|
||||||
|
|
||||||
for (let i in text.arr) {
|
|
||||||
eid("picker-holder").innerHTML +=
|
|
||||||
`<a class="picker-image-container" ${
|
|
||||||
isIOS ? `onClick="share('${text.arr[i]["url"]}')"` : `href="${text.arr[i]["url"]}" target="_blank"`
|
|
||||||
}>` +
|
|
||||||
`<div class="picker-element-name">${text.arr[i].type}</div>` +
|
|
||||||
(text.arr[i].type === 'photo' ? '' : '<div class="imageBlock"></div>') +
|
|
||||||
`<img class="picker-image" src="${text.arr[i]["thumb"]}" onerror="this.style.display='none'">` +
|
|
||||||
`</a>`
|
|
||||||
}
|
|
||||||
eid("picker-download").classList.remove("visible");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
store.isPopupOpen = false;
|
|
||||||
|
|
||||||
// reset status bar to base color
|
|
||||||
changeStatusBarColor();
|
|
||||||
|
|
||||||
if (type === "picker") {
|
|
||||||
eid("picker-download").href = '/';
|
|
||||||
eid("picker-download").classList.remove("visible");
|
|
||||||
eid("picker-holder").innerHTML = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bottomPopups.includes(type)) {
|
|
||||||
eid(`popup-${type}-container`).classList.toggle("visible");
|
|
||||||
}
|
|
||||||
eid("popup-backdrop").classList.toggle("visible");
|
|
||||||
eid(`popup-${type}`).classList.toggle("visible");
|
|
||||||
eid(`popup-${type}`).focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeSwitcher = (switcher, state) => {
|
|
||||||
if (state) {
|
|
||||||
if (!switchers[switcher].includes(state)) {
|
|
||||||
state = switchers[switcher][0];
|
|
||||||
}
|
|
||||||
sSet(switcher, state);
|
|
||||||
|
|
||||||
for (let i in switchers[switcher]) {
|
|
||||||
if (switchers[switcher][i] === state) {
|
|
||||||
eid(`${switcher}-${state}`).dataset.enabled = "true";
|
|
||||||
} else {
|
|
||||||
eid(`${switcher}-${switchers[switcher][i]}`).dataset.enabled = "false";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (switcher === "theme") detectColorScheme();
|
|
||||||
if (switcher === "filenamePattern") updateFilenamePreview();
|
|
||||||
} else {
|
|
||||||
let defaultValue = switchers[switcher][0];
|
|
||||||
sSet(switcher, defaultValue);
|
|
||||||
for (let i in switchers[switcher]) {
|
|
||||||
if (switchers[switcher][i] === defaultValue) {
|
|
||||||
eid(`${switcher}-${defaultValue}`).dataset.enabled = "true";
|
|
||||||
} else {
|
|
||||||
eid(`${switcher}-${switchers[switcher][i]}`).dataset.enabled = "false";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkbox = (action) => {
|
|
||||||
sSet(action, !!eid(action).checked);
|
|
||||||
switch(action) {
|
|
||||||
case "alwaysVisibleButton": button(); break;
|
|
||||||
case "reduceTransparency": eid("cobalt-body").classList.toggle('no-transparency'); break;
|
|
||||||
case "disableAnimations": eid("cobalt-body").classList.toggle('no-animation'); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeButton = (type, text) => {
|
|
||||||
switch (type) {
|
|
||||||
case "error": //error
|
|
||||||
eid("url-input-area").disabled = false
|
|
||||||
eid("url-clear").style.display = "block";
|
|
||||||
changeDownloadButton("disabled", '!!');
|
|
||||||
popup("error", 1, text);
|
|
||||||
setTimeout(() => { changeButton("default") }, 2500);
|
|
||||||
break;
|
|
||||||
case "default": //enable back
|
|
||||||
changeDownloadButton();
|
|
||||||
eid("url-clear").style.display = "block";
|
|
||||||
eid("url-input-area").disabled = false
|
|
||||||
break;
|
|
||||||
case "error-default": //enable back + information popup
|
|
||||||
popup("error", 1, text);
|
|
||||||
changeDownloadButton();
|
|
||||||
eid("url-clear").style.display = "block";
|
|
||||||
eid("url-input-area").disabled = false
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const internetError = () => {
|
|
||||||
eid("url-input-area").disabled = false
|
|
||||||
changeDownloadButton("disabled", '!!');
|
|
||||||
setTimeout(() => { changeButton("default") }, 2500);
|
|
||||||
popup("error", 1, loc.ErrorNoInternet);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetSettings = () => {
|
|
||||||
localStorage.clear();
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const download = async(url) => {
|
|
||||||
changeDownloadButton("disabled", '...');
|
|
||||||
|
|
||||||
eid("url-clear").style.display = "none";
|
|
||||||
eid("url-input-area").disabled = true;
|
|
||||||
|
|
||||||
let req = {
|
|
||||||
url,
|
|
||||||
vCodec: lazyGet("vCodec"),
|
|
||||||
vQuality: lazyGet("vQuality"),
|
|
||||||
aFormat: lazyGet("aFormat"),
|
|
||||||
filenamePattern: lazyGet("filenamePattern"),
|
|
||||||
isAudioOnly: lazyGet("audioMode"),
|
|
||||||
isTTFullAudio: lazyGet("fullTikTokAudio"),
|
|
||||||
isAudioMuted: lazyGet("muteAudio"),
|
|
||||||
disableMetadata: lazyGet("disableMetadata"),
|
|
||||||
dubLang: lazyGet("ytDub"),
|
|
||||||
twitterGif: lazyGet("twitterGif"),
|
|
||||||
tiktokH265: lazyGet("tiktokH265"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let j = await fetch(`${apiURL}/api/json`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}).then(r => r.json()).catch(() => {});
|
|
||||||
|
|
||||||
if (!j) {
|
|
||||||
internetError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((j.status === "error" || j.status === "rate-limit") && j && j.text) {
|
|
||||||
changeButton("error", j.text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (j.text && (!j.url || !j.picker)) {
|
|
||||||
if (j.status === "success") {
|
|
||||||
changeButton("error-default", j.text)
|
|
||||||
} else {
|
|
||||||
changeButton("error", loc.ErrorNoUrlReturned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (j.status) {
|
|
||||||
case "redirect":
|
|
||||||
changeDownloadButton("disabled", '>>>');
|
|
||||||
setTimeout(() => { changeButton("default") }, 1500);
|
|
||||||
|
|
||||||
if (sGet("downloadPopup") === "true") {
|
|
||||||
popup('download', 1, j.url)
|
|
||||||
} else {
|
|
||||||
window.open(j.url, '_blank')
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "stream":
|
|
||||||
changeDownloadButton("disabled", '?..');
|
|
||||||
|
|
||||||
let probeStream = await fetch(`${j.url}&p=1`).then(r => r.json()).catch(() => {});
|
|
||||||
if (!probeStream) return internetError();
|
|
||||||
|
|
||||||
if (probeStream.status !== "continue") {
|
|
||||||
changeButton("error", probeStream.text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeDownloadButton("disabled", '>>>');
|
|
||||||
if (sGet("downloadPopup") === "true") {
|
|
||||||
popup('download', 1, j.url)
|
|
||||||
} else {
|
|
||||||
if (isMobile || isSafari) {
|
|
||||||
window.location.href = j.url;
|
|
||||||
} else {
|
|
||||||
window.open(j.url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(() => { changeButton("default") }, 2500);
|
|
||||||
break;
|
|
||||||
case "picker":
|
|
||||||
if (j.audio && j.picker) {
|
|
||||||
changeDownloadButton("disabled", '>>>');
|
|
||||||
popup('picker', 1, {
|
|
||||||
audio: j.audio,
|
|
||||||
arr: j.picker,
|
|
||||||
type: j.pickerType
|
|
||||||
});
|
|
||||||
setTimeout(() => { changeButton("default") }, 2500);
|
|
||||||
} else if (j.picker) {
|
|
||||||
changeDownloadButton("disabled", '>>>');
|
|
||||||
popup('picker', 1, {
|
|
||||||
arr: j.picker,
|
|
||||||
type: j.pickerType
|
|
||||||
});
|
|
||||||
setTimeout(() => { changeButton("default") }, 2500);
|
|
||||||
} else {
|
|
||||||
changeButton("error", loc.ErrorNoUrlReturned);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "success":
|
|
||||||
changeButton("error-default", j.text);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
changeButton("error", loc.ErrorUnknownStatus);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pasteClipboard = async() => {
|
|
||||||
try {
|
|
||||||
let clipboard = await navigator.clipboard.readText();
|
|
||||||
let onlyURL = clipboard.match(/https:\/\/[^\s]+/g)
|
|
||||||
if (onlyURL) {
|
|
||||||
eid("url-input-area").value = onlyURL;
|
|
||||||
download(eid("url-input-area").value);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
let errorMessage = loc.FeatureErrorGeneric;
|
|
||||||
let doError = true;
|
|
||||||
let error = String(e).toLowerCase();
|
|
||||||
|
|
||||||
if (error.includes("denied")) errorMessage = loc.ClipboardErrorNoPermission;
|
|
||||||
if (error.includes("dismissed") || isIOS) doError = false;
|
|
||||||
if (error.includes("function") && isFirefox) errorMessage = loc.ClipboardErrorFirefox;
|
|
||||||
|
|
||||||
if (doError) popup("error", 1, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCelebrationsEmoji = async() => {
|
|
||||||
let aboutButtonBackup = eid("about-footer").innerHTML;
|
|
||||||
try {
|
|
||||||
let j = await fetch(`/onDemand?blockId=1`).then(r => r.json()).catch(() => {});
|
|
||||||
if (j && j.status === "success" && j.text) {
|
|
||||||
eid("about-footer").innerHTML = eid("about-footer").innerHTML.replace(
|
|
||||||
`${aboutButtonBackup.split('> ')[0]}>`,
|
|
||||||
j.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
eid("about-footer").innerHTML = aboutButtonBackup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadOnDemand = async(elementId, blockId) => {
|
|
||||||
store.historyButton = eid(elementId).innerHTML;
|
|
||||||
eid(elementId).innerHTML = `<div class="loader">...</div>`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!store.historyContent) {
|
|
||||||
let j = await fetch(`/onDemand?blockId=${blockId}`).then(r => r.json()).catch(() => {});
|
|
||||||
if (!j) throw new Error();
|
|
||||||
|
|
||||||
if (j.status === "success") {
|
|
||||||
store.historyContent = j.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eid(elementId).innerHTML =
|
|
||||||
`<button class="switch bottom-margin" onclick="restoreUpdateHistory()">
|
|
||||||
${loc.ChangelogPressToHide}
|
|
||||||
</button>
|
|
||||||
${store.historyContent}`;
|
|
||||||
} catch {
|
|
||||||
eid(elementId).innerHTML = store.historyButton;
|
|
||||||
internetError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreUpdateHistory = () => {
|
|
||||||
eid("changelog-history").innerHTML = store.historyButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSettings = () => {
|
|
||||||
if (sGet("alwaysVisibleButton") === "true") {
|
|
||||||
eid("alwaysVisibleButton").checked = true;
|
|
||||||
eid("download-button").value = '>>'
|
|
||||||
eid("download-button").style.padding = '0 1rem';
|
|
||||||
}
|
|
||||||
if (sGet("downloadPopup") === "true" && !isIOS) {
|
|
||||||
eid("downloadPopup").checked = true;
|
|
||||||
}
|
|
||||||
if (sGet("reduceTransparency") === "true" || isOldFirefox) {
|
|
||||||
eid("cobalt-body").classList.add('no-transparency');
|
|
||||||
}
|
|
||||||
if (sGet("disableAnimations") === "true") {
|
|
||||||
eid("cobalt-body").classList.add('no-animation');
|
|
||||||
}
|
|
||||||
if (!isMobile) {
|
|
||||||
eid("cobalt-body").classList.add('desktop');
|
|
||||||
}
|
|
||||||
if (isAndroid) {
|
|
||||||
eid("cobalt-body").classList.add('android');
|
|
||||||
}
|
|
||||||
if (isIOS) {
|
|
||||||
eid("download-switcher")
|
|
||||||
.querySelector(".explanation")
|
|
||||||
.innerHTML = loc.DownloadPopupDescriptionIOS;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < checkboxes.length; i++) {
|
|
||||||
try {
|
|
||||||
if (sGet(checkboxes[i]) === "true") eid(checkboxes[i]).checked = true;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
console.error(`checkbox ${checkboxes[i]} failed to initialize`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i in switchers) {
|
|
||||||
changeSwitcher(i, sGet(i))
|
|
||||||
}
|
|
||||||
updateFilenamePreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onload = () => {
|
|
||||||
loadCelebrationsEmoji();
|
|
||||||
|
|
||||||
loadSettings();
|
|
||||||
detectColorScheme();
|
|
||||||
|
|
||||||
changeDownloadButton("hidden");
|
|
||||||
eid("url-input-area").value = "";
|
|
||||||
|
|
||||||
if (isIOS) {
|
|
||||||
sSet("downloadPopup", "true");
|
|
||||||
eid("downloadPopup-chkbx").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
eid("home").style.visibility = 'visible';
|
|
||||||
eid("home").classList.toggle("visible");
|
|
||||||
|
|
||||||
const pageQuery = new URLSearchParams(window.location.search);
|
|
||||||
if (pageQuery.has("u") && validLink(pageQuery.get("u"))) {
|
|
||||||
eid("url-input-area").value = pageQuery.get("u");
|
|
||||||
button()
|
|
||||||
}
|
|
||||||
window.history.replaceState(null, '', window.location.pathname);
|
|
||||||
|
|
||||||
// fix for animations not working in Safari
|
|
||||||
if (isIOS) {
|
|
||||||
document.addEventListener('touchstart', () => {}, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eid("url-input-area").addEventListener("keydown", () => {
|
|
||||||
button();
|
|
||||||
})
|
|
||||||
eid("url-input-area").addEventListener("keyup", (e) => {
|
|
||||||
if (e.key === 'Enter') eid("download-button").click();
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Tab") {
|
|
||||||
eid("download-button").value = '>>'
|
|
||||||
eid("download-button").style.padding = '0 1rem'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.onkeydown = (e) => {
|
|
||||||
if (!store.isPopupOpen) {
|
|
||||||
if (e.metaKey || e.ctrlKey || e.key === "/") eid("url-input-area").focus();
|
|
||||||
if (e.key === "Escape" || e.key === "Clear") clearInput();
|
|
||||||
|
|
||||||
if (e.target === eid("url-input-area")) return;
|
|
||||||
|
|
||||||
// top buttons
|
|
||||||
if (e.key === "D") pasteClipboard();
|
|
||||||
if (e.key === "K") changeSwitcher('audioMode', 'false');
|
|
||||||
if (e.key === "L") changeSwitcher('audioMode', 'true');
|
|
||||||
|
|
||||||
// popups
|
|
||||||
if (e.key === "B") popup('about', 1, 'about'); // open about
|
|
||||||
if (e.key === "N") popup('about', 1, 'changelog'); // open changelog
|
|
||||||
if (e.key === "M") popup('settings', 1);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
if (e.key === "Escape") hideAllPopups();
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 8.5 KiB |
Before Width: | Height: | Size: 11 KiB |
|
@ -1,8 +0,0 @@
|
||||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M29 8H3V9H29V8ZM29 13H3V14H29V13ZM3 18H29V19H3V18ZM29 23H3V24H29V23Z" fill="#D3D3D3" />
|
|
||||||
<path d="M8 2C4.68629 2 2 4.68629 2 8V24C2 27.3137 4.68629 30 8 30H24C27.3137 30 30 27.3137 30 24V8C30 4.68629 27.3137 2 24 2H8ZM8 4H24C26.2091 4 28 5.79086 28 8V24C28 26.2091 26.2091 28 24 28H8C5.79086 28 4 26.2091 4 24V8C4 5.79086 5.79086 4 8 4Z" fill="#FF6723" />
|
|
||||||
<path d="M6 8C6 7.17157 6.67157 6.5 7.5 6.5C8.32843 6.5 9 7.17157 9 8C9 7.17157 9.67157 6.5 10.5 6.5C11.3284 6.5 12 7.17157 12 8C12 7.17157 12.6716 6.5 13.5 6.5C14.3284 6.5 15 7.17157 15 8C15 7.17157 15.6716 6.5 16.5 6.5C17.3284 6.5 18 7.17157 18 8V9C18 9.82843 17.3284 10.5 16.5 10.5C15.6716 10.5 15 9.82843 15 9C15 9.82843 14.3284 10.5 13.5 10.5C12.6716 10.5 12 9.82843 12 9C12 9.82843 11.3284 10.5 10.5 10.5C9.67157 10.5 9 9.82843 9 9C9 9.82843 8.32843 10.5 7.5 10.5C6.67157 10.5 6 9.82843 6 9V8ZM24.5 6.5C23.6716 6.5 23 7.17157 23 8V9C23 9.82843 23.6716 10.5 24.5 10.5C25.3284 10.5 26 9.82843 26 9V8C26 7.17157 25.3284 6.5 24.5 6.5Z" fill="#F70A8D" />
|
|
||||||
<path d="M6 13C6 12.1716 6.67157 11.5 7.5 11.5C8.32843 11.5 9 12.1716 9 13C9 12.1716 9.67157 11.5 10.5 11.5C11.3284 11.5 12 12.1716 12 13C12 12.1716 12.6716 11.5 13.5 11.5C14.3284 11.5 15 12.1716 15 13C15 12.1716 15.6716 11.5 16.5 11.5C17.3284 11.5 18 12.1716 18 13V14C18 14.8284 17.3284 15.5 16.5 15.5C15.6716 15.5 15 14.8284 15 14C15 14.8284 14.3284 15.5 13.5 15.5C12.6716 15.5 12 14.8284 12 14C12 14.8284 11.3284 15.5 10.5 15.5C9.67157 15.5 9 14.8284 9 14C9 14.8284 8.32843 15.5 7.5 15.5C6.67157 15.5 6 14.8284 6 14V13ZM24.5 11.5C23.6716 11.5 23 12.1716 23 13V14C23 14.8284 23.6716 15.5 24.5 15.5C25.3284 15.5 26 14.8284 26 14V13C26 12.1716 25.3284 11.5 24.5 11.5Z" fill="#00A6ED" />
|
|
||||||
<path d="M6 18C6 17.1716 6.67157 16.5 7.5 16.5C8.32843 16.5 9 17.1716 9 18C9 17.1716 9.67157 16.5 10.5 16.5C11.3284 16.5 12 17.1716 12 18C12 17.1716 12.6716 16.5 13.5 16.5C14.3284 16.5 15 17.1716 15 18C15 17.1716 15.6716 16.5 16.5 16.5C17.3284 16.5 18 17.1716 18 18V19C18 19.8284 17.3284 20.5 16.5 20.5C15.6716 20.5 15 19.8284 15 19C15 19.8284 14.3284 20.5 13.5 20.5C12.6716 20.5 12 19.8284 12 19C12 19.8284 11.3284 20.5 10.5 20.5C9.67157 20.5 9 19.8284 9 19C9 19.8284 8.32843 20.5 7.5 20.5C6.67157 20.5 6 19.8284 6 19V18ZM24.5 16.5C23.6716 16.5 23 17.1716 23 18V19C23 19.8284 23.6716 20.5 24.5 20.5C25.3284 20.5 26 19.8284 26 19V18C26 17.1716 25.3284 16.5 24.5 16.5Z" fill="#FCD53F" />
|
|
||||||
<path d="M6 23C6 22.1716 6.67157 21.5 7.5 21.5C8.32843 21.5 9 22.1716 9 23C9 22.1716 9.67157 21.5 10.5 21.5C11.3284 21.5 12 22.1716 12 23C12 22.1716 12.6716 21.5 13.5 21.5C14.3284 21.5 15 22.1716 15 23C15 22.1716 15.6716 21.5 16.5 21.5C17.3284 21.5 18 22.1716 18 23V24C18 24.8284 17.3284 25.5 16.5 25.5C15.6716 25.5 15 24.8284 15 24C15 24.8284 14.3284 25.5 13.5 25.5C12.6716 25.5 12 24.8284 12 24C12 24.8284 11.3284 25.5 10.5 25.5C9.67157 25.5 9 24.8284 9 24C9 24.8284 8.32843 25.5 7.5 25.5C6.67157 25.5 6 24.8284 6 24V23ZM24.5 21.5C23.6716 21.5 23 22.1716 23 23V24C23 24.8284 23.6716 25.5 24.5 25.5C25.3284 25.5 26 24.8284 26 24V23C26 22.1716 25.3284 21.5 24.5 21.5Z" fill="#00D26A" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 3.1 KiB |
|
@ -1,9 +0,0 @@
|
||||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M9 3H10C10.55 3 11 3.45 11 4V5.43C11 5.74 10.74 6 10.43 6H9C8.45 6 8 5.55 8 5V4C8 3.45 8.45 3 9 3Z" fill="#635994" />
|
|
||||||
<path d="M11.99 29.03H13C13.55 29.03 14 28.58 14 28.03V27.03C14 26.48 13.55 26.03 13 26.03H10.57C10.26 26.03 10 26.29 10 26.6V27.04C10 28.14 10.89 29.03 11.99 29.03Z" fill="#635994" />
|
|
||||||
<path d="M18 27.03V28.03C18 28.58 18.45 29.03 19 29.03H20.03C21.12 29.03 22 28.15 22 27.06V26.6C22 26.28 21.74 26.03 21.43 26.03H19C18.45 26.03 18 26.48 18 27.03Z" fill="#635994" />
|
|
||||||
<path d="M24 5V4C24 3.45 23.55 3 23 3H22C21.45 3 21 3.45 21 4V5.43C21 5.74 21.26 6 21.57 6H23C23.55 6 24 5.55 24 5Z" fill="#635994" />
|
|
||||||
<path d="M28 11.03C28 10.48 28.45 10.03 29 10.03C29.55 10.03 30 10.48 30 11.03V15.03C30 15.58 29.55 16.03 29 16.03H28.57C28.26 16.03 28 16.28 28 16.6V17.06C28 18.15 27.12 19.03 26.03 19.03H25.57C25.26 19.03 25 19.28 25 19.6V24.04C25 25.14 24.11 26.03 23.01 26.03H22.57C22.26 26.03 22 25.78 22 25.46V22.6C22 22.29 21.75 22.03 21.43 22.03H10.57C10.26 22.03 10 22.28 10 22.6V25.46C10 25.77 9.75 26.03 9.43 26.03H9C7.9 26.03 7 25.13 7 24.03V19.6C7 19.29 6.74 19.03 6.43 19.03H6C4.9 19.03 4 18.13 4 17.03V16.6C4 16.29 3.74 16.03 3.43 16.03H3C2.45 16.03 2 15.58 2 15.03V11.03C2 10.48 2.45 10.03 3 10.03H3.03C3.58 10.03 4.03 10.48 4.03 11.03V12.46C4.03 12.78 4.28 13.03 4.6 13.03L6.4 13.02C6.7 13.01 6.96 12.8 7 12.51C7.24 10.7 8.71 9.29 10.53 9.06C10.8 9.03 11 8.78 11 8.5V6.57C11 6.26 11.26 6 11.58 6H11.88C13.05 6 14 6.95 14 8.12V8.46C14 8.78 14.26 9.03 14.57 9.03H17.43C17.74 9.03 18 8.78 18 8.46V8.07C18 6.93 18.93 6 20.07 6H20.43C20.74 6 21 6.26 21 6.57V8.5C21 8.78 21.2 9.03 21.47 9.06C23.29 9.28 24.74 10.7 24.97 12.52C25.01 12.82 25.27 13.03 25.57 13.03H27.43C27.74 13.03 28 12.78 28 12.46V11.03Z" fill="#635994" />
|
|
||||||
<path d="M10 15.9824C10 16.5466 10.4455 17 10.9999 17C11.5543 17 12.0097 16.5466 11.9998 15.9824V14.0176C11.9998 13.4534 11.5543 13 10.9999 13C10.4455 13 10 13.4534 10 14.0176V15.9824Z" fill="#402A32" />
|
|
||||||
<path d="M20 15.9824C20 16.5466 20.4455 17 21 17C21.5545 17 22 16.5365 22 15.9824V14.0176C22 13.4534 21.5545 13 21 13C20.4455 13 20 13.4534 20 14.0176V15.9824Z" fill="#402A32" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.2 KiB |
|
@ -1,7 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20.5131 29.9687H9.09374C4.34375 29.9687 2.01314 26.1101 2.0625 23C2.0625 19.5937 6.90625 7.12497 6.90625 7.12497C7.71875 5.09372 8.42131 3.42992 10.8125 3.46868C12.6563 3.46868 16.2031 4.9687 16.2031 7.96869C16.2031 9.5937 15.0625 10.0781 15.0625 10.0781C15.0625 11.0468 14.2656 11.9687 13.4219 11.9687H9.6875C9.6875 11.9687 12.8252 18.2666 13.0938 18.7656C13.3623 19.2645 13.625 18.7656 13.625 18.7656C14.6875 15.5312 18.25 12.994 21.4063 12.994C25.8947 12.9929 29.9994 17.015 30 20.7682C30 20.7685 30 20.7679 30 20.7682C29.9995 25.5319 26.8432 29.9687 20.5131 29.9687Z" fill="#FFC83D"/>
|
|
||||||
<path d="M10.25 6.43747L11.5469 8.08591H13.6406C13.1719 7.44531 11.525 6.21247 10.25 6.43747Z" fill="#D67D00"/>
|
|
||||||
<path d="M15.0764 10.0718C15.0674 10.0761 15.0625 10.0781 15.0625 10.0781C15.0625 11.0469 14.2656 11.9688 13.4219 11.9688H9.83165C9.83165 11.9688 10.2492 12.9355 10.771 14.1415C7.96197 13.9081 7 10.9922 7 10.9922H7.81449C7.82508 10.9972 7.83577 11.0024 7.84655 11.0078H13.1562C13.6875 11.0078 14.8438 10.5312 14.0156 9C14.3051 9 14.9137 9.43857 15.0764 10.0718Z" fill="#D67D00"/>
|
|
||||||
<path d="M14.6514 18.7018C14.4646 18.394 14.1945 18.1846 13.8945 18.0737C13.7922 18.3002 13.7021 18.5311 13.625 18.7656C13.625 18.7656 13.5309 18.9443 13.3978 18.9809C13.5531 18.9896 13.7049 19.0696 13.7966 19.2206L16.7601 24.1032C16.9034 24.3392 17.2109 24.4144 17.4469 24.2711C17.683 24.1279 17.7582 23.8203 17.6149 23.5843L14.6514 18.7018Z" fill="#D67D00"/>
|
|
||||||
<path d="M12.0916 18.6939C12.2604 18.4197 12.4952 18.2246 12.758 18.1084C12.9292 18.4473 13.0498 18.684 13.0938 18.7656C13.1693 18.906 13.2445 18.9674 13.3134 18.9831C13.1687 18.9991 13.0299 19.0773 12.9433 19.218L10.0508 23.9183C9.90612 24.1534 9.59814 24.2268 9.36296 24.0821C9.12778 23.9373 9.05446 23.6294 9.19918 23.3942L12.0916 18.6939Z" fill="#D67D00"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.9 KiB |
|
@ -1,6 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6.38815 7.21997L3.31815 8.82997C2.95815 9.01997 2.88815 9.50997 3.18815 9.79997L5.79815 12.27L6.38815 7.21997Z" fill="#F9C23C"/>
|
|
||||||
<path d="M18.5582 28.5H16.7782L17.9782 22.5H16.4782L15.2782 28.5H11.7781L12.9781 22.5H11.4781L10.2781 28.5H8.47812C7.74812 28.5 7.14812 29.02 7.00812 29.71C6.96812 29.86 7.09812 30 7.24812 30H19.7782C19.9382 30 20.0582 29.86 20.0282 29.71C19.8882 29.02 19.2782 28.5 18.5582 28.5Z" fill="#F9C23C"/>
|
|
||||||
<path d="M17.5681 6.22C17.4381 5.8 17.2681 5.4 17.0481 5.03H17.6681C18.9581 5.03 19.9981 3.99 19.9981 2.7C19.9981 2.32 19.6781 2 19.2981 2H11.8381C8.65813 2 6.05813 4.48 5.84813 7.61L4.55813 18.79C4.17813 22.1 6.75813 25 10.0881 25H23.8381L23.8348 24.99H29.1181C29.5181 24.99 29.8381 24.67 29.8381 24.27V15.12C29.8381 14.6 29.2881 14.25 28.8081 14.47L21.4662 17.8893L19.1682 11H19.164L17.5681 6.22Z" fill="#00A6ED"/>
|
|
||||||
<path d="M10 10C10.5523 10 11 9.55228 11 9C11 8.44772 10.5523 8 10 8C9.44772 8 9 8.44772 9 9C9 9.55228 9.44772 10 10 10Z" fill="#212121"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.1 KiB |
|
@ -1,8 +0,0 @@
|
||||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4.5 1C3.11929 1 2 2.11929 2 3.5V26.5C2 27.8807 3.11929 29 4.5 29H7V29.5C7 30.3284 7.67157 31 8.5 31H25.5C26.3284 31 27 30.3284 27 29.5V6.5C27 5.67157 26.3284 5 25.5 5H20.9142L17.6464 1.73223C17.1776 1.26339 16.5417 1 15.8787 1H4.5Z" fill="#B4ACBC" />
|
|
||||||
<path d="M3 3.5C3 2.67157 3.67157 2 4.5 2H15.8787C16.2765 2 16.658 2.15804 16.9393 2.43934L22.5607 8.06066C22.842 8.34196 23 8.7235 23 9.12132V26.5C23 27.3284 22.3284 28 21.5 28H4.5C3.67157 28 3 27.3284 3 26.5V3.5Z" fill="#F3EEF8" />
|
|
||||||
<path d="M6.5 11C6.22386 11 6 11.2239 6 11.5C6 11.7761 6.22386 12 6.5 12H19.5C19.7761 12 20 11.7761 20 11.5C20 11.2239 19.7761 11 19.5 11H6.5ZM6.5 14C6.22386 14 6 14.2239 6 14.5C6 14.7761 6.22386 15 6.5 15H19.5C19.7761 15 20 14.7761 20 14.5C20 14.2239 19.7761 14 19.5 14H6.5ZM6 17.5C6 17.2239 6.22386 17 6.5 17H19.5C19.7761 17 20 17.2239 20 17.5C20 17.7761 19.7761 18 19.5 18H6.5C6.22386 18 6 17.7761 6 17.5ZM6.5 20C6.22386 20 6 20.2239 6 20.5C6 20.7761 6.22386 21 6.5 21H14.5C14.7761 21 15 20.7761 15 20.5C15 20.2239 14.7761 20 14.5 20H6.5Z" fill="#998EA4" />
|
|
||||||
<path d="M16 2.00488C16.3534 2.03355 16.6868 2.18674 16.9393 2.43931L22.5607 8.06063C22.8132 8.3132 22.9664 8.64656 22.9951 8.99997H17.5C16.6716 8.99997 16 8.3284 16 7.49997V2.00488Z" fill="#CDC4D6" />
|
|
||||||
<path d="M22.3606 13.1177C22.4507 13.0417 22.5648 13 22.6828 13H25.5002C25.7763 13 26.0002 13.2239 26.0002 13.5V15.5C26.0002 15.7761 25.7763 16 25.5002 16H22.6828C22.5648 16 22.4507 15.9583 22.3606 15.8823L21.1739 14.8823C20.9368 14.6826 20.9368 14.3174 21.1739 14.1177L22.3606 13.1177Z" fill="#F70A8D" />
|
|
||||||
<path d="M25.3606 20.1177C25.4507 20.0417 25.5648 20 25.6828 20H28.5002C28.7763 20 29.0002 20.2239 29.0002 20.5V22.5C29.0002 22.7761 28.7763 23 28.5002 23H25.6828C25.5648 23 25.4507 22.9583 25.3606 22.8823L24.1739 21.8823C23.9368 21.6826 23.9368 21.3174 24.1739 21.1177L25.3606 20.1177Z" fill="#F9C23C" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.9 KiB |
|
@ -1,30 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M21.5 18C25.6421 18 29 14.6421 29 10.5C29 6.35786 25.6421 3 21.5 3C17.3579 3 14 6.35786 14 10.5C14 14.6421 17.3579 18 21.5 18Z" fill="#E0AEF8"/>
|
|
||||||
<path d="M10.75 14.375C10.75 16.6532 8.90317 18.5 6.625 18.5C4.34683 18.5 2.5 16.6532 2.5 14.375C2.5 12.0968 4.34683 10.25 6.625 10.25C8.90317 10.25 10.75 12.0968 10.75 14.375Z" fill="#E0AEF8"/>
|
|
||||||
<path d="M19.5989 24.5C19.5989 27.316 17.316 29.5989 14.5 29.5989C11.684 29.5989 9.40112 27.316 9.40112 24.5C9.40112 21.684 11.684 19.4011 14.5 19.4011C17.316 19.4011 19.5989 21.684 19.5989 24.5Z" fill="#E0AEF8"/>
|
|
||||||
<path d="M29 10.5C29 14.0281 26.5639 16.9872 23.2821 17.787C24.9278 16.6077 26 14.6791 26 12.5C26 8.91015 23.0899 6 19.5 6C17.321 6 15.3923 7.07225 14.213 8.71792C15.0128 5.43607 17.9719 3 21.5 3C25.6421 3 29 6.35786 29 10.5Z" fill="#C4BBF9"/>
|
|
||||||
<path d="M19.5 24.5C19.5 26.0548 18.7903 27.4439 17.6772 28.3609C17.8861 27.7797 18 27.1532 18 26.5C18 23.4624 15.5376 21 12.5 21C11.8468 21 11.2203 21.1139 10.639 21.3228C11.5561 20.2097 12.9452 19.5 14.5 19.5C17.2614 19.5 19.5 21.7386 19.5 24.5Z" fill="#C4BBF9"/>
|
|
||||||
<path d="M29.5 10.5C29.5 13.9844 27.2723 16.9485 24.1635 18.0459C26.4529 16.7619 28 14.3116 28 11.5C28 7.35786 24.6421 4 20.5 4C17.6883 4 15.2381 5.54715 13.954 7.83654C15.0514 4.72769 18.0155 2.5 21.5 2.5C25.9182 2.5 29.5 6.08172 29.5 10.5Z" fill="#AEDDFF"/>
|
|
||||||
<path d="M19.7969 24.2162C19.7969 25.3601 19.3936 26.4109 18.7201 27.237C18.9017 26.691 19 26.107 19 25.5C19 22.4624 16.5376 20 13.5 20C13.116 20 12.7412 20.0394 12.3794 20.1142C13.121 19.662 13.9946 19.4011 14.9297 19.4011C17.6178 19.4011 19.7969 21.5569 19.7969 24.2162Z" fill="#AEDDFF"/>
|
|
||||||
<path d="M29.4999 8C29.7959 8.78448 29.9347 10.1243 29.9855 10.9996C29.8755 12.897 29.1432 14.6275 27.9895 15.9899C26.5085 17.2439 24.5925 18.5 22.4999 18.5C22.3229 18.5 22.147 18.3576 21.9726 18.2164C21.8138 18.0878 21.6562 17.9602 21.4999 17.9418C25.7231 17.4469 28.9999 13.8562 28.9999 9.50003C28.9999 8.53244 28.8383 7.60261 28.5404 6.73608C28.9999 7 29.2635 7.3734 29.4999 8Z" fill="#FCD53F"/>
|
|
||||||
<path d="M10.9931 14.7496C10.9875 13.826 10.6534 12.8627 10.2458 12.5056C10.4104 12.9732 10.4999 13.4762 10.4999 14.0001C10.4999 16.3164 8.74995 18.2239 6.5 18.4726C6.5773 18.4812 6.6458 18.5222 6.71428 18.5633C6.79122 18.6094 6.86816 18.6556 6.95756 18.6556C8.07293 18.6556 9.06514 18.2854 9.92215 17.4223C10.7144 16.6244 11 15.874 10.9931 14.7496Z" fill="#FCD53F"/>
|
|
||||||
<path d="M19.1466 22.0559C19.1949 22.132 19.2416 22.203 19.2866 22.2714C19.4726 22.5544 19.6291 22.7924 19.745 23.1541C19.8905 23.608 19.9721 24.2532 19.9944 24.7499C19.9365 26.0435 19.4317 27.2208 18.6307 28.1312C17.662 28.9832 16.3395 29.6941 14.948 29.6941C14.8603 29.6941 14.7871 29.6397 14.7138 29.5853C14.646 29.5349 14.5781 29.4845 14.4987 29.4774C17.3026 29.2253 19.4999 26.8691 19.4999 23.9997C19.4999 23.3154 19.375 22.6603 19.1466 22.0559Z" fill="#FCD53F"/>
|
|
||||||
<path d="M29.9856 11C29.9952 10.8346 30 10.6679 30 10.5C30 8.04319 28.9577 5.82978 27.291 4.27795C25.9269 3.34543 24.2772 2.80005 22.5 2.80005C21.749 2.80005 21.0207 2.89745 20.3271 3.08031C20.7105 3.02739 21.1021 3.00005 21.5 3.00005C26.0266 3.00005 29.7267 6.53836 29.9856 11Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M11 14.5001C11 14.5838 10.9977 14.667 10.9932 14.7495C10.8677 12.4636 9.03606 10.6321 6.74999 10.5069C6.79014 10.5047 6.82851 10.4836 6.86696 10.4624C6.90775 10.44 6.94863 10.4175 6.99181 10.4175C8.66187 10.5069 9.13592 10.9059 9.92216 11.5779C10.5942 12.3641 11 13.3847 11 14.5001Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M19.9944 24.7502C19.9981 24.6673 20 24.5839 20 24.5001C20 23.1085 19.4832 21.8377 18.6311 20.869C17.6773 19.9687 16.5385 19.3779 15.1229 19.3181C15.0807 19.3181 15.0065 19.366 14.9331 19.4133C14.8626 19.4588 14.7929 19.5038 14.7527 19.5055C17.5902 19.6339 19.8676 21.9123 19.9944 24.7502Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M15 29.5C16.3916 29.5 17.6625 28.9832 18.6312 28.131C17.6233 29.2769 16.1461 30 14.5 30C13.0491 30 11.7294 29.4382 10.7467 28.5203C9.49997 26.8685 9.26615 25.8102 9.49997 24C9.49997 27.0376 11.9624 29.5 15 29.5Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M6.99998 18.5C8.11535 18.5 9.13594 18.0942 9.92218 17.4222C9.09682 18.3879 7.86988 19 6.49998 19C5.18777 19 4.00675 18.4383 3.18419 17.5423C2.60279 16.559 2.36462 15.6482 2.36462 14.4751C2.38643 14.3988 2.39759 14.3397 2.40768 14.2863C2.42512 14.1941 2.43937 14.1187 2.49998 14C2.49998 16.4853 4.51469 18.5 6.99998 18.5Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M16.0102 16.9896C17.4912 18.2438 19.4073 19.0001 21.5 19.0001C24.1018 19.0001 26.4305 17.8311 27.9897 15.9898C26.5087 17.2438 24.5926 18.0001 22.5 18.0001C18.1439 18.0001 14.5531 14.7232 14.0582 10.5001C14.0399 10.6564 13.9122 10.814 13.7836 10.9728C13.6424 11.1471 13.5 11.3229 13.5 11.5C13.5 13.5926 14.7562 15.5086 16.0102 16.9896Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M24.9686 10.9687C25.2209 10.9348 25.4701 10.8735 25.7115 10.7846C25.7859 10.7572 25.8596 10.7272 25.9324 10.6946C26.46 9.42491 26.2075 7.9076 25.1749 6.87498C24.1184 5.81849 22.5545 5.57861 21.2676 6.15534C21.1502 6.43779 21.0715 6.7325 21.0313 7.0314C22.076 6.89103 23.1719 7.2223 23.9748 8.02519C24.7777 8.82809 25.109 9.92403 24.9686 10.9687Z" fill="#EBCAFF"/>
|
|
||||||
<path d="M8.82323 14.8232C8.70434 14.877 8.57925 14.9195 8.44933 14.9493C8.48249 14.8049 8.5 14.6545 8.5 14.5C8.5 13.3954 7.60457 12.5 6.5 12.5C6.3455 12.5 6.21094 12.5195 6.05066 12.5507C6.09766 12.3281 6.17676 12.1767 6.17676 12.1767C6.42782 12.0632 6.70653 12 6.99999 12C8.10456 12 8.99999 12.8954 8.99999 14C8.99999 14.2935 8.93678 14.5722 8.82323 14.8232Z" fill="#EBCAFF"/>
|
|
||||||
<path d="M16.0598 24.9944C16.34 24.9236 16.5909 24.8093 16.8104 24.6618C16.9804 23.6993 16.4826 22.8173 15.7467 22.3141C15.0109 21.8109 13.7989 21.5999 13.0531 22.1033C13.0184 22.2655 13 22.4356 13 22.6125C13 22.7045 13.0051 22.7952 13.0149 22.8846C13.1498 22.8637 13.2879 22.8529 13.4286 22.8529C14.7256 22.8529 15.8079 23.772 16.0598 24.9944Z" fill="#EBCAFF"/>
|
|
||||||
<path d="M26.1249 5.87498C24.903 4.6531 23.0025 4.52352 21.6367 5.48622C21.4704 5.7221 21.3366 5.97393 21.2355 6.23547C22.4884 5.75093 23.9639 6.01414 24.9749 7.02511C25.9859 8.03608 26.2491 9.51165 25.7645 10.7645C26.0259 10.6635 26.2776 10.5298 26.5133 10.3637C27.4764 8.9978 27.3469 7.09699 26.1249 5.87498Z" fill="#EFD5FF"/>
|
|
||||||
<path d="M9.14202 14.6421C9.04203 14.7118 8.93539 14.7726 8.82323 14.8233C8.93678 14.5722 8.99999 14.2935 8.99999 14C8.99999 12.8955 8.10456 12 6.99999 12C6.70653 12 6.42782 12.0633 6.17676 12.1768C6.24219 12.0469 6.29688 11.9493 6.35797 11.8579C6.68176 11.6323 7.06816 11.4113 7.49272 11.4113C8.0001 11.4113 8.61197 11.5466 8.96458 11.8579C9.37959 12.2244 9.6122 12.8748 9.6122 13.472C9.6122 13.8966 9.36766 14.3183 9.14202 14.6421Z" fill="#EFD5FF"/>
|
|
||||||
<path d="M16.7415 24.7069C17.0113 24.5411 17.2466 24.3247 17.4341 24.0708C17.9701 23.3453 17.371 21.9121 16.7423 21.3271C15.9331 20.5743 14.2913 20.4843 13.4795 21.155C13.2655 21.4449 13.1137 21.7835 13.0439 22.1511C13.3562 22.024 13.6978 21.954 14.0558 21.954C15.5395 21.954 16.7423 23.1567 16.7423 24.6404C16.7423 24.6627 16.742 24.6848 16.7415 24.7069Z" fill="#EFD5FF"/>
|
|
||||||
<path d="M26.9748 9.97487C26.8036 10.1462 26.6189 10.296 26.4243 10.4243C27.3202 9.06595 27.1704 7.22067 25.9748 6.02513C24.7793 4.82958 22.934 4.67976 21.5756 5.57566C21.704 5.38104 21.8538 5.19641 22.0251 5.02513C23.3919 3.65829 25.608 3.65829 26.9748 5.02513C28.3417 6.39196 28.3417 8.60804 26.9748 9.97487Z" fill="white"/>
|
|
||||||
<path d="M9.14195 14.6421C9.66057 14.2808 9.99995 13.68 9.99995 13C9.99995 11.8954 9.10452 11 7.99995 11C7.31998 11 6.71927 11.3393 6.35791 11.8579C6.6817 11.6323 7.07536 11.5 7.49991 11.5C8.60448 11.5 9.49991 12.3954 9.49991 13.5C9.49991 13.9246 9.3676 14.3183 9.14195 14.6421Z" fill="white"/>
|
|
||||||
<path d="M17.3629 24.1618C17.7068 23.7391 17.913 23.1999 17.913 22.6125C17.913 21.2558 16.8131 20.156 15.4564 20.156C14.6246 20.156 13.8893 20.5695 13.4449 21.2022C13.8303 20.9885 14.2737 20.8668 14.7456 20.8668C16.2293 20.8668 17.4321 22.0696 17.4321 23.5533C17.4321 23.7626 17.4082 23.9663 17.3629 24.1618Z" fill="white"/>
|
|
||||||
<path d="M22.5 3C24.5926 3 26.5087 3.75622 27.9897 5.0103C26.4305 3.16895 24.1018 2 21.5 2C16.8056 2 13 5.80558 13 10.5C13 13.1018 14.1689 15.4305 16.0103 16.9897C14.7562 15.5087 14 13.5926 14 11.5C14 6.80558 17.8056 3 22.5 3Z" fill="#26C9FC"/>
|
|
||||||
<path d="M9.9222 11.5778C9.13597 10.9058 8.11537 10.5 7 10.5C4.51472 10.5 2.5 12.5147 2.5 15C2.5 16.1154 2.90579 17.136 3.5778 17.9222C2.61213 17.0968 2 15.8699 2 14.5C2 12.0147 4.01472 10 6.5 10C7.86991 10 9.09685 10.6121 9.9222 11.5778Z" fill="#26C9FC"/>
|
|
||||||
<path d="M18.6311 20.8689C17.6624 20.0168 16.3916 19.5 15 19.5C11.9624 19.5 9.5 21.9624 9.5 25C9.5 26.3916 10.0168 27.6624 10.8689 28.6311C9.72307 27.6231 9 26.146 9 24.5C9 21.4624 11.4624 19 14.5 19C16.146 19 17.6231 19.7231 18.6311 20.8689Z" fill="#26C9FC"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 8.6 KiB |
|
@ -1,11 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M8.5 10.9976V6.95758C8.5 6.66758 8.62 6.38758 8.82 6.18758L9.5 5.51758L10.18 6.18758C10.38 6.38758 10.5 6.66758 10.5 6.95758V10.9976H8.5ZM15 10.9976V6.95758C15 6.66758 15.12 6.38758 15.32 6.18758L16 5.51758L16.68 6.18758C16.88 6.38758 17 6.66758 17 6.95758V10.9976H15ZM21.5 6.95758V10.9976H23.5V6.95758C23.5 6.66758 23.39 6.38758 23.18 6.18758L22.5 5.51758L21.82 6.18758C21.62 6.38758 21.5 6.66758 21.5 6.95758Z" fill="#26EAFC"/>
|
|
||||||
<path d="M10.78 3.7275L9.87999 2.2175C9.70999 1.9275 9.27999 1.9275 9.10999 2.2175L8.21999 3.7275C8.04999 4.0075 7.96999 4.3375 8.00999 4.6875C8.08999 5.3775 8.63999 5.9275 9.32999 5.9975C10.23 6.0975 11 5.3975 11 4.5075C10.99 4.2275 10.91 3.9575 10.78 3.7275ZM17.28 3.7275L16.39 2.2175C16.22 1.9275 15.79 1.9275 15.62 2.2175L14.73 3.7275C14.56 3.9975 14.48 4.3375 14.52 4.6875C14.6 5.3675 15.15 5.9175 15.84 5.9975C16.74 6.0975 17.51 5.3975 17.51 4.5075C17.5 4.2275 17.42 3.9575 17.28 3.7275ZM22.89 2.2175L23.78 3.7275C23.92 3.9575 24 4.2275 24 4.5075C24 5.3975 23.24 6.0975 22.33 5.9975C21.65 5.9275 21.1 5.3775 21.02 4.6875C20.98 4.3375 21.06 3.9975 21.23 3.7275L22.12 2.2175C22.29 1.9275 22.72 1.9275 22.89 2.2175Z" fill="#FCD53F"/>
|
|
||||||
<path d="M9.49999 3.13745L10.11 3.91745C10.26 4.07745 10.35 4.27745 10.35 4.50745C10.35 4.97745 9.96999 5.35745 9.49999 5.35745C9.02999 5.35745 8.64999 4.97745 8.64999 4.50745C8.64999 4.27745 8.73999 4.07745 8.88999 3.91745L9.49999 3.13745ZM16.61 3.91745L16 3.13745L15.39 3.92745C15.24 4.07745 15.15 4.28745 15.15 4.51745C15.15 4.98745 15.53 5.36745 16 5.36745C16.47 5.36745 16.85 4.98745 16.85 4.51745C16.85 4.27745 16.76 4.07745 16.61 3.91745ZM23.11 3.91745L22.5 3.13745L21.89 3.92745C21.74 4.07745 21.65 4.28745 21.65 4.51745C21.65 4.98745 22.03 5.36745 22.5 5.36745C22.97 5.36745 23.35 4.98745 23.35 4.51745C23.35 4.27745 23.26 4.07745 23.11 3.91745Z" fill="#FFB02E"/>
|
|
||||||
<path d="M27.75 29.9976H4.25C3.56 29.9976 3 29.4376 3 28.7476V14.9976H29V28.7476C29 29.4376 28.44 29.9976 27.75 29.9976Z" fill="#D3883E"/>
|
|
||||||
<path d="M2 12C2 10.3431 3.34314 9 5 9H27.01C28.6669 9 30.01 10.3431 30.01 12V16.9975V17.5575C30.01 18.3775 28.97 18.7575 28.45 18.1175C27.94 17.4875 27.02 17.3875 26.39 17.8875L26.15 18.0775C24.7 19.2075 22.67 19.2075 21.22 18.0775L20.76 17.7175C20.22 17.2975 19.47 17.2975 18.93 17.7175L18.47 18.0775C17.02 19.2075 14.99 19.2075 13.54 18.0775L13.08 17.7175C12.54 17.2975 11.79 17.2975 11.25 17.7175L10.79 18.0775C9.34 19.2075 7.31 19.2075 5.86 18.0775L5.62 17.8875C4.99 17.3975 4.07 17.4975 3.56 18.1175C3.03 18.7475 2 18.3775 2 17.5575V12ZM3 21.9975H29V23.9975H3V21.9975Z" fill="#FFDEA7"/>
|
|
||||||
<path d="M15.15 11.15C14.95 11.35 14.95 11.66 15.15 11.86L15.89 12.6C16.09 12.8 16.4 12.8 16.6 12.6C16.8 12.4 16.8 12.09 16.6 11.89L15.86 11.15C15.66 10.95 15.34 10.95 15.15 11.15Z" fill="#00A6ED"/>
|
|
||||||
<path d="M6.85355 11.1464C6.65829 10.9512 6.34171 10.9512 6.14645 11.1464C5.95118 11.3417 5.95118 11.6583 6.14645 11.8536L7.14645 12.8536C7.34171 13.0488 7.65829 13.0488 7.85355 12.8536C8.04882 12.6583 8.04882 12.3417 7.85355 12.1464L6.85355 11.1464ZM19.8536 14.1464C19.6583 13.9512 19.3417 13.9512 19.1464 14.1464C18.9512 14.3417 18.9512 14.6583 19.1464 14.8536L20.1464 15.8536C20.3417 16.0488 20.6583 16.0488 20.8536 15.8536C21.0488 15.6583 21.0488 15.3417 20.8536 15.1464L19.8536 14.1464Z" fill="#FF6DC6"/>
|
|
||||||
<path d="M25.8536 11.8536C26.0488 11.6583 26.0488 11.3417 25.8536 11.1464C25.6583 10.9512 25.3417 10.9512 25.1464 11.1464L24.1464 12.1464C23.9512 12.3417 23.9512 12.6583 24.1464 12.8536C24.3417 13.0488 24.6583 13.0488 24.8536 12.8536L25.8536 11.8536ZM11.8536 14.8536C12.0488 14.6583 12.0488 14.3417 11.8536 14.1464C11.6583 13.9512 11.3417 13.9512 11.1464 14.1464L10.1464 15.1464C9.95118 15.3417 9.95118 15.6583 10.1464 15.8536C10.3417 16.0488 10.6583 16.0488 10.8536 15.8536L11.8536 14.8536Z" fill="#FF822D"/>
|
|
||||||
<path d="M15.1464 11.1464C15.3417 10.9512 15.6583 10.9512 15.8536 11.1464L16.8536 12.1464C17.0488 12.3417 17.0488 12.6583 16.8536 12.8536C16.6583 13.0488 16.3417 13.0488 16.1464 12.8536L15.1464 11.8536C14.9512 11.6583 14.9512 11.3417 15.1464 11.1464Z" fill="#00A6ED"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 4.1 KiB |
|
@ -1,7 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M19.6288 30.0005H11.7156C10.9356 30.0005 10.3036 29.3685 10.3036 28.5884V15.4698C10.3036 14.6897 10.9356 14.0577 11.7156 14.0577H19.6288C20.4089 14.0577 21.0409 14.6897 21.0409 15.4698V28.5884C21.0409 29.3685 20.4089 30.0005 19.6288 30.0005Z" fill="#FFDEA7"/>
|
|
||||||
<path d="M16.4787 9.73157H14.866V12.1041H16.4787V9.73157Z" fill="#9B9B9B"/>
|
|
||||||
<path d="M20.5408 11.4495H10.8045C9.80758 11.4495 9 12.2579 9 13.254V17.8972C9 18.6878 9.6336 19.3303 10.4201 19.3449C11.2318 19.3602 11.8961 18.6708 11.8961 17.8592V15.5141C11.8961 15.2624 12.1001 15.0585 12.3517 15.0585C12.6034 15.0585 15.2924 15.0585 15.2924 15.0585C15.4841 15.0585 15.6403 15.2139 15.6403 15.4065V16.1712C15.6403 16.9828 16.3047 17.6722 17.1163 17.6569C17.9036 17.6423 18.5364 16.9998 18.5364 16.2092V15.5141C18.5364 15.2624 18.7404 15.0585 18.992 15.0585C19.2437 15.0585 19.4476 15.2624 19.4476 15.5141V20.1524C19.4476 20.9641 20.112 21.6535 20.9236 21.6381C21.7109 21.6236 22.3437 20.9811 22.3437 20.1905V13.2548C22.3445 12.2579 21.537 11.4495 20.5408 11.4495Z" fill="#FFCE7C"/>
|
|
||||||
<path d="M18.258 5.57141L16.4082 2.42119C16.078 1.8596 15.2664 1.8596 14.9362 2.42119L13.0807 5.58031C13.0565 5.61996 13.033 5.65962 13.0103 5.70008L12.9998 5.71707C12.7425 6.18479 12.6049 6.72614 12.6268 7.30229C12.6883 8.90694 14.0259 10.2089 15.6313 10.2292C17.3331 10.251 18.7193 8.87862 18.7193 7.18253C18.7193 6.59101 18.5501 6.03832 18.258 5.57141Z" fill="#FFB02E"/>
|
|
||||||
<path d="M15.6727 9.03566C16.5911 9.03566 17.3356 8.29115 17.3356 7.37275C17.3356 6.45435 16.5911 5.70984 15.6727 5.70984C14.7543 5.70984 14.0098 6.45435 14.0098 7.37275C14.0098 8.29115 14.7543 9.03566 15.6727 9.03566Z" fill="#FCD53F"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.7 KiB |
|
@ -1,13 +0,0 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4 25.942C4 28.1739 5.76327 30 7.91837 30H24.0816C26.2367 30 28 28.0725 28 25.8406V6.4297C28 5.1297 26.4099 4.5297 25.5155 5.4297L20.9736 10H11.1617L6.5 5.4297C5.6 4.5297 4 5.1297 4 6.4297V25.942Z" fill="#FFB02E"/>
|
|
||||||
<path d="M9.00005 10.9265L6.20005 13.5265C5.70005 14.0265 4.80005 13.6265 4.80005 12.9265V7.72648C4.80005 7.12648 5.70005 6.72648 6.20005 7.22648L9.00005 9.82648C9.30005 10.1265 9.30005 10.6265 9.00005 10.9265Z" fill="#FF822D"/>
|
|
||||||
<path d="M23.05 10.9265L25.85 13.5265C26.35 14.0265 27.25 13.6265 27.25 12.9265V7.72648C27.25 7.12648 26.35 6.72648 25.85 7.22648L23.05 9.82648C22.75 10.1265 22.75 10.6265 23.05 10.9265Z" fill="#FF822D"/>
|
|
||||||
<path d="M17.0429 20H14.9571C14.5117 20 14.2886 20.5386 14.6036 20.8536L15.6465 21.8964C15.8417 22.0917 16.1583 22.0917 16.3536 21.8964L17.3965 20.8536C17.7114 20.5386 17.4884 20 17.0429 20Z" fill="#F70A8D"/>
|
|
||||||
<path d="M2.72372 20.0528C2.47673 19.9293 2.17639 20.0294 2.0529 20.2764C1.9294 20.5234 2.02951 20.8237 2.2765 20.9472L6.2765 22.9472C6.52349 23.0707 6.82383 22.9706 6.94732 22.7236C7.07082 22.4766 6.97071 22.1763 6.72372 22.0528L2.72372 20.0528Z" fill="#FF6723"/>
|
|
||||||
<path d="M2.72372 26.9472C2.47673 27.0707 2.17639 26.9706 2.0529 26.7236C1.9294 26.4766 2.02951 26.1763 2.2765 26.0528L6.2765 24.0528C6.52349 23.9293 6.82383 24.0294 6.94732 24.2764C7.07082 24.5234 6.97071 24.8237 6.72372 24.9472L2.72372 26.9472Z" fill="#FF6723"/>
|
|
||||||
<path d="M29.9473 20.2764C29.8238 20.0294 29.5235 19.9293 29.2765 20.0528L25.2765 22.0528C25.0295 22.1763 24.9294 22.4766 25.0529 22.7236C25.1764 22.9706 25.4767 23.0707 25.7237 22.9472L29.7237 20.9472C29.9707 20.8237 30.0708 20.5234 29.9473 20.2764Z" fill="#FF6723"/>
|
|
||||||
<path d="M29.2765 26.9472C29.5235 27.0707 29.8238 26.9706 29.9473 26.7236C30.0708 26.4766 29.9707 26.1763 29.7237 26.0528L25.7237 24.0528C25.4767 23.9293 25.1764 24.0294 25.0529 24.2764C24.9294 24.5234 25.0295 24.8237 25.2765 24.9472L29.2765 26.9472Z" fill="#FF6723"/>
|
|
||||||
<path d="M12 17C11.4477 17 11 17.4477 11 18V19C11 19.5523 11.4477 20 12 20C12.5523 20 13 19.5523 13 19V18C13 17.4477 12.5523 17 12 17Z" fill="#402A32"/>
|
|
||||||
<path d="M20 17C19.4477 17 19 17.4477 19 18V19C19 19.5523 19.4477 20 20 20C20.5523 20 21 19.5523 21 19V18C21 17.4477 20.5523 17 20 17Z" fill="#402A32"/>
|
|
||||||
<path d="M15.9999 23.106C15.4625 23.6449 14.5434 24 13.4999 24C12.4681 24 11.5579 23.6527 11.0181 23.1239C11.1384 23.8481 11.9461 27.5 15.9999 27.5C20.0538 27.5 20.8615 23.8481 20.9818 23.1239C20.4419 23.6527 19.5317 24 18.4999 24C17.4564 24 16.5374 23.6449 15.9999 23.106Z" fill="#BB1D80"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.6 KiB |
|
@ -1,14 +0,0 @@
|
||||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4 25.942C4 28.1739 5.76327 30 7.91837 30H24.0816C26.2367 30 28 28.0725 28 25.8406V6.4297C28 5.1297 26.4099 4.5297 25.5155 5.4297L20.9736 10H11.1617L6.5 5.4297C5.6 4.5297 4 5.1297 4 6.4297V25.942Z" fill="#FFB02E" />
|
|
||||||
<path d="M9.00005 10.9265L6.20005 13.5265C5.70005 14.0265 4.80005 13.6265 4.80005 12.9265V7.72648C4.80005 7.12648 5.70005 6.72648 6.20005 7.22648L9.00005 9.82648C9.30005 10.1265 9.30005 10.6265 9.00005 10.9265Z" fill="#FF822D" />
|
|
||||||
<path d="M23.05 10.9265L25.85 13.5265C26.35 14.0265 27.25 13.6265 27.25 12.9265V7.72648C27.25 7.12648 26.35 6.72648 25.85 7.22648L23.05 9.82648C22.75 10.1265 22.75 10.6265 23.05 10.9265Z" fill="#FF822D" />
|
|
||||||
<path d="M2.72372 20.0528C2.47673 19.9293 2.17639 20.0294 2.0529 20.2764C1.9294 20.5234 2.02951 20.8237 2.2765 20.9472L6.2765 22.9472C6.52349 23.0707 6.82383 22.9706 6.94732 22.7236C7.07082 22.4766 6.97071 22.1763 6.72372 22.0528L2.72372 20.0528Z" fill="#FF6723" />
|
|
||||||
<path d="M2.72372 26.9472C2.47673 27.0707 2.17639 26.9706 2.0529 26.7236C1.9294 26.4766 2.02951 26.1763 2.2765 26.0528L6.2765 24.0528C6.52349 23.9293 6.82383 24.0294 6.94732 24.2764C7.07082 24.5234 6.97071 24.8237 6.72372 24.9472L2.72372 26.9472Z" fill="#FF6723" />
|
|
||||||
<path d="M29.9473 20.2764C29.8238 20.0294 29.5235 19.9293 29.2765 20.0528L25.2765 22.0528C25.0295 22.1763 24.9294 22.4766 25.0529 22.7236C25.1764 22.9706 25.4767 23.0707 25.7237 22.9472L29.7237 20.9472C29.9707 20.8237 30.0708 20.5234 29.9473 20.2764Z" fill="#FF6723" />
|
|
||||||
<path d="M29.2765 26.9472C29.5235 27.0707 29.8238 26.9706 29.9473 26.7236C30.0708 26.4766 29.9707 26.1763 29.7237 26.0528L25.7237 24.0528C25.4767 23.9293 25.1764 24.0294 25.0529 24.2764C24.9294 24.5234 25.0295 24.8237 25.2765 24.9472L29.2765 26.9472Z" fill="#FF6723" />
|
|
||||||
<path d="M12.6213 17.0149C12.8892 17.0819 13.052 17.3534 12.9851 17.6213C12.8392 18.2046 12.5727 18.6457 12.2151 18.9507C11.8588 19.2546 11.445 19.3955 11.0498 19.435C10.6581 19.4742 10.2759 19.4153 9.95546 19.3117C9.64377 19.2108 9.34567 19.0528 9.14645 18.8535C8.95118 18.6583 8.95118 18.3417 9.14645 18.1464C9.34171 17.9512 9.65829 17.9512 9.85355 18.1464C9.90433 18.1972 10.0437 18.2892 10.2633 18.3602C10.4741 18.4284 10.7169 18.4633 10.9502 18.44C11.18 18.417 11.3912 18.3391 11.5662 18.1899C11.7398 18.0418 11.9108 17.7954 12.0149 17.3787C12.0819 17.1108 12.3534 16.948 12.6213 17.0149Z" fill="#402A32" />
|
|
||||||
<path d="M16 24.5C14.6098 24.5 13.6831 25.3767 13.416 25.7773C13.2628 26.0071 12.9524 26.0692 12.7226 25.916C12.4929 25.7628 12.4308 25.4524 12.584 25.2226C12.9456 24.6803 13.9679 23.709 15.5 23.5291V21C15.5 20.7239 15.7239 20.5 16 20.5C16.2761 20.5 16.5 20.7239 16.5 21V23.5291C18.0321 23.709 19.0544 24.6803 19.416 25.2226C19.5692 25.4524 19.5071 25.7628 19.2773 25.916C19.0476 26.0692 18.7372 26.0071 18.584 25.7773C18.3169 25.3767 17.3902 24.5 16 24.5Z" fill="#402A32" />
|
|
||||||
<path d="M19.0149 17.6213C18.948 17.3534 19.1108 17.0819 19.3787 17.0149C19.6466 16.948 19.9181 17.1108 19.9851 17.3787C20.0892 17.7954 20.2602 18.0418 20.4338 18.1899C20.6088 18.3391 20.82 18.417 21.0498 18.44C21.2831 18.4633 21.5259 18.4284 21.7367 18.3602C21.9563 18.2892 22.0957 18.1972 22.1464 18.1464C22.3417 17.9512 22.6583 17.9512 22.8536 18.1464C23.0488 18.3417 23.0488 18.6583 22.8536 18.8535C22.6543 19.0528 22.3562 19.2108 22.0445 19.3117C21.7241 19.4153 21.3419 19.4742 20.9502 19.435C20.555 19.3955 20.1412 19.2546 19.7849 18.9507C19.4273 18.6457 19.1608 18.2046 19.0149 17.6213Z" fill="#402A32" />
|
|
||||||
<path d="M17.0429 20H14.9571C14.5117 20 14.2886 20.5386 14.6036 20.8536L15.6465 21.8964C15.8417 22.0917 16.1583 22.0917 16.3536 21.8964L17.3965 20.8536C17.7114 20.5386 17.4884 20 17.0429 20Z" fill="#F70A8D" />
|
|
||||||
<path d="M8 23C8 21.8954 8.89543 21 10 21C11.1046 21 12 21.8954 12 23V26C12 27.1046 11.1046 28 10 28C8.89543 28 8 27.1046 8 26V23Z" fill="#5092FF" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 3.8 KiB |
|
@ -1,21 +0,0 @@
|
||||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4 25.942C4 28.1739 5.76327 30 7.91837 30H24.0816C26.2367 30 28 28.0725 28 25.8406V6.4297C28 5.1297 26.4099 4.5297 25.5155 5.4297L20.9736 10H11.1617L6.5 5.4297C5.6 4.5297 4 5.1297 4 6.4297V25.942Z" fill="#FFB02E" />
|
|
||||||
<path d="M9.00005 10.9265L6.20005 13.5265C5.70005 14.0265 4.80005 13.6265 4.80005 12.9265V7.72648C4.80005 7.12648 5.70005 6.72648 6.20005 7.22648L9.00005 9.82648C9.30005 10.1265 9.30005 10.6265 9.00005 10.9265Z" fill="#FF822D" />
|
|
||||||
<path d="M23.05 10.9265L25.85 13.5265C26.35 14.0265 27.25 13.6265 27.25 12.9265V7.72648C27.25 7.12648 26.35 6.72648 25.85 7.22648L23.05 9.82648C22.75 10.1265 22.75 10.6265 23.05 10.9265Z" fill="#FF822D" />
|
|
||||||
<path d="M2.72372 20.0528C2.47673 19.9293 2.17639 20.0294 2.0529 20.2764C1.9294 20.5234 2.02951 20.8237 2.2765 20.9472L6.2765 22.9472C6.52349 23.0707 6.82383 22.9706 6.94732 22.7236C7.07082 22.4766 6.97071 22.1763 6.72372 22.0528L2.72372 20.0528Z" fill="#FF6723" />
|
|
||||||
<path d="M2.72372 26.9472C2.47673 27.0707 2.17639 26.9706 2.0529 26.7236C1.9294 26.4766 2.02951 26.1763 2.2765 26.0528L6.2765 24.0528C6.52349 23.9293 6.82383 24.0294 6.94732 24.2764C7.07082 24.5234 6.97071 24.8237 6.72372 24.9472L2.72372 26.9472Z" fill="#FF6723" />
|
|
||||||
<path d="M29.9473 20.2764C29.8238 20.0294 29.5235 19.9293 29.2765 20.0528L25.2765 22.0528C25.0295 22.1763 24.9294 22.4766 25.0529 22.7236C25.1764 22.9706 25.4767 23.0707 25.7237 22.9472L29.7237 20.9472C29.9707 20.8237 30.0708 20.5234 29.9473 20.2764Z" fill="#FF6723" />
|
|
||||||
<path d="M29.2765 26.9472C29.5235 27.0707 29.8238 26.9706 29.9473 26.7236C30.0708 26.4766 29.9707 26.1763 29.7237 26.0528L25.7237 24.0528C25.4767 23.9293 25.1764 24.0294 25.0529 24.2764C24.9294 24.5234 25.0295 24.8237 25.2765 24.9472L29.2765 26.9472Z" fill="#FF6723" />
|
|
||||||
<path d="M12 24V30L7.91837 30C5.76327 30 4 28.1739 4 25.942V19.9996C4.83566 19.3719 5.87439 19 7 19C9.76142 19 12 21.2386 12 24Z" fill="#FF822D" />
|
|
||||||
<path d="M24.0816 30L20 30V24C20 21.2386 22.2386 19 25 19C26.1256 19 27.1643 19.3719 28 19.9996V25.8406C28 28.0725 26.2367 30 24.0816 30Z" fill="#FF822D" />
|
|
||||||
<path d="M17.0429 19H14.9571C14.5117 19 14.2886 19.5386 14.6036 19.8536L15.6465 20.8964C15.8417 21.0917 16.1583 21.0917 16.3536 20.8964L17.3965 19.8536C17.7114 19.5386 17.4884 19 17.0429 19Z" fill="#F70A8D" />
|
|
||||||
<path d="M7 20C4.79086 20 3 21.7909 3 24V30H11V24C11 21.7909 9.20914 20 7 20Z" fill="#FFB02E" />
|
|
||||||
<path d="M25 20C22.7909 20 21 21.7909 21 24V30H29V24C29 21.7909 27.2091 20 25 20Z" fill="#FFB02E" />
|
|
||||||
<path d="M14 24C14 22.8954 14.8954 22 16 22C17.1046 22 18 22.8954 18 24V25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25V24Z" fill="#BB1D80" />
|
|
||||||
<path d="M11.5 19C13.433 19 15 17.433 15 15.5C15 13.567 13.433 12 11.5 12C9.567 12 8 13.567 8 15.5C8 17.433 9.567 19 11.5 19Z" fill="white" />
|
|
||||||
<path d="M20.5 19C22.433 19 24 17.433 24 15.5C24 13.567 22.433 12 20.5 12C18.567 12 17 13.567 17 15.5C17 17.433 18.567 19 20.5 19Z" fill="white" />
|
|
||||||
<path d="M5 20.5351C5.30951 20.356 5.64523 20.2173 6 20.126V23.5C6 23.7761 5.77614 24 5.5 24C5.22386 24 5 23.7761 5 23.5V20.5351Z" fill="#FF6723" />
|
|
||||||
<path d="M8 20.126C8.35477 20.2173 8.69049 20.356 9 20.5351V23.5C9 23.7761 8.77614 24 8.5 24C8.22386 24 8 23.7761 8 23.5V20.126Z" fill="#FF6723" />
|
|
||||||
<path d="M23 20.5351C23.3095 20.356 23.6452 20.2173 24 20.126V23.5C24 23.7761 23.7761 24 23.5 24C23.2239 24 23 23.7761 23 23.5V20.5351Z" fill="#FF6723" />
|
|
||||||
<path d="M26 20.126C26.3548 20.2173 26.6905 20.356 27 20.5351V23.5C27 23.7761 26.7761 24 26.5 24C26.2239 24 26 23.7761 26 23.5V20.126Z" fill="#FF6723" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 3.6 KiB |