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..29a3e1f0 100644 --- a/README.md +++ b/README.md @@ -1,264 +1,205 @@ # ytdlbot [![docker image](https://github.com/tgbot-collection/ytdlbot/actions/workflows/builder.yaml/badge.svg)](https://github.com/tgbot-collection/ytdlbot/actions/workflows/builder.yaml) +[![pdm-managed](https://img.shields.io/endpoint?url=https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fpdm-project%2F.github%2Fbadge.json)](https://pdm-project.org) -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..b0f61701 --- /dev/null +++ b/pdm.lock @@ -0,0 +1,1226 @@ +# 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:b9114dfe375fa024dcf531c0f3eebea8347b65caaa28e53b78c2cbe093fcbd0d" + +[[metadata.targets]] +requires_python = ">=3.10" + +[[package]] +name = "apscheduler" +version = "3.11.2" +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.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[[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.14.3" +requires_python = ">=3.7.0" +summary = "Screen-scraping library" +groups = ["default"] +dependencies = [ + "soupsieve>=1.6.1", + "typing-extensions>=4.0.0", +] +files = [ + {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, + {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, +] + +[[package]] +name = "black" +version = "26.1.0" +requires_python = ">=3.10" +summary = "The uncompromising code formatter." +groups = ["default"] +dependencies = [ + "click>=8.0.0", + "mypy-extensions>=0.4.3", + "packaging>=22.0", + "pathspec>=1.0.0", + "platformdirs>=2", + "pytokens>=0.3.0", + "tomli>=1.1.0; python_version < \"3.11\"", + "typing-extensions>=4.0.1; python_version < \"3.11\"", +] +files = [ + {file = "black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168"}, + {file = "black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d"}, + {file = "black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0"}, + {file = "black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24"}, + {file = "black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89"}, + {file = "black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5"}, + {file = "black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68"}, + {file = "black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14"}, + {file = "black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c"}, + {file = "black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4"}, + {file = "black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f"}, + {file = "black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6"}, + {file = "black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a"}, + {file = "black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791"}, + {file = "black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954"}, + {file = "black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304"}, + {file = "black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9"}, + {file = "black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b"}, + {file = "black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b"}, + {file = "black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca"}, + {file = "black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115"}, + {file = "black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79"}, + {file = "black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af"}, + {file = "black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f"}, + {file = "black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0"}, + {file = "black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede"}, + {file = "black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58"}, +] + +[[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 = "2.0.0" +requires_python = ">=3.9" +summary = "Foreign Function Interface for Python calling C code." +groups = ["default"] +dependencies = [ + "pycparser; implementation_name != \"PyPy\"", +] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[[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 = "46.0.4" +requires_python = "!=3.9.0,!=3.9.1,>=3.8" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +groups = ["default"] +dependencies = [ + "cffi>=1.14; python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"", + "cffi>=2.0.0; python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", + "typing-extensions>=4.13.2; python_full_version < \"3.11\"", +] +files = [ + {file = "cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32"}, + {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616"}, + {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0"}, + {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0"}, + {file = "cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5"}, + {file = "cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b"}, + {file = "cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e"}, + {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f"}, + {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82"}, + {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c"}, + {file = "cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061"}, + {file = "cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7"}, + {file = "cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b"}, + {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019"}, + {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4"}, + {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b"}, + {file = "cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc"}, + {file = "cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947"}, + {file = "cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3"}, + {file = "cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59"}, +] + +[[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.33.0" +requires_python = ">=3.7" +summary = "Python implementation of redis API, can be used for testing purposes." +groups = ["default"] +dependencies = [ + "redis<7.1.0; python_version < \"3.10\"", + "redis>=4.3; python_version > \"3.8\"", + "redis>=4; python_version < \"3.8\"", + "sortedcontainers>=2", + "typing-extensions~=4.7; python_version < \"3.11\"", +] +files = [ + {file = "fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965"}, + {file = "fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770"}, +] + +[[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.3.1" +requires_python = ">=3.10" +summary = "Lightweight in-process concurrent programming" +groups = ["default"] +files = [ + {file = "greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe"}, + {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729"}, + {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4"}, + {file = "greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8"}, + {file = "greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2"}, + {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9"}, + {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f"}, + {file = "greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b"}, + {file = "greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4"}, + {file = "greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336"}, + {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1"}, + {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149"}, + {file = "greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a"}, + {file = "greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1"}, + {file = "greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3"}, + {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951"}, + {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2"}, + {file = "greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946"}, + {file = "greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d"}, + {file = "greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f"}, + {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683"}, + {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1"}, + {file = "greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a"}, + {file = "greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79"}, + {file = "greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2"}, + {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53"}, + {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249"}, + {file = "greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451"}, + {file = "greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98"}, +] + +[[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.18" +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.18-py3-none-any.whl", hash = "sha256:18aa4f70fb6f74f5763248c0d13ed3b5a999081fec980ae2f9b6483c51171bb2"}, + {file = "kurigram-2.2.18.tar.gz", hash = "sha256:f0a47163fde696f8558356da5955e8015ad5da6cf67063e251995a250e5a79c0"}, +] + +[[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 = "1.0.4" +requires_python = ">=3.9" +summary = "Utility library for gitignore style pattern matching of file paths." +groups = ["default"] +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[[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 = "7.2.2" +requires_python = ">=3.6" +summary = "Cross-platform lib for process and system monitoring." +groups = ["default"] +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[[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"] +marker = "implementation_name != \"PyPy\"" +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.2" +requires_python = ">=3.8" +summary = "Pure Python MySQL Driver" +groups = ["default"] +files = [ + {file = "pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9"}, + {file = "pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03"}, +] + +[[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.2.1" +requires_python = ">=3.9" +summary = "Read key-value pairs from a .env file and set them as environment variables" +groups = ["default"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +requires_python = ">=3.8" +summary = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +groups = ["default"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[[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.5" +requires_python = ">=3.9" +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.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[[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.46" +requires_python = ">=3.7" +summary = "Database Abstraction Library" +groups = ["default"] +dependencies = [ + "greenlet>=1; 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\"", + "importlib-metadata; python_version < \"3.8\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "sqlalchemy-2.0.46-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:895296687ad06dc9b11a024cf68e8d9d3943aa0b4964278d2553b86f1b267735"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab65cb2885a9f80f979b85aa4e9c9165a31381ca322cbde7c638fe6eefd1ec39"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52fe29b3817bd191cc20bad564237c808967972c97fa683c04b28ec8979ae36f"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:09168817d6c19954d3b7655da6ba87fcb3a62bb575fb396a81a8b6a9fadfe8b5"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be6c0466b4c25b44c5d82b0426b5501de3c424d7a3220e86cd32f319ba56798e"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-win32.whl", hash = "sha256:1bc3f601f0a818d27bfe139f6766487d9c88502062a2cd3a7ee6c342e81d5047"}, + {file = "sqlalchemy-2.0.46-cp310-cp310-win_amd64.whl", hash = "sha256:e0c05aff5c6b1bb5fb46a87e0f9d2f733f83ef6cbbbcd5c642b6c01678268061"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d"}, + {file = "sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb"}, + {file = "sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f"}, + {file = "sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef"}, + {file = "sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10"}, + {file = "sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764"}, + {file = "sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b"}, + {file = "sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908"}, + {file = "sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b"}, + {file = "sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa"}, + {file = "sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863"}, + {file = "sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede"}, + {file = "sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330"}, + {file = "sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e"}, + {file = "sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7"}, +] + +[[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.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.2" +requires_python = ">=3.7" +summary = "Fast, Extensible Progress Meter" +groups = ["default"] +dependencies = [ + "colorama; platform_system == \"Windows\"", +] +files = [ + {file = "tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7"}, + {file = "tqdm-4.67.2.tar.gz", hash = "sha256:649aac53964b2cb8dec76a14b405a4c0d13612cb8933aae547dd144eacc99653"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[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 = "2026.1.31" +requires_python = ">=3.10" +summary = "A feature-rich command-line audio/video downloader" +groups = ["default"] +files = [ + {file = "yt_dlp-2026.1.31-py3-none-any.whl", hash = "sha256:81f8e70c0d111b9572420cfea0ba9b4c2ae783fa1a4237fa767eeb777d4c5a58"}, + {file = "yt_dlp-2026.1.31.tar.gz", hash = "sha256:16767a3172cbb7183199f4db19e34b77b19e030ab7101b8f26d6c9e6af6f42ae"}, +] + +[[package]] +name = "yt-dlp-ejs" +version = "0.4.0" +requires_python = ">=3.10" +summary = "External JavaScript for yt-dlp supporting many runtimes" +groups = ["default"] +files = [ + {file = "yt_dlp_ejs-0.4.0-py3-none-any.whl", hash = "sha256:19278cff397b243074df46342bb7616c404296aeaff01986b62b4e21823b0b9c"}, + {file = "yt_dlp_ejs-0.4.0.tar.gz", hash = "sha256:3c67e0beb6f9f3603fbcb56f425eabaa37c52243d90d20ccbcce1dd941cfbd07"}, +] + +[[package]] +name = "yt-dlp" +version = "2026.1.31" +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.15,>=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.4.0", + "yt-dlp==2026.1.31", +] +files = [ + {file = "yt_dlp-2026.1.31-py3-none-any.whl", hash = "sha256:81f8e70c0d111b9572420cfea0ba9b4c2ae783fa1a4237fa767eeb777d4c5a58"}, + {file = "yt_dlp-2026.1.31.tar.gz", hash = "sha256:16767a3172cbb7183199f4db19e34b77b19e030ab7101b8f26d6c9e6af6f42ae"}, +] 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..5aefbb20 --- /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]==2026.1.31", "APScheduler>=3.11.2", "ffmpeg-python>=0.2.0", "PyMySQL>=1.1.1", "filetype>=1.2.0", "beautifulsoup4>=4.14.3", "fakeredis>=2.33.0", "redis==6.4.0", "requests>=2.32.5", "tqdm==4.67.2", "token-bucket>=0.3.0", "python-dotenv>=1.0.1", "black>=24.10.0", "sqlalchemy>=2.0.36", "psutil==7.2.2", "ffpb>=0.4.1", "kurigram==2.2.18", "cryptography>=46.0.4", "greenlet==3.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..54783940 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,19 @@ -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.14.3 +fakeredis>=2.33.0 +redis==6.4.0 +requests>=2.32.5 +tqdm>=4.67.2 +token-bucket>=0.3.0 +python-dotenv>=1.0.1 +black>=24.10.0 +sqlalchemy>=2.0.36 +psutil>=7.2.2 +ffpb>=0.4.1 +cryptography>=46.0.4 +kurigram==2.2.18 +yt-dlp[default,curl-cffi]==2026.1.31 \ 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()