Compare commits

...

78 commits

Author SHA1 Message Date
71582971e6
False package name
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-11 14:19:19 +02:00
435bf7d5c1
False branch
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-04-11 14:14:24 +02:00
957971d6a5
Start ci pipeline 2023-04-11 14:13:20 +02:00
bec5daa377
Add ci pipeline 2023-04-11 14:10:47 +02:00
wukko
b4eddd06fe there's a better way to do it oops 2023-04-10 18:32:14 +06:00
wukko
b0e6dc7528 fixed a lil oversight in youtube module 2023-04-10 18:28:41 +06:00
wukko
e4eac5bebe
add cors option to setup script
Merge pull request #113 from jojobii-arks/jojobii-arks/112
2023-04-09 18:05:24 +06:00
wukko
ace49e87df
better wording 2023-04-09 18:04:03 +06:00
wukko
3004daecc9 add "powered by" text when running on hop 2023-04-09 11:58:29 +06:00
wukko
80de6a1ebe i don't get it 2023-04-09 11:38:37 +06:00
wukko
81e634b091 good lord 2023-04-09 11:18:18 +06:00
wukko
5696d4091d OH FUCK 2023-04-09 11:12:11 +06:00
wukko
e9e0fa82c7 testing git info retrieval in docker 2023-04-09 11:11:06 +06:00
wukko
f8d42cfe36 Update Dockerfile 2023-04-09 11:09:05 +06:00
wukko
7dc8ab252d change the way user ip address is retrieved 2023-04-09 10:58:23 +06:00
wukko
b645abc34a something's up with git 2023-04-09 10:49:03 +06:00
jojobii
314c5fbfcc
feat(setup): add cors option to setup script 2023-04-08 21:47:17 -07:00
wukko
b8330fc855 minor clean up 2023-04-09 10:42:18 +06:00
wukko
450d01753d update the way environment variables are checked
no longer checking for .env file, just the variables
2023-04-09 09:40:13 +06:00
wukko
7e01f0b496 Update Dockerfile 2023-04-09 09:32:15 +06:00
wukko
2110432a80 Update Dockerfile 2023-04-09 09:25:59 +06:00
wukko
dbc6bfef6e trying to get .git inside the container on hop 2023-04-09 09:23:16 +06:00
wukko
db461fdd9f nope 2023-04-09 08:58:25 +06:00
wukko
00a88b9349 added git clone to dockerfile to (hopefully) fix the issue with git 2023-04-09 08:53:53 +06:00
wukko
8512a47181 add docker info to readme 2023-04-09 08:49:51 +06:00
wukko
2877f29ba1
add dockerfile
merge pull request #85 from alyx/current
2023-04-09 08:49:19 +06:00
wukko
a3e4c8dcfd added a status check endpoint 2023-04-09 08:40:07 +06:00
wukko
68703ae300 5.3.2: link sharing and nanoid
- you can now share video links directly from cobalt!
- cobalt is now using nanoid for stream ids instead of giant sha256 hashes
- one more fix to address the copy animation, this time on pc
2023-04-08 22:55:44 +06:00
wukko
307da3dce4 5.3.1: tiny fixes
- fixed backdrop on press for "copy url" button in mobile "how to save?" popup
- added ability to disable cors via env file
2023-04-08 16:58:44 +06:00
wukko
cf6dcfe7a6 oops 2023-04-03 22:43:41 +06:00
wukko
a70e762350 updated changelog with one more fix 2023-04-03 22:42:46 +06:00
wukko
742ce7c86e 5.3: accessibility and css polish 2023-04-03 22:36:23 +06:00
wukko
94cdc80bd9 one more fix to prevent future padding breakage 2023-04-03 17:40:00 +06:00
wukko
75826d79c8 fix input area padding in chromium 112+ 2023-04-03 17:35:02 +06:00
wukko
35e89f30a4 revert april fools 2023-04-02 21:53:23 +06:00
wukko
8a214cf2a7 fuck 2023-04-01 15:57:41 +06:00
wukko
e263a83dd9 another super important update 2023-04-01 15:51:37 +06:00
wukko
7b56f33e7a very major and important update 2023-04-01 15:46:05 +06:00
wukko
49e85efe23 catch connection resets in streamables and other error handling improvements 2023-03-31 11:20:49 +06:00
wukko
19087944f7 5.2.2: accessibility improvements
- moved clipboard button to right, added left-handed layout toggle for those who prefer to have it on left.
- removed button hover highlights on phones.
- added proper checkbox icon for better clarity.
- checkboxes are now stretched edge-to-edge on phone to be easier to manage for right-handed people.
2023-03-29 22:08:41 +06:00
wukko
6e01026894
enable cors
merge pull request #100 from jojobii-arks/jojobii-arks/98-fix-cors
2023-03-29 18:25:46 +06:00
josephangelobravo@gmail.com
87e684543e change: enable CORS preflight on /api/:type 2023-03-28 15:05:06 -07:00
wukko
adba73d60c disabled emoji image selection on ios 2023-03-26 10:24:02 +06:00
wukko
3264cab247 fixed text selection on ios 2023-03-26 10:19:33 +06:00
wukko
097b145114 5.2 2023-03-25 01:31:53 +06:00
wukko
1890b44ea3 fix rate limiting 2023-03-25 00:14:44 +06:00
wukko
324d58991f cleaned up readme 2023-03-24 23:41:58 +06:00
wukko
7f533b3485 retweet links, new clipboard icon, mobile layout and loc improvements
- added support for retweet links
- updated spaces endpoint
- bumped up the user agent version
- new clipboard icon
- new clipboard + auto mode layout on mobile, less wasted space
- fixed button press animations for safari on mobile
- tons of localization improvements for english and russian
- bumped up youtubei.js to 4.1.0
2023-03-24 23:16:10 +06:00
wukko
6e9f9efa28 vimeo support revamp and bug fixes
- completely reworked vimeo module.
- added support for audio downloads from vimeo.
- added support for chop type of dash for vimeo.
- added ability to choose between progressive and dash vimeo downloads. both to api and settings on frontend.
- added support for single m3u8 playlists. will be useful for future additions and is currently used for vimeo.
- proper error is now shown if there are no matching vimeo videos found
- temporarily disabled douyin support because bytedance killed off old endpoint.
- fixed the issue related to periods in tiktok usernames. (closes #96)
- fixed error text value patching in match module.
- fixed video stream removal for audio only option, wouldn't work in some edge cases.
- minor clean up.
2023-03-15 22:18:31 +06:00
wukko
f6ee934949 oops 2023-03-10 00:45:07 +06:00
wukko
a3daa65148 5.2
- page render caching
- onDemand block caching
- page html minify
- better rate limiting
- minor cobalt.js clean up
- page render platform indication in settings popup

all these changes are aimed to improve performance and responsiveness

!! not final version of 5.2 !!
2023-03-10 00:41:17 +06:00
wukko
daa2856d92 5.1.2
- added women's day celebration emoji
- fixed vk user id extraction
2023-03-08 13:17:33 +06:00
wukko
2884bd9081 5.1.1
- bigger video/audio duration limit (3 hours instead of 2 hours and 5 minutes).
- no more unexpected errors when downloading audio from youtube.
2023-03-01 08:37:26 +06:00
wukko
d196510b80
updated variables for vquality in api docs 2023-02-26 23:50:57 +06:00
wukko
9645472a53 added more closed issues to changelog 2023-02-26 22:51:22 +06:00
wukko
6465ac8d6f 5.1
closes #62, #66, #75
2023-02-26 22:49:25 +06:00
alyx
e2db3b8fb7 add dockerfile 2023-02-20 13:12:26 -06:00
wukko
9b17300492
Create FUNDING.yml 2023-02-16 16:04:22 +06:00
wukko
a82db92991
removed deprecation note from api docs 2023-02-14 14:44:45 +06:00
wukko
dbf870da87 forgot to bump up the version oopsie 2023-02-13 22:02:38 +06:00
wukko
c57d16b615 added 5.0 changelog 2023-02-13 21:56:52 +06:00
wukko
7911a35eb6
merge 5.0 from wukko/develop
stable 5.0
2023-02-13 20:44:27 +06:00
wukko
3617382bb0 more clean up 2023-02-13 20:42:16 +06:00
wukko
ff9f2c5cce deepsource config update 2023-02-13 20:39:09 +06:00
wukko
73b5da8df0 Update .deepsource.toml 2023-02-13 20:34:12 +06:00
wukko
a1fea0a5f1 fixes 2023-02-13 20:30:57 +06:00
wukko
20ae9acfe8 5.0 2023-02-13 20:23:48 +06:00
wukko
3c578d6d49 cleaning up what i missed 2023-02-13 20:02:52 +06:00
wukko
18199c534f changed the socialLink element class
some adblocking filters block .social-link class, and this is not an ad, at all :/
2023-02-13 19:49:18 +06:00
wukko
75a85972aa 5.0
- finished writing tests for all services
- fixed douyin support
- fixed tiktok picker that was broken by previous commit
- temporarily removed douyin photos from list of supported services
- fixed support for "user view" vk clip links
- slightly improved the testing script
2023-02-13 19:44:58 +06:00
wukko
0e7a281366 accidentally left error logging 2023-02-12 13:41:28 +06:00
wukko
dacaaf5b27 5.0-dev1
- rewrote and/or optimized all service modules
- rewrote matching and processing modules to optimize readability and performance
- added support for reddit gifs
- fixed various issues with twitter error explanations
- code optimizations and enhancements (such as finally getting rid of ==, prettier and more readable formatting, etc)
- added branch information
- all functions in currentCommit submodule run only once and cache received data
- added a test script. only twitter and soundcloud are 100% covered and tested atm, will add tests (and probably fixes) for the rest of services in next commits
- changed some localization strings for russian
- added more clarity to rate limit message
- moved services folder into processing folder
2023-02-12 13:40:49 +06:00
wukko
3432c91482 refactoring & fixes
- added duration check to vimeo module
- fixed quality picking in vimeo module for progressive video type
- dropping requests from ie users instead of redirecting
- probably something else but i forgot to be honest
2023-02-09 20:45:17 +06:00
wukko
c7a9723847 second attempt of updating the wide banner 2023-01-30 00:55:20 +06:00
wukko
28becbc76b new wide banner 2023-01-30 00:46:18 +06:00
wukko
fb67e2b495 updated readme 2023-01-30 00:39:53 +06:00
wukko
30356b453a extremely stupid typo 2023-01-30 00:28:10 +06:00
wukko
6b87c7babd 4.8: new about popup and other visual improvements 2023-01-30 00:17:33 +06:00
72 changed files with 2757 additions and 1551 deletions

View file

@ -1,5 +1,11 @@
version = 1 version = 1
test_patterns = [
"src/test/test.js"
]
[[analyzers]] [[analyzers]]
name = "javascript" name = "javascript"
enabled = true enabled = true
[analyzers.meta]
environment = ["nodejs"]

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://boosty.to/wukko

6
.gitignore vendored
View file

@ -7,3 +7,9 @@ package-lock.json
# esbuild # esbuild
min min
# page build
build
# stuff i already made but delayed
future

19
.woodpecker.yml Normal file
View file

@ -0,0 +1,19 @@
workspace:
path: static-hoster
base: /build
pipeline:
build:
image: docker:23-cli
privileged: true
commands:
- apk add git
- docker buildx build --no-cache -t dev.cat-enby.club/nikurasu/cobalt:latest -t dev.cat-enby.club/nikurasu/cobalt:$(date "+%Y-%m-%d") -f Dockerfile .
- docker login -u $USER -p $PASSWORD dev.cat-enby.club
- docker push dev.cat-enby.club/nikurasu/cobalt:latest
- docker push dev.cat-enby.club/nikurasu/cobalt:$(date "+%Y-%m-%d")
volumes:
- /var/run/docker.sock:/var/run/docker.sock
secrets: [ user, password ]
branches: current

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:18-bullseye-slim
WORKDIR /app
RUN apt-get update
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install
RUN git clone -n https://github.com/wukko/cobalt.git --depth 1 && mv cobalt/.git ./ && rm -rf cobalt
COPY . .
EXPOSE 9000
CMD [ "node", "src/cobalt" ]

View file

@ -1,56 +1,55 @@
# cobalt # cobalt
Best way to save content you love. Best way to save what you love.
[co.wukko.me](https://co.wukko.me/) Live: [co.wukko.me](https://co.wukko.me/)
![cobalt logo](https://raw.githubusercontent.com/wukko/cobalt/current/src/front/icons/wide.png "cobalt logo") ![cobalt logo with repeated logo pattern background](https://raw.githubusercontent.com/wukko/cobalt/current/src/front/icons/pattern.png "cobalt logo with repeated logo pattern background")
[![Crowdin](https://badges.crowdin.net/cobalt/localized.svg)](https://crowdin.com/project/cobalt) [![DeepSource](https://deepsource.io/gh/wukko/cobalt.svg/?label=active+issues&token=MsmsJ9zUOKwcQor0yaiFot84)](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge) [![DeepSource](https://deepsource.io/gh/wukko/cobalt.svg/?label=resolved+issues&token=MsmsJ9zUOKwcQor0yaiFot84)](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge) [![Crowdin](https://badges.crowdin.net/cobalt/localized.svg)](https://crowdin.com/project/cobalt) [![DeepSource](https://deepsource.io/gh/wukko/cobalt.svg/?label=active+issues&token=MsmsJ9zUOKwcQor0yaiFot84)](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge) [![DeepSource](https://deepsource.io/gh/wukko/cobalt.svg/?label=resolved+issues&token=MsmsJ9zUOKwcQor0yaiFot84)](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge)
## What's cobalt? ## What's cobalt?
cobalt is social media downloader with zero bullshit. It's friendly, accessible, efficient, and doesn't bother you with shock ads or privacy invasion "consent" popups. cobalt is a social and media platform downloader that doesn't piss you off.
It preserves original media quality so you get best downloads possible (unless you change that in settings). It's fast, friendly, and doesn't have any bullshit that modern web is filled with: no ads, trackers, or analytics. Paste the link, get the video, move on. It's that simple. Just how it should be.
## Supported services ## Supported services
| Service | Video + Audio | Only audio | Additional features | | Service | Video + Audio | Only audio | Additional features |
| -------- | :---: | :---: | :----- | | -------- | :---: | :---: | :----- |
| Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. | | Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. |
| Twitter Spaces | ❌️ | ✅ | Audio metadata. | | Twitter Spaces | ❌️ | ✅ | Audio metadata. |
| YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. | | YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. Audio metadata & dubs. h264/av1/vp9 codecs. |
| YouTube Music | ❌ | ✅ | Audio metadata. | | YouTube Music | ❌ | ✅ | Audio metadata. |
| Reddit | ✅ | ✅ | | | Reddit | ✅ | ✅ | GIFs and videos. |
| TikTok & douyin | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermarks. | | TikTok | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermark. Full audio downloads. |
| SoundCloud | ❌ | ✅ | Audio metadata, downloads from private links | | SoundCloud | ❌ | ✅ | Audio metadata, downloads from private links. |
| bilibili.com | ✅ | ✅ | | | bilibili.com | ✅ | ✅ | |
| Tumblr | ✅ | ✅ | | | Tumblr | ✅ | ✅ | |
| Vimeo | ✅ | ❌️ | | | Vimeo | ✅ | ❌️ | |
| VK Videos & Clips | ✅ | ❌️ | | | VK Videos & Clips | ✅ | ❌️ | |
## cobalt API ## cobalt API
cobalt has an open API that you can use for free. It's pretty straightforward in use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/API.md) and see for yourself. cobalt has an open API that you can use for free. It's easy and straightforward to use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/API.md) and see for yourself.
## How to contribute translations ## How to contribute translations
You can translate cobalt to any language you want on [cobalt's crowdin](https://crowdin-co.wukko.me/). Feel free to ignore QA errors if you think you know better. If you don't see a language you want to translate cobalt to, open an issue, and I'll add it to crowdin. You can translate cobalt to any language you want on [cobalt's Crowdin](https://crowdin-co.wukko.me/). Feel free to ignore QA errors if you think you know better. If you don't see a language you want to translate cobalt to, open an issue, and I'll add it to Crowdin.
### Translation guidelines: ### Translation guidelines:
- Avoid formal language. Leave it for boring big tech companies. Use informal language on all occasions. - All text is **ALWAYS** stylized as **lowercase** unless it's STRESSED LIKE THIS or is an internal value like `{ContactLink}` or `{appName}`.
- Strings are **ALWAYS** stylized as lowercase unless it's STRESSED LIKE THIS or is an internal value like `{ContactLink}`. - Example: "`this is a live video, i am yet to learn how to look into future. wait for the stream to finish and try again!`".
- Keep translations lively, friendly, and fun. Translate strings as if cobalt user was your buddy. Notice how **everything is lowercase**, no matter the punctuation marks? Yes, that's cobalt's style and you have to follow it.
- Automatic translations from original language are not valid, and will be ignored. - Avoid formal language. Leave it for big and classy tech companies. Use informal language wherever possible.
- You can (and should) rephrase sentences as long as they keep the same point, if you think it'd be better that way. - Keep translations lively, friendly, and fun. Translate strings as if the user was your buddy.
- You can (and should) rephrase sentences as long as they keep the same sense and send the same message as original.
- You can add wordplays or puns if it feels natural to do so. - You can add wordplays or puns if it feels natural to do so.
- Even though I love cursing, keep that to minimum in translations, and do **NOT** use any offensive words. - Do **NOT** use offensive or explicit vocabulary.
- Check if there are issues in UI with your localization, and optimize it accordingly, or open an issue. - Check if there are issues in UI with your localization, and optimize it accordingly. If impossible, open an issue.
- Add "(in english)" translated to your language at the end of `ChangelogLastCommit`, `ChangelogLastMajor`, and `ChangelogOlder`. Those are always kept exclusively in English (for now), due to how often changelog changes.
- Sample translation to Russian: `"ChangelogLastCommit": "последний коммит (на английском)"`
- Be nice. - Be nice.
## Host an instance yourself ## Host an instance yourself
You might find cobalt's source code a bit messy, but I do my best to improve it with every commit. You might find cobalt's source code a bit messy, but I do my best to improve it with every commit.
### Requirements ### Requirements
- Node.js 14.16 or above - Node.js 17.5 or above
- git - git
### npm modules ### npm modules
@ -64,7 +63,7 @@ You might find cobalt's source code a bit messy, but I do my best to improve it
- node-cache - node-cache
- url-pattern - url-pattern
- xml-js - xml-js
- ytdl-core - youtubei.js
Setup script installs all needed `npm` dependencies, but you have to install `Node.js` and `git` yourself. Setup script installs all needed `npm` dependencies, but you have to install `Node.js` and `git` yourself.
@ -73,10 +72,20 @@ Setup script installs all needed `npm` dependencies, but you have to install `No
3. Run cobalt via `npm start` 3. Run cobalt via `npm start`
4. Done. 4. Done.
### Docker
It's also possible to host cobalt via a Docker image, but in that case you'd need to set all environment variables by yourself.
That includes:
| Variable | Example |
| -------- | :--- |
| `selfURL` | `https://co.wukko.me/` |
| `port` | `9000` |
| `streamSalt` | `randomly generated sha512 hash` |
| `cors` | `0` |
## Disclaimer ## Disclaimer
cobalt is my passion project, so new feature release schedule depends solely on my motivation and mood. Don't expect any consistency in that. cobalt is my passion project, so update release schedule depends solely on my motivation, free time, and mood. Don't expect any consistency in that.
## License ## License
cobalt is under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE). cobalt is under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE) license.
[Fluent Emoji](https://github.com/microsoft/fluentui-emoji) by Microsoft is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE). [Fluent Emoji](https://github.com/microsoft/fluentui-emoji) used in the project is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.

View file

@ -2,26 +2,22 @@
This document provides info about methods and acceptable variables for all cobalt API requests.<br> This document provides info about methods and acceptable variables for all cobalt API requests.<br>
## POST: ``/api/json`` ## POST: ``/api/json``
Main processing endpoint.<br> Main processing endpoint.<br>
```
⚠️ GET method for this endpoint is deprecated and will be removed entirely soon.
Make sure to update your shortcuts and scripts.
Only url query can be used with this method.
```
Request Body Type: ``application/json``<br> Request Body Type: ``application/json``<br>
Response Body Type: ``application/json`` Response Body Type: ``application/json``
### Request Body Variables ### Request Body Variables
| key | type | variables | default | description | | key | type | variables | default | description |
|:----------------|:--------|:----------------------------------|:-----------|:----------------------------------------------------------------------| |:----------------|:--------|:----------------------------------|:----------|:-------------------------------------------------------------------------------|
| url | string | Sharable URL encoded as URI | ``null`` | **Must** be included in every request. | | url | string | Sharable URL encoded as URI | ``null`` | **Must** be included in every request. |
| vFormat | string | ``mp4 / webm`` | ``mp4`` | Applies only to YouTube downloads. ``mp4`` is recommended for phones. | | vCodec | string | ``h264 / av1 / vp9`` | ``h264`` | Applies only to YouTube downloads. ``h264`` is recommended for phones. |
| vQuality | string | ``los / low / mid / hig / max`` | ``hig`` | ``mid`` quality is recommended for phones. | | vQuality | string | ``144 / ... / 2160 / max`` | ``720`` | ``720`` quality is recommended for phones. |
| aFormat | string | ``best / mp3 / ogg / wav / opus`` | ``mp3`` | | | aFormat | string | ``best / mp3 / ogg / wav / opus`` | ``mp3`` | |
| isAudioOnly | boolean | ``true / false`` | ``false`` | | | isAudioOnly | boolean | ``true / false`` | ``false`` | |
| isNoTTWatermark | boolean | ``true / false`` | ``false`` | Changes whether downloaded TikTok & Douyin videos have watermarks. | | isNoTTWatermark | boolean | ``true / false`` | ``false`` | Changes whether downloaded TikTok & Douyin videos have watermarks. |
| isTTFullAudio | boolean | ``true / false`` | ``false`` | Enables download of original sound used in a TikTok video. | | 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. | | isAudioMuted | boolean | ``true / false`` | ``false`` | Disables audio track in video downloads. |
| dubLang | boolean | ``true / false`` | ``false`` | Backend uses Accept-Language for YouTube video audio tracks when ``true``. |
### Response Body Variables ### Response Body Variables
| key | type | variables | | key | type | variables |
@ -47,8 +43,8 @@ Content live render streaming endpoint.<br>
### Request Query Variables ### Request Query Variables
| key | variables | description | | key | variables | description |
|:----|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------| |:----|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------|
| p | ``1`` | Used for checking the rate limit. | | p | ``1`` | Used for probing the rate limit. |
| t | Stream token | Unique stream identificator which is used for retrieving cached stream info data. | | t | Stream token | Unique stream ID. Used for retrieving cached stream info data. |
| h | HMAC | Hashed combination of: (hashed) ip address, stream token, expiry timestamp, and service name. Used for verification of stream. | | h | HMAC | Hashed combination of: (hashed) ip address, stream token, expiry timestamp, and service name. Used for verification of stream. |
| e | Expiry timestamp | | | e | Expiry timestamp | |

View file

@ -1,16 +1,17 @@
{ {
"name": "cobalt", "name": "cobalt",
"description": "save what you love", "description": "save what you love",
"version": "4.7.4", "version": "5.3.3",
"author": "wukko", "author": "wukko",
"exports": "./src/cobalt.js", "exports": "./src/cobalt.js",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=17.5" "node": ">=18"
}, },
"scripts": { "scripts": {
"start": "node src/cobalt", "start": "node src/cobalt",
"setup": "node src/modules/setup" "setup": "node src/modules/setup",
"test": "node src/test/test"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -22,16 +23,17 @@
}, },
"homepage": "https://github.com/wukko/cobalt#readme", "homepage": "https://github.com/wukko/cobalt#readme",
"dependencies": { "dependencies": {
"better-ytdl-core": "^1.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"esbuild": "^0.14.51", "esbuild": "^0.14.51",
"express": "^4.17.1", "express": "^4.18.1",
"express-rate-limit": "^6.3.0", "express-rate-limit": "^6.3.0",
"ffmpeg-static": "^5.1.0", "ffmpeg-static": "^5.1.0",
"got": "^12.1.0", "got": "^12.1.0",
"nanoid": "^4.0.2",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"url-pattern": "1.0.3", "url-pattern": "1.0.3",
"xml-js": "^1.6.11" "xml-js": "^1.6.11",
"youtubei.js": "4.1.1"
} }
} }

View file

@ -1,60 +1,74 @@
import "dotenv/config" import "dotenv/config";
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import * as fs from "fs";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import { shortCommit } from "./modules/sub/currentCommit.js"; import path from 'path';
import { appName, genericUserAgent, version, internetExplorerRedirect } from "./modules/config.js"; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename).slice(0, -4); // go up another level (get rid of src/)
import { getCurrentBranch, shortCommit } from "./modules/sub/currentCommit.js";
import { appName, genericUserAgent, version } from "./modules/config.js";
import { getJSON } from "./modules/api.js"; import { getJSON } from "./modules/api.js";
import renderPage from "./modules/pageRender/page.js"; import { apiJSON, checkJSONPost, getIP, languageCode } from "./modules/sub/utils.js";
import { apiJSON, checkJSONPost, languageCode } from "./modules/sub/utils.js";
import { Bright, Cyan, Green, Red } from "./modules/sub/consoleText.js"; import { Bright, Cyan, Green, Red } from "./modules/sub/consoleText.js";
import stream from "./modules/stream/stream.js"; import stream from "./modules/stream/stream.js";
import loc from "./localization/manager.js"; import loc from "./localization/manager.js";
import { buildFront } from "./modules/build.js"; import { buildFront } from "./modules/build.js";
import { changelogHistory } from "./modules/pageRender/onDemand.js"; import { changelogHistory } from "./modules/pageRender/onDemand.js";
import { sha256 } from "./modules/sub/crypto.js"; import { sha256 } from "./modules/sub/crypto.js";
import findRendered from "./modules/pageRender/findRendered.js";
if (process.env.selfURL && process.env.streamSalt && process.env.port) {
const commitHash = shortCommit(); const commitHash = shortCommit();
const branch = getCurrentBranch();
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt && process.env.port) { const corsConfig = process.env.cors === '0' ? { origin: process.env.selfURL, optionsSuccessStatus: 200 } : {};
const apiLimiter = rateLimit({ const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, windowMs: 60000,
max: 12, max: 25,
standardHeaders: true, standardHeaders: false,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req, res) => sha256(getIP(req), process.env.streamSalt),
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
res.status(429).json({ "status": "error", "text": loc(languageCode(req), 'ErrorRateLimit') }); res.status(429).json({ "status": "error", "text": loc(languageCode(req), 'ErrorRateLimit') });
return;
} }
}); });
const apiLimiterStream = rateLimit({ const apiLimiterStream = rateLimit({
windowMs: 1 * 60 * 1000, windowMs: 60000,
max: 12, max: 28,
standardHeaders: true, standardHeaders: false,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req, res) => sha256(getIP(req), process.env.streamSalt),
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
res.status(429).json({ "status": "error", "text": loc(languageCode(req), 'ErrorRateLimit') }); res.status(429).json({ "status": "error", "text": loc(languageCode(req), 'ErrorRateLimit') });
return;
} }
}); });
await buildFront(); await buildFront(commitHash, branch);
app.use('/api/', apiLimiter);
app.use('/api/:type', cors(corsConfig));
app.use('/api/json', apiLimiter);
app.use('/api/stream', apiLimiterStream); app.use('/api/stream', apiLimiterStream);
app.use('/api/onDemand', apiLimiter);
app.use('/', express.static('./min')); app.use('/', express.static('./min'));
app.use('/', express.static('./src/front')); app.use('/', express.static('./src/front'));
app.use((req, res, next) => { app.use((req, res, next) => {
try { try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') }
decodeURIComponent(req.path) next();
} });
catch (e) { app.use((req, res, next) => {
return res.redirect(process.env.selfURL); if (req.header("user-agent") && req.header("user-agent").includes("Trident")) res.destroy();
}
next(); next();
}); });
app.use('/api/json', express.json({ app.use('/api/json', express.json({
@ -62,60 +76,61 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
try { try {
JSON.parse(buf); JSON.parse(buf);
if (buf.length > 720) throw new Error(); if (buf.length > 720) throw new Error();
if (req.header('Content-Type') != "application/json") res.status(500).json({ 'status': 'error', 'text': 'invalid content type header' }) if (String(req.header('Content-Type')) !== "application/json") {
if (req.header('Accept') != "application/json") res.status(500).json({ 'status': 'error', 'text': 'invalid accept header' }) res.status(400).json({ 'status': 'error', 'text': 'invalid content type header' });
return;
}
if (String(req.header('Accept')) !== "application/json") {
res.status(400).json({ 'status': 'error', 'text': 'invalid accept header' });
return;
}
} catch(e) { } catch(e) {
res.status(500).json({ 'status': 'error', 'text': 'invalid json body.' }) res.status(400).json({ 'status': 'error', 'text': 'invalid json body.' });
return;
} }
} }
})); }));
app.post('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), async (req, res) => {
app.post('/api/json', async (req, res) => {
try { try {
let ip = sha256(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt); let ip = sha256(getIP(req), process.env.streamSalt);
switch (req.params.type) { let lang = languageCode(req);
case 'json': let j = apiJSON(0, { t: "Bad request" });
try { try {
let request = req.body; let request = req.body;
if (request.url) {
request.dubLang = request.dubLang ? lang : false;
let chck = checkJSONPost(request); let chck = checkJSONPost(request);
if (request.url && chck) { if (chck) chck["ip"] = ip;
chck["ip"] = ip; j = chck ? await getJSON(chck["url"], lang, chck) : apiJSON(0, { t: loc(lang, 'ErrorCouldntFetch') });
let j = await getJSON(chck["url"], languageCode(req), chck)
res.status(j.status).json(j.body);
} else if (request.url && !chck) {
let j = apiJSON(3, { t: loc(languageCode(req), 'ErrorCouldntFetch') });
res.status(j.status).json(j.body);
} else { } else {
let j = apiJSON(3, { t: loc(languageCode(req), 'ErrorNoLink') }) j = apiJSON(0, { t: loc(lang, 'ErrorNoLink') });
res.status(j.status).json(j.body);
} }
} catch (e) { } catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') }) j = apiJSON(0, { t: loc(lang, 'ErrorCantProcess') });
} }
break;
default:
let j = apiJSON(0, { t: "unknown response type" })
res.status(j.status).json(j.body); res.status(j.status).json(j.body);
break; return;
}
} catch (e) { } catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') }) res.destroy();
return
} }
}); });
app.get('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), (req, res) => {
app.get('/api/:type', (req, res) => {
try { try {
let ip = sha256(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt); let ip = sha256(getIP(req), process.env.streamSalt);
switch (req.params.type) { switch (req.params.type) {
case 'json':
res.status(405).json({ 'status': 'error', 'text': 'GET method for this request has been deprecated. see https://github.com/wukko/cobalt/blob/current/docs/API.md for up-to-date API documentation.' });
break;
case 'stream': case 'stream':
if (req.query.p) { if (req.query.p) {
res.status(200).json({ "status": "continue" }); res.status(200).json({ "status": "continue" });
return;
} else if (req.query.t && req.query.h && req.query.e) { } else if (req.query.t && req.query.h && req.query.e) {
stream(res, ip, req.query.t, req.query.h, req.query.e); stream(res, ip, req.query.t, req.query.h, req.query.e);
} else { } else {
let j = apiJSON(0, { t: "no stream id" }) let j = apiJSON(0, { t: "no stream id" })
res.status(j.status).json(j.body); res.status(j.status).json(j.body);
return;
} }
break; break;
case 'onDemand': case 'onDemand':
@ -143,27 +158,18 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
break; break;
} }
} catch (e) { } catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') }) res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') });
return;
} }
}); });
app.get("/api", (req, res) => { app.get("/api", (req, res) => {
res.redirect('/api/json') res.redirect('/api/json')
}); });
app.get("/status", (req, res) => {
res.status(200).end()
});
app.get("/", (req, res) => { app.get("/", (req, res) => {
if (req.header("user-agent") && req.header("user-agent").includes("Trident")) { res.sendFile(`${__dirname}/${findRendered(languageCode(req), req.header('user-agent') ? req.header('user-agent') : genericUserAgent)}`);
if (internetExplorerRedirect.newNT.includes(req.header("user-agent").split('NT ')[1].split(';')[0])) {
res.redirect(internetExplorerRedirect.new)
} else {
res.redirect(internetExplorerRedirect.old)
}
} else {
res.send(renderPage({
"hash": commitHash,
"type": "default",
"lang": languageCode(req),
"useragent": req.header('user-agent') ? req.header('user-agent') : genericUserAgent
}))
}
}); });
app.get("/favicon.ico", (req, res) => { app.get("/favicon.ico", (req, res) => {
res.redirect('/icons/favicon.ico'); res.redirect('/icons/favicon.ico');
@ -171,10 +177,11 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
app.get("/*", (req, res) => { app.get("/*", (req, res) => {
res.redirect('/') res.redirect('/')
}); });
app.listen(process.env.port, () => { app.listen(process.env.port, () => {
let startTime = new Date(); let startTime = new Date();
console.log(`\n${Cyan(appName)} ${Bright(`v.${version}-${commitHash}`)}\nStart time: ${Bright(`${startTime.toUTCString()} (${Math.floor(new Date().getTime())})`)}\n\nURL: ${Cyan(`${process.env.selfURL}`)}\nPort: ${process.env.port}\n`) console.log(`\n${Cyan(appName)} ${Bright(`v.${version}-${commitHash} (${branch})`)}\nStart time: ${Bright(`${startTime.toUTCString()} (${Math.floor(new Date().getTime())})`)}\n\nURL: ${Cyan(`${process.env.selfURL}`)}\nPort: ${process.env.port}\n`)
}); })
} else { } else {
console.log(Red(`cobalt hasn't been configured yet or configuration is invalid.\n`) + Bright(`please run the setup script to fix this: `) + Green(`npm run setup`)) console.log(Red(`cobalt hasn't been configured yet or configuration is invalid.\n`) + Bright(`please run the setup script to fix this: `) + Green(`npm run setup`));
} }

View file

@ -1,17 +1,21 @@
{ {
"streamLifespan": 120000, "streamLifespan": 120000,
"maxVideoDuration": 7500000, "maxVideoDuration": 10800000,
"maxAudioDuration": 7500000, "genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
"genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
"authorInfo": { "authorInfo": {
"name": "wukko", "name": "wukko",
"link": "https://wukko.me/", "link": "https://wukko.me/",
"contact": "https://wukko.me/contacts" "contact": "https://wukko.me/contacts",
"support": {
"twitter": {
"url": "https://twitter.com/justusecobalt",
"handle": "@justusecobalt"
}, },
"internetExplorerRedirect": { "mastodon": {
"newNT": ["6.1", "6.2", "6.3", "10.0"], "url": "https://wetdry.world/@cobalt",
"old": "https://mypal-browser.org/", "handle": "@cobalt@wetdry.world"
"new": "https://www.mozilla.org/firefox/new/" }
}
}, },
"donations": { "donations": {
"crypto": { "crypto": {
@ -23,11 +27,6 @@
"boosty": "https://boosty.to/wukko" "boosty": "https://boosty.to/wukko"
} }
}, },
"quality": {
"hig": "1440",
"mid": "720",
"low": "480"
},
"celebrations": { "celebrations": {
"01-01": "🎄", "01-01": "🎄",
"02-17": "😺", "02-17": "😺",
@ -52,14 +51,15 @@
"12-28": "🎄", "12-28": "🎄",
"12-29": "🎄", "12-29": "🎄",
"12-30": "🎄", "12-30": "🎄",
"12-31": "🎄" "12-31": "🎄",
"03-08": "💪"
}, },
"supportedAudio": ["mp3", "ogg", "wav", "opus"], "supportedAudio": ["mp3", "ogg", "wav", "opus"],
"ffmpegArgs": { "ffmpegArgs": {
"webm": ["-c:v", "copy", "-c:a", "copy"], "webm": ["-c:v", "copy", "-c:a", "copy"],
"mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"], "mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
"copy": ["-c:a", "copy"], "copy": ["-c:a", "copy"],
"audio": ["-vn", "-ar", "48000", "-ac", "2", "-b:a", "320k"], "audio": ["-ar", "48000", "-ac", "2", "-b:a", "320k"],
"m4a": ["-movflags", "frag_keyframe+empty_moov"] "m4a": ["-movflags", "frag_keyframe+empty_moov"]
} }
} }

View file

@ -4,7 +4,11 @@
--border-15: 0.15rem solid var(--accent); --border-15: 0.15rem solid var(--accent);
--border-10: 0.1rem solid var(--accent); --border-10: 0.1rem solid var(--accent);
--font-mono: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace; --font-mono: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace;
--padding-1: 0.75rem;
--line-height: 1.65rem;
--red: rgb(255, 0, 61); --red: rgb(255, 0, 61);
--color: rgb(107, 67, 139);
--gap: 0.6rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@ -15,6 +19,7 @@
--accent-unhover: rgb(100, 100, 100); --accent-unhover: rgb(100, 100, 100);
--accent-unhover-2: rgb(110, 110, 110); --accent-unhover-2: rgb(110, 110, 110);
--background: rgb(0, 0, 0); --background: rgb(0, 0, 0);
--checkmark: url(vectorIcons/checkmark_b.svg);
} }
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -26,6 +31,7 @@
--accent-unhover: rgb(190, 190, 190); --accent-unhover: rgb(190, 190, 190);
--accent-unhover-2: rgb(110, 110, 110); --accent-unhover-2: rgb(110, 110, 110);
--background: rgb(255, 255, 255); --background: rgb(255, 255, 255);
--checkmark: url(vectorIcons/checkmark.svg);
} }
} }
[data-theme="dark"] { [data-theme="dark"] {
@ -36,6 +42,7 @@
--accent-unhover: rgb(100, 100, 100); --accent-unhover: rgb(100, 100, 100);
--accent-unhover-2: rgb(110, 110, 110); --accent-unhover-2: rgb(110, 110, 110);
--background: rgb(0, 0, 0); --background: rgb(0, 0, 0);
--checkmark: url(vectorIcons/checkmark_b.svg);
} }
[data-theme="light"] { [data-theme="light"] {
--accent: rgb(25, 25, 25); --accent: rgb(25, 25, 25);
@ -45,15 +52,17 @@
--accent-unhover: rgb(190, 190, 190); --accent-unhover: rgb(190, 190, 190);
--accent-unhover-2: rgb(110, 110, 110); --accent-unhover-2: rgb(110, 110, 110);
--background: rgb(255, 255, 255); --background: rgb(255, 255, 255);
--checkmark: url(vectorIcons/checkmark.svg);
} }
html, html,
body { body {
margin: 0; margin: 0;
background: var(--background); background: var(--background);
color: var(--accent); color: var(--accent);
-webkit-tap-highlight-color: var(--transparent);
font-family: var(--font-mono); font-family: var(--font-mono);
user-select: none; user-select: none;
-webkit-tap-highlight-color: var(--transparent); -webkit-user-select: none;
overflow: hidden; overflow: hidden;
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
@ -62,6 +71,7 @@ a {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
-webkit-user-select: none;
} }
::placeholder { ::placeholder {
color: var(--accent-unhover-2); color: var(--accent-unhover-2);
@ -75,27 +85,34 @@ a {
[type="checkbox"] { [type="checkbox"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
margin-right: 1rem; margin-right: var(--padding-1);
z-index: 0; z-index: 0;
border: 0; border: 0;
height: 15px; height: 15px;
width: 15px;
} }
[type="checkbox"]::before { [type="checkbox"]::before {
content: ""; content: "";
width: 15px; width: 15px;
height: 15px; height: 15px;
border: var(--border-15); border: 0.15rem solid var(--accent);
background-color: var(--accent-button-bg);
display: block; display: block;
z-index: 5; z-index: 5;
position: relative; position: relative;
} }
[type="checkbox"]:checked::before { [type="checkbox"]:checked::before {
box-shadow: inset 0 0 0 0.14rem var(--accent-button-bg); background: var(--checkmark);
background-size: 90%;
background-position: center;
background-repeat: no-repeat;
}
[type="checkbox"]:checked::before {
background-color: var(--accent); background-color: var(--accent);
border: 0.15rem solid var(--accent);
} }
.checkbox span { .checkbox span {
margin-top: 0.21rem; margin-top: 0.21rem;
margin-left: 0.4rem;
} }
button { button {
background: none; background: none;
@ -109,20 +126,15 @@ input[type="text"],
[type="text"] { [type="text"] {
border-radius: 0; border-radius: 0;
} }
button:hover, .desktop button:hover,
.switch:hover, .desktop .switch:hover,
.checkbox:hover, .desktop .checkbox:hover,
.text-to-copy:hover { .desktop .text-to-copy:hover,
.desktop .collapse-header:hover,
.desktop #close-button:hover {
background: var(--accent-hover); background: var(--accent-hover);
cursor: pointer; cursor: pointer;
} }
.switch.text-backdrop:hover,
.switch.text-backdrop:active,
.text-to-copy.text-backdrop:hover,
.text-to-copy.text-backdrop:active {
background: var(--accent);
color: var(--background);
}
button:active, button:active,
.switch:active, .switch:active,
.checkbox:active, .checkbox:active,
@ -131,6 +143,19 @@ button:active,
cursor: pointer; cursor: pointer;
transform: scale(0.95) transform: scale(0.95)
} }
.collapse-header:active {
background: var(--accent-press);
cursor: pointer;
}
.switch.text-backdrop,
.switch.text-backdrop:hover,
.switch.text-backdrop:active,
.text-to-copy.text-backdrop,
.text-to-copy.text-backdrop:hover,
.text-to-copy.text-backdrop:active {
background: var(--accent);
color: var(--background);
}
.picker-image:active { .picker-image:active {
cursor: pointer; cursor: pointer;
transform: scale(0.95) transform: scale(0.95)
@ -157,14 +182,17 @@ input[type="checkbox"] {
position: fixed; position: fixed;
width: 60%; width: 60%;
height: auto; height: auto;
display: inline-flex; display: flex;
flex-direction: row;
} }
#logo-area { #logo {
padding-right: 3rem;
padding-top: 0.1rem;
text-align: left; text-align: left;
font-size: 1rem; font-size: 1rem;
white-space: nowrap; white-space: nowrap;
width: 7rem;
height: 2.5rem;
align-items: center;
display: flex;
} }
#download-area { #download-area {
display: flex; display: flex;
@ -173,20 +201,15 @@ input[type="checkbox"] {
} }
#cobalt-main-box #top { #cobalt-main-box #top {
display: inline-flex; display: inline-flex;
height: 2rem; height: 2.5rem;
margin-top: -0.6rem;
flex-direction: row; flex-direction: row;
} }
#cobalt-main-box #bottom { #cobalt-main-box #bottom {
padding-top: 1.5rem; padding-top: 1rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
} }
#cobalt-main-box #bottom button {
width: auto!important;
padding: 0.6rem 1.2rem!important;
}
.box { .box {
background: var(--background); background: var(--background);
border: var(--border-15); border: var(--border-15);
@ -194,7 +217,7 @@ input[type="checkbox"] {
} }
#url-input-area { #url-input-area {
background: var(--background); background: var(--background);
padding: 1.2rem 1rem; padding: 0 1rem;
width: 100%; width: 100%;
color: var(--accent); color: var(--accent);
border: 0; border: 0;
@ -204,13 +227,11 @@ input[type="checkbox"] {
font-size: 0.8rem; font-size: 0.8rem;
} }
#url-clear { #url-clear {
height: 100%;
background: none; background: none;
padding: 0 1.1rem; padding: 0 1rem 0.2rem;
font-size: 1rem;
transform: none; transform: none;
line-height: 0; font-size: 1rem;
height: 1.6rem;
margin-top: .4rem;
} }
#url-input-area:focus { #url-input-area:focus {
outline: none; outline: none;
@ -242,7 +263,7 @@ input[type="checkbox"] {
#cobalt-main-box #bottom, #cobalt-main-box #bottom,
#footer-buttons, #footer-buttons,
#footer-buttons, .footer-pair { #footer-buttons, .footer-pair {
gap: 0.8rem; gap: var(--gap);
} }
#footer-buttons, .footer-pair { #footer-buttons, .footer-pair {
display: flex; display: flex;
@ -252,7 +273,7 @@ input[type="checkbox"] {
.footer-button { .footer-button {
width: auto!important; width: auto!important;
color: var(--accent-unhover-2); color: var(--accent-unhover-2);
padding: 0.6rem 1.2rem!important; padding: var(--gap) 1.2rem!important;
align-content: center; align-content: center;
} }
.notification-dot { .notification-dot {
@ -265,7 +286,18 @@ input[type="checkbox"] {
.text-backdrop { .text-backdrop {
background: var(--accent); background: var(--accent);
color: var(--background); color: var(--background);
padding: 0 0.1rem; }
.italic {
font-style: italic;
}
.cobalt-support-link {
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 0.3rem;
margin-top: 0.5rem;
user-select: none;
-webkit-user-select: none;
} }
::-moz-selection { ::-moz-selection {
background-color: var(--accent); background-color: var(--accent);
@ -310,9 +342,8 @@ input[type="checkbox"] {
} }
.changelog-banner { .changelog-banner {
width: 100%; width: 100%;
background-color: var(--accent-button-bg);
max-height: 300px; max-height: 300px;
margin-bottom: 2rem; margin-bottom: 1.65rem;
float: left; float: left;
} }
.changelog-img { .changelog-img {
@ -332,7 +363,7 @@ input[type="checkbox"] {
} }
#popup-subtitle { #popup-subtitle {
font-size: 1.1rem; font-size: 1.1rem;
padding-bottom: 1rem; padding-bottom: var(--padding-1);
} }
#popup-desc, #popup-desc,
#desc-error, #desc-error,
@ -340,8 +371,9 @@ input[type="checkbox"] {
width: 100%; width: 100%;
text-align: left; text-align: left;
float: left; float: left;
line-height: 1.7rem; line-height: var(--line-height);
user-select: text; user-select: text;
-webkit-user-select: text;
} }
#popup-title { #popup-title {
font-size: 1.5rem; font-size: 1.5rem;
@ -359,7 +391,7 @@ input[type="checkbox"] {
} }
.popup-footer-content { .popup-footer-content {
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.7rem; line-height: var(--line-height);
color: var(--accent-unhover-2); color: var(--accent-unhover-2);
border-top: 0.05rem solid var(--accent-unhover-2); border-top: 0.05rem solid var(--accent-unhover-2);
padding-top: 0.4rem; padding-top: 0.4rem;
@ -386,14 +418,8 @@ input[type="checkbox"] {
#popup-content.with-footer { #popup-content.with-footer {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
#popup-close {
cursor: pointer;
float: right;
right: 0;
position: absolute;
}
.settings-category { .settings-category {
padding-bottom: 1.2rem; padding-bottom: 1rem;
} }
.separator { .separator {
float: left; float: left;
@ -408,13 +434,17 @@ input[type="checkbox"] {
} }
.category-title { .category-title {
text-align: left; text-align: left;
line-height: 1.7rem; line-height: var(--line-height);
} }
.bottom-margin { .bottom-margin {
margin-bottom: 1rem!important; margin-bottom: var(--padding-1)!important;
} }
.top-margin { .top-margin {
margin-top: 1rem!important; margin-top: var(--padding-1)!important;
}
.top-margin-only {
margin-top: var(--padding-1)!important;
margin-bottom: 0!important;
} }
.no-margin { .no-margin {
margin: 0!important; margin: 0!important;
@ -424,10 +454,10 @@ input[type="checkbox"] {
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-content: center;
padding: 0.55rem 1rem 0.8rem 0.7rem; padding: 0.55rem 1rem 0.8rem 0.7rem;
width: auto; width: auto;
margin: 0 0.5rem 0.5rem 0; margin-right: var(--padding-1);
margin-bottom: var(--padding-1);
background: var(--accent-button-bg); background: var(--accent-button-bg);
} }
.checkbox-label { .checkbox-label {
@ -439,7 +469,7 @@ input[type="checkbox"] {
.subtitle { .subtitle {
width: 100%; width: 100%;
text-align: left; text-align: left;
line-height: 1.7rem; line-height: var(--line-height);
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
color: var(--accent); color: var(--accent);
} }
@ -460,7 +490,7 @@ input[type="checkbox"] {
.switch { .switch {
padding: 0.7rem; padding: 0.7rem;
width: 100%; width: 100%;
text-align: center; text-align: left;
color: var(--accent); color: var(--accent);
background: var(--accent-button-bg); background: var(--accent-button-bg);
display: flex; display: flex;
@ -469,7 +499,7 @@ input[type="checkbox"] {
cursor: pointer; cursor: pointer;
} }
.switch.space-right { .switch.space-right {
margin-right: 1rem margin-right: var(--padding-1);
} }
.switch[data-enabled="true"] { .switch[data-enabled="true"] {
color: var(--background); color: var(--background);
@ -477,6 +507,9 @@ input[type="checkbox"] {
cursor: default; cursor: default;
z-index: 999 z-index: 999
} }
.switch[data-enabled="true"]:hover {
background: var(--accent);
}
.switches { .switches {
display: flex; display: flex;
width: auto; width: auto;
@ -493,18 +526,25 @@ input[type="checkbox"] {
} }
.text-to-copy { .text-to-copy {
user-select: text; user-select: text;
-webkit-user-select: text;
border: var(--border-15); border: var(--border-15);
padding: 1rem; padding: var(--padding-1);
overflow: auto; overflow: auto;
} }
#close-bottom { #close-button {
max-width: 2.8rem; max-width: 2.8rem;
margin-left: 1rem; margin-left: var(--padding-1);
background: var(--background); background: var(--background);
border: var(--border-15); border: var(--border-15);
color: var(--accent); color: var(--accent);
padding: 0.3rem 0.75rem 0.5rem; padding: 0.3rem 0.75rem 0.5rem;
} }
#close-button.up {
float: right;
position: absolute;
right: 0;
height: 2.8rem;
}
.popup-tab-content { .popup-tab-content {
display: none; display: none;
} }
@ -519,18 +559,9 @@ input[type="checkbox"] {
} }
.emoji { .emoji {
margin-right: 0.4rem; margin-right: 0.4rem;
} user-select: none;
.tooltip { -webkit-user-select: none;
position: absolute;
margin-top: -6rem;
margin-left: -0.5rem;
line-height: 1.2;
text-align: left;
pointer-events: none; pointer-events: none;
color: var(--accent-unhover-2)!important;
}
.button:active .tooltip {
display: none;
} }
.picker-image { .picker-image {
object-fit: cover; object-fit: cover;
@ -540,13 +571,13 @@ input[type="checkbox"] {
.picker-image-container { .picker-image-container {
width: 8rem; width: 8rem;
height: 8rem; height: 8rem;
margin-bottom: 1rem; margin-bottom: var(--padding-1);
background-color: var(--accent-button-bg); background-color: var(--accent-button-bg);
} }
.picker-various-container { .picker-various-container {
height: 20rem; height: 20rem;
width: 25rem; width: 25rem;
margin-bottom: 1rem; margin-bottom: var(--padding-1);
background-color: var(--accent-button-bg); background-color: var(--accent-button-bg);
position: relative; position: relative;
} }
@ -572,14 +603,64 @@ input[type="checkbox"] {
position: absolute; position: absolute;
background: var(--background); background: var(--background);
color: var(--accent); color: var(--accent);
padding: 0.3rem 0.6rem; padding: 0.3rem var(--gap);
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.7; opacity: 0.7;
margin: 0.4rem; margin: 0.4rem;
} }
#popup-picker .explanation { #popup-picker .explanation {
margin-top: 0!important; margin-top: 0!important;
margin-bottom: 1rem; margin-bottom: var(--padding-1);
}
#cobalt-main-box #bottom button {
width: auto;
padding: var(--gap) 1.2rem;
}
.collapse-list {
background: var(--accent-press);
user-select: none;
-webkit-user-select: none;
}
.collapse-header {
padding: var(--padding-1);
font-size: 1rem;
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
background: var(--accent-button-bg);
}
.collapse-indicator {
transform: rotate(180deg);
}
.expanded .collapse-indicator {
transform: none;
}
.collapse-title {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.8rem;
}
.collapse-body {
display: none;
padding: var(--padding-1);
user-select: text;
-webkit-user-select: text;
}
.expanded .collapse-body {
display: block;
}
#download-switcher .switches {
gap: var(--gap);
}
#pd-share {
display: none;
}
#hop-attribution {
display: block;
text-align: right;
} }
/* adapt the page according to screen size */ /* adapt the page according to screen size */
@media screen and (min-width: 2300px) { @media screen and (min-width: 2300px) {
@ -655,12 +736,12 @@ input[type="checkbox"] {
} }
} }
/* mobile page */ /* mobile page */
@media screen and (max-width: 949px) { @media screen and (max-width: 720px) {
#cobalt-main-box, #footer { #cobalt-main-box, #footer {
width: 85%; width: 90%;
} }
} }
@media screen and (max-width: 475px) { @media screen and (max-width: 499px) {
.tab { .tab {
font-size: 0!important; font-size: 0!important;
} }
@ -670,15 +751,27 @@ input[type="checkbox"] {
#cobalt-main-box, #footer { #cobalt-main-box, #footer {
width: 90%; width: 90%;
} }
.checkbox {
width: 100%;
}
} }
@media screen and (max-width: 320px) { @media screen and (max-width: 320px) {
#popup-title { #popup-title {
font-size: 1.3rem; font-size: 1.3rem;
line-height: 2rem; line-height: 2rem;
} }
.footer-button { .footer-button,
#audioMode-false,
#audioMode-true,
#paste {
font-size: 0!important; font-size: 0!important;
} }
.footer-button .emoji,
#audioMode-false .emoji,
#audioMode-true .emoji,
#paste .emoji {
margin-right: 0;
}
.switch, .checkbox, .category-title, .subtitle, #popup-desc { .switch, .checkbox, .category-title, .subtitle, #popup-desc {
font-size: .75rem; font-size: .75rem;
} }
@ -695,8 +788,36 @@ input[type="checkbox"] {
.category-title { .category-title {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
.footer-button .emoji { }
margin-right: 0; @media screen and (max-width: 720px) {
#cobalt-main-box #bottom {
flex-direction: column-reverse;
}
#cobalt-main-box #bottom button {
width: 100%;
}
#footer {
bottom: 4%;
transform: translate(-50%, 0%);
}
#footer-buttons {
flex-direction: column;
align-items: stretch;
}
.footer-pair .footer-button {
width: 100%!important;
}
#logo {
width: 100%;
height: auto;
justify-content: center;
}
#cobalt-main-box {
display: flex;
border: none;
padding: 0;
flex-direction: column;
gap: var(--gap);
} }
} }
@media screen and (max-width: 949px) { @media screen and (max-width: 949px) {
@ -715,23 +836,6 @@ input[type="checkbox"] {
height: 20rem; height: 20rem;
max-width: 100%; max-width: 100%;
} }
#cobalt-main-box #bottom {
flex-direction: column;
}
#cobalt-main-box #bottom button {
width: 100%!important;
}
#footer {
bottom: 1.7rem;
transform: translate(-50%, 0%);
}
#footer-buttons {
flex-direction: column;
align-items: stretch;
}
.footer-pair .footer-button {
width: 100%!important;
}
#popup-header { #popup-header {
padding-top: 1.2rem; padding-top: 1.2rem;
} }
@ -744,24 +848,10 @@ input[type="checkbox"] {
line-height: 7rem; line-height: 7rem;
} }
#close-error { #close-error {
bottom: 5%; bottom: 3rem;
position: absolute; position: absolute;
width: var(--without-padding); width: var(--without-padding);
} }
#logo-area {
padding-right: 0;
padding-top: 0;
position: fixed;
line-height: 0;
margin-top: -2rem;
width: 100%;
text-align: center;
}
#cobalt-main-box {
display: flex;
border: none;
padding: 0;
}
.popup, .popup.scrollable, .popup.small { .popup, .popup.scrollable, .popup.small {
border: none; border: none;
width: 90%; width: 90%;

View file

@ -1,7 +1,7 @@
let ua = navigator.userAgent.toLowerCase(); let ua = navigator.userAgent.toLowerCase();
let isIOS = ua.match("iphone os"); let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os"); let isMobile = ua.match("android") || ua.match("iphone os");
let version = 21; let version = 25;
let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/); let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/);
let notification = `<div class="notification-dot"></div>` let notification = `<div class="notification-dot"></div>`
@ -9,13 +9,16 @@ let store = {}
let switchers = { let switchers = {
"theme": ["auto", "light", "dark"], "theme": ["auto", "light", "dark"],
"vFormat": ["mp4", "webm"], "vCodec": ["h264", "av1", "vp9"],
"vQuality": ["hig", "max", "mid", "low"], "vQuality": ["1080", "max", "2160", "1440", "720", "480", "360"],
"aFormat": ["mp3", "best", "ogg", "wav", "opus"] "aFormat": ["mp3", "best", "ogg", "wav", "opus"],
"dubLang": ["original", "auto"],
"vimeoDash": ["false", "true"],
"audioMode": ["false", "true"]
} }
let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio", "muteAudio"]; let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio", "muteAudio"];
let exceptions = { // used for mobile devices let exceptions = { // used for mobile devices
"vQuality": "mid" "vQuality": "720"
} }
function eid(id) { function eid(id) {
@ -85,8 +88,11 @@ function clearInput() {
function copy(id, data) { function copy(id, data) {
let e = document.getElementById(id); let e = document.getElementById(id);
e.classList.add("text-backdrop"); e.classList.add("text-backdrop");
data ? navigator.clipboard.writeText(data) : navigator.clipboard.writeText(e.innerText);
setTimeout(() => { e.classList.remove("text-backdrop") }, 600); setTimeout(() => { e.classList.remove("text-backdrop") }, 600);
data ? navigator.clipboard.writeText(data) : navigator.clipboard.writeText(e.innerText);
}
async function share(url) {
try { await navigator.share({url: url}) } catch (e) {}
} }
function detectColorScheme() { function detectColorScheme() {
let theme = "auto"; let theme = "auto";
@ -112,6 +118,11 @@ function changeTab(evnt, tabId, tabClass) {
if (tabId === "tab-about-changelog" && sGet("changelogStatus") !== `${version}`) notificationCheck("changelog"); if (tabId === "tab-about-changelog" && sGet("changelogStatus") !== `${version}`) notificationCheck("changelog");
if (tabId === "tab-about-about" && !sGet("seenAbout")) notificationCheck("about"); if (tabId === "tab-about-about" && !sGet("seenAbout")) notificationCheck("about");
} }
function expandCollapsible(evnt) {
let classlist = evnt.currentTarget.parentNode.classList;
let c = "expanded";
!classlist.contains(c) ? classlist.add(c) : classlist.remove(c);
}
function notificationCheck(type) { function notificationCheck(type) {
let changed = true; let changed = true;
switch (type) { switch (type) {
@ -123,7 +134,6 @@ function notificationCheck(type) {
break; break;
default: default:
changed = false; changed = false;
break;
} }
if (changed && sGet("changelogStatus") === `${version}` || type === "disable") { if (changed && sGet("changelogStatus") === `${version}` || type === "disable") {
setTimeout(() => { setTimeout(() => {
@ -167,6 +177,8 @@ function popup(type, action, text) {
case "download": case "download":
eid("pd-download").href = text; eid("pd-download").href = text;
eid("pd-copy").setAttribute("onClick", `copy('pd-copy', '${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; break;
case "picker": case "picker":
switch (text.type) { switch (text.type) {
@ -212,17 +224,14 @@ function popup(type, action, text) {
eid("popup-backdrop").style.visibility = vis(action); eid("popup-backdrop").style.visibility = vis(action);
eid(`popup-${type}`).style.visibility = vis(action); eid(`popup-${type}`).style.visibility = vis(action);
} }
function updateMP4Text() {
eid("vFormat-mp4").innerHTML = sGet("vQuality") === "mid" ? "mp4 (h264/av1)" : "mp4 (av1)";
}
function changeSwitcher(li, b) { function changeSwitcher(li, b) {
if (b) { if (b) {
if (!switchers[li].includes(b)) b = switchers[li][0];
sSet(li, b); sSet(li, b);
for (let i in switchers[li]) { for (let i in switchers[li]) {
(switchers[li][i] === b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`) (switchers[li][i] === b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`)
} }
if (li === "theme") detectColorScheme(); if (li === "theme") detectColorScheme();
if (li === "vQuality") updateMP4Text();
} else { } else {
let pref = switchers[li][0]; let pref = switchers[li][0];
if (isMobile && exceptions[li]) pref = exceptions[li]; if (isMobile && exceptions[li]) pref = exceptions[li];
@ -238,40 +247,17 @@ function internetError() {
popup("error", 1, loc.noInternet); popup("error", 1, loc.noInternet);
} }
function checkbox(action) { function checkbox(action) {
if (eid(action).checked) { sSet(action, !!eid(action).checked);
sSet(action, "true"); switch(action) {
if (action === "alwaysVisibleButton") button(); case "alwaysVisibleButton": button(); break;
} else {
sSet(action, "false");
if (action === "alwaysVisibleButton") button();
} }
sGet(action) === "true" ? notificationCheck("disable") : notificationCheck(); action === "disableChangelog" && sGet(action) === "true" ? notificationCheck("disable") : notificationCheck();
}
function updateToggle(toggl, state) {
switch(state) {
case "true":
eid(toggl).innerHTML = loc.toggleAudio;
break;
case "false":
eid(toggl).innerHTML = loc.toggleDefault;
break;
}
}
function toggle(toggl) {
let state = sGet(toggl);
if (state) {
sSet(toggl, opposite(state))
if (opposite(state) === "true") sSet(`${toggl}ToggledOnce`, "true");
} else {
sSet(toggl, "false")
}
updateToggle(toggl, sGet(toggl))
} }
function loadSettings() { function loadSettings() {
try { try {
if (typeof(navigator.clipboard.readText) == "undefined") throw new Error(); if (typeof(navigator.clipboard.readText) == "undefined") throw new Error();
} catch (err) { } catch (err) {
eid("pasteFromClipboard").style.display = "none" eid("paste").style.display = "none";
} }
if (sGet("alwaysVisibleButton") === "true") { if (sGet("alwaysVisibleButton") === "true") {
eid("alwaysVisibleButton").checked = true; eid("alwaysVisibleButton").checked = true;
@ -281,17 +267,12 @@ function loadSettings() {
if (sGet("downloadPopup") === "true" && !isIOS) { if (sGet("downloadPopup") === "true" && !isIOS) {
eid("downloadPopup").checked = true; eid("downloadPopup").checked = true;
} }
if (!sGet("audioMode")) {
toggle("audioMode")
}
for (let i = 0; i < checkboxes.length; i++) { for (let i = 0; i < checkboxes.length; i++) {
if (sGet(checkboxes[i]) === "true") eid(checkboxes[i]).checked = true; if (sGet(checkboxes[i]) === "true") eid(checkboxes[i]).checked = true;
} }
updateToggle("audioMode", sGet("audioMode"));
for (let i in switchers) { for (let i in switchers) {
changeSwitcher(i, sGet(i)) changeSwitcher(i, sGet(i))
} }
updateMP4Text();
} }
function changeButton(type, text) { function changeButton(type, text) {
switch (type) { switch (type) {
@ -319,11 +300,13 @@ function resetSettings() {
window.location.reload(); window.location.reload();
} }
async function pasteClipboard() { async function pasteClipboard() {
try {
let t = await navigator.clipboard.readText(); let t = await navigator.clipboard.readText();
if (regex.test(t)) { if (regex.test(t)) {
eid("url-input-area").value = t; eid("url-input-area").value = t;
download(eid("url-input-area").value); download(eid("url-input-area").value);
} }
} catch (e) {}
} }
async function download(url) { async function download(url) {
changeDownloadButton(2, '...'); changeDownloadButton(2, '...');
@ -332,16 +315,23 @@ async function download(url) {
let req = { let req = {
url: encodeURIComponent(url.split("&")[0].split('%')[0]), url: encodeURIComponent(url.split("&")[0].split('%')[0]),
aFormat: sGet("aFormat").slice(0, 4), aFormat: sGet("aFormat").slice(0, 4),
dubLang: false
} }
if (sGet("dubLang") === "auto") {
req.dubLang = true
} else if (sGet("dubLang") === "custom") {
req.dubLang = true
}
if (sGet("vimeoDash") === "true") req.vimeoDash = true;
if (sGet("audioMode") === "true") { if (sGet("audioMode") === "true") {
req["isAudioOnly"] = true; req.isAudioOnly = true;
req["isNoTTWatermark"] = true; // video tiktok no watermark req.isNoTTWatermark = true; // video tiktok no watermark
if (sGet("fullTikTokAudio") === "true") req["isTTFullAudio"] = true; // audio tiktok full if (sGet("fullTikTokAudio") === "true") req.isTTFullAudio = true; // audio tiktok full
} else { } else {
req["vQuality"] = sGet("vQuality").slice(0, 4); req.vQuality = sGet("vQuality").slice(0, 4);
if (sGet("muteAudio") === "true") req["isAudioMuted"] = true; if (sGet("muteAudio") === "true") req.isAudioMuted = true;
if (url.includes("youtube.com/") || url.includes("/youtu.be/")) req["vFormat"] = sGet("vFormat").slice(0, 4); if (url.includes("youtube.com/") || url.includes("/youtu.be/")) req.vCodec = sGet("vCodec").slice(0, 4);
if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") req["isNoTTWatermark"] = true; if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") req.isNoTTWatermark = true;
} }
await fetch('/api/json', { method: "POST", body: JSON.stringify(req), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(async (r) => { await fetch('/api/json', { method: "POST", body: JSON.stringify(req), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(async (r) => {
let j = await r.json(); let j = await r.json();
@ -458,4 +448,4 @@ eid("url-input-area").addEventListener("keyup", (event) => {
document.onkeydown = (event) => { document.onkeydown = (event) => {
if (event.key === "Tab" || event.ctrlKey) eid("url-input-area").focus(); if (event.key === "Tab" || event.ctrlKey) eid("url-input-area").focus();
if (event.key === 'Escape') hideAllPopups(); if (event.key === 'Escape') hideAllPopups();
}; }

View file

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

After

Width:  |  Height:  |  Size: 1.9 KiB

6
src/front/emoji/bird.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,10 +1,11 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 4.5C5 3.67157 5.67157 3 6.5 3H25.5C26.3284 3 27 3.67157 27 4.5V28.5C27 29.3284 26.3284 30 25.5 30H6.5C5.67157 30 5 29.3284 5 28.5V4.5Z" fill="#E19747"/> <path d="M25.5 2H6.5C5.67157 2 5 2.67157 5 3.5V27.5C5 28.3284 5.67157 29 6.5 29H16.4244C16.795 29 17.1524 28.8628 17.4278 28.6149L26.5034 20.4469C26.8195 20.1624 27 19.7572 27 19.332V3.5C27 2.67157 26.3284 2 25.5 2Z" fill="#E19747"/>
<path d="M25 6C25 5.44772 24.5523 5 24 5H8C7.44772 5 7 5.44772 7 6V27C7 27.5523 7.44772 28 8 28H18.5858C18.7327 28 18.8764 27.9677 19.0071 27.9069L19.3282 27.1239L19.96 23.0379L24.4166 22.255L24.9064 22.0082C24.9675 21.8772 25 21.7332 25 21.5858V6Z" fill="#F3EEF8"/> <rect x="7" y="4" width="18" height="23" rx="1" fill="#F3EEF8"/>
<path d="M24.9102 22H20C19.4477 22 19 22.4477 19 23V27.9102C19.108 27.861 19.2074 27.7926 19.2929 27.7071L24.7071 22.2929C24.7926 22.2074 24.861 22.108 24.9102 22Z" fill="#CDC4D6"/> <path d="M18 3C18 1.89543 17.1046 1 16 1C14.8954 1 14 1.89543 14 3H13C11.8954 3 11 3.89543 11 5V6.5C11 6.77614 11.2239 7 11.5 7H20.5C20.7761 7 21 6.77614 21 6.5V5C21 3.89543 20.1046 3 19 3H18ZM17 3C17 3.55228 16.5523 4 16 4C15.4477 4 15 3.55228 15 3C15 2.44772 15.4477 2 16 2C16.5523 2 17 2.44772 17 3Z" fill="#9B9B9B"/>
<path d="M18 4C18 2.89543 17.1046 2 16 2C14.8954 2 14 2.89543 14 4H13C11.8954 4 11 4.89543 11 6V7.5C11 7.77614 11.2239 8 11.5 8H20.5C20.7761 8 21 7.77614 21 7.5V6C21 4.89543 20.1046 4 19 4H18ZM17 4C17 4.55228 16.5523 5 16 5C15.4477 5 15 4.55228 15 4C15 3.44772 15.4477 3 16 3C16.5523 3 17 3.44772 17 4Z" fill="#9B9B9B"/> <path d="M28 11C28.5523 11 29 11.4477 29 12V26L28.5 26.5L24 31H14C13.4477 31 13 30.5523 13 30V12C13 11.4477 13.4477 11 14 11H28Z" fill="#D9D9D9"/>
<path d="M9 12.5C9 12.2239 9.22386 12 9.5 12H22.5C22.7761 12 23 12.2239 23 12.5C23 12.7761 22.7761 13 22.5 13H9.5C9.22386 13 9 12.7761 9 12.5Z" fill="#9B9B9B"/> <path d="M29 26H24.846C24.3788 26 24 26.3788 24 26.846V31C24.0914 30.9584 24.1755 30.9005 24.2478 30.8282L28.8282 26.2478C28.9005 26.1755 28.9584 26.0914 29 26Z" fill="#B9B9B9"/>
<path d="M9 15.5C9 15.2239 9.22386 15 9.5 15H22.5C22.7761 15 23 15.2239 23 15.5C23 15.7761 22.7761 16 22.5 16H9.5C9.22386 16 9 15.7761 9 15.5Z" fill="#9B9B9B"/> <path d="M15 15.5C15 15.2239 15.1919 15 15.4286 15H26.5714C26.8081 15 27 15.2239 27 15.5C27 15.7761 26.8081 16 26.5714 16H15.4286C15.1919 16 15 15.7761 15 15.5Z" fill="#9B9B9B"/>
<path d="M9.5 18C9.22386 18 9 18.2239 9 18.5C9 18.7761 9.22386 19 9.5 19H22.5C22.7761 19 23 18.7761 23 18.5C23 18.2239 22.7761 18 22.5 18H9.5Z" fill="#9B9B9B"/> <path d="M15 18.5C15 18.2239 15.1919 18 15.4286 18H26.5714C26.8081 18 27 18.2239 27 18.5C27 18.7761 26.8081 19 26.5714 19H15.4286C15.1919 19 15 18.7761 15 18.5Z" fill="#9B9B9B"/>
<path d="M9 21.5C9 21.2239 9.22386 21 9.5 21H17.5C17.7761 21 18 21.2239 18 21.5C18 21.7761 17.7761 22 17.5 22H9.5C9.22386 22 9 21.7761 9 21.5Z" fill="#9B9B9B"/> <path d="M15.4286 21C15.1919 21 15 21.2239 15 21.5C15 21.7761 15.1919 22 15.4286 22H26.5714C26.8081 22 27 21.7761 27 21.5C27 21.2239 26.8081 21 26.5714 21H15.4286Z" fill="#9B9B9B"/>
<path d="M15 24.5C15 24.2239 15.199 24 15.4444 24H22.5556C22.801 24 23 24.2239 23 24.5C23 24.7761 22.801 25 22.5556 25H15.4444C15.199 25 15 24.7761 15 24.5Z" fill="#9B9B9B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 27C22.6274 27 28 21.6274 28 15C28 8.37258 22.6274 3 16 3C9.37257 3 4 8.37258 4 15C4 21.6274 9.37257 27 16 27Z" fill="#533566"/>
<path d="M24 24H8L7.07853 28.1805C6.7458 29.0769 7.51208 30 8.59093 30H23.4125C24.4913 30 25.2475 29.0769 24.9249 28.1805L24 24Z" fill="#B4ACBC"/>
<path d="M14.205 6.26449C14.085 6.21411 13.995 6.11335 13.945 6.00252L13.565 5.10579C13.495 4.96474 13.295 4.96474 13.225 5.10579L12.845 6.00252C12.795 6.12343 12.705 6.21411 12.585 6.26449L12.105 6.48615C11.965 6.55668 11.965 6.75819 12.105 6.82871L12.585 7.05038C12.705 7.10076 12.795 7.20151 12.845 7.31235L13.225 8.20907C13.295 8.35013 13.495 8.35013 13.565 8.20907L13.945 7.31235C13.995 7.19144 14.085 7.10076 14.205 7.05038L14.685 6.82871C14.825 6.75819 14.825 6.55668 14.685 6.48615L14.205 6.26449Z" fill="#FCD53F"/>
<path d="M24.12 10.8035C23.96 10.733 23.83 10.5919 23.76 10.4307L23.22 9.15113C23.12 8.94962 22.83 8.94962 22.73 9.15113L22.19 10.4307C22.12 10.5919 21.99 10.733 21.83 10.8035L21.15 11.1159C20.95 11.2166 20.95 11.5088 21.15 11.6096L21.83 11.9219C21.99 11.9924 22.12 12.1335 22.19 12.2947L22.73 13.5743C22.83 13.7758 23.12 13.7758 23.22 13.5743L23.76 12.2947C23.83 12.1335 23.96 11.9924 24.12 11.9219L24.8 11.6096C25 11.5088 25 11.2166 24.8 11.1159L24.12 10.8035Z" fill="#FCD53F"/>
<path d="M12.5861 14.0303C12.7249 14.3822 12.9838 14.6657 13.3168 14.8221L14.6948 15.477C15.1017 15.6921 15.1017 16.3079 14.6948 16.523L13.3168 17.1779C12.9931 17.3343 12.7249 17.6178 12.5861 17.9697L11.4948 20.6774C11.2913 21.1075 10.7087 21.1075 10.5052 20.6774L9.41387 17.9697C9.27515 17.6178 9.01618 17.3343 8.68323 17.1779L7.3052 16.523C6.89827 16.3079 6.89827 15.6921 7.3052 15.477L8.68323 14.8221C9.00693 14.6657 9.27515 14.3822 9.41387 14.0303L10.5052 11.3226C10.7087 10.8925 11.2913 10.8925 11.4948 11.3226L12.5861 14.0303Z" fill="#FCD53F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 24.563C14 23.3163 13.02 22.3052 11.75 22.3052L19 16.7V27.7926C19 28.4601 18.43 29 17.75 29H15.25C14.57 29 14 28.4601 14 27.7926V24.563Z" fill="#636363"/>
<path d="M22.51 22.25L22 22H29V28.06C29 28.58 28.58 29 28.06 29H24.94C24.42 29 24 28.58 24 28.06V24.67C24 23.64 23.43 22.71 22.51 22.25Z" fill="#636363"/>
<path d="M19.35 6C25.23 6 30 10.723 29.99 16.5673V27.7796C29.99 28.4543 29.44 29 28.76 29H26.2339C25.5539 29 25.0039 28.4543 25.0039 27.7796V24.5151C25.0039 23.255 23.9739 22 22.7039 22H17.5C16.49 22 15.79 22.9573 15.32 23.7908C15.2356 23.9407 15.1457 24.1025 15.0509 24.273C14.0231 26.1221 12.4234 29 11.05 29H8.31C8.28361 29 8.26 28.9972 8.23771 28.9946C8.21777 28.9923 8.19889 28.9901 8.18 28.9901C7.35 28.9107 6.96 27.9284 7.45 27.2636L9.16 24.9318C9.88 23.9594 10.07 22.6795 9.59 21.5781C8.84797 19.8763 7.16017 19.1422 5 18.8549V22.5C5 22.7783 5.07227 22.8945 5.08948 22.9152C5.09336 22.9199 5.1032 22.9318 5.13954 22.9472C5.18248 22.9654 5.29076 23 5.5 23C6.32843 23 7 23.6716 7 24.5C7 25.3285 6.32843 26 5.5 26C4.38888 26 3.43171 25.6126 2.7841 24.8349C2.17679 24.1055 2 23.2217 2 22.5V10.9909C2 8.23253 4.25 6 7.03 6H19.35Z" fill="#9B9B9B"/>
<path d="M5.5 12.5C5.77614 12.5 6 12.7239 6 13V14C6 14.2761 5.77614 14.5 5.5 14.5C5.22386 14.5 5 14.2761 5 14V13C5 12.7239 5.22386 12.5 5.5 12.5Z" fill="#1C1C1C"/>
<path d="M10 6C14.4741 6 18.2026 9.18153 18.9773 13.3758C19.1323 14.2261 18.4737 15 17.6022 15H11.3945C10.6295 15 10 14.3885 10 13.6242V6Z" fill="#D3D3D3"/>
<path d="M3.46002 19.7C3.46002 20.2 3.86002 20.6 4.36002 20.6C6.36002 20.6 7.98002 19 7.99002 17H6.19002C6.19002 18.01 5.37002 18.8 4.36002 18.8C3.86002 18.8 3.46002 19.2 3.46002 19.7Z" fill="#D3D3D3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.31133 17.6539C7.42658 18.0654 7.50535 18.3466 6.71874 18.25C6.27534 18.1955 5.881 18.1308 5.56463 18.0788C5.24553 18.0264 5.00576 17.987 4.87499 17.9844C4.70832 17.9896 4.46249 18.2844 4.74999 18.9844C5.03749 19.6844 5.38541 20.3958 5.57811 20.7031L9.17187 19.8125L7.56249 17.3594L7.26561 17.4844C7.28021 17.5428 7.29609 17.5995 7.31133 17.6539Z" fill="#580A7C"/>
<path d="M24.7125 17.6539C24.5973 18.0654 24.5185 18.3466 25.3051 18.25C25.7485 18.1955 26.1428 18.1308 26.4592 18.0788C26.7783 18.0264 27.0181 17.987 27.1488 17.9844C27.3155 17.9896 27.5613 18.2844 27.2738 18.9844C26.9863 19.6844 26.6384 20.3958 26.4457 20.7031L22.852 19.8125L24.4613 17.3594L24.7582 17.4844C24.7436 17.5428 24.7277 17.5995 24.7125 17.6539Z" fill="#580A7C"/>
<path d="M8.17722 18.841C8.33838 19.1786 8.54222 19.6056 7.375 19.875L2.76562 20.9219C2.00783 21.1249 2.11991 21.4306 2.24244 21.765C2.2614 21.8167 2.2806 21.8691 2.29687 21.9219C2.74007 23.3599 6 25.8125 8.15625 25.5L12.3125 21.7812L8.42188 18.5938L8.15625 18.7969C8.16307 18.8114 8.17011 18.8261 8.17722 18.841Z" fill="#7D0AB3"/>
<path d="M23.977 18.841C23.8159 19.1786 23.612 19.6056 24.7792 19.875L29.3886 20.9219C30.1464 21.1249 30.0343 21.4306 29.9118 21.765C29.8928 21.8167 29.8736 21.8691 29.8574 21.9219C29.4142 23.3599 26.1542 25.8125 23.998 25.5L19.8417 21.7812L23.7324 18.5938L23.998 18.7969C23.9912 18.8114 23.9841 18.8261 23.977 18.841Z" fill="#7D0AB3"/>
<path d="M20.9627 20.7275L26.988 22.1259C27.1456 22.1176 27.4162 22.2503 27.238 22.8476C27.0152 23.5942 26.4704 25.1452 22.9577 25.1347C20.1475 25.1264 19.7181 22.5055 19.8547 21.196L20.9627 20.7275Z" fill="#9B2ECE"/>
<path d="M11.25 21.2812L5.12499 22.0937C4.96874 22.0728 4.68749 22.1749 4.81249 22.7499C4.96874 23.4687 5.37499 24.9687 8.87499 25.2499C11.675 25.4749 12.3333 23.052 12.3125 21.8124L11.25 21.2812Z" fill="#9B2ECE"/>
<path d="M10.9531 21.3594C11.1198 21.5521 11.3 22.0656 10.6875 22.5781C10.075 23.0906 6.94271 25.8542 5.45313 27.1719C5.16476 27.427 5.28134 27.5969 5.49493 27.9083C5.5117 27.9328 5.52906 27.9581 5.54688 27.9844C6.54939 29.4643 11.6719 31.9219 15.6875 26.7656C15.8022 26.6183 15.8926 26.5183 16 26.5163V26.5281C16.0118 26.524 16.0233 26.521 16.0345 26.519C16.0458 26.521 16.0573 26.524 16.0691 26.5281V26.5163C16.1765 26.5183 16.2669 26.6183 16.3816 26.7656C20.3972 31.9219 25.5197 29.4643 26.5222 27.9844C26.54 27.9581 26.5574 27.9328 26.5741 27.9083C26.7877 27.5969 26.9043 27.427 26.6159 27.1719C25.1264 25.8542 21.9941 23.0906 21.3816 22.5781C20.7691 22.0656 20.9493 21.5521 21.1159 21.3594L20.0144 20.9766L16.0345 22.2623L12.0547 20.9766L10.9531 21.3594Z" fill="#7D0AB3"/>
<path d="M6.375 12C6.375 7.02944 10.4044 3 15.375 3H17C21.9706 3 26 7.02944 26 12V13.625C26 18.5956 21.9706 22.625 17 22.625H15.375C10.4044 22.625 6.375 18.5956 6.375 13.625V12Z" fill="#952CC6"/>
<path d="M8.71875 15.25C9.66799 15.25 10.4375 14.4805 10.4375 13.5312C10.4375 12.582 9.66799 11.8125 8.71875 11.8125C7.76951 11.8125 7 12.582 7 13.5312C7 14.4805 7.76951 15.25 8.71875 15.25Z" fill="#1C1C1C"/>
<path d="M23.7187 15.25C24.6679 15.25 25.4374 14.4805 25.4374 13.5312C25.4374 12.582 24.6679 11.8125 23.7187 11.8125C22.7695 11.8125 22 12.582 22 13.5312C22 14.4805 22.7695 15.25 23.7187 15.25Z" fill="#1C1C1C"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,4 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0372 20.8626C13.0372 22.1648 14.1823 23.2221 15.5924 23.2221C17.0025 23.2221 18.1475 22.1648 18.1475 20.8528V19.1506C18.1475 19.0395 18.2212 18.9421 18.3271 18.9086C21.6766 17.8508 24 14.9188 24 11.5616V10.3084C24 6.0691 20.3104 2.53471 15.7726 2.4466C13.4931 2.39764 11.3409 3.19068 9.70813 4.65926C8.08598 6.12784 7.18478 8.10553 7.18478 10.2105C7.18478 11.5224 8.34043 12.5798 9.75054 12.5798C11.1606 12.5798 12.3057 11.5224 12.3057 10.2203C12.3057 9.39788 12.6556 8.62443 13.2917 8.04679C13.9278 7.46915 14.7654 7.15585 15.6666 7.17543C17.4478 7.21459 18.8897 8.62443 18.8897 10.3182V11.5616C18.8897 13.0302 17.7659 14.2932 16.2073 14.5575C14.3731 14.8708 13.0372 16.3492 13.0372 18.0723V20.8626Z" fill="#F8312F"/> <path d="M13.0372 20.8626C13.0372 22.1648 14.1823 23.2221 15.5924 23.2221C17.0025 23.2221 18.1475 22.1648 18.1475 20.8528V19.1506C18.1475 19.0395 18.2212 18.9421 18.3271 18.9086C21.6766 17.8508 24 14.9188 24 11.5616V10.3084C24 6.0691 20.3104 2.53471 15.7726 2.4466C13.4931 2.39764 11.3409 3.19068 9.70813 4.65926C8.08598 6.12784 7.18478 8.10553 7.18478 10.2105C7.18478 11.5224 8.34043 12.5798 9.75054 12.5798C11.1606 12.5798 12.3057 11.5224 12.3057 10.2203C12.3057 9.39788 12.6556 8.62443 13.2917 8.04679C13.9278 7.46915 14.7654 7.15585 15.6666 7.17543C17.4478 7.21459 18.8897 8.62443 18.8897 10.3182V11.5616C18.8897 13.0302 17.7659 14.2932 16.2073 14.5575C14.3731 14.8708 13.0372 16.3492 13.0372 18.0723V20.8626Z" fill="#6B438B"/>
<path d="M15.5 30C16.8807 30 18 28.8807 18 27.5C18 26.1193 16.8807 25 15.5 25C14.1193 25 13 26.1193 13 27.5C13 28.8807 14.1193 30 15.5 30Z" fill="#F8312F"/> <path d="M15.5 30C16.8807 30 18 28.8807 18 27.5C18 26.1193 16.8807 25 15.5 25C14.1193 25 13 26.1193 13 27.5C13 28.8807 14.1193 30 15.5 30Z" fill="#6B438B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 992 B

BIN
src/front/icons/pattern.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 13.3871H2.1188L2.57078 14.1436L12.1982 30.2565L12.3437 30.5H12.6274H14.9529H15.2564L15.3965 30.2308L29.4436 3.23077L29.8238 2.5H29H25.6087H25.3024L25.1633 2.77281L13.875 24.903L6.45111 13.6124L6.30297 13.3871H6.03333H3Z" fill="white" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 13.3871H2.1188L2.57078 14.1436L12.1982 30.2565L12.3437 30.5H12.6274H14.9529H15.2564L15.3965 30.2308L29.4436 3.23077L29.8238 2.5H29H25.6087H25.3024L25.1633 2.77281L13.875 24.903L6.45111 13.6124L6.30297 13.3871H6.03333H3Z" fill="black" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -1,13 +1,12 @@
{ {
"name": "english", "name": "english",
"substrings": { "substrings": {
"ContactLink": "<a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">file an issue on github</a>" "ContactLink": "<a class=\"text-backdrop italic\" href=\"{repo}\" target=\"_blank\">create an issue on github</a>"
}, },
"strings": { "strings": {
"LinkInput": "paste the link here", "LinkInput": "paste the link here",
"AboutSummary": "{appName} is your go-to place for social media downloads. zero ads, trackers, or any other creepy bullshit. simply paste a share link and you're ready to rock!", "AboutSummary": "{appName} is your go-to place for downloads from social and media platforms. zero ads, trackers, or other creepy bullshit. simply paste a share link and you're ready to rock!",
"AboutSupportedServices": "currently supported services:", "EmbedBriefDescription": "save what you love without ads, trackers, or other creepy bullshit.",
"EmbedBriefDescription": "save content from social media without annoyances",
"MadeWithLove": "made with <3 by wukko", "MadeWithLove": "made with <3 by wukko",
"AccessibilityInputArea": "link input area", "AccessibilityInputArea": "link input area",
"AccessibilityOpenAbout": "open about popup", "AccessibilityOpenAbout": "open about popup",
@ -20,48 +19,40 @@
"TitlePopupError": "uh-oh...", "TitlePopupError": "uh-oh...",
"TitlePopupChangelog": "what's new?", "TitlePopupChangelog": "what's new?",
"TitlePopupDonate": "support {appName}", "TitlePopupDonate": "support {appName}",
"TitlePopupDownload": "download", "TitlePopupDownload": "how to continue?",
"ErrorSomethingWentWrong": "something went wrong and i couldn't get anything for you. you can try again, but if issue persists, please {ContactLink}.", "ErrorSomethingWentWrong": "something went wrong and i couldn't get anything for you. try again, but if issue persists, {ContactLink}.",
"ErrorUnsupported": "it seems like this service is not supported yet or your link is invalid.", "ErrorUnsupported": "it seems like this service is not supported yet or your link is invalid. have you pasted the right link?",
"ErrorBrokenLink": "{s} is supported, but something is wrong with your link. maybe you didn't copy it fully?", "ErrorBrokenLink": "{s} is supported, but something is wrong with your link. maybe you didn't copy it fully?",
"ErrorNoLink": "i can't guess what you want to download! please give me a link.", "ErrorNoLink": "i can't guess what you want to download! please give me a link :(",
"ErrorPageRenderFail": "something went wrong and page couldn't render. if it's a recurring or critical issue, please {ContactLink}. it'd be useful if you provided current commit hash ({s}) and error recreation steps. thank you in advance :D", "ErrorPageRenderFail": "if you're reading this, then there's something wrong with the page renderer. please {ContactLink}. make sure to provide the domain this error is present on and current commit hash ({s}). thank you in advance :D",
"ErrorRateLimit": "you're making too many requests. calm down and try again in a bit.", "ErrorRateLimit": "you're making too many requests. try again in a minute!",
"ErrorCouldntFetch": "couldn't get any info about your link. check if it's correct and try again.", "ErrorCouldntFetch": "i couldn't find anything about this link. check if it works and try again! some content may be region restricted, so keep that in mind.",
"ErrorLengthLimit": "current length limit is {s} minutes. video that you tried to download is longer than {s} minutes. pick something else!", "ErrorLengthLimit": "i can't process videos longer than {s} minutes, so pick something shorter instead!",
"ErrorBadFetch": "an error occurred when i tried to get info about your link. are you sure it works? check if it does, and try again.", "ErrorBadFetch": "something went wrong when i tried getting info about your link. are you sure it works? check if it does, and try again.",
"ErrorCorruptedStream": "this download is unfortunately corrupted. try again or try a different format and resolution.",
"ErrorNoInternet": "there's no internet or {appName} api is down. check your connection and try again.", "ErrorNoInternet": "there's no internet or {appName} api is down. check your connection and try again.",
"ErrorCantConnectToServiceAPI": "i couldn't connect to {s} api. seems like either {s} is down or {appName} server ip got blocked. try again later.", "ErrorCantConnectToServiceAPI": "i couldn't connect to the service api. maybe it's down, or {appName} got blocked. try again, but if error persists, {ContactLink}.",
"ErrorEmptyDownload": "i don't see anything i could download from here. try a different link.", "ErrorEmptyDownload": "i don't see anything i could download by your link. try a different one!",
"ErrorLiveVideo": "i can't look into future and download a video live of which is ongoing. wait for the stream to finish and try again!", "ErrorLiveVideo": "this is a live video, i am yet to learn how to look into future. wait for the stream to finish and try again!",
"SettingsAppearanceSubtitle": "appearance", "SettingsAppearanceSubtitle": "appearance",
"SettingsThemeSubtitle": "theme", "SettingsThemeSubtitle": "theme",
"SettingsFormatSubtitle": "download format", "SettingsFormatSubtitle": "format",
"SettingsQualitySubtitle": "quality", "SettingsQualitySubtitle": "quality",
"SettingsThemeAuto": "auto", "SettingsThemeAuto": "auto",
"SettingsThemeLight": "light", "SettingsThemeLight": "light",
"SettingsThemeDark": "dark", "SettingsThemeDark": "dark",
"SettingsQualitySwitchMax": "max",
"SettingsQualitySwitchHigh": "high",
"SettingsQualitySwitchMedium": "medium",
"SettingsQualitySwitchLow": "low",
"SettingsQualitySwitchLowest": "lowest",
"SettingsKeepDownloadButton": "keep &gt;&gt; visible", "SettingsKeepDownloadButton": "keep &gt;&gt; visible",
"AccessibilityKeepDownloadButton": "keep the download button always visible", "AccessibilityKeepDownloadButton": "keep the download button always visible",
"SettingsEnableDownloadPopup": "ask for a way to save", "SettingsEnableDownloadPopup": "ask how to save",
"AccessibilityEnableDownloadPopup": "ask what to do with downloads", "AccessibilityEnableDownloadPopup": "ask what to do with downloads",
"SettingsFormatDescription": "select webm if you want max quality available. webm videos are usually higher bitrate, but ios devices can't play them natively.", "SettingsQualityDescription": "if selected quality isn't available, closest one is used instead.",
"SettingsQualityDescription": "if selected quality isn't available, closest one gets picked instead.\nif you want to post a youtube video on social media, select a combination of mp4 and 720p.",
"LinkGitHubIssues": "&gt;&gt; report issues and check out the source code on github",
"LinkGitHubChanges": "&gt;&gt; see previous commits and contribute on github", "LinkGitHubChanges": "&gt;&gt; see previous commits and contribute on github",
"NoScriptMessage": "{appName} uses javascript for api requests and interactive interface. you have to allow javascript to use this site. i don't have any ads or trackers, pinky promise.", "NoScriptMessage": "{appName} uses javascript for api requests and interactive interface. you have to allow javascript to use this site. there are no pesty scripts, pinky promise.",
"DownloadPopupDescriptionIOS": "press and hold the download button, hide the video preview, and then select \"download linked file\" to save.", "DownloadPopupDescriptionIOS": "press and hold the download button, hide the video preview, and then select \"download linked file\" to save.",
"DownloadPopupDescription": "download button opens a new tab with requested file. you can disable this popup in settings.", "DownloadPopupDescription": "download button opens a new tab with requested file. you can disable this popup in settings.",
"DownloadPopupWayToSave": "pick a way to save", "DownloadPopupWayToSave": "pick a way to save",
"ClickToCopy": "press to copy", "ClickToCopy": "press to copy",
"Download": "download", "Download": "download",
"CopyURL": "copy url", "CopyURL": "copy",
"AboutTab": "about", "AboutTab": "about",
"ChangelogTab": "changelog", "ChangelogTab": "changelog",
"DonationsTab": "donations", "DonationsTab": "donations",
@ -70,46 +61,63 @@
"SettingsOtherTab": "other", "SettingsOtherTab": "other",
"ChangelogLastMajor": "current version & commit", "ChangelogLastMajor": "current version & commit",
"AccessibilityModeToggle": "toggle download mode", "AccessibilityModeToggle": "toggle download mode",
"DonateLinksDescription": "donation links open in a new tab. this is the best way to donate if you want me to receive your donation directly.", "DonateLinksDescription": "this is the best way to donate if you want me to receive your donation directly.",
"SettingsAudioFormatBest": "best", "SettingsAudioFormatBest": "best",
"SettingsAudioFormatDescription": "when best format is selected, you get audio in best quality available, because it's not re-encoded. everything else will be re-encoded.", "SettingsAudioFormatDescription": "when \"best\" format is selected, you get audio the way it is on service's side. it's not re-encoded. everything else will be re-encoded.",
"Keyphrase": "save what you love", "Keyphrase": "save what you love",
"SettingsRemoveWatermark": "disable watermark", "SettingsRemoveWatermark": "disable watermark",
"ErrorPopupCloseButton": "got it", "ErrorPopupCloseButton": "got it",
"ErrorLengthAudioConvert": "current length limit for audio conversion is {s} minutes. pick \"best\" format if you want to avoid limitations.", "ErrorLengthAudioConvert": "i can't convert audio longer than {s} minutes. pick \"best\" format if you want to avoid limitations!",
"SettingsAudioFullTikTok": "download full audio", "SettingsAudioFullTikTok": "full audio",
"SettingsAudioFullTikTokDescription": "downloads original audio or sound used in video without any additional changes by the video author.", "SettingsAudioFullTikTokDescription": "downloads original sound used in the video without any additional changes by the post's author.",
"ErrorCantGetID": "i couldn't get the full info from the shortened link. make sure it works or try a full one.", "ErrorCantGetID": "i couldn't get the full info from the shortened link. make sure it works or try a full one! if issue persists, {ContactLink}.",
"ErrorNoVideosInTweet": "i couldn't find any videos or gifs in this tweet. try another one!", "ErrorNoVideosInTweet": "there are no videos or gifs in this tweet, try another one!",
"ImagePickerTitle": "pick images to download", "ImagePickerTitle": "pick images to download",
"ImagePickerDownloadAudio": "download audio", "ImagePickerDownloadAudio": "download audio",
"ImagePickerExplanationPC": "right click an image to save it.", "ImagePickerExplanationPC": "right click an image to save it.",
"ImagePickerExplanationPhone": "press and hold an image to save it.", "ImagePickerExplanationPhone": "press and hold an image to save it.",
"ErrorNoUrlReturned": "server didn't return a download link. this should never happen. reload the page and try again, but if it doesn't help, {ContactLink}.", "ErrorNoUrlReturned": "i didn't get a download link from the server. this should never happen. try again, but if it still doesn't work, {ContactLink}.",
"ErrorUnknownStatus": "i received a response i can't process. most likely something with status is wrong. this should never happen. reload the page and try again, but if it doesn't help, {ContactLink}.", "ErrorUnknownStatus": "i received a response i can't process. this should never happen. try again, but if it still doesn't work, {ContactLink}.",
"PasteFromClipboard": "paste from clipboard", "PasteFromClipboard": "paste and download",
"FollowTwitter": "follow {appName}'s twitter account for polls, updates, and more: <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">@justusecobalt</a>",
"ChangelogOlder": "previous versions", "ChangelogOlder": "previous versions",
"ChangelogPressToExpand": "press to expand", "ChangelogPressToExpand": "expand",
"Miscellaneous": "miscellaneous", "Miscellaneous": "miscellaneous",
"ModeToggleAuto": "auto mode", "ModeToggleAuto": "auto mode",
"ModeToggleAudio": "audio mode", "ModeToggleAudio": "audio mode",
"SettingsDisableNotifications": "hide notification dots", "SettingsDisableNotifications": "hide notifications",
"MediaPickerTitle": "pick what to save", "MediaPickerTitle": "pick what to save",
"MediaPickerExplanationPC": "click or right click to download what you want.", "MediaPickerExplanationPC": "click or right click to download what you want.",
"MediaPickerExplanationPhone": "press or press and hold to download what you want.", "MediaPickerExplanationPhone": "press or press and hold to download what you want.",
"MediaPickerExplanationPhoneIOS": "press and hold, hide the preview, and then select \"download linked file\" to save.", "MediaPickerExplanationPhoneIOS": "press and hold, hide the preview, and then select \"download linked file\" to save.",
"TwitterSpaceWasntRecorded": "this twitter space wasn't recorded, so there's nothing to download. try another one!", "TwitterSpaceWasntRecorded": "this twitter space wasn't recorded, so there's nothing to download. try another one!",
"ErrorCantProcess": "i couldn't process your request :(\nyou can try again, but if issue persists, please {ContactLink}.", "ErrorCantProcess": "i couldn't process your request :(\nyou can try again, but if issue persists, please {ContactLink}.",
"ChangelogPressToHide": "press to collapse", "ChangelogPressToHide": "collapse",
"Donate": "donate", "Donate": "donate",
"DonateSub": "help me keep it up", "DonateSub": "help me keep it up",
"DonateExplanation": "{appName} does not (and will never) serve ads or sell your data, therefore it's <span class=\"text-backdrop\">completely free to use</span>. but hey! turns out keeping up a web service used by hundreds of thousands of people is somewhat costly.\n\nif you ever found {appName} useful and want to keep it online, or simply want to thank the developer, consider chipping in! each and every cent helps and is VERY appreciated.", "DonateExplanation": "{appName} does not (and will never) serve ads or sell your data, therefore it's <span class=\"text-backdrop\">completely free to use</span>. but turns out keeping up a web service used by over 40 thousand people is somewhat costly.\n\nif you ever found {appName} useful and want to keep it online, or simply want to thank the developer, consider chipping in! each and every cent helps and is VERY appreciated :D",
"DonateVia": "donate via", "DonateVia": "donate via",
"DonateHireMe": "or, as an alternative, you can <a class=\"text-backdrop\" href=\"{s}\" target=\"_blank\">hire me</a>.", "DonateHireMe": "or you can <a class=\"text-backdrop italic\" href=\"{s}\" target=\"_blank\">hire me</a>",
"SettingsVideoMute": "mute audio", "SettingsVideoMute": "mute audio",
"SettingsVideoMuteExplanation": "disables audio in downloaded video when possible. ignored when audio mode is on or service only supports audio.", "SettingsVideoMuteExplanation": "removes audio from video downloads when possible.",
"SettingsVideoGeneral": "general", "ErrorSoundCloudNoClientId": "i couldn't get the temporary token that's required to download songs from soundcloud. try again, but if issue persists, {ContactLink}.",
"ErrorSoundCloudNoClientId": "couldn't find client_id that is required to fetch audio data from soundcloud. try again, and if issue persists, {ContactLink}." "CollapseServices": "supported services",
"CollapseSupport": "support & source code",
"CollapsePrivacy": "privacy policy",
"ServicesNote": "this list is not final and keeps expanding over time, make sure to check it once in a while!",
"FollowSupport": "follow {appName} on mastodon or twitter for support, polls, news, and more:",
"SupportNote": "please note that questions and issues may take a while to respond to, there's only one person managing everything.",
"SourceCode": "report issues, explore source code, star or fork the repo:",
"PrivacyPolicy": "{appName}'s privacy policy is simple: no data about you is ever collected or stored. zero, zilch, nada, nothing.\nwhat you download is your business, not mine.\n\nsome non-backtraceable data does get temporarily stored when requested download requires live render. it's necessary for that feature to function.\n\nin that case, <span class=\"text-backdrop\">salted sha256 hash of your ip address</span> and information about requested stream are temporarily stored in server's RAM for <span class=\"text-backdrop\">2 minutes</span>. after 2 minutes all previously stored information is permanently removed. hash of your ip address is <span class=\"text-backdrop\">used for limiting stream access only to you</span>.\nno one (even me) has access to this data, because official {appName} codebase doesn't provide a way to read it outside of processing functions in the first place.\n\nyou can check {appName}'s <a class=\"text-backdrop italic\" href=\"{repo}\" target=\"_blank\">github repo</a> yourself and see that everything is as stated.",
"ErrorYTUnavailable": "this youtube video is unavailable or age restricted. i am currently unable to download videos with sensitive content. try another one!",
"ErrorYTTryOtherCodec": "i couldn't find anything to download with your settings. try another codec or quality!\n\nnote: youtube api sometimes acts unexpectedly. blame google for this, not me.",
"SettingsCodecSubtitle": "youtube codec",
"SettingsCodecDescription": "h264: generally better player support, but quality tops out at 1080p.\nav1: low player support, but supports 8k & HDR.\nvp9: usually highest bitrate, preserves most detail. supports 4k & HDR.\n\npick h264 if you want best editor/player/social media compatibility.",
"SettingsAudioDub": "youtube audio track",
"SettingsAudioDubDescription": "defines which audio track will be used. if dubbed track isn't available, original video language is used instead.\n\noriginal: original video language is used.\nauto: default browser (and {appName}) language is used.",
"SettingsDubDefault": "original",
"SettingsDubAuto": "auto",
"SettingsVimeoPrefer": "vimeo downloads type",
"SettingsVimeoPreferDescription": "progressive: direct file link to vimeo's cdn. max quality is 1080p.\ndash: video and audio are merged by {appName} into one file. max quality is 4k.\n\npick \"progressive\" if you want best editor/player/social media compatibility. if progressive download isn't available, dash is used instead.",
"ShareURL": "share"
} }
} }

View file

@ -1,14 +1,13 @@
{ {
"name": "русский", "name": "русский",
"substrings": { "substrings": {
"ContactLink": "<a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">напиши об этом на github</a>" "ContactLink": "<a class=\"text-backdrop italic\" href=\"{repo}\" target=\"_blank\">напиши об этом на github (можно на русском)</a>"
}, },
"strings": { "strings": {
"LinkInput": "вставь ссылку сюда", "LinkInput": "вставь ссылку сюда",
"AboutSummary": "{appName} — твой друг при скачивании контента из соц. сетей. никакой рекламы или трекеров. вставляешь ссылку и получаешь файл. ничего лишнего.", "AboutSummary": "{appName} - твой друг при скачивании контента из соцсетей и других сервисов. никакой рекламы, трекеров и прочего мусора. вставляешь ссылку и получаешь файл. всё. ничего лишнего.",
"AboutSupportedServices": "что поддерживается:", "EmbedBriefDescription": "сохраняй то, что любишь. без рекламы, трекеров и лишней мороки.",
"EmbedBriefDescription": "сохраняй что хочешь, без мороки и вторжения в личное пространство", "MadeWithLove": "сделано wukko, с <3",
"MadeWithLove": "сделано с <3 wukko",
"AccessibilityInputArea": "зона вставки ссылки", "AccessibilityInputArea": "зона вставки ссылки",
"AccessibilityOpenAbout": "открыть окно с инфой", "AccessibilityOpenAbout": "открыть окно с инфой",
"AccessibilityDownloadButton": "кнопка скачивания", "AccessibilityDownloadButton": "кнопка скачивания",
@ -20,48 +19,40 @@
"TitlePopupError": "опаньки...", "TitlePopupError": "опаньки...",
"TitlePopupChangelog": "что нового?", "TitlePopupChangelog": "что нового?",
"TitlePopupDonate": "поддержи {appName}", "TitlePopupDonate": "поддержи {appName}",
"TitlePopupDownload": "скачивание", "TitlePopupDownload": "как продолжить?",
"ErrorSomethingWentWrong": "что-то пошло совсем не так, и у меня не получилось ничего для тебя достать. ты можешь попробовать ещё раз, но если так и не получится, {ContactLink}.", "ErrorSomethingWentWrong": "что-то пошло совсем не так и у меня не получилось ничего для тебя достать. попробуй ещё раз, но если так и не получится, {ContactLink}.",
"ErrorUnsupported": "с твоей ссылкой что-то не так, или же этот сервис ещё не поддерживается. может быть, ты вставил не ту ссылку?", "ErrorUnsupported": "с твоей ссылкой что-то не так, или же этот сервис ещё не поддерживается. может быть, ты вставил не ту ссылку?",
"ErrorBrokenLink": "{s} поддерживается, но с твоей ссылкой что-то не так. может быть, ты её не полностью скопировал?", "ErrorBrokenLink": "{s} поддерживается, но с твоей ссылкой что-то не так. может быть, ты её не полностью скопировал?",
"ErrorNoLink": "я не гадалка и не могу угадывать, что ты хочешь скачать. попробуй в следующий раз вставить ссылку.", "ErrorNoLink": "пока что я не умею угадывать, что ты хочешь скачать. дай мне, пожалуйста, ссылку :(",
"ErrorPageRenderFail": "что-то пошло не так и у меня не получилось срендерить страницу. если это повторится ещё раз, пожалуйста, {ContactLink}. также приложи хэш текущего коммита ({s}) с действиями для повторения этой ошибки. можно на русском языке. спасибо :)", "ErrorPageRenderFail": "если ты видишь этот текст, значит что-то не так с рендером страницы. пожалуйста, {ContactLink}. также приложи домен, на котором присутсвует эта ошибка, и хэш коммита ({s}). спасибо :)",
"ErrorRateLimit": "ты делаешь слишком много запросов. успокойся и попробуй ещё раз через несколько минут.", "ErrorRateLimit": "ты делаешь слишком много запросов. попробуй ещё раз через минуту!",
"ErrorCouldntFetch": "мне не удалось получить инфу о твоей ссылке. проверь её и попробуй ещё раз.", "ErrorCouldntFetch": "у меня не получилось ничего найти по этой ссылке. убедись, что она работает, и попробуй ещё раз. некоторый контент может быть залочен на регион.",
"ErrorLengthLimit": "твоё видео длиннее чем {s} минут(ы). это превышает текущий лимит. скачай что-нибудь покороче, а не экранизацию \"войны и мира\".", "ErrorLengthLimit": "я не могу обрабатывать видео длиннее чем {s} минут(ы), так что скачай что-нибудь покороче!",
"ErrorBadFetch": "произошла ошибка при получении инфы о твоей ссылке. ты уверен, что она работает? проверь её, и попробуй ещё раз.", "ErrorBadFetch": "произошла какая-то ошибка при получении данных по твоей ссылке. убедись, что она работает, и попробуй ещё раз.",
"ErrorCorruptedStream": "этот файл сломан на стороне {s}. ты можешь попробовать ещё раз, но если не получится, то попробуй другой формат и разрешение.", "ErrorNoInternet": "не получилось подключиться к серверу. проверь подключение к интернету и попробуй ещё раз!",
"ErrorNoInternet": "кажется, нет подключения к интернету. возможно лежит сервер {appName}. в любом случае, проверь подключение к интернету и попробуй ещё раз.", "ErrorCantConnectToServiceAPI": "у меня не получилось подключиться к серверу этого сервиса. возможно он лежит, или же {appName} заблокировали. попробуй ещё раз, но если так и не получится, {ContactLink}.",
"ErrorCantConnectToServiceAPI": "у меня не получилось подключиться к серверу {s}. скорее всего {s} лежит, или же ip адрес {appName} добавили в чёрный список. попробуй ещё раз чуть позже.",
"ErrorEmptyDownload": "я не нашёл того, что могу скачать. попробуй другую ссылку!", "ErrorEmptyDownload": "я не нашёл того, что могу скачать. попробуй другую ссылку!",
"ErrorLiveVideo": не гадалка, и не умею заглядывать в будущее. дождись окончания прямого эфира и попробуй ещё раз чуть позже.", "ErrorLiveVideo": пока что не умею заглядывать в будущее, поэтому дождись окончания прямого эфира, и потом уже скачивай видео!",
"SettingsAppearanceSubtitle": "внешний вид", "SettingsAppearanceSubtitle": "внешний вид",
"SettingsThemeSubtitle": "тема", "SettingsThemeSubtitle": "тема",
"SettingsFormatSubtitle": "формат загрузок", "SettingsFormatSubtitle": "формат",
"SettingsQualitySubtitle": "качество", "SettingsQualitySubtitle": "качество",
"SettingsThemeAuto": "авто", "SettingsThemeAuto": "авто",
"SettingsThemeLight": "светлая", "SettingsThemeLight": "светлая",
"SettingsThemeDark": "тёмная", "SettingsThemeDark": "тёмная",
"SettingsQualitySwitchMax": "макс", "SettingsKeepDownloadButton": "всегда показывать &gt;&gt;",
"SettingsQualitySwitchHigh": "высокое", "AccessibilityKeepDownloadButton": "всегда показывать кнопку скачивания на экране",
"SettingsQualitySwitchMedium": "среднее", "SettingsEnableDownloadPopup": "выбор метода скачивания",
"SettingsQualitySwitchLow": "низкое",
"SettingsQualitySwitchLowest": "худшее",
"SettingsKeepDownloadButton": "оставлять &gt;&gt; на экране",
"AccessibilityKeepDownloadButton": "оставлять кнопку скачивания на экране",
"SettingsEnableDownloadPopup": "спрашивать, что делать при скачивании",
"AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками", "AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками",
"SettingsFormatDescription": "выбирай webm, если хочешь максимальное качество. у webm видео битрейт обычно выше, но устройства на ios не могут проигрывать их без сторонних приложений.", "SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.",
"SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.\nесли ты хочешь опубликовать видео с youtube где-то в соц. сетях, то выбирай комбинацию из mp4 и 720p.",
"LinkGitHubIssues": "&gt;&gt; сообщай о проблемах и смотри исходный код на github",
"LinkGitHubChanges": "&gt;&gt; смотри предыдущие изменения на github", "LinkGitHubChanges": "&gt;&gt; смотри предыдущие изменения на github",
"NoScriptMessage": "{appName} использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких трекеров или рекламы, обещаю.", "NoScriptMessage": "{appName} использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких зловредных скриптов, обещаю.",
"DownloadPopupDescriptionIOS": "зажми кнопку \"скачать\", затем скрой превью видео и выбери \"загрузить файл по ссылке\" в появившемся окне.", "DownloadPopupDescriptionIOS": "зажми кнопку \"скачать\", затем скрой превью и выбери \"загрузить файл по ссылке\" в появившемся окне.",
"DownloadPopupDescription": "кнопка скачивания открывает новое окно с файлом. ты можешь отключить выбор метода сохранения файла в настройках.", "DownloadPopupDescription": "кнопка скачивания открывает новое окно с файлом. ты можешь отключить выбор метода скачивания файла в настройках.",
"DownloadPopupWayToSave": "выбери, как сохранить", "DownloadPopupWayToSave": "выбери, как сохранить",
"ClickToCopy": "нажми, чтобы скопировать", "ClickToCopy": "нажми, чтобы скопировать",
"Download": "скачать", "Download": "скачать",
"CopyURL": "скопировать ссылку", "CopyURL": "скопировать",
"AboutTab": "о {appName}", "AboutTab": "о {appName}",
"ChangelogTab": "изменения", "ChangelogTab": "изменения",
"DonationsTab": "донаты", "DonationsTab": "донаты",
@ -70,46 +61,63 @@
"SettingsOtherTab": "другое", "SettingsOtherTab": "другое",
"ChangelogLastMajor": "текущая версия и коммит (на английском)", "ChangelogLastMajor": "текущая версия и коммит (на английском)",
"AccessibilityModeToggle": "переключить режим скачивания", "AccessibilityModeToggle": "переключить режим скачивания",
"DonateLinksDescription": "ссылки на донаты открываются в новой вкладке. это наилучший способ отправить донат, если ты хочешь, чтобы я получил его напрямую.", "DonateLinksDescription": "это лучший способ отправить донат, если ты хочешь, чтобы я получил его лично.",
"SettingsAudioFormatBest": "лучший", "SettingsAudioFormatBest": "лучший",
"SettingsAudioFormatDescription": "когда выбран \"лучший\" формат, ты получишь аудио лучшего качества, так как оно не будет сконвертировано. если же выбрано что-то другое, то аудио будет немного сжато.", "SettingsAudioFormatDescription": "когда выбран \"лучший\", ты получишь аудио без каких-либо изменений. такое, какое оно есть на стороне сервиса. если же выбрано что-то другое, то аудио будет немного сжато.",
"Keyphrase": "сохраняй то, что любишь", "Keyphrase": "сохраняй то, что любишь",
"SettingsRemoveWatermark": "убрать ватермарку", "SettingsRemoveWatermark": "убирать ватермарку",
"ErrorPopupCloseButton": "ясно", "ErrorPopupCloseButton": "ясно",
"ErrorLengthAudioConvert": "я не могу конвертировать аудио дольше чем {s} минут(ы). выбери \"лучший\" формат аудио, чтобы избежать ограничения.", "ErrorLengthAudioConvert": "я не могу конвертировать аудио дольше чем {s} минут(ы). выбери \"лучший\" формат, чтобы обойти ограничения.",
"SettingsAudioFullTikTok": "скачивать полное аудио", "SettingsAudioFullTikTok": "полное аудио",
"SettingsAudioFullTikTokDescription": "скачивает оригинальный звук, который использован в видео, без каких-либо изменений от автора видео.", "SettingsAudioFullTikTokDescription": "скачивает оригинальный звук, использованный в видео. без каких-либо изменений от автора поста.",
"ErrorCantGetID": "у меня не получилось достать инфу по этой короткой ссылке. попробуй полную ссылку, или же попробуй позже.", "ErrorCantGetID": "у меня не получилось достать инфу по этой короткой ссылке. попробуй полную ссылку, а если так и не получится, то {ContactLink}.",
"ErrorNoVideosInTweet": "в этом твите нет ни видео, ни гифок. попробуй другой!", "ErrorNoVideosInTweet": "в этом твите нет ни видео, ни гифок. попробуй другой!",
"ImagePickerTitle": "выбери картинки для скачивания", "ImagePickerTitle": "выбери картинки для скачивания",
"ImagePickerDownloadAudio": "скачать аудио", "ImagePickerDownloadAudio": "скачать звук",
"ImagePickerExplanationPC": "нажми правой кнопкой мыши на изображение, чтобы его сохранить.", "ImagePickerExplanationPC": "нажми правой кнопкой мыши на картинку, чтобы её сохранить.",
"ImagePickerExplanationPhone": "зажми и удерживай изображение, чтобы его сохранить.", "ImagePickerExplanationPhone": "зажми и удерживай картинку, чтобы её сохранить.",
"ErrorNoUrlReturned": "я не получил ссылку для скачивания от сервера. такого происходить не должно. перезагрузи страницу, а если не поможет, то {ContactLink}.", "ErrorNoUrlReturned": "я не получил ссылку для скачивания от сервера. такого происходить не должно. попробуй ещё раз, а если не поможет, то {ContactLink}.",
"ErrorUnknownStatus": "сервер ответил мне чем-то непонятным. такого происходить не должно. перезагрузи страницу, а если не поможет, то {ContactLink}.", "ErrorUnknownStatus": "сервер ответил мне чем-то непонятным. такого происходить не должно. попробуй ещё раз, а если не поможет, то {ContactLink}.",
"PasteFromClipboard": "вставить из буфера обмена", "PasteFromClipboard": "вставить и скачать",
"FollowTwitter": "а ещё, в твиттере {appName} есть опросы, новости, и многое другое: <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">@justusecobalt</a>",
"ChangelogOlder": "предыдущие версии (на английском)", "ChangelogOlder": "предыдущие версии (на английском)",
"ChangelogPressToExpand": "нажми, чтобы раскрыть", "ChangelogPressToExpand": "раскрыть",
"Miscellaneous": "разное", "Miscellaneous": "разное",
"ModeToggleAuto": "авто режим", "ModeToggleAuto": "авто режим",
"ModeToggleAudio": "аудио режим", "ModeToggleAudio": "аудио режим",
"SettingsDisableNotifications": "cкрыть значки уведомлений", "SettingsDisableNotifications": "cкрыть уведомления",
"MediaPickerTitle": "выбери, что сохранить", "MediaPickerTitle": "выбери, что сохранить",
"MediaPickerExplanationPC": "кликни, чтобы скачать. также можно скачать через контекстное меню правой кнопки мыши.", "MediaPickerExplanationPC": "кликни, чтобы скачать. также можно скачать через контекстное меню правой кнопки мыши.",
"MediaPickerExplanationPhone": "нажми, или нажми и удерживай, чтобы скачать.", "MediaPickerExplanationPhone": "нажми, или нажми и удерживай, чтобы скачать.",
"MediaPickerExplanationPhoneIOS": "нажми и удерживай, затем скрой превью, и наконец выбери \"загрузить файл по ссылке\".", "MediaPickerExplanationPhoneIOS": "нажми и удерживай, затем скрой превью и выбери \"загрузить файл по ссылке\", чтобы скачать.",
"TwitterSpaceWasntRecorded": "этот twitter space не был записан, поэтому я не могу его скачать. попробуй другой!", "TwitterSpaceWasntRecorded": "мне нечего скачать, так как этот twitter space не был записан. попробуй другой!",
"ErrorCantProcess": "я не смог обработать твой запрос :(\nты можешь попробовать ещё раз, но если не поможет, то {ContactLink}.", "ErrorCantProcess": "я не смог обработать твой запрос :(\nты можешь попробовать ещё раз, но если не поможет, то {ContactLink}.",
"ChangelogPressToHide": "нажми, чтобы скрыть", "ChangelogPressToHide": "скрыть",
"Donate": "донаты", "Donate": "задонатить",
"DonateSub": "ты можешь помочь!", "DonateSub": "ты можешь помочь!",
"DonateExplanation": "{appName} не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает <span class=\"text-backdrop\">совершенно бесплатно</span>. но оказывается, что хостинг сервиса, которым пользуются сотни тысяч людей, обходится довольно дорого.\n\nесли ты хочешь, чтобы твой любимый загрузчик оставался онлайн, а разработчик не помер с голоду вместе с двумя котами, то подумай над тем, чтобы задонатить. каждый рубль поможет мне, моим котам, и {appName}!", "DonateExplanation": "{appName} не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает <span class=\"text-backdrop\">совершенно бесплатно</span>. но оказывается, что хостинг сервиса, которым пользуются более 40 тысяч людей, обходится довольно дорого.\n\nесли {appName} тебе помог и ты хочешь поблагодарить или помочь разработчику, то это можно сделать через донаты! каждый рубль помогает мне, моим котам, и {appName}! спасибо :)",
"DonateVia": "открыть", "DonateVia": "открыть",
"DonateHireMe": "или же ты можешь <a class=\"text-backdrop\" href=\"{s}\" target=\"_blank\">пригласить меня на работу</a>.", "DonateHireMe": "или же ты можешь <a class=\"text-backdrop italic\" href=\"{s}\" target=\"_blank\">пригласить меня на работу</a>",
"SettingsVideoMute": "отключить аудио", "SettingsVideoMute": "убрать аудио",
"SettingsVideoMuteExplanation": "убирает аудио при загрузке видео, когда это возможно. игнорируется если включен режим аудио или сервис поддерживает только аудио загрузки.", "SettingsVideoMuteExplanation": "убирает аудио при загрузке видео, но только когда это возможно.",
"SettingsVideoGeneral": "основные", "ErrorSoundCloudNoClientId": "мне не удалось достать временный токен, который необходим для скачивания аудио из soundcloud. попробуй ещё раз, но если так и не получится, {ContactLink}.",
"ErrorSoundCloudNoClientId": "мне не удалось достать client_id, который необходим для получения аудио из soundcloud. попробуй ещё раз, но если так и не получится, {ContactLink}." "CollapseServices": "что поддерживается?",
"CollapseSupport": "поддержка и исходный код",
"CollapsePrivacy": "политика конфиденциальности",
"ServicesNote": "этот список далеко не финальный и постоянно пополняется. заглядывай сюда почаще, тогда точно будешь знать, что поддерживается!",
"FollowSupport": "подписывайся на аккаунты {appName} на mastodon или twitter для новостей, поддержки, участия в опросах, и многого другого:",
"SupportNote": "так как я один занимаюсь разработкой и поддержкой в одиночку, время ожидания ответа может достигать нескольких часов. я отвечаю всем, не стесняйся.",
"SourceCode": "пиши о проблемах, шарься в исходнике, или же форкай репозиторий:",
"PrivacyPolicy": "политика конфиденциальности {appName} довольно проста: ничего не хранится об истории твоих действий или загрузок. совсем. даже ошибки.\nто, что ты скачиваешь - только твоё личное дело.\n\nв случаях, когда твоей загрузке требуется лайв-рендер, временно хранится неотслеживаемая информация. это необходимо для работы такого типа загрузок.\n\nв этом случае, <span class=\"text-backdrop\">sha256 хэш (с солью) твоего ip адреса</span> и данные о запрошенном стриме хранятся в ОЗУ сервера в течение <span class=\"text-backdrop\">двух минут</span>. по истечении этого периода всё стирается. хэш твоего ip адреса используется для предоставления доступа к стриму только тебе. ни у кого (даже у меня) нет доступа к временно хранящимся данным, так как оригинальный код {appName} не предоставляет такой возможности.\n\nты всегда можешь посмотреть <a class=\"text-backdrop italic\" href=\"{repo}\" target=\"_blank\">исходный код {appName}</a> и убедиться, что всё так, как описано.",
"ErrorYTUnavailable": "это видео недоступно или же ограничено по возрасту на youtube. пока что я не умею скачивать подобные видео. попробуй другое!",
"ErrorYTTryOtherCodec": "я не нашёл того, что мог бы скачать с твоими настройками. попробуй другой кодек или качество!",
"SettingsCodecSubtitle": "кодек для видео с youtube",
"SettingsCodecDescription": "h264: обширная поддержка плеерами, но макс. качество всего лишь 1080p.\nav1: слабая поддержка плеерами, но поддерживает 8k и HDR.\nvp9: обычно наиболее высокий битрейт, лучше сохраняется качество видео. поддерживает 4k и HDR.\n\nвыбирай h264, если тебе нужна наилучшая совместимость с плеерами/редакторами/соцсетями.",
"SettingsAudioDub": "звуковая дорожка для видео с youtube",
"SettingsAudioDubDescription": "определяет, какая звуковая дорожка используется при скачивании видео. если дублированная дорожка недоступна, то вместо неё используется оригинальная.\n\nоригинал: используется оригинальная дорожка.\nавто: используется язык браузера (и {appName}).",
"SettingsDubDefault": "оригинал",
"SettingsDubAuto": "авто",
"SettingsVimeoPrefer": "тип загрузок с vimeo",
"SettingsVimeoPreferDescription": "progressive: прямая ссылка на файл с сервера vimeo. максимальное качество: 1080p.\ndash: {appName} совмещает видео и аудио в один файл. максимальное качество: 4k.\n\nвыбирай \"progressive\", если тебе нужна наилучшая совместимость с плеерами/редакторами/соцсетями. если \"progressive\" файл недоступен, {appName} скачает \"dash\".",
"ShareURL": "поделиться"
} }
} }

View file

@ -5,17 +5,21 @@ import loadJson from "../modules/sub/loadJSON.js";
const locPath = './src/localization/languages' const locPath = './src/localization/languages'
let loc = {} let loc = {}
let languages = [];
export function loadLoc() { export function loadLoc() {
fs.readdir(locPath, (err, files) => { fs.readdir(locPath, (err, files) => {
if (err) return false;
files.forEach(file => { files.forEach(file => {
loc[file.split('.')[0]] = loadJson(`${locPath}/${file}`) loc[file.split('.')[0]] = loadJson(`${locPath}/${file}`);
languages.push(file.split('.')[0])
}); });
}) })
} }
loadLoc(); loadLoc();
export function replaceBase(s) { export function replaceBase(s) {
return s.replace(/\n/g, '<br/>').replace(/{appName}/g, appName).replace(/{repo}/g, repo).replace(/{bS}/g, '<div class=\"bullpadding\">').replace(/{bE}/g, '</div>').replace(/\*;/g, "&bull;"); return s.replace(/\n/g, '<br/>').replace(/{appName}/g, appName).replace(/{repo}/g, repo).replace(/\*;/g, "&bull;");
} }
export function replaceAll(lang, str, string, replacement) { export function replaceAll(lang, str, string, replacement) {
let s = replaceBase(str[string]) let s = replaceBase(str[string])
@ -41,3 +45,4 @@ export default function(lang, string, replacement) {
return `!!${string}!!` return `!!${string}!!`
} }
} }
export const languageList = languages;

View file

@ -9,34 +9,39 @@ import match from "./processing/match.js";
export async function getJSON(originalURL, lang, obj) { export async function getJSON(originalURL, lang, obj) {
try { try {
let url = decodeURIComponent(originalURL); let patternMatch, url = decodeURIComponent(originalURL),
if (!url.includes('http://')) { hostname = new URL(url).hostname.split('.'),
let hostname = url.replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."), host = hostname[hostname.length - 2];
host = hostname[hostname.length - 2], if (!url.startsWith('https://')) return apiJSON(0, { t: errorUnsupported(lang) });
patternMatch;
if (host === "youtu") { switch(host) {
case "youtu":
host = "youtube"; host = "youtube";
url = `https://youtube.com/watch?v=${url.replace("youtu.be/", "").replace("https://", "")}`; url = `https://youtube.com/watch?v=${url.replace("youtu.be/", "").replace("https://", "")}`;
} break;
if (host === "goo" && url.substring(0, 30) === "https://soundcloud.app.goo.gl/") { case "goo":
host = "soundcloud" if (url.substring(0, 30) === "https://soundcloud.app.goo.gl/") {
host = "soundcloud";
url = `https://soundcloud.com/${url.replace("https://soundcloud.app.goo.gl/", "").split('/')[0]}` url = `https://soundcloud.com/${url.replace("https://soundcloud.app.goo.gl/", "").split('/')[0]}`
} }
if (host === "tumblr" && !url.includes("blog/view")) { break;
if (url.slice(-1) == '/') url = url.slice(0, -1); case "tumblr":
url = url.replace(url.split('/')[5], ''); if (!url.includes("blog/view")) {
if (url.slice(-1) === '/') url = url.slice(0, -1);
url = url.replace(url.split('/')[5], '')
} }
if (host && host.length < 20 && host in patterns && patterns[host]["enabled"]) { break;
}
if (!(host && host.length < 20 && host in patterns && patterns[host]["enabled"])) return apiJSON(0, { t: errorUnsupported(lang) });
for (let i in patterns[host]["patterns"]) { for (let i in patterns[host]["patterns"]) {
patternMatch = new UrlPattern(patterns[host]["patterns"][i]).match(cleanURL(url, host).split(".com/")[1]); patternMatch = new UrlPattern(patterns[host]["patterns"][i]).match(cleanURL(url, host).split(`.${patterns[host]['tld'] ? patterns[host]['tld'] : "com"}/`)[1].replace('.', ''));
if (patternMatch) break; if (patternMatch) break
} }
if (patternMatch) { if (!patternMatch) return apiJSON(0, { t: errorUnsupported(lang) });
return await match(host, patternMatch, url, lang, obj);
} else return apiJSON(0, { t: errorUnsupported(lang) }); return await match(host, patternMatch, url, lang, obj)
} else return apiJSON(0, { t: errorUnsupported(lang) });
} else return apiJSON(0, { t: errorUnsupported(lang) });
} catch (e) { } catch (e) {
return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') }); return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') })
} }
} }

View file

@ -1,12 +1,45 @@
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import * as fs from "fs";
import { languageList } from "../localization/manager.js";
import page from "./pageRender/page.js";
export async function buildFront() { function cleanHTML(html) {
let clean = html.replace(/ {4}/g, '');
clean = clean.replace(/\n/g, '');
return clean
}
export async function buildFront(commitHash, branch) {
try { try {
// build html
if (!fs.existsSync('./build/')){
fs.mkdirSync('./build/');
fs.mkdirSync('./build/ios/');
fs.mkdirSync('./build/pc/');
fs.mkdirSync('./build/mob/');
}
for (let i in languageList) {
i = languageList[i];
let params = {
"hash": commitHash,
"lang": i,
"useragent": "pc",
"branch": branch
}
fs.writeFileSync(`./build/pc/${i}.html`, cleanHTML(page(params)));
params["useragent"] = "iphone os";
fs.writeFileSync(`./build/ios/${i}.html`, cleanHTML(page(params)));
params["useragent"] = "android";
fs.writeFileSync(`./build/mob/${i}.html`, cleanHTML(page(params)));
}
// build js & css
await esbuild.build({ await esbuild.build({
entryPoints: ['src/front/cobalt.js', 'src/front/cobalt.css'], entryPoints: ['src/front/cobalt.js', 'src/front/cobalt.css'],
outdir: `min/`, outdir: 'min/',
minify: true, minify: true,
loader: { ".js": "js", ".css": "css" } loader: { '.js': 'js', '.css': 'css', },
charset: 'utf8'
}) })
} catch (e) { } catch (e) {
return; return;

View file

@ -1,20 +1,45 @@
{ {
"current": { "current": {
"version": "5.3",
"title": "better looks, better feel",
"banner": "cattired.webp",
"content": "this update isn't as big as previous ones, but it still greatly enhances the cobalt experience.\n\nhere's what's up:\n*; new mode switcher! elegant and 100% clear. should no longer cause any confusion. let me know if you like it better this way :D\n*; wide paste button on mobile is back, but now it's even closer to your finger.\n*; removed the weird grey chin on changelog banners.\n*; removed left-handed layout toggle since it is no longer needed.\n*; fixed input area display in chromium 112+.\n*; centered the main action box.\n*; cleaned up css of main action box to get rid of tricks and ensure correct display on all devices.\n*; fixed a bug that'd cause notifications dots to disappear when an unrelated checkbox was checked.\n\nhopefully from now on i'll focus on adding support for more services.\nthank you for using cobalt. stay cool :)"
},
"history": [{
"version": "5.2",
"title": "fastest one in the game",
"banner": "catspeed.webp",
"content": "hey, notice anything different? well, at very least the page loaded way faster! this update includes many improvements and fixes, but also some new features.\n\n<span class=\"text-backdrop\">tl;dr:</span>\n*; twitter retweet links are now supported.\n*; all vimeo videos should now be possible to download.\n*; you now can download audio from vimeo.\n*; it's now possible to pick between preferred vimeo download method in settings.\n*; fixed issues related to tiktok, twitter, twitter spaces, and vimeo downloads.\n*; overall cobalt performance should be MUCH better.\n\nservice improvements:\n*; added support for twitter retweet links. now all kinds of tweet links are supported.\n*; fixed the issue related to periods in tiktok usernames (#96).\n*; fixed twitter spaces downloads.\n*; added support for audio downloads from vimeo.\n*; added ability to choose between \"progressive\" and \"dash\" vimeo downloads. go to settings > video to pick your preference.\n*; fixed the issue related to vimeo quality picking.\n*; fixed the issue when vimeo module wouldn't show appropriate errors and instead would fallback to default ones.\n*; improved audio only downloads for some edge cases.\n*; (hopefully) better youtube reliability.\n*; temporarily disabled douyin support due to api endpoint cut off.\n\ninterface improvements:\n*; merged clipboard and mode switcher rows into one for mobile view.\n*; added left-handed layout toggle for those who prefer to have the clipboard button on left.\n*; new custom-made clipboard icon. now it clearly indicates what it does.\n*; improved english and russian localization. both are way more direct and less bloaty.\n*; frontend page is now rendered once and is cached on disk instead of being rendered every time someone requests a page. this greatly improves page loading speeds and further reduces strain put on the server.\n*; frontend page is now minimized just like js and css files. this should minimize traffic wasted on loading the page, along with minor loading speed improvement.\n*; added proper checkbox icon for better clarity.\n*; checkboxes are now stretched edge-to-edge on phone to be easier to manage for right-handed people.\n*; removed button hover highlights on phones.\n*; fixed button press animations for safari on ios.\n*; fixed text selection on ios. previously you could select text or images anywhere, but now they're selectable in limited places, just like on other platforms.\n*; frontend platform is now marked in settings: p is for pc; m is for mobile; i is for ios. this is done for possible future debugging and issue-solving.\n*; better error messaging.\n\ninternal improvements:\n*; better rate limiting, there should be way less cases of accidental limits.\n*; added support for m3u8 playlists. this will be useful for future additions, and is currently used by vimeo module.\n*; added support for \"chop\" stream format for vimeo downloads.\n*; fixed vk user id extraction. i assumed the - in url was a separator, but it's actually a part of id.\n*; completely reworked the vimeo module. it's much cleaner and better performant now.\n*; minor clean ups across the board.\n\nnot really related to this update, but thank you for 50k monthly users! i really appreciate that you're still here, because that means i'm doing some things right :D"
}, {
"version": "5.1",
"title": "the evil has been defeated",
"banner": "happymeowth.webp",
"content": "hey, ever wanted to download a youtube video without a hassle? cobalt is here to help. this update fixes all issues related to youtube downloads.\nnot only that, but it also introduces features never before seen in a downloader, such as youtube dub downloads! read below to see what's up :)\n\n<span class=\"text-backdrop\">tl;dr:</span>\n*; audio in youtube videos FINALLY no longer gets cut off.\n*; you now can pick any video resolution you want (from 360p to 8k) and any possible youtube video codec (h264/av1/vp9).\n*; you now can download youtube videos with dubs in your native language. just check settings > audio.\n*; youtube processing has been vastly sped up.\n\nok, now onto the nerdy part of changelog. this update is pretty huge and includes improvements across the board.\n\nservice improvements:\n*; all youtube functionality has been reworked. cobalt now relies on innertube apis, not web scraping.\n*; random audio cut off issue has been fixed, let me know if it ever occurs again. (closes #62, #66, #75, #88).\n*; added support for youtube dubs. currently it's using your browser's default language when enabled, but i have plans on making a picker. i'll ask people on twitter and mastodon if this feature is needed, and add a picker in next updates.\n*; instead of adding more quality presets, i added granular quality options. pick whatever you like, from 360p up to 4320p (for all services, not just youtube).\n*; replaced a format picker with codec picker for youtube. you can pick h264, av1, or vp9. all of them should work as expected (closes #88).\n*; youtube audio files are now properly matched to corresponding video files.\n*; it's now always possible to download pristine h264 720p/360p videos from youtube. these videos will work ANYWHERE, so they're default for mobile.\n*; youtube requests are no longer permanently cached, ram usage should drop even further.\n*; youtube video and audio file names now include codec and dub language when applicable.\n*; max video and audio duration limits have been bumped up to 3 hours.\n*; general performance of entire youtube download process has been greatly improved.\n*; vk module has been reworked to be more compact and not make use of outdated technique of quality picking. should also be way more reliable.\n\ninternal improvements:\n*; cleaned up services config, all constants have been moved directly to modules for quicker access.\n*; matching module has been slightly cleaned up.\n\ninterface improvements:\n*; many descriptions and error messages have been slightly tuned to be less wordy.\n*; unnecessary title duplications in settings have been merged into one.\n*; added more clarity to quality and codec descriptions.\n\nif you use cobalt api, please note that you have to update your creation to support new features.\n\nthis is the second batch of 5.x improvements, there's way more to come. thank you for being here, i really appreciate your support.\n\nif you want to thank me (the developer), there's a nice tab under this changelog that has \"donations\" text on it. anything helps me continue developing and hosting the friendliest media downloader :D"
}, {
"version": "5.0",
"title": "it's all about attention to detail!",
"banner": "valentines.webp",
"content": "happy valentine's day! i have an update for you, as a gift :D\n\ntl;dr: added support for <span class=\"text-backdrop\">reddit gifs</span>, fixed douyin downloads, fixed vimeo quality picking, revamped entirety of codebase, and many other fixes.\n\nhere's more info:\n\nthis update is mostly about cleaning up and polishing the codebase, but it also has some new features. here's what's up:\n\nservice-related improvements:\n*; you now can download gifs from reddit!\n*; attempting to download a video from douyin no longer throws an error (bytedance changed the api endpoint, yet again).\n*; fixed quality picking for vimeo downloads.\n*; fixed length limit check in vimeo module.\n*; fixed support for \"user view\" vk clips links.\n*; various twitter errors are now displayed correctly instead of falling back to the default error.\n*; state of all services is now tested on each commit.\n\nui improvements:\n*; cobalt social links no longer disappear if you have an aggressive ad blocking extension installed.\n*; various localization improvements for both english and russian.\n*; changed some service aliases to display full list of supported downloads.\n*; added current branch information to version text (in settings).\n*; fixed typos in older changelogs.\n\ninternal improvements:\n*; <span class=\"text-backdrop\">everything</span> has been sanitized, improved, and refactored. code is now much easier to read and maintain.\n*; rewrote and/or optimized all modules that were messy or inefficient.\n*; all git interaction functions now store info in cache instead of fetching it every time the function is called.\n*; added a test script that checks functionality of all supported services.\n*; updated deepsource config. checks are more accurate now.\n*; requests from internet explorer are now dropped entirely instead of redirecting people stuck in 90s to a proper browser download page. this was done to avoid (my) personal bias towards browsers.\n\ni put a ton of effort into this version, and i hope you like it as much as i do.\n\nthank you for using cobalt. there's so much more to come :)"
}, {
"version": "4.8",
"title": "prettier than ever",
"banner": "catmakeup.webp",
"content": "this version brings many visual improvements and a completely revamped \"about\" tab.\n\nwhat's new in \"about\" tab:\n*; all information is now split into collapsible sections, making it easier to navigate.\n*; added privacy policy to further prove that none of your data is collected.\n*; added emoji to the page title to make it look consistent with other pages.\n*; added mastodon account handle and link.\n*; there are now short notes at the end of each section.\n*; other changes that are too small to describe. just go check it out!\n\nvisual improvements:\n*; less wasted space: paddings and margins have been reduced and optimized for usability, consistency, and overall beauty.\n*; all <a class=\"text-backdrop italic\" href=\"https://youtu.be/dQw4w9WgXcQ\" target=\"_blank\">links</a> are now in italic. it's much easier to tell them apart from <span class=\"text-backdrop\">regular highlights</span>.\n*; error popup no longer looks broken and out of place.\n*; download popup now has a proper close button, not something from 2.x era.\n*; emoji are no longer selectable or draggable.\n*; better scalability: desktop layout for home screen is shown if device viewport is wide enough to fit in three action buttons.\n*; page shouldn't look broken on phones in landscape mode (i still highly recommend using cobalt in portrait mode).\n*; removed bulletpoint padding. it was unnecessary.\n*; updated some service names.\n\nas always, you can suggest features or report bugs on any platform listed in the \"support\" section of about tab.\n\nthank you for using cobalt. i hope you have a good day :)"
}, {
"version": "4.7", "version": "4.7",
"title": "we're better together! thank you for bug reports.", "title": "we're better together! thank you for bug reports.",
"banner": "bettertogether.webp", "banner": "bettertogether.webp",
"content": "this update includes a bunch of improvements, many of which were made thanks to the community :D\n\nservice-related improvements:\n*; private soundcloud links are now supported (#68);\n*; tiktok usernames with dots in them no longer confuse cobalt (#71);\n*; .ogg files no longer wrongfully include a video channel (#67);\n*; fixed an issue that caused cobalt to freak out when user attempted to download an audio from audio-only service with \"mute video\" option enabled.\n\nui improvements:\n*; popup padding has been evened out. popups are now able to fit in more information on scroll, especially on mobile;\n*; all buttons are now of even size and are displayed without any padding issues across all modern browsers and devices;\n*; checkbox is no longer crippled on ios;\n*; many explanation texts have been simplified to get rid of unnecessary bloat (no bullshit, remember?);\n*; moved tiktok section in video settings higher due to higher priority;\n*; fixed unexpectedly displayed scrollbars on switch rows in firefox.\n\nstability improvements:\n*; ffmpeg process now should end upon finishing the render;\n*; ffmpeg should also quit when download is abruptly cut off;\n*; fixed a memory leak that was caused by misconfigured stream information caching (#63).\n\ninternal improvements:\n*; requested streams are now stored in cache for 2 minutes instead of 1000 hours (yes, 1000 hours, i fucked up);\n*; cached data is now reused if user requests same content within 2 minutes;\n*; page render module is now even cleaner than before;\n*; proper support for bullet-points in loc strings.\n\nyou can suggest features or report bugs on <a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">github</a> or <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">twitter</a>. both work just fine, use whichever you're more comfortable with.\n\nthank you for using cobalt, and thank you for reading this changelog.\n\nyou're amazing, keep it up :)" "content": "this update includes a bunch of improvements, many of which were made thanks to the community :D\n\nservice-related improvements:\n*; private soundcloud links are now supported (#68);\n*; tiktok usernames with dots in them no longer confuse cobalt (#71);\n*; .ogg files no longer wrongfully include a video channel (#67);\n*; fixed an issue that caused cobalt to freak out when user attempted to download an audio from audio-only service with \"mute video\" option enabled.\n\nui improvements:\n*; popup padding has been evened out. popups are now able to fit in more information on scroll, especially on mobile;\n*; all buttons are now of even size and are displayed without any padding issues across all modern browsers and devices;\n*; checkbox is no longer crippled on ios;\n*; many explanation texts have been simplified to get rid of unnecessary bloat (no bullshit, remember?);\n*; moved tiktok section in video settings higher due to higher priority;\n*; fixed unexpectedly displayed scrollbars on switch rows in firefox.\n\nstability improvements:\n*; ffmpeg process now should end upon finishing the render;\n*; ffmpeg should also quit when download is abruptly cut off;\n*; fixed a memory leak that was caused by misconfigured stream information caching (#63).\n\ninternal improvements:\n*; requested streams are now stored in cache for 2 minutes instead of 1000 hours (yes, 1000 hours, i fucked up);\n*; cached data is now reused if user requests same content within 2 minutes;\n*; page render module is now even cleaner than before;\n*; proper support for bullet-points in loc strings.\n\nyou can suggest features or report bugs on <a class=\"text-backdrop italic\" href=\"{repo}\" target=\"_blank\">github</a> or <a class=\"text-backdrop italic\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">twitter</a>. both work just fine, use whichever you're more comfortable with.\n\nthank you for using cobalt, and thank you for reading this changelog.\n\nyou're amazing, keep it up :)"
}, }, {
"history": [{
"version": "4.6", "version": "4.6",
"title": "mute videos and proper soundcloud support", "title": "mute videos and proper soundcloud support",
"banner": "shutup.png", "banner": "shutup.png",
"content": "i've been longing to implement both of these things, and here they finally are.\n\nservice-related improvements:\n{bS}*; you now can download videos with no audio! simply enable the \"mute audio\" option in settings &gt; audio.\n*; soundcloud module has been updated, and downloads should no longer break after some time.{bE}\nvisual improvements:\n{bS}*; moved some things around in settings popup, and added separators where separation is needed.\n*; updated some texts in english and russian.\n*; version and commit hash have been joined together, now they're a single unit.{bE}\ninternal improvements:\n{bS}*; updated api documentation to include isAudioMuted.\n*; simplified the startup message.\n*; created render elements for separator and explanation due to high duplication of them in the page.\n*; fully deprecated GET method for API requests.\n*; fixed some code quirks.{bE}\nhere's how soundcloud downloads got fixed:\n\npreviously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated.\nnow, cobalt tries to find the up-to-date client_id, caches it in memory, and checks if web app version has changed to update the id accordingly. you can see this change for yourself on github." "content": "i've been longing to implement both of these things, and here they finally are.\n\nservice-related improvements:\n*; you now can download videos with no audio! simply enable the \"mute audio\" option in settings &gt; audio.\n*; soundcloud module has been updated, and downloads should no longer break after some time.\nvisual improvements:\n*; moved some things around in settings popup, and added separators where separation is needed.\n*; updated some texts in english and russian.\n*; version and commit hash have been joined together, now they're a single unit.\ninternal improvements:\n*; updated api documentation to include isAudioMuted.\n*; simplified the startup message.\n*; created render elements for separator and explanation due to high duplication of them in the page.\n*; fully deprecated GET method for API requests.\n*; fixed some code quirks.\nhere's how soundcloud downloads got fixed:\n\npreviously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated.\nnow, cobalt tries to find the up-to-date client_id, caches it in memory, and checks if web app version has changed to update the id accordingly. you can see this change for yourself on github."
}, { }, {
"version": "4.5", "version": "4.5",
"title": "better, faster, stronger, stable", "title": "better, faster, stronger, stable",
"banner": "meowthstrong.webp", "banner": "meowthstrong.webp",
"content": "your favorite social media downloader just got even better! this update includes a ton of imporvements and fixes.\n\nin fact, there are so many changes, i had to split them in sections.\n\nservice-related improvements:\n{bS}*; vimeo module has been revamped, all sorts of videos should now be supported.\n*; vimeo audio downloads! you now can download audios from more recent videos.\n*; {appName} now supports all sorts of tumblr links. (even those scary ones from the mobile app)\n*; vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.\n*; youtube videos with community warnings should now be possible to download.{bE}\nuser interface improvements:\n{bS}*; list of supported services is now MUCH easier to read.\n*; banners in changelog history should no longer overlap each other.\n*; bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.{bE}\ninternal improvements:\n{bS}*; cobalt will now match the link to regex when using ?u= query for autopasting it into input area.\n*; better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.\n*; moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on <a class=\"text-backdrop\" href=\"https://github.com/wukko/better-ytdl-core\" target=\"_blank\">github</a> or <a class=\"text-backdrop\" href=\"https://www.npmjs.com/package/better-ytdl-core\" target=\"_blank\">npm</a>!\n*; ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.\n*; \"got\" package has been (mostly) replaced by native fetch api. this should greately reduce ram usage.\n*; all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?\n*; other code optimizations. there's less clutter overall.{bE}\nhuge update, right? seems like everything's fixed now?\n\nnope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt/issues/62\" target=\"_blank\">please feel free to do it on github!</a>\n\nthank you for reading this, and thank you for sticking with cobalt and me." "content": "your favorite social media downloader just got even better! this update includes a ton of improvements and fixes.\n\nin fact, there are so many changes, i had to split them in sections.\n\nservice-related improvements:\n*; vimeo module has been revamped, all sorts of videos should now be supported.\n*; vimeo audio downloads! you now can download audios from more recent videos.\n*; cobalt now supports all sorts of tumblr links. (even those scary ones from the mobile app)\n*; vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.\n*; youtube videos with community warnings should now be possible to download.\nuser interface improvements:\n*; list of supported services is now MUCH easier to read.\n*; banners in changelog history should no longer overlap each other.\n*; bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.\ninternal improvements:\n*; cobalt will now match the link to regex when using ?u= query for autopasting it into input area.\n*; better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.\n*; moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on <a class=\"text-backdrop italic\" href=\"https://github.com/wukko/better-ytdl-core\" target=\"_blank\">github</a> or <a class=\"text-backdrop italic\" href=\"https://www.npmjs.com/package/better-ytdl-core\" target=\"_blank\">npm</a>!\n*; ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.\n*; \"got\" package has been (mostly) replaced by native fetch api. this should greatly reduce ram usage.\n*; all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?\n*; other code optimizations. there's less clutter overall.\nhuge update, right? seems like everything's fixed now?\n\nnope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, <a class=\"text-backdrop italic\" href=\"https://github.com/wukko/cobalt/issues/62\" target=\"_blank\">please feel free to do it on github!</a>\n\nthank you for reading this, and thank you for sticking with cobalt and me."
}, { }, {
"version": "4.4", "version": "4.4",
"title": "over 1 million monthly requests. thank you.", "title": "over 1 million monthly requests. thank you.",
@ -23,12 +48,12 @@
}, { }, {
"version": "4.3.2", "version": "4.3.2",
"title": "twitter improvements & changelog overhaul", "title": "twitter improvements & changelog overhaul",
"content": "- you can download explicit content from twitter.\n- direct video links from twitter are properly supported (video/1, video/2, etc.).\n- changelog history got support for banners.\n- changelog categories are not messy anymore.\n- {appName} version in changelogs is now highlighted.\n- changelog history got separators to make text easier to read.\n- changelog history can be collapsed after loading.\n- download button takes less time to change back to pressable state.\n\nif you're a developer and would like to play around with cobalt's api, then read more about it in older changelogs below!" "content": "- you can download explicit content from twitter.\n- direct video links from twitter are properly supported (video/1, video/2, etc.).\n- changelog history got support for banners.\n- changelog categories are not messy anymore.\n- cobalt version in changelogs is now highlighted.\n- changelog history got separators to make text easier to read.\n- changelog history can be collapsed after loading.\n- download button takes less time to change back to pressable state.\n\nif you're a developer and would like to play around with cobalt's api, then read more about it in older changelogs below!"
}, { }, {
"version": "4.3", "version": "4.3",
"title": "developers, developers, developers, developers", "title": "developers, developers, developers, developers",
"banner": "developersdevelopersdevelopers.webp", "banner": "developersdevelopersdevelopers.webp",
"content": "this update features a TON of improvements.\n\n<a class=\"text-backdrop\" href=\"https://www.youtube.com/watch?v=SaVTHG-Ev4k\" target=\"_blank\">developers</a>, you now can rely on {appName} for getting content from social media. the api has been revamped and <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt/tree/current/docs/API.md\" target=\"_blank\">documentation</a> is now available. you can read more about API changes down below. go crazy, and have fun :D\n\nif you're not a developer, here's a list of changes that you probably care about:\n- rate limit is now approximately 8 times bigger. no more waiting, even if you want to download entirety of your tiktok \"for you\" page.\n- some updates will now have expressive banners, just like this one.\n- fixed what was causing an error when a youtube video had no description.\n- mp4 format button text should now be displayed properly, no matter if you touched the switcher or not.\n\nnext, the star of this update — improved api!\n- main endpoint now uses POST method instead of GET.\n- internal variables for preferences have been updated to be consistent and easier to understand.\n- ip address is now hashed right upon request, not somewhere deep inside the code.\n- global stream salt variable is no longer unnecessarily passed over a billion functions.\n- url and picker keys are now separate in the json response.\n- {appName} web app now correctly processes responses with \"success\" status.\n\nif you currently have a siri shortcut or some other script that uses the GET method, make sure to update it soon. this method is deprecated, limited, and will be removed entirely in coming updates.\n\nif you ever make something using {appName}'s api, make sure to mention <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">@justusecobalt</a> on twitter, i would absolutely love to see what you made." "content": "this update features a TON of improvements.\n\n<a class=\"text-backdrop italic\" href=\"https://www.youtube.com/watch?v=SaVTHG-Ev4k\" target=\"_blank\">developers</a>, you now can rely on cobalt for getting content from social media. the api has been revamped and <a class=\"text-backdrop italic\" href=\"https://github.com/wukko/cobalt/tree/current/docs/API.md\" target=\"_blank\">documentation</a> is now available. you can read more about API changes down below. go crazy, and have fun :D\n\nif you're not a developer, here's a list of changes that you probably care about:\n- rate limit is now approximately 8 times bigger. no more waiting, even if you want to download entirety of your tiktok \"for you\" page.\n- some updates will now have expressive banners, just like this one.\n- fixed what was causing an error when a youtube video had no description.\n- mp4 format button text should now be displayed properly, no matter if you touched the switcher or not.\n\nnext, the star of this update — improved api!\n- main endpoint now uses POST method instead of GET.\n- internal variables for preferences have been updated to be consistent and easier to understand.\n- ip address is now hashed right upon request, not somewhere deep inside the code.\n- global stream salt variable is no longer unnecessarily passed over a billion functions.\n- url and picker keys are now separate in the json response.\n- cobalt web app now correctly processes responses with \"success\" status.\n\nif you currently have a siri shortcut or some other script that uses the GET method, make sure to update it soon. this method is deprecated, limited, and will be removed entirely in coming updates.\n\nif you ever make something using cobalt's api, make sure to mention <a class=\"text-backdrop italic\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">@justusecobalt</a> on twitter, i would absolutely love to see what you made."
}, { }, {
"version": "4.2", "version": "4.2",
"title": "optimized quality picking and 8k video support", "title": "optimized quality picking and 8k video support",
@ -36,15 +61,15 @@
}, { }, {
"version": "4.1", "version": "4.1",
"title": "better tiktok image downloads", "title": "better tiktok image downloads",
"content": "here's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- emoji in about button now changes on special occasions. be it halloween or christmas, {appName} will change just a tiny bit to fit in :D\n\nif you're not caught up with new stuff in {appName} 4.x yet, check out the previous changelog down below. there's a ton of stuff to like." "content": "here's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- emoji in about button now changes on special occasions. be it halloween or christmas, cobalt will change just a tiny bit to fit in :D\n\nif you're not caught up with new stuff in cobalt 4.x yet, check out the previous changelog down below. there's a ton of stuff to like."
}, { }, {
"version": "4.0", "version": "4.0",
"title": "better and faster than ever", "title": "better and faster than ever",
"content": "this update has a ton of improvements and new features.\n\nchanges you probably care about:\n- {appName} now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when {appName} is able to get more info, it adds that metadata too.\n- tiktok downloads have been fixed, yet again, and if they ever break in the future, {appName} will fall back to downloading a less annoyingly watermarked video.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt\" target=\"_blank\">you can do it on github</a>." "content": "this update has a ton of improvements and new features.\n\nchanges you probably care about:\n- cobalt now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when cobalt is able to get more info, it adds that metadata too.\n- tiktok downloads have been fixed, yet again, and if they ever break in the future, cobalt will fall back to downloading a less annoyingly watermarked video.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, <a class=\"text-backdrop italic\" href=\"https://github.com/wukko/cobalt\" target=\"_blank\">you can do it on github</a>."
}, { }, {
"version": "3.7", "version": "3.7",
"title": "support for multi media tweets is here!", "title": "support for multi media tweets is here!",
"content": "{appName} now lets you save any of the videos or gifs in a tweet. even if there are many of them.\n\nsimply paste a link like you'd usually do and {appName} will ask what exactly you want to save.\n\nFIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for {appName}, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive.\n\nhowever, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome.\n\nother changes:\n- repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work.\n- {appName} is now properly viewable on phones with tiny screens, such as first gen iphone se.\n- scrollbars now should be visible only where they're needed.\n- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).\n- cleaned up some internal files, including main frontend js file.\n- reorganized some files in project directory, now you won't get lost when contributing or just looking through {appName}'s code." "content": "cobalt now lets you save any of the videos or gifs in a tweet. even if there are many of them.\n\nsimply paste a link like you'd usually do and cobalt will ask what exactly you want to save.\n\nFIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for cobalt, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive.\n\nhowever, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome.\n\nother changes:\n- repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work.\n- cobalt is now properly viewable on phones with tiny screens, such as first gen iphone se.\n- scrollbars now should be visible only where they're needed.\n- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).\n- cleaned up some internal files, including main frontend js file.\n- reorganized some files in project directory, now you won't get lost when contributing or just looking through cobalt's code."
}, { }, {
"version": "3.6.2 + 3.6.3", "version": "3.6.2 + 3.6.3",
"title": "less disturbance", "title": "less disturbance",
@ -52,18 +77,18 @@
}, { }, {
"version": "3.6", "version": "3.6",
"title": "improvements all around!", "title": "improvements all around!",
"content": "- download mode switcher is moving places, it's now right next to link input area.\n- smart mode has been renamed to auto mode, because this name is easier to understand.\n- all spacings in ui have been evened out. no more eye strain.\n- added support for twitter /video/1 links\n- clipboard button exception has been redone to prepare for adoption of readtext clipboard api in firefox.\n- {appName} is now using different tiktok api endpoint, because previous one got killed, just like the one before.\n- \"other\" settings tab has been cleaned up." "content": "- download mode switcher is moving places, it's now right next to link input area.\n- smart mode has been renamed to auto mode, because this name is easier to understand.\n- all spacings in ui have been evened out. no more eye strain.\n- added support for twitter /video/1 links\n- clipboard button exception has been redone to prepare for adoption of readtext clipboard api in firefox.\n- cobalt is now using different tiktok api endpoint, because previous one got killed, just like the one before.\n- \"other\" settings tab has been cleaned up."
}, { }, {
"version": "3.5.4", "version": "3.5.4",
"title": "tiktok support is back :D", "title": "tiktok support is back :D",
"content": "you can download videos, sounds, and images from tiktok again!\nhuge thank you to <a class=\"text-backdrop\" href=\"https://github.com/minzique\" target=\"_blank\">@minzique</a> for finding another api endpoint that works." "content": "you can download videos, sounds, and images from tiktok again!\nhuge thank you to <a class=\"text-backdrop italic\" href=\"https://github.com/minzique\" target=\"_blank\">@minzique</a> for finding another api endpoint that works."
}, { }, {
"version": "3.5.2", "version": "3.5.2",
"title": "vk clips support, improved changelog system, and less bugs", "title": "vk clips support, improved changelog system, and less bugs",
"content": "new features: \n- added support for vk clips. {appName} now lets you download even more cringy videos!\n- added update history right to the changelog menu. it's not loaded by default to minimize page load time, but can be loaded upon pressing a button. probably someone will enjoy this.\n- as you've just read, {appName} now has on-demand blocks. they're rendered on server upon request and exist to prevent any unnecessary clutter by default. the first feature to use on-demand rendering is history of updates in changelog tab.\n\nchanges:\n- moved twitter entry to about tab and made it localized.\n- added clarity to what services exactly are supported in about tab.\n\nbug fixes:\n- {appName} should no longer crash to firefox users if they love to play around with user-agent switching.\n- vk videos of any resolution and aspect ratio should now be downloadable.\n- vk quality picking has been fixed after vk broke it for parsers on their side." "content": "new features: \n- added support for vk clips. cobalt now lets you download even more cringy videos!\n- added update history right to the changelog menu. it's not loaded by default to minimize page load time, but can be loaded upon pressing a button. probably someone will enjoy this.\n- as you've just read, cobalt now has on-demand blocks. they're rendered on server upon request and exist to prevent any unnecessary clutter by default. the first feature to use on-demand rendering is history of updates in changelog tab.\n\nchanges:\n- moved twitter entry to about tab and made it localized.\n- added clarity to what services exactly are supported in about tab.\n\nbug fixes:\n- cobalt should no longer crash to firefox users if they love to play around with user-agent switching.\n- vk videos of any resolution and aspect ratio should now be downloadable.\n- vk quality picking has been fixed after vk broke it for parsers on their side."
}, { }, {
"version": "3.5", "version": "3.5",
"title": "ui revamp and usability imporvements", "title": "ui revamp and usability improvements",
"content": "new features:\n- {appName} now lets you paste the link in your clipboard and download the file in a single press of a button.if your clipboard's latest content isn't a valid url, {appName} won't process or paste it. you can also hide the clipboard button in settings if you want to.\nunfortunately, the clipboard feature is not available to firefox users because mozilla didn't add proper support for clipboard api.\n- there's now a button to quickly clean the input area, right next to download button. it's really useful in case when you want to quickly save a bunch of videos and don't want to bother selecting text.\n- keyboard shortcuts! you love them, i love them, and now we can use them to perform quick actions in {appName}. use ctrl+v combo to paste the link without focusing the input area; press escape key to close the active popup or clean the input area; and if you didn't know, you can also press enter to download content from the link.\n\nnew looks:\n- main box has been revamped. it has lost its border, thick padding, and now feels light and fresh.\n- download button is now prettier, and has been tuned to make >> look just like the logo.\n- buttons on the bottom now actually look like buttons and are way more descriptive. no more #@+?$ bullshit. it's way easier to see and understand what each of them does.\n- bottom buttons are prettier and easier to use on a phone. they're bigger and stretch out to sides, making them easier to press.\n\nfixes:\n- it's now impossible to overlap multiple popups at once. no more mess if you decide to explore popups while waiting for request to process.\n- popup tabs have been slightly moved down to prevent popup content overlapping.\n- ui scalability has been improved." "content": "new features:\n- cobalt now lets you paste the link in your clipboard and download the file in a single press of a button.if your clipboard's latest content isn't a valid url, cobalt won't process or paste it. you can also hide the clipboard button in settings if you want to.\nunfortunately, the clipboard feature is not available to firefox users because mozilla didn't add proper support for clipboard api.\n- there's now a button to quickly clean the input area, right next to download button. it's really useful in case when you want to quickly save a bunch of videos and don't want to bother selecting text.\n- keyboard shortcuts! you love them, i love them, and now we can use them to perform quick actions in cobalt. use ctrl+v combo to paste the link without focusing the input area; press escape key to close the active popup or clean the input area; and if you didn't know, you can also press enter to download content from the link.\n\nnew looks:\n- main box has been revamped. it has lost its border, thick padding, and now feels light and fresh.\n- download button is now prettier, and has been tuned to make >> look just like the logo.\n- buttons on the bottom now actually look like buttons and are way more descriptive. no more #@+?$ bullshit. it's way easier to see and understand what each of them does.\n- bottom buttons are prettier and easier to use on a phone. they're bigger and stretch out to sides, making them easier to press.\n\nfixes:\n- it's now impossible to overlap multiple popups at once. no more mess if you decide to explore popups while waiting for request to process.\n- popup tabs have been slightly moved down to prevent popup content overlapping.\n- ui scalability has been improved."
}] }]
} }

View file

@ -10,12 +10,9 @@ export const
version = packageJson.version, version = packageJson.version,
streamLifespan = config.streamLifespan, streamLifespan = config.streamLifespan,
maxVideoDuration = config.maxVideoDuration, maxVideoDuration = config.maxVideoDuration,
maxAudioDuration = config.maxAudioDuration,
genericUserAgent = config.genericUserAgent, genericUserAgent = config.genericUserAgent,
repo = packageJson["bugs"]["url"].replace('/issues', ''), repo = packageJson["bugs"]["url"].replace('/issues', ''),
authorInfo = config.authorInfo, authorInfo = config.authorInfo,
quality = config.quality,
internetExplorerRedirect = config.internetExplorerRedirect,
donations = config.donations, donations = config.donations,
ffmpegArgs = config.ffmpegArgs, ffmpegArgs = config.ffmpegArgs,
supportedAudio = config.supportedAudio, supportedAudio = config.supportedAudio,

View file

@ -3,7 +3,7 @@ const names = {
"🎬": "clapper_board", "🎬": "clapper_board",
"💰": "money_bag", "💰": "money_bag",
"🎉": "party_popper", "🎉": "party_popper",
"❓": "red_question_mark", "❓": "question_mark",
"✨": "sparkles", "✨": "sparkles",
"🪅": "pinata", "🪅": "pinata",
"🪄": "magic_wand", "🪄": "magic_wand",
@ -18,17 +18,23 @@ const names = {
"🕯️": "candle", "🕯️": "candle",
"😺": "cat", "😺": "cat",
"🐶": "dog", "🐶": "dog",
"🎂": "cake" "🎂": "cake",
"🐘": "elephant",
"🐦": "bird",
"🐙": "octopus",
"🔮": "crystal_ball",
"💪": "biceps"
} }
let sizing = { let sizing = {
22: 0.4, 22: 0.4,
30: 0.7, 30: 0.7,
48: 0.9 48: 0.9,
64: 0.9
} }
export default function(emoji, size, disablePadding) { export default function(emoji, size, disablePadding) {
if (!size) size = 22; if (!size) size = 22;
let padding = size !== 22 ? `margin-right:${sizing[size] ? sizing[size] : "0.4"}rem;` : ``; let padding = size !== 22 ? `margin-right:${sizing[size] ? sizing[size] : "0.4"}rem;` : false;
if (disablePadding) padding = 'margin-right:0!important;'; if (disablePadding) padding = 'margin-right:0!important;';
if (!names[emoji]) emoji = "❓"; if (!names[emoji]) emoji = "❓";
return `<img class="emoji" height="${size}" width="${size}" style="${padding}" alt="${emoji}" src="emoji/${names[emoji]}.svg">` return `<img class="emoji" draggable=false height="${size}" width="${size}" ${padding ? `style="${padding}"` : ''}alt="${emoji}" src="emoji/${names[emoji]}.svg">`
} }

View file

@ -2,26 +2,24 @@ import { celebrations } from "../config.js";
export function switcher(obj) { export function switcher(obj) {
let items = ``; let items = ``;
switch(obj.name) { if (obj.name === "download") {
case "download":
items = obj.items; items = obj.items;
break; } else {
default:
for (let i = 0; i < obj.items.length; i++) { for (let i = 0; i < obj.items.length; i++) {
let classes = obj.items[i]["classes"] ? obj.items[i]["classes"] : [] let classes = obj.items[i]["classes"] ? obj.items[i]["classes"] : []
items += `<button id="${obj.name}-${obj.items[i]["action"]}" class="switch${classes.length > 0 ? ' ' + classes.join(' ') : ''}" onclick="changeSwitcher('${obj.name}', '${obj.items[i]["action"]}')">${obj.items[i]["text"] ? obj.items[i]["text"] : obj.items[i]["action"]}</button>` items += `<button id="${obj.name}-${obj.items[i]["action"]}" class="switch${classes.length > 0 ? ' ' + classes.join(' ') : ''}" onclick="changeSwitcher('${obj.name}', '${obj.items[i]["action"]}')">${obj.items[i]["text"] ? obj.items[i]["text"] : obj.items[i]["action"]}</button>`
} }
break;
} }
return `
<div id="${obj.name}-switcher" class="switch-container"> if (obj.noParent) return `<div class="switches">${items}</div>`;
return `<div id="${obj.name}-switcher" class="switch-container">
${obj.subtitle ? `<div class="subtitle">${obj.subtitle}</div>` : ``} ${obj.subtitle ? `<div class="subtitle">${obj.subtitle}</div>` : ``}
<div class="switches">${items}</div> <div class="switches">${items}</div>
${obj.explanation ? `<div class="explanation">${obj.explanation}</div>` : ``} ${obj.explanation ? `<div class="explanation">${obj.explanation}</div>` : ``}
</div>` </div>`
} }
export function checkbox(action, text, aria, paddingType) { export function checkbox(action, text, paddingType, aria) {
let paddingClass = ` ` let paddingClass = ` `
switch (paddingType) { switch (paddingType) {
case 1: case 1:
@ -33,6 +31,8 @@ export function checkbox(action, text, aria, paddingType) {
case 3: case 3:
paddingClass += "no-margin" paddingClass += "no-margin"
break; break;
case 4:
paddingClass += "top-margin-only"
} }
return `<label id="${action}-chkbx" class="checkbox${paddingClass}"> return `<label id="${action}-chkbx" class="checkbox${paddingClass}">
<input id="${action}" type="checkbox" ${aria ? `aria-label="${aria}"` : `aria-label="${text}"`} onclick="checkbox('${action}')"> <input id="${action}" type="checkbox" ${aria ? `aria-label="${aria}"` : `aria-label="${text}"`} onclick="checkbox('${action}')">
@ -65,9 +65,9 @@ export function popup(obj) {
} }
return ` return `
${obj.standalone ? `<div id="popup-${obj.name}" class="popup center box${classes.length > 0 ? ' ' + classes.join(' ') : ''}" style="visibility: hidden;">` : ''} ${obj.standalone ? `<div id="popup-${obj.name}" class="popup center box${classes.length > 0 ? ' ' + classes.join(' ') : ''}" style="visibility: hidden;">` : ''}
${obj.buttonOnly ? obj.emoji : ``}
<div id="popup-header" class="popup-header"> <div id="popup-header" class="popup-header">
${obj.standalone && !obj.buttonOnly ? `<button id="popup-close" class="button mono" onclick="popup('${obj.name}', 0)" ${obj.header.closeAria ? `aria-label="${obj.header.closeAria}"` : ''}>x</button>` : ''} ${obj.standalone && !obj.buttonOnly ? `<button id="close-button" class="switch up" onclick="popup('${obj.name}', 0)" ${obj.header.closeAria ? `aria-label="${obj.header.closeAria}"` : ''}>x</button>` : ''}
${obj.buttonOnly ? obj.header.emoji : ``}
${obj.header.aboveTitle ? `<a id="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''} ${obj.header.aboveTitle ? `<a id="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div id="popup-title">${obj.header.title}</div>` : ''} ${obj.header.title ? `<div id="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''} ${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
@ -88,7 +88,7 @@ export function multiPagePopup(obj) {
tabs += `<button id="tab-button-${obj.name}-${obj.tabs[i]["name"]}" class="switch tab tab-${obj.name}" onclick="changeTab(event, 'tab-${obj.name}-${obj.tabs[i]["name"]}', '${obj.name}')">${obj.tabs[i]["title"]}</button>` tabs += `<button id="tab-button-${obj.name}-${obj.tabs[i]["name"]}" class="switch tab tab-${obj.name}" onclick="changeTab(event, 'tab-${obj.name}-${obj.tabs[i]["name"]}', '${obj.name}')">${obj.tabs[i]["title"]}</button>`
tabContent += `<div id="tab-${obj.name}-${obj.tabs[i]["name"]}" class="popup-tab-content tab-content-${obj.name}">${obj.tabs[i]["content"]}</div>` tabContent += `<div id="tab-${obj.name}-${obj.tabs[i]["name"]}" class="popup-tab-content tab-content-${obj.name}">${obj.tabs[i]["content"]}</div>`
} }
tabs += `<button id="close-bottom" class="switch tab-${obj.name}" onclick="popup('${obj.name}', 0)" ${obj.closeAria ? `aria-label="${obj.closeAria}"` : ''}>x</button>` tabs += `<button id="close-button" class="switch tab-${obj.name}" onclick="popup('${obj.name}', 0)" ${obj.closeAria ? `aria-label="${obj.closeAria}"` : ''}>x</button>`
return ` return `
<div id="popup-${obj.name}" class="popup center box scrollable" style="visibility: hidden;"> <div id="popup-${obj.name}" class="popup center box scrollable" style="visibility: hidden;">
<div id="popup-content">${obj.header ? `<div id="popup-header" class="popup-header"> <div id="popup-content">${obj.header ? `<div id="popup-header" class="popup-header">
@ -98,13 +98,25 @@ export function multiPagePopup(obj) {
<div id="popup-tabs" class="switches popup-tabs">${tabs}</div> <div id="popup-tabs" class="switches popup-tabs">${tabs}</div>
</div>` </div>`
} }
export function collapsibleList(arr) {
let items = ``
for (let i = 0; i < arr.length; i++) {
items += `<div id="${arr[i]["name"]}-collapse" class="collapse-list">
<div class="collapse-header" onclick="expandCollapsible(event)">
<div class="collapse-title">${arr[i]["title"]}</div>
<div class="collapse-indicator">^</div>
</div>
<div id="${arr[i]["name"]}-body" class="collapse-body">${arr[i]["body"]}</div>
</div>`
}
return items;
}
export function popupWithBottomButtons(obj) { export function popupWithBottomButtons(obj) {
let tabs = `` let tabs = ``
for (let i = 0; i < obj.buttons.length; i++) { for (let i = 0; i < obj.buttons.length; i++) {
tabs += obj.buttons[i] tabs += obj.buttons[i]
} }
tabs += `<button id="close-bottom" class="switch tab-${obj.name}" onclick="popup('${obj.name}', 0)" ${obj.closeAria ? `aria-label="${obj.closeAria}"` : ''}>x</button>` tabs += `<button id="close-button" class="switch tab-${obj.name}" onclick="popup('${obj.name}', 0)" ${obj.closeAria ? `aria-label="${obj.closeAria}"` : ''}>x</button>`
return ` return `
<div id="popup-${obj.name}" class="popup center box scrollable" style="visibility: hidden;"> <div id="popup-${obj.name}" class="popup center box scrollable" style="visibility: hidden;">
<div id="popup-content">${obj.header ? `<div id="popup-header" class="popup-header"> <div id="popup-content">${obj.header ? `<div id="popup-header" class="popup-header">
@ -116,13 +128,15 @@ export function popupWithBottomButtons(obj) {
</div>` </div>`
} }
export function backdropLink(link, text) { export function backdropLink(link, text) {
return `<a class="text-backdrop" href="${link}" target="_blank">${text}</a>` return `<a class="text-backdrop italic" href="${link}" target="_blank">${text}</a>`
}
export function socialLink(emoji, name, handle, url) {
return `<div class="cobalt-support-link">${emoji} ${name}: <a class="text-backdrop italic" href="${url}" target="_blank">${handle}</a></div>`
} }
export function settingsCategory(obj) { export function settingsCategory(obj) {
return `<div id="settings-${obj.name}" class="settings-category"> return `<div id="settings-${obj.name}" class="settings-category">
<div class="category-title">${obj.title ? obj.title : obj.name}</div> <div class="category-title">${obj.title ? obj.title : obj.name}</div>
<div class="settings-category-content">${obj.body}</div> <div class="category-content">${obj.body}</div>
</div>` </div>`
} }

View file

@ -0,0 +1,11 @@
import { languageList } from "../../localization/manager.js";
export default function(lang, userAgent) {
let language = languageList.includes(lang) ? lang : "en";
let ua = userAgent.toLowerCase();
let platform = (ua.match("android") || ua.match("iphone os")) ? "mob" : "pc";
if (platform === "mob" && ua.match("iphone os")) platform = "ios";
return `/build/${platform}/${language}.html`;
}

View file

@ -1,13 +1,17 @@
import changelogManager from "../changelog/changelogManager.js" import changelogManager from "../changelog/changelogManager.js"
let cache = {}
export function changelogHistory() { // blockId 0 export function changelogHistory() { // blockId 0
if (cache['0']) return cache['0'];
let history = changelogManager("history"); let history = changelogManager("history");
let render = ``; let render = ``;
let historyLen = history.length let historyLen = history.length;
for (let i in history) { for (let i in history) {
let separator = (i != 0 && i != historyLen) ? '<div class="separator"></div>' : '' let separator = (i !== 0 && i !== historyLen) ? '<div class="separator"></div>' : '';
render += `${separator}${history[i]["banner"] ? `<div class="changelog-banner"><img class="changelog-img" src="${history[i]["banner"]}" onerror="this.style.display='none'"></img></div>` : ''}<div id="popup-desc" class="changelog-subtitle">${history[i]["title"]}</div><div id="popup-desc" class="desc-padding">${history[i]["content"]}</div>` render += `${separator}${history[i]["banner"] ? `<div class="changelog-banner"><img class="changelog-img" src="${history[i]["banner"]}" onerror="this.style.display='none'"></img></div>` : ''}<div id="popup-desc" class="changelog-subtitle">${history[i]["title"]}</div><div id="popup-desc" class="desc-padding">${history[i]["content"]}</div>`
} }
cache['0'] = render;
return render; return render;
} }

View file

@ -1,5 +1,5 @@
import { backdropLink, celebrationsEmoji, checkbox, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher } from "./elements.js"; import { backdropLink, celebrationsEmoji, checkbox, collapsibleList, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher, socialLink } from "./elements.js";
import { services as s, appName, authorInfo, version, quality, repo, donations, supportedAudio } from "../config.js"; import { services as s, appName, authorInfo, version, repo, donations, supportedAudio } from "../config.js";
import { getCommitInfo } from "../sub/currentCommit.js"; import { getCommitInfo } from "../sub/currentCommit.js";
import loc from "../../localization/manager.js"; import loc from "../../localization/manager.js";
import emoji from "../emoji.js"; import emoji from "../emoji.js";
@ -11,7 +11,7 @@ let enabledServices = Object.keys(s).filter((p) => {
if (s[p].enabled) return true; if (s[p].enabled) return true;
}).sort().map((p) => { }).sort().map((p) => {
return `<br>&bull; ${s[p].alias ? s[p].alias : p}` return `<br>&bull; ${s[p].alias ? s[p].alias : p}`
}).join(';').substring(4) }).join('').substring(4)
let donate = `` let donate = ``
let donateLinks = `` let donateLinks = ``
@ -33,6 +33,10 @@ export default function(obj) {
let ua = obj.useragent.toLowerCase(); let ua = obj.useragent.toLowerCase();
let isIOS = ua.match("iphone os"); let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os"); let isMobile = ua.match("android") || ua.match("iphone os");
let platform = isMobile ? "m" : "p";
if (isMobile && isIOS) platform = "i";
audioFormats[0]["text"] = t('SettingsAudioFormatBest'); audioFormats[0]["text"] = t('SettingsAudioFormatBest');
try { try {
@ -47,7 +51,7 @@ export default function(obj) {
<meta property="og:url" content="${process.env.selfURL}" /> <meta property="og:url" content="${process.env.selfURL}" />
<meta property="og:title" content="${appName}" /> <meta property="og:title" content="${appName}" />
<meta property="og:description" content="${t('EmbedBriefDescription')}" /> <meta property="og:description" content="${t('EmbedBriefDescription')}" />
<meta property="og:image" content="${process.env.selfURL}icons/generic.png" /> <meta property="og:image" content="icons/generic.png" />
<meta name="title" content="${appName}" /> <meta name="title" content="${appName}" />
<meta name="description" content="${t('AboutSummary')}" /> <meta name="description" content="${t('AboutSummary')}" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
@ -64,7 +68,7 @@ export default function(obj) {
<noscript><div style="margin: 2rem;">${t('NoScriptMessage')}</div></noscript> <noscript><div style="margin: 2rem;">${t('NoScriptMessage')}</div></noscript>
</head> </head>
<body id="cobalt-body" data-nosnippet> <body id="cobalt-body" ${platform === "p" ? 'class="desktop"' : ''} data-nosnippet ontouchstart>
${multiPagePopup({ ${multiPagePopup({
name: "about", name: "about",
closeAria: t('AccessibilityClosePopup'), closeAria: t('AccessibilityClosePopup'),
@ -79,21 +83,30 @@ export default function(obj) {
url: authorInfo.link url: authorInfo.link
}, },
closeAria: t('AccessibilityClosePopup'), closeAria: t('AccessibilityClosePopup'),
title: t('TitlePopupAbout') title: `${emoji("🔮", 30)} ${t('TitlePopupAbout')}`
}, },
body: [{ body: [{
text: t('AboutSummary') text: t('AboutSummary')
}, { }, {
text: `${t('AboutSupportedServices')}`, text: collapsibleList([{
nopadding: true "name": "services",
"title": t("CollapseServices"),
"body": `${enabledServices}<br/><br/>${t("ServicesNote")}`
}, { }, {
text: `<div class="bullpadding">${enabledServices}.</div>` "name": "support",
"title": t("CollapseSupport"),
"body": `${t("FollowSupport")}<br/>
${socialLink(emoji("🐘"), "mastodon", authorInfo.support.mastodon.handle, authorInfo.support.mastodon.url)}
${socialLink(emoji("🐦"), "twitter", authorInfo.support.twitter.handle, authorInfo.support.twitter.url)}<br/>
${t("SourceCode")}<br/>
${socialLink(emoji("🐙"), "github", repo.replace("https://github.com/", ''), repo)}<br/>
${t("SupportNote")}`
}, { }, {
text: obj.lang !== "ru" ? t('FollowTwitter') : "", "name": "privacy",
classes: ["desc-padding"] "title": t("CollapsePrivacy"),
}, { "body": t("PrivacyPolicy")
text: backdropLink(repo, t('LinkGitHubIssues')), }])
classes: ["bottom-link"] + `${process.env.DEPLOYMENT_ID && process.env.INTERNAL_IP ? '<a id="hop-attribution" class="explanation" href="https://hop.io/" target="_blank">powered by hop.io</a>' : ''}`
}] }]
}) })
}, { }, {
@ -178,7 +191,7 @@ export default function(obj) {
closeAria: t('AccessibilityClosePopup'), closeAria: t('AccessibilityClosePopup'),
header: { header: {
aboveTitle: { aboveTitle: {
text: `v.${version}-${obj.hash}`, text: `v.${version}-${obj.hash}${platform} (${obj.branch})`,
url: `${repo}/commit/${obj.hash}` url: `${repo}/commit/${obj.hash}`
}, },
title: `${emoji("⚙️", 30)} ${t('TitlePopupSettings')}` title: `${emoji("⚙️", 30)} ${t('TitlePopupSettings')}`
@ -188,43 +201,67 @@ export default function(obj) {
title: `${emoji("🎬")} ${t('SettingsVideoTab')}`, title: `${emoji("🎬")} ${t('SettingsVideoTab')}`,
content: settingsCategory({ content: settingsCategory({
name: "downloads", name: "downloads",
title: t('SettingsVideoGeneral'), title: t('SettingsQualitySubtitle'),
body: switcher({ body: switcher({
name: "vQuality", name: "vQuality",
subtitle: t('SettingsQualitySubtitle'),
explanation: t('SettingsQualityDescription'), explanation: t('SettingsQualityDescription'),
items: [{ items: [{
"action": "max", "action": "max",
"text": `${t('SettingsQualitySwitchMax')}<br/>(2160p+)` "text": "4320p+"
}, { }, {
"action": "hig", "action": "2160",
"text": `${t('SettingsQualitySwitchHigh')}<br/>(${quality.hig}p)` "text": "2160p"
}, { }, {
"action": "mid", "action": "1440",
"text": `${t('SettingsQualitySwitchMedium')}<br/>(${quality.mid}p)` "text": "1440p"
}, { }, {
"action": "low", "action": "1080",
"text": `${t('SettingsQualitySwitchLow')}<br/>(${quality.low}p)` "text": "1080p"
}, {
"action": "720",
"text": "720p"
}, {
"action": "480",
"text": "480p"
}, {
"action": "360",
"text": "360p"
}] }]
}) })
}) })
+ settingsCategory({ + settingsCategory({
name: "tiktok", name: "tiktok",
title: "tiktok & douyin", title: "tiktok & douyin",
body: checkbox("disableTikTokWatermark", t('SettingsRemoveWatermark')) body: checkbox("disableTikTokWatermark", t('SettingsRemoveWatermark'), 3)
}) })
+ settingsCategory({ + settingsCategory({
name: "youtube", name: t('SettingsCodecSubtitle'),
body: switcher({ body: switcher({
name: "vFormat", name: "vCodec",
subtitle: t('SettingsFormatSubtitle'), explanation: t('SettingsCodecDescription'),
explanation: t('SettingsFormatDescription'),
items: [{ items: [{
"action": "mp4", "action": "h264",
"text": "mp4 (av1)" "text": "h264 (mp4)"
}, { }, {
"action": "webm", "action": "av1",
"text": "webm (vp9)" "text": "av1 (mp4)"
}, {
"action": "vp9",
"text": "vp9 (webm)"
}]
})
})
+ settingsCategory({
name: t('SettingsVimeoPrefer'),
body: switcher({
name: "vimeoDash",
explanation: t('SettingsVimeoPreferDescription'),
items: [{
"action": "false",
"text": "progressive"
}, {
"action": "true",
"text": "dash"
}] }]
}) })
}) })
@ -233,17 +270,31 @@ export default function(obj) {
title: `${emoji("🎶")} ${t('SettingsAudioTab')}`, title: `${emoji("🎶")} ${t('SettingsAudioTab')}`,
content: settingsCategory({ content: settingsCategory({
name: "general", name: "general",
title: t('SettingsAudioTab'), title: t('SettingsFormatSubtitle'),
body: switcher({ body:
switcher({
name: "aFormat", name: "aFormat",
subtitle: t('SettingsFormatSubtitle'),
explanation: t('SettingsAudioFormatDescription'), explanation: t('SettingsAudioFormatDescription'),
items: audioFormats items: audioFormats
}) + sep(0) + checkbox("muteAudio", t('SettingsVideoMute'), t('SettingsVideoMute'), 3) + explanation(t('SettingsVideoMuteExplanation')) }) + sep(0) + checkbox("muteAudio", t('SettingsVideoMute'), 3) + explanation(t('SettingsVideoMuteExplanation'))
}) + settingsCategory({
name: "dub",
title: t("SettingsAudioDub"),
body: switcher({
name: "dubLang",
explanation: t('SettingsAudioDubDescription'),
items: [{
"action": "original",
"text": t('SettingsDubDefault')
}, {
"action": "auto",
"text": t('SettingsDubAuto')
}]
})
}) + settingsCategory({ }) + settingsCategory({
name: "tiktok", name: "tiktok",
title: "tiktok & douyin", title: "tiktok & douyin",
body: checkbox("fullTikTokAudio", t('SettingsAudioFullTikTok'), t('SettingsAudioFullTikTok'), 3) + `<div class="explanation">${t('SettingsAudioFullTikTokDescription')}</div>` body: checkbox("fullTikTokAudio", t('SettingsAudioFullTikTok'), 3) + explanation(t('SettingsAudioFullTikTokDescription'))
}) })
}, { }, {
name: "other", name: "other",
@ -264,11 +315,11 @@ export default function(obj) {
"action": "light", "action": "light",
"text": t('SettingsThemeLight') "text": t('SettingsThemeLight')
}] }]
}) + checkbox("alwaysVisibleButton", t('SettingsKeepDownloadButton'), t('AccessibilityKeepDownloadButton'), 2) }) + checkbox("alwaysVisibleButton", t('SettingsKeepDownloadButton'), 4, t('AccessibilityKeepDownloadButton'))
}) + settingsCategory({ }) + settingsCategory({
name: "miscellaneous", name: "miscellaneous",
title: t('Miscellaneous'), title: t('Miscellaneous'),
body: checkbox("disableChangelog", t('SettingsDisableNotifications')) + `${!isIOS ? checkbox("downloadPopup", t('SettingsEnableDownloadPopup'), t('AccessibilityEnableDownloadPopup'), 1) : ''}` body: checkbox("disableChangelog", t('SettingsDisableNotifications')) + `${!isIOS ? checkbox("downloadPopup", t('SettingsEnableDownloadPopup'), 1, t('AccessibilityEnableDownloadPopup')) : ''}`
}) })
}], }],
})} })}
@ -283,7 +334,8 @@ export default function(obj) {
name: "download", name: "download",
subtitle: t('DownloadPopupWayToSave'), subtitle: t('DownloadPopupWayToSave'),
explanation: `${!isIOS ? t('DownloadPopupDescription') : t('DownloadPopupDescriptionIOS')}`, explanation: `${!isIOS ? t('DownloadPopupDescription') : t('DownloadPopupDescriptionIOS')}`,
items: `<a id="pd-download" class="switch full space-right" target="_blank" href="/">${t('Download')}</a> items: `<a id="pd-download" class="switch full" target="_blank" href="/">${t('Download')}</a>
<div id="pd-share" class="switch full">${t('ShareURL')}</div>
<div id="pd-copy" class="switch full">${t('CopyURL')}</div>` <div id="pd-copy" class="switch full">${t('CopyURL')}</div>`
}) })
})} })}
@ -301,27 +353,37 @@ export default function(obj) {
name: "error", name: "error",
standalone: true, standalone: true,
buttonOnly: true, buttonOnly: true,
emoji: emoji("☹️", 48, 1),
classes: ["small"], classes: ["small"],
buttonText: t('ErrorPopupCloseButton'), buttonText: t('ErrorPopupCloseButton'),
header: { header: {
closeAria: t('AccessibilityClosePopup'), closeAria: t('AccessibilityClosePopup'),
title: t('TitlePopupError') title: t('TitlePopupError'),
emoji: emoji("☹️", 64, 1),
}, },
body: `<div id="desc-error" class="desc-padding subtext"></div>` body: `<div id="desc-error" class="desc-padding subtext"></div>`
})} })}
<div id="popup-backdrop" style="visibility: hidden;" onclick="hideAllPopups()"></div> <div id="popup-backdrop" style="visibility: hidden;" onclick="hideAllPopups()"></div>
<div id="cobalt-main-box" class="center" style="visibility: hidden;"> <div id="cobalt-main-box" class="center" style="visibility: hidden;">
<div id="logo-area">${appName}</div> <div id="logo">${appName}</div>
<div id="download-area" class="mobile-center"> <div id="download-area">
<div id="top"> <div id="top">
<input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="128" autocapitalize="off" placeholder="${t('LinkInput')}" aria-label="${t('AccessibilityInputArea')}" oninput="button()"></input> <input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="128" autocapitalize="off" placeholder="${t('LinkInput')}" aria-label="${t('AccessibilityInputArea')}" oninput="button()"></input>
<button id="url-clear" onclick="clearInput()" style="display:none;">x</button> <button id="url-clear" onclick="clearInput()" style="display:none;">x</button>
<input id="download-button" class="mono dontRead" onclick="download(document.getElementById('url-input-area').value)" type="submit" value="" disabled=true aria-label="${t('AccessibilityDownloadButton')}"> <input id="download-button" class="mono dontRead" onclick="download(document.getElementById('url-input-area').value)" type="submit" value="" disabled=true aria-label="${t('AccessibilityDownloadButton')}">
</div> </div>
<div id="bottom"> <div id="bottom">
<button id="pasteFromClipboard" class="switch" onclick="pasteClipboard()" aria-label="${t('PasteFromClipboard')}">${emoji("📋", 22)} ${t('PasteFromClipboard')}</button> <button id="paste" class="switch" onclick="pasteClipboard()" aria-label="${t('PasteFromClipboard')}">${emoji("📋", 22)} ${t('PasteFromClipboard')}</button>
<button id="audioMode" class="switch" onclick="toggle('audioMode')" aria-label="${t('AccessibilityModeToggle')}">${emoji("✨", 22, 1)}</button> ${switcher({
name: "audioMode",
noParent: true,
items: [{
"action": "false",
"text": `${emoji("✨")} ${t("ModeToggleAuto")}`
}, {
"action": "true",
"text": `${emoji("🎶")} ${t("ModeToggleAudio")}`
}]
})}
</div> </div>
</div> </div>
</div> </div>
@ -351,8 +413,6 @@ export default function(obj) {
noURLReturned: ` + "`" + t('ErrorNoUrlReturned') + "`" + `, noURLReturned: ` + "`" + t('ErrorNoUrlReturned') + "`" + `,
unknownStatus: ` + "`" + t('ErrorUnknownStatus') + "`" + `, unknownStatus: ` + "`" + t('ErrorUnknownStatus') + "`" + `,
collapseHistory: ` + "`" + t('ChangelogPressToHide') + "`" + `, collapseHistory: ` + "`" + t('ChangelogPressToHide') + "`" + `,
toggleDefault: '${emoji("✨")} ${t("ModeToggleAuto")}',
toggleAudio: '${emoji("🎶")} ${t("ModeToggleAudio")}',
pickerDefault: ` + "`" + t('MediaPickerTitle') + "`" + `, pickerDefault: ` + "`" + t('MediaPickerTitle') + "`" + `,
pickerImages: ` + "`" + t('ImagePickerTitle') + "`" + `, pickerImages: ` + "`" + t('ImagePickerTitle') + "`" + `,
pickerImagesExpl: ` + "`" + t(`ImagePickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `, pickerImagesExpl: ` + "`" + t(`ImagePickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `,

View file

@ -1,67 +1,61 @@
import { apiJSON } from "../sub/utils.js"; import { apiJSON } from "../sub/utils.js";
import { errorUnsupported, genericError } from "../sub/errors.js"; import { errorUnsupported, genericError, brokenLink } from "../sub/errors.js";
import loc from "../../localization/manager.js"; import loc from "../../localization/manager.js";
import { testers } from "./servicesPatternTesters.js"; import { testers } from "./servicesPatternTesters.js";
import bilibili from "../services/bilibili.js";
import reddit from "../services/reddit.js";
import twitter from "../services/twitter.js";
import youtube from "../services/youtube.js";
import vk from "../services/vk.js";
import tiktok from "../services/tiktok.js";
import tumblr from "../services/tumblr.js";
import matchActionDecider from "./matchActionDecider.js"; import matchActionDecider from "./matchActionDecider.js";
import vimeo from "../services/vimeo.js";
import soundcloud from "../services/soundcloud.js"; import bilibili from "./services/bilibili.js";
import reddit from "./services/reddit.js";
import twitter from "./services/twitter.js";
import youtube from "./services/youtube.js";
import vk from "./services/vk.js";
import tiktok from "./services/tiktok.js";
import tumblr from "./services/tumblr.js";
import vimeo from "./services/vimeo.js";
import soundcloud from "./services/soundcloud.js";
export default async function (host, patternMatch, url, lang, obj) { export default async function (host, patternMatch, url, lang, obj) {
try { try {
if (!testers[host]) return apiJSON(0, { t: errorUnsupported(lang) }); let r, isAudioOnly = !!obj.isAudioOnly;
if (!(testers[host](patternMatch))) throw Error();
if (!testers[host]) return apiJSON(0, { t: errorUnsupported(lang) });
if (!(testers[host](patternMatch))) return apiJSON(0, { t: brokenLink(lang, host) });
let r;
switch (host) { switch (host) {
case "twitter": case "twitter":
r = await twitter({ r = await twitter({
id: patternMatch["id"] ? patternMatch["id"] : false, id: patternMatch["id"] ? patternMatch["id"] : false,
spaceId: patternMatch["spaceId"] ? patternMatch["spaceId"] : false, spaceId: patternMatch["spaceId"] ? patternMatch["spaceId"] : false
lang: lang
}); });
if (r.isAudioOnly) obj.isAudioOnly = true
break; break;
case "vk": case "vk":
r = await vk({ r = await vk({
url: url, url: url,
userId: patternMatch["userId"], userId: patternMatch["userId"],
videoId: patternMatch["videoId"], videoId: patternMatch["videoId"],
lang: lang, quality: obj.vQuality quality: obj.vQuality
}); });
break; break;
case "bilibili": case "bilibili":
r = await bilibili({ r = await bilibili({
id: patternMatch["id"].slice(0, 12), id: patternMatch["id"].slice(0, 12)
lang: lang
}); });
break; break;
case "youtube": case "youtube":
let fetchInfo = { let fetchInfo = {
id: patternMatch["id"].slice(0, 11), id: patternMatch["id"].slice(0, 11),
lang: lang, quality: obj.vQuality, quality: obj.vQuality,
format: "webm" format: obj.vCodec,
}; isAudioOnly: isAudioOnly,
if (url.match('music.youtube.com') || obj.isAudioOnly == true) obj.vFormat = "audio"; isAudioMuted: obj.isAudioMuted,
switch (obj.vFormat) { dubLang: obj.dubLang
case "mp4": }
fetchInfo["format"] = "mp4"; if (url.match('music.youtube.com') || isAudioOnly === true) {
break; fetchInfo.quality = "max";
case "audio": fetchInfo.format = "vp9";
fetchInfo["format"] = "webm"; fetchInfo.isAudioOnly = true
fetchInfo["isAudioOnly"] = true;
fetchInfo["quality"] = "max";
obj.isAudioOnly = true;
break;
} }
r = await youtube(fetchInfo); r = await youtube(fetchInfo);
break; break;
@ -69,7 +63,7 @@ export default async function (host, patternMatch, url, lang, obj) {
r = await reddit({ r = await reddit({
sub: patternMatch["sub"], sub: patternMatch["sub"],
id: patternMatch["id"], id: patternMatch["id"],
title: patternMatch["title"], lang: lang, title: patternMatch["title"]
}); });
break; break;
case "douyin": case "douyin":
@ -77,40 +71,47 @@ export default async function (host, patternMatch, url, lang, obj) {
r = await tiktok({ r = await tiktok({
host: host, host: host,
postId: patternMatch["postId"], postId: patternMatch["postId"],
id: patternMatch["id"], lang: lang, id: patternMatch["id"],
noWatermark: obj.isNoTTWatermark, fullAudio: obj.isTTFullAudio, noWatermark: obj.isNoTTWatermark,
isAudioOnly: obj.isAudioOnly fullAudio: obj.isTTFullAudio,
isAudioOnly: isAudioOnly
}); });
if (r.isAudioOnly) obj.isAudioOnly = true;
break; break;
case "tumblr": case "tumblr":
r = await tumblr({ r = await tumblr({
id: patternMatch["id"], url: url, user: patternMatch["user"] ? patternMatch["user"] : false, id: patternMatch["id"],
lang: lang url: url,
user: patternMatch["user"] ? patternMatch["user"] : false
}); });
break; break;
case "vimeo": case "vimeo":
r = await vimeo({ r = await vimeo({
id: patternMatch["id"].slice(0, 11), quality: obj.vQuality, id: patternMatch["id"].slice(0, 11),
lang: lang quality: obj.vQuality,
isAudioOnly: isAudioOnly,
forceDash: isAudioOnly ? true : obj.vimeoDash
}); });
break; break;
case "soundcloud": case "soundcloud":
obj.isAudioOnly = true; isAudioOnly = true;
r = await soundcloud({ r = await soundcloud({
author: patternMatch["author"], song: patternMatch["song"], url: url, author: patternMatch["author"],
song: patternMatch["song"], url: url,
shortLink: patternMatch["shortLink"] ? patternMatch["shortLink"] : false, shortLink: patternMatch["shortLink"] ? patternMatch["shortLink"] : false,
accessKey: patternMatch["accessKey"] ? patternMatch["accessKey"] : false, accessKey: patternMatch["accessKey"] ? patternMatch["accessKey"] : false,
format: obj.aFormat, format: obj.aFormat
lang: lang
}); });
break; break;
default: default:
return apiJSON(0, { t: errorUnsupported(lang) }); return apiJSON(0, { t: errorUnsupported(lang) });
} }
return !r.error ? matchActionDecider(r, host, obj.ip, obj.aFormat, obj.isAudioOnly, lang, obj.isAudioMuted) : apiJSON(0, {
t: Array.isArray(r.error) ? loc(lang, r.error[0], r.error[1]) : loc(lang, r.error) if (r.isAudioOnly) isAudioOnly = true;
}); let isAudioMuted = isAudioOnly ? false : obj.isAudioMuted;
if (r.error) return apiJSON(0, { t: Array.isArray(r.error) ? loc(lang, r.error[0], r.error[1]) : loc(lang, r.error) });
return matchActionDecider(r, host, obj.ip, obj.aFormat, isAudioOnly, lang, isAudioMuted);
} catch (e) { } catch (e) {
return apiJSON(0, { t: genericError(lang, host) }) return apiJSON(0, { t: genericError(lang, host) })
} }

View file

@ -1,121 +1,144 @@
import { audioIgnore, services, supportedAudio } from "../config.js" import { audioIgnore, services, supportedAudio } from "../config.js";
import { apiJSON } from "../sub/utils.js" import { apiJSON } from "../sub/utils.js";
import loc from "../../localization/manager.js"; import loc from "../../localization/manager.js";
export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMuted) { export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMuted) {
if (!isAudioOnly && !r.picker && !isAudioMuted) { let action,
switch (host) { responseType = 2,
case "twitter": defaultParams = {
return apiJSON(1, { u: r.urls }); u: r.urls,
case "vk":
return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename,
});
case "bilibili":
return apiJSON(2, {
type: "render", u: r.urls, service: host, ip: ip,
filename: r.filename,
time: r.time
});
case "youtube":
return apiJSON(2, {
type: r.type, u: r.urls, service: host, ip: ip,
filename: r.filename,
time: r.time,
});
case "reddit":
return apiJSON(r.typeId, {
type: r.type, u: r.urls, service: host, ip: ip,
filename: r.filename,
});
case "tiktok":
return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename,
});
case "douyin":
return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename,
});
case "tumblr":
return apiJSON(1, { u: r.urls });
case "vimeo":
if (Array.isArray(r.urls)) {
return apiJSON(2, {
type: "render", u: r.urls, service: host, ip: ip,
filename: r.filename
});
} else {
return apiJSON(1, { u: r.urls });
}
}
} else if (isAudioMuted && !isAudioOnly) {
let isSplit = Array.isArray(r.urls);
return apiJSON(2, {
type: isSplit ? "bridge" : "mute",
u: isSplit ? r.urls[0] : r.urls,
service: host, service: host,
ip: ip, ip: ip,
filename: r.filename, filename: r.filename,
mute: true, },
}); params = {}
} else if (r.picker) {
if (!isAudioOnly && !r.picker && !isAudioMuted) action = "video";
if (r.isM3U8) action = "singleM3U8";
if (isAudioOnly && !r.picker) action = "audio";
if (r.picker) action = "picker";
if (isAudioMuted) action = "muteVideo";
if (action === "picker" || action === "audio") {
defaultParams.filename = r.audioFilename;
defaultParams.isAudioOnly = true;
defaultParams.audioFormat = audioFormat;
}
switch (action) {
case "video":
switch (host) { switch (host) {
case "bilibili":
params = { type: "render", time: r.time };
break;
case "youtube":
params = { type: r.type, time: r.time };
break;
case "reddit":
responseType = r.typeId;
params = { type: r.type };
break;
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "render" }
} else {
responseType = 1;
}
break;
case "vk":
case "douyin": case "douyin":
case "tiktok": case "tiktok":
let type = "render"; params = { type: "bridge" };
if (audioFormat === "mp3" || audioFormat === "best") { break;
audioFormat = "mp3"
type = "bridge" case "tumblr":
}
return apiJSON(5, {
type: type,
picker: r.picker,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip,
filename: r.audioFilename, isAudioOnly: true, audioFormat: audioFormat, copy: audioFormat === "best" ? true : false,
})
case "twitter": case "twitter":
return apiJSON(5, { responseType = 1;
picker: r.picker, service: host break;
})
} }
} else if (isAudioOnly) { break;
if ((host === "reddit" && r.typeId === 1) || (host === "vimeo" && !r.filename) || audioIgnore.includes(host)) return apiJSON(0, { t: loc(lang, 'ErrorEmptyDownload') }); case "singleM3U8":
let type = "render"; params = { type: "videoM3U8" }
break;
case "muteVideo":
params = {
type: Array.isArray(r.urls) ? "bridge" : "mute",
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
mute: true
}
break;
case "picker":
responseType = 5;
switch (host) {
case "twitter":
params = { picker: r.picker };
break;
case "douyin":
case "tiktok":
let pickerType = "render";
if (audioFormat === "mp3" || audioFormat === "best") {
audioFormat = "mp3";
pickerType = "bridge"
}
params = {
type: pickerType,
picker: r.picker,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
copy: audioFormat === "best" ? true : false
}
}
break;
case "audio":
if ((host === "reddit" && r.typeId === 1) || audioIgnore.includes(host)) return apiJSON(0, { t: loc(lang, 'ErrorEmptyDownload') });
let processType = "render";
let copy = false; let copy = false;
if (!supportedAudio.includes(audioFormat)) audioFormat = "best"; if (!supportedAudio.includes(audioFormat)) audioFormat = "best";
if ((host == "tiktok" || host == "douyin") && services.tiktok.audioFormats.includes(audioFormat)) {
if ((host === "tiktok" || host === "douyin") && services.tiktok.audioFormats.includes(audioFormat)) {
if (r.isMp3) { if (r.isMp3) {
if (audioFormat === "mp3" || audioFormat === "best") { if (audioFormat === "mp3" || audioFormat === "best") {
audioFormat = "mp3" audioFormat = "mp3";
type = "bridge" processType = "bridge"
} }
} else if (audioFormat === "best") { } else if (audioFormat === "best") {
audioFormat = "m4a" audioFormat = "m4a";
type = "bridge" processType = "bridge"
} }
} }
if ((audioFormat === "best" && services[host]["bestAudio"]) || services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"])) {
audioFormat = services[host]["bestAudio"] if ((audioFormat === "best" && services[host]["bestAudio"])
type = "bridge" || services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"])) {
audioFormat = services[host]["bestAudio"];
processType = "bridge"
} else if (audioFormat === "best") { } else if (audioFormat === "best") {
audioFormat = "m4a" audioFormat = "m4a";
copy = true copy = true;
if (r.audioFilename.includes("twitterspaces")) { if (r.audioFilename.includes("twitterspaces")) {
audioFormat = "mp3" audioFormat = "mp3"
copy = false copy = false
} }
} }
return apiJSON(2, { if (r.isM3U8 || host === "vimeo") {
type: type, copy = false;
u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip, processType = "render"
filename: r.audioFilename, isAudioOnly: true,
audioFormat: audioFormat, copy: copy, fileMetadata: r.fileMetadata ? r.fileMetadata : false
})
} else {
return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') });
} }
params = {
type: processType,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioFormat: audioFormat,
copy: copy,
fileMetadata: r.fileMetadata ? r.fileMetadata : false
}
break;
default:
return apiJSON(0, { t: loc(lang, 'ErrorEmptyDownload') });
}
return apiJSON(responseType, {...defaultParams, ...params})
} }

View file

@ -0,0 +1,28 @@
import { genericUserAgent, maxVideoDuration } from "../../config.js";
// TO-DO: quality picking, bilibili.tv support, and higher quality downloads (currently requires an account)
export default async function(obj) {
let html = await fetch(`https://bilibili.com/video/${obj.id}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) return { error: 'ErrorEmptyDownload' };
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
if (streamData.data.timelength > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let video = streamData["data"]["dash"]["video"].filter((v) => {
if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter((a) => {
if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
return {
urls: [video[0]["baseUrl"], audio[0]["baseUrl"]],
time: streamData.data.timelength,
audioFilename: `bilibili_${obj.id}_audio`,
filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4`
};
}

View file

@ -0,0 +1,28 @@
import { maxVideoDuration } from "../../config.js";
export default async function(obj) {
let data = await fetch(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`).then((r) => { return r.json() }).catch(() => { return false });
if (!data) return { error: 'ErrorCouldntFetch' };
data = data[0]["data"]["children"][0]["data"];
if (data.url.endsWith('.gif')) return { typeId: 1, urls: data.url };
if (!("reddit_video" in data["secure_media"])) return { error: 'ErrorEmptyDownload' };
if (data["secure_media"]["reddit_video"]["duration"] * 1000 > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let video = data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0],
audio = video.match('.mp4') ? `${video.split('_')[0]}_audio.mp4` : `${data["secure_media"]["reddit_video"]["fallback_url"].split('DASH')[0]}audio`;
await fetch(audio, { method: "HEAD" }).then((r) => {if (Number(r.status) !== 200) audio = ''}).catch(() => {audio = ''});
let id = data["secure_media"]["reddit_video"]["fallback_url"].split('/')[3];
if (!audio.length > 0) return { typeId: 1, urls: video };
return {
typeId: 2,
type: "render",
urls: [video, audio],
audioFilename: `reddit_${id}_audio`,
filename: `reddit_${id}.mp4`
};
}

View file

@ -0,0 +1,74 @@
import { maxVideoDuration } from "../../config.js";
let cachedID = {};
async function findClientID() {
try {
let sc = await fetch('https://soundcloud.com/').then((r) => { return r.text() }).catch(() => { return false });
let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
if (cachedID.version === scVersion) return cachedID.id;
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
let url = script[1];
if (url && !url.startsWith('https://a-v2.sndcdn.com')) return;
let scrf = await fetch(url).then((r) => {return r.text()}).catch(() => { return false });
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
if (id && typeof id[0] === 'string') {
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
break;
}
}
cachedID.version = scVersion;
cachedID.id = clientid;
return clientid;
} catch (e) {
return false;
}
}
export default async function(obj) {
let html;
if (!obj.author && !obj.song && obj.shortLink) {
html = await fetch(`https://soundcloud.app.goo.gl/${obj.shortLink}/`).then((r) => { return r.text() }).catch(() => { return false });
}
if (obj.author && obj.song) {
html = await fetch(`https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`).then((r) => { return r.text() }).catch(() => { return false });
}
if (!html) return { error: 'ErrorCouldntFetch'};
if (!(html.includes('<script>window.__sc_hydration = ')
&& html.includes('"format":{"protocol":"progressive","mime_type":"audio/mpeg"},')
&& html.includes('{"hydratable":"sound","data":'))) {
return { error: ['ErrorBrokenLink', 'soundcloud'] }
}
let json = JSON.parse(html.split('{"hydratable":"sound","data":')[1].split('}];</script>')[0])
if (!json["media"]["transcodings"]) return { error: 'ErrorEmptyDownload' };
let clientId = await findClientID();
if (!clientId) return { error: 'ErrorSoundCloudNoClientId' };
let fileUrlBase = json.media.transcodings[0]["url"].replace("/hls", "/progressive"),
fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
if (fileUrl.substring(0, 54) !== "https://api-v2.soundcloud.com/media/soundcloud:tracks:") return { error: 'ErrorEmptyDownload' };
if (json.duration > maxVideoDuration) return { error: ['ErrorLengthAudioConvert', maxVideoDuration / 60000] };
let file = await fetch(fileUrl).then(async (r) => { return (await r.json()).url }).catch(() => { return false });
if (!file) return { error: 'ErrorCouldntFetch' };
return {
urls: file,
audioFilename: `soundcloud_${json.id}`,
fileMetadata: {
title: json.title,
artist: json.user.username,
}
}
}

View file

@ -0,0 +1,112 @@
import { genericUserAgent } from "../../config.js";
const userAgent = genericUserAgent.split(' Chrome/1')[0],
config = {
tiktok: {
short: "https://vt.tiktok.com/",
api: "https://api2.musical.ly/aweme/v1/feed/?aweme_id={postId}&version_code=262&app_name=musical_ly&channel=App&device_id=null&os_version=14.4.2&device_platform=iphone&device_type=iPhone9&region=US&carrier_region=US"
},
douyin: {
short: "https://v.douyin.com/",
api: "https://www.iesdouyin.com/aweme/v1/web/aweme/detail/?aweme_id={postId}"
}
}
function selector(j, h, id) {
if (!j) return false;
let t;
switch (h) {
case "tiktok":
t = j["aweme_list"].filter((v) => { if (v["aweme_id"] === id) return true })[0];
break;
case "douyin":
t = j['aweme_detail'];
break;
}
if (t.length < 3) return false;
return t;
}
export default async function(obj) {
let postId = obj.postId ? obj.postId : false;
if (!postId) {
let html = await fetch(`${config[obj.host]["short"]}${obj.id}`, {
redirect: "manual",
headers: { "user-agent": userAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.slice(0, 17) === '<a href="https://' && html.includes('/video/')) {
postId = html.split('/video/')[1].split('?')[0].replace("/", '')
} else if (html.slice(0, 32) === '<a href="https://m.tiktok.com/v/' && html.includes('/v/')) {
postId = html.split('/v/')[1].split('.html')[0].replace("/", '')
}
}
if (!postId) return { error: 'ErrorCantGetID' };
let detail;
detail = await fetch(config[obj.host]["api"].replace("{postId}", postId), {
headers: {"user-agent": "TikTok 26.2.0 rv:262018 (iPhone; iOS 14.4.2; en_US) Cronet"}
}).then((r) => { return r.json() }).catch(() => { return false });
detail = selector(detail, obj.host, postId);
if (!detail) return { error: 'ErrorCouldntFetch' };
let video, videoFilename, audioFilename, isMp3, audio, images, filenameBase = `${obj.host}_${postId}`;
if (obj.host === "tiktok") {
images = detail["image_post_info"] ? detail["image_post_info"]["images"] : false
} else {
images = detail["images"] ? detail["images"] : false
}
if (!obj.isAudioOnly && !images) {
video = obj.host === "tiktok" ? detail["video"]["download_addr"]["url_list"][0] : detail["video"]["play_addr"]["url_list"][2].replace("/play/", "/playwm/");
videoFilename = `${filenameBase}_video.mp4`;
if (obj.noWatermark) {
video = obj.host === "tiktok" ? detail["video"]["play_addr"]["url_list"][0] : detail["video"]["play_addr"]["url_list"][0];
videoFilename = `${filenameBase}_video_nw.mp4` // nw - no watermark
}
} else {
let fallback = obj.host === "douyin" ? detail["video"]["play_addr"]["url_list"][0].replace("playwm", "play") : detail["video"]["play_addr"]["url_list"][0];
audio = fallback;
audioFilename = `${filenameBase}_audio_fv`; // fv - from video
if (obj.fullAudio || fallback.includes("music")) {
audio = detail["music"]["play_url"]["url_list"][0]
audioFilename = `${filenameBase}_audio`
}
if (audio.slice(-4) === ".mp3") isMp3 = true;
}
if (video) return {
urls: video,
filename: videoFilename
}
if (images && obj.isAudioOnly) return {
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3
}
if (images) {
let imageLinks = [];
for (let i in images) {
let sel = obj.host === "tiktok" ? images[i]["display_image"]["url_list"] : images[i]["url_list"];
sel = sel.filter((p) => { if (p.includes(".jpeg?")) return true; })
imageLinks.push({url: sel[0]})
}
return {
picker: imageLinks,
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3
}
}
if (audio) return {
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3
}
}

View file

@ -0,0 +1,14 @@
import { genericUserAgent } from "../../config.js";
export default async function(obj) {
let html = await fetch(`https://${
obj.user ? obj.user : obj.url.split('.')[0].replace('https://', '')
}.tumblr.com/post/${obj.id}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html.includes('property="og:video" content="https://va.media.tumblr.com/')) return { error: 'ErrorEmptyDownload' };
return { urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`, audioFilename: `tumblr_${obj.id}_audio` }
}

View file

@ -0,0 +1,108 @@
import { genericUserAgent } from "../../config.js";
function bestQuality(arr) {
return arr.filter((v) => { if (v["content_type"] === "video/mp4") return true }).sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"].split("?")[0]
}
const apiURL = "https://api.twitter.com/1.1"
// TO-DO: move from 1.1 api to graphql
export default async function(obj) {
let _headers = {
"user-agent": genericUserAgent,
"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
// ^ no explicit content, but with multi media support
"host": "api.twitter.com"
};
let req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then((r) => { return r.status === 200 ? r.json() : false }).catch(() => { return false });
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
let showURL = `${apiURL}/statuses/show/${obj.id}.json?tweet_mode=extended&include_user_entities=0&trim_user=1&include_entities=0&cards_platform=Web-12&include_cards=1`;
if (!obj.spaceId) {
let req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status === 200 ? r.json() : false }).catch((e) => { return false });
if (!req_status) {
_headers.authorization = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw";
// ^ explicit content, but no multi media support
delete _headers["x-guest-token"]
req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then((r) => { return r.status === 200 ? r.json() : false}).catch(() => { return false });
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status === 200 ? r.json() : false }).catch(() => { return false });
}
if (!req_status) return { error: 'ErrorCouldntFetch' };
let baseStatus;
if (req_status["extended_entities"] && req_status["extended_entities"]["media"]) {
baseStatus = req_status["extended_entities"]
} else if (req_status["retweeted_status"] && req_status["retweeted_status"]["extended_entities"] && req_status["retweeted_status"]["extended_entities"]["media"]) {
baseStatus = req_status["retweeted_status"]["extended_entities"]
}
if (!baseStatus) return { error: 'ErrorNoVideosInTweet' };
let single, multiple = [], media = baseStatus["media"];
media = media.filter((i) => { if (i["type"] === "video" || i["type"] === "animated_gif") return true })
if (media.length > 1) {
for (let i in media) { multiple.push({type: "video", thumb: media[i]["media_url_https"], url: bestQuality(media[i]["video_info"]["variants"])}) }
} else if (media.length === 1) {
single = bestQuality(media[0]["video_info"]["variants"])
} else {
return { error: 'ErrorNoVideosInTweet' }
}
if (single) {
return { urls: single, filename: `twitter_${obj.id}.mp4`, audioFilename: `twitter_${obj.id}_audio` }
} else if (multiple) {
return { picker: multiple }
} else {
return { error: 'ErrorNoVideosInTweet' }
}
} else {
_headers["host"] = "twitter.com";
_headers["content-type"] = "application/json";
let query = {
variables: {"id": obj.spaceId,"isMetatagsQuery":true,"withDownvotePerspective":false,"withReactionsMetadata":false,"withReactionsPerspective":false,"withReplays":true},
features: {"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"responsive_web_twitter_blue_verified_badge_is_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"tweetypie_unmention_optimization_enabled":true,"vibe_api_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":false,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"interactive_text_enabled":true,"responsive_web_text_conversations_enabled":false,"longform_notetweets_richtext_consumption_enabled":false,"responsive_web_enhance_cards_enabled":false}
}
query.variables = new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1);
query.features = new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1);
query = `https://twitter.com/i/api/graphql/Gdz2uCtmIGMmhjhHG3V7nA/AudioSpaceById?variables=${query.variables}&features=${query.features}`;
let AudioSpaceById = await fetch(query, { headers: _headers }).then((r) => {return r.status === 200 ? r.json() : false}).catch((e) => { return false });
if (!AudioSpaceById) return { error: 'ErrorEmptyDownload' };
if (!AudioSpaceById.data.audioSpace.metadata) return { error: 'ErrorEmptyDownload' };
if (AudioSpaceById.data.audioSpace.metadata.is_space_available_for_replay !== true) return { error: 'TwitterSpaceWasntRecorded' };
let streamStatus = await fetch(
`https://twitter.com/i/api/1.1/live_video_stream/status/${AudioSpaceById.data.audioSpace.metadata.media_key}`, { headers: _headers }
).then((r) =>{ return r.status === 200 ? r.json() : false }).catch(() => { return false });
if (!streamStatus) return { error: 'ErrorCouldntFetch' };
let participants = AudioSpaceById.data.audioSpace.participants.speakers,
listOfParticipants = `Twitter Space speakers: `;
for (let i in participants) { listOfParticipants += `@${participants[i]["twitter_screen_name"]}, ` }
listOfParticipants = listOfParticipants.slice(0, -2);
return {
urls: streamStatus.source.noRedirectPlaybackUrl,
audioFilename: `twitterspaces_${obj.spaceId}`,
isAudioOnly: true,
fileMetadata: {
title: AudioSpaceById.data.audioSpace.metadata.title,
artist: `Twitter Space by @${AudioSpaceById.data.audioSpace.metadata.creator_results.result.legacy.screen_name}`,
comment: listOfParticipants,
// cover: AudioSpaceById.data.audioSpace.metadata.creator_results.result.legacy.profile_image_url_https.replace("_normal", "")
}
}
}
}

