cobalt 10: svelte web app, improved backend (#719)

This commit is contained in:
wukko 2024-09-09 22:26:14 +06:00 committed by GitHub
commit 08bc5022a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
413 changed files with 20773 additions and 8550 deletions

21
.dockerignore Normal file
View 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
View file

@ -13,19 +13,20 @@ waitport() {
test_api() {
waitport 3000
curl -m 3 http://localhost:3000/api/serverInfo
API_RESPONSE=$(curl -m 3 http://localhost:3000/api/json \
curl -m 3 http://localhost:3000/
API_RESPONSE=$(curl -m 3 http://localhost:3000/ \
-X POST \
-H "Accept: 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)
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 \
| cut -d' ' -f2 \
| tr -d '\r')
@ -37,34 +38,27 @@ test_api() {
fi
}
test_web() {
waitport 3001
curl -m 3 http://127.0.0.1:3001/onDemand?blockId=0 \
| grep -q '"status":"success"'
}
setup_api() {
export API_PORT=3000
export API_URL=http://localhost:3000
timeout 10 npm run start
timeout 10 pnpm run --prefix api start &
API_PID=$!
}
setup_web() {
export WEB_PORT=3001
export WEB_URL=http://localhost:3001
export API_URL=http://localhost:3000
timeout 5 npm run start
pnpm run --prefix web build
}
cd "$(git rev-parse --show-toplevel)"
npm i
pnpm install --frozen-lockfile
if [ "$1" = "api" ]; then
setup_api &
setup_api
test_api
[ "$API_PID" != "" ] \
&& kill "$API_PID"
elif [ "$1" = "web" ]; then
setup_web &
test_web
setup_web
else
echo "usage: $0 <api/web>" >&2
exit 1

33
.github/workflows/test-services.yml vendored Normal file
View 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 }}

View file

@ -3,60 +3,32 @@ name: Run tests
on:
pull_request:
push:
branches: [ current ]
jobs:
check-lockfile:
name: check lockfile correctness
runs-on: ubuntu-latest
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
run: |
cp package-lock.json before.json
npm ci
npm i --package-lock-only
diff before.json package-lock.json
run: pnpm install --frozen-lockfile
test-web:
name: web sanity check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run test script
run: .github/test.sh web
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- run: .github/test.sh web
test-api:
name: api sanity check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run test script
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 }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: .github/test.sh api

18
.gitignore vendored
View file

@ -1,21 +1,21 @@
# os stuff
# OS directory info files
.DS_Store
desktop.ini
# npm
# node
node_modules
# static build
build
# secrets
.env
.env.*
!.env.example
cookies.json
# docker
docker-compose.yml
# vscode
# ide
.vscode
# cookie file
cookies.json
# page build
build

View file

@ -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
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
CMD [ "node", "src/cobalt" ]
CMD [ "node", "src/cobalt" ]

122
README.md
View file

@ -1,41 +1,56 @@
# cobalt
best way to save what you love: [cobalt.tools](https://cobalt.tools/)
<div align="center">
<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")
[💬 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***.
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***.
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 👀).
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram posts & reels | ✅ | ✅ | ✅ | | |
| facebook videos | ✅ | ❌ | ❌ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat stories & spotlights | ✅ | ✅ | ✅ | | |
| soundcloud | | ✅ | | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | | |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | | |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vine archive | ✅ | ✅ | ✅ | | |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| youtube videos, shorts & music | ✅ | ✅ | ✅ | ✅ | ✅ |
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili | ✅ | ✅ | ✅ | | |
| bluesky | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram | ✅ | ✅ | ✅ | | |
| facebook | ✅ | ❌ | ✅ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat | ✅ | ✅ | ✅ | | |
| soundcloud | | ✅ | | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | | |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | | |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vine | ✅ | ✅ | ✅ | | |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
| 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. |
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
## cobalt api
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.
### partners
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), all main instances are currently hosted on their network :)
✅ 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 :)
## 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 ***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 code 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/).
### cobalt 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).
## acknowledgements
### ffmpeg
@ -111,7 +97,7 @@ we use [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static) to get binar
you can support the developer via various methods listed on their github page! (linked above)
### youtube.js
cobalt relies on [youtube.js](https://github.com/LuanRT/YouTube.js) for interacting with the innertube api, it wouldn't have been possible without it.
cobalt relies on [youtube.js](https://github.com/LuanRT/YouTube.js) for interacting with the innertube api, it wouldn't have been possible without it.
you can support the developer via various methods listed on their github page! (linked above)

661
api/LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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"
)
})
}

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

View file

