diff --git a/.dockerignore b/.dockerignore index cd3a1671..67c221fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ env +venv db_data .ash_history -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c46b9245 --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# Example .env file for ytdlbot configuration + +# Number of workers (default is 100) +WORKERS=100 + +# Telegram app ID +APP_ID= + +# Telegram app hash +APP_HASH= + +# Telegram bot token +BOT_TOKEN= + +# Owner ID, comma-separated +OWNER= + +# List of authorized users, comma-separated +AUTHORIZED_USER= + +# Database connection address, i.e. mysql+pymysql://user:pass@mysql/dbname +DB_DSN=mysql+pymysql://ytdlbot:your_password@mysql/ytdlbot + +# Redis host, leave it empty to use fakeredis +REDIS_HOST=redis + +# Enable FFMPEG for video processing (True/False) +ENABLE_FFMPEG=False + +# Desired audio format (e.g., mp3, wav), leave it empty to use m4a +AUDIO_FORMAT= + +# Enable m3u8 link support (True/False) +M3U8_SUPPORT=False + +# Enable Aria2 for downloads (True/False) +ENABLE_ARIA2=False + +# Path to Rclone executable +RCLONE_PATH= + +# Enable VIP features (True/False) +ENABLE_VIP=False + +# Payment provider token from Bot Father +PROVIDER_TOKEN= + +# Free downloads allowed per user +FREE_DOWNLOAD=5 + +# Rate limit for requests +RATE_LIMIT=120 + +# Path for temporary files (ensure the directory exists and is writable) +TMPFILE_PATH= + +# Maximum size for Telegram uploads in MB +TG_NORMAL_MAX_SIZE=2000 + +# Maximum URL length in captions +CAPTION_URL_LENGTH_LIMIT=150 + +# potoken 'https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide' +POTOKEN=11 + +BROWSERS=firefox diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..1aef0940 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: BennyThink +custom: https://buy.stripe.com/bIYbMa9JletbevCaEE diff --git a/.github/workflows/builder.yaml b/.github/workflows/builder.yaml index e8b78fcf..47431167 100644 --- a/.github/workflows/builder.yaml +++ b/.github/workflows/builder.yaml @@ -1,6 +1,8 @@ name: docker image builder on: push: + paths-ignore: + - '**.md' branches: - 'master' @@ -9,18 +11,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: true - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -28,13 +30,13 @@ jobs: ${{ runner.os }}-buildx- - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -42,25 +44,27 @@ jobs: - name: Lower case for Docker Hub id: dh_string - uses: ASzc/change-string-case-action@v1 + uses: ASzc/change-string-case-action@v5 with: string: ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} - name: Lower case for ghcr id: ghcr_string - uses: ASzc/change-string-case-action@v1 + uses: ASzc/change-string-case-action@v5 with: string: ${{ github.event.repository.full_name }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v4 with: context: . - platforms: linux/arm,linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64 push: true tags: | ${{ steps.dh_string.outputs.lowercase }} + ${{ steps.dh_string.outputs.lowercase }}:${{ github.sha }} ghcr.io/${{ steps.ghcr_string.outputs.lowercase }} + ghcr.io/${{ steps.ghcr_string.outputs.lowercase }}:${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max @@ -68,4 +72,4 @@ jobs: - name: Move cache run: | rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache \ No newline at end of file + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b6c6ea29..c7cea334 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 \ No newline at end of file + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index ea7f521a..5f87873a 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,23 @@ ytdlbot/ytdl.session data/* upgrade_worker.sh ytdl.session -reinforcement/* \ No newline at end of file +reinforcement/* +/ytdlbot/session/celery.session +/.idea/prettier.xml +/.idea/watcherTasks.xml +/ytdlbot/session/ytdl.session-journal +/ytdlbot/unknown_errors.txt +/ytdlbot/ytdl.session-journal +/ytdlbot/ytdl-main.session-journal +/ytdlbot/ytdl-main.session +/ytdlbot/ytdl-celery.session-journal +/ytdlbot/ytdl-celery.session +/ytdlbot/main.session +/ytdlbot/tasks.session +/ytdlbot/tasks.session-journal +/ytdlbot/premium.session +/dump.rdb +/ytdlbot/premium.session-journal +/ytdlbot/main.session-journal +/src/main.session +/src/main.session-journal diff --git a/Dockerfile b/Dockerfile index 918b8dd5..d28fb5d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,16 @@ -FROM python:3.9-alpine as builder +FROM python:3.12-alpine AS pybuilder +ADD pyproject.toml pdm.lock /build/ +WORKDIR /build +RUN apk add alpine-sdk python3-dev musl-dev linux-headers +RUN pip install pdm +RUN pdm install -RUN apk update && apk add --no-cache tzdata alpine-sdk libffi-dev ca-certificates -ADD requirements.txt /tmp/ -RUN pip3 install --user -r /tmp/requirements.txt && rm /tmp/requirements.txt +FROM python:3.12-alpine AS runner +WORKDIR /app +RUN apk update && apk add --no-cache ffmpeg aria2 deno +COPY --from=pybuilder /build/.venv/lib/ /usr/local/lib/ +COPY src /app +WORKDIR /app -FROM python:3.9-alpine -WORKDIR /ytdlbot/ytdlbot -ENV TZ=Asia/Shanghai - -COPY apk.txt /tmp/ -RUN apk update && xargs apk add < /tmp/apk.txt -COPY --from=builder /root/.local /usr/local -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo -COPY . /ytdlbot - -CMD ["/usr/local/bin/supervisord", "-c" ,"/ytdlbot/conf/supervisor_main.conf"] +CMD ["python" ,"main.py"] diff --git a/Makefile b/Makefile deleted file mode 100644 index 299f31fa..00000000 --- a/Makefile +++ /dev/null @@ -1,52 +0,0 @@ -define NOLOGGING - - logging: - driver: none -endef -export NOLOGGING - -default: - docker pull bennythink/ytdlbot - -bot: - make - docker-compose up -d - docker system prune -a --volumes -f - -worker: - make - docker-compose -f worker.yml up -d - docker system prune -a --volumes -f - sleep 5 - -weak-worker: - make - docker-compose --compatibility -f worker.yml up -d - docker system prune -a --volumes -f - sleep 5 - -upgrade-all-worker: - bash upgrade_worker.sh - -tag: - git tag -a v$(shell date "+%Y-%m-%d")_$(shell git rev-parse --short HEAD) -m v$(shell date "+%Y-%m-%d") - git push --tags - -nolog: - echo "$$NOLOGGING">> worker.yml - -flower: - echo 'import dbm;dbm.open("data/flower","n");exit()'| python3 - -up: - docker build -t bennythink/ytdlbot:latest . - docker-compose -f docker-compose.yml -f worker.yml up -d - -ps: - docker-compose -f docker-compose.yml -f worker.yml ps - -down: - docker-compose -f docker-compose.yml -f worker.yml down - -logs: - docker-compose -f docker-compose.yml -f worker.yml logs -f worker ytdl \ No newline at end of file diff --git a/Procfile b/Procfile deleted file mode 100644 index 8be22ed7..00000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -worker: python ytdlbot/ytdl_bot.py \ No newline at end of file diff --git a/README.md b/README.md index 2ebe4bb4..82211b21 100644 --- a/README.md +++ b/README.md @@ -2,263 +2,203 @@ [![docker image](https://github.com/tgbot-collection/ytdlbot/actions/workflows/builder.yaml/badge.svg)](https://github.com/tgbot-collection/ytdlbot/actions/workflows/builder.yaml) -YouTube Download Bot🚀 +**YouTube Download Bot🚀🎬⬇️** -Download videos from YouTube and other platforms through a Telegram Bot - ------ - -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) - -Can't deploy? Fork to your personal account and deploy it there! +This Telegram bot allows you to download videos from YouTube and [other supported websites](#supported-websites). # Usage -[https://t.me/benny_ytdlbot](https://t.me/benny_ytdlbot) +* EU🇪🇺: [https://t.me/benny_2ytdlbot](https://t.me/benny_2ytdlbot) +* Singapore🇸🇬:[https://t.me/benny_ytdlbot](https://t.me/benny_ytdlbot) -Send link directly to the bot. Any -Websites [supported by youtube-dl](https://ytdl-org.github.io/youtube-dl/supportedsites.html) will also work. +* Join Telegram Channel https://t.me/ytdlbot0 for updates. -# Limitations of my bot +Just send a link directly to the bot. -I don't have unlimited servers and bandwidth, so I have to make some restrictions. +# Supported websites -* 10 GiB one-way traffic per 24 hours for each user -* maximum 5 minutes streaming conversion support -* maximum 3 subscriptions +* YouTube +* Any websites [supported by yt-dlp](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) -You can choose to become 'VIP' if you really need large traffic. And also, you could always deploy your own bot. + ### Specific link downloader (Use /spdl for these links) + * Instagram (Videos, Photos, Reels, IGTV & carousel) + * Pixeldrain + * KrakenFiles # Features -![](assets/1.jpeg) - 1. fast download and upload. -2. ads free -3. support progress bar -4. audio conversion -5. playlist support -6. VIP support -7. support different video resolutions -8. support sending as file or streaming as video -9. supports celery worker distribution - faster than before. -10. subscriptions to YouTube Channels -11. cache mechanism - download once for the same video. +2. No ads +3. download & upload progress bar +4. download quality selection +5. upload format: file, video, audio +6. cache mechanism - download once for the same video. +7. Supports multiple download engines (yt-dlp, aria2, requests). -![](assets/2.jpeg) - -# How to deploy? +> ## Limitations +> Due to limitations on servers and bandwidth, there are some restrictions on this free service. +> * Each user is limited to 1 free downloads every day. -You can deploy this bot on any platform that supports Python. +# Screenshots -## Heroku +## Normal download -Use the button above! It should work like a magic but with limited functionalities. - -## Run natively on your machine +![](assets/1.jpeg) -1. clone code -2. install ffmpeg -3. install Python 3.6+ -4. pip3 install -r requirements.txt -5. set environment variables `TOKEN`, `APP_ID` and `APP_HASH`, and more if you like. -6. `python3 ytdl_bot.py` +## Instagram download -## Docker +![](assets/instagram.png) -Some functions, such as VIP, ping will be disabled. +![](assets/2.jpeg) -```shell -docker run -e APP_ID=111 -e APP_HASH=111 -e TOKEN=370FXI bennythink/ytdlbot -``` +# How to deploy? -# Complete deployment guide for docker-compose +This bot can be deployed on any platform that supports Python. -* contains every functionality -* compatible with amd64, arm64 and armv7l +## Run natively on your machine -## 1. get docker-compose.yml +> Project use PDM to manage dependencies. + +1.
+ Install PDM + + You can install using pip: `pip install --user pdm` + or for detailed instructions: [Official Docs](https://pdm-project.org/en/latest/#installation) + +
+ +2. Install modules using PDM: `pdm install`, or the old way use `pip install -r requirements.txt` + + +> [!IMPORTANT] +> All users who intend to download from YouTube are strongly encouraged to install one of the JS runtimes (like deno) supported by yt-dlp. + +3.
+ Setting up config file + + ``` + cp .env.example .env + ``` + + Fill the fields in `.env`. For more information, see the comments in the `.env.example` file. + + **- Required Fields** + - `WORKERS`: Number of workers (default is 100) + - `APP_ID`: Telegram app ID + - `APP_HASH`: Telegram app hash + - `BOT_TOKEN`: Your telegram bot token + - `OWNER`: Owner ID (separate by `,`) + - `AUTHORIZED_USER`: List of authorized users ids, (separate by `,`) + - `DB_DSN`: Your database URL (mysql+pymysql://user:pass@mysql/dbname) or SQLite (sqlite:///db.sqlite) + - `REDIS_HOST`: Redis host + + **- Optional Fields** + - `ENABLE_FFMPEG`: Enable FFMPEG for video processing (True/False) + - `AUDIO_FORMAT`: Desired audio format (e.g.:- mp3, wav) + - `ENABLE_ARIA2`: Enable Aria2 for downloads (True/False) + - `RCLONE_PATH`: Path to Rclone executable + - `ENABLE_VIP`: Enable VIP features (True/False) + - `PROVIDER_TOKEN`: Payment provider token from Stripe + - `FREE_DOWNLOAD`: Free downloads allowed per user + - `RATE_LIMIT`: Rate limit for requests + - `TMPFILE_PATH`: Path for temporary/download files (ensure the directory exists and is writable) + - `TG_NORMAL_MAX_SIZE`: Maximum size for Telegram uploads in MB + - `CAPTION_URL_LENGTH_LIMIT`: Maximum URL length in captions + - `POTOKEN`: Your PO Token. [PO-Token-Guide](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide) + - `BROWSERS`: Browser to handle 'cookies from browser', i.e. firefox +
+ +4. Activate virtual environment that created by PDM: `source .venv/bin/activate` + +5. Finally run the bot: `python src/main.py` -Download `docker-compose.yml` file to a directory +## Docker -## 2. create data directory +One line command to run the bot ```shell -mkdir data -mkdir env +docker run --env-file .env bennythink/ytdlbot ``` -## 3. configuration - -### 3.1. set environment variables +# Command -```shell -vim env/ytdl.env ``` - -you can configure all the following environment variables: - -* PYRO_WORKERS: number of workers for pyrogram, default is 100 -* WORKERS: workers count for celery -* APP_ID: **REQUIRED**, get it from https://core.telegram.org/ -* APP_HASH: **REQUIRED** -* TOKEN: **REQUIRED** -* REDIS: **REQUIRED if you need VIP mode and cache** ⚠️ Don't publish your redis server on the internet. ⚠️ - -* OWNER: owner username -* QUOTA: quota in bytes -* EX: quota expire time -* MULTIPLY: vip quota comparing to normal quota -* USD2CNY: exchange rate -* VIP: VIP mode, default: disable -* AFD_LINK -* COFFEE_LINK -* COFFEE_TOKEN -* AFD_TOKEN -* AFD_USER_ID - -* AUTHORIZED_USER: users that could use this bot, user_id, separated with `,` -* REQUIRED_MEMBERSHIP: group or channel username, user must join this group to use the bot. Could be use with - above `AUTHORIZED_USER` - -* ENABLE_CELERY: Distribution mode, default: disable. You'll can setup workers in different locations. -* MYSQL_HOST: you'll have to setup MySQL if you enable VIP mode -* MYSQL_USER -* MYSQL_PASS -* GOOGLE_API_KEY: YouTube API key, required for YouTube video subscription. -* AUDIO_FORMAT: audio format, default is m4a. You can set to any known and supported format for ffmpeg. For - example,`mp3`, `flac`, etc. ⚠️ m4a is the fastest. Other formats may affect performance. -* ARCHIVE_ID: group or channel id/username. All downloads will send to this group first and then forward to end user. -**Inline button will be lost during the forwarding.** - -## 3.2 Set up init data - -If you only need basic functionality, you can skip this step. - -### 3.2.1 Create MySQL db - -Required for VIP, settings, YouTube subscription. - -```shell -docker-compose up -d -docker-compose exec mysql bash - -mysql -u root -p - -> create database ytdl; +start - Let's start +about - What's this bot? +help - Help +spdl - Use to download specific link downloader links +direct - Download using aria2/requests engines +ytdl - Download video in group +settings - Set your preference +unsub - Unsubscribe from YouTube Channel +ping - Ping the Bot +stats - Server and bot stats +buy - Buy quota. ``` -### 3.2.2 Setup flower db in `ytdlbot/ytdlbot/data` - -Required if you enable celery and want to monitor the workers. +# Test data -```shell -{} ~ python3 -Python 3.9.9 (main, Nov 21 2021, 03:22:47) -[Clang 12.0.0 (clang-1200.0.32.29)] on darwin -Type "help", "copyright", "credits" or "license" for more information. ->>> import dbm;dbm.open("flower","n");exit() -``` +
Tap to expand -### 3.2.3 Setup instagram cookies +## Test video -Required if you want to support instagram. +https://www.youtube.com/watch?v=V3RtA-1b_2E -You can use this extension -[Get cookies.txt](https://chrome.google.com/webstore/detail/get-cookiestxt/bgaddhkoddajcdgocldbbfleckgcbcid) -to get instagram cookies +## Test Playlist -```shell -vim data/instagram.com_cookies.txt -# paste your cookies -``` +https://www.youtube.com/playlist?list=PL1Hdq7xjQCJxQnGc05gS4wzHWccvEJy0w -## 3.3 Tidy docker-compose.yml +## Test twitter -In `flower` service section, you may want to change your basic authentication username password and publish port. +https://twitter.com/nitori_sayaka/status/1526199729864200192 +https://twitter.com/BennyThinks/status/1475836588542341124 -You can also limit CPU and RAM usage by adding an `deploy' key: +## Test instagram -```docker - deploy: - resources: - limits: - cpus: '0.5' - memory: 1500M -``` +* single image: https://www.instagram.com/p/CXpxSyOrWCA/ +* single video: https://www.instagram.com/p/Cah_7gnDVUW/ +* reels: https://www.instagram.com/p/C0ozGsjtY0W/ +* image carousel: https://www.instagram.com/p/C0ozPQ5o536/ +* video and image carousel: https://www.instagram.com/p/C0ozhsVo-m8/ -Be sure to use `--compatibility` when deploying. +## Test Pixeldrain -## 4. run +https://pixeldrain.com/u/765ijw9i -### 4.1. standalone mode +## Test KrakenFiles -If you only want to run the mode without any celery worker and VIP mode, you can just start `ytdl` service +https://krakenfiles.com/view/oqmSTF0T5t/file.html -```shell -docker-compose up -d ytdl -``` +
-### 4.2 VIP mode +# Donation -You'll have to start MySQL and redis to support VIP mode, subscription and settings. +Found this bot useful? You can donate to support the development of this bot. -``` -docker-compose up -d mysql redis ytdl -``` +## Donation Platforms -### 4.3 Celery worker mode +* [Buy me a coffee](https://www.buymeacoffee.com/bennythink) +* [GitHub Sponsor](https://github.com/sponsors/BennyThink) -Firstly, set `ENABLE_CELERY` to true. And then, on one machine: +## Stripe -```shell -docker-compose up -d -``` +You can choose to donate via Stripe. -On the other machine: +| USD(Card, Apple Pay and Google Pay) | CNY(Card, Apple Pay, Google Pay and Alipay) | +|--------------------------------------------------|--------------------------------------------------| +| [USD](https://buy.stripe.com/cN203sdZB98RevC3cd) | [CNY](https://buy.stripe.com/dR67vU4p13Ox73a6oq) | +| ![](assets/USD.png) | ![](assets/CNY.png) | -```shell -docker-compose -f worker.yml up -d -``` +## Cryptocurrency -**⚠️ Bear in mind don't publish redis directly on the internet! You can use WireGuard to wrap it up.** +TRX or USDT(TRC20) -# Command +![](assets/tron.png) ``` -start - Let's start -about - What's this bot? -ping - Bot running status -help - Help -ytdl - Download video in group -vip - Join VIP -terms - View Terms of Service -settings - Set your preference -direct - Download file directly -sub - Subscribe to YouTube Channel -unsub - Unsubscribe from YouTube Channel -sub_count - Check subscription status, owner only. +TF9peZjC2FYjU4xNMPg3uP4caYLJxtXeJS ``` -# Test data - -## Test video - -https://www.youtube.com/watch?v=BaW_jenozKc - -## Test Playlist - -https://www.youtube.com/playlist?list=PL1Hdq7xjQCJxQnGc05gS4wzHWccvEJy0w - -## Test m3u8 - -https://dmesg.app/m3u8/prog_index.m3u8 - -# Donation - -* [Buy me a coffee](https://www.buymeacoffee.com/bennythink) -* [Afdian](https://afdian.net/@BennyThink) - # License Apache License 2.0 diff --git a/apk.txt b/apk.txt deleted file mode 100644 index fd936fe0..00000000 --- a/apk.txt +++ /dev/null @@ -1 +0,0 @@ -ffmpeg vnstat git \ No newline at end of file diff --git a/app.json b/app.json deleted file mode 100644 index 6db4337a..00000000 --- a/app.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "YouTube-Downloader", - "description": "A Telegrambot to download youtube video", - "repository": "https://github.com/tgbot-collection/ytdlbot", - "logo": "https://avatars.githubusercontent.com/u/73354211?s=200&v=4", - "keywords": [ - "telegram", - "youtube-dl" - ], - "env": { - "TOKEN": { - "description": "Bot token", - "value": "token" - }, - "APP_ID": { - "description": "APP ID", - "value": "12345" - }, - "APP_HASH": { - "description": "APP HASH", - "value": "12345abc" - }, - "OWNER": { - "description": "Your telegram username", - "value": "username", - "required": false - } - }, - "formation": { - "worker": { - "quantity": 1, - "size": "free" - } - }, - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-python.git" - }, - { - "url": "https://github.com/jonathanong/heroku-buildpack-ffmpeg-latest.git" - } - ] -} \ No newline at end of file diff --git a/assets/2.jpeg b/assets/2.jpeg deleted file mode 100644 index 65c36cd4..00000000 Binary files a/assets/2.jpeg and /dev/null differ diff --git a/assets/CNY.png b/assets/CNY.png new file mode 100644 index 00000000..0bf58380 Binary files /dev/null and b/assets/CNY.png differ diff --git a/assets/USD.png b/assets/USD.png new file mode 100644 index 00000000..108fee1c Binary files /dev/null and b/assets/USD.png differ diff --git a/assets/instagram.png b/assets/instagram.png new file mode 100644 index 00000000..f19a0e39 Binary files /dev/null and b/assets/instagram.png differ diff --git a/assets/tron.png b/assets/tron.png new file mode 100644 index 00000000..fb3fea3e Binary files /dev/null and b/assets/tron.png differ diff --git a/conf/YouTube Download Celery.json b/conf/YouTube Download Celery.json deleted file mode 100644 index 34393887..00000000 --- a/conf/YouTube Download Celery.json +++ /dev/null @@ -1,794 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_CELERY", - "label": "celery", - "description": "", - "type": "datasource", - "pluginId": "influxdb", - "pluginName": "InfluxDB" - } - ], - "__elements": [], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "8.3.1" - }, - { - "type": "datasource", - "id": "influxdb", - "name": "InfluxDB", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "iteration": 1644554238421, - "links": [], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "Active", - "groupBy": [ - { - "params": [ - "$__interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "measurement": "active", - "orderByTime": "ASC", - "policy": "default", - "query": "SELECT mean(\"active\") FROM \"active\" WHERE $timeFilter GROUP BY time($__interval) ", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "active" - ], - "type": "field" - }, - { - "params": [], - "type": "mean" - } - ] - ], - "tags": [] - }, - { - "alias": "$tag_hostname", - "hide": false, - "query": "\nSELECT \nmean(\"active\") AS active\nFROM \"tasks\" WHERE (\"hostname\" =~ /^$hostname$/) AND $timeFilter GROUP BY time($__interval) ,* ORDER BY asc ", - "rawQuery": true, - "refId": "B", - "resultFormat": "time_series" - } - ], - "title": "Active Jobs", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 10, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "$col", - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "groupBy": [ - { - "params": [ - "$__interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "measurement": "metrics", - "orderByTime": "ASC", - "policy": "default", - "query": "\nSELECT \nmean(\"today_audio_success\")/mean(\"today_audio_request\")*100 as audio_success,\nmean(\"today_video_success\")/mean(\"today_video_request\")*100 as video_success\n\nFROM \"metrics\" WHERE $timeFilter GROUP BY time($__interval), * ORDER BY asc ", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "today_audio_success" - ], - "type": "field" - }, - { - "params": [], - "type": "mean" - } - ] - ], - "tags": [] - } - ], - "title": "Video & Audio Success Rate", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 6, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "$tag_hostname:$col", - "query": "SELECT mean(\"load1\") AS load1,mean(\"load5\") AS load5,mean(\"load15\") AS load15\nFROM \"tasks\" WHERE (\"hostname\" =~ /^$hostname$/) AND $timeFilter GROUP BY time($__interval) ,* ORDER BY asc \n\n", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series" - } - ], - "title": "Load Average", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 9, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "$tag_hostname:$col", - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "groupBy": [ - { - "params": [ - "$__interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "measurement": "tasks", - "orderByTime": "ASC", - "policy": "default", - "query": "\nSELECT mean(\"task-succeeded\")/mean(\"task-received\")*100 AS success_rate, mean(\"task-failed\")/mean(\"task-received\")*100 AS fail_rate\n\nFROM \"tasks\" WHERE (\"hostname\" =~ /^$hostname$/) AND $timeFilter GROUP BY time($__interval) ,* ORDER BY asc ", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "task-received" - ], - "type": "field" - } - ] - ], - "tags": [ - { - "key": "hostname", - "operator": "=~", - "value": "/^$hostname$/" - } - ] - } - ], - "title": "Task Rate", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 16 - }, - "id": 13, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "$tag_hostname:$col", - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "groupBy": [ - { - "params": [ - "$__interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "measurement": "tasks", - "orderByTime": "ASC", - "policy": "default", - "query": "\nSELECT mean(\"task-received\") AS received, mean(\"task-started\") AS started,mean(\"task-succeeded\") AS succeeded,mean(\"task-failed\") AS failed\n\nFROM \"tasks\" WHERE (\"hostname\" =~ /^$hostname$/) AND $timeFilter GROUP BY time($__interval) ,* ORDER BY asc ", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "task-received" - ], - "type": "field" - } - ] - ], - "tags": [ - { - "key": "hostname", - "operator": "=~", - "value": "/^$hostname$/" - } - ] - } - ], - "title": "Task Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 16 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "alias": "$col", - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "groupBy": [ - { - "params": [ - "$__interval" - ], - "type": "time" - }, - { - "params": [ - "null" - ], - "type": "fill" - } - ], - "measurement": "metrics", - "orderByTime": "ASC", - "policy": "default", - "query": "SELECT \nmean(\"today_audio_request\") as audio_request,\nmean(\"today_audio_success\") as audio_success,\n\nmean(\"today_bad_request\") as bad_request,\n\nmean(\"today_video_request\") as video_request,\nmean(\"today_video_success\") as video_success\nFROM \"metrics\" WHERE $timeFilter GROUP BY time($__interval), * ORDER BY asc ", - "rawQuery": true, - "refId": "A", - "resultFormat": "time_series", - "select": [ - [ - { - "params": [ - "today_audio_success" - ], - "type": "field" - }, - { - "params": [], - "type": "mean" - } - ] - ], - "tags": [] - } - ], - "title": "Video & Audio", - "type": "timeseries" - } - ], - "refresh": "", - "schemaVersion": 33, - "style": "dark", - "tags": [], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "influxdb", - "uid": "${DS_CELERY}" - }, - "definition": "show tag values with KEY=\"hostname\"", - "hide": 0, - "includeAll": true, - "label": "hostname", - "multi": true, - "name": "hostname", - "options": [], - "query": "show tag values with KEY=\"hostname\"", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "YouTube Download Celery", - "uid": "9yXGmc1nk", - "version": 14, - "weekStart": "" -} \ No newline at end of file diff --git a/conf/supervisor_main.conf b/conf/supervisor_main.conf deleted file mode 100644 index 5a6c9929..00000000 --- a/conf/supervisor_main.conf +++ /dev/null @@ -1,32 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/dev/null -logfile_maxbytes=0 -user=root - - -[program:vnstat] -command=vnstatd -n -autorestart=true - - -[program:ytdl] -directory=/ytdlbot/ytdlbot/ -command=python ytdl_bot.py -autorestart=true -priority=900 -stopasgroup=true - -redirect_stderr=true -stdout_logfile_maxbytes = 50MB -stdout_logfile_backups = 2 -stdout_logfile = /var/log/ytdl.log - -[program:log] -command=tail -f /var/log/ytdl.log -autorestart=true -priority=999 - -redirect_stderr=true -stdout_logfile=/dev/fd/1 -stdout_logfile_maxbytes=0 \ No newline at end of file diff --git a/conf/supervisor_worker.conf b/conf/supervisor_worker.conf deleted file mode 100644 index 6c4dccfe..00000000 --- a/conf/supervisor_worker.conf +++ /dev/null @@ -1,28 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/dev/null -logfile_maxbytes=0 -user=root - - - -[program:worker] -directory=/ytdlbot/ytdlbot/ -command=python tasks.py -autorestart=true -priority=900 -stopasgroup=true - -redirect_stderr=true -stdout_logfile_maxbytes = 50MB -stdout_logfile_backups = 2 -stdout_logfile = /var/log/ytdl.log - -[program:log] -command=tail -f /var/log/ytdl.log -autorestart=true -priority=999 - -redirect_stderr=true -stdout_logfile=/dev/fd/1 -stdout_logfile_maxbytes=0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e6012c1f..b78b6207 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,51 +1,49 @@ -version: '3.1' - services: - socat: - image: bennythink/socat - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - entrypoint: [ "socat", "tcp-listen:2375,fork,reuseaddr","unix-connect:/var/run/docker.sock" ] - redis: - image: redis:alpine - restart: always + image: redis:7-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 logging: - driver: none + options: + max-size: "10m" + max-file: "3" mysql: - image: ubuntu/mysql:8.0-20.04_beta + image: ubuntu/mysql:8.0-22.04_beta restart: always volumes: - ./db_data:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD: 'root' + MYSQL_ROOT_PASSWORD: "root" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 3 + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --explicit_defaults_for_timestamp=1 + - --max_allowed_packet=64M logging: - driver: none + options: + max-size: "10m" + max-file: "3" ytdl: image: bennythink/ytdlbot env_file: - - env/ytdl.env + - .env restart: always - depends_on: - - socat - - redis volumes: - - ./data/instagram.com_cookies.txt:/ytdlbot/ytdlbot/instagram.com_cookies.txt - - ./data/vnstat/:/var/lib/vnstat/ - - flower: - image: bennythink/ytdlbot - env_file: - - env/ytdl.env - restart: on-failure - command: [ "/usr/local/bin/celery", - "-A", "flower_tasks", "flower", - "--basic_auth=benny:123456", - "--address=0.0.0.0", "--persistent","--purge_offline_workers=3600" ] - volumes: - - ./data/flower:/ytdlbot/ytdlbot/flower - ports: - - "5555:5555" \ No newline at end of file + - ./youtube-cookies.txt:/app/youtube-cookies.txt + depends_on: + redis: + condition: service_healthy + mysql: + condition: service_healthy diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 00000000..ad00d10a --- /dev/null +++ b/pdm.lock @@ -0,0 +1,1138 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:d7917c1051f4b5ff27b1dd3bf9fc03daa8a1c6178a848d93eaae8e1d92e15600" + +[[metadata.targets]] +requires_python = ">=3.10" + +[[package]] +name = "apscheduler" +version = "3.11.0" +requires_python = ">=3.8" +summary = "In-process task scheduler with Cron-like capabilities" +groups = ["default"] +dependencies = [ + "backports-zoneinfo; python_version < \"3.9\"", + "tzlocal>=3.0", +] +files = [ + {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, + {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +requires_python = ">=3.8" +summary = "Timeout context manager for asyncio programs" +groups = ["default"] +marker = "python_full_version < \"3.11.3\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +requires_python = ">=3.6.0" +summary = "Screen-scraping library" +groups = ["default"] +dependencies = [ + "soupsieve>1.2", +] +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[[package]] +name = "black" +version = "24.10.0" +requires_python = ">=3.9" +summary = "The uncompromising code formatter." +groups = ["default"] +dependencies = [ + "click>=8.0.0", + "mypy-extensions>=0.4.3", + "packaging>=22.0", + "pathspec>=0.9.0", + "platformdirs>=2", + "tomli>=1.1.0; python_version < \"3.11\"", + "typing-extensions>=4.0.1; python_version < \"3.11\"", +] +files = [ + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, +] + +[[package]] +name = "brotli" +version = "1.1.0" +summary = "Python bindings for the Brotli compression library" +groups = ["default"] +marker = "implementation_name == \"cpython\"" +files = [ + {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, + {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, + {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, + {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, + {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, + {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, + {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, + {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, + {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, + {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, + {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, + {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, + {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, + {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, + {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, + {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, + {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, +] + +[[package]] +name = "brotlicffi" +version = "1.1.0.0" +requires_python = ">=3.7" +summary = "Python CFFI bindings to the Brotli library" +groups = ["default"] +marker = "implementation_name != \"cpython\"" +dependencies = [ + "cffi>=1.0.0", +] +files = [ + {file = "brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-win32.whl", hash = "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2e4aeb0bd2540cb91b069dbdd54d458da8c4334ceaf2d25df2f4af576d6766ca"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7b0033b0d37bb33009fb2fef73310e432e76f688af76c156b3594389d81391"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54a07bb2374a1eba8ebb52b6fafffa2afd3c4df85ddd38fcc0511f2bb387c2a8"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7901a7dc4b88f1c1475de59ae9be59799db1007b7d059817948d8e4f12e24e35"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce01c7316aebc7fce59da734286148b1d1b9455f89cf2c8a4dfce7d41db55c2d"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1a807d760763e398bbf2c6394ae9da5815901aa93ee0a37bca5efe78d4ee3171"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa8ca0623b26c94fccc3a1fdd895be1743b838f3917300506d04aa3346fd2a14"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3de0cf28a53a3238b252aca9fed1593e9d36c1d116748013339f0949bfc84112"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be5ec0e88a4925c91f3dea2bb0013b3a2accda6f77238f76a34a1ea532a1cb0"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d9eb71bb1085d996244439154387266fd23d6ad37161f6f52f1cd41dd95a3808"}, + {file = "brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +requires_python = ">=3.6" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default"] +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +requires_python = ">=3.8" +summary = "Foreign Function Interface for Python calling C code." +groups = ["default"] +dependencies = [ + "pycparser", +] +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +requires_python = ">=3.7.0" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +groups = ["default"] +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +requires_python = ">=3.7" +summary = "Composable command line interface toolkit" +groups = ["default"] +dependencies = [ + "colorama; platform_system == \"Windows\"", + "importlib-metadata; python_version < \"3.8\"", +] +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cross-platform colored terminal text." +groups = ["default"] +marker = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "43.0.3" +requires_python = ">=3.7" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +groups = ["default"] +dependencies = [ + "cffi>=1.12; platform_python_implementation != \"PyPy\"", +] +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[[package]] +name = "curl-cffi" +version = "0.10.0" +requires_python = ">=3.9" +summary = "libcurl ffi bindings for Python, with impersonation support." +groups = ["default"] +marker = "implementation_name == \"cpython\"" +dependencies = [ + "certifi>=2024.2.2", + "cffi>=1.12.0", +] +files = [ + {file = "curl_cffi-0.10.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:15053d01c6a3e3c4c5331ce9e07e1dc31ca5aa063babca05d18b1b5aad369fac"}, + {file = "curl_cffi-0.10.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:3969e4260ad4dab638fb6dbe349623f9f5f022435c7fd21daf760231380367fa"}, + {file = "curl_cffi-0.10.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:458f53c41bd76d90d8974d60c3a8a0dd902a1af1f9056215cf24f454bcedc6fd"}, + {file = "curl_cffi-0.10.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfc74f09e44d2d8d61b8e8fda3a7004b5bc0217a703fbbe9e16ef8caa1f3d4e4"}, + {file = "curl_cffi-0.10.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f03f4b17dc679c82bd3c946feb1ad38749b2ad731d7c26daefaac857d1c72fd9"}, + {file = "curl_cffi-0.10.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f1b0c7b7b81afca15a0e56c593d3c2bdcd4fd4c9ca49b9ded5b9d8076ba78ff9"}, + {file = "curl_cffi-0.10.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:04b1d23f0f54f94b8298ed417e6bece85a635d674723cde2b155da686efbf78f"}, + {file = "curl_cffi-0.10.0-cp39-abi3-win32.whl", hash = "sha256:1e60b8ecc80bfb0da4ff73ac9d194e80482b50ecbb8aefec1b0edaf45fafd80e"}, + {file = "curl_cffi-0.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:59389773a1556e087120e91eac1e33f84f1599d853e1bc168b153e4cdf360002"}, + {file = "curl_cffi-0.10.0.tar.gz", hash = "sha256:3e37b35268ca58492f54ed020ae4b50c33ee0debad4145db9f746f04ed466eb0"}, +] + +[[package]] +name = "fakeredis" +version = "2.26.2" +requires_python = "<4.0,>=3.7" +summary = "Python implementation of redis API, can be used for testing purposes." +groups = ["default"] +dependencies = [ + "redis>=4.3; python_full_version > \"3.8.0\"", + "redis>=4; python_version < \"3.8\"", + "sortedcontainers<3,>=2", + "typing-extensions<5.0,>=4.7; python_version < \"3.11\"", +] +files = [ + {file = "fakeredis-2.26.2-py3-none-any.whl", hash = "sha256:86d4129df001efc25793cb334008160fccc98425d9f94de47884a92b63988c14"}, + {file = "fakeredis-2.26.2.tar.gz", hash = "sha256:3ee5003a314954032b96b1365290541346c9cc24aab071b52cc983bb99ecafbf"}, +] + +[[package]] +name = "ffmpeg-python" +version = "0.2.0" +summary = "Python bindings for FFmpeg - with complex filtering support" +groups = ["default"] +dependencies = [ + "future", +] +files = [ + {file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"}, + {file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"}, +] + +[[package]] +name = "ffpb" +version = "0.4.1" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "A progress bar for ffmpeg. Yay !" +groups = ["default"] +dependencies = [ + "tqdm~=4.25", +] +files = [ + {file = "ffpb-0.4.1-py2.py3-none-any.whl", hash = "sha256:0e3e2962f4812e39f29649f09785e7cd877ea7f0e14e84d17918c33618647321"}, + {file = "ffpb-0.4.1.tar.gz", hash = "sha256:ede56a6cba4c1d2d6c070daf612e1c4edc957679e49c6b4423cd7dd159577e59"}, +] + +[[package]] +name = "filetype" +version = "1.2.0" +summary = "Infer file type and MIME type of any file/buffer. No external dependencies." +groups = ["default"] +files = [ + {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, + {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, +] + +[[package]] +name = "future" +version = "1.0.0" +requires_python = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Clean single-source support for Python 3 and 2" +groups = ["default"] +files = [ + {file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"}, + {file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"}, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +requires_python = ">=3.7" +summary = "Lightweight in-process concurrent programming" +groups = ["default"] +marker = "(platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.14\"" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +] + +[[package]] +name = "idna" +version = "3.10" +requires_python = ">=3.6" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[[package]] +name = "kurigram" +version = "2.2.15" +requires_python = ">=3.8" +summary = "Elegant, modern and asynchronous Telegram MTProto API framework in Python for users and bots" +groups = ["default"] +dependencies = [ + "pyaes<=1.6.1", + "pysocks<=1.7.1", +] +files = [ + {file = "kurigram-2.2.15-py3-none-any.whl", hash = "sha256:9622af086988d0df783257b34e0ab423f7d33f1412b563e0d4f3df58d57d8f65"}, + {file = "kurigram-2.2.15.tar.gz", hash = "sha256:4e777ef2809d0883a318299374438a408d71ecb4ddef597b7292b7ffc332955a"}, +] + +[[package]] +name = "mutagen" +version = "1.47.0" +requires_python = ">=3.7" +summary = "read and write audio tags for many formats" +groups = ["default"] +files = [ + {file = "mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"}, + {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +requires_python = ">=3.5" +summary = "Type system extensions for programs checked with the mypy type checker." +groups = ["default"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.2" +requires_python = ">=3.8" +summary = "Core utilities for Python packages" +groups = ["default"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +requires_python = ">=3.8" +summary = "Utility library for gitignore style pattern matching of file paths." +groups = ["default"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +requires_python = ">=3.8" +summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +groups = ["default"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[[package]] +name = "psutil" +version = "6.1.1" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +summary = "Cross-platform lib for process and system monitoring in Python." +groups = ["default"] +files = [ + {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, + {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, + {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, + {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +summary = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +groups = ["default"] +files = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +requires_python = ">=3.8" +summary = "C parser in Python" +groups = ["default"] +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pycryptodomex" +version = "3.22.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cryptographic library for Python" +groups = ["default"] +files = [ + {file = "pycryptodomex-3.22.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:aef4590263b9f2f6283469e998574d0bd45c14fb262241c27055b82727426157"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5ac608a6dce9418d4f300fab7ba2f7d499a96b462f2b9b5c90d8d994cd36dcad"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a24f681365ec9757ccd69b85868bbd7216ba451d0f86f6ea0eed75eeb6975db"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:259664c4803a1fa260d5afb322972813c5fe30ea8b43e54b03b7e3a27b30856b"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7127d9de3c7ce20339e06bcd4f16f1a1a77f1471bcf04e3b704306dde101b719"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee75067b35c93cc18b38af47b7c0664998d8815174cfc66dd00ea1e244eb27e6"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a8b0c5ba061ace4bcd03496d42702c3927003db805b8ec619ea6506080b381d"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bfe4fe3233ef3e58028a3ad8f28473653b78c6d56e088ea04fe7550c63d4d16b"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-win32.whl", hash = "sha256:2cac9ed5c343bb3d0075db6e797e6112514764d08d667c74cb89b931aac9dddd"}, + {file = "pycryptodomex-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:ff46212fda7ee86ec2f4a64016c994e8ad80f11ef748131753adb67e9b722ebd"}, + {file = "pycryptodomex-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c8cffb03f5dee1026e3f892f7cffd79926a538c67c34f8b07c90c0bd5c834e27"}, + {file = "pycryptodomex-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:140b27caa68a36d0501b05eb247bd33afa5f854c1ee04140e38af63c750d4e39"}, + {file = "pycryptodomex-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:644834b1836bb8e1d304afaf794d5ae98a1d637bd6e140c9be7dd192b5374811"}, + {file = "pycryptodomex-3.22.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c506aba3318505dbeecf821ed7b9a9f86f422ed085e2d79c4fba0ae669920a"}, + {file = "pycryptodomex-3.22.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7cd39f7a110c1ab97ce9ee3459b8bc615920344dc00e56d1b709628965fba3f2"}, + {file = "pycryptodomex-3.22.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e4eaaf6163ff13788c1f8f615ad60cdc69efac6d3bf7b310b21e8cfe5f46c801"}, + {file = "pycryptodomex-3.22.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eac39e237d65981554c2d4c6668192dc7051ad61ab5fc383ed0ba049e4007ca2"}, + {file = "pycryptodomex-3.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab0d89d1761959b608952c7b347b0e76a32d1a5bb278afbaa10a7f3eaef9a0a"}, + {file = "pycryptodomex-3.22.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e64164f816f5e43fd69f8ed98eb28f98157faf68208cd19c44ed9d8e72d33e8"}, + {file = "pycryptodomex-3.22.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f005de31efad6f9acefc417296c641f13b720be7dbfec90edeaca601c0fab048"}, + {file = "pycryptodomex-3.22.0.tar.gz", hash = "sha256:a1da61bacc22f93a91cbe690e3eb2022a03ab4123690ab16c46abb693a9df63d"}, +] + +[[package]] +name = "pymysql" +version = "1.1.1" +requires_python = ">=3.7" +summary = "Pure Python MySQL Driver" +groups = ["default"] +files = [ + {file = "PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c"}, + {file = "pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0"}, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +groups = ["default"] +files = [ + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +requires_python = ">=3.8" +summary = "Read key-value pairs from a .env file and set them as environment variables" +groups = ["default"] +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[[package]] +name = "redis" +version = "6.4.0" +requires_python = ">=3.9" +summary = "Python client for Redis database and key-value store" +groups = ["default"] +dependencies = [ + "async-timeout>=4.0.3; python_full_version < \"3.11.3\"", +] +files = [ + {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"}, + {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +requires_python = ">=3.8" +summary = "Python HTTP for Humans." +groups = ["default"] +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +groups = ["default"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "soupsieve" +version = "2.6" +requires_python = ">=3.8" +summary = "A modern CSS selector implementation for Beautiful Soup." +groups = ["default"] +files = [ + {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, + {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.37" +requires_python = ">=3.7" +summary = "Database Abstraction Library" +groups = ["default"] +dependencies = [ + "greenlet!=0.4.17; (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.14\"", + "importlib-metadata; python_version < \"3.8\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "SQLAlchemy-2.0.37-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da36c3b0e891808a7542c5c89f224520b9a16c7f5e4d6a1156955605e54aef0e"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e7402ff96e2b073a98ef6d6142796426d705addd27b9d26c3b32dbaa06d7d069"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6f5d254a22394847245f411a2956976401e84da4288aa70cbcd5190744062c1"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41296bbcaa55ef5fdd32389a35c710133b097f7b2609d8218c0eabded43a1d84"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bedee60385c1c0411378cbd4dc486362f5ee88deceea50002772912d798bb00f"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6c67415258f9f3c69867ec02fea1bf6508153709ecbd731a982442a590f2b7e4"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-win32.whl", hash = "sha256:650dcb70739957a492ad8acff65d099a9586b9b8920e3507ca61ec3ce650bb72"}, + {file = "SQLAlchemy-2.0.37-cp310-cp310-win_amd64.whl", hash = "sha256:93d1543cd8359040c02b6614421c8e10cd7a788c40047dbc507ed46c29ae5636"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78361be6dc9073ed17ab380985d1e45e48a642313ab68ab6afa2457354ff692c"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b661b49d0cb0ab311a189b31e25576b7ac3e20783beb1e1817d72d9d02508bf5"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d57bafbab289e147d064ffbd5cca2d7b1394b63417c0636cea1f2e93d16eb9e8"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa2c0913f02341d25fb858e4fb2031e6b0813494cca1ba07d417674128ce11b"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9df21b8d9e5c136ea6cde1c50d2b1c29a2b5ff2b1d610165c23ff250e0704087"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db18ff6b8c0f1917f8b20f8eca35c28bbccb9f83afa94743e03d40203ed83de9"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-win32.whl", hash = "sha256:46954173612617a99a64aee103bcd3f078901b9a8dcfc6ae80cbf34ba23df989"}, + {file = "SQLAlchemy-2.0.37-cp311-cp311-win_amd64.whl", hash = "sha256:7b7e772dc4bc507fdec4ee20182f15bd60d2a84f1e087a8accf5b5b7a0dcf2ba"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2952748ecd67ed3b56773c185e85fc084f6bdcdec10e5032a7c25a6bc7d682ef"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3151822aa1db0eb5afd65ccfafebe0ef5cda3a7701a279c8d0bf17781a793bb4"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaa8039b6d20137a4e02603aba37d12cd2dde7887500b8855356682fc33933f4"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cdba1f73b64530c47b27118b7053b8447e6d6f3c8104e3ac59f3d40c33aa9fd"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1b2690456528a87234a75d1a1644cdb330a6926f455403c8e4f6cad6921f9098"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf5ae8a9dcf657fd72144a7fd01f243236ea39e7344e579a121c4205aedf07bb"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-win32.whl", hash = "sha256:ea308cec940905ba008291d93619d92edaf83232ec85fbd514dcb329f3192761"}, + {file = "SQLAlchemy-2.0.37-cp312-cp312-win_amd64.whl", hash = "sha256:635d8a21577341dfe4f7fa59ec394b346da12420b86624a69e466d446de16aff"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c4096727193762e72ce9437e2a86a110cf081241919ce3fab8e89c02f6b6658"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4fb5ac86d8fe8151966814f6720996430462e633d225497566b3996966b9bdb"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e56a139bfe136a22c438478a86f8204c1eb5eed36f4e15c4224e4b9db01cb3e4"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f95fc8e3f34b5f6b3effb49d10ac97c569ec8e32f985612d9b25dd12d0d2e94"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c505edd429abdfe3643fa3b2e83efb3445a34a9dc49d5f692dd087be966020e0"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12b0f1ec623cccf058cf21cb544f0e74656618165b083d78145cafde156ea7b6"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-win32.whl", hash = "sha256:293f9ade06b2e68dd03cfb14d49202fac47b7bb94bffcff174568c951fbc7af2"}, + {file = "SQLAlchemy-2.0.37-cp313-cp313-win_amd64.whl", hash = "sha256:d70f53a0646cc418ca4853da57cf3ddddbccb8c98406791f24426f2dd77fd0e2"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:648ec5acf95ad59255452ef759054f2176849662af4521db6cb245263ae4aa33"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:35bd2df269de082065d4b23ae08502a47255832cc3f17619a5cea92ce478b02b"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f581d365af9373a738c49e0c51e8b18e08d8a6b1b15cc556773bcd8a192fa8b"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82df02816c14f8dc9f4d74aea4cb84a92f4b0620235daa76dde002409a3fbb5a"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:94b564e38b344d3e67d2e224f0aec6ba09a77e4582ced41e7bfd0f757d926ec9"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:955a2a765aa1bd81aafa69ffda179d4fe3e2a3ad462a736ae5b6f387f78bfeb8"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-win32.whl", hash = "sha256:03f0528c53ca0b67094c4764523c1451ea15959bbf0a8a8a3096900014db0278"}, + {file = "SQLAlchemy-2.0.37-cp39-cp39-win_amd64.whl", hash = "sha256:4b12885dc85a2ab2b7d00995bac6d967bffa8594123b02ed21e8eb2205a7584b"}, + {file = "SQLAlchemy-2.0.37-py3-none-any.whl", hash = "sha256:a8998bf9f8658bd3839cbc44ddbe982955641863da0c1efe5b00c1ab4f5c16b1"}, + {file = "sqlalchemy-2.0.37.tar.gz", hash = "sha256:12b28d99a9c14eaf4055810df1001557176716de0167b91026e648e65229bffb"}, +] + +[[package]] +name = "tgcrypto" +version = "1.2.5" +requires_python = "~=3.7" +summary = "Fast and Portable Cryptography Extension Library for Pyrogram" +groups = ["default"] +files = [ + {file = "TgCrypto-1.2.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4507102377002966f35f2481830b7529e00c9bbff8c7d1e09634f984af801675"}, + {file = "TgCrypto-1.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:38fe25c0d79b41d7a89caba2a78dea0358e17ca73b033cefd16abed680685829"}, + {file = "TgCrypto-1.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c035bf8ef89846f67e77e82ea85c089b6ea30631b32e8ac1a6511b9be52ab065"}, + {file = "TgCrypto-1.2.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f594e2680daf20dbac6bf56862f567ddc3cc8d6a19757ed07faa8320ff7acee4"}, + {file = "TgCrypto-1.2.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8723a16076e229ffdf537fdb5e638227d10f44ca43e6939db1eab524de6eaed7"}, + {file = "TgCrypto-1.2.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1c8d974b8b2d7132364b6f0f6712b92bfe47ab9c5dcee25c70327ff68d22d95"}, + {file = "TgCrypto-1.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89d9c143a1fcdb2562a4aa887152abbe9253e1979d7bebef2b489148e0bbe086"}, + {file = "TgCrypto-1.2.5-cp310-cp310-win32.whl", hash = "sha256:aa4bc1d11d4a90811c162abd45a5981f171679d1b5bd0322cd7ccd16447366a2"}, + {file = "TgCrypto-1.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:39145103614c5e38fe938549742d355920f4a0778fa8259eb69c0c85ba4b1d28"}, + {file = "TgCrypto-1.2.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:59597cdb1c87eb1184088563d20b42a8f2e431e9334fed64926079044ad2a4af"}, + {file = "TgCrypto-1.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1283337ae75b02406dd700377b8b783e70033b548492517df6e6c4156b0ed69c"}, + {file = "TgCrypto-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1735437df0023a40e5fdd95e6b09ce806ec8f2cd2f8879023818840dfae60cab"}, + {file = "TgCrypto-1.2.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfa17a20206532c6d2442c9d7a7f6434120bd75896ad9a3e9b9277477afa084f"}, + {file = "TgCrypto-1.2.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48da3674474839e5619e7430ff1f98aed9f55369f3cfaef7f65511852869572e"}, + {file = "TgCrypto-1.2.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b49e982e5b156be821a5235bd9102c00dc506a58607e2c8bd50ac872724a951f"}, + {file = "TgCrypto-1.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9d9f13586065a6d86d05c16409054033a84be208acee29b49f6f194e27b08642"}, + {file = "TgCrypto-1.2.5-cp311-cp311-win32.whl", hash = "sha256:10dd3870aecb1a783c6eafd3b164b2149dbc93a9ee13feb7e6f5c58f87c24cd0"}, + {file = "TgCrypto-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:a1beec47d6af8b509af7cf266e30f7703208076076594714005b42d2c25225b3"}, + {file = "TgCrypto-1.2.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7885a75db09ce8bdba42d2c332085bfe314f232541a729808c7507ffa261ff9a"}, + {file = "TgCrypto-1.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0d28aa317364a5c27317fe97a48267aa1c65c9aaf589909e97489ebe82a714e3"}, + {file = "TgCrypto-1.2.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:940974e19044dc65bcf7b9c5255173b896dff010142f3833047dc55d59cde21c"}, + {file = "TgCrypto-1.2.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:457c657dd10ffb4bbbb007132a0f6a7bee5080176a98c51f285fedf636b624cb"}, + {file = "TgCrypto-1.2.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:539bdc6b9239fb6a6b134591a998dc7f50d4dcc4fed861f80540682acc0c3802"}, + {file = "TgCrypto-1.2.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d70d5517d64ca952896b726d22c8a66594e6f6259ee2cb4fa134c02d0e8c3e0"}, + {file = "TgCrypto-1.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:90b6337d3ae4348ed14f89dd2ebf7011fa63d67a48c8a98d955a1e392176c60a"}, + {file = "TgCrypto-1.2.5-cp39-cp39-win32.whl", hash = "sha256:37c4b9be82716fbc6d2b123caef448eee28683888803db075d842327766f7624"}, + {file = "TgCrypto-1.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:6e96b3a478fae977228c5750194c20a18cde402bbbea6593de424f84d4a8893b"}, + {file = "TgCrypto-1.2.5-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b0a088ff2e05b6bbe891da936f62b99bd85202b2b9f4f57f71a408490dd518c"}, + {file = "TgCrypto-1.2.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f245895c7d518342089d15b5dca3cee9ffa5a0f3534db9d5a930f6a27dff4adf"}, + {file = "TgCrypto-1.2.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dbf607d645c39a577a0f8571039d11ddd2dcdf9656465be75f9e0f540472444"}, + {file = "TgCrypto-1.2.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6b0c2dc84e632ce7b3d0b767cfe20967e557ad7d71ea5dbd7df2dd544323181"}, + {file = "TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682"}, +] + +[[package]] +name = "token-bucket" +version = "0.3.0" +requires_python = ">=3.5" +summary = "Very fast implementation of the token bucket algorithm." +groups = ["default"] +files = [ + {file = "token_bucket-0.3.0-py2.py3-none-any.whl", hash = "sha256:6df24309e3cf5b808ae5ef714a3191ec5b54f48c34ef959e4882eef140703369"}, + {file = "token_bucket-0.3.0.tar.gz", hash = "sha256:979571c99db2ff9e651f2b2146a62b2ebadf7de6c217a8781698282976cb675f"}, +] + +[[package]] +name = "tomli" +version = "2.1.0" +requires_python = ">=3.8" +summary = "A lil' TOML parser" +groups = ["default"] +marker = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, + {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +requires_python = ">=3.7" +summary = "Fast, Extensible Progress Meter" +groups = ["default"] +dependencies = [ + "colorama; platform_system == \"Windows\"", +] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +requires_python = ">=3.8" +summary = "Backported and Experimental Type Hints for Python 3.8+" +groups = ["default"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.2" +requires_python = ">=2" +summary = "Provider of IANA time zone data" +groups = ["default"] +marker = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + +[[package]] +name = "tzlocal" +version = "5.2" +requires_python = ">=3.8" +summary = "tzinfo object for the local timezone" +groups = ["default"] +dependencies = [ + "backports-zoneinfo; python_version < \"3.9\"", + "tzdata; platform_system == \"Windows\"", +] +files = [ + {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, + {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +requires_python = ">=3.8" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +groups = ["default"] +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[[package]] +name = "websockets" +version = "15.0.1" +requires_python = ">=3.9" +summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +groups = ["default"] +files = [ + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, +] + +[[package]] +name = "yt-dlp" +version = "2025.11.12" +requires_python = ">=3.10" +summary = "A feature-rich command-line audio/video downloader" +groups = ["default"] +files = [ + {file = "yt_dlp-2025.11.12-py3-none-any.whl", hash = "sha256:b47af37bbb16b08efebb36825a280ea25a507c051f93bf413a6e4a0e586c6e79"}, + {file = "yt_dlp-2025.11.12.tar.gz", hash = "sha256:5f0795a6b8fc57a5c23332d67d6c6acf819a0b46b91a6324bae29414fa97f052"}, +] + +[[package]] +name = "yt-dlp-ejs" +version = "0.3.1" +requires_python = ">=3.10" +summary = "External JavaScript for yt-dlp supporting many runtimes" +groups = ["default"] +files = [ + {file = "yt_dlp_ejs-0.3.1-py3-none-any.whl", hash = "sha256:a6e3548874db7c774388931752bb46c7f4642c044b2a189e56968f3d5ecab622"}, + {file = "yt_dlp_ejs-0.3.1.tar.gz", hash = "sha256:7f2119eb02864800f651fa33825ddfe13d152a1f730fa103d9864f091df24227"}, +] + +[[package]] +name = "yt-dlp" +version = "2025.11.12" +extras = ["curl-cffi", "default"] +requires_python = ">=3.10" +summary = "A feature-rich command-line audio/video downloader" +groups = ["default"] +dependencies = [ + "brotli; implementation_name == \"cpython\"", + "brotlicffi; implementation_name != \"cpython\"", + "certifi", + "curl-cffi!=0.6.*,!=0.7.*,!=0.8.*,!=0.9.*,<0.14,>=0.5.10; implementation_name == \"cpython\"", + "mutagen", + "pycryptodomex", + "requests<3,>=2.32.2", + "urllib3<3,>=2.0.2", + "websockets>=13.0", + "yt-dlp-ejs==0.3.1", + "yt-dlp==2025.11.12", +] +files = [ + {file = "yt_dlp-2025.11.12-py3-none-any.whl", hash = "sha256:b47af37bbb16b08efebb36825a280ea25a507c051f93bf413a6e4a0e586c6e79"}, + {file = "yt_dlp-2025.11.12.tar.gz", hash = "sha256:5f0795a6b8fc57a5c23332d67d6c6acf819a0b46b91a6324bae29414fa97f052"}, +] diff --git a/pre-push.py b/pre-push.py new file mode 100755 index 00000000..38d54884 --- /dev/null +++ b/pre-push.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - pre-commit.py +# for dependabot + +import tomllib +import subprocess + + +with open("pyproject.toml", "rb") as file: + config = tomllib.load(file) + +with open("requirements.txt", "w") as file: + for item in config["project"]["dependencies"]: + if " " in item: + item = item.split()[-1] + file.write(f"{item}\n") + +# commit with amend +# subprocess.run(["git", "add", "requirements.txt"]) +# subprocess.run(["git", "commit", "-m", "pre-push"]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..d1f14feb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "ytdlbot" +version = "1.0.0" +description = "Default template for PDM package" +authors = [ + {name = "Benny", email = "benny.think@gmail.com"}, +] +dependencies = ["tgcrypto>=1.2.5", "yt-dlp[curl-cffi,default]==2025.11.12", "APScheduler>=3.11.0", "ffmpeg-python>=0.2.0", "PyMySQL>=1.1.1", "filetype>=1.2.0", "beautifulsoup4>=4.12.3", "fakeredis>=2.26.2", "redis==6.4.0", "requests>=2.32.3", "tqdm>=4.67.1", "token-bucket>=0.3.0", "python-dotenv>=1.0.1", "black>=24.10.0", "sqlalchemy>=2.0.36", "psutil>=6.1.0", "ffpb>=0.4.1", "kurigram==2.2.15", "cryptography>=43.0.3", "yt-dlp-ejs>=0.3.1"] +requires-python = ">=3.10" +readme = "README.md" +license = {text = "Apache2.0"} + + +[tool.pdm] +distribution = false diff --git a/requirements.txt b/requirements.txt index eefc3a76..3f711b66 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,20 @@ -pyrogram==1.4.8 -tgcrypto==1.2.3 -yt-dlp==2022.2.4 -APScheduler==3.9.1 -beautifultable==1.0.1 -ffmpeg-python==0.2.0 -PyMySQL==1.0.2 -celery==5.2.3 -filetype==1.0.10 -flower==1.0.0 -psutil==5.9.0 -influxdb==5.3.1 -beautifulsoup4==4.10.0 -fakeredis==1.7.1 -supervisor==4.2.4 -tgbot-ping==1.0.4 -redis==4.1.4 -requests==2.27.1 -tqdm==4.63.0 -requests-toolbelt==0.9.1 -ffpb==0.4.1 \ No newline at end of file +tgcrypto>=1.2.5 +APScheduler>=3.11.0 +ffmpeg-python>=0.2.0 +PyMySQL>=1.1.1 +filetype>=1.2.0 +beautifulsoup4>=4.12.3 +fakeredis>=2.26.2 +redis==6.4.0 +requests>=2.32.3 +tqdm>=4.67.1 +token-bucket>=0.3.0 +python-dotenv>=1.0.1 +black>=24.10.0 +sqlalchemy>=2.0.36 +psutil>=6.1.0 +ffpb>=0.4.1 +kurigram==2.2.15 +cryptography>=43.0.3 +yt-dlp[default,curl-cffi]==2025.11.12 +yt-dlp-ejs==0.3.1 \ No newline at end of file diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 00000000..5dcf0ac9 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - __init__.py.py + +import logging + +from dotenv import load_dotenv + +load_dotenv() + +from config.config import * +from config.constant import * + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s %(filename)s:%(lineno)d %(levelname).1s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 00000000..ccf130f9 --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,58 @@ +#!/usr/local/bin/python3 +# coding: utf-8 + +# ytdlbot - config.py +# 8/28/21 15:01 +# + +__author__ = "Benny " + +import os + + +def get_env(name: str, default=None): + val = os.getenv(name, default) + if val is None: + return None + if isinstance(val, str): + if val.lower() == "true": + return True + if val.lower() == "false": + return False + if val.isdigit() and name != "AUTHORIZED_USER": + return int(val) + return val + + +# general settings +WORKERS: int = get_env("WORKERS", 100) +APP_ID: int = get_env("APP_ID") +APP_HASH = get_env("APP_HASH") +BOT_TOKEN = get_env("BOT_TOKEN") +OWNER = [int(i) for i in str(get_env("OWNER")).split(",")] +# db settings +AUTHORIZED_USER: str = get_env("AUTHORIZED_USER", "") +DB_DSN = get_env("DB_DSN") +REDIS_HOST = get_env("REDIS_HOST") + +ENABLE_FFMPEG = get_env("ENABLE_FFMPEG") +AUDIO_FORMAT = get_env("AUDIO_FORMAT", "m4a") +M3U8_SUPPORT = get_env("M3U8_SUPPORT") +ENABLE_ARIA2 = get_env("ENABLE_ARIA2") + +RCLONE_PATH = get_env("RCLONE") + +# payment settings +ENABLE_VIP = get_env("ENABLE_VIP") +PROVIDER_TOKEN = get_env("PROVIDER_TOKEN") +FREE_DOWNLOAD = get_env("FREE_DOWNLOAD", 3) +TOKEN_PRICE = get_env("TOKEN_PRICE", 10) # 1 USD=10 downloads + +# For advance users +# Please do not change, if you don't know what these are. +TG_NORMAL_MAX_SIZE = 2000 * 1024 * 1024 +CAPTION_URL_LENGTH_LIMIT = 150 + +# This will set the value for the tmpfile path(engine path). If not, will return None and use system’s default path. +# Please ensure that the directory exists and you have necessary permissions to write to it. +TMPFILE_PATH = get_env("TMPFILE_PATH") diff --git a/src/config/constant.py b/src/config/constant.py new file mode 100644 index 00000000..bfa27908 --- /dev/null +++ b/src/config/constant.py @@ -0,0 +1,52 @@ +#!/usr/local/bin/python3 +# coding: utf-8 + +# ytdlbot - constant.py +# 8/16/21 16:59 +# + +__author__ = "Benny " + +import typing + +from pyrogram import Client, types + + +class BotText: + + start = """ + Welcome to YouTube Download bot. Type /help for more information. + EU🇪🇺: @benny_2ytdlbot + SG🇸🇬:@benny_ytdlbot + + Join https://t.me/ytdlbot0 for updates.\n\n""" + + help = """ +1. For YouTube and any websites supported by yt-dlp, just send the link and we will engine and send it to you. + +2. For specific links use `/spdl {URL}`. More info at https://github.com/tgbot-collection/ytdlbot#supported-websites + +3. If the bot doesn't work, try again or join https://t.me/ytdlbot0 for updates. + +4. Want to deploy it yourself?\nHere's the source code: https://github.com/tgbot-collection/ytdlbot + """ + + about = "YouTube Downloader by @BennyThink.\n\nOpen source on GitHub: https://github.com/tgbot-collection/ytdlbot" + + settings = """ +Please choose the preferred format and video quality for your video. These settings only **apply to YouTube videos**. +High: 1080P +Medium: 720P +Low: 480P + +If you choose to send the video as a document, Telegram client will not be able stream it. + +Your current settings: +Video quality: {} +Sending type: {} +""" + + +class Types: + Message = typing.Union[types.Message, typing.Coroutine] + Client = Client diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 00000000..77daf4c9 --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - __init__.py.py + +from database.cache import Redis diff --git a/src/database/cache.py b/src/database/cache.py new file mode 100644 index 00000000..68089667 --- /dev/null +++ b/src/database/cache.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - cache.py + + +import logging + +import fakeredis +import redis + +from config import REDIS_HOST + + +class Redis: + def __init__(self): + try: + self.r = redis.StrictRedis(host=REDIS_HOST, db=1, decode_responses=True) + self.r.ping() + except Exception: + logging.warning("Redis connection failed, using fake redis instead.") + self.r = fakeredis.FakeStrictRedis(host=REDIS_HOST, db=1, decode_responses=True) + + def __del__(self): + self.r.close() + + def add_cache(self, key, mapping): + self.r.hset(key, mapping=mapping) + + def get_cache(self, k: str): + return self.r.hgetall(k) diff --git a/src/database/model.py b/src/database/model.py new file mode 100644 index 00000000..bf878560 --- /dev/null +++ b/src/database/model.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# coding: utf-8 +import logging +import math +import os +from contextlib import contextmanager +from typing import Literal + +from sqlalchemy import ( + BigInteger, + Column, + Enum, + Float, + ForeignKey, + Integer, + String, + create_engine, +) +from sqlalchemy.dialects.mysql import JSON +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, sessionmaker + +from config import ENABLE_VIP, FREE_DOWNLOAD + + +class PaymentStatus: + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + REFUNDED = "refunded" + + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, unique=True, nullable=False) # telegram user id + free = Column(Integer, default=FREE_DOWNLOAD) + paid = Column(Integer, default=0) + config = Column(JSON) + + settings = relationship("Setting", back_populates="user", cascade="all, delete-orphan", uselist=False) + payments = relationship("Payment", back_populates="user", cascade="all, delete-orphan") + + +class Setting(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + quality = Column(Enum("high", "medium", "low", "audio", "custom"), nullable=False, default="high") + format = Column(Enum("video", "audio", "document"), nullable=False, default="video") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + user = relationship("User", back_populates="settings") + + +class Payment(Base): + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, autoincrement=True) + method = Column(String(50), nullable=False) + amount = Column(Float, nullable=False) + status = Column( + Enum( + PaymentStatus.PENDING, + PaymentStatus.COMPLETED, + PaymentStatus.FAILED, + PaymentStatus.REFUNDED, + ), + nullable=False, + ) + transaction_id = Column(String(100)) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + user = relationship("User", back_populates="payments") + + +def create_session(): + engine = create_engine( + os.getenv("DB_DSN"), + pool_size=50, + max_overflow=100, + pool_timeout=30, + pool_recycle=1800, + ) + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +SessionFactory = create_session() + + +@contextmanager +def session_manager(): + s = SessionFactory() + try: + yield s + s.commit() + except Exception as e: + s.rollback() + raise + finally: + s.close() + + +def get_quality_settings(tgid) -> Literal["high", "medium", "low", "audio", "custom"]: + with session_manager() as session: + user = session.query(User).filter(User.user_id == tgid).first() + if user and user.settings: + return user.settings.quality + + return "high" + + +def get_format_settings(tgid) -> Literal["video", "audio", "document"]: + with session_manager() as session: + user = session.query(User).filter(User.user_id == tgid).first() + if user and user.settings: + return user.settings.format + return "video" + + +def set_user_settings(tgid: int, key: str, value: str): + # set quality or format settings + with session_manager() as session: + # find user first + user = session.query(User).filter(User.user_id == tgid).first() + # upsert + setting = session.query(Setting).filter(Setting.user_id == user.id).first() + if setting: + setattr(setting, key, value) + else: + session.add(Setting(user_id=user.id, **{key: value})) + + +def get_free_quota(uid: int): + if not ENABLE_VIP: + return math.inf + + with session_manager() as session: + data = session.query(User).filter(User.user_id == uid).first() + if data: + return data.free + return FREE_DOWNLOAD + + +def get_paid_quota(uid: int): + if ENABLE_VIP: + with session_manager() as session: + data = session.query(User).filter(User.user_id == uid).first() + if data: + return data.paid + + return 0 + + return math.inf + + +def reset_free_quota(uid: int): + with session_manager() as session: + data = session.query(User).filter(User.user_id == uid).first() + if data: + data.free = 5 + + +def add_paid_quota(uid: int, amount: int): + with session_manager() as session: + data = session.query(User).filter(User.user_id == uid).first() + if data: + data.paid += amount + + +def check_quota(uid: int): + if not ENABLE_VIP: + return + + with session_manager() as session: + data = session.query(User).filter(User.user_id == uid).first() + if data and (data.free + data.paid) <= 0: + raise Exception("Quota exhausted. Please /buy or wait until free quota is reset") + + +def use_quota(uid: int): + # use free first, then paid + if not ENABLE_VIP: + return + + with session_manager() as session: + user = session.query(User).filter(User.user_id == uid).first() + if user: + if user.free > 0: + user.free -= 1 + elif user.paid > 0: + user.paid -= 1 + else: + raise Exception("Quota exhausted. Please /buy or wait until free quota is reset") + + +def init_user(uid: int): + with session_manager() as session: + user = session.query(User).filter(User.user_id == uid).first() + if not user: + session.add(User(user_id=uid)) + + +def reset_free(): + with session_manager() as session: + users = session.query(User).all() + for user in users: + user.free = FREE_DOWNLOAD + session.commit() + + +def credit_account(who, total_amount: int, quota: int, transaction, method="stripe"): + with session_manager() as session: + user = session.query(User).filter(User.user_id == who).first() + if user: + dollar = total_amount / 100 + user.paid += quota + logging.info("user %d credited with %d tokens, payment:$%.2f", who, user.paid, dollar) + session.add( + Payment( + method=method, + amount=total_amount, + status=PaymentStatus.COMPLETED, + transaction_id=transaction, + user_id=user.id, + ) + ) + session.commit() + return user.free, user.paid + + return None, None diff --git a/src/engine/__init__.py b/src/engine/__init__.py new file mode 100644 index 00000000..ec9b2a54 --- /dev/null +++ b/src/engine/__init__.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - __init__.py.py + +from urllib.parse import urlparse +from typing import Any, Callable + +from engine.generic import YoutubeDownload +from engine.direct import DirectDownload +from engine.pixeldrain import pixeldrain_download +from engine.instagram import InstagramDownload +from engine.krakenfiles import krakenfiles_download + + +def youtube_entrance(client, bot_message, url): + youtube = YoutubeDownload(client, bot_message, url) + youtube.start() + + +def direct_entrance(client, bot_message, url): + dl = DirectDownload(client, bot_message, url) + dl.start() + + +# --- Handler for the Instagram class, to make the interface consistent --- +def instagram_handler(client: Any, bot_message: Any, url: str) -> None: + """A wrapper to handle the InstagramDownload class.""" + downloader = InstagramDownload(client, bot_message, url) + downloader.start() + +DOWNLOADER_MAP: dict[str, Callable[[Any, Any, str], Any]] = { + "pixeldrain.com": pixeldrain_download, + "krakenfiles.com": krakenfiles_download, + "instagram.com": instagram_handler, +} + +def special_download_entrance(client: Any, bot_message: Any, url: str) -> Any: + try: + hostname = urlparse(url).hostname + if not hostname: + raise ValueError(f"Could not parse a valid hostname from URL: {url}") + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid URL format: {url}") from e + + # Handle the special case for YouTube URLs first. + if hostname.endswith("youtube.com") or hostname == "youtu.be": + raise ValueError("ERROR: For YouTube links, just send the link directly.") + + # Iterate through the map to find a matching handler. + for domain_suffix, handler_function in DOWNLOADER_MAP.items(): + if hostname.endswith(domain_suffix): + return handler_function(client, bot_message, url) + + raise ValueError(f"Invalid URL: No specific downloader found for {hostname}") diff --git a/src/engine/base.py b/src/engine/base.py new file mode 100644 index 00000000..5ba232cd --- /dev/null +++ b/src/engine/base.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - types.py + +import hashlib +import json +import logging +import re +import tempfile +import uuid +from abc import ABC, abstractmethod +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from typing import final + +import ffmpeg +import filetype +from pyrogram import enums, types +from tqdm import tqdm + +from config import TG_NORMAL_MAX_SIZE, Types +from database import Redis +from database.model import ( + check_quota, + get_format_settings, + get_free_quota, + get_paid_quota, + get_quality_settings, + use_quota, +) +from engine.helper import debounce, sizeof_fmt + + +def generate_input_media(file_paths: list, cap: str) -> list: + input_media = [] + for path in file_paths: + mime = filetype.guess_mime(path) + if "video" in mime: + input_media.append(types.InputMediaVideo(media=path)) + elif "image" in mime: + input_media.append(types.InputMediaPhoto(media=path)) + elif "audio" in mime: + input_media.append(types.InputMediaAudio(media=path)) + else: + input_media.append(types.InputMediaDocument(media=path)) + + input_media[0].caption = cap + return input_media + + +class BaseDownloader(ABC): + def __init__(self, client: Types.Client, bot_msg: Types.Message, url: str): + self._client = client + self._url = url + # chat id is the same for private chat + self._chat_id = self._from_user = bot_msg.chat.id + if bot_msg.chat.type == enums.ChatType.GROUP or bot_msg.chat.type == enums.ChatType.SUPERGROUP: + # if in group, we need to find out who send the message + self._from_user = bot_msg.reply_to_message.from_user.id + self._id = bot_msg.id + self._tempdir = tempfile.TemporaryDirectory(prefix="ytdl-") + self._bot_msg: Types.Message = bot_msg + self._redis = Redis() + self._quality = get_quality_settings(self._chat_id) + self._format = get_format_settings(self._chat_id) + + def __del__(self): + self._tempdir.cleanup() + + def _record_usage(self): + free, paid = get_free_quota(self._from_user), get_paid_quota(self._from_user) + logging.info("User %s has %s free and %s paid quota", self._from_user, free, paid) + if free + paid < 0: + raise Exception("Usage limit exceeded") + + use_quota(self._from_user) + + @staticmethod + def __remove_bash_color(text): + return re.sub(r"\u001b|\[0;94m|\u001b\[0m|\[0;32m|\[0m|\[0;33m", "", text) + + @staticmethod + def __tqdm_progress(desc, total, finished, speed="", eta=""): + def more(title, initial): + if initial: + return f"{title} {initial}" + else: + return "" + + f = StringIO() + tqdm( + total=total, + initial=finished, + file=f, + ascii=False, + unit_scale=True, + ncols=30, + bar_format="{l_bar}{bar} |{n_fmt}/{total_fmt} ", + ) + raw_output = f.getvalue() + tqdm_output = raw_output.split("|") + progress = f"`[{tqdm_output[1]}]`" + detail = tqdm_output[2].replace("[A", "") + text = f""" + {desc} + + {progress} + {detail} + {more("Speed:", speed)} + {more("ETA:", eta)} + """ + f.close() + return text + + def download_hook(self, d: dict): + if d["status"] == "downloading": + downloaded = d.get("downloaded_bytes", 0) + total = d.get("total_bytes") or d.get("total_bytes_estimate", 0) + + if total > TG_NORMAL_MAX_SIZE: + msg = f"Your download file size {sizeof_fmt(total)} is too large for Telegram." + raise Exception(msg) + + # percent = remove_bash_color(d.get("_percent_str", "N/A")) + speed = self.__remove_bash_color(d.get("_speed_str", "N/A")) + eta = self.__remove_bash_color(d.get("_eta_str", d.get("eta"))) + text = self.__tqdm_progress("Downloading...", total, downloaded, speed, eta) + self.edit_text(text) + + def upload_hook(self, current, total): + text = self.__tqdm_progress("Uploading...", total, current) + self.edit_text(text) + + @debounce(5) + def edit_text(self, text: str): + self._bot_msg.edit_text(text) + + @abstractmethod + def _setup_formats(self) -> list | None: + pass + + @abstractmethod + def _download(self, formats) -> list: + # responsible for get format and download it + pass + + @property + def _methods(self): + return { + "document": self._client.send_document, + "audio": self._client.send_audio, + "video": self._client.send_video, + "animation": self._client.send_animation, + "photo": self._client.send_photo, + } + + def send_something(self, *, chat_id, files, _type, caption=None, thumb=None, **kwargs): + self._client.send_chat_action(chat_id, enums.ChatAction.UPLOAD_DOCUMENT) + is_cache = kwargs.pop("cache", False) + if len(files) > 1 and is_cache == False: + inputs = generate_input_media(files, caption) + return self._client.send_media_group(chat_id, inputs)[0] + else: + file_arg_name = None + if _type == "photo": + file_arg_name = "photo" + elif _type == "video": + file_arg_name = "video" + elif _type == "animation": + file_arg_name = "animation" + elif _type == "document": + file_arg_name = "document" + elif _type == "audio": + file_arg_name = "audio" + else: + logging.error("Unknown _type encountered: %s", _type) + return None + + send_args = { + "chat_id": chat_id, + file_arg_name: files[0], + "caption": caption, + "progress": self.upload_hook, + **kwargs, + } + + if _type in ["video", "animation", "document", "audio"] and thumb is not None: + send_args["thumb"] = thumb + + return self._methods[_type](**send_args) + + def get_metadata(self): + video_path = list(Path(self._tempdir.name).glob("*"))[0] + filename = Path(video_path).name + width = height = duration = 0 + try: + video_streams = ffmpeg.probe(video_path, select_streams="v") + for item in video_streams.get("streams", []): + height = item["height"] + width = item["width"] + duration = int(float(video_streams["format"]["duration"])) + except Exception as e: + logging.error("Error while getting metadata: %s", e) + try: + thumb = Path(video_path).parent.joinpath(f"{uuid.uuid4().hex}-thunmnail.png").as_posix() + # A thumbnail's width and height should not exceed 320 pixels. + ffmpeg.input(video_path, ss=duration / 2).filter( + "scale", + "if(gt(iw,ih),300,-1)", # If width > height, scale width to 320 and height auto + "if(gt(iw,ih),-1,300)", + ).output(thumb, vframes=1).run() + except ffmpeg._run.Error: + thumb = None + + caption = f"{self._url}\n{filename}\n\nResolution: {width}x{height}\nDuration: {duration} seconds" + return dict(height=height, width=width, duration=duration, thumb=thumb, caption=caption) + + def _upload(self, files=None, meta=None): + if files is None: + files = list(Path(self._tempdir.name).glob("*")) + if meta is None: + meta = self.get_metadata() + + success = SimpleNamespace(document=None, video=None, audio=None, animation=None, photo=None) + if self._format == "document": + logging.info("Sending as document for %s", self._url) + success = self.send_something( + chat_id=self._chat_id, + files=files, + _type="document", + thumb=meta.get("thumb"), + force_document=True, + caption=meta.get("caption"), + ) + elif self._format == "photo": + logging.info("Sending as photo for %s", self._url) + success = self.send_something( + chat_id=self._chat_id, + files=files, + _type="photo", + caption=meta.get("caption"), + ) + elif self._format == "audio": + logging.info("Sending as audio for %s", self._url) + success = self.send_something( + chat_id=self._chat_id, + files=files, + _type="audio", + caption=meta.get("caption"), + ) + elif self._format == "video": + logging.info("Sending as video for %s", self._url) + attempt_methods = ["video", "animation", "audio", "photo"] + video_meta = meta.copy() + + upload_successful = False # Flag to track if any method succeeded + for method in attempt_methods: + current_meta = video_meta.copy() + + if method == "photo": + current_meta.pop("thumb", None) + current_meta.pop("duration", None) + current_meta.pop("height", None) + current_meta.pop("width", None) + elif method == "audio": + current_meta.pop("height", None) + current_meta.pop("width", None) + + try: + success_obj = self.send_something( + chat_id=self._chat_id, + files=files, + _type=method, + **current_meta + ) + + if method == "video": + success = success_obj + elif method == "animation": + success = success_obj + elif method == "photo": + success = success_obj + elif method == "audio": + success = success_obj + + upload_successful = True # Set flag to True on success + break + except Exception as e: + logging.error("Retry to send as %s, error: %s", method, e) + + # Check the flag after the loop + if not upload_successful: + raise ValueError("ERROR: For direct links, try again with `/direct`.") + + else: + logging.error("Unknown upload format settings for %s", self._format) + return + + video_key = self._calc_video_key() + obj = success.document or success.video or success.audio or success.animation or success.photo + mapping = { + "file_id": json.dumps([getattr(obj, "file_id", None)]), + "meta": json.dumps({k: v for k, v in meta.items() if k != "thumb"}, ensure_ascii=False), + } + + self._redis.add_cache(video_key, mapping) + # change progress bar to done + self._bot_msg.edit_text("✅ Success") + return success + + def _get_video_cache(self): + return self._redis.get_cache(self._calc_video_key()) + + def _calc_video_key(self): + h = hashlib.md5() + h.update((self._url + self._quality + self._format).encode()) + key = h.hexdigest() + return key + + @final + def start(self): + check_quota(self._from_user) + if cache := self._get_video_cache(): + logging.info("Cache hit for %s", self._url) + meta, file_id = json.loads(cache["meta"]), json.loads(cache["file_id"]) + meta["cache"] = True + self._upload(file_id, meta) + else: + self._start() + self._record_usage() + + @abstractmethod + def _start(self): + pass diff --git a/src/engine/direct.py b/src/engine/direct.py new file mode 100644 index 00000000..0388ad45 --- /dev/null +++ b/src/engine/direct.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - direct.py + +import logging +import os +import re +import pathlib +import subprocess +import tempfile +from pathlib import Path +from uuid import uuid4 + +import filetype +import requests + +from config import ENABLE_ARIA2, TMPFILE_PATH +from engine.base import BaseDownloader + + +class DirectDownload(BaseDownloader): + + def _setup_formats(self) -> list | None: + # direct download doesn't need to setup formats + pass + + # def _get_aria2_name(self): + # try: + # cmd = f"aria2c --truncate-console-readout=true -x10 --dry-run --file-allocation=none {self._url}" + # result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) + # stdout_str = result.stdout.decode("utf-8") + # name = os.path.basename(stdout_str).split("\n")[0] + # if len(name) == 0: + # name = os.path.basename(self._url) + # return name + # except Exception: + # name = os.path.basename(self._url) + # return name + + def _requests_download(self): + logging.info("Requests download with url %s", self._url) + response = requests.get(self._url, stream=True) + response.raise_for_status() + file = Path(self._tempdir.name).joinpath(uuid4().hex) + with open(file, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + ext = filetype.guess_extension(file) + if ext is not None: + new_name = file.with_suffix(f".{ext}") + file.rename(new_name) + + return [file.as_posix()] + + def _aria2_download(self): + ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + # filename = self._get_aria2_name() + self._process = None + try: + self._bot_msg.edit_text("Aria2 download starting...") + temp_dir = self._tempdir.name + command = [ + "aria2c", + "--max-tries=3", + "--max-concurrent-downloads=8", + "--max-connection-per-server=16", + "--split=16", + "--summary-interval=1", + "--console-log-level=notice", + "--show-console-readout=true", + "--quiet=false", + "--human-readable=true", + f"--user-agent={ua}", + "-d", temp_dir, + self._url, + ] + + self._process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + bufsize=1 + ) + + while True: + line = self._process.stdout.readline() + if not line: + if self._process.poll() is not None: + break + continue + + progress = self.__parse_progress(line) + if progress: + self.download_hook(progress) + elif "Download complete:" in line: + self.download_hook({"status": "complete"}) + + self._process.wait(timeout=300) + success = self._process.wait() == 0 + if not success: + raise subprocess.CalledProcessError( + self._process.returncode, + command, + self._process.stderr.read() + ) + if self._process.returncode != 0: + raise subprocess.CalledProcessError( + self._process.returncode, + command, + stderr + ) + + # This will get [Path_object] if a file is found, or None if no files are found. + files = [f] if (f := next((item for item in Path(temp_dir).glob("*") if item.is_file()), None)) is not None else None + if files is None: + logging.error(f"No files found in {temp_dir}") + raise FileNotFoundError(f"No files found in {temp_dir}") + else: + logging.info("Successfully downloaded file: %s", files[0]) + + return files + + except subprocess.TimeoutExpired: + error_msg = "Download timed out after 5 minutes." + logging.error(error_msg) + self._bot_msg.edit_text(f"Download failed!❌\n\n{error_msg}") + return [] + except Exception as e: + self._bot_msg.edit_text(f"Download failed!❌\n\n`{e}`") + return [] + finally: + if self._process: + self._process.terminate() + self._process = None + + def __parse_progress(self, line: str) -> dict | None: + if "Download complete:" in line or "(OK):download completed" in line: + return {"status": "complete"} + + progress_match = re.search( + r'\[#\w+\s+(?P[\d.]+[KMGTP]?iB)/(?P[\d.]+[KMGTP]?iB)\(.*?\)\s+CN:\d+\s+DL:(?P[\d.]+[KMGTP]?iB)\s+ETA:(?P[\dhms]+)', + line + ) + + if progress_match: + return { + "status": "downloading", + "downloaded_bytes": self.__parse_size(progress_match.group("progress")), + "total_bytes": self.__parse_size(progress_match.group("total")), + "_speed_str": f"{progress_match.group('speed')}/s", + "_eta_str": progress_match.group("eta") + } + + # Fallback check for summary lines + if "Download Progress Summary" in line and "MiB" in line: + return {"status": "progress", "details": line} + + return None + + def __parse_size(self, size_str: str) -> int: + units = { + "B": 1, + "K": 1024, "KB": 1024, "KIB": 1024, + "M": 1024**2, "MB": 1024**2, "MIB": 1024**2, + "G": 1024**3, "GB": 1024**3, "GIB": 1024**3, + "T": 1024**4, "TB": 1024**4, "TIB": 1024**4 + } + match = re.match(r"([\d.]+)([A-Za-z]*)", size_str.replace("i", "").upper()) + if match: + number, unit = match.groups() + unit = unit or "B" + return int(float(number) * units.get(unit, 1)) + return 0 + + def _download(self, formats=None) -> list: + if ENABLE_ARIA2: + return self._aria2_download() + return self._requests_download() + + def _start(self): + downloaded_files = self._download() + self._upload(files=downloaded_files) \ No newline at end of file diff --git a/src/engine/generic.py b/src/engine/generic.py new file mode 100644 index 00000000..bbcf3d7b --- /dev/null +++ b/src/engine/generic.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - generic.py + +import logging +import os +from pathlib import Path + +import yt_dlp + +from config import AUDIO_FORMAT +from utils import is_youtube +from database.model import get_format_settings, get_quality_settings +from engine.base import BaseDownloader + + +def match_filter(info_dict): + if info_dict.get("is_live"): + raise NotImplementedError("Skipping live video") + return None # Allow download for non-live videos + + +class YoutubeDownload(BaseDownloader): + @staticmethod + def get_format(m): + return [ + f"bestvideo[ext=mp4][height={m}]+bestaudio[ext=m4a]", + f"bestvideo[vcodec^=avc][height={m}]+bestaudio[acodec^=mp4a]/best[vcodec^=avc]/best", + ] + + def _setup_formats(self) -> list | None: + if not is_youtube(self._url): + return [None] + + quality, format_ = get_quality_settings(self._chat_id), get_format_settings(self._chat_id) + # quality: high, medium, low, custom + # format: audio, video, document + formats = [] + defaults = [ + # webm , vp9 and av01 are not streamable on telegram, so we'll extract only mp4 + "bestvideo[ext=mp4][vcodec!*=av01][vcodec!*=vp09]+bestaudio[ext=m4a]/bestvideo+bestaudio", + "bestvideo[vcodec^=avc]+bestaudio[acodec^=mp4a]/best[vcodec^=avc]/best", + None, + ] + audio = AUDIO_FORMAT or "m4a" + maps = { + "high-audio": [f"bestaudio[ext={audio}]"], + "high-video": defaults, + "high-document": defaults, + "medium-audio": [f"bestaudio[ext={audio}]"], # no mediumaudio :-( + "medium-video": self.get_format(720), + "medium-document": self.get_format(720), + "low-audio": [f"bestaudio[ext={audio}]"], + "low-video": self.get_format(480), + "low-document": self.get_format(480), + "custom-audio": "", + "custom-video": "", + "custom-document": "", + } + + if quality == "custom": + pass + # TODO not supported yet + # get format from ytdlp, send inlinekeyboard button to user so they can choose + # another callback will be triggered to download the video + # available_options = { + # "480P": "best[height<=480]", + # "720P": "best[height<=720]", + # "1080P": "best[height<=1080]", + # } + # markup, temp_row = [], [] + # for quality, data in available_options.items(): + # temp_row.append(types.InlineKeyboardButton(quality, callback_data=data)) + # if len(temp_row) == 3: # Add a row every 3 buttons + # markup.append(temp_row) + # temp_row = [] + # # Add any remaining buttons as the last row + # if temp_row: + # markup.append(temp_row) + # self._bot_msg.edit_text("Choose the format", reply_markup=types.InlineKeyboardMarkup(markup)) + # return None + + formats.extend(maps[f"{quality}-{format_}"]) + # extend default formats if not high* + if quality != "high": + formats.extend(defaults) + return formats + + def _download(self, formats) -> list: + output = Path(self._tempdir.name, "%(title).70s.%(ext)s").as_posix() + ydl_opts = { + "progress_hooks": [lambda d: self.download_hook(d)], + "outtmpl": output, + "restrictfilenames": False, + "quiet": True, + "match_filter": match_filter, + "concurrent_fragments": 16, + "buffersize": 4194304, + "retries": 6, + "fragment_retries": 6, + "skip_unavailable_fragments": True, + "embed_metadata": True, + "embed_thumbnail": True, + "writethumbnail": False, + } + # setup cookies for youtube only + if is_youtube(self._url): + # use cookies from browser firstly + if browsers := os.getenv("BROWSERS"): + ydl_opts["cookiesfrombrowser"] = browsers.split(",") + if os.path.isfile("youtube-cookies.txt") and os.path.getsize("youtube-cookies.txt") > 100: + ydl_opts["cookiefile"] = "youtube-cookies.txt" + # try add extract_args if present + if potoken := os.getenv("POTOKEN"): + ydl_opts["extractor_args"] = {"youtube": ["player-client=web,default", f"po_token=web+{potoken}"]} + # for new version? https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide + # ydl_opts["extractor_args"] = { + # "youtube": [f"po_token=web.player+{potoken}", f"po_token=web.gvs+{potoken}"] + # } + + if self._url.startswith("https://drive.google.com"): + # Always use the `source` format for Google Drive URLs. + formats = ["source"] + formats + + files = None + for f in formats: + ydl_opts["format"] = f + logging.info("yt-dlp options: %s", ydl_opts) + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([self._url]) + files = list(Path(self._tempdir.name).glob("*")) + break + + return files + + def _start(self, formats=None): + # start download and upload, no cache hit + # user can choose format by clicking on the button(custom config) + default_formats = self._setup_formats() + if formats is not None: + # formats according to user choice + default_formats = formats + self._setup_formats() + self._download(default_formats) + self._upload() diff --git a/src/engine/helper.py b/src/engine/helper.py new file mode 100644 index 00000000..a2ca94cd --- /dev/null +++ b/src/engine/helper.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - helper.py + +import functools +import logging +import os +import pathlib +import re +import subprocess +import threading +import time +from http import HTTPStatus +from io import StringIO + +import ffmpeg +import ffpb +import filetype +import pyrogram +import requests +import yt_dlp +from bs4 import BeautifulSoup +from pyrogram import types +from tqdm import tqdm + +from config import ( + AUDIO_FORMAT, + CAPTION_URL_LENGTH_LIMIT, + ENABLE_ARIA2, + TG_NORMAL_MAX_SIZE, +) +from utils import shorten_url, sizeof_fmt + + +def debounce(wait_seconds): + """ + Thread-safe debounce decorator for functions that take a message with chat.id and msg.id attributes. + The function will only be called if it hasn't been called with the same chat.id and msg.id in the last 'wait_seconds'. + """ + + def decorator(func): + last_called = {} + lock = threading.Lock() + + @functools.wraps(func) + def wrapper(*args, **kwargs): + nonlocal last_called + now = time.time() + + # Assuming the first argument is the message object with chat.id and msg.id + bot_msg = args[0]._bot_msg + key = (bot_msg.chat.id, bot_msg.id) + + with lock: + if key not in last_called or now - last_called[key] >= wait_seconds: + last_called[key] = now + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def get_caption(url, video_path): + if isinstance(video_path, pathlib.Path): + meta = get_metadata(video_path) + file_name = video_path.name + file_size = sizeof_fmt(os.stat(video_path).st_size) + else: + file_name = getattr(video_path, "file_name", "") + file_size = sizeof_fmt(getattr(video_path, "file_size", (2 << 2) + ((2 << 2) + 1) + (2 << 5))) + meta = dict( + width=getattr(video_path, "width", 0), + height=getattr(video_path, "height", 0), + duration=getattr(video_path, "duration", 0), + thumb=getattr(video_path, "thumb", None), + ) + + # Shorten the URL if necessary + try: + if len(url) > CAPTION_URL_LENGTH_LIMIT: + url_for_cap = shorten_url(url, CAPTION_URL_LENGTH_LIMIT) + else: + url_for_cap = url + except Exception as e: + logging.warning(f"Error shortening URL: {e}") + url_for_cap = url + + cap = ( + f"{file_name}\n\n{url_for_cap}\n\nInfo: {meta['width']}x{meta['height']} {file_size}\t" f"{meta['duration']}s\n" + ) + return cap + + +def convert_audio_format(video_paths: list, bm): + # 1. file is audio, default format + # 2. file is video, default format + # 3. non default format + + for path in video_paths: + streams = ffmpeg.probe(path)["streams"] + if AUDIO_FORMAT is None and len(streams) == 1 and streams[0]["codec_type"] == "audio": + logging.info("%s is audio, default format, no need to convert", path) + elif AUDIO_FORMAT is None and len(streams) >= 2: + logging.info("%s is video, default format, need to extract audio", path) + audio_stream = {"codec_name": "m4a"} + for stream in streams: + if stream["codec_type"] == "audio": + audio_stream = stream + break + ext = audio_stream["codec_name"] + new_path = path.with_suffix(f".{ext}") + run_ffmpeg_progressbar(["ffmpeg", "-y", "-i", path, "-vn", "-acodec", "copy", new_path], bm) + path.unlink() + index = video_paths.index(path) + video_paths[index] = new_path + else: + logging.info("Not default format, converting %s to %s", path, AUDIO_FORMAT) + new_path = path.with_suffix(f".{AUDIO_FORMAT}") + run_ffmpeg_progressbar(["ffmpeg", "-y", "-i", path, new_path], bm) + path.unlink() + index = video_paths.index(path) + video_paths[index] = new_path + + +def split_large_video(video_paths: list): + original_video = None + split = False + for original_video in video_paths: + size = os.stat(original_video).st_size + if size > TG_NORMAL_MAX_SIZE: + split = True + logging.warning("file is too large %s, splitting...", size) + subprocess.check_output(f"sh split-video.sh {original_video} {TG_NORMAL_MAX_SIZE * 0.95} ".split()) + os.remove(original_video) + + if split and original_video: + return [i for i in pathlib.Path(original_video).parent.glob("*")] diff --git a/src/engine/instagram.py b/src/engine/instagram.py new file mode 100644 index 00000000..216c71e0 --- /dev/null +++ b/src/engine/instagram.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - instagram.py + +import time +import pathlib +import re + +import filetype +import requests +from engine.base import BaseDownloader + + +class InstagramDownload(BaseDownloader): + def extract_code(self): + patterns = [ + # Instagram stories highlights + r"/stories/highlights/([a-zA-Z0-9_-]+)/", + # Posts + r"/p/([a-zA-Z0-9_-]+)/", + # Reels + r"/reel/([a-zA-Z0-9_-]+)/", + # TV + r"/tv/([a-zA-Z0-9_-]+)/", + # Threads post (both with @username and without) + r"(?:https?://)?(?:www\.)?(?:threads\.net)(?:/[@\w.]+)?(?:/post)?/([\w-]+)(?:/?\?.*)?$", + ] + + for pattern in patterns: + match = re.search(pattern, self._url) + if match: + if pattern == patterns[0]: # Check if it's the stories highlights pattern + # Return the URL as it is + return self._url + else: + # Return the code part (first group) + return match.group(1) + + return None + + def _setup_formats(self) -> list | None: + pass + + def _download(self, formats=None): + try: + resp = requests.get(f"http://instagram:15000/?url={self._url}").json() + except Exception as e: + self._bot_msg.edit_text(f"Download failed!❌\n\n`{e}`") + pass + + code = self.extract_code() + counter = 1 + video_paths = [] + found_media_types = set() + + if url_results := resp.get("data"): + for media in url_results: + link = media["link"] + media_type = media["type"] + + if media_type == "image": + ext = "jpg" + found_media_types.add("photo") + elif media_type == "video": + ext = "mp4" + found_media_types.add("video") + else: + continue + + try: + req = requests.get(link, stream=True) + length = int(req.headers.get("content-length", 0) or req.headers.get("x-full-image-content-length", 0)) + filename = f"Instagram_{code}-{counter}" + save_path = pathlib.Path(self._tempdir.name, filename) + chunk_size = 8192 + downloaded = 0 + start_time = time.time() + + with open(save_path, "wb") as fp: + for chunk in req.iter_content(chunk_size): + if chunk: + downloaded += len(chunk) + fp.write(chunk) + + elapsed_time = time.time() - start_time + if elapsed_time > 0: + speed = downloaded / elapsed_time # bytes per second + + if speed >= 1024 * 1024: # MB/s + speed_str = f"{speed / (1024 * 1024):.2f}MB/s" + elif speed >= 1024: # KB/s + speed_str = f"{speed / 1024:.2f}KB/s" + else: # B/s + speed_str = f"{speed:.2f}B/s" + + if length > 0: + eta_seconds = (length - downloaded) / speed + if eta_seconds >= 3600: + eta_str = f"{eta_seconds / 3600:.1f}h" + elif eta_seconds >= 60: + eta_str = f"{eta_seconds / 60:.1f}m" + else: + eta_str = f"{eta_seconds:.0f}s" + else: + eta_str = "N/A" + else: + speed_str = "N/A" + eta_str = "N/A" + + # dictionary for calling the download_hook + d = { + "status": "downloading", + "downloaded_bytes": downloaded, + "total_bytes": length, + "_speed_str": speed_str, + "_eta_str": eta_str + } + + self.download_hook(d) + + if ext := filetype.guess_extension(save_path): + new_path = save_path.with_suffix(f".{ext}") + save_path.rename(new_path) + save_path = new_path + + video_paths.append(str(save_path)) + counter += 1 + + except Exception as e: + self._bot_msg.edit_text(f"Download failed!❌\n\n`{e}`") + return [] + + if "video" in found_media_types: + self._format = "video" + elif "photo" in found_media_types: + self._format = "photo" + else: + self._format = "document" + + return video_paths + + def _start(self): + downloaded_files = self._download() + self._upload(files=downloaded_files) diff --git a/src/engine/krakenfiles.py b/src/engine/krakenfiles.py new file mode 100644 index 00000000..795422a4 --- /dev/null +++ b/src/engine/krakenfiles.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - krakenfiles.py + +__author__ = "SanujaNS " + +import requests +from bs4 import BeautifulSoup +from engine.direct import DirectDownload + + +def krakenfiles_download(client, bot_message, url: str): + session = requests.Session() + + def _extract_form_data(url: str) -> list[tuple[str, str]]: + try: + resp = session.get(url) + resp.raise_for_status() + soup = BeautifulSoup(resp.content, "html.parser") + + if post_url := soup.xpath('//form[@id="dl-form"]/@action'): + post_url = f"https://krakenfiles.com{post_url[0]}" + else: + raise ValueError("ERROR: Unable to find post link.") + if token := soup.xpath('//input[@id="dl-token"]/@value'): + data = {"token": token[0]} + else: + raise ValueError("ERROR: Unable to find token for post.") + + return list(zip(post_url, data)) + + except requests.RequestException as e: + raise ValueError(f"Failed to fetch page: {str(e)}") + except Exception as e: + raise ValueError(f"Failed to parse page: {str(e)}") + + def _get_download_url(form_data: list[tuple[str, str]]) -> str: + for post_url, data in form_data: + try: + response = session.post(post_url, data=data) + response.raise_for_status() + + json_data = response.json() + if "url" in json_data: + return json_data["url"] + + except requests.RequestException as e: + bot_message.edit_text(f"Error during form submission: {str(e)}") + except ValueError as e: + bot_message.edit_text(f"Error parsing response: {str(e)}") + + raise ValueError("Could not obtain download URL") + + def _download(url: str): + try: + bot_message.edit_text("Processing krakenfiles download link...") + form_data = _extract_form_data(url) + download_url = _get_download_url(form_data) + + bot_message.edit_text("Starting download...") + downloader = DirectDownload(client, bot_message, download_url) + downloader.start() + + except ValueError as e: + bot_message.edit_text(f"Download failed!❌\n{str(e)}") + except Exception as e: + bot_message.edit_text( + f"Download failed!❌\nAn error occurred: {str(e)}\n" + "Please check your URL and try again." + ) + + _download(url) diff --git a/src/engine/pixeldrain.py b/src/engine/pixeldrain.py new file mode 100644 index 00000000..a248a575 --- /dev/null +++ b/src/engine/pixeldrain.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - pixeldrain.py + +__author__ = "SanujaNS " + +import tempfile +import pathlib +import re +from urllib.parse import urlparse +from engine.direct import DirectDownload + + +def pixeldrain_download(client, bot_message, url): + FILE_URL_FORMAT = "https://pixeldrain.com/api/file/{}?download" + USER_PAGE_PATTERN = re.compile(r"https://pixeldrain.com/u/(\w+)") + + def _extract_file_id(url): + if match := USER_PAGE_PATTERN.match(url): + return match.group(1) + + parsed = urlparse(url) + if parsed.path.startswith('/file/'): + return parsed.path.split('/')[-1] + + raise ValueError("Invalid Pixeldrain URL format") + + def _get_download_url(file_id): + return FILE_URL_FORMAT.format(file_id) + + def _download(url): + try: + file_id = _extract_file_id(url) + download_url = _get_download_url(file_id) + + ddl = DirectDownload(client, bot_message, download_url) + ddl.start() + + except ValueError as e: + bot_message.edit_text(f"Download failed!❌\n\n`{e}`") + except Exception as e: + bot_message.edit_text( + f"Download failed!❌\nAn error occurred: {str(e)}\n" + "Please check your URL and try again." + ) + + _download(url) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..67759d0b --- /dev/null +++ b/src/main.py @@ -0,0 +1,416 @@ +#!/usr/local/bin/python3 +# coding: utf-8 + +# ytdlbot - new.py +# 8/14/21 14:37 +# + +__author__ = "Benny " + +import logging +import os +import re +import threading +import time +import typing +from io import BytesIO +from typing import Any + +import psutil +import pyrogram.errors +import yt_dlp +from apscheduler.schedulers.background import BackgroundScheduler +from pyrogram import Client, enums, filters, types + +from config import ( + APP_HASH, + APP_ID, + AUTHORIZED_USER, + BOT_TOKEN, + ENABLE_ARIA2, + ENABLE_FFMPEG, + M3U8_SUPPORT, + ENABLE_VIP, + OWNER, + PROVIDER_TOKEN, + TOKEN_PRICE, + BotText, +) +from database.model import ( + credit_account, + get_format_settings, + get_free_quota, + get_paid_quota, + get_quality_settings, + init_user, + reset_free, + set_user_settings, +) +from engine import direct_entrance, youtube_entrance, special_download_entrance +from utils import extract_url_and_name, sizeof_fmt, timeof_fmt + +logging.info("Authorized users are %s", AUTHORIZED_USER) +logging.getLogger("apscheduler.executors.default").propagate = False + + +def create_app(name: str, workers: int = 64) -> Client: + return Client( + name, + APP_ID, + APP_HASH, + bot_token=BOT_TOKEN, + workers=workers, + # max_concurrent_transmissions=max(1, WORKERS // 2), + # https://github.com/pyrogram/pyrogram/issues/1225#issuecomment-1446595489 + ) + + +app = create_app("main") + + +def private_use(func): + def wrapper(client: Client, message: types.Message): + chat_id = getattr(message.from_user, "id", None) + + # message type check + if message.chat.type != enums.ChatType.PRIVATE and not getattr(message, "text", "").lower().startswith("/ytdl"): + logging.debug("%s, it's annoying me...🙄️ ", message.text) + return + + # authorized users check + if AUTHORIZED_USER: + users = [int(i) for i in AUTHORIZED_USER.split(",")] + else: + users = [] + + if users and chat_id and chat_id not in users: + message.reply_text("BotText.private", quote=True) + return + + return func(client, message) + + return wrapper + + +@app.on_message(filters.command(["start"])) +def start_handler(client: Client, message: types.Message): + from_id = message.chat.id + init_user(from_id) + logging.info("%s welcome to youtube-dl bot!", message.from_user.id) + client.send_chat_action(from_id, enums.ChatAction.TYPING) + free, paid = get_free_quota(from_id), get_paid_quota(from_id) + client.send_message( + from_id, + BotText.start + f"You have {free} free and {paid} paid quota.", + disable_web_page_preview=True, + ) + + +@app.on_message(filters.command(["help"])) +def help_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + client.send_message(chat_id, BotText.help, disable_web_page_preview=True) + + +@app.on_message(filters.command(["about"])) +def about_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + client.send_message(chat_id, BotText.about) + + +@app.on_message(filters.command(["ping"])) +def ping_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + + def send_message_and_measure_ping(): + start_time = int(round(time.time() * 1000)) + reply: types.Message | typing.Any = client.send_message(chat_id, "Starting Ping...") + + end_time = int(round(time.time() * 1000)) + ping_time = int(round(end_time - start_time)) + message_sent = True + if message_sent: + message.reply_text(f"Ping: {ping_time:.2f} ms", quote=True) + time.sleep(0.5) + client.edit_message_text(chat_id=reply.chat.id, message_id=reply.id, text="Ping Calculation Complete.") + time.sleep(1) + client.delete_messages(chat_id=reply.chat.id, message_ids=reply.id) + + thread = threading.Thread(target=send_message_and_measure_ping) + thread.start() + + +@app.on_message(filters.command(["buy"])) +def buy(client: Client, message: types.Message): + markup = types.InlineKeyboardMarkup( + [ + [ # First row + types.InlineKeyboardButton("10-$1", callback_data="buy-10-1"), + types.InlineKeyboardButton("20-$2", callback_data="buy-20-2"), + types.InlineKeyboardButton("40-$3.5", callback_data="buy-40-3.5"), + ], + [ # second row + types.InlineKeyboardButton("50-$4", callback_data="buy-50-4"), + types.InlineKeyboardButton("75-$6", callback_data="buy-75-6"), + types.InlineKeyboardButton("100-$8", callback_data="buy-100-8"), + ], + ] + ) + message.reply_text("Please choose the amount you want to buy.", reply_markup=markup) + + +@app.on_callback_query(filters.regex(r"buy.*")) +def send_invoice(client: Client, callback_query: types.CallbackQuery): + chat_id = callback_query.message.chat.id + data = callback_query.data + _, count, price = data.split("-") + price = int(float(price) * 100) + client.send_invoice( + chat_id, + f"{count} permanent download quota", + "Please make a payment via Stripe", + f"{count}", + "USD", + [types.LabeledPrice(label="VIP", amount=price)], + provider_token=os.getenv("PROVIDER_TOKEN"), + protect_content=True, + start_parameter="no-forward-placeholder", + ) + + +@app.on_pre_checkout_query() +def pre_checkout(client: Client, query: types.PreCheckoutQuery): + client.answer_pre_checkout_query(query.id, ok=True) + + +@app.on_message(filters.successful_payment) +def successful_payment(client: Client, message: types.Message): + who = message.chat.id + amount = message.successful_payment.total_amount # in cents + quota = int(message.successful_payment.invoice_payload) + ch = message.successful_payment.provider_payment_charge_id + free, paid = credit_account(who, amount, quota, ch) + if paid > 0: + message.reply_text(f"Payment successful! You now have {free} free and {paid} paid quota.") + else: + message.reply_text("Something went wrong. Please contact the admin.") + message.delete() + + +@app.on_message(filters.command(["stats"])) +def stats_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + cpu_usage = psutil.cpu_percent() + total, used, free, disk = psutil.disk_usage("/") + swap = psutil.swap_memory() + memory = psutil.virtual_memory() + boot_time = psutil.boot_time() + + owner_stats = ( + "\n\n⌬─────「 Stats 」─────⌬\n\n" + f"╭🖥️ **CPU Usage »** __{cpu_usage}%__\n" + f"├💾 **RAM Usage »** __{memory.percent}%__\n" + f"╰🗃️ **DISK Usage »** __{disk}%__\n\n" + f"╭📤Upload: {sizeof_fmt(psutil.net_io_counters().bytes_sent)}\n" + f"╰📥Download: {sizeof_fmt(psutil.net_io_counters().bytes_recv)}\n\n\n" + f"Memory Total: {sizeof_fmt(memory.total)}\n" + f"Memory Free: {sizeof_fmt(memory.available)}\n" + f"Memory Used: {sizeof_fmt(memory.used)}\n" + f"SWAP Total: {sizeof_fmt(swap.total)} | SWAP Usage: {swap.percent}%\n\n" + f"Total Disk Space: {sizeof_fmt(total)}\n" + f"Used: {sizeof_fmt(used)} | Free: {sizeof_fmt(free)}\n\n" + f"Physical Cores: {psutil.cpu_count(logical=False)}\n" + f"Total Cores: {psutil.cpu_count(logical=True)}\n\n" + f"🤖Bot Uptime: {timeof_fmt(time.time() - botStartTime)}\n" + f"⏲️OS Uptime: {timeof_fmt(time.time() - boot_time)}\n" + ) + + user_stats = ( + "\n\n⌬─────「 Stats 」─────⌬\n\n" + f"╭🖥️ **CPU Usage »** __{cpu_usage}%__\n" + f"├💾 **RAM Usage »** __{memory.percent}%__\n" + f"╰🗃️ **DISK Usage »** __{disk}%__\n\n" + f"╭📤Upload: {sizeof_fmt(psutil.net_io_counters().bytes_sent)}\n" + f"╰📥Download: {sizeof_fmt(psutil.net_io_counters().bytes_recv)}\n\n\n" + f"Memory Total: {sizeof_fmt(memory.total)}\n" + f"Memory Free: {sizeof_fmt(memory.available)}\n" + f"Memory Used: {sizeof_fmt(memory.used)}\n" + f"Total Disk Space: {sizeof_fmt(total)}\n" + f"Used: {sizeof_fmt(used)} | Free: {sizeof_fmt(free)}\n\n" + f"🤖Bot Uptime: {timeof_fmt(time.time() - botStartTime)}\n" + ) + + if message.from_user.id in OWNER: + message.reply_text(owner_stats, quote=True) + else: + message.reply_text(user_stats, quote=True) + + +@app.on_message(filters.command(["settings"])) +def settings_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + markup = types.InlineKeyboardMarkup( + [ + [ # First row + types.InlineKeyboardButton("send as document", callback_data="document"), + types.InlineKeyboardButton("send as video", callback_data="video"), + types.InlineKeyboardButton("send as audio", callback_data="audio"), + ], + [ # second row + types.InlineKeyboardButton("High Quality", callback_data="high"), + types.InlineKeyboardButton("Medium Quality", callback_data="medium"), + types.InlineKeyboardButton("Low Quality", callback_data="low"), + ], + ] + ) + + quality = get_quality_settings(chat_id) + send_type = get_format_settings(chat_id) + client.send_message(chat_id, BotText.settings.format(quality, send_type), reply_markup=markup) + + +@app.on_message(filters.command(["direct"])) +def direct_download(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + message_text = message.text + url, new_name = extract_url_and_name(message_text) + logging.info("Direct download using aria2/requests start %s", url) + if url is None or not re.findall(r"^https?://", url.lower()): + message.reply_text("Send me a correct LINK.", quote=True) + return + bot_msg = message.reply_text("Direct download request received.", quote=True) + try: + direct_entrance(client, bot_msg, url) + except ValueError as e: + message.reply_text(e.__str__(), quote=True) + bot_msg.delete() + return + + +@app.on_message(filters.command(["spdl"])) +def spdl_handler(client: Client, message: types.Message): + chat_id = message.chat.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + message_text = message.text + url, new_name = extract_url_and_name(message_text) + logging.info("spdl start %s", url) + if url is None or not re.findall(r"^https?://", url.lower()): + message.reply_text("Something wrong 🤔.\nCheck your URL and send me again.", quote=True) + return + bot_msg = message.reply_text("SPDL request received.", quote=True) + try: + special_download_entrance(client, bot_msg, url) + except ValueError as e: + message.reply_text(e.__str__(), quote=True) + bot_msg.delete() + return + + +@app.on_message(filters.command(["ytdl"]) & filters.group) +def ytdl_handler(client: Client, message: types.Message): + # for group only + init_user(message.from_user.id) + client.send_chat_action(message.chat.id, enums.ChatAction.TYPING) + message_text = message.text + url, new_name = extract_url_and_name(message_text) + logging.info("ytdl start %s", url) + if url is None or not re.findall(r"^https?://", url.lower()): + message.reply_text("Check your URL.", quote=True) + return + + bot_msg = message.reply_text("Group download request received.", quote=True) + try: + youtube_entrance(client, bot_msg, url) + except ValueError as e: + message.reply_text(e.__str__(), quote=True) + bot_msg.delete() + return + + +def check_link(url: str): + ytdl = yt_dlp.YoutubeDL() + if re.findall(r"^https://www\.youtube\.com/channel/", url) or "list" in url: + # TODO maybe using ytdl.extract_info + raise ValueError("Playlist or channel download are not supported at this moment.") + + if not M3U8_SUPPORT and (re.findall(r"m3u8|\.m3u8|\.m3u$", url.lower())): + return "m3u8 links are disabled." + + +@app.on_message(filters.incoming & filters.text) +@private_use +def download_handler(client: Client, message: types.Message): + chat_id = message.from_user.id + init_user(chat_id) + client.send_chat_action(chat_id, enums.ChatAction.TYPING) + url = message.text + logging.info("start %s", url) + + try: + check_link(url) + # raise pyrogram.errors.exceptions.FloodWait(10) + bot_msg: types.Message | Any = message.reply_text("Task received.", quote=True) + client.send_chat_action(chat_id, enums.ChatAction.UPLOAD_VIDEO) + youtube_entrance(client, bot_msg, url) + except pyrogram.errors.Flood as e: + f = BytesIO() + f.write(str(e).encode()) + f.write(b"Your job will be done soon. Just wait!") + f.name = "Please wait.txt" + message.reply_document(f, caption=f"Flood wait! Please wait {e} seconds...", quote=True) + f.close() + client.send_message(OWNER, f"Flood wait! 🙁 {e} seconds....") + time.sleep(e.value) + except ValueError as e: + message.reply_text(e.__str__(), quote=True) + except Exception as e: + logging.error("Download failed", exc_info=True) + message.reply_text(f"❌ Download failed: {e}", quote=True) + + +@app.on_callback_query(filters.regex(r"document|video|audio")) +def format_callback(client: Client, callback_query: types.CallbackQuery): + chat_id = callback_query.message.chat.id + data = callback_query.data + logging.info("Setting %s file type to %s", chat_id, data) + callback_query.answer(f"Your send type was set to {callback_query.data}") + set_user_settings(chat_id, "format", data) + + +@app.on_callback_query(filters.regex(r"high|medium|low")) +def quality_callback(client: Client, callback_query: types.CallbackQuery): + chat_id = callback_query.message.chat.id + data = callback_query.data + logging.info("Setting %s download quality to %s", chat_id, data) + callback_query.answer(f"Your default engine quality was set to {callback_query.data}") + set_user_settings(chat_id, "quality", data) + + +if __name__ == "__main__": + botStartTime = time.time() + scheduler = BackgroundScheduler() + scheduler.add_job(reset_free, "cron", hour=0, minute=0) + scheduler.start() + banner = f""" +▌ ▌ ▀▛▘ ▌ ▛▀▖ ▜ ▌ +▝▞ ▞▀▖ ▌ ▌ ▌ ▌ ▌ ▛▀▖ ▞▀▖ ▌ ▌ ▞▀▖ ▌ ▌ ▛▀▖ ▐ ▞▀▖ ▝▀▖ ▞▀▌ + ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▛▀ ▌ ▌ ▌ ▌ ▐▐▐ ▌ ▌ ▐ ▌ ▌ ▞▀▌ ▌ ▌ + ▘ ▝▀ ▝▀▘ ▘ ▝▀▘ ▀▀ ▝▀▘ ▀▀ ▝▀ ▘▘ ▘ ▘ ▘ ▝▀ ▝▀▘ ▝▀▘ + +By @BennyThink, VIP Mode: {ENABLE_VIP} + """ + print(banner) + app.run() diff --git a/src/test.py b/src/test.py new file mode 100644 index 00000000..f8a0f71e --- /dev/null +++ b/src/test.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - test.py + +import yt_dlp + +url = "https://www.youtube.com/watch?v=e19kTVgb2c8" +opts = { + "cookiefile": "cookies.txt", + "cookiesfrombrowser": ["firefox"], +} + +with yt_dlp.YoutubeDL(opts) as ydl: + ydl.download([url]) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 00000000..5dfdf518 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# coding: utf-8 + +# ytdlbot - __init__.py.py + + +import logging +import pathlib +import re +import shutil +import tempfile +import time +import uuid +from http.cookiejar import MozillaCookieJar +from urllib.parse import quote_plus, urlparse + +import ffmpeg + + +def sizeof_fmt(num: int, suffix="B"): + for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: + if abs(num) < 1024.0: + return "%3.1f%s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f%s%s" % (num, "Yi", suffix) + + +def timeof_fmt(seconds: int | float): + periods = [("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] + result = "" + for period_name, period_seconds in periods: + if seconds >= period_seconds: + period_value, seconds = divmod(seconds, period_seconds) + result += f"{int(period_value)}{period_name}" + return result + + +def is_youtube(url: str) -> bool: + try: + if not url or not isinstance(url, str): + return False + + parsed = urlparse(url) + return parsed.netloc.lower() in {'youtube.com', 'www.youtube.com', 'youtu.be'} + + except Exception: + return False + + +def adjust_formats(formats): + # high: best quality 1080P, 2K, 4K, 8K + # medium: 720P + # low: 480P + + mapping = {"high": [], "medium": [720], "low": [480]} + # formats.insert(0, f"bestvideo[ext=mp4][height={m}]+bestaudio[ext=m4a]") + # formats.insert(1, f"bestvideo[vcodec^=avc][height={m}]+bestaudio[acodec^=mp4a]/best[vcodec^=avc]/best") + # + # if settings[2] == "audio": + # formats.insert(0, "bestaudio[ext=m4a]") + # + # if settings[2] == "document": + # formats.insert(0, None) + + +def current_time(ts=None): + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) + + +def clean_tempfile(): + patterns = ["ytdl*", "spdl*", "leech*", "direct*"] + temp_path = pathlib.Path(TMPFILE_PATH or tempfile.gettempdir()) + + for pattern in patterns: + for item in temp_path.glob(pattern): + if time.time() - item.stat().st_ctime > 3600: + shutil.rmtree(item, ignore_errors=True) + + +def shorten_url(url, CAPTION_URL_LENGTH_LIMIT): + # Shortens a URL by cutting it to a specified length. + shortened_url = url[: CAPTION_URL_LENGTH_LIMIT - 3] + "..." + + return shortened_url + + +def extract_filename(response): + try: + content_disposition = response.headers.get("content-disposition") + if content_disposition: + filename = re.findall("filename=(.+)", content_disposition)[0] + return filename + except (TypeError, IndexError): + pass # Handle potential exceptions during extraction + + # Fallback if Content-Disposition header is missing + filename = response.url.rsplit("/")[-1] + if not filename: + filename = quote_plus(response.url) + return filename + + +def extract_url_and_name(message_text): + # Regular expression to match the URL + url_pattern = r"(https?://[^\s]+)" + # Regular expression to match the new name after '-n' + name_pattern = r"-n\s+(.+)$" + + # Find the URL in the message_text + url_match = re.search(url_pattern, message_text) + url = url_match.group(0) if url_match else None + + # Find the new name in the message_text + name_match = re.search(name_pattern, message_text) + new_name = name_match.group(1) if name_match else None + + return url, new_name diff --git a/tools/migrate_to_mysql.py b/tools/migrate_to_mysql.py deleted file mode 100644 index 30b5f4f4..00000000 --- a/tools/migrate_to_mysql.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - migrate_to_mysql.py -# 12/29/21 15:28 -# - -__author__ = "Benny " - -import sqlite3 -import pymysql - -mysql_con = pymysql.connect(host='localhost', user='root', passwd='root', db='vip', charset='utf8mb4') -sqlite_con = sqlite3.connect('vip.sqlite') - -vips = sqlite_con.execute('SELECT * FROM VIP').fetchall() - -for vip in vips: - mysql_con.cursor().execute('INSERT INTO vip VALUES (%s, %s, %s, %s, %s, %s)', vip) - -settings = sqlite_con.execute('SELECT * FROM settings').fetchall() - -for setting in settings: - mysql_con.cursor().execute("INSERT INTO settings VALUES (%s,%s,%s)", setting) - -mysql_con.commit() diff --git a/worker.yml b/worker.yml deleted file mode 100644 index 30ba3a4a..00000000 --- a/worker.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: '3.1' - -services: - worker: - image: bennythink/ytdlbot - env_file: - - env/ytdl.env - restart: always - command: [ "/usr/local/bin/supervisord", "-c" ,"/ytdlbot/conf/supervisor_worker.conf" ] - volumes: - - ./data/instagram.com_cookies.txt:/ytdlbot/ytdlbot/instagram.com_cookies.txt -# network_mode: "host" -# deploy: -# resources: -# limits: -# cpus: '0.3' -# memory: 1500M \ No newline at end of file diff --git a/.gitmodules b/youtube-cookies.txt similarity index 100% rename from .gitmodules rename to youtube-cookies.txt diff --git a/ytdlbot/broadcast.py b/ytdlbot/broadcast.py deleted file mode 100644 index 91d9a4b8..00000000 --- a/ytdlbot/broadcast.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - broadcast.py -# 8/25/21 16:11 -# - -__author__ = "Benny " - -import argparse -import contextlib -import logging -import random -import sys -import tempfile -import time - -from tqdm import tqdm - -from db import Redis -from ytdl_bot import create_app - -parser = argparse.ArgumentParser(description='Broadcast to users') -parser.add_argument('-m', help='message', required=True) -parser.add_argument('-p', help='picture', default=None) -parser.add_argument('--notify', help='notify all users?', action="store_false") -parser.add_argument('-u', help='user_id', type=int) -logging.basicConfig(level=logging.INFO) -args = parser.parse_args() - -r = Redis().r -keys = r.keys("*") -user_ids = set() -for key in keys: - if key.isdigit(): - user_ids.add(key) - -metrics = r.hgetall("metrics") - -for key in metrics: - if key.isdigit(): - user_ids.add(key) - -if args.u: - user_ids = [args.u] - -if "YES" != input("Are you sure you want to send broadcast message to %s users?\n>" % len(user_ids)): - logging.info("Abort") - sys.exit(1) - -with tempfile.NamedTemporaryFile() as tmp: - app = create_app(tmp.name, 1) - app.start() - for user_id in tqdm(user_ids): - time.sleep(random.random() * 5) - if args.p: - with contextlib.suppress(Exception): - app.send_photo(user_id, args.p, caption=args.m, disable_notification=args.notify) - else: - with contextlib.suppress(Exception): - app.send_message(user_id, args.m, disable_notification=args.notify) - app.stop() diff --git a/ytdlbot/client_init.py b/ytdlbot/client_init.py deleted file mode 100644 index cb1718e1..00000000 --- a/ytdlbot/client_init.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - client_init.py -# 12/29/21 16:20 -# - -__author__ = "Benny " - -import os - -from pyrogram import Client - -from config import APP_HASH, APP_ID, PYRO_WORKERS, TOKEN - - -def create_app(session="ytdl", workers=PYRO_WORKERS): - _app = Client(session, APP_ID, APP_HASH, - bot_token=TOKEN, workers=workers, - ipv6=os.getenv("ipv6", False), - # proxy={"hostname": "host.docker.internal", "port": 1080} - ) - - return _app diff --git a/ytdlbot/config.py b/ytdlbot/config.py deleted file mode 100644 index 9f4860c1..00000000 --- a/ytdlbot/config.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - config.py -# 8/28/21 15:01 -# - -__author__ = "Benny " - -import os - -# general settings -WORKERS: "int" = int(os.getenv("WORKERS", 100)) -PYRO_WORKERS: "int" = int(os.getenv("PYRO_WORKERS", 100)) -APP_ID: "int" = int(os.getenv("APP_ID", 111)) -APP_HASH = os.getenv("APP_HASH", "111") -TOKEN = os.getenv("TOKEN", "3703WLI") - -REDIS = os.getenv("REDIS") - -# quota settings -QUOTA = int(os.getenv("QUOTA", 10 * 1024 * 1024 * 1024)) # 10G -if os.uname().sysname == "Darwin": - QUOTA = 10 * 1024 * 1024 # 10M - -TG_MAX_SIZE = 2 * 1024 * 1024 * 1024 * 0.99 -# TG_MAX_SIZE = 10 * 1024 * 1024 - -EX = os.getenv("EX", 24 * 3600) -MULTIPLY = os.getenv("MULTIPLY", 5) # VIP1 is 5*5-25G, VIP2 is 50G -USD2CNY = os.getenv("USD2CNY", 6) # $5 --> ¥30 - -ENABLE_VIP = os.getenv("VIP", False) -MAX_DURATION = int(os.getenv("MAX_DURATION", 300)) -AFD_LINK = os.getenv("AFD_LINK", "https://afdian.net/@BennyThink") -COFFEE_LINK = os.getenv("COFFEE_LINK", "https://www.buymeacoffee.com/bennythink") -COFFEE_TOKEN = os.getenv("COFFEE_TOKEN") -AFD_TOKEN = os.getenv("AFD_TOKEN") -AFD_USER_ID = os.getenv("AFD_USER_ID") -OWNER = os.getenv("OWNER", "BennyThink") - -# limitation settings -AUTHORIZED_USER: "str" = os.getenv("AUTHORIZED", "") -# membership requires: the format could be username/chat_id of channel or group -REQUIRED_MEMBERSHIP: "str" = os.getenv("REQUIRED_MEMBERSHIP", "") - -# celery related -ENABLE_CELERY = os.getenv("ENABLE_CELERY", False) -BROKER = os.getenv("BROKER", f"redis://{REDIS}:6379/4") -MYSQL_HOST = os.getenv("MYSQL_HOST") -MYSQL_USER = os.getenv("MYSQL_USER", "root") -MYSQL_PASS = os.getenv("MYSQL_PASS", "root") - -AUDIO_FORMAT = os.getenv("AUDIO_FORMAT", "m4a") -ARCHIVE_ID = os.getenv("ARCHIVE_ID") diff --git a/ytdlbot/constant.py b/ytdlbot/constant.py deleted file mode 100644 index bc8ceecc..00000000 --- a/ytdlbot/constant.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - constant.py -# 8/16/21 16:59 -# - -__author__ = "Benny " - -import os -import time - -from config import (AFD_LINK, COFFEE_LINK, ENABLE_CELERY, ENABLE_VIP, EX, - MULTIPLY, REQUIRED_MEMBERSHIP, USD2CNY) -from db import InfluxDB -from downloader import sizeof_fmt -from limit import QUOTA, VIP -from utils import get_func_queue - - -class BotText: - start = "Welcome to YouTube Download bot. Type /help for more information." - - help = f""" -1. This bot should works at all times. If it doesn't, try to send the link again or DM @BennyThink - -2. At this time of writing, this bot consumes hundreds of GigaBytes of network traffic per day. -In order to avoid being abused, -every one can use this bot within **{sizeof_fmt(QUOTA)} of quota for every {int(EX / 3600)} hours.** - -3. Free users can't receive streaming formats of one video whose duration is longer than 300 seconds. - -4. You can optionally choose to become 'VIP' user if you need more traffic. Type /vip for more information. - -5. Source code for this bot will always stay open, here-> https://github.com/tgbot-collection/ytdlbot - """ if ENABLE_VIP else "Help text" - - about = "YouTube-DL by @BennyThink. Open source on GitHub: https://github.com/tgbot-collection/ytdlbot" - - terms = f""" -1. You can use this service, free of charge, {sizeof_fmt(QUOTA)} per {int(EX / 3600)} hours. - -2. The above traffic, is counted for one-way. -For example, if you download a video of 1GB, your current quota will be 9GB instead of 8GB. - -3. Streaming support is limited due to high costs of conversion. - -4. I won't gather any personal information, which means I don't know how many and what videos did you download. - -5. Please try not to abuse this service. - -6. It's a open source project, you can always deploy your own bot. - -7. For VIPs, please refer to /vip command - """ if ENABLE_VIP else "Please contact the actual owner of this bot" - - vip = f""" -**Terms:** -1. No refund, I'll keep it running as long as I can. -2. I'll record your unique ID after a successful payment, usually it's payment ID or email address. -3. VIPs identity won't expire. - -**Pay Tier:** -1. Everyone: {sizeof_fmt(QUOTA)} per {int(EX / 3600)} hours -2. VIP1: ${MULTIPLY} or ¥{MULTIPLY * USD2CNY}, {sizeof_fmt(QUOTA * 5)} per {int(EX / 3600)} hours -3. VIP2: ${MULTIPLY * 2} or ¥{MULTIPLY * USD2CNY * 2}, {sizeof_fmt(QUOTA * 5 * 2)} per {int(EX / 3600)} hours -4. VIP4....VIPn. -5. Unlimited streaming conversion support. -Note: If you pay $9, you'll become VIP1 instead of VIP2. - -**Payment method:** -1. (afdian) Mainland China: {AFD_LINK} -2. (buy me a coffee) Other countries or regions: {COFFEE_LINK} -__I live in a place where I don't have access to Telegram Payments. So...__ - -**After payment:** -1. afdian: with your order number `/vip 123456` -2. buy me a coffee: with your email `/vip someone@else.com` - """ if ENABLE_VIP else "VIP is not enabled." - vip_pay = "Processing your payments...If it's not responding after one minute, please contact @BennyThink." - - private = "This bot is for private use" - membership_require = f"You need to join this group or channel to use this bot\n\nhttps://t.me/{REQUIRED_MEMBERSHIP}" - - settings = """ -Select sending format and video quality. **Only applies to YouTube** -High quality is recommended; Medium quality is aimed as 480P while low quality is aimed as 360P and 240P. - -Remember if you choose to send as document, there will be no streaming. - -Your current settings: -Video quality: **{0}** -Sending format: **{1}** -""" - custom_text = os.getenv("CUSTOM_TEXT", "") - - def remaining_quota_caption(self, chat_id): - if not ENABLE_VIP: - return "" - used, total, ttl = self.return_remaining_quota(chat_id) - refresh_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ttl + time.time())) - caption = f"Remaining quota: **{sizeof_fmt(used)}/{sizeof_fmt(total)}**, " \ - f"refresh at {refresh_time}\n" - return caption - - @staticmethod - def return_remaining_quota(chat_id): - used, total, ttl = VIP().check_remaining_quota(chat_id) - return used, total, ttl - - @staticmethod - def get_vip_greeting(chat_id): - if not ENABLE_VIP: - return "" - v = VIP().check_vip(chat_id) - if v: - return f"Hello {v[1]}, VIP{v[-2]}☺️\n\n" - else: - return "" - - @staticmethod - def get_receive_link_text(): - reserved = get_func_queue("reserved") - if ENABLE_CELERY and reserved: - text = f"Too many tasks. Your tasks was added to the reserved queue {reserved}." - else: - text = "Your task was added to active queue.\nProcessing...\n\n" - - return text - - @staticmethod - def ping_worker(): - from tasks import app as celery_app - workers = InfluxDB().extract_dashboard_data() - # [{'celery@BennyのMBP': 'abc'}, {'celery@BennyのMBP': 'abc'}] - response = celery_app.control.broadcast("ping_revision", reply=True) - revision = {} - for item in response: - revision.update(item) - - text = "" - for worker in workers: - fields = worker["fields"] - hostname = worker["tags"]["hostname"] - status = {True: "✅"}.get(fields["status"], "❌") - active = fields["active"] - load = "{},{},{}".format(fields["load1"], fields["load5"], fields["load15"]) - rev = revision.get(hostname, "") - text += f"{status}{hostname} **{active}** {load} {rev}\n" - - return text diff --git a/ytdlbot/db.py b/ytdlbot/db.py deleted file mode 100644 index 26d60fea..00000000 --- a/ytdlbot/db.py +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - db.py -# 12/7/21 16:57 -# - -__author__ = "Benny " - -import base64 -import contextlib -import datetime -import logging -import os -import re -import subprocess -import time -from io import BytesIO - -import fakeredis -import pymysql -import redis -import requests -from beautifultable import BeautifulTable -from influxdb import InfluxDBClient - -from config import MYSQL_HOST, MYSQL_PASS, MYSQL_USER, QUOTA, REDIS -from fakemysql import FakeMySQL - - -class Redis: - def __init__(self): - super(Redis, self).__init__() - if REDIS: - self.r = redis.StrictRedis(host=REDIS, db=0, decode_responses=True) - else: - self.r = fakeredis.FakeStrictRedis(host=REDIS, db=0, decode_responses=True) - - db_banner = "=" * 20 + "DB data" + "=" * 20 - quota_banner = "=" * 20 + "Quota" + "=" * 20 - metrics_banner = "=" * 20 + "Metrics" + "=" * 20 - usage_banner = "=" * 20 + "Usage" + "=" * 20 - vnstat_banner = "=" * 20 + "vnstat" + "=" * 20 - self.final_text = f""" -{db_banner} -%s - - -{vnstat_banner} -%s - - -{quota_banner} -%s - - -{metrics_banner} -%s - - -{usage_banner} -%s - """ - - def __del__(self): - self.r.close() - - def update_metrics(self, metrics): - logging.info(f"Setting metrics: {metrics}") - all_ = f"all_{metrics}" - today = f"today_{metrics}" - self.r.hincrby("metrics", all_) - self.r.hincrby("metrics", today) - - @staticmethod - def generate_table(header, all_data: "list"): - table = BeautifulTable() - for data in all_data: - table.rows.append(data) - table.columns.header = header - table.rows.header = [str(i) for i in range(1, len(all_data) + 1)] - return table - - def show_usage(self): - from downloader import sizeof_fmt - db = MySQL() - db.cur.execute("select * from vip") - data = db.cur.fetchall() - fd = [] - for item in data: - fd.append([item[0], item[1], sizeof_fmt(item[-1])]) - db_text = self.generate_table(["ID", "username", "quota"], fd) - - fd = [] - hash_keys = self.r.hgetall("metrics") - for key, value in hash_keys.items(): - if re.findall(r"^today|all", key): - fd.append([key, value]) - fd.sort(key=lambda x: x[0]) - metrics_text = self.generate_table(["name", "count"], fd) - - fd = [] - for key, value in hash_keys.items(): - if re.findall(r"\d+", key): - fd.append([key, value]) - fd.sort(key=lambda x: int(x[-1]), reverse=True) - usage_text = self.generate_table(["UserID", "count"], fd) - - fd = [] - for key in self.r.keys("*"): - if re.findall(r"^\d+$", key): - value = self.r.get(key) - date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.r.ttl(key) + time.time())) - fd.append([key, value, sizeof_fmt(int(value)), date]) - fd.sort(key=lambda x: int(x[1])) - quota_text = self.generate_table(["UserID", "bytes", "human readable", "refresh time"], fd) - - # vnstat - if os.uname().sysname == "Darwin": - cmd = "/usr/local/bin/vnstat -i en0".split() - else: - cmd = "/usr/bin/vnstat -i eth0".split() - vnstat_text = subprocess.check_output(cmd).decode('u8') - return self.final_text % (db_text, vnstat_text, quota_text, metrics_text, usage_text) - - def reset_today(self): - pairs = self.r.hgetall("metrics") - for k in pairs: - if k.startswith("today"): - self.r.hdel("metrics", k) - - def user_count(self, user_id): - self.r.hincrby("metrics", user_id) - - def generate_file(self): - text = self.show_usage() - file = BytesIO() - file.write(text.encode("u8")) - date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - file.name = f"{date}.txt" - return file - - def add_send_cache(self, unique, file_id): - self.r.hset("cache", unique, file_id) - - def get_send_cache(self, unique) -> "str": - return self.r.hget("cache", unique) - - def del_send_cache(self, unique): - self.r.hdel("cache", unique) - - -class MySQL: - vip_sql = """ - create table if not exists vip - ( - user_id bigint not null, - username varchar(256) null, - payment_amount int null, - payment_id varchar(256) null, - level int default 1 null, - quota bigint default %s null, - constraint VIP_pk - primary key (user_id) - ); - """ % QUOTA - - settings_sql = """ - create table if not exists settings - ( - user_id bigint not null, - resolution varchar(128) null, - method varchar(64) null, - mode varchar(32) default 'Celery' null, - constraint settings_pk - primary key (user_id) - ); - """ - - channel_sql = """ - create table if not exists channel - ( - link varchar(256) null, - title varchar(256) null, - description text null, - channel_id varchar(256), - playlist varchar(256) null, - latest_video varchar(256) null, - constraint channel_pk - primary key (channel_id) - ) CHARSET=utf8mb4; - """ - - subscribe_sql = """ - create table if not exists subscribe - ( - user_id bigint null, - channel_id varchar(256) null - ) CHARSET=utf8mb4; - """ - - def __init__(self): - if MYSQL_HOST: - self.con = pymysql.connect(host=MYSQL_HOST, user=MYSQL_USER, passwd=MYSQL_PASS, db="ytdl", - charset="utf8mb4") - else: - self.con = FakeMySQL() - - self.cur = self.con.cursor() - self.init_db() - - def init_db(self): - self.cur.execute(self.vip_sql) - self.cur.execute(self.settings_sql) - self.cur.execute(self.channel_sql) - self.cur.execute(self.subscribe_sql) - self.con.commit() - - def __del__(self): - self.con.close() - - -class InfluxDB: - def __init__(self): - self.client = InfluxDBClient(host=os.getenv("INFLUX_HOST", "192.168.7.233"), database="celery") - self.data = None - - def __del__(self): - self.client.close() - - @staticmethod - def get_worker_data(): - password = os.getenv("FLOWER_PASSWORD", "123456abc") - username = os.getenv("FLOWER_USERNAME", "benny") - token = base64.b64encode(f"{username}:{password}".encode()).decode() - headers = {"Authorization": f"Basic {token}"} - return requests.get("https://celery.dmesg.app/dashboard?json=1", headers=headers).json() - - def extract_dashboard_data(self): - self.data = self.get_worker_data() - json_body = [] - for worker in self.data["data"]: - load1, load5, load15 = worker["loadavg"] - t = { - "measurement": "tasks", - "tags": { - "hostname": worker["hostname"], - }, - - "time": datetime.datetime.utcnow(), - "fields": { - "task-received": worker.get("task-received", 0), - "task-started": worker.get("task-started", 0), - "task-succeeded": worker.get("task-succeeded", 0), - "task-failed": worker.get("task-failed", 0), - "active": worker.get("active", 0), - "status": worker.get("status", False), - "load1": load1, - "load5": load5, - "load15": load15, - } - } - json_body.append(t) - return json_body - - def __fill_worker_data(self): - json_body = self.extract_dashboard_data() - self.client.write_points(json_body) - - def __fill_overall_data(self): - active = sum([i["active"] for i in self.data["data"]]) - json_body = [ - { - "measurement": "active", - "time": datetime.datetime.utcnow(), - "fields": { - "active": active - } - } - ] - self.client.write_points(json_body) - - def __fill_redis_metrics(self): - json_body = [ - { - "measurement": "metrics", - "time": datetime.datetime.utcnow(), - "fields": { - } - } - ] - r = Redis().r - hash_keys = r.hgetall("metrics") - for key, value in hash_keys.items(): - if re.findall(r"^today", key): - json_body[0]["fields"][key] = int(value) - - self.client.write_points(json_body) - - def collect_data(self): - if os.getenv("INFLUX_HOST") is None: - return - - with contextlib.suppress(Exception): - self.data = self.get_worker_data() - self.__fill_worker_data() - self.__fill_overall_data() - self.__fill_redis_metrics() - logging.debug("InfluxDB data was collected.") diff --git a/ytdlbot/downloader.py b/ytdlbot/downloader.py deleted file mode 100644 index fc7801d8..00000000 --- a/ytdlbot/downloader.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - downloader.py -# 8/14/21 16:53 -# - -__author__ = "Benny " - -import logging -import os -import pathlib -import random -import re -import subprocess -import time -from io import StringIO -from unittest.mock import MagicMock - -import fakeredis -import ffmpeg -import ffpb -import filetype -import yt_dlp as ytdl -from tqdm import tqdm -from yt_dlp import DownloadError - -from config import AUDIO_FORMAT, ENABLE_VIP, MAX_DURATION, TG_MAX_SIZE -from db import Redis -from limit import VIP -from utils import (adjust_formats, apply_log_formatter, current_time, - get_user_settings) - -r = fakeredis.FakeStrictRedis() -apply_log_formatter() - - -def sizeof_fmt(num: int, suffix='B'): - for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: - if abs(num) < 1024.0: - return "%3.1f%s%s" % (num, unit, suffix) - num /= 1024.0 - return "%.1f%s%s" % (num, 'Yi', suffix) - - -def edit_text(bot_msg, text): - key = f"{bot_msg.chat.id}-{bot_msg.message_id}" - # if the key exists, we shouldn't send edit message - if not r.exists(key): - time.sleep(random.random()) - r.set(key, "ok", ex=3) - bot_msg.edit_text(text) - - -def tqdm_progress(desc, total, finished, speed="", eta=""): - def more(title, initial): - if initial: - return f"{title} {initial}" - else: - return "" - - f = StringIO() - tqdm(total=total, initial=finished, file=f, ascii=False, unit_scale=True, ncols=30, - bar_format="{l_bar}{bar} |{n_fmt}/{total_fmt} " - ) - raw_output = f.getvalue() - tqdm_output = raw_output.split("|") - progress = f"`[{tqdm_output[1]}]`" - detail = tqdm_output[2].replace("[A", "") - text = f""" -{desc} - -{progress} -{detail} -{more("Speed:", speed)} -{more("ETA:", eta)} - """ - f.close() - return text - - -def remove_bash_color(text): - return re.sub(r'\u001b|\[0;94m|\u001b\[0m|\[0;32m|\[0m|\[0;33m', "", text) - - -def download_hook(d: dict, bot_msg): - # since we're using celery, server location may be located in different continent. - # Therefore, we can't trigger the hook very often. - # the key is user_id + download_link - original_url = d["info_dict"]["original_url"] - key = f"{bot_msg.chat.id}-{original_url}" - - if d['status'] == 'downloading': - downloaded = d.get("downloaded_bytes", 0) - total = d.get("total_bytes") or d.get("total_bytes_estimate", 0) - - # percent = remove_bash_color(d.get("_percent_str", "N/A")) - speed = remove_bash_color(d.get("_speed_str", "N/A")) - if ENABLE_VIP and not r.exists(key): - result, err_msg = check_quota(total, bot_msg.chat.id) - if result is False: - raise ValueError(err_msg) - eta = remove_bash_color(d.get("_eta_str", d.get("eta"))) - text = tqdm_progress("Downloading...", total, downloaded, speed, eta) - edit_text(bot_msg, text) - r.set(key, "ok", ex=5) - - -def upload_hook(current, total, bot_msg): - # filesize = sizeof_fmt(total) - text = tqdm_progress("Uploading...", total, current) - edit_text(bot_msg, text) - - -def check_quota(file_size, chat_id) -> ("bool", "str"): - remain, _, ttl = VIP().check_remaining_quota(chat_id) - if file_size > remain: - refresh_time = current_time(ttl + time.time()) - err = f"Quota exceed, you have {sizeof_fmt(remain)} remaining, " \ - f"but you want to download a video with {sizeof_fmt(file_size)} in size. \n" \ - f"Try again in {ttl} seconds({refresh_time})" - logging.warning(err) - Redis().update_metrics("quota_exceed") - return False, err - else: - return True, "" - - -def convert_to_mp4(resp: dict, bot_msg): - default_type = ["video/x-flv", "video/webm"] - if resp["status"]: - # all_converted = [] - for path in resp["filepath"]: - # if we can't guess file type, we assume it's video/mp4 - mime = getattr(filetype.guess(path), "mime", "video/mp4") - if mime in default_type: - if not can_convert_mp4(path, bot_msg.chat.id): - logging.warning("Conversion abort for non VIP %s", bot_msg.chat.id) - bot_msg._client.send_message( - bot_msg.chat.id, - "You're not VIP, so you can't convert longer video to streaming formats.") - break - edit_text(bot_msg, f"{current_time()}: Converting {path.name} to mp4. Please wait.") - new_file_path = path.with_suffix(".mp4") - logging.info("Detected %s, converting to mp4...", mime) - run_ffmpeg(["ffmpeg", "-y", "-i", path, new_file_path], bot_msg) - index = resp["filepath"].index(path) - resp["filepath"][index] = new_file_path - - return resp - - -class ProgressBar(tqdm): - b = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot_msg = self.b - - def update(self, n=1): - super().update(n) - t = tqdm_progress("Converting...", self.total, self.n) - edit_text(self.bot_msg, t) - - -def run_ffmpeg(cmd_list, bm): - cmd_list = cmd_list.copy()[1:] - ProgressBar.b = bm - ffpb.main(cmd_list, tqdm=ProgressBar) - - -def can_convert_mp4(video_path, uid): - if not ENABLE_VIP: - return True - video_streams = ffmpeg.probe(video_path, select_streams="v") - try: - duration = int(float(video_streams["format"]["duration"])) - except Exception: - duration = 0 - if duration > MAX_DURATION and not VIP().check_vip(uid): - logging.info("Video duration: %s, not vip, can't convert", duration) - return False - else: - return True - - -def ytdl_download(url, tempdir, bm) -> dict: - chat_id = bm.chat.id - response = {"status": True, "error": "", "filepath": []} - output = pathlib.Path(tempdir, "%(title).70s.%(ext)s").as_posix() - ydl_opts = { - 'progress_hooks': [lambda d: download_hook(d, bm)], - 'outtmpl': output, - 'restrictfilenames': False, - 'quiet': True - } - formats = [ - "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio", - "bestvideo[vcodec^=avc]+bestaudio[acodec^=mp4a]/best[vcodec^=avc]/best", - None - ] - adjust_formats(chat_id, url, formats) - add_instagram_cookies(url, ydl_opts) - - address = ["::", "0.0.0.0"] if os.getenv("ipv6") else [None] - - for format_ in formats: - ydl_opts["format"] = format_ - for addr in address: - # IPv6 goes first in each format - ydl_opts["source_address"] = addr - try: - logging.info("Downloading for %s with format %s", url, format_) - with ytdl.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - response["status"] = True - response["error"] = "" - break - except (ValueError, DownloadError) as e: - logging.error("Download failed for %s ", url) - response["status"] = False - response["error"] = str(e) - except Exception as e: - logging.error("UNKNOWN EXCEPTION: %s", e) - - logging.info("%s - %s", url, response) - if response["status"] is False: - return response - - for i in os.listdir(tempdir): - p = pathlib.Path(tempdir, i) - file_size = os.stat(p).st_size - if ENABLE_VIP: - remain, _, ttl = VIP().check_remaining_quota(chat_id) - result, err_msg = check_quota(file_size, chat_id) - else: - result, err_msg = True, "" - if result is False: - response["status"] = False - response["error"] = err_msg - else: - VIP().use_quota(bm.chat.id, file_size) - response["status"] = True - response["filepath"].append(p) - - # convert format if necessary - settings = get_user_settings(str(chat_id)) - if settings[2] == "video" or isinstance(settings[2], MagicMock): - # only convert if send type is video - convert_to_mp4(response, bm) - if settings[2] == "audio": - convert_audio_format(response, bm) - # disable it for now - # split_large_video(response) - return response - - -def convert_audio_format(resp: "dict", bm): - if resp["status"]: - # all_converted = [] - path: pathlib.PosixPath - for path in resp["filepath"]: - if path.suffix != f".{AUDIO_FORMAT}": - new_path = path.with_suffix(f".{AUDIO_FORMAT}") - run_ffmpeg(["ffmpeg", "-y", "-i", path, new_path], bm) - path.unlink() - index = resp["filepath"].index(path) - resp["filepath"][index] = new_path - - -def add_instagram_cookies(url: "str", opt: "dict"): - if url.startswith("https://www.instagram.com"): - opt["cookiefi22"] = pathlib.Path(__file__).parent.joinpath("instagram.com_cookies.txt").as_posix() - - -def run_splitter(video_path: "str"): - subprocess.check_output(f"sh split-video.sh {video_path} {TG_MAX_SIZE} ".split()) - os.remove(video_path) - - -def split_large_video(response: "dict"): - original_video = None - split = False - for original_video in response.get("filepath", []): - size = os.stat(original_video).st_size - if size > TG_MAX_SIZE: - split = True - logging.warning("file is too large %s, splitting...", size) - run_splitter(original_video) - - if split and original_video: - response["filepath"] = [i.as_posix() for i in pathlib.Path(original_video).parent.glob("*")] diff --git a/ytdlbot/fakemysql.py b/ytdlbot/fakemysql.py deleted file mode 100644 index eb96a34b..00000000 --- a/ytdlbot/fakemysql.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - fakemysql.py -# 2/20/22 20:08 -# - -__author__ = "Benny " - -import re -import sqlite3 - -init_con = sqlite3.connect(":memory:", check_same_thread=False) - - -class FakeMySQL: - @staticmethod - def cursor() -> "Cursor": - return Cursor() - - def commit(self): - pass - - def close(self): - pass - - -class Cursor: - def __init__(self): - self.con = init_con - self.cur = self.con.cursor() - - def execute(self, *args, **kwargs): - sql = self.sub(args[0]) - new_args = (sql,) + args[1:] - return self.cur.execute(*new_args, **kwargs) - - def fetchall(self): - return self.cur.fetchall() - - def fetchone(self): - return self.cur.fetchone() - - @staticmethod - def sub(sql): - sql = re.sub(r"CHARSET.*|charset.*", "", sql, re.IGNORECASE) - sql = sql.replace("%s", "?") - return sql - - -if __name__ == '__main__': - con = FakeMySQL() - cur = con.cursor() - cur.execute("create table user(id int, name varchar(20))") - cur.execute("insert into user values(%s,%s)", (1, "benny")) - cur.execute("select * from user") - data = cur.fetchall() - print(data) diff --git a/ytdlbot/flower_tasks.py b/ytdlbot/flower_tasks.py deleted file mode 100644 index f580e95b..00000000 --- a/ytdlbot/flower_tasks.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - flower_tasks.py -# 1/2/22 10:17 -# - -__author__ = "Benny " - -from celery import Celery - -from config import BROKER - -app = Celery('tasks', broker=BROKER, timezone="Asia/Shanghai") diff --git a/ytdlbot/limit.py b/ytdlbot/limit.py deleted file mode 100644 index 76cd288e..00000000 --- a/ytdlbot/limit.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - limit.py -# 8/15/21 18:23 -# - -__author__ = "Benny " - -import hashlib -import http -import logging -import math -import os -import re -import time -from unittest.mock import MagicMock - -import requests -from bs4 import BeautifulSoup - -from config import (AFD_TOKEN, AFD_USER_ID, COFFEE_TOKEN, ENABLE_VIP, EX, - MULTIPLY, OWNER, QUOTA, USD2CNY) -from db import MySQL, Redis -from utils import apply_log_formatter - -apply_log_formatter() - - -class VIP(Redis, MySQL): - - def check_vip(self, user_id: "int") -> "tuple": - self.cur.execute("SELECT * FROM vip WHERE user_id=%s", (user_id,)) - data = self.cur.fetchone() - return data - - def add_vip(self, user_data: "dict") -> ("bool", "str"): - sql = "INSERT INTO vip VALUES (%s,%s,%s,%s,%s,%s);" - # first select - self.cur.execute("SELECT * FROM vip WHERE payment_id=%s", (user_data["payment_id"],)) - is_exist = self.cur.fetchone() - if is_exist: - return "Failed. {} is being used by user {}".format(user_data["payment_id"], is_exist[0]) - self.cur.execute(sql, list(user_data.values())) - self.con.commit() - # also remove redis cache - self.r.delete(user_data["user_id"]) - return "Success! You are VIP{} now!".format(user_data["level"]) - - def remove_vip(self, user_id: "int"): - raise NotImplementedError() - - def get_user_quota(self, user_id: "int") -> int: - # even VIP have certain quota - q = self.check_vip(user_id) - return q[-1] if q else QUOTA - - def check_remaining_quota(self, user_id: "int"): - user_quota = self.get_user_quota(user_id) - ttl = self.r.ttl(user_id) - q = int(self.r.get(user_id)) if self.r.exists(user_id) else user_quota - if q <= 0: - q = 0 - return q, user_quota, ttl - - def use_quota(self, user_id: "int", traffic: "int"): - user_quota = self.get_user_quota(user_id) - # fix for standard mode - if isinstance(user_quota, MagicMock): - user_quota = 2 ** 32 - if self.r.exists(user_id): - self.r.decr(user_id, traffic) - else: - self.r.set(user_id, user_quota - traffic, ex=EX) - - def subscribe_channel(self, user_id: "int", share_link: "str"): - if not re.findall(r"youtube\.com|youtu\.be", share_link): - raise ValueError("Is this a valid YouTube Channel link?") - if ENABLE_VIP: - self.cur.execute("select count(user_id) from subscribe where user_id=%s", (user_id,)) - usage = int(self.cur.fetchone()[0]) - if usage >= 5 and not self.check_vip(user_id): - logging.warning("User %s is not VIP but has subscribed %s channels", user_id, usage) - return "You have subscribed too many channels. Please upgrade to VIP to subscribe more channels." - - data = self.get_channel_info(share_link) - channel_id = data["channel_id"] - - self.cur.execute("select user_id from subscribe where user_id=%s and channel_id=%s", (user_id, channel_id)) - if self.cur.fetchall(): - raise ValueError("You have already subscribed this channel.") - - self.cur.execute("INSERT IGNORE INTO channel values" - "(%(link)s,%(title)s,%(description)s,%(channel_id)s,%(playlist)s,%(last_video)s)", data) - self.cur.execute("INSERT INTO subscribe values(%s,%s)", (user_id, channel_id)) - self.con.commit() - logging.info("User %s subscribed channel %s", user_id, data["title"]) - return "Subscribed to {}".format(data["title"]) - - def unsubscribe_channel(self, user_id: "int", channel_id: "str"): - affected_rows = self.cur.execute("DELETE FROM subscribe WHERE user_id=%s AND channel_id=%s", - (user_id, channel_id)) - self.con.commit() - logging.info("User %s tried to unsubscribe channel %s", user_id, channel_id) - return affected_rows - - @staticmethod - def extract_canonical_link(url): - # canonic link works for many websites. It will strip out unnecessary stuff - props = ["canonical", "alternate", "shortlinkUrl"] - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36"} - # send head request first - r = requests.head(url, headers=headers) - if r.status_code != http.HTTPStatus.METHOD_NOT_ALLOWED and "text/html" not in r.headers.get("content-type"): - # get content-type, if it's not text/html, there's no need to issue a GET request - logging.warning("%s Content-type is not text/html, no need to GET for extract_canonical_link", url) - return url - - html_doc = requests.get(url, headers=headers, timeout=5).text - soup = BeautifulSoup(html_doc, "html.parser") - for prop in props: - element = soup.find("link", rel=prop) - try: - href = element["href"] - if href not in ["null", "", None]: - return href - except Exception: - logging.warning("Canonical exception %s", url) - - return url - - def get_channel_info(self, url: "str"): - api_key = os.getenv("GOOGLE_API_KEY") - canonical_link = self.extract_canonical_link(url) - channel_id = canonical_link.split("https://www.youtube.com/channel/")[1] - channel_api = f"https://www.googleapis.com/youtube/v3/channels?part=snippet,contentDetails&" \ - f"id={channel_id}&key={api_key}" - data = requests.get(channel_api).json() - snippet = data['items'][0]['snippet'] - title = snippet['title'] - description = snippet['description'] - playlist = data['items'][0]['contentDetails']['relatedPlaylists']['uploads'] - - return { - "link": url, - "title": title, - "description": description, - "channel_id": channel_id, - "playlist": playlist, - "last_video": VIP.get_latest_video(playlist) - } - - @staticmethod - def get_latest_video(playlist_id: "str"): - api_key = os.getenv("GOOGLE_API_KEY") - video_api = f"https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=1&" \ - f"playlistId={playlist_id}&key={api_key}" - data = requests.get(video_api).json() - video_id = data['items'][0]['snippet']['resourceId']['videoId'] - logging.info(f"Latest video %s from %s", video_id, data['items'][0]['snippet']['channelTitle']) - return f"https://www.youtube.com/watch?v={video_id}" - - def has_newer_update(self, channel_id: "str"): - self.cur.execute("SELECT playlist,latest_video FROM channel WHERE channel_id=%s", (channel_id,)) - data = self.cur.fetchone() - playlist_id = data[0] - old_video = data[1] - newest_video = VIP.get_latest_video(playlist_id) - if old_video != newest_video: - logging.info("Newer update found for %s %s", channel_id, newest_video) - self.cur.execute("UPDATE channel SET latest_video=%s WHERE channel_id=%s", (newest_video, channel_id)) - self.con.commit() - return newest_video - - def get_user_subscription(self, user_id: "int"): - self.cur.execute( - """ - select title, link, channel.channel_id from channel, subscribe - where subscribe.user_id = %s and channel.channel_id = subscribe.channel_id - """, (user_id,)) - data = self.cur.fetchall() - text = "" - for item in data: - text += "[{}]({}) `{}\n`".format(*item) - return text - - def group_subscriber(self): - # {"channel_id": [user_id, user_id, ...]} - self.cur.execute("select * from subscribe") - data = self.cur.fetchall() - group = {} - for item in data: - group.setdefault(item[1], []).append(item[0]) - logging.info("Checking peroidic subscriber...") - return group - - def sub_count(self): - sql = """ - select user_id, channel.title, channel.link - from subscribe, channel where subscribe.channel_id = channel.channel_id - """ - self.cur.execute(sql) - data = self.cur.fetchall() - text = f"Total {len(data)} subscriptions found.\n\n" - for item in data: - text += "{} ==> [{}]({})\n".format(*item) - return text - - -class BuyMeACoffee: - def __init__(self): - self._token = COFFEE_TOKEN - self._url = "https://developers.buymeacoffee.com/api/v1/supporters" - self._data = [] - - def _get_data(self, url): - d = requests.get(url, headers={"Authorization": f"Bearer {self._token}"}).json() - self._data.extend(d["data"]) - next_page = d["next_page_url"] - if next_page: - self._get_data(next_page) - - def _get_bmac_status(self, email: "str") -> "dict": - self._get_data(self._url) - for user in self._data: - if user["payer_email"] == email or user["support_email"] == email: - return user - return {} - - def get_user_payment(self, email: "str") -> ("int", "float", "str"): - order = self._get_bmac_status(email) - price = float(order.get("support_coffee_price", 0)) - cups = float(order.get("support_coffees", 1)) - amount = price * cups - level = math.floor(amount / MULTIPLY) - return level, amount, email - - -class Afdian: - def __init__(self): - self._token = AFD_TOKEN - self._user_id = AFD_USER_ID - self._url = "https://afdian.net/api/open/query-order" - - def _generate_signature(self): - data = { - "user_id": self._user_id, - "params": "{\"x\":0}", - "ts": int(time.time()), - } - sign_text = "{token}params{params}ts{ts}user_id{user_id}".format( - token=self._token, params=data['params'], ts=data["ts"], user_id=data["user_id"] - ) - - md5 = hashlib.md5(sign_text.encode("u8")) - md5 = md5.hexdigest() - data["sign"] = md5 - - return data - - def _get_afdian_status(self, trade_no: "str") -> "dict": - req_data = self._generate_signature() - data = requests.post(self._url, json=req_data).json() - # latest 50 - for order in data["data"]["list"]: - if order["out_trade_no"] == trade_no: - return order - - return {} - - def get_user_payment(self, trade_no: "str") -> ("int", "float", "str"): - order = self._get_afdian_status(trade_no) - amount = float(order.get("show_amount", 0)) - level = math.floor(amount / (MULTIPLY * USD2CNY)) - return level, amount, trade_no - - -def verify_payment(user_id, unique, client) -> "str": - if not ENABLE_VIP: - return "VIP is not enabled." - logging.info("Verifying payment for %s - %s", user_id, unique) - if "@" in unique: - pay = BuyMeACoffee() - else: - pay = Afdian() - - level, amount, pay_id = pay.get_user_payment(unique) - if amount == 0: - return f"You pay amount is {amount}. Did you input wrong order ID or email? " \ - f"Talk to @{OWNER} if you need any assistant." - if not level: - return f"You pay amount {amount} is below minimum ${MULTIPLY}. " \ - f"Talk to @{OWNER} if you need any assistant." - else: - vip = VIP() - ud = { - "user_id": user_id, - "username": client.get_chat(user_id).first_name, - "payment_amount": amount, - "payment_id": pay_id, - "level": level, - "quota": QUOTA * level * MULTIPLY - } - - message = vip.add_vip(ud) - return message - - -def subscribe_query(): - vip = VIP() - for cid, uid in vip.group_subscriber().items(): - has = vip.has_newer_update(cid) - if has: - print(f"{has} - {uid}") diff --git a/ytdlbot/migration.sql b/ytdlbot/migration.sql deleted file mode 100644 index 15fc385d..00000000 --- a/ytdlbot/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table settings - add mode varchar(32) default 'Celery' null; - diff --git a/ytdlbot/split-video.sh b/ytdlbot/split-video.sh deleted file mode 100755 index 5265ea19..00000000 --- a/ytdlbot/split-video.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -# Short script to split videos by filesize using ffmpeg by LukeLR - -if [ $# -ne 2 ]; then - echo 'Illegal number of parameters. Needs 2 parameters:' - echo 'Usage:' - echo './split-video.sh FILE SIZELIMIT "FFMPEG_ARGS' - echo - echo 'Parameters:' - echo ' - FILE: Name of the video file to split' - echo ' - SIZELIMIT: Maximum file size of each part (in bytes)' - echo ' - FFMPEG_ARGS: Additional arguments to pass to each ffmpeg-call' - echo ' (video format and quality options etc.)' - exit 1 -fi - -FILE="$1" -SIZELIMIT="$2" -FFMPEG_ARGS="$3" - -# Duration of the source video -DURATION=$(ffprobe -i "$FILE" -show_entries format=duration -v quiet -of default=noprint_wrappers=1:nokey=1|cut -d. -f1) - -# Duration that has been encoded so far -CUR_DURATION=0 - -# Filename of the source video (without extension) -BASENAME="${FILE%.*}" - -# Extension for the video parts -#EXTENSION="${FILE##*.}" -EXTENSION="mp4" - -# Number of the current video part -i=1 - -# Filename of the next video part -NEXTFILENAME="$BASENAME-$i.$EXTENSION" - -echo "Duration of source video: $DURATION" - -# Until the duration of all partial videos has reached the duration of the source video -while [[ $CUR_DURATION -lt $DURATION ]]; do - # Encode next part - echo ffmpeg -i "$FILE" -ss "$CUR_DURATION" -fs "$SIZELIMIT" $FFMPEG_ARGS "$NEXTFILENAME" - ffmpeg -ss "$CUR_DURATION" -i "$FILE" -fs "$SIZELIMIT" $FFMPEG_ARGS "$NEXTFILENAME" - - # Duration of the new part - NEW_DURATION=$(ffprobe -i "$NEXTFILENAME" -show_entries format=duration -v quiet -of default=noprint_wrappers=1:nokey=1|cut -d. -f1) - - # Total duration encoded so far - CUR_DURATION=$((CUR_DURATION + NEW_DURATION)) - - i=$((i + 1)) - - echo "Duration of $NEXTFILENAME: $NEW_DURATION" - echo "Part No. $i starts at $CUR_DURATION" - - NEXTFILENAME="$BASENAME-$i.$EXTENSION" -done \ No newline at end of file diff --git a/ytdlbot/tasks.py b/ytdlbot/tasks.py deleted file mode 100644 index 58ded731..00000000 --- a/ytdlbot/tasks.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - tasks.py -# 12/29/21 14:57 -# - -__author__ = "Benny " - -import logging -import os -import pathlib -import re -import subprocess -import tempfile -import threading -import time -import traceback -import typing -from hashlib import md5 -from urllib.parse import quote_plus - -import psutil -import requests -from apscheduler.schedulers.background import BackgroundScheduler -from celery import Celery -from celery.worker.control import Panel -from pyrogram import Client, idle -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message -from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor - -from client_init import create_app -from config import (ARCHIVE_ID, AUDIO_FORMAT, BROKER, ENABLE_CELERY, - ENABLE_VIP, TG_MAX_SIZE, WORKERS) -from constant import BotText -from db import Redis -from downloader import (edit_text, run_ffmpeg, sizeof_fmt, tqdm_progress, - upload_hook, ytdl_download) -from limit import VIP -from utils import (apply_log_formatter, auto_restart, customize_logger, - get_metadata, get_revision, get_user_settings) - -customize_logger(["pyrogram.client", "pyrogram.session.session", "pyrogram.connection.connection"]) -apply_log_formatter() -bot_text = BotText() -logging.getLogger('apscheduler.executors.default').propagate = False - -# celery -A tasks worker --loglevel=info --pool=solo -# app = Celery('celery', broker=BROKER, accept_content=['pickle'], task_serializer='pickle') -app = Celery('tasks', broker=BROKER) - -celery_client = create_app(":memory:") - - -def get_messages(chat_id, message_id): - try: - return celery_client.get_messages(chat_id, message_id) - except ConnectionError as e: - logging.critical("WTH!!! %s", e) - celery_client.start() - return celery_client.get_messages(chat_id, message_id) - - -@app.task() -def ytdl_download_task(chat_id, message_id, url): - logging.info("YouTube celery tasks started for %s", url) - bot_msg = get_messages(chat_id, message_id) - ytdl_normal_download(bot_msg, celery_client, url) - logging.info("YouTube celery tasks ended.") - - -@app.task() -def audio_task(chat_id, message_id): - logging.info("Audio celery tasks started for %s-%s", chat_id, message_id) - bot_msg = get_messages(chat_id, message_id) - normal_audio(bot_msg, celery_client) - logging.info("Audio celery tasks ended.") - - -def get_unique_clink(original_url, user_id): - settings = get_user_settings(str(user_id)) - clink = VIP().extract_canonical_link(original_url) - try: - unique = "{}?p={}{}".format(clink, *settings[1:]) - except IndexError: - unique = clink - return unique - - -@app.task() -def direct_download_task(chat_id, message_id, url): - logging.info("Direct download celery tasks started for %s", url) - bot_msg = get_messages(chat_id, message_id) - direct_normal_download(bot_msg, celery_client, url) - logging.info("Direct download celery tasks ended.") - - -def forward_video(url, client, bot_msg): - chat_id = bot_msg.chat.id - red = Redis() - vip = VIP() - unique = get_unique_clink(url, chat_id) - - cached_fid = red.get_send_cache(unique) - if not cached_fid: - return False - - try: - res_msg: "Message" = upload_processor(client, bot_msg, url, cached_fid) - if not res_msg: - raise ValueError("Failed to forward message") - obj = res_msg.document or res_msg.video or res_msg.audio - if ENABLE_VIP: - file_size = getattr(obj, "file_size", None) \ - or getattr(obj, "file_size", None) \ - or getattr(obj, "file_size", 10) - # TODO: forward file size may exceed the limit - vip.use_quota(chat_id, file_size) - caption, _ = gen_cap(bot_msg, url, obj) - res_msg.edit_text(caption, reply_markup=gen_video_markup()) - bot_msg.edit_text(f"Download success!✅✅✅") - red.update_metrics("cache_hit") - return True - - except Exception as e: - traceback.print_exc() - logging.error("Failed to forward message %s", e) - red.del_send_cache(unique) - red.update_metrics("cache_miss") - - -def ytdl_download_entrance(bot_msg, client, url): - chat_id = bot_msg.chat.id - if forward_video(url, client, bot_msg): - return - mode = get_user_settings(str(chat_id))[-1] - if ENABLE_CELERY and mode in [None, "Celery"]: - ytdl_download_task.delay(chat_id, bot_msg.message_id, url) - else: - ytdl_normal_download(bot_msg, client, url) - - -def direct_download_entrance(bot_msg, client, url): - if ENABLE_CELERY: - # TODO disable it for now - direct_normal_download(bot_msg, client, url) - # direct_download_task.delay(bot_msg.chat.id, bot_msg.message_id, url) - else: - direct_normal_download(bot_msg, client, url) - - -def audio_entrance(bot_msg, client): - if ENABLE_CELERY: - audio_task.delay(bot_msg.chat.id, bot_msg.message_id) - else: - normal_audio(bot_msg, client) - - -def direct_normal_download(bot_msg, client, url): - chat_id = bot_msg.chat.id - headers = { - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"} - vip = VIP() - length = 0 - if ENABLE_VIP: - remain, _, _ = vip.check_remaining_quota(chat_id) - try: - head_req = requests.head(url, headers=headers) - length = int(head_req.headers.get("content-length")) - except (TypeError, requests.exceptions.RequestException): - length = 0 - if remain < length: - bot_msg.reply_text(f"Sorry, you have reached your quota.\n") - return - - req = None - try: - req = requests.get(url, headers=headers, stream=True) - length = int(req.headers.get("content-length")) - filename = re.findall("filename=(.+)", req.headers.get("content-disposition"))[0] - except TypeError: - filename = getattr(req, "url", "").rsplit("/")[-1] - except Exception as e: - bot_msg.edit_text(f"Download failed!❌\n\n```{e}```", disable_web_page_preview=True) - return - - if not filename: - filename = quote_plus(url) - - with tempfile.TemporaryDirectory(prefix="ytdl-") as f: - filepath = f"{f}/{filename}" - # consume the req.content - downloaded = 0 - for chunk in req.iter_content(1024 * 1024): - text = tqdm_progress("Downloading...", length, downloaded) - edit_text(bot_msg, text) - with open(filepath, "ab") as fp: - fp.write(chunk) - downloaded += len(chunk) - logging.info("Downloaded file %s", filename) - st_size = os.stat(filepath).st_size - if ENABLE_VIP: - vip.use_quota(chat_id, st_size) - client.send_chat_action(chat_id, "upload_document") - client.send_document(bot_msg.chat.id, filepath, - caption=f"filesize: {sizeof_fmt(st_size)}", - progress=upload_hook, progress_args=(bot_msg,), - ) - bot_msg.edit_text("Download success!✅") - - -def normal_audio(bot_msg, client): - chat_id = bot_msg.chat.id - fn = getattr(bot_msg.video, "file_name", None) or getattr(bot_msg.document, "file_name", None) - status_msg = bot_msg.reply_text("Converting to audio...please wait patiently", quote=True) - with tempfile.TemporaryDirectory(prefix="ytdl-") as tmp: - logging.info("downloading to %s", tmp) - base_path = pathlib.Path(tmp) - video_path = base_path.joinpath(fn) - audio = base_path.joinpath(fn).with_suffix(f".{AUDIO_FORMAT}") - client.send_chat_action(chat_id, 'record_video_note') - status_msg.edit_text("Preparing your conversion....") - client.download_media(bot_msg, video_path) - logging.info("downloading complete %s", video_path) - # execute ffmpeg - client.send_chat_action(chat_id, 'record_audio') - try: - run_ffmpeg(["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "copy", audio], status_msg) - except subprocess.CalledProcessError: - # CPU consuming if re-encoding. - run_ffmpeg(["ffmpeg", "-y", "-i", video_path, audio], status_msg) - - status_msg.edit_text("Sending audio now...") - client.send_chat_action(chat_id, 'upload_audio') - client.send_audio(chat_id, audio) - status_msg.edit_text("✅ Conversion complete.") - Redis().update_metrics("audio_success") - - -def get_dl_source(): - worker_name = os.getenv("WORKER_NAME") - if worker_name: - return f"Downloaded by {worker_name}" - return "" - - -def upload_transfer_sh(bm, paths: list) -> "str": - d = {p.name: (md5(p.name.encode("utf8")).hexdigest() + p.suffix, p.open("rb")) for p in paths} - monitor = MultipartEncoderMonitor(MultipartEncoder(fields=d), lambda x: upload_hook(x.bytes_read, x.len, bm)) - headers = {'Content-Type': monitor.content_type} - try: - req = requests.post("https://transfer.sh", data=monitor, headers=headers) - bm.edit_text(f"Download success!✅") - return re.sub(r"https://", "\nhttps://", req.text) - except requests.exceptions.RequestException as e: - return f"Upload failed!❌\n\n```{e}```" - - -def ytdl_normal_download(bot_msg, client, url): - chat_id = bot_msg.chat.id - temp_dir = tempfile.TemporaryDirectory(prefix="ytdl-") - - result = ytdl_download(url, temp_dir.name, bot_msg) - logging.info("Download complete.") - if result["status"]: - client.send_chat_action(chat_id, 'upload_document') - video_paths = result["filepath"] - bot_msg.edit_text('Download complete. Sending now...') - for video_path in video_paths: - # normally there's only one video in that path... - st_size = os.stat(video_path).st_size - if st_size > TG_MAX_SIZE: - t = f"Your video({sizeof_fmt(st_size)}) is too large for Telegram. I'll upload it to transfer.sh" - bot_msg.edit_text(t) - client.send_chat_action(chat_id, 'upload_document') - client.send_message(chat_id, upload_transfer_sh(bot_msg, video_paths)) - return - upload_processor(client, bot_msg, url, video_path) - bot_msg.edit_text('Download success!✅') - else: - client.send_chat_action(chat_id, 'typing') - tb = result["error"][0:4000] - bot_msg.edit_text(f"Download failed!❌\n\n```{tb}```", disable_web_page_preview=True) - - temp_dir.cleanup() - - -def upload_processor(client, bot_msg, url, vp_or_fid: "typing.Any[str, pathlib.Path]"): - chat_id = bot_msg.chat.id - red = Redis() - markup = gen_video_markup() - cap, meta = gen_cap(bot_msg, url, vp_or_fid) - settings = get_user_settings(str(chat_id)) - if ARCHIVE_ID and isinstance(vp_or_fid, pathlib.Path): - chat_id = ARCHIVE_ID - if settings[2] == "document": - logging.info("Sending as document") - res_msg = client.send_document(chat_id, vp_or_fid, - caption=cap, - progress=upload_hook, progress_args=(bot_msg,), - reply_markup=markup, - thumb=meta["thumb"] - ) - elif settings[2] == "audio": - logging.info("Sending as audio") - res_msg = client.send_audio(chat_id, vp_or_fid, - caption=cap, - progress=upload_hook, progress_args=(bot_msg,), - ) - else: - logging.info("Sending as video") - res_msg = client.send_video(chat_id, vp_or_fid, - supports_streaming=True, - caption=cap, - progress=upload_hook, progress_args=(bot_msg,), - reply_markup=markup, - **meta - ) - unique = get_unique_clink(url, bot_msg.chat.id) - obj = res_msg.document or res_msg.video or res_msg.audio - red.add_send_cache(unique, getattr(obj, "file_id", None)) - red.update_metrics("video_success") - if ARCHIVE_ID and isinstance(vp_or_fid, pathlib.Path): - client.forward_messages(bot_msg.chat.id, ARCHIVE_ID, res_msg.message_id) - return res_msg - - -def gen_cap(bm, url, video_path): - chat_id = bm.chat.id - user = bm.chat - if user is None: - user_info = "" - else: - user_info = "@{}({})-{}".format( - user.username or "N/A", - user.first_name or "" + user.last_name or "", - user.id - ) - - if isinstance(video_path, pathlib.Path): - meta = get_metadata(video_path) - file_name = video_path.name - file_size = sizeof_fmt(os.stat(video_path).st_size) - else: - file_name = getattr(video_path, "file_name", "") - file_size = sizeof_fmt(getattr(video_path, "file_size", (2 << 6) - (2 << 4) - (2 << 2) + (0 ^ 1) + (2 << 5))) - meta = dict( - width=getattr(video_path, "width", 0), - height=getattr(video_path, "height", 0), - duration=getattr(video_path, "duration", 0), - thumb=getattr(video_path, "thumb", None), - ) - remain = bot_text.remaining_quota_caption(chat_id) - worker = get_dl_source() - cap = f"{user_info}\n`{file_name}`\n\n{url}\n\nInfo: {meta['width']}x{meta['height']} {file_size}\t" \ - f"{meta['duration']}s\n{remain}\n{worker}\n{bot_text.custom_text}" - return cap, meta - - -def gen_video_markup(): - markup = InlineKeyboardMarkup( - [ - [ # First row - InlineKeyboardButton( # Generates a callback query when pressed - f"convert to audio({AUDIO_FORMAT})", - callback_data="convert" - ) - ] - ] - ) - return markup - - -@Panel.register -def ping_revision(*args): - return get_revision() - - -@Panel.register -def hot_patch(*args): - app_path = pathlib.Path().cwd().parent - logging.info("Hot patching on path %s...", app_path) - - apk_install = "xargs apk add < apk.txt" - pip_install = "pip install -r requirements.txt" - unset = "git config --unset http.https://github.com/.extraheader" - pull_unshallow = "git pull origin --unshallow" - pull = "git pull" - - subprocess.call(unset, shell=True, cwd=app_path) - if subprocess.call(pull_unshallow, shell=True, cwd=app_path) != 0: - logging.info("Already unshallow, pulling now...") - subprocess.call(pull, shell=True, cwd=app_path) - - logging.info("Code is updated, applying hot patch now...") - subprocess.call(apk_install, shell=True, cwd=app_path) - subprocess.call(pip_install, shell=True, cwd=app_path) - psutil.Process().kill() - - -def run_celery(): - argv = [ - "-A", "tasks", 'worker', '--loglevel=info', - "--pool=threads", f"--concurrency={WORKERS}", - "-n", os.getenv("WORKER_NAME", "") - ] - app.worker_main(argv) - - -if __name__ == '__main__': - celery_client.start() - print("Bootstrapping Celery worker now.....") - time.sleep(5) - threading.Thread(target=run_celery, daemon=True).start() - - scheduler = BackgroundScheduler(timezone="Asia/Shanghai") - scheduler.add_job(auto_restart, 'interval', seconds=5) - scheduler.start() - - idle() - celery_client.stop() diff --git a/ytdlbot/utils.py b/ytdlbot/utils.py deleted file mode 100644 index 90c20546..00000000 --- a/ytdlbot/utils.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - utils.py -# 9/1/21 22:50 -# - -__author__ = "Benny " - -import contextlib -import inspect as pyinspect -import logging -import os -import pathlib -import shutil -import subprocess -import tempfile -import time -import uuid - -import ffmpeg -import psutil - -from config import ENABLE_CELERY -from db import MySQL -from flower_tasks import app - -inspect = app.control.inspect() - - -def apply_log_formatter(): - logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s %(filename)s:%(lineno)d %(levelname).1s] %(message)s', - datefmt="%Y-%m-%d %H:%M:%S" - ) - - -def customize_logger(logger: "list"): - apply_log_formatter() - for log in logger: - logging.getLogger(log).setLevel(level=logging.INFO) - - -def get_user_settings(user_id: "str") -> "tuple": - db = MySQL() - cur = db.cur - cur.execute("SELECT * FROM settings WHERE user_id = %s", (user_id,)) - data = cur.fetchone() - if data is None: - return 100, "high", "video", "Celery" - return data - - -def set_user_settings(user_id: int, field: "str", value: "str"): - db = MySQL() - cur = db.cur - cur.execute("SELECT * FROM settings WHERE user_id = %s", (user_id,)) - data = cur.fetchone() - if data is None: - resolution = method = "" - if field == "resolution": - method = "video" - resolution = value - if field == "method": - method = value - resolution = "high" - cur.execute("INSERT INTO settings VALUES (%s,%s,%s,%s)", (user_id, resolution, method, "Celery")) - else: - cur.execute(f"UPDATE settings SET {field} =%s WHERE user_id = %s", (value, user_id)) - db.con.commit() - - -def is_youtube(url: "str"): - if url.startswith("https://www.youtube.com/") or url.startswith("https://youtu.be/"): - return True - - -def adjust_formats(user_id: "str", url: "str", formats: "list"): - # high: best quality, 720P, 1080P, 2K, 4K, 8K - # medium: 480P - # low: 360P+240P - mapping = {"high": [], "medium": [480], "low": [240, 360]} - settings = get_user_settings(user_id) - if settings and is_youtube(url): - for m in mapping.get(settings[1], []): - formats.insert(0, f"bestvideo[ext=mp4][height={m}]+bestaudio[ext=m4a]") - formats.insert(1, f"bestvideo[vcodec^=avc][height={m}]+bestaudio[acodec^=mp4a]/best[vcodec^=avc]/best") - - if settings[2] == "audio": - formats.insert(0, "bestaudio[ext=m4a]") - - -def get_metadata(video_path): - width, height, duration = 1280, 720, 0 - try: - video_streams = ffmpeg.probe(video_path, select_streams="v") - for item in video_streams.get("streams", []): - height = item["height"] - width = item["width"] - duration = int(float(video_streams["format"]["duration"])) - except Exception as e: - logging.error(e) - try: - thumb = pathlib.Path(video_path).parent.joinpath(f"{uuid.uuid4().hex}-thunmnail.png").as_posix() - ffmpeg.input(video_path, ss=duration / 2).filter('scale', width, -1).output(thumb, vframes=1).run() - except ffmpeg._run.Error: - thumb = None - - return dict(height=height, width=width, duration=duration, thumb=thumb) - - -def current_time(ts=None): - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) - - -def get_revision(): - with contextlib.suppress(subprocess.SubprocessError): - return subprocess.check_output("git -C ../ rev-parse --short HEAD".split()).decode("u8").replace("\n", "") - return "unknown" - - -def get_func_queue(func) -> int: - try: - count = 0 - data = getattr(inspect, func)() or {} - for _, task in data.items(): - count += len(task) - return count - except Exception: - return 0 - - -def tail(f, lines=1, _buffer=4098): - """Tail a file and get X lines from the end""" - # place holder for the lines found - lines_found = [] - - # block counter will be multiplied by buffer - # to get the block size from the end - block_counter = -1 - - # loop until we find X lines - while len(lines_found) < lines: - try: - f.seek(block_counter * _buffer, os.SEEK_END) - except IOError: # either file is too small, or too many lines requested - f.seek(0) - lines_found = f.readlines() - break - - lines_found = f.readlines() - - # we found enough lines, get out - # Removed this line because it was redundant the while will catch - # it, I left it for history - # if len(lines_found) > lines: - # break - - # decrement the block counter to get the - # next X bytes - block_counter -= 1 - - return lines_found[-lines:] - - -class Detector: - def __init__(self, logs: "str"): - self.logs = logs - - @staticmethod - def func_name(): - with contextlib.suppress(Exception): - return pyinspect.stack()[1][3] - return "N/A" - - def updates_too_long_detector(self): - # If you're seeing this, that means you have logged more than 10 device - # and the earliest account was kicked out. Restart the program could get you back in. - indicators = [ - "types.UpdatesTooLong", - "Got shutdown from remote", - "Code is updated", - 'Retrying "messages.GetMessages"', - "OSError: Connection lost", - "[Errno -3] Try again" - ] - for indicator in indicators: - if indicator in self.logs: - logging.warning("Potential crash detected by %s, it's time to commit suicide...", self.func_name()) - return True - logging.debug("No crash detected.") - - def next_salt_detector(self): - text = "Next salt in" - if self.logs.count(text) >= 4: - logging.warning("Potential crash detected by %s, it's time to commit suicide...", self.func_name()) - return True - - def idle_detector(self): - mtime = os.stat("/var/log/ytdl.log").st_mtime - cur_ts = time.time() - if cur_ts - mtime > 300: - logging.warning("Potential crash detected by %s, it's time to commit suicide...", self.func_name()) - return True - - -def auto_restart(): - log_path = "/var/log/ytdl.log" - if not os.path.exists(log_path): - return - with open(log_path) as f: - logs = "".join(tail(f, lines=10)) - - det = Detector(logs) - method_list = [getattr(det, func) for func in dir(det) if func.endswith("_detector")] - for method in method_list: - if method(): - logging.critical("Bye bye world!☠️") - for item in pathlib.Path(tempfile.gettempdir()).glob("ytdl-*"): - shutil.rmtree(item, ignore_errors=True) - - psutil.Process().kill() - - -if __name__ == '__main__': - auto_restart() diff --git a/ytdlbot/ytdl_bot.py b/ytdlbot/ytdl_bot.py deleted file mode 100644 index ef4e523c..00000000 --- a/ytdlbot/ytdl_bot.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/local/bin/python3 -# coding: utf-8 - -# ytdlbot - new.py -# 8/14/21 14:37 -# - -__author__ = "Benny " - -import logging -import os -import random -import re -import time -import traceback -import typing -from io import BytesIO - -import pyrogram.errors -from apscheduler.schedulers.background import BackgroundScheduler -from pyrogram import Client, filters, types -from pyrogram.errors.exceptions.bad_request_400 import UserNotParticipant -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from tgbot_ping import get_runtime - -from client_init import create_app -from config import (AUTHORIZED_USER, ENABLE_CELERY, ENABLE_VIP, OWNER, - REQUIRED_MEMBERSHIP) -from constant import BotText -from db import InfluxDB, MySQL, Redis -from limit import VIP, verify_payment -from tasks import app as celery_app -from tasks import (audio_entrance, direct_download_entrance, hot_patch, - ytdl_download_entrance) -from utils import (auto_restart, customize_logger, get_revision, - get_user_settings, set_user_settings) - -customize_logger(["pyrogram.client", "pyrogram.session.session", "pyrogram.connection.connection"]) -logging.getLogger('apscheduler.executors.default').propagate = False - -app = create_app() -bot_text = BotText() - -logging.info("Authorized users are %s", AUTHORIZED_USER) - - -def private_use(func): - def wrapper(client: "Client", message: "types.Message"): - chat_id = getattr(message.from_user, "id", None) - - # message type check - if message.chat.type != "private" and not message.text.lower().startswith("/ytdl"): - logging.warning("%s, it's annoying me...🙄️ ", message.text) - return - - # authorized users check - if AUTHORIZED_USER: - users = [int(i) for i in AUTHORIZED_USER.split(",")] - else: - users = [] - - if users and chat_id and chat_id not in users: - message.reply_text(bot_text.private, quote=True) - return - - # membership check - if REQUIRED_MEMBERSHIP: - try: - app.get_chat_member(REQUIRED_MEMBERSHIP, chat_id) - logging.info("user %s check passed for group/channel %s.", chat_id, REQUIRED_MEMBERSHIP) - except UserNotParticipant: - logging.warning("user %s is not a member of group/channel %s", chat_id, REQUIRED_MEMBERSHIP) - message.reply_text(bot_text.membership_require, quote=True) - return - - return func(client, message) - - return wrapper - - -@app.on_message(filters.command(["start"])) -def start_handler(client: "Client", message: "types.Message"): - from_id = message.from_user.id - logging.info("Welcome to youtube-dl bot!") - client.send_chat_action(from_id, "typing") - greeting = bot_text.get_vip_greeting(from_id) - quota = bot_text.remaining_quota_caption(from_id) - custom_text = bot_text.custom_text - text = f"{greeting}{bot_text.start}\n\n{quota}\n{custom_text}" - - client.send_message(message.chat.id, text) - - -@app.on_message(filters.command(["help"])) -def help_handler(client: "Client", message: "types.Message"): - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - client.send_message(chat_id, bot_text.help, disable_web_page_preview=True) - - -@app.on_message(filters.command(["sub"])) -def subscribe_handler(client: "Client", message: "types.Message"): - vip = VIP() - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - if message.text == "/sub": - result = vip.get_user_subscription(chat_id) - else: - link = message.text.split()[1] - try: - result = vip.subscribe_channel(chat_id, link) - except (IndexError, ValueError): - result = f"Error: \n{traceback.format_exc()}" - client.send_message(chat_id, result or "You have no subscription.", disable_web_page_preview=True) - - -@app.on_message(filters.command(["unsub"])) -def unsubscribe_handler(client: "Client", message: "types.Message"): - vip = VIP() - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - text = message.text.split(" ") - if len(text) == 1: - client.send_message(chat_id, "/unsubscribe channel_id", disable_web_page_preview=True) - return - - rows = vip.unsubscribe_channel(chat_id, text[1]) - if rows: - text = f"Unsubscribed from {text[1]}" - else: - text = "Unable to find the channel." - client.send_message(chat_id, text, disable_web_page_preview=True) - - -@app.on_message(filters.command(["patch"])) -def patch_handler(client: "Client", message: "types.Message"): - username = message.from_user.username - chat_id = message.chat.id - if username == OWNER: - celery_app.control.broadcast("hot_patch") - client.send_chat_action(chat_id, "typing") - client.send_message(chat_id, "Oorah!") - hot_patch() - - -@app.on_message(filters.command(["ping"])) -def ping_handler(client: "Client", message: "types.Message"): - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - if os.uname().sysname == "Darwin" or ".heroku" in os.getenv("PYTHONHOME", ""): - bot_info = "ping unavailable." - else: - bot_info = get_runtime("ytdlbot_ytdl_1", "YouTube-dl") - if message.chat.username == OWNER: - stats = bot_text.ping_worker() - client.send_document(chat_id, Redis().generate_file(), caption=f"{bot_info}\n\n{stats}") - else: - client.send_message(chat_id, f"{bot_info}") - - -@app.on_message(filters.command(["about"])) -def help_handler(client: "Client", message: "types.Message"): - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - client.send_message(chat_id, bot_text.about) - - -@app.on_message(filters.command(["terms"])) -def terms_handler(client: "Client", message: "types.Message"): - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - client.send_message(chat_id, bot_text.terms) - - -@app.on_message(filters.command(["sub_count"])) -def sub_count_handler(client: "Client", message: "types.Message"): - username = message.from_user.username - chat_id = message.chat.id - if username == OWNER: - with BytesIO() as f: - f.write(VIP().sub_count().encode("u8")) - f.name = "subscription count.txt" - client.send_document(chat_id, f) - - -@app.on_message(filters.command(["direct"])) -def direct_handler(client: "Client", message: "types.Message"): - chat_id = message.from_user.id - client.send_chat_action(chat_id, "typing") - url = re.sub(r'/direct\s*', '', message.text) - logging.info("direct start %s", url) - if not re.findall(r"^https?://", url.lower()): - Redis().update_metrics("bad_request") - message.reply_text("Send me a DIRECT LINK.", quote=True) - return - - bot_msg = message.reply_text("Request received.", quote=True) - Redis().update_metrics("direct_request") - direct_download_entrance(bot_msg, client, url) - - -@app.on_message(filters.command(["settings"])) -def settings_handler(client: "Client", message: "types.Message"): - chat_id = message.chat.id - client.send_chat_action(chat_id, "typing") - data = get_user_settings(str(chat_id)) - set_mode = (data[-1]) - text = {"Local": "Celery", "Celery": "Local"}.get(set_mode, "Local") - mode_text = f"Download mode: **{set_mode}**" - if message.chat.username == OWNER: - extra = [InlineKeyboardButton(f"Change download mode to {text}", callback_data=text)] - else: - extra = [] - - markup = InlineKeyboardMarkup( - [ - [ # First row - InlineKeyboardButton("send as document", callback_data="document"), - InlineKeyboardButton("send as video", callback_data="video"), - InlineKeyboardButton("send as audio", callback_data="audio") - ], - [ # second row - InlineKeyboardButton("High Quality", callback_data="high"), - InlineKeyboardButton("Medium Quality", callback_data="medium"), - InlineKeyboardButton("Low Quality", callback_data="low"), - ], - extra - ] - ) - - client.send_message(chat_id, bot_text.settings.format(data[1], data[2]) + mode_text, reply_markup=markup) - - -@app.on_message(filters.command(["vip"])) -def vip_handler(client: "Client", message: "types.Message"): - # process as chat.id, not from_user.id - chat_id = message.chat.id - text = message.text.strip() - client.send_chat_action(chat_id, "typing") - if text == "/vip": - client.send_message(chat_id, bot_text.vip, disable_web_page_preview=True) - else: - bm: typing.Union["types.Message", "typing.Any"] = message.reply_text(bot_text.vip_pay, quote=True) - unique = text.replace("/vip", "").strip() - msg = verify_payment(chat_id, unique, client) - bm.edit_text(msg) - - -@app.on_message(filters.incoming & filters.text) -@private_use -def download_handler(client: "Client", message: "types.Message"): - # check remaining quota - red = Redis() - chat_id = message.from_user.id - client.send_chat_action(chat_id, 'typing') - red.user_count(chat_id) - - url = re.sub(r'/ytdl\s*', '', message.text) - logging.info("start %s", url) - - if not re.findall(r"^https?://", url.lower()): - red.update_metrics("bad_request") - message.reply_text("I think you should send me a link.", quote=True) - return - - if re.findall(r"^https://www\.youtube\.com/channel/", VIP.extract_canonical_link(url)): - message.reply_text("Channel download is disabled now. Please send me individual video link.", quote=True) - red.update_metrics("reject_channel") - return - - red.update_metrics("video_request") - text = bot_text.get_receive_link_text() - try: - # raise pyrogram.errors.exceptions.FloodWait(10) - bot_msg: typing.Union["types.Message", "typing.Any"] = message.reply_text(text, quote=True) - except pyrogram.errors.Flood as e: - f = BytesIO() - f.write(str(e).encode()) - f.write(b"Your job will be done soon. Just wait! Don't rush.") - f.name = "Please don't flood me.txt" - bot_msg = message.reply_document(f, caption=f"Flood wait! Please wait {e.x} seconds...." - f"Your job will start automatically", quote=True) - f.close() - client.send_message(OWNER, f"Flood wait! 🙁 {e.x} seconds....") - time.sleep(e.x) - - client.send_chat_action(chat_id, 'upload_video') - bot_msg.chat = message.chat - ytdl_download_entrance(bot_msg, client, url) - - -@app.on_callback_query(filters.regex(r"document|video|audio")) -def send_method_callback(client: "Client", callback_query: types.CallbackQuery): - chat_id = callback_query.message.chat.id - data = callback_query.data - logging.info("Setting %s file type to %s", chat_id, data) - set_user_settings(chat_id, "method", data) - callback_query.answer(f"Your send type was set to {callback_query.data}") - - -@app.on_callback_query(filters.regex(r"high|medium|low")) -def download_resolution_callback(client: "Client", callback_query: types.CallbackQuery): - chat_id = callback_query.message.chat.id - data = callback_query.data - logging.info("Setting %s file type to %s", chat_id, data) - set_user_settings(chat_id, "resolution", data) - callback_query.answer(f"Your default download quality was set to {callback_query.data}") - - -@app.on_callback_query(filters.regex(r"convert")) -def audio_callback(client: "Client", callback_query: types.CallbackQuery): - callback_query.answer(f"Converting to audio...please wait patiently") - Redis().update_metrics("audio_request") - - vmsg = callback_query.message - audio_entrance(vmsg, client) - - -@app.on_callback_query(filters.regex(r"Local|Celery")) -def owner_local_callback(client: "Client", callback_query: types.CallbackQuery): - chat_id = callback_query.message.chat.id - set_user_settings(chat_id, "mode", callback_query.data) - callback_query.answer(f"Download mode was changed to {callback_query.data}") - - -def periodic_sub_check(): - vip = VIP() - for cid, uids in vip.group_subscriber().items(): - video_url = vip.has_newer_update(cid) - if video_url: - logging.info(f"periodic update:{video_url} - {uids}") - for uid in uids: - bot_msg = app.send_message(uid, f"{video_url} is downloading...", disable_web_page_preview=True) - ytdl_download_entrance(bot_msg, app, video_url) - time.sleep(random.random()) - - -if __name__ == '__main__': - MySQL() - scheduler = BackgroundScheduler(timezone="Asia/Shanghai", job_defaults={'max_instances': 5}) - scheduler.add_job(Redis().reset_today, 'cron', hour=0, minute=0) - scheduler.add_job(auto_restart, 'interval', seconds=5) - scheduler.add_job(InfluxDB().collect_data, 'interval', seconds=60) - # default quota allocation of 10,000 units per day, - scheduler.add_job(periodic_sub_check, 'interval', seconds=60 * 30) - scheduler.start() - banner = f""" -▌ ▌ ▀▛▘ ▌ ▛▀▖ ▜ ▌ -▝▞ ▞▀▖ ▌ ▌ ▌ ▌ ▌ ▛▀▖ ▞▀▖ ▌ ▌ ▞▀▖ ▌ ▌ ▛▀▖ ▐ ▞▀▖ ▝▀▖ ▞▀▌ - ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▛▀ ▌ ▌ ▌ ▌ ▐▐▐ ▌ ▌ ▐ ▌ ▌ ▞▀▌ ▌ ▌ - ▘ ▝▀ ▝▀▘ ▘ ▝▀▘ ▀▀ ▝▀▘ ▀▀ ▝▀ ▘▘ ▘ ▘ ▘ ▝▀ ▝▀▘ ▝▀▘ - -By @BennyThink, VIP mode: {ENABLE_VIP}, Distribution: {ENABLE_CELERY} -Version: {get_revision()} - """ - print(banner) - app.run()