View file

@ -0,0 +1,85 @@
import { maxVideoDuration } from "../../config.js";
const resolutionMatch = {
"3840": "2160",
"2732": "1440",
"2048": "1080",
"1920": "1080",
"1366": "720",
"1280": "720",
"960": "480",
"640": "360",
"426": "240"
}
// ^ vimeo you're fucked in the head for this ^
const qualityMatch = {
"2160": "4K",
"1440": "2K",
"480": "540",
"4K": "2160",
"2K": "1440",
"540": "480"
}
export default async function(obj) {
let quality = obj.quality === "max" ? "9000" : obj.quality;
if (!quality || obj.isAudioOnly) quality = "9000";
let api = await fetch(`https://player.vimeo.com/video/${obj.id}/config`).then((r) => { return r.json() }).catch(() => { return false });
if (!api) return { error: 'ErrorCouldntFetch' };
let downloadType = "dash";
if (!obj.forceDash && JSON.stringify(api).includes('"progressive":[{')) downloadType = "progressive";
if (downloadType !== "dash") {
if (qualityMatch[quality]) quality = qualityMatch[quality];
let all = api["request"]["files"]["progressive"].sort((a, b) => Number(b.width) - Number(a.width));
let best = all[0];
let bestQuality = all[0]["quality"].split('p')[0];
bestQuality = qualityMatch[bestQuality] ? qualityMatch[bestQuality] : bestQuality;
if (Number(quality) < Number(bestQuality)) best = all.find(i => i["quality"].split('p')[0] === quality);
if (!best) return { error: 'ErrorEmptyDownload' };
return { urls: best["url"], audioFilename: `vimeo_${obj.id}_audio`, filename: `vimeo_${obj.id}_${best["width"]}x${best["height"]}.mp4` }
}
if (api.video.duration > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let masterJSONURL = api["request"]["files"]["dash"]["cdns"]["akfire_interconnect_quic"]["url"];
let masterJSON = await fetch(masterJSONURL).then((r) => { return r.json() }).catch(() => { return false });
if (!masterJSON) return { error: 'ErrorCouldntFetch' };
if (!masterJSON.video) return { error: 'ErrorEmptyDownload' };
let type = "parcel";
if (masterJSON.base_url === "../") type = "chop";
let masterJSON_Video = masterJSON.video.sort((a, b) => Number(b.width) - Number(a.width)),
bestVideo = masterJSON_Video[0];
if (Number(quality) < Number(resolutionMatch[bestVideo["width"]])) bestVideo = masterJSON_Video.find(i => resolutionMatch[i["width"]] === quality);
let videoUrl, audioUrl, baseUrl = masterJSONURL.split("/sep/")[0];
switch (type) {
case "parcel":
let masterJSON_Audio = masterJSON.audio.sort((a, b) => Number(b.bitrate) - Number(a.bitrate)).filter((a) => { if (a['mime_type'] === "audio/mp4") return true }),
bestAudio = masterJSON_Audio[0];
videoUrl = `${baseUrl}/parcel/video/${bestVideo.index_segment.split('?')[0]}`,
audioUrl = `${baseUrl}/parcel/audio/${bestAudio.index_segment.split('?')[0]}`;
break;
case "chop":
videoUrl = `${baseUrl}/sep/video/${bestVideo.id}/master.m3u8`;
break;
}
if (videoUrl) {
return {
urls: audioUrl ? [videoUrl, audioUrl] : videoUrl,
isM3U8: audioUrl ? false : true,
audioFilename: `vimeo_${obj.id}_audio`,
filename: `vimeo_${obj.id}_${bestVideo["width"]}x${bestVideo["height"]}.mp4`
}
}
return { error: 'ErrorEmptyDownload' }
}

View file

@ -0,0 +1,49 @@
import { xml2json } from "xml-js";
import { genericUserAgent, maxVideoDuration } from "../../config.js";
const representationMatch = {
"2160": 7,
"1440": 6,
"1080": 5,
"720": 4,
"480": 3,
"360": 2,
"240": 1,
"144": 0
}, resolutionMatch = {
"3840": "2160",
"2560": "1440",
"1920": "1080",
"1280": "720",
"852": "480",
"640": "360",
"426": "240",
// "256": "144"
}
export default async function(o) {
let html;
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html.includes(`{"lang":`)) return { error: 'ErrorEmptyDownload' };
let quality = o.quality === "max" ? 7 : representationMatch[o.quality],
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 > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let mpd = JSON.parse(xml2json(js.player.params[0]["manifest"], { compact: true, spaces: 4 })),
repr = mpd.MPD.Period.AdaptationSet.Representation ? mpd.MPD.Period.AdaptationSet.Representation : mpd.MPD.Period.AdaptationSet[0]["Representation"],
bestQuality = repr[repr.length - 1],
resolutionPick = Number(bestQuality._attributes.width) > Number(bestQuality._attributes.height) ? 'width': 'height';
if (Number(bestQuality._attributes.id) > Number(quality)) bestQuality = repr[quality];
if (bestQuality) return {
urls: js.player.params[0][`url${resolutionMatch[bestQuality._attributes[resolutionPick]]}`],
filename: `vk_${o.userId}_${o.videoId}_${bestQuality._attributes.width}x${bestQuality._attributes.height}.mp4`
};
return { error: 'ErrorEmptyDownload' }
}