@ -1,10 +1,10 @@
import { normalizeRequest } from "../modules/processing/request.js";
import match from "./processing/match.js";
import { extract } from "./processing/url.js";
import { normalizeRequest } from "../processing/request.js";
import match from "../processing/match.js";
import { extract } from "../processing/url.js";
export async function runTest(url, params, expect) {
const normalized = normalizeRequest({ url, ...params });
if (!normalized) {
const { success, data: normalized } = await normalizeRequest({ url, ...params });
if (!success) {
throw "invalid request";
}
@ -13,9 +13,11 @@ export async function runTest(url, params, expect) {
throw `invalid url: ${normalized.url}`;
}
const result = await match(
parsed.host, parsed.patternMatch, "en", normalized
);
const result = await match({
host: parsed.host,
patternMatch: parsed.patternMatch,
params: normalized,
});
let error = [];
if (expect.status !== result.body.status) {
@ -36,7 +38,7 @@ export async function runTest(url, params, expect) {
throw error.join('\n');
}
if (result.body.status === 'stream') {
if (result.body.status === 'tunnel') {
// TODO: stream testing
}
}

View file

@ -1,7 +1,7 @@
import Cookie from './cookie.js';
import { readFile, writeFile } from 'fs/promises';
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
import { env } from '../../../modules/config.js'
import { env } from '../../config.js';
const WRITE_INTERVAL = 60000,
cookiePath = env.cookiePath,

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

View file

@ -1,71 +1,75 @@
import { audioIgnore, services, supportedAudio } from "../config.js";
import { createResponse } from "../processing/request.js";
import loc from "../../localization/manager.js";
import createFilename from "./createFilename.js";
import createFilename from "./create-filename.js";
import { createResponse } from "./request.js";
import { audioIgnore } from "./service-config.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,
responseType = "stream",
responseType = "tunnel",
defaultParams = {
u: r.urls,
headers: r.headers,
service: host,
filename: r.filenameAttributes ?
createFilename(r.filenameAttributes, filenamePattern, isAudioOnly, isAudioMuted) : r.filename,
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false,
requestIP
},
params = {},
audioFormat = String(userFormat);
params = {};
if (r.isPhoto) action = "photo";
else if (r.picker) action = "picker"
else if (r.isGif && toGif) action = "gif";
else if (isAudioMuted) action = "muteVideo";
else if (r.isGif && twitterGif) action = "gif";
else if (isAudioOnly) action = "audio";
else if (isAudioMuted) action = "muteVideo";
else if (r.isM3U8) action = "m3u8";
else action = "video";
if (action === "picker" || action === "audio") {
if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
defaultParams.isAudioOnly = true;
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) {
default:
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') });
return createResponse("error", {
code: "error.api.fetch.empty"
});
case "photo":
responseType = "redirect";
break;
case "gif":
params = { type: "gif" }
params = { type: "gif" };
break;
case "m3u8":
params = {
type: Array.isArray(r.urls) ? "render" : "remux"
type: Array.isArray(r.urls) ? "merge" : "remux"
}
break;
case "muteVideo":
let muteType = "mute";
if (Array.isArray(r.urls) && !r.isM3U8) {
muteType = "bridge";
muteType = "proxy";
}
params = {
type: muteType,
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
mute: true
u: Array.isArray(r.urls) ? r.urls[0] : r.urls
}
if (host === "reddit" && r.typeId === "redirect")
if (host === "reddit" && r.typeId === "redirect") {
responseType = "redirect";
}
break;
case "picker":
@ -74,13 +78,15 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
case "instagram":
case "twitter":
case "snapchat":
case "bsky":
params = { picker: r.picker };
break;
case "tiktok":
let audioStreamType = "render";
if (r.bestAudio === "mp3" && (audioFormat === "mp3" || audioFormat === "best")) {
let audioStreamType = "audio";
if (r.bestAudio === "mp3" && audioFormat === "best") {
audioFormat = "mp3";
audioStreamType = "bridge"
audioStreamType = "proxy"
}
params = {
picker: r.picker,
@ -92,27 +98,30 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
filename: r.audioFilename,
isAudioOnly: true,
audioFormat,
}),
copy: audioFormat === "best"
})
}
break;
}
break;
case "video":
switch (host) {
case "bilibili":
params = { type: "render" };
params = { type: "merge" };
break;
case "youtube":
params = { type: r.type };
break;
case "reddit":
responseType = r.typeId;
params = { type: r.type };
break;
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "render" }
params = { type: "merge" }
} else {
responseType = "redirect";
}
@ -128,7 +137,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
case "vk":
case "tiktok":
params = { type: "bridge" };
params = { type: "proxy" };
break;
case "facebook":
@ -145,58 +154,56 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
break;
case "audio":
if (audioIgnore.includes(host)
|| (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') })
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", {
code: "error.api.fetch.empty"
})
}
let processType = "render",
copy = false;
let processType = "audio";
let copy = false;
if (!supportedAudio.includes(audioFormat)) {
audioFormat = "best"
}
if (audioFormat === "best") {
const serviceBestAudio = r.bestAudio;
const serviceBestAudio = r.bestAudio || services[host]["bestAudio"];
const isBestAudio = audioFormat === "best";
const isBestOrMp3 = isBestAudio || audioFormat === "mp3";
const isBestAudioDefined = isBestAudio && serviceBestAudio;
const isBestHostAudio = serviceBestAudio && (audioFormat === serviceBestAudio);
if (serviceBestAudio) {
audioFormat = serviceBestAudio;
processType = "proxy";
const isTumblrAudio = host === "tumblr" && !r.filename;
const isSoundCloud = host === "soundcloud";
const isTiktok = host === "tiktok";
if (isBestAudioDefined || isBestHostAudio) {
audioFormat = serviceBestAudio;
processType = "bridge";
if (isSoundCloud || (isTiktok && audioFormat === "m4a")) {
processType = "render"
copy = true
if (host === "soundcloud") {
processType = "audio";
copy = true;
}
} else {
audioFormat = "m4a";
copy = true;
}
} else if (isBestAudio && !isSoundCloud) {
audioFormat = "m4a";
copy = true
}
if (isTumblrAudio && isBestOrMp3) {
audioFormat = "mp3";
processType = "bridge"
}
if (r.isM3U8 || host === "vimeo") {
copy = false;
processType = "render"
processType = "audio";
}
params = {
type: processType,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioFormat: audioFormat,
copy: copy
audioBitrate,
audioCopy: copy,
audioFormat,
}
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})
}

View file