View file

@ -0,0 +1,97 @@
import { Innertube } from 'youtubei.js';
import { maxVideoDuration } from '../../config.js';
const yt = await Innertube.create();
const c = {
h264: {
codec: "avc1",
aCodec: "mp4a",
container: "mp4"
},
av1: {
codec: "av01",
aCodec: "mp4a",
container: "mp4"
},
vp9: {
codec: "vp9",
aCodec: "opus",
container: "webm"
}
}
export default async function(o) {
let info, isDubbed, quality = o.quality === "max" ? "9000" : o.quality; //set quality 9000(p) to be interpreted as max
try {
info = await yt.getBasicInfo(o.id, 'ANDROID');
} catch (e) {
return { error: 'ErrorCantConnectToServiceAPI' };
}
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
if (info.playability_status.status !== 'OK') return { error: 'ErrorYTUnavailable' };
if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' };
let bestQuality, hasAudio, adaptive_formats = info.streaming_data.adaptive_formats.filter((e) => {
if (e["mime_type"].includes(c[o.format].codec) || e["mime_type"].includes(c[o.format].aCodec)) return true
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
bestQuality = adaptive_formats.find(i => i["has_video"]);
hasAudio = adaptive_formats.find(i => i["has_audio"]);
if (bestQuality) bestQuality = bestQuality['quality_label'].split('p')[0];
if (!bestQuality && !o.isAudioOnly || !hasAudio) return { error: 'ErrorYTTryOtherCodec' };
if (info.basic_info.duration > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let checkBestAudio = (i) => (i["has_audio"] && !i["has_video"]),
audio = adaptive_formats.find(i => checkBestAudio(i) && i["is_original"]);
if (o.dubLang) {
let dubbedAudio = adaptive_formats.find(i => checkBestAudio(i) && i["language"] === o.dubLang);
if (dubbedAudio) {
audio = dubbedAudio;
isDubbed = true
}
}
if (hasAudio && o.isAudioOnly) {
let r = {
type: "render",
isAudioOnly: true,
urls: audio.url,
audioFilename: `youtube_${o.id}_audio${isDubbed ? `_${o.dubLang}`:''}`,
fileMetadata: {
title: info.basic_info.title,
artist: info.basic_info.author.replace("- Topic", "").trim(),
}
};
if (info.basic_info.short_description && info.basic_info.short_description.startsWith("Provided to YouTube by")) {
let descItems = info.basic_info.short_description.split("\n\n")
r.fileMetadata.album = descItems[2]
r.fileMetadata.copyright = descItems[3]
if (descItems[4].startsWith("Released on:")) r.fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
};
return r
}
let checkSingle = (i) => ((i['quality_label'].split('p')[0] === quality || i['quality_label'].split('p')[0] === bestQuality) && i["mime_type"].includes(c[o.format].codec)),
checkBestVideo = (i) => (i["has_video"] && !i["has_audio"] && i['quality_label'].split('p')[0] === bestQuality),
checkRightVideo = (i) => (i["has_video"] && !i["has_audio"] && i['quality_label'].split('p')[0] === quality);
if (!o.isAudioOnly && !o.isAudioMuted && o.format === 'h264') {
let single = info.streaming_data.formats.find(i => checkSingle(i));
if (single) return {
type: "bridge",
urls: single.url,
filename: `youtube_${o.id}_${single.width}x${single.height}_${o.format}.${c[o.format].container}`
}
};
let video = adaptive_formats.find(i => ((Number(quality) > Number(bestQuality)) ? checkBestVideo(i) : checkRightVideo(i)));
if (video && audio) return {
type: "render",
urls: [video.url, audio.url],
filename: `youtube_${o.id}_${video.width}x${video.height}_${o.format}${isDubbed ? `_${o.dubLang}`:''}.${c[o.format].container}`
};
return { error: 'ErrorYTTryOtherCodec' }
}

View file

@ -2,60 +2,29 @@
"audioIgnore": ["vk"], "audioIgnore": ["vk"],
"config": { "config": {
"bilibili": { "bilibili": {
"alias": "bilibili.com", "alias": "bilibili (.com only)",
"patterns": ["video/:id"], "patterns": ["video/:id"],
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
"enabled": true "enabled": true
}, },
"reddit": { "reddit": {
"alias": "reddit videos & gifs",
"patterns": ["r/:sub/comments/:id/:title"], "patterns": ["r/:sub/comments/:id/:title"],
"enabled": true "enabled": true
}, },
"twitter": { "twitter": {
"alias": "twitter posts & spaces", "alias": "twitter posts & spaces & voice",
"patterns": [":user/status/:id", ":user/status/:id/video/:v", "i/spaces/:spaceId"], "patterns": [":user/status/:id", ":user/status/:id/video/:v", "i/spaces/:spaceId"],
"enabled": true "enabled": true
}, },
"vk": { "vk": {
"alias": "vk video & clips", "alias": "vk video & clips",
"patterns": ["video-:userId_:videoId", "clip-:userId_:videoId", "clips-:userId?z=clip-:userId_:videoId"], "patterns": ["video:userId_:videoId", "clip:userId_:videoId", "clips:duplicate?z=clip:userId_:videoId"],
"quality_match": {
"2160": 7,
"1440": 6,
"1080": 5,
"720": 3,
"480": 2,
"360": 1,
"240": 0,
"144": 4
},
"representation_match": {
"2160": 7,
"1440": 6,
"1080": 5,
"720": 4,
"480": 3,
"360": 2,
"240": 1,
"144": 0
},
"quality": {
"1080": "hig",
"720": "mid",
"480": "low"
},
"enabled": true "enabled": true
}, },
"youtube": { "youtube": {
"alias": "youtube videos & shorts & music", "alias": "youtube videos & shorts & music",
"patterns": ["watch?v=:id"], "patterns": ["watch?v=:id"],
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
"bestAudio": "opus", "bestAudio": "opus",
"quality": {
"1080": "hig",
"720": "mid",
"480": "low"
},
"enabled": true "enabled": true
}, },
"tumblr": { "tumblr": {
@ -63,25 +32,20 @@
"enabled": true "enabled": true
}, },
"tiktok": { "tiktok": {
"alias": "tiktok videos & slideshow & audio", "alias": "tiktok videos & photos & audio",
"patterns": [":user/video/:postId", ":id", "t/:id"], "patterns": [":user/video/:postId", ":id", "t/:id"],
"audioFormats": ["best", "m4a", "mp3"], "audioFormats": ["best", "m4a", "mp3"],
"enabled": true "enabled": true
}, },
"douyin": { "douyin": {
"alias": "douyin videos & slideshow & audio", "alias": "douyin videos & audio",
"patterns": ["video/:postId", ":id"], "patterns": ["video/:postId", ":id"],
"enabled": true "enabled": false
}, },
"vimeo": { "vimeo": {
"patterns": [":id"], "patterns": [":id"],
"resolutionMatch": { "enabled": true,
"3840": "2160", "bestAudio": "mp3"
"1920": "1080",
"1280": "720",
"960": "480"
},
"enabled": true
}, },
"soundcloud": { "soundcloud": {
"patterns": [":author/:song/s-:accessKey", ":author/:song", ":shortLink"], "patterns": [":author/:song/s-:accessKey", ":author/:song", ":shortLink"],

View file

@ -1,27 +1,28 @@
export const testers = { export const testers = {
"twitter": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length < 20) || (patternMatch["spaceId"] && patternMatch["spaceId"].length === 13), "twitter": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length < 20)
|| (patternMatch["spaceId"] && patternMatch["spaceId"].length === 13),
"vk": (patternMatch) => (patternMatch["userId"] && patternMatch["videoId"] && "vk": (patternMatch) => (patternMatch["userId"] && patternMatch["videoId"]
patternMatch["userId"].length <= 10 && patternMatch["videoId"].length === 9), && patternMatch["userId"].length <= 10 && patternMatch["videoId"].length === 9),
"bilibili": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length >= 12), "bilibili": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length >= 12),
"youtube": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length >= 11), "youtube": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length >= 11),
"reddit": (patternMatch) => (patternMatch["sub"] && patternMatch["id"] && patternMatch["title"] && "reddit": (patternMatch) => (patternMatch["sub"] && patternMatch["id"] && patternMatch["title"]
patternMatch["sub"].length <= 22 && patternMatch["id"].length <= 10 && patternMatch["title"].length <= 96), && patternMatch["sub"].length <= 22 && patternMatch["id"].length <= 10 && patternMatch["title"].length <= 96),
"tiktok": (patternMatch) => ((patternMatch["user"] && patternMatch["postId"] && patternMatch["postId"].length <= 21) || "tiktok": (patternMatch) => ((patternMatch["user"] && patternMatch["postId"] && patternMatch["postId"].length <= 21)
(patternMatch["id"] && patternMatch["id"].length <= 13)), || (patternMatch["id"] && patternMatch["id"].length <= 13)),
"douyin": (patternMatch) => ((patternMatch["postId"] && patternMatch["postId"].length <= 21) || "douyin": (patternMatch) => ((patternMatch["postId"] && patternMatch["postId"].length <= 21)
(patternMatch["id"] && patternMatch["id"].length <= 13)), || (patternMatch["id"] && patternMatch["id"].length <= 13)),
"tumblr": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length < 21) || "tumblr": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length < 21)
(patternMatch["id"] && patternMatch["id"].length < 21 && patternMatch["user"] && patternMatch["user"].length <= 32)), || (patternMatch["id"] && patternMatch["id"].length < 21 && patternMatch["user"] && patternMatch["user"].length <= 32)),
"vimeo": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length <= 11)), "vimeo": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length <= 11)),
"soundcloud": (patternMatch) => ((patternMatch["author"] && patternMatch["song"] && (patternMatch["author"].length + patternMatch["song"].length) <= 96) || "soundcloud": (patternMatch) => ((patternMatch["author"] && patternMatch["song"]
(patternMatch["shortLink"] && patternMatch["shortLink"].length <= 32)) && (patternMatch["author"].length + patternMatch["song"].length) <= 96) || (patternMatch["shortLink"] && patternMatch["shortLink"].length <= 32))
}; }

View file

@ -1,29 +0,0 @@
import { genericUserAgent, maxVideoDuration } from "../config.js";
export default async function(obj) {
try {
let html = await fetch(`https://bilibili.com/video/${obj.id}`, {
headers: {"user-agent": genericUserAgent}
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"')) {
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
if (streamData.data.timelength <= maxVideoDuration) {
let video = streamData["data"]["dash"]["video"].filter((v) => {
if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter((a) => {
if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
return { urls: [video[0]["baseUrl"], audio[0]["baseUrl"]], time: streamData.data.timelength, audioFilename: `bilibili_${obj.id}_audio`, filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4` };
} else {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
} else {
return { error: 'ErrorEmptyDownload' };
}
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,27 +0,0 @@
import { maxVideoDuration } from "../config.js";
export default async function(obj) {
try {
let data = await fetch(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`).then((r) => {return r.json()}).catch(() => {return false});
if (!data) return { error: 'ErrorCouldntFetch' };
data = data[0]["data"]["children"][0]["data"];
if ("reddit_video" in data["secure_media"] && data["secure_media"]["reddit_video"]["duration"] * 1000 < maxVideoDuration) {
let video = data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0],
audio = video.match('.mp4') ? `${video.split('_')[0]}_audio.mp4` : `${data["secure_media"]["reddit_video"]["fallback_url"].split('DASH')[0]}audio`;
await fetch(audio, {method: "HEAD"}).then((r) => {if (r.status != 200) audio = ''}).catch(() => {audio = ''});
let id = data["secure_media"]["reddit_video"]["fallback_url"].split('/')[3]
if (audio.length > 0) {
return { typeId: 2, type: "render", urls: [video, audio], audioFilename: `reddit_${id}_audio`, filename: `reddit_${id}.mp4` };
} else {
return { typeId: 1, urls: video };
}
} else {
return { error: 'ErrorEmptyDownload' };
}
} catch (err) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,79 +0,0 @@
import { genericUserAgent, maxAudioDuration } from "../config.js";
let cachedID = {}
async function findClientID() {
try {
let sc = await fetch('https://soundcloud.com/').then((r) => {return r.text()}).catch(() => {return false});
let sc_version = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
if (cachedID.version == sc_version) {
return cachedID.id
} else {
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
let url = script[1];
if (url && !url.startsWith('https://a-v2.sndcdn.com')) return;
let scrf = await fetch(url).then((r) => {return r.text()}).catch(() => {return false});
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
if (id && typeof id[0] === 'string') {
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
break;
}
}
cachedID.version = sc_version;
cachedID.id = clientid;
return clientid;
}
} catch (e) {
return false;
}
}
export default async function(obj) {
try {
let html;
if (!obj.author && !obj.song && obj.shortLink) {
html = await fetch(`https://soundcloud.app.goo.gl/${obj.shortLink}/`, {
headers: {"user-agent": genericUserAgent}
}).then((r) => {return r.text()}).catch(() => {return false});
}
if (obj.author && obj.song) {
html = await fetch(`https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`, {
headers: {"user-agent": genericUserAgent}
}).then((r) => {return r.text()}).catch(() => {return false});
}
if (!html) return { error: 'ErrorCouldntFetch'};
if (html.includes('<script>window.__sc_hydration = ') && html.includes('"format":{"protocol":"progressive","mime_type":"audio/mpeg"},') && html.includes('{"hydratable":"sound","data":')) {
let json = JSON.parse(html.split('{"hydratable":"sound","data":')[1].split('}];</script>')[0])
if (json["media"]["transcodings"]) {
let clientId = await findClientID();
if (clientId) {
let fileUrlBase = json.media.transcodings[0]["url"].replace("/hls", "/progressive")
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
if (fileUrl.substring(0, 54) === "https://api-v2.soundcloud.com/media/soundcloud:tracks:") {
if (json.duration < maxAudioDuration) {
let file = await fetch(fileUrl).then(async (r) => {return (await r.json()).url}).catch(() => {return false});
if (!file) return { error: 'ErrorCouldntFetch' };
return {
urls: file,
audioFilename: `soundcloud_${json.id}`,
fileMetadata: {
title: json.title,
artist: json.user.username,
}
}
} else return { error: ['ErrorLengthAudioConvert', maxAudioDuration / 60000] }
}
} else return { error: 'ErrorSoundCloudNoClientId' }
} else return { error: 'ErrorEmptyDownload' }
} else return { error: ['ErrorBrokenLink', 'soundcloud'] }
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,116 +0,0 @@
import { genericUserAgent } from "../config.js";
let userAgent = genericUserAgent.split(' Chrome/1')[0]
let config = {
tiktok: {
short: "https://vt.tiktok.com/",
api: "https://api2.musical.ly/aweme/v1/feed/?aweme_id={postId}&version_code=262&app_name=musical_ly&channel=App&device_id=null&os_version=14.4.2&device_platform=iphone&device_type=iPhone9&region=US&carrier_region=US",
},
douyin: {
short: "https://v.douyin.com/",
api: "https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids={postId}",
}
}
function selector(j, h, id) {
if (j) {
let t;
switch (h) {
case "tiktok":
t = j["aweme_list"].filter((v) => { if (v["aweme_id"] == id) return true })
break;
case "douyin":
t = j['item_list'].filter((v) => { if (v["aweme_id"] == id) return true })
break;
}
if (t.length > 0) { return t[0] } else return false
} else return false
}
export default async function(obj) {
try {
if (!obj.postId) {
let html = await fetch(`${config[obj.host]["short"]}${obj.id}`, {
redirect: "manual",
headers: { "user-agent": userAgent }
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.slice(0, 17) === '<a href="https://' && html.includes('/video/')) {
obj.postId = html.split('/video/')[1].split('?')[0].replace("/", '')
} else if (html.slice(0, 32) === '<a href="https://m.tiktok.com/v/' && html.includes('/v/')) {
obj.postId = html.split('/v/')[1].split('.html')[0].replace("/", '')
}
}
if (!obj.postId) return { error: 'ErrorCantGetID' };
let detail;
detail = await fetch(config[obj.host]["api"].replace("{postId}", obj.postId), {
headers: {"user-agent": "TikTok 26.2.0 rv:262018 (iPhone; iOS 14.4.2; en_US) Cronet"}
}).then((r) => {return r.json()}).catch(() => {return false});
detail = selector(detail, obj.host, obj.postId);
if (!detail) return { error: 'ErrorCouldntFetch' }
let video, videoFilename, audioFilename, isMp3, audio, images,
filenameBase = `${obj.host}_${obj.postId}`;
if (obj.host == "tiktok") {
images = detail["image_post_info"] ? detail["image_post_info"]["images"] : false
} else {
images = detail["images"] ? detail["images"] : false
}
if (!obj.isAudioOnly && !images) {
video = obj.host === "tiktok" ? detail["video"]["play_addr"]["url_list"][0] : detail["video"]["play_addr"]["url_list"][0].replace("playwm", "play");
videoFilename = `${filenameBase}_video_nw.mp4` // nw - no watermark
if (!obj.noWatermark) {
video = obj.host === "tiktok" ? detail["video"]["download_addr"]["url_list"][0] : detail['video']['play_addr']['url_list'][0]
videoFilename = `${filenameBase}_video.mp4`
}
} else {
let fallback = obj.host === "douyin" ? detail["video"]["play_addr"]["url_list"][0].replace("playwm", "play") : detail["video"]["play_addr"]["url_list"][0];
if (obj.fullAudio || fallback.includes("music")) {
audio = detail["music"]["play_url"]["url_list"][0]
audioFilename = `${filenameBase}_audio`
} else {
audio = fallback
audioFilename = `${filenameBase}_audio_fv` // fv - from video
}
if (audio.slice(-4) === ".mp3") isMp3 = true;
}
if (video) return {
urls: video,
filename: videoFilename
}
if (images && obj.isAudioOnly) {
return {
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3,
}
}
if (images) {
let imageLinks = [];
for (let i in images) {
let sel = obj.host == "tiktok" ? images[i]["display_image"]["url_list"] : images[i]["url_list"];
sel = sel.filter((p) => { if (p.includes(".jpeg?")) return true; })
imageLinks.push({url: sel[0]})
}
return {
picker: imageLinks,
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3,
}
}
if (audio) return {
urls: audio,
audioFilename: audioFilename,
isAudioOnly: true,
isMp3: isMp3,
}
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,16 +0,0 @@
import { genericUserAgent } from "../config.js";
export default async function(obj) {
try {
let user = obj.user ? obj.user : obj.url.split('.')[0].replace('https://', '');
let html = await fetch(`https://${user}.tumblr.com/post/${obj.id}`, {
headers: {"user-agent": genericUserAgent}
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes('property="og:video" content="https://va.media.tumblr.com/')) {
return { urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`, audioFilename: `tumblr_${obj.id}_audio` }
} else return { error: 'ErrorEmptyDownload' }
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,102 +0,0 @@
import { genericUserAgent } from "../config.js";
function bestQuality(arr) {
return arr.filter((v) => { if (v["content_type"] === "video/mp4") return true; }).sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"].split("?")[0]
}
const apiURL = "https://api.twitter.com/1.1"
export default async function(obj) {
try {
let _headers = {
"user-agent": genericUserAgent,
"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
"host": "api.twitter.com"
};
let req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
let showURL = `${apiURL}/statuses/show/${obj.id}.json?tweet_mode=extended&include_user_entities=0&trim_user=1&include_entities=0&cards_platform=Web-12&include_cards=1`
if (!obj.spaceId) {
let req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status == 200 ? r.json() : false;}).catch((e) => { return false});
if (!req_status) {
_headers.authorization = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw";
delete _headers["x-guest-token"]
req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
}
if (!req_status) return { error: 'ErrorCouldntFetch' }
if (req_status["extended_entities"] && req_status["extended_entities"]["media"]) {
let single, multiple = [], media = req_status["extended_entities"]["media"];
media = media.filter((i) => { if (i["type"] === "video" || i["type"] === "animated_gif") return true })
if (media.length > 1) {
for (let i in media) { multiple.push({type: "video", thumb: media[i]["media_url_https"], url: bestQuality(media[i]["video_info"]["variants"])}) }
} else if (media.length > 0) {
single = bestQuality(media[0]["video_info"]["variants"])
} else {
return { error: 'ErrorNoVideosInTweet' }
}
if (single) {
return { urls: single, filename: `twitter_${obj.id}.mp4`, audioFilename: `twitter_${obj.id}_audio` }
} else if (multiple) {
return { picker: multiple }
} else {
return { error: 'ErrorNoVideosInTweet' }
}
} else {
return { error: 'ErrorNoVideosInTweet' }
}
} else {
_headers["host"] = "twitter.com"
_headers["content-type"] = "application/json"
let query = {
variables: {"id": obj.spaceId,"isMetatagsQuery":true,"withSuperFollowsUserFields":true,"withDownvotePerspective":false,"withReactionsMetadata":false,"withReactionsPerspective":false,"withSuperFollowsTweetFields":true,"withReplays":true}, features: {"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"verified_phone_label_enabled":false,"tweetypie_unmention_optimization_enabled":true,"responsive_web_uc_gql_enabled":true,"vibe_api_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":false,"interactive_text_enabled":true,"responsive_web_text_conversations_enabled":false,"responsive_web_enhance_cards_enabled":true}
}
let AudioSpaceById = await fetch(`https://twitter.com/i/api/graphql/wJ5g4zf7v8qPHSQbaozYuw/AudioSpaceById?variables=${new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1)}&features=${new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1)}`, { headers: _headers }).then((r) => {
return r.status == 200 ? r.json() : false;
}).catch((e) => {return false});
if (AudioSpaceById) {
if (AudioSpaceById.data.audioSpace.metadata.is_space_available_for_replay === true) {
let streamStatus = await fetch(`https://twitter.com/i/api/1.1/live_video_stream/status/${AudioSpaceById.data.audioSpace.metadata.media_key}`, { headers: _headers }).then((r) => {return r.status == 200 ? r.json() : false;}).catch(() => {return false;});
if (!streamStatus) return { error: 'ErrorCouldntFetch' };
let participants = AudioSpaceById.data.audioSpace.participants.speakers
let listOfParticipants = `Twitter Space speakers: `
for (let i in participants) {
listOfParticipants += `@${participants[i]["twitter_screen_name"]}, `
}
listOfParticipants = listOfParticipants.slice(0, -2);
return {
urls: streamStatus.source.noRedirectPlaybackUrl,
audioFilename: `twitterspaces_${obj.spaceId}`,
isAudioOnly: true,
fileMetadata: {
title: AudioSpaceById.data.audioSpace.metadata.title,
artist: `Twitter Space by @${AudioSpaceById.data.audioSpace.metadata.creator_results.result.legacy.screen_name}`,
comment: listOfParticipants,
// cover: AudioSpaceById.data.audioSpace.metadata.creator_results.result.legacy.profile_image_url_https.replace("_normal", "")
}
}
} else {
return { error: 'TwitterSpaceWasntRecorded' };
}
} else {
return { error: 'ErrorEmptyDownload' }
}
}
} catch (err) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,80 +0,0 @@
import { quality, services } from "../config.js";
export default async function(obj) {
try {
let api = await fetch(`https://player.vimeo.com/video/${obj.id}/config`).then((r) => {return r.json()}).catch(() => {return false});
if (!api) return { error: 'ErrorCouldntFetch' };
let downloadType = "";
if (JSON.stringify(api).includes('"progressive":[{')) {
downloadType = "progressive";
} else if (JSON.stringify(api).includes('"files":{"dash":{"')) downloadType = "dash";
switch(downloadType) {
case "progressive":
let all = api["request"]["files"]["progressive"].sort((a, b) => Number(b.width) - Number(a.width));
let best = all[0]
try {
if (obj.quality != "max") {
let pref = parseInt(quality[obj.quality], 10)
for (let i in all) {
let currQuality = parseInt(all[i]["quality"].replace('p', ''), 10)
if (currQuality < pref) {
break;
} else if (currQuality == pref) {
best = all[i]
}
}
}
} catch (e) {
best = all[0]
}
return { urls: best["url"], filename: `tumblr_${obj.id}.mp4` };
case "dash":
let masterJSONURL = api["request"]["files"]["dash"]["cdns"]["akfire_interconnect_quic"]["url"];
let masterJSON = await fetch(masterJSONURL).then((r) => {return r.json()}).catch(() => {return false});
if (!masterJSON) return { error: 'ErrorCouldntFetch' };
if (masterJSON.video) {
let type = "";
if (masterJSON.base_url.includes("parcel")) {
type = "parcel"
} else if (masterJSON.base_url == "../") {
type = "chop"
}
let masterJSON_Video = masterJSON.video.sort((a, b) => Number(b.width) - Number(a.width));
let masterJSON_Audio = masterJSON.audio.sort((a, b) => Number(b.bitrate) - Number(a.bitrate)).filter((a)=> {if (a['mime_type'] === "audio/mp4") return true;});
let bestVideo = masterJSON_Video[0]
let bestAudio = masterJSON_Audio[0]
switch (type) {
case "parcel":
if (obj.quality != "max") {
let pref = parseInt(quality[obj.quality], 10)
for (let i in masterJSON_Video) {
let currQuality = parseInt(services.vimeo.resolutionMatch[masterJSON_Video[i]["width"]], 10)
if (currQuality < pref) {
break;
} else if (currQuality == pref) {
bestVideo = masterJSON_Video[i]
}
}
}
let baseUrl = masterJSONURL.split("/sep/")[0]
let videoUrl = `${baseUrl}/parcel/video/${bestVideo.index_segment.split('?')[0]}`;
let audioUrl = `${baseUrl}/parcel/audio/${bestAudio.index_segment.split('?')[0]}`;
return { urls: [videoUrl, audioUrl], audioFilename: `vimeo_${obj.id}_audio`, filename: `vimeo_${obj.id}_${bestVideo["width"]}x${bestVideo["height"]}.mp4` }
case "chop": // TO-DO: support chop type of streams
default:
return { error: 'ErrorEmptyDownload' }
}
} else {
return { error: 'ErrorEmptyDownload' }
}
default:
return { error: 'ErrorEmptyDownload' }
}
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,58 +0,0 @@
import { xml2json } from "xml-js";
import { genericUserAgent, maxVideoDuration, services } from "../config.js";
import selectQuality from "../stream/selectQuality.js";
export default async function(obj) {
try {
let html;
html = await fetch(`https://vk.com/video-${obj.userId}_${obj.videoId}`, {
headers: {"user-agent": genericUserAgent}
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes(`{"lang":`)) {
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
if (js["mvData"]["is_active_live"] == '0') {
if (js["mvData"]["duration"] <= maxVideoDuration / 1000) {
let mpd = JSON.parse(xml2json(js["player"]["params"][0]["manifest"], { compact: true, spaces: 4 }));
let repr = mpd["MPD"]["Period"]["AdaptationSet"]["Representation"];
if (!mpd["MPD"]["Period"]["AdaptationSet"]["Representation"]) {
repr = mpd["MPD"]["Period"]["AdaptationSet"][0]["Representation"];
}
let attr = repr[repr.length - 1]["_attributes"];
let selectedQuality;
let qualities = Object.keys(services.vk.quality_match);
for (let i in qualities) {
if (qualities[i] == attr["height"]) {
selectedQuality = `url${attr["height"]}`;
break;
}
if (qualities[i] == attr["width"]) {
selectedQuality = `url${attr["width"]}`;
break;
}
}
let maxQuality = js["player"]["params"][0][selectedQuality].split('type=')[1].slice(0, 1)
let userQuality = selectQuality('vk', obj.quality, Object.entries(services.vk.quality_match).reduce((r, [k, v]) => { r[v] = k; return r; })[maxQuality]);
let userRepr = repr[services.vk.representation_match[userQuality]]["_attributes"];
if (selectedQuality in js["player"]["params"][0]) {
return {
urls: js["player"]["params"][0][`url${userQuality}`],
filename: `vk_${obj.userId}_${obj.videoId}_${userRepr["width"]}x${userRepr['height']}.mp4`
};
} else {
return { error: 'ErrorEmptyDownload' };
}
} else {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
} else {
return { error: 'ErrorLiveVideo' };
}
} else {
return { error: 'ErrorEmptyDownload' };
}
} catch (err) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -1,98 +0,0 @@
import ytdl from "better-ytdl-core";
import { maxVideoDuration, quality as mq } from "../config.js";
import selectQuality from "../stream/selectQuality.js";
export default async function(obj) {
try {
let infoInitial = await ytdl.getInfo(obj.id);
if (infoInitial) {
let info = infoInitial.formats;
if (!info[0]["isLive"]) {
let videoMatch = [], fullVideoMatch = [], video = [], audio = info.filter((a) => {
if (!a["isHLS"] && !a["isDashMPD"] && a["hasAudio"] && !a["hasVideo"] && a["container"] == obj.format) return true;
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
if (!obj.isAudioOnly) {
video = info.filter((a) => {
if (!a["isHLS"] && !a["isDashMPD"] && a["hasVideo"] && a["container"] == obj.format) {
if (obj.quality != "max") {
if (a["hasAudio"] && mq[obj.quality] == a["height"]) {
fullVideoMatch.push(a)
} else if (!a["hasAudio"] && mq[obj.quality] == a["height"]) {
videoMatch.push(a);
}
}
return true
}
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
if (obj.quality != "max") {
if (videoMatch.length == 0) {
let ss = selectQuality("youtube", obj.quality, video[0]["qualityLabel"].slice(0, 5).replace('p', '').trim())
videoMatch = video.filter((a) => {
if (a["qualityLabel"].slice(0, 5).replace('p', '').trim() == ss) return true;
})
} else if (fullVideoMatch.length > 0) {
videoMatch = [fullVideoMatch[0]]
}
} else videoMatch = [video[0]];
if (obj.quality == "los") videoMatch = [video[video.length - 1]];
}
let generalMeta = {
title: infoInitial.videoDetails.title,
artist: infoInitial.videoDetails.ownerChannelName.replace("- Topic", "").trim(),
}
if (audio[0]["approxDurationMs"] <= maxVideoDuration) {
if (!obj.isAudioOnly && videoMatch.length > 0) {
if (video.length > 0 && audio.length > 0) {
if (videoMatch[0]["hasVideo"] && videoMatch[0]["hasAudio"]) {
return {
type: "bridge", urls: videoMatch[0]["url"], time: videoMatch[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${videoMatch[0]["width"]}x${videoMatch[0]["height"]}.${obj.format}`
};
} else {
return {
type: "render", urls: [videoMatch[0]["url"], audio[0]["url"]], time: videoMatch[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${videoMatch[0]["width"]}x${videoMatch[0]["height"]}.${obj.format}`
};
}
} else {
return { error: 'ErrorBadFetch' };
}
} else if (!obj.isAudioOnly) {
return {
type: "render", urls: [video[0]["url"], audio[0]["url"]], time: video[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.${video[0]["container"]}`
};
} else if (audio.length > 0) {
let r = {
type: "render",
isAudioOnly: true,
urls: audio[0]["url"],
audioFilename: `youtube_${obj.id}_audio`,
fileMetadata: generalMeta
};
if (infoInitial.videoDetails.description) {
let isAutoGenAudio = infoInitial.videoDetails.description.startsWith("Provided to YouTube by");
if (isAutoGenAudio) {
let descItems = infoInitial.videoDetails.description.split("\n\n")
r.fileMetadata.album = descItems[2]
r.fileMetadata.copyright = descItems[3]
if (descItems[4].startsWith("Released on:")) r.fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
}
}
return r
} else {
return { error: 'ErrorBadFetch' };
}
} else {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
} else {
return { error: 'ErrorLiveVideo' };
}
} else {
return { error: 'ErrorCantConnectToServiceAPI' };
}
} catch (e) {
return { error: 'ErrorBadFetch' };
}
}

View file

@ -33,22 +33,21 @@ console.log(
) )
rl.question(q, r1 => { rl.question(q, r1 => {
if (r1) {
ob['selfURL'] = `https://${r1}/`
} else {
ob['selfURL'] = `http://localhost`
}
console.log(Bright("\nGreat! Now, what's the port it'll be running on? (9000)"))
rl.question(q, r2 => {
if (!r1 && !r2) {
ob['selfURL'] = `http://localhost:9000/` ob['selfURL'] = `http://localhost:9000/`
ob['port'] = 9000 ob['port'] = 9000
} else if (!r1 && r2) { if (r1) ob['selfURL'] = `https://${r1}/`
ob['selfURL'] = `http://localhost:${r2}/`
ob['port'] = r2 console.log(Bright("\nGreat! Now, what's the port it'll be running on? (9000)"))
} else {
ob['port'] = r2 rl.question(q, r2 => {
} if (r2) ob['port'] = r2
if (!r1 && r2) ob['selfURL'] = `http://localhost:${r2}/`
console.log(Bright("\nWould you like to enable CORS? It allows other websites and extensions to use your instance's API.\n y/n (n)"))
rl.question(q, r3 => {
if (r3.toLowerCase() !== 'y') ob['cors'] = '0'
final() final()
})
}); });
}) })

View file

@ -1,4 +1,5 @@
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { nanoid } from 'nanoid';
import { sha256 } from "../sub/crypto.js"; import { sha256 } from "../sub/crypto.js";
import { streamLifespan } from "../config.js"; import { streamLifespan } from "../config.js";
@ -11,9 +12,9 @@ streamCache.on("expired", (key) => {
}); });
export function createStream(obj) { export function createStream(obj) {
let streamID = sha256(`${obj.ip},${obj.service},${obj.filename},${obj.audioFormat},${obj.mute}`, salt), let streamID = nanoid(),
exp = Math.floor(new Date().getTime()) + streamLifespan, exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = sha256(`${streamID},${obj.service},${obj.ip},${exp}`, salt); ghmac = sha256(`${streamID},${obj.ip},${obj.service},${exp}`, salt);
if (!streamCache.has(streamID)) { if (!streamCache.has(streamID)) {
streamCache.set(streamID, { streamCache.set(streamID, {
@ -42,17 +43,17 @@ export function createStream(obj) {
export function verifyStream(ip, id, hmac, exp) { export function verifyStream(ip, id, hmac, exp) {
try { try {
if (id.length === 21) {
let streamInfo = streamCache.get(id); let streamInfo = streamCache.get(id);
if (streamInfo) { if (!streamInfo) return { error: 'this stream token does not exist', status: 400 };
let ghmac = sha256(`${id},${streamInfo.service},${ip},${exp}`, salt);
if (hmac == ghmac && ip == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) { let ghmac = sha256(`${id},${ip},${streamInfo.service},${exp}`, salt);
if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac)
&& String(ip) === streamInfo.ip && Number(exp) > Math.floor(new Date().getTime())) {
return streamInfo; return streamInfo;
} else { }
}
return { error: 'Unauthorized', status: 401 }; return { error: 'Unauthorized', status: 401 };
}
} else {
return { error: 'this stream token does not exist', status: 400 };
}
} catch (e) { } catch (e) {
return { status: 500, body: { status: "error", text: "Internal Server Error" } }; return { status: 500, body: { status: "error", text: "Internal Server Error" } };
} }

View file

@ -1,29 +0,0 @@
import { services, quality as mq } from "../config.js";
function closest(goal, array) {
return array.sort().reduce(function (prev, curr) {
return (Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev);
});
}
export default function(service, quality, maxQuality) {
if (quality == "max") return maxQuality;
quality = parseInt(mq[quality], 10)
maxQuality = parseInt(maxQuality, 10)
if (quality >= maxQuality || quality == maxQuality) return maxQuality;
if (quality < maxQuality) {
if (services[service]["quality"][quality]) {
return quality
} else {
let s = Object.keys(services[service]["quality_match"]).filter((q) => {
if (q <= quality) {
return true
}
})
return closest(quality, s)
}
}
}

View file

@ -5,14 +5,19 @@ import { streamAudioOnly, streamDefault, streamLiveRender, streamVideoOnly } fro
export default function(res, ip, id, hmac, exp) { export default function(res, ip, id, hmac, exp) {
try { try {
let streamInfo = verifyStream(ip, id, hmac, exp); let streamInfo = verifyStream(ip, id, hmac, exp);
if (!streamInfo.error) { if (streamInfo.error) {
res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
return;
}
if (streamInfo.isAudioOnly && streamInfo.type !== "bridge") { if (streamInfo.isAudioOnly && streamInfo.type !== "bridge") {
streamAudioOnly(streamInfo, res); streamAudioOnly(streamInfo, res);
} else { return;
}
switch (streamInfo.type) { switch (streamInfo.type) {
case "render": case "render":
streamLiveRender(streamInfo, res); streamLiveRender(streamInfo, res);
break; break;
case "videoM3U8":
case "mute": case "mute":
streamVideoOnly(streamInfo, res); streamVideoOnly(streamInfo, res);
break; break;
@ -20,10 +25,6 @@ export default function(res, ip, id, hmac, exp) {
streamDefault(streamInfo, res); streamDefault(streamInfo, res);
break; break;
} }
}
} else {
res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
}
} catch (e) { } catch (e) {
res.status(500).json({ status: "error", text: "Internal Server Error" }); res.status(500).json({ status: "error", text: "Internal Server Error" });
} }

View file

@ -6,8 +6,8 @@ import { metadataManager, msToTime } from "../sub/utils.js";
export function streamDefault(streamInfo, res) { export function streamDefault(streamInfo, res) {
try { try {
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1] let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
let regFilename = !streamInfo.mute ? streamInfo.filename : `${streamInfo.filename.split('.')[0]}_mute.${format}` let regFilename = !streamInfo.mute ? streamInfo.filename : `${streamInfo.filename.split('.')[0]}_mute.${format}`;
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.isAudioOnly ? `${streamInfo.filename}.${streamInfo.audioFormat}` : regFilename}"`); res.setHeader('Content-disposition', `attachment; filename="${streamInfo.isAudioOnly ? `${streamInfo.filename}.${streamInfo.audioFormat}` : regFilename}"`);
const stream = got.get(streamInfo.urls, { const stream = got.get(streamInfo.urls, {
headers: { headers: {
@ -15,53 +15,80 @@ export function streamDefault(streamInfo, res) {
}, },
isStream: true isStream: true
}); });
stream.pipe(res).on('error', (err) => { stream.pipe(res).on('error', () => {
res.end(); res.destroy();
}); });
stream.on('error', (err) => { stream.on('error', () => {
res.end(); res.destroy();
});
stream.on('aborted', () => {
res.destroy();
}); });
} catch (e) { } catch (e) {
res.end(); res.destroy();
} }
} }
export function streamLiveRender(streamInfo, res) { export function streamLiveRender(streamInfo, res) {
try { try {
if (streamInfo.urls.length === 2) { if (streamInfo.urls.length !== 2) {
res.destroy();
return;
}
let audio = got.get(streamInfo.urls[1], { isStream: true });
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [ let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [
'-loglevel', '-8', '-loglevel', '-8',
'-i', streamInfo.urls[0], '-i', streamInfo.urls[0],
'-i', streamInfo.urls[1], '-i', 'pipe:3',
'-map', '0:v', '-map', '0:v',
'-map', '1:a', '-map', '1:a',
]; ];
args = args.concat(ffmpegArgs[format]) args = args.concat(ffmpegArgs[format])
if (streamInfo.time) args.push('-t', msToTime(streamInfo.time)); if (streamInfo.time) args.push('-t', msToTime(streamInfo.time));
args.push('-f', format, 'pipe:3'); args.push('-f', format, 'pipe:4');
const ffmpegProcess = spawn(ffmpeg, args, { let ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true, windowsHide: true,
stdio: [ stdio: [
'inherit', 'inherit', 'inherit', 'inherit', 'inherit', 'inherit',
'pipe' 'pipe', 'pipe'
], ],
}); });
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
ffmpegProcess.stdio[3].pipe(res); res.on('error', () => {
ffmpegProcess.kill();
res.destroy();
});
ffmpegProcess.stdio[4].pipe(res).on('error', () => {
ffmpegProcess.kill();
res.destroy();
});
audio.pipe(ffmpegProcess.stdio[3]).on('error', () => {
ffmpegProcess.kill();
res.destroy();
});
audio.on('error', () => {
ffmpegProcess.kill();
res.destroy();
});
audio.on('aborted', () => {
ffmpegProcess.kill();
res.destroy();
});
ffmpegProcess.on('disconnect', () => ffmpegProcess.kill()); ffmpegProcess.on('disconnect', () => ffmpegProcess.kill());
ffmpegProcess.on('close', () => ffmpegProcess.kill()); ffmpegProcess.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('exit', () => ffmpegProcess.kill()); ffmpegProcess.on('exit', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill()); res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill()); res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => { ffmpegProcess.on('error', () => {
ffmpegProcess.kill(); ffmpegProcess.kill();
res.end(); res.destroy();
}); });
} else {
res.end();
}
} catch (e) { } catch (e) {
res.end(); res.destroy();
} }
} }
export function streamAudioOnly(streamInfo, res) { export function streamAudioOnly(streamInfo, res) {
@ -71,18 +98,21 @@ export function streamAudioOnly(streamInfo, res) {
'-i', streamInfo.urls '-i', streamInfo.urls
] ]
if (streamInfo.metadata) { if (streamInfo.metadata) {
if (streamInfo.metadata.cover) { // doesn't work on the server but works locally, no idea why if (streamInfo.metadata.cover) { // currently corrupts the audio
args.push('-i', streamInfo.metadata.cover, '-map', '0:a', '-map', '1:0', '-filter:v', 'scale=w=400:h=400,format=yuvj420p') args.push('-i', streamInfo.metadata.cover, '-map', '0:a', '-map', '1:0')
} else { } else {
args.push('-vn') args.push('-vn')
} }
args = args.concat(metadataManager(streamInfo.metadata)) args = args.concat(metadataManager(streamInfo.metadata))
} else {
args.push('-vn')
} }
let arg = streamInfo.copy ? ffmpegArgs["copy"] : ffmpegArgs["audio"] let arg = streamInfo.copy ? ffmpegArgs["copy"] : ffmpegArgs["audio"];
args = args.concat(arg) args = args.concat(arg);
if (streamInfo.metadata.cover) args.push("-c:v", "mjpeg")
if (ffmpegArgs[streamInfo.audioFormat]) args = args.concat(ffmpegArgs[streamInfo.audioFormat]); if (ffmpegArgs[streamInfo.audioFormat]) args = args.concat(ffmpegArgs[streamInfo.audioFormat]);
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3'); args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
const ffmpegProcess = spawn(ffmpeg, args, { const ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true, windowsHide: true,
stdio: [ stdio: [
@ -93,17 +123,18 @@ export function streamAudioOnly(streamInfo, res) {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}.${streamInfo.audioFormat}"`); res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}.${streamInfo.audioFormat}"`);
ffmpegProcess.stdio[3].pipe(res); ffmpegProcess.stdio[3].pipe(res);
ffmpegProcess.on('disconnect', () => ffmpegProcess.kill()); ffmpegProcess.on('disconnect', () => ffmpegProcess.kill());
ffmpegProcess.on('close', () => ffmpegProcess.kill()); ffmpegProcess.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('exit', () => ffmpegProcess.kill()); ffmpegProcess.on('exit', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill()); res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill()); res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => { ffmpegProcess.on('error', () => {
ffmpegProcess.kill(); ffmpegProcess.kill();
res.end(); res.destroy();
}); });
} catch (e) { } catch (e) {
res.end(); res.destroy();
} }
} }
export function streamVideoOnly(streamInfo, res) { export function streamVideoOnly(streamInfo, res) {
@ -111,9 +142,11 @@ export function streamVideoOnly(streamInfo, res) {
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [ let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [
'-loglevel', '-8', '-loglevel', '-8',
'-i', streamInfo.urls, '-i', streamInfo.urls,
'-c', 'copy', '-an' '-c', 'copy'
] ]
if (format == "mp4") args.push('-movflags', 'faststart+frag_keyframe+empty_moov') if (streamInfo.mute) args.push('-an');
if (streamInfo.service === "vimeo") args.push('-bsf:a', 'aac_adtstoasc');
if (format === "mp4") args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
args.push('-f', format, 'pipe:3'); args.push('-f', format, 'pipe:3');
const ffmpegProcess = spawn(ffmpeg, args, { const ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true, windowsHide: true,
@ -123,18 +156,19 @@ export function streamVideoOnly(streamInfo, res) {
], ],
}); });
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename.split('.')[0]}_mute.${format}"`); res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename.split('.')[0]}${streamInfo.mute ? '_mute' : ''}.${format}"`);
ffmpegProcess.stdio[3].pipe(res); ffmpegProcess.stdio[3].pipe(res);
ffmpegProcess.on('disconnect', () => ffmpegProcess.kill()); ffmpegProcess.on('disconnect', () => ffmpegProcess.kill());
ffmpegProcess.on('close', () => ffmpegProcess.kill()); ffmpegProcess.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('exit', () => ffmpegProcess.kill()); ffmpegProcess.on('exit', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill()); res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill()); res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => { ffmpegProcess.on('error', () => {
ffmpegProcess.kill(); ffmpegProcess.kill();
res.end(); res.destroy();
}); });
} catch (e) { } catch (e) {
res.end(); res.destroy();
} }
} }

View file

@ -1,10 +1,23 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
let commit, commitInfo, branch;
export function shortCommit() { export function shortCommit() {
return execSync('git rev-parse --short HEAD').toString().trim() if (commit) return commit;
let c = execSync('git rev-parse --short HEAD').toString().trim();
commit = c;
return c
} }
export function getCommitInfo() { export function getCommitInfo() {
let d = execSync(`git show -s --format='%s;;;%B'`).toString().trim().replace(/[\r\n]/gm, '\n').split(';;;') if (commitInfo) return commitInfo;
d[1] = d[1].replace(d[0], '').trim().toString().replace(/[\r\n]/gm, '<br>') let d = execSync(`git show -s --format='%s;;;%B'`).toString().trim().replace(/[\r\n]/gm, '\n').split(';;;');
d[1] = d[1].replace(d[0], '').trim().toString().replace(/[\r\n]/gm, '<br>');
commitInfo = d;
return d return d
} }
export function getCurrentBranch() {
if (branch) return branch;
let b = execSync('git branch --show-current').toString().trim();
branch = b;
return b
}

View file

@ -3,6 +3,9 @@ import loc from "../../localization/manager.js";
export function errorUnsupported(lang) { export function errorUnsupported(lang) {
return loc(lang, 'ErrorUnsupported'); return loc(lang, 'ErrorUnsupported');
} }
export function genericError(lang, host) { export function brokenLink(lang, host) {
return loc(lang, 'ErrorBrokenLink', host); return loc(lang, 'ErrorBrokenLink', host);
} }
export function genericError(lang, host) {
return loc(lang, 'ErrorBadFetch', host);
}

View file

@ -2,11 +2,11 @@ import { createStream } from "../stream/manage.js";
let apiVar = { let apiVar = {
allowed: { allowed: {
vFormat: ["mp4", "webm"], vCodec: ["h264", "av1", "vp9"],
vQuality: ["max", "hig", "mid", "low", "los"], vQuality: ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"],
aFormat: ["best", "mp3", "ogg", "wav", "opus"] aFormat: ["best", "mp3", "ogg", "wav", "opus"]
}, },
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted"] booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash"]
} }
export function apiJSON(type, obj) { export function apiJSON(type, obj) {
@ -64,6 +64,7 @@ export function msToTime(d) {
export function cleanURL(url, host) { export function cleanURL(url, host) {
let forbiddenChars = ['}', '{', '(', ')', '\\', '%', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@"] let forbiddenChars = ['}', '{', '(', ')', '\\', '%', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@"]
switch(host) { switch(host) {
case "vk":
case "youtube": case "youtube":
url = url.split('&')[0]; url = url.split('&')[0];
break; break;
@ -83,8 +84,11 @@ export function cleanURL(url, host) {
} }
return url.slice(0, 128) return url.slice(0, 128)
} }
export function verifyLanguageCode(code) {
return RegExp(/[a-z]{2}/).test(String(code.slice(0, 2).toLowerCase())) ? String(code.slice(0, 2).toLowerCase()) : "en"
}
export function languageCode(req) { export function languageCode(req) {
return req.header('Accept-Language') ? req.header('Accept-Language').slice(0, 2) : "en" return req.header('Accept-Language') ? verifyLanguageCode(req.header('Accept-Language')) : "en"
} }
export function unicodeDecode(str) { export function unicodeDecode(str) {
return str.replace(/\\u[\dA-F]{4}/gi, (unicode) => { return str.replace(/\\u[\dA-F]{4}/gi, (unicode) => {
@ -93,18 +97,21 @@ export function unicodeDecode(str) {
} }
export function checkJSONPost(obj) { export function checkJSONPost(obj) {
let def = { let def = {
vFormat: "mp4", vCodec: "h264",
vQuality: "hig", vQuality: "720",
aFormat: "mp3", aFormat: "mp3",
isAudioOnly: false, isAudioOnly: false,
isNoTTWatermark: false, isNoTTWatermark: false,
isTTFullAudio: false, isTTFullAudio: false,
isAudioMuted: false, isAudioMuted: false,
dubLang: false,
vimeoDash: false
} }
try { try {
let objKeys = Object.keys(obj); let objKeys = Object.keys(obj);
if (objKeys.length < 8 && obj.url) { if (!(objKeys.length <= 9 && obj.url)) return false;
let defKeys = Object.keys(def); let defKeys = Object.keys(def);
for (let i in objKeys) { for (let i in objKeys) {
if (String(objKeys[i]) !== "url" && defKeys.includes(objKeys[i])) { if (String(objKeys[i]) !== "url" && defKeys.includes(objKeys[i])) {
if (apiVar.booleanOnly.includes(objKeys[i])) { if (apiVar.booleanOnly.includes(objKeys[i])) {
@ -114,15 +121,19 @@ export function checkJSONPost(obj) {
} }
} }
} }
obj["url"] = decodeURIComponent(String(obj["url"]))
if (def.dubLang) def.dubLang = verifyLanguageCode(obj.dubLang);
obj["url"] = decodeURIComponent(String(obj["url"]));
let hostname = obj["url"].replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."), let hostname = obj["url"].replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."),
host = hostname[hostname.length - 2] host = hostname[hostname.length - 2];
def["url"] = encodeURIComponent(cleanURL(obj["url"], host)) def["url"] = encodeURIComponent(cleanURL(obj["url"], host));
return def return def
} else { } catch (e) {
return false return false
} }
} catch (e) {
return false;
} }
export function getIP(req) {
return req.header('cf-connecting-ip') ? req.header('cf-connecting-ip') : req.ip;
} }

71
src/test/test.js Normal file
View file

@ -0,0 +1,71 @@
import "dotenv/config";
import { getJSON } from "../modules/api.js";
import { services } from "../modules/config.js";
import loadJSON from "../modules/sub/loadJSON.js";
import { checkJSONPost } from "../modules/sub/utils.js";
let tests = loadJSON('./src/test/tests.json');
let noTest = [];
let failed = [];
let success = 0;
function addToFail(service, testName, url, status, response) {
failed.push({
service: service,
name: testName,
url: url,
status: status,
response: response
})
}
for (let i in services) {
if (tests[i]) {
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 = checkJSONPost(params);
if (chck) {
chck["ip"] = "d21ec524bc2ade41bef569c0361ac57728c69e2764b5cb3cb310fe36568ca53f"; // random sha256
let j = await getJSON(chck["url"], "en", chck);
console.log('\nReceived:');
console.log(j)
if (j.status === test.expected.code && j.body.status === test.expected.status) {
console.log("\n✅ Success.\n");
success++
} else {
console.log(`\n❌ Fail. Expected: ${test.expected.code} & ${test.expected.status}, received: ${j.status} & ${j.body.status}\n`);
addToFail(i, test.name, test.url, j.body.status, j)
}
} else {
console.log("\n❌ couldn't validate the request JSON.\n");
addToFail(i, test.name, test.url, "unknown", {})
}
}
console.log("\n\n")
} else {
console.warn(`No tests found for ${i}.`);
noTest.push(i)
}
}
console.log(`${success} tests succeeded.`);
console.log(`${failed.length} tests failed.`);
console.log(`${noTest.length} services weren't tested.`);
if (failed.length > 0) {
console.log(`\nFailed tests:`);
console.log(failed)
}
if (noTest.length > 0) {
console.log(`\nMissing tests:`);
console.log(noTest)
}

759
src/test/tests.json Normal file
View file

@ -0,0 +1,759 @@
{
"twitter": [{
"name": "regular video",
"url": "https://twitter.com/TwitterSpaces/status/1526955853743546372?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "embedded twitter video",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "mixed media (image + gif)",
"url": "https://twitter.com/Twitter/status/1580661436132757506?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "picker: mixed media (3 gifs + image)",
"url": "https://twitter.com/emerald_pedrod/status/1582418163521581063?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "picker"
}
}, {
"name": "audio from embedded twitter video (mp3, isAudioOnly)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": true,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "audio from embedded twitter video (best, isAudioOnly)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"aFormat": "best",
"isAudioOnly": true,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "audio from embedded twitter video (ogg, isAudioOnly, isAudioMuted)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"aFormat": "best",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "muted embedded twitter video",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "retweeted video",
"url": "https://twitter.com/winload_exe/status/1633091769482063874",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "inexistent post",
"url": "https://twitter.com/test/status/9487653",
"params": {
"aFormat": "best",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "post with no media content",
"url": "https://twitter.com/elonmusk/status/1604617643973124097?s=20",
"params": {
"aFormat": "best",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "recorded space by nyc (best)",
"url": "https://twitter.com/i/spaces/1gqxvyLoYQkJB",
"params": {
"aFormat": "best",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "recorded space by nyc (mp3)",
"url": "https://twitter.com/i/spaces/1gqxvyLoYQkJB",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "recorded space by nyc (wav, isAudioMuted)",
"url": "https://twitter.com/i/spaces/1gqxvyLoYQkJB",
"params": {
"aFormat": "wav",
"isAudioOnly": false,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "recorded space by service95 & dualipa (mp3, isAudioMuted, isAudioOnly)",
"url": "https://twitter.com/i/spaces/1nAJErvvVXgxL",
"params": {
"aFormat": "mp3",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "unavailable space",
"url": "https://twitter.com/i/spaces/1OwGWwjRjVVGQ?s=20",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "inexistent space",
"url": "https://twitter.com/i/spaces/10Wkie2j29iiI",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}],
"soundcloud": [{
"name": "public song (best)",
"url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
"params": {
"aFormat": "best",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "public song (mp3, isAudioMuted)",
"url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "private song",
"url": "https://soundcloud.com/4kayy/unhappy-new-year-prod4kay/s-9bKbvwLdRWG",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "private song (wav, isAudioMuted)",
"url": "https://soundcloud.com/4kayy/unhappy-new-year-prod4kay/s-9bKbvwLdRWG",
"params": {
"aFormat": "wav",
"isAudioOnly": false,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "private song (ogg, isAudioMuted, isAudioOnly)",
"url": "https://soundcloud.com/4kayy/unhappy-new-year-prod4kay/s-9bKbvwLdRWG",
"params": {
"aFormat": "ogg",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}],
"youtube": [{
"name": "4k video (h264, 1440)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "h264",
"vQuality": "1440"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (vp9, 720)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "vp9",
"vQuality": "720"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (av1, max)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "av1",
"vQuality": "max"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (h264, 720)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "h264",
"vQuality": "720"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (vp9, max, isAudioMuted)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "vp9",
"vQuality": "max",
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (h264, max, isAudioMuted)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "h264",
"vQuality": "max",
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (av1, max, isAudioMuted, isAudioOnly, mp3)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "av1",
"vQuality": "max",
"aFormat": "mp3",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "4k video (av1, max, isAudioMuted, isAudioOnly, best)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"params": {
"vCodec": "av1",
"vQuality": "max",
"aFormat": "best",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "music (mp3, isAudioOnly, isAudioMuted)",
"url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share",
"params": {
"aFormat": "mp3",
"isAudioOnly": true,
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "music (mp3)",
"url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share",
"params": {
"aFormat": "mp3",
"isAudioOnly": false,
"isAudioMuted": false
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "audio bitrate higher than video, no vp9 video in response (mp3, isAudioOnly)",
"url": "https://www.youtube.com/watch?v=t5nC_ucYBrc",
"params": {
"aFormat": "mp3",
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "audio bitrate higher than video, no vp9 video in response (vp9)",
"url": "https://www.youtube.com/watch?v=t5nC_ucYBrc",
"params": {
"vCodec": "vp9"
},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "short, defaults",
"url": "https://www.youtube.com/shorts/r5FpeOJItbw",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "inexistent video",
"url": "https://youtube.com/watch?v=gnjuHYWGEW",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}],
"vk": [{
"name": "clip, defaults",
"url": "https://vk.com/clip-57274055_456239788",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "clip, 360",
"url": "https://vk.com/clip-57274055_456239788",
"params": {
"vQuality": "360"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "clip different link, max",
"url": "https://vk.com/clips-57274055?z=clip-57274055_456239788",
"params": {
"vQuality": "max"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "video, defaults",
"url": "https://vk.com/video-57274055_456239399",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "inexistent video",
"url": "https://vk.com/video-53333333_456233333",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}],
"douyin": [{
"name": "short link video, with watermark",
"url": "https://v.douyin.com/2p4Aya7/",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link video (isNoTTWatermark)",
"url": "https://v.douyin.com/2p4Aya7/",
"params": {
"isNoTTWatermark": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link video (isAudioOnly)",
"url": "https://v.douyin.com/2p4Aya7/",
"params": {
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link video (isAudioOnly, isTTFullAudio)",
"url": "https://v.douyin.com/2p4Aya7/",
"params": {
"isAudioOnly": true,
"isTTFullAudio": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "long link video (isNoTTWatermark)",
"url": "https://www.douyin.com/video/7120601033314716968",
"params": {
"isNoTTWatermark": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "images",
"url": "https://v.douyin.com/MdVwo31/",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
}, {
"name": "long link inexistent",
"url": "https://www.douyin.com/video/7120851458451417478",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "short link inexistent",
"url": "https://v.douyin.com/2p4ewa7/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}],
"tiktok": [{
"name": "short link (vt) video, with watermark",
"url": "https://vt.tiktok.com/ZS85U86aa/",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link (vt) video (isNoTTWatermark)",
"url": "https://vt.tiktok.com/ZS85U86aa/",
"params": {
"isNoTTWatermark": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link (vm) video (isAudioOnly)",
"url": "https://vm.tiktok.com/ZMYrYAf34/",
"params": {
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "short link (vm) video (isAudioOnly, isTTFullAudio)",
"url": "https://vm.tiktok.com/ZMYrYAf34/",
"params": {
"isAudioOnly": true,
"isTTFullAudio": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "long link video (isNoTTWatermark)",
"url": "https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894",
"params": {
"isNoTTWatermark": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "images",
"url": "https://vt.tiktok.com/ZS8JP89eB/",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
}, {
"name": "long link inexistent",
"url": "https://www.tiktok.com/@blablabla/video/7120851458451417478",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}, {
"name": "short link inexistent",
"url": "https://vt.tiktok.com/2p4ewa7/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}],
"bilibili": [{
"name": "1080p video",
"url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "1080p video muted",
"url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
"params": {
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "1080p vertical video",
"url": "https://www.bilibili.com/video/BV1uu411z7VV/",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "1080p vertical video muted",
"url": "https://www.bilibili.com/video/BV1uu411z7VV/",
"params": {
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}],
"tumblr": [{
"name": "at.tumblr link",
"url": "https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "user subdomain link",
"url": "https://garfield-69.tumblr.com/post/696499862852780032",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "web app link",
"url": "https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}],
"vimeo": [{
"name": "4k progressive",
"url": "https://vimeo.com/288386543",
"params": {
"vQuality": "2160"
},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "720p progressive",
"url": "https://vimeo.com/288386543",
"params": {
"vQuality": "720"
},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "1080p dash parcel",
"url": "https://vimeo.com/774694040",
"params": {
"vQuality": "1440"
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "720p dash parcel",
"url": "https://vimeo.com/774694040",
"params": {
"vQuality": "360"
},
"expected": {
"code": 200,
"status": "stream"
}
}],
"reddit": [{
"name": "video with audio",
"url": "https://www.reddit.com/r/catvideos/comments/b2rygq/my_new_kittens_1st_day_checking_out_his_new_home/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "video with audio (isAudioOnly)",
"url": "https://www.reddit.com/r/catvideos/comments/b2rygq/my_new_kittens_1st_day_checking_out_his_new_home/?utm_source=share&utm_medium=web2x&context=3",
"params": {
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "video with audio (isAudioMuted)",
"url": "https://www.reddit.com/r/catvideos/comments/b2rygq/my_new_kittens_1st_day_checking_out_his_new_home/?utm_source=share&utm_medium=web2x&context=3",
"params": {
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "video without audio",
"url": "https://www.reddit.com/r/catvideos/comments/ftoeo7/luna_doesnt_want_to_be_bothered_while_shes_napping/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}, {
"name": "actual gif, not looping video",
"url": "https://www.reddit.com/r/whenthe/comments/109wqy1/god_really_did_some_trolling/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}]
}