@ -1,11 +1,12 @@
import { strict as assert } from "node:assert";
import { env } from '../config.js';
import { env } from "../config.js";
import { createResponse } from "../processing/request.js";
import loc from "../../localization/manager.js";
import { testers } from "./servicesPatternTesters.js";
import matchActionDecider from "./matchActionDecider.js";
import { testers } from "./service-patterns.js";
import matchAction from "./match-action.js";
import { friendlyServiceName } from "./service-alias.js";
import bilibili from "./services/bilibili.js";
import reddit from "./services/reddit.js";
@ -27,11 +28,12 @@ import dailymotion from "./services/dailymotion.js";
import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
let freebind;
export default async function(host, patternMatch, lang, obj) {
const { url } = obj;
export default async function({ host, patternMatch, params }) {
const { url } = params;
assert(url instanceof URL);
let dispatcher, requestIP;
@ -46,17 +48,20 @@ export default async function(host, patternMatch, lang, obj) {
try {
let r,
isAudioOnly = !!obj.isAudioOnly,
disableMetadata = !!obj.disableMetadata;
isAudioOnly = params.downloadMode === "audio",
isAudioMuted = params.downloadMode === "mute";
if (!testers[host]) {
return createResponse("error", {
t: loc(lang, 'ErrorUnsupported')
code: "error.api.service.unsupported"
});
}
if (!(testers[host](patternMatch))) {
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({
id: patternMatch.id,
index: patternMatch.index - 1,
toGif: !!obj.twitterGif,
toGif: !!params.twitterGif,
alwaysProxy: params.alwaysProxy,
dispatcher
});
break;
case "vk":
r = await vk({
userId: patternMatch.userId,
videoId: patternMatch.videoId,
quality: obj.vQuality
quality: params.videoQuality
});
break;
case "ok":
r = await ok({
id: patternMatch.id,
quality: obj.vQuality
quality: params.videoQuality
});
break;
case "bilibili":
r = await bilibili(patternMatch);
break;
case "youtube":
let fetchInfo = {
id: patternMatch.id.slice(0, 11),
quality: obj.vQuality,
format: obj.vCodec,
isAudioOnly: isAudioOnly,
isAudioMuted: obj.isAudioMuted,
dubLang: obj.dubLang,
quality: params.videoQuality,
format: params.youtubeVideoCodec,
isAudioOnly,
isAudioMuted,
dubLang: params.youtubeDubLang,
dispatcher
}
if (url.hostname === 'music.youtube.com' || isAudioOnly === true) {
if (url.hostname === "music.youtube.com" || isAudioOnly) {
fetchInfo.quality = "max";
fetchInfo.format = "vp9";
fetchInfo.isAudioOnly = true
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
}
r = await youtube(fetchInfo);
break;
case "reddit":
r = await reddit({
sub: patternMatch.sub,
@ -111,15 +123,18 @@ export default async function(host, patternMatch, lang, obj) {
user: patternMatch.user
});
break;
case "tiktok":
r = await tiktok({
postId: patternMatch.postId,
id: patternMatch.id,
fullAudio: obj.isTTFullAudio,
isAudioOnly: isAudioOnly,
h265: obj.tiktokH265
fullAudio: params.tiktokFullAudio,
isAudioOnly,
h265: params.tiktokH265,
alwaysProxy: params.alwaysProxy,
});
break;
case "tumblr":
r = await tumblr({
id: patternMatch.id,
@ -127,116 +142,169 @@ export default async function(host, patternMatch, lang, obj) {
url
});
break;
case "vimeo":
r = await vimeo({
id: patternMatch.id.slice(0, 11),
password: patternMatch.password,
quality: obj.vQuality,
isAudioOnly: isAudioOnly
quality: params.videoQuality,
isAudioOnly,
});
break;
case "soundcloud":
isAudioOnly = true;
isAudioMuted = false;
r = await soundcloud({
url,
author: patternMatch.author,
song: patternMatch.song,
format: obj.aFormat,
format: params.audioFormat,
shortLink: patternMatch.shortLink || false,
accessKey: patternMatch.accessKey || false
});
break;
case "instagram":
r = await instagram({
...patternMatch,
quality: obj.vQuality,
quality: params.videoQuality,
alwaysProxy: params.alwaysProxy,
dispatcher
})
break;
case "vine":
r = await vine({
id: patternMatch.id
});
break;
case "pinterest":
r = await pinterest({
id: patternMatch.id,
shortLink: patternMatch.shortLink || false
});
break;
case "streamable":
r = await streamable({
id: patternMatch.id,
quality: obj.vQuality,
isAudioOnly: isAudioOnly,
quality: params.videoQuality,
isAudioOnly,
});
break;
case "twitch":
r = await twitch({
clipId: patternMatch.clip || false,
quality: obj.vQuality,
isAudioOnly: obj.isAudioOnly
quality: params.videoQuality,
isAudioOnly,
});
break;
case "rutube":
r = await rutube({
id: patternMatch.id,
yappyId: patternMatch.yappyId,
key: patternMatch.key,
quality: obj.vQuality,
isAudioOnly: isAudioOnly
quality: params.videoQuality,
isAudioOnly,
});
break;
case "dailymotion":
r = await dailymotion(patternMatch);
break;
case "snapchat":
r = await snapchat({
hostname: url.hostname,
...patternMatch
...patternMatch,
alwaysProxy: params.alwaysProxy,
});
break;
case "loom":
r = await loom({
id: patternMatch.id
});
break;
case "facebook":
r = await facebook({
...patternMatch
});
break;
case "bsky":
r = await bluesky({
...patternMatch,
alwaysProxy: params.alwaysProxy
});
break;
default:
return createResponse("error", {
t: loc(lang, 'ErrorUnsupported')
code: "error.api.service.unsupported"
});
}
if (r.isAudioOnly) isAudioOnly = true;
let isAudioMuted = isAudioOnly ? false : obj.isAudioMuted;
if (r.isAudioOnly) {
isAudioOnly = true;
isAudioMuted = false;
}
if (r.error && r.critical) {
return createResponse("critical", {
t: loc(lang, 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)
code: `error.api.${r.error}`,
})
}
return matchActionDecider(
r, host, obj.aFormat, isAudioOnly,
lang, isAudioMuted, disableMetadata,
obj.filenamePattern, obj.twitterGif,
requestIP
)
if (r.error) {
let context;
switch(r.error) {
case "content.too_long":
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 {
return createResponse("error", {
t: loc(lang, 'ErrorBadFetch', host)
code: "error.api.fetch.critical",
context: {
service: friendlyServiceName(host),
}
})
}
}

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

View 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();

View file

@ -0,0 +1,10 @@
const friendlyNames = {
bsky: "bluesky",
}
export const friendlyServiceName = (service) => {
if (service in friendlyNames) {
return friendlyNames[service];
}
return service;
}

View 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 + '@\\.'
})
)
})

View 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,
}

View file

@ -30,22 +30,29 @@ function extractBestQuality(dashData) {
async function com_download(id) {
let html = await fetch(`https://bilibili.com/video/${id}`, {
headers: { "user-agent": genericUserAgent }
}).then(r => r.text()).catch(() => {});
if (!html) return { error: 'ErrorCouldntFetch' };
headers: {
"user-agent": genericUserAgent
}
})
.then(r => r.text())
.catch(() => {});
if (!html) {
return { error: "fetch.fail" }
}
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]);
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);
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
return {
@ -66,7 +73,7 @@ async function tv_download(id) {
const { data } = await fetch(url).then(a => a.json());
if (!data?.playurl?.video) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
const [ video, audio ] = extractBestQuality({
@ -76,11 +83,11 @@ async function tv_download(id) {
});
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
if (video.duration > env.durationLimit * 1000) {
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
}
return {
@ -101,5 +108,5 @@ export default async function({ comId, tvId, comShortLink }) {
return tv_download(tvId);
}
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}

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

View file

@ -1,5 +1,5 @@
import HLSParser from 'hls-parser';
import { env } from '../../config.js';
import HLSParser from "hls-parser";
import { env } from "../../config.js";
let _token;
@ -31,7 +31,7 @@ const getToken = async () => {
export default async function({ id }) {
const token = await getToken();
if (!token) return { error: 'ErrorSomethingWentWrong' };
if (!token) return { error: "fetch.fail" };
const req = await fetch('https://graphql.api.dailymotion.com/',
{
@ -70,20 +70,20 @@ export default async function({ id }) {
const media = req?.data?.media;
if (media?.__typename !== 'Video' || !media.hlsURL) {
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}
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(() => {});
if (!manifest) return { error: 'ErrorSomethingWentWrong' };
if (!manifest) return { error: "fetch.fail" };
const bestQuality = HLSParser.parse(manifest).variants
.filter(v => v.codecs.includes('avc1'))
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
if (!bestQuality) return { error: 'ErrorEmptyDownload' }
if (!bestQuality) return { error: "fetch.empty" }
const fileMetadata = {
title: media.title,

View file

@ -33,7 +33,8 @@ export default async function({ id, shareType, shortLink }) {
.then(r => r.text())
.catch(() => false);
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html && shortLink) return { error: "fetch.short_link" }
if (!html) return { error: "fetch.fail" };
const urls = [];
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 (!urls.length) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
const baseFilename = `facebook_${id || shortLink}`;

View file

@ -1,5 +1,5 @@
import { createStream } from "../../stream/manage.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";
const commonHeaders = {
@ -163,23 +163,34 @@ export default function(obj) {
?.[0];
}
function extractOldPost(data, id) {
function extractOldPost(data, id, alwaysProxy) {
const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
if (sidecar) {
const picker = sidecar.edges.filter(e => e.node?.display_url)
.map(e => {
.map((e, i) => {
const type = e.node?.is_video ? "video" : "photo";
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 {
type, url,
type,
url: proxyFile || url,
/* thumbnails have `Cross-Origin-Resource-Policy`
** set to `same-origin`, so we need to proxy them */
thumb: createStream({
service: "instagram",
type: "default",
type: "proxy",
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;
if (carousel) {
const picker = carousel.filter(e => e?.image_versions2)
.map(e => {
.map((e, i) => {
const type = e.video_versions ? "video" : "photo";
const imageUrl = e.image_versions2.candidates[0].url;
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);
url = video.url;
}
let proxyFile;
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
u: url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
return {
type, url,
type,
url: proxyFile || url,
/* 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({
service: "instagram",
type: "default",
type: "proxy",
u: imageUrl,
filename: "image.jpg"
filename: `instagram_${id}_${i + 1}.jpg`
})
}
});
@ -237,16 +259,17 @@ export default function(obj) {
} else if (data.image_versions2?.candidates) {
return {
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;
try {
const cookie = getCookie('instagram');
const bearer = getCookie('instagram_bearer');
const token = bearer?.values()?.token;
@ -271,16 +294,16 @@ export default function(obj) {
if (!data && cookie) data = await requestGQL(id, cookie);
} catch {}
if (!data) return { error: 'ErrorCouldntFetch' };
if (!data) return { error: "fetch.fail" };
if (data?.gql_data) {
result = extractOldPost(data, id)
result = extractOldPost(data, id, alwaysProxy)
} else {
result = extractNewPost(data, id)
result = extractNewPost(data, id, alwaysProxy)
}
if (result) return result;
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}
async function usernameToId(username, cookie) {
@ -295,11 +318,11 @@ export default function(obj) {
async function getStory(username, id) {
const cookie = getCookie('instagram');
if (!cookie) return { error: 'ErrorUnsupported' };
if (!cookie) return { error: "link.unsupported" };
const userId = await usernameToId(username, cookie);
if (!userId) return { error: 'ErrorEmptyDownload' };
if (!userId) return { error: "fetch.empty" };
const dtsgId = await findDtsgId(cookie);
const url = new URL('https://www.instagram.com/api/graphql/');
@ -320,8 +343,8 @@ export default function(obj) {
} catch {}
const item = media.items.find(m => m.pk === id);
if (!item) return { error: 'ErrorEmptyDownload' };
if (!item) return { error: "fetch.empty" };
if (item.video_versions) {
const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
return {
@ -338,12 +361,12 @@ export default function(obj) {
}
}
return { error: 'ErrorUnsupported' };
return { error: "link.unsupported" };
}
const { postId, storyId, username } = obj;
if (postId) return getPost(postId);
const { postId, storyId, username, alwaysProxy } = obj;
if (postId) return getPost(postId, alwaysProxy);
if (username && storyId) return getStory(username, storyId);
return { error: 'ErrorUnsupported' }
return { error: "fetch.empty" }
}

View file

@ -23,7 +23,7 @@ export default async function({ id }) {
.then(r => r.status === 200 ? r.json() : false)
.catch(() => {});
if (!gql) return { error: 'ErrorEmptyDownload' };
if (!gql) return { error: "fetch.empty" };
const videoUrl = gql?.url;
@ -35,5 +35,5 @@ export default async function({ id }) {
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}

View file

@ -1,5 +1,5 @@
import { genericUserAgent, env } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
import { cleanString } from "../../misc/utils.js";
const resolutions = {
"ultra": "2160",
@ -19,26 +19,26 @@ export default async function(o) {
headers: { "user-agent": genericUserAgent }
}).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="({.*?})"( .*?)>/)
?.[1]
?.replaceAll("&quot;", '"');
if (!videoData) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
if (videoData.provider !== "UPLOADED_ODKL")
return { error: 'ErrorUnsupported' };
return { error: "link.unsupported" };
if (videoData.movie.is_live)
return { error: 'ErrorLiveVideo' };
return { error: "content.video.live" };
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 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" }
}

View file

@ -12,15 +12,15 @@ export default async function(o) {
.catch(() => {});
}
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 }
}).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)
.find(a => a.endsWith('.mp4') && a.includes('720p'));
@ -30,14 +30,17 @@ export default async function(o) {
audioFilename: `pinterest_${o.id}_audio`
}
let imageLink = [...html.matchAll(imageRegex)]
const imageLink = [...html.matchAll(imageRegex)]
.map(([, link]) => link)
.find(a => a.endsWith('.jpg') || a.endsWith('.gif'));
const imageType = imageLink.endsWith(".gif") ? "gif" : "jpg"
if (imageLink) return {
urls: imageLink,
isPhoto: true
isPhoto: true,
filename: `pinterest_${o.id}.${imageType}`
}
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}

View file

@ -9,7 +9,7 @@ async function getAccessToken() {
* you can get these by making a reddit app and
* authenticating an account against reddit's oauth2 api
* see: https://github.com/reddit-archive/reddit/wiki/OAuth2
*
*
* any additional cookie fields are managed by this code and you
* should not touch them unless you know what you're doing. **/
const cookie = await getCookie('reddit');
@ -67,20 +67,25 @@ export default async function(obj) {
}
).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;
const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
if (data?.url?.endsWith('.gif')) return {
typeId: "redirect",
urls: data.url
urls: data.url,
filename: `reddit_${id}.gif`,
}
if (!data.secure_media?.reddit_video)
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
let audio = false,
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
@ -107,16 +112,14 @@ export default async function(obj) {
}).catch(() => {})
}
let id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
if (!audio) return {
typeId: "redirect",
urls: video
}
return {
typeId: "stream",
type: "render",
typeId: "tunnel",
type: "merge",
urls: [video, audioFileLink],
audioFilename: `reddit_${id}_audio`,
filename: `reddit_${id}.mp4`

View file

@ -1,7 +1,7 @@
import HLS from 'hls-parser';
import HLS from "hls-parser";
import { env } from "../../config.js";
import { cleanString } from '../../sub/utils.js';
import { cleanString } from "../../misc/utils.js";
async function requestJSON(url) {
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`
)
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
if (!yappyURL) return { error: 'ErrorEmptyDownload' };
if (!yappyURL) return { error: "fetch.empty" };
return {
urls: yappyURL,
@ -33,19 +33,19 @@ export default async function(obj) {
if (obj.key) requestURL.searchParams.set('p', obj.key);
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.live_streams?.hls) return { error: 'ErrorLiveVideo' };
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
if (play.live_streams?.hls) return { error: "content.video.live" };
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)
.then(r => r.text())
.catch(() => {});
if (!m3u8) return { error: 'ErrorCouldntFetch' };
if (!m3u8) return { error: "fetch.fail" };
m3u8 = HLS.parse(m3u8).variants;

View file

@ -1,20 +1,23 @@
import { genericUserAgent } from "../../config.js";
import { getRedirectingURL } from "../../sub/utils.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.?=]+&amp;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>$/;
async function getSpotlight(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);
if (!html) {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
if (videoURL) {
if (videoURL && new URL(videoURL).hostname.endsWith(".sc-cdn.net")) {
return {
urls: videoURL,
filename: `snapchat_${id}.mp4`,
@ -23,12 +26,16 @@ async function getSpotlight(id) {
}
}
async function getStory(username, storyId) {
const html = await fetch(`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`, {
headers: { 'User-Agent': genericUserAgent }
}).then((r) => r.text()).catch(() => null);
async function getStory(username, storyId, alwaysProxy) {
const html = await fetch(
`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`,
{ headers: { 'user-agent': genericUserAgent } }
)
.then((r) => r.text())
.catch(() => null);
if (!html) {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
@ -42,6 +49,7 @@ async function getStory(username, storyId) {
if (story.snapMediaType === 0) {
return {
urls: story.snapUrls.mediaUrl,
filename: `snapchat_${storyId}.jpg`,
isPhoto: true
}
}
@ -57,11 +65,33 @@ async function getStory(username, storyId) {
const defaultStory = data.props.pageProps.curatedHighlights[0];
if (defaultStory) {
return {
picker: defaultStory.snapList.map((snap) => ({
type: snap.snapMediaType === 0 ? 'photo' : 'video',
url: snap.snapUrls.mediaUrl,
thumb: snap.snapUrls.mediaPreviewUrl.value
}))
picker: defaultStory.snapList.map(snap => {
const snapType = snap.snapMediaType === 0 ? "photo" : "video";
const snapExt = snapType === "video" ? "mp4" : "jpg";
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) {
let params = obj;
if (obj.hostname === 't.snapchat.com' && obj.shortLink) {
if (obj.shortLink) {
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
if (!link?.startsWith('https://www.snapchat.com/')) {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.short_link" };
}
const extractResult = extract(normalizeURL(link));
if (extractResult?.host !== 'snapchat') {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.short_link" };
}
params = extractResult.patternMatch;
@ -88,9 +118,9 @@ export default async function (obj) {
const result = await getSpotlight(params.spotlightId);
if (result) return result;
} 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;
}
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}

View file

@ -1,5 +1,5 @@
import { env } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
import { cleanString } from "../../misc/utils.js";
const cachedID = {
version: '',
@ -39,7 +39,7 @@ async function findClientID() {
export default async function(obj) {
let clientId = await findClientID();
if (!clientId) return { error: 'ErrorSoundCloudNoClientId' };
if (!clientId) return { error: "fetch.fail" };
let link;
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}` : ''}`
}
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}`)
.then(r => r.status === 200 ? r.json() : false)
.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",
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}`;
if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
if (json.duration > env.durationLimit * 1000)
return { error: ['ErrorLengthAudioConvert', env.durationLimit / 60] };
if (json.duration > env.durationLimit * 1000) {
return { error: "content.too_long" };
}
let file = await fetch(fileUrl)
.then(async r => (await r.json()).url)
.catch(() => {});
if (!file) return { error: 'ErrorCouldntFetch' };
if (!file) return { error: "fetch.empty" };
let fileMetadata = {
title: cleanString(json.title.trim()),

View file

@ -3,9 +3,9 @@ export default async function(obj) {
.then(r => r.status === 200 ? r.json() : false)
.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)) {
best = video.files.mp4;
}
@ -18,5 +18,5 @@ export default async function(obj) {
title: video.title
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.fail" }
}

View file

@ -1,7 +1,9 @@
import Cookie from "../cookie/cookie.js";
import { extract } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { updateCookie } from "../cookie/manager.js";
import { extract } from "../url.js";
import Cookie from "../cookie/cookie.js";
import { createStream } from "../../stream/manage.js";
const shortDomain = "https://vt.tiktok.com/";
@ -17,7 +19,7 @@ export default async function(obj) {
}
}).then(r => r.text()).catch(() => {});
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html) return { error: "fetch.fail" };
if (html.startsWith('<a href="https://')) {
const extractedURL = html.split('<a href="')[1].split('?')[0];
@ -25,7 +27,7 @@ export default async function(obj) {
postId = patternMatch.postId
}
}
if (!postId) return { error: 'ErrorCantGetID' };
if (!postId) return { error: "fetch.short_link" };
// should always be /video/, even for photos
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
@ -46,12 +48,12 @@ export default async function(obj) {
const data = JSON.parse(json)
detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
} catch {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}
let video, videoFilename, audioFilename, audio, images,
filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
bestAudio = 'm4a';
bestAudio; // will get defaulted to m4a later on in match-action
images = detail.imagePost?.images;
@ -96,7 +98,19 @@ export default async function(obj) {
if (images) {
let imageLinks = images
.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 {
picker: imageLinks,

View file

@ -22,7 +22,7 @@ export default async function(input) {
let { subdomain } = psl.parse(input.url.hostname);
if (subdomain?.includes('.')) {
return { error: ['ErrorBrokenLink', 'tumblr'] }
return { error: "link.unsupported" };
} else if (subdomain === 'www' || subdomain === 'at') {
subdomain = undefined
}
@ -31,7 +31,7 @@ export default async function(input) {
const data = await request(domain, input.id);
const element = data?.response?.timeline?.elements?.[0];
if (!element) return { error: 'ErrorEmptyDownload' };
if (!element) return { error: "fetch.empty" };
const contents = [
...element.content,
@ -53,7 +53,8 @@ export default async function(input) {
title: fileMetadata.title,
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" }
}

View file

@ -1,11 +1,11 @@
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 clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
export default async function (obj) {
let req_metadata = await fetch(gqlURL, {
const req_metadata = await fetch(gqlURL, {
method: "POST",
headers: clientIdHead,
body: JSON.stringify({
@ -30,16 +30,19 @@ export default async function (obj) {
}`
})
}).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)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster)
return { error: 'ErrorEmptyDownload' };
const clipMetadata = req_metadata.data.clip;
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",
headers: clientIdHead,
body: JSON.stringify([
@ -58,13 +61,13 @@ export default async function (obj) {
])
}).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;
let format = formats.find(f => f.quality === obj.quality) || formats[0];
const formats = clipMetadata.videoQualities;
const format = formats.find(f => f.quality === obj.quality) || formats[0];
return {
type: "bridge",
type: "proxy",
urls: `${format.sourceURL}?${new URLSearchParams({
sig: req_token[0].data.clip.playbackAccessToken.signature,
token: req_token[0].data.clip.playbackAccessToken.value

View file

@ -101,11 +101,11 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
return result
}
export default async function({ id, index, toGif, dispatcher }) {
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
const cookie = await getCookie('twitter');
let guestToken = await getGuestToken(dispatcher);
if (!guestToken) return { error: 'ErrorCouldntFetch' };
if (!guestToken) return { error: "fetch.fail" };
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;
if (!tweetTypename) {
return { error: "fetch.empty" }
}
if (tweetTypename === "TweetUnavailable") {
const reason = tweet?.data?.tweetResult?.result?.reason;
switch(reason) {
case "Protected":
return { error: 'ErrorTweetProtected' }
return { error: "content.post.private" }
case "NsfwLoggedOut":
if (cookie) {
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
tweet = await tweet.json();
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
} else return { error: 'ErrorTweetNSFW' }
} else return { error: "content.post.age" }
}
}
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
return { error: 'ErrorTweetUnavailable' }
return { error: "content.post.unavailable" }
}
let tweetResult = tweet.data.tweetResult.result,
@ -153,56 +157,77 @@ export default async function({ id, index, toGif, dispatcher }) {
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) {
case undefined:
case 0:
return { error: 'ErrorNoVideosInTweet' };
return {
error: "fetch.empty"
}
case 1:
if (media[0].type === "photo") {
return {
type: "normal",
type: "proxy",
isPhoto: true,
filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
urls: `${media[0].media_url_https}?name=4096x4096`
}
}
return {
type: needsFixing(media[0]) ? "remux" : "normal",
type: needsFixing(media[0]) ? "remux" : "proxy",
urls: bestQuality(media[0].video_info.variants),
filename: `twitter_${id}.mp4`,
audioFilename: `twitter_${id}_audio`,
isGif: media[0].type === "animated_gif"
}
default:
const proxyThumb = (url, i) =>
proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
const picker = media.map((content, i) => {
if (content.type === "photo") {
let url = `${content.media_url_https}?name=4096x4096`;
let proxiedImage = proxyThumb(url, i);
if (alwaysProxy) url = proxiedImage;
return {
type: "photo",
url,
thumb: url,
thumb: proxiedImage,
}
}
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";
if (shouldRenderGif) type = "gif";
if (needsFixing(content) || shouldRenderGif) {
url = createStream({
service: 'twitter',
type: shouldRenderGif ? 'gif' : 'remux',
service: "twitter",
type: shouldRenderGif ? "gif" : "remux",
u: url,
filename: `twitter_${id}_${i + 1}.mp4`
filename: videoFilename,
})
} else if (alwaysProxy) {
url = proxyMedia(url, videoFilename);
}
return {
type,
url,
thumb: content.media_url_https
thumb: proxyThumb(content.media_url_https, i),
}
});
return { picker };

View file

@ -1,8 +1,8 @@
import { env } from "../../config.js";
import { cleanString, merge } from '../../sub/utils.js';
import HLS from "hls-parser";
import { env } from "../../config.js";
import { cleanString, merge } from '../../misc/utils.js';
const resolutionMatch = {
"3840": 2160,
"2732": 1440,
@ -63,7 +63,8 @@ const getDirectLink = (data, quality) => {
resolution: `${match.width}x${match.height}`,
qualityLabel: match.rendition,
extension: "mp4"
}
},
bestAudio: "mp3",
}
}
@ -73,25 +74,25 @@ const getHLS = async (configURL, obj) => {
const api = await fetch(configURL)
.then(r => r.json())
.catch(() => {});
if (!api) return { error: 'ErrorCouldntFetch' };
if (!api) return { error: "fetch.fail" };
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;
if (!urlMasterHLS) return { error: 'ErrorCouldntFetch' }
if (!urlMasterHLS) return { error: "fetch.fail" };
const masterHLS = await fetch(urlMasterHLS)
.then(r => r.text())
.catch(() => {});
if (!masterHLS) return { error: 'ErrorCouldntFetch' };
if (!masterHLS) return { error: "fetch.fail" };
const variants = HLS.parse(masterHLS)?.variants?.sort(
(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;
@ -116,7 +117,7 @@ const getHLS = async (configURL, obj) => {
expandLink(audioPath)
]
} else if (obj.isAudioOnly) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
return {
@ -126,7 +127,8 @@ const getHLS = async (configURL, obj) => {
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
extension: "mp4"
}
},
bestAudio: "mp3",
}
}
@ -143,7 +145,7 @@ export default async function(obj) {
}
if (!response) response = getDirectLink(info, quality);
if (!response) response = { error: 'ErrorEmptyDownload' };
if (!response) response = { error: "fetch.empty" };
if (response.error) {
return response;

View file

@ -3,7 +3,7 @@ export default async function(obj) {
.then(r => r.json())
.catch(() => {});
if (!post) return { error: 'ErrorEmptyDownload' };
if (!post) return { error: "fetch.empty" };
if (post.videoUrl) return {
urls: post.videoUrl.replace("http://", "https://"),
@ -11,5 +11,5 @@ export default async function(obj) {
audioFilename: `vine_${obj.id}_audio`
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}

View file

@ -1,5 +1,5 @@
import { cleanString } from "../../misc/utils.js";
import { genericUserAgent, env } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
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;
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
headers: { "user-agent": genericUserAgent }
}).then(r => r.arrayBuffer()).catch(() => {});
headers: {
"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
let decoder = new TextDecoder('windows-1251');
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]);
if (Number(js.mvData.is_active_live) !== 0) return { error: 'ErrorLiveVideo' };
if (js.mvData.duration > env.durationLimit)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
if (Number(js.mvData.is_active_live) !== 0) {
return { error: "content.video.live" };
}
if (js.mvData.duration > env.durationLimit) {
return { error: "content.too_long" };
}
for (let i in resolutions) {
if (js.player.params[0][`url${resolutions[i]}`]) {
@ -51,5 +59,5 @@ export default async function(o) {
extension: "mp4"
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}

View file

@ -3,7 +3,7 @@ import { fetch } from "undici";
import { Innertube, Session } from "youtubei.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";
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
@ -110,7 +110,9 @@ export default async function(o) {
);
} catch(e) {
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;
}
@ -130,44 +132,60 @@ export default async function(o) {
try {
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
} catch(e) {
if (e?.message === 'This video is unavailable') {
return { error: 'ErrorCouldntFetch' };
if (e?.info?.reason === "This video is private") {
return { error: "content.video.private" };
} else if (e?.message === "This video is unavailable") {
return { error: "content.video.unavailable" };
} 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 basicInfo = info.basic_info;
if (playability.status === 'LOGIN_REQUIRED') {
if (playability.reason.endsWith('bot')) {
return { error: 'ErrorYTLogin' }
if (playability.status === "LOGIN_REQUIRED") {
if (playability.reason.endsWith("bot")) {
return { error: "youtube.login" }
}
if (playability.reason.endsWith('age')) {
return { error: 'ErrorYTAgeRestrict' }
if (playability.reason.endsWith("age")) {
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 (basicInfo.is_live) return { error: 'ErrorLiveVideo' };
if (playability.status === "UNPLAYABLE") {
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"
// or a similar stub by youtube
if (basicInfo.id !== o.id) {
return {
error: 'ErrorCantConnectToServiceAPI',
error: "fetch.fail",
critical: true
}
}
let bestQuality, hasAudio;
const filterByCodec = (formats) =>
formats
.filter(e =>
@ -183,16 +201,18 @@ export default async function(o) {
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
}
bestQuality = adaptive_formats.find(i => i.has_video && i.content_length);
hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
let bestQuality;
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)
return { error: 'ErrorYTTryOtherCodec' };
return { error: "youtube.codec" };
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);
@ -207,7 +227,7 @@ export default async function(o) {
&& i.audio_track
)
if (dubbedAudio) {
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
audio = dubbedAudio;
isDubbed = true;
}
@ -240,7 +260,7 @@ export default async function(o) {
}
if (audio && o.isAudioOnly) return {
type: "render",
type: "audio",
isAudioOnly: true,
urls: audio.decipher(yt.session.player),
filenameAttributes: filenameAttributes,
@ -256,9 +276,10 @@ export default async function(o) {
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);
type = "bridge";
type = "proxy";
urls = match?.decipher(yt.session.player);
}
@ -266,7 +287,7 @@ export default async function(o) {
if (!match && video && audio) {
match = video;
type = "render";
type = "merge";
urls = [
video.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" }
}

View file

@ -1,6 +1,9 @@
import { services } from "../config.js";
import { strict as assert } from "node:assert";
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) {
assert(url instanceof URL);
@ -54,6 +57,7 @@ function aliasURL(url) {
url = new URL(`https://bilibili.com/_tv${url.pathname}`);
}
break;
case "b23":
if (url.hostname === 'b23.tv' && parts.length === 2) {
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
@ -64,6 +68,7 @@ function aliasURL(url) {
if (url.hostname === 'dai.ly' && parts.length === 2) {
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
}
break;
case "facebook":
case "fb":
@ -159,8 +164,12 @@ export function extract(url) {
const host = getHostIfValid(url);
if (!host || !services[host].enabled) {
return null;
if (!host) {
return { error: "link.invalid" };
}
if (!env.enabledServices.has(host)) {
return { error: "service.disabled" };
}
let patternMatch;
@ -175,8 +184,13 @@ export function extract(url) {
}
if (!patternMatch) {
return null;
return {
error: "link.unsupported",
context: {
service: friendlyServiceName(host),
}
};
}
return { host, patternMatch };
}
}

59
api/src/security/jwt.js Normal file
View 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,
}

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

View file

@ -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) {
try {

View file

@ -1,7 +1,7 @@
import { request } from 'undici';
import { Readable } from 'node:stream';
import { closeRequest, getHeaders, pipe } from './shared.js';
import { handleHlsPlaylist, isHlsRequest } from './internal-hls.js';
import { request } from "undici";
import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js";
import { handleHlsPlaylist, isHlsRequest } from "./internal-hls.js";
const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b;
@ -28,7 +28,7 @@ async function* readChunks(streamInfo, size) {
if (received < expected / 2n) {
closeRequest(streamInfo.controller);
}
for await (const data of chunk.body) {
yield data;
}
@ -64,7 +64,7 @@ async function handleYoutubeStream(streamInfo, res) {
}
signal.addEventListener('abort', abortGenerator);
const stream = Readable.from(generator);
for (const headerName of ['content-type', 'content-length']) {
@ -119,4 +119,4 @@ export function internalStream(streamInfo, res) {
}
return handleGenericStream(streamInfo, res);
}
}

View file

@ -1,12 +1,13 @@
import NodeCache from "node-cache";
import { randomBytes } from "crypto";
import { nanoid } from "nanoid";
import { randomBytes } from "crypto";
import { strict as assert } from "assert";
import { setMaxListeners } from "node:events";
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
import { env } from "../config.js";
import { strict as assert } from "assert";
import { closeRequest } from "./shared.js";
import { decryptStream, encryptStream, generateHmac } from "../misc/crypto.js";
// optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
@ -21,7 +22,7 @@ streamCache.on("expired", (key) => {
streamCache.del(key);
})
const internalStreamCache = {};
const internalStreamCache = new Map();
const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) {
@ -36,13 +37,15 @@ export function createStream(obj) {
urls: obj.u,
service: obj.service,
filename: obj.filename,
audioFormat: obj.audioFormat,
isAudioOnly: !!obj.isAudioOnly,
requestIP: obj.requestIP,
headers: obj.headers,
copy: !!obj.copy,
mute: !!obj.mute,
metadata: obj.fileMetadata || false,
requestIP: obj.requestIP
audioBitrate: obj.audioBitrate,
audioCopy: !!obj.audioCopy,
audioFormat: obj.audioFormat,
};
streamCache.set(
@ -50,7 +53,7 @@ export function createStream(obj) {
encryptStream(streamData, iv, secret)
)
let streamLink = new URL('/api/stream', env.apiURL);
let streamLink = new URL('/tunnel', env.apiURL);
const params = {
'id': streamID,
@ -68,7 +71,7 @@ export function createStream(obj) {
}
export function getInternalStream(id) {
return internalStreamCache[id];
return internalStreamCache.get(id);
}
export function createInternalStream(url, obj = {}) {
@ -83,7 +86,7 @@ export function createInternalStream(url, obj = {}) {
let controller = obj.controller;
if (!controller) {
controller = new AbortController();
controller = new AbortController();
setMaxListeners(Infinity, controller.signal);
}
@ -92,15 +95,15 @@ export function createInternalStream(url, obj = {}) {
headers = new Map(Object.entries(obj.headers));
}
internalStreamCache[streamID] = {
internalStreamCache.set(streamID, {
url,
service: obj.service,
headers,
controller,
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);
const cleanup = () => {
@ -121,9 +124,9 @@ export function destroyInternalStream(url) {
const id = url.searchParams.get('id');
if (internalStreamCache[id]) {
closeRequest(internalStreamCache[id].controller);
delete internalStreamCache[id];
if (internalStreamCache.has(id)) {
closeRequest(getInternalStream(id)?.controller);
internalStreamCache.delete(id);
}
}

View file

@ -42,4 +42,4 @@ export function pipe(from, to, done) {
.on('close', done);
from.pipe(to);
}
}

33
api/src/stream/stream.js Normal file
View 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);
}
}

View file

@ -3,35 +3,42 @@ import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
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 { env, ffmpegArgs, hlsExceptions } from "../config.js";
import { hlsExceptions } from "../processing/service-config.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)
.map(([key, value]) => `${key}: ${value}\r\n`)
.join('');
}
function killProcess(p) {
// ask the process to terminate itself gracefully
p?.kill('SIGTERM');
const killProcess = (p) => {
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
setTimeout(() => {
if (p?.exitCode === null)
// brutally murder the process if it didn't quit
p?.kill('SIGKILL');
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
}, 5000);
}
function getCommand(args) {
const getCommand = (args) => {
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
}
return [ffmpeg, args]
}
export async function streamDefault(streamInfo, res) {
const proxy = async (streamInfo, res) => {
const abortController = new AbortController();
const shutdown = () => (
closeRequest(abortController),
@ -40,19 +47,21 @@ export async function streamDefault(streamInfo, res) {
);
try {
let filename = streamInfo.filename;
if (streamInfo.isAudioOnly) {
filename = `${streamInfo.filename}.${streamInfo.audioFormat}`
}
res.setHeader('Content-disposition', contentDisposition(filename));
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
const { body: stream, headers } = await request(streamInfo.urls, {
headers: getHeaders(streamInfo.service),
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
headers: {
...getHeaders(streamInfo.service),
Range: streamInfo.range
},
signal: abortController.signal,
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]) {
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;
const shutdown = () => (
killProcess(process),
@ -124,61 +133,7 @@ export function streamLiveRender(streamInfo, res) {
}
}
export function streamAudioOnly(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) {
const remux = (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
@ -198,15 +153,18 @@ export function streamVideoOnly(streamInfo, res) {
args.push(
'-i', streamInfo.urls,
'-c', 'copy'
'-c:v', 'copy',
)
if (streamInfo.mute) {
args.push('-an')
if (streamInfo.type === "mute") {
args.push('-an');
}
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];
@ -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;
const shutdown = () => (killProcess(process), closeResponse(res));
@ -252,7 +277,7 @@ export function convertToGif(streamInfo, res) {
}
args.push('-i', streamInfo.urls);
args = args.concat(ffmpegArgs["gif"]);
args = args.concat(ffmpegArgs.gif);
args.push('-f', "gif", 'pipe:3');
process = spawn(...getCommand(args), {
@ -276,3 +301,11 @@ export function convertToGif(streamInfo, res) {
shutdown();
}
}
export default {
proxy,
merge,
remux,
convertAudio,
convertGif,
}

View file

@ -1,5 +1,5 @@
import { Innertube } from 'youtubei.js';
import { Red } from '../modules/sub/consoleText.js'
import { Red } from '../misc/console-text.js'
const bail = (...msg) => {
console.error(...msg);

View file

@ -1,7 +1,7 @@
import { existsSync, unlinkSync, appendFileSync } from "fs";
import { createInterface } from "readline";
import { Cyan, Bright } from "./sub/consoleText.js";
import { loadJSON } from "./sub/loadFromFs.js";
import { Cyan, Bright } from "./misc/console-text.js";
import { loadJSON } from "./misc/load-from-fs.js";
import { execSync } from "child_process";
const { version } = loadJSON("./package.json");

View file

@ -1,20 +1,21 @@
import { env } from "../modules/config.js";
import { runTest } from "../modules/test.js";
import { loadLoc } from "../localization/manager.js";
import { loadJSON } from "../modules/sub/loadFromFs.js";
import { Red, Bright } from "../modules/sub/consoleText.js";
import { env } from "../config.js";
import { runTest } from "../misc/run-test.js";
import { loadJSON } from "../misc/load-from-fs.js";
import { Red, Bright } from "../misc/console-text.js";
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { services } from "../processing/service-config.js";
const tests = loadJSON('./src/util/tests.json');
const services = loadJSON('./src/modules/processing/servicesConfig.json');
// services that are known to frequently fail due to external
// factors (e.g. rate limiting)
const finnicky = new Set(['bilibili', 'instagram', 'youtube'])
const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube'])
const action = process.argv[2];
switch (action) {
case "get-services":
const fromConfig = Object.keys(services.config);
const fromConfig = Object.keys(services);
const missingTests = fromConfig.filter(
service => !tests[service] || tests[service].length === 0
@ -38,9 +39,9 @@ switch (action) {
console.error('no such service:', service);
}
await loadLoc();
env.streamLifespan = 10000;
env.apiURL = 'http://x';
randomizeCiphers();
for (const test of tests[service]) {
const { name, url, params, expected } = test;
@ -78,4 +79,4 @@ switch (action) {
default:
console.error('invalid action:', action);
process.exitCode = 1;
}
}

View file

@ -1,12 +1,11 @@
import "dotenv/config";
import "../modules/sub/alias-envs.js";
import { services } from "../modules/config.js";
import { extract } from "../modules/processing/url.js";
import match from "../modules/processing/match.js";
import { loadJSON } from "../modules/sub/loadFromFs.js";
import { normalizeRequest } from "../modules/processing/request.js";
import { env } from "../modules/config.js";
import { services } from "../processing/service-config.js";
import { extract } from "../processing/url.js";
import match from "../processing/match.js";
import { loadJSON } from "../misc/load-from-fs.js";
import { normalizeRequest } from "../processing/request.js";
import { env } from "../config.js";
env.apiURL = 'http://localhost:9000'
let tests = loadJSON('./src/util/tests.json');
@ -29,20 +28,26 @@ for (let i in services) {
console.log(`\nRunning tests for ${i}...\n`)
for (let k = 0; k < tests[i].length; k++) {
let test = tests[i][k];
console.log(`Running test ${k+1}: ${test.name}`);
console.log('params:');
let params = {...{url: test.url}, ...test.params};
console.log(params);
let chck = normalizeRequest(params);
if (chck) {
let chck = await normalizeRequest(params);
if (chck.success) {
chck = chck.data;
const parsed = extract(chck.url);
if (parsed === null) {
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(j)
if (j.status === test.expected.code && j.body.status === test.expected.status) {

1461
api/src/util/tests.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,77 +1,110 @@
# 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
> it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
<!-- TODO: authorization -->
## POST: `/`
cobalt's main processing endpoint.
request body type: `application/json`
response body type: `application/json`
```
👍 you can use api.cobalt.tools in your projects for free, just don't be an asshole.
```
## POST: `/api/json`
cobalt's main processing endpoint.
request 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
Content-Type: application/json
```
### request body variables
| key | type | variables | default | description |
|:------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
| `url` | `string` | URL encoded as URI | `null` | **must** be included in every request. |
| `vCodec` | `string` | `h264 / av1 / vp9` | `h264` | applies only to youtube downloads. `h264` is recommended for phones. |
| `vQuality` | `string` | `144 / ... / 2160 / max` | `720` | `720` quality is recommended for phones. |
| `aFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
| `filenamePattern` | `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` | |
| `isTTFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
| `isAudioMuted` | `boolean` | `true / false` | `false` | disables audio track in video downloads. |
| `dubLang` | `boolean` | `true / false` | `false` | backend uses Accept-Language header for youtube video audio tracks when `true`. |
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
| `twitterGif` | `boolean` | `true / false` | `false` | changes whether twitter gifs are converted to .gif |
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
### request body
| key | type | expected value(s) | default | description |
|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
| `url` | `string` | URL to download | -- | **must** be included in every request. |
| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
| `audioFormat` | `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. |
| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. |
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
| `youtubeDubLang` | `string` | `en / ru / cs / ja / ...` | -- | specifies the language of audio to download, when the youtube video is dubbed |
| `youtubeDubBrowserLang` | `boolean` | `true / false` | `false` | uses value from the Accept-Language header for `youtubeDubLang`. |
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
| `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
| key | type | variables |
### response
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` |
| `text` | `string` | various text, mostly used for errors |
| `url` | `string` | direct link to a file or a link to cobalt's live render |
| `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 |
| `status` | `string` | `tunnel / redirect` |
| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
| `filename` | `string` | cobalt-generated filename for the file being downloaded |
### picker item variables
item type: `object`
### picker response
| 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 |
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
| `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 | |
| `thumb` | `string` | item thumbnail that's displayed in the picker | used for `video` and `gif` types |
#### picker object
| key | type | values |
|:-------------|:----------|:------------------------------------------------------------|
| `type` | `string` | `photo` / `video` / `gif` |
| `url` | `string` | |
| `thumb` | `string` | **optional** thumbnail url |
## GET: `/api/stream`
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
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.
### error response
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `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
[read the source code](/src/modules/stream/manage.js).
#### error object
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `code` | `string` | machine-readable error code explaining the failure reason |
| `context` | `object` | **optional** container for providing more context |
## GET: `/api/serverInfo`
returns current basic server info.
#### 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.
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 |
|:------------|:---------|:------------------|
| `version` | `string` | cobalt version |
| `commit` | `string` | git commit |
| `commit` | `string` | commit hash |
| `branch` | `string` | git branch |
| `name` | `string` | server name |
| `url` | `string` | server url |
| `cors` | `number` | cors status |
| `startTime` | `string` | server start time |
| `remote` | `string` | git remote |

View file

@ -1,5 +1,3 @@
version: '3.5'
services:
cobalt-api:
image: ghcr.io/imputnet/cobalt:7
@ -31,30 +29,6 @@ services:
#volumes:
#- ./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
watchtower:
image: ghcr.io/containrrr/watchtower

View file

@ -6,20 +6,20 @@ if you need help with installing docker, follow *only the first step* of these t
- [how to install docker](https://www.digitalocean.com/community/tutorial-collections/how-to-install-and-use-docker)
- [how to install docker compose](https://www.digitalocean.com/community/tutorial-collections/how-to-install-docker-compose)
## how to run a cobalt docker package:
1. create a folder for cobalt config file, something like this:
## how to run a cobalt docker package:
1. create a folder for cobalt config file, something like this:
```sh
mkdir cobalt
```
```
2. go to cobalt folder, and create a docker compose config file:
2. go to cobalt folder, and create a docker compose config file:
```sh
cd cobalt && nano docker-compose.yml
```
i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.
```
i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.
3. copy and paste the [sample config from here](examples/docker-compose.example.yml) for either web or api instance (or both, if you wish) and edit it to your needs.
make sure to replace default URLs with your own or cobalt won't work correctly.
3. copy and paste the [sample config from here](examples/docker-compose.example.yml) for either web or api instance (or both, if you wish) and edit it to your needs.
make sure to replace default URLs with your own or cobalt won't work correctly.
4. finally, start the cobalt container (from cobalt directory):
```sh
@ -72,16 +72,4 @@ sudo service nscd start
setting a `FREEBIND_CIDR` allows cobalt to pick a random IP for every download and use it for all
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
`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.
`network_mode` for the container to `host`.

1142
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,48 +1,7 @@
{
"name": "cobalt",
"description": "save what you love",
"version": "7.15",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
"packageManager": "pnpm@9.6.0",
"engines": {
"node": ">=18"
},
"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"
"pnpm": ">=9"
}
}
}

View file

@ -0,0 +1,2 @@
# Ignore artifacts:
dist

View file

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid"
}

View 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.

View 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"
}
}

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

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

View 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

File diff suppressed because it is too large Load diff

4
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,4 @@
packages:
- "api"
- "web"
- "packages/*"

View file

@ -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`)
)
}

View file

@ -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
}
}]
}

View file

@ -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`
)
})
}

View file

@ -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`
)
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

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