├── .bumpversion.cfg ├── .codeclimate.yml ├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── docker.yml │ ├── pip-rating.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── _static │ └── logo.png ├── authors.rst ├── benchmark_2.0_GiB.png ├── benchmark_2.0_GiB.rst ├── benchmark_20.0_MiB.png ├── benchmark_20.0_MiB.rst ├── benchmark_200.0_MiB.png ├── benchmark_200.0_MiB.rst ├── benchmark_512.0_KiB.png ├── benchmark_512.0_KiB.rst ├── benchmark_full.rst ├── caption_format.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── readme.rst ├── supported_file_types.py ├── supported_file_types.rst ├── troubleshooting.rst ├── upload_benchmark.json ├── upload_benchmark.py ├── upload_benchmark.rst └── usage.rst ├── logo.png ├── logo.xcf ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── telegram-upload-demo.gif ├── telegram_upload ├── __init__.py ├── _compat.py ├── caption_formatter.py ├── cli.py ├── client │ ├── __init__.py │ ├── progress_bar.py │ ├── telegram_download_client.py │ ├── telegram_manager_client.py │ └── telegram_upload_client.py ├── config.py ├── download_files.py ├── exceptions.py ├── management.py ├── upload_files.py ├── utils.py └── video.py ├── tests ├── __init__.py ├── _compat.py ├── test_caption_formatter.py ├── test_cli.py ├── test_client │ ├── __init__.py │ ├── test_progress_bar.py │ ├── test_telegram_download_client.py │ ├── test_telegram_manager_client.py │ └── test_telegram_upload_client.py ├── test_config.py ├── test_download_files.py ├── test_exceptions.py ├── test_files.py ├── test_management.py ├── test_upload_files.py ├── test_utils.py └── test_video.py ├── tox.ini └── travis_pypi_setup.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.7.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:telegram_upload/__init__.py] 7 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Python: true 3 | exclude_paths: 4 | - "setup.py" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !telegram_upload 3 | !requirements.txt 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * telegram-upload version: 2 | * Python version: 3 | * Operating System: 4 | * Dependencies list (run `pip freeze`): 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Publish Docker image 7 | 8 | on: 9 | release: 10 | types: [published] 11 | 12 | jobs: 13 | push_to_registry: 14 | name: Push Docker image to Docker Hub 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v3 22 | with: 23 | images: | 24 | nekmo/telegram-upload 25 | tags: | 26 | type=schedule 27 | type=ref,event=branch 28 | type=ref,event=pr 29 | type=semver,pattern={{version}} 30 | type=semver,pattern={{major}}.{{minor}} 31 | type=semver,pattern={{major}} 32 | type=sha 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v1 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v1 37 | - name: Login to DockerHub 38 | uses: docker/login-action@v1 39 | with: 40 | username: ${{ secrets.DOCKER_USERNAME }} 41 | password: ${{ secrets.DOCKER_PASSWORD }} 42 | - name: Build and push 43 | uses: docker/build-push-action@v2 44 | with: 45 | context: . 46 | platforms: linux/amd64,linux/arm64 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | -------------------------------------------------------------------------------- /.github/workflows/pip-rating.yml: -------------------------------------------------------------------------------- 1 | name: Pip-rating 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | - cron: '0 0 * * SUN' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: write-all 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Run pip-rating 17 | uses: Nekmo/pip-rating@master 18 | with: 19 | create_badge: true 20 | badge_style: flat-square 21 | badge_branch: pip-rating-badge 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 3.11 11 | uses: actions/setup-python@v2 12 | with: 13 | python-version: '3.11' 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install tox-gh-actions wheel twine 18 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 19 | - name: Create packages 20 | run: | 21 | python setup.py sdist bdist_wheel 22 | - name: Check packages 23 | run: | 24 | twine check dist/* 25 | - name: Publish package 26 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 27 | uses: pypa/gh-action-pypi-publish@master 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.PYPI_API_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install tox-gh-actions 23 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 24 | # - name: Lint with flake8 25 | # run: | 26 | # # stop the build if there are Python syntax errors or undefined names 27 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | - name: Test with coverage 31 | run: | 32 | python -m unittest discover 33 | # coverage run -m unittest discover 34 | # codecov 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # pyenv python configuration file 62 | .python-version 63 | 64 | /.idea 65 | *.session* 66 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Nekmo 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Christian Aguilera (@cristian64) 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/Nekmo/telegram-upload/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | and "help wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | telegram-upload could always use more documentation, whether as part of the 42 | official telegram-upload docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/Nekmo/telegram-upload/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `telegram-upload` for local development. 61 | 62 | 1. Fork the `telegram-upload` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/telegram-upload.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv telegram-upload 70 | $ cd telegram-upload/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 telegram_upload tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 2.6, 2.7, 3.3, 3.4 and 3.5, and for PyPy. Check 105 | https://travis-ci.org/Nekmo/telegram-upload/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | 114 | $ python -m unittest tests.test_telegram_upload 115 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG python_version=3.9.7 2 | 3 | FROM python:$python_version 4 | ENV TELEGRAM_UPLOAD_CONFIG_DIRECTORY=/config 5 | ENV PYTHONPATH=/app/ 6 | VOLUME /config 7 | VOLUME /files 8 | 9 | RUN mkdir /app 10 | COPY requirements.txt /tmp/ 11 | RUN pip install -r /tmp/requirements.txt 12 | COPY telegram_upload/ /app/telegram_upload/ 13 | WORKDIR /files 14 | 15 | ENTRYPOINT ["/usr/local/bin/python", "/app/telegram_upload/management.py"] 16 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.7.1 (2023-08-04) 6 | ------------------ 7 | 8 | * Issue #215: "TypeError: __init__() got an unexpected keyword argument 'reply_to_msg_id'" in command - "telegram-upload --directories "recursive" --album" 9 | 10 | 0.7.0 (2023-06-29) 11 | ------------------ 12 | 13 | * Issue #140: Speed up upload & download speed 14 | * Issue #115: Add support for variables in the caption argument 15 | * Issue #159: Telegram premium 16 | * Issue #176: Bug uploading .flv files 17 | * Issue #198: Improve README 18 | 19 | 0.6.1 (2023-06-17) 20 | ------------------ 21 | 22 | * Issue #197: if to.lstrip("-+").isdigit(): AttributeError: 'int' object has no attribute 'lstrip' 23 | 24 | 0.6.0 (2023-06-15) 25 | ------------------ 26 | 27 | * Issue #99: Combine split files when downloading 28 | * Issue #118: Feature Request - Choose channel by ID 29 | * Issue #113: Numbered files are uploaded in weird order 30 | * Issue #111: telethon.errors.rpcerrorlist.FloodWaitError: A wait of 819 seconds is required (caused by CheckChatInviteRequest) 31 | * Issue #108: RPCError 400: INPUT_GZIP_INVALID 32 | * Issue #193: Remove Python 3.6 support 33 | * Issue #194: Python 3.11 support. 34 | 35 | 0.5.1 (2022-05-18) 36 | ------------------ 37 | 38 | * Issue #154: Python classifiers for Python 3.1.0 39 | * Issue #151: Error while uploading files 40 | * Issue #121: Thumbnail gets auto deleted 41 | 42 | 0.5.0 (2022-02-27) 43 | ------------------ 44 | 45 | * Issue #34: Selective downloading option 46 | * Issue #131: Selective uploading option 47 | * Issue #61: Upload as album 48 | * Issue #66: How to re-verify when I type in wrong app-id 49 | * Issue #69: Create Dockerfile 50 | * Issue #82: Error in files with corrupted or unsupported video mimetype 51 | * Issue #83: Raise error when file is empty 52 | * Issue #84: Catch ChatWriteForbiddenError 53 | * Issue #94: Unclosed file ~/.config/telegram-upload.json wb 54 | * Issue #110: Error uploading corrupt or unsupported video file 55 | * Issue #129: Caption chars length 56 | * Issue #149: Support Python 3.10 57 | 58 | 59 | 0.4.0 (2020-12-31) 60 | ------------------ 61 | 62 | * Issue #79: Python 3.9 support 63 | * Issue #74: Help on dependency issues 64 | * Issue #70: Document streamable file types 65 | * Issue #68: Silence hachoir warnings ([warn] [/file[0]/uid] ...) 66 | * Issue #65: Custom Thumbnail For the Files getting Uploaded 67 | * Issue #43: Write tests 68 | * Issue #40: Not using system HTTP Proxy 69 | * Issue #38: Upload to Pypi using Github Actions 70 | * Issue #36: Database is locked 71 | * Issue #35: Document send files to a chat_id 72 | * Issue #13: Change session directory enhancement 73 | * Issue #11: Change session directory enhancement 74 | * Issue #3: Split large files (> 2GB) 75 | 76 | 77 | 0.3.4 (2020-10-06) 78 | ------------------ 79 | 80 | * Issue #59: Stream upload videos 81 | 82 | 0.3.3 (2020-09-11) 83 | ------------------ 84 | 85 | * Pull request #54: Finalizing ProgressBar 86 | * Pull request #55: Verifying document size returned by Telegram 87 | * Pull request #56: Extra convenience options for no caption and no thumbnail 88 | 89 | 0.3.3 (2020-09-11) 90 | ------------------ 91 | 92 | * Pull request #54: Finalizing ProgressBar 93 | * Pull request #55: Verifying document size returned by Telegram 94 | * Pull request #56: Extra convenience options for no caption and no thumbnail 95 | 96 | 97 | 0.3.2 (2020-07-15) 98 | ------------------ 99 | 100 | * Issue #44: Caption problem 101 | 102 | 0.3.1 (2020-05-11) 103 | ------------------ 104 | 105 | * Issue #37: Directories recursive does not work 106 | 107 | 108 | 0.3.0 (2020-05-07) 109 | ------------------ 110 | 111 | * Issue #2: Upload directories 112 | * Issue #30: Check available disk space in download file 113 | * Issue #33: edit file name 114 | * Issue #24: How to install and use in windows? 115 | * Issue #29: Option to forward uploaded file enhancement 116 | * Issue #20: Can't upload video as Document. 117 | * Issue #12: Docs 118 | 119 | 0.2.1 (2019-07-30) 120 | ------------------ 121 | 122 | * Issue #26: Installation Error - hachoir3 123 | 124 | 0.2.0 (2019-00-00) 125 | ------------------ 126 | 127 | * Issue #10: Update docs and validation: mobile phone is required 128 | * Issue #23: Create ~/.config directory if not exists 129 | * Issue #15: Getting file_id of the uploaded file 130 | * Issue #21: Windows support for videos 131 | * Issue #22: Download files 132 | 133 | 0.1.10 (2019-03-22) 134 | ------------------- 135 | 136 | * Issue #19: uploading video files with delay 137 | 138 | 0.1.9 (2019-03-15) 139 | ------------------ 140 | 141 | * Fixed setup: Included requirements.txt to MANIFEST.in. 142 | 143 | 0.1.8 (2019-03-08) 144 | ------------------ 145 | 146 | * Setup.py requirements only supports python3. 147 | 148 | 0.1.7 (2019-03-08) 149 | ------------------ 150 | 151 | * Support MKV videos 152 | 153 | 0.1.6 (2018-07-22) 154 | ------------------ 155 | 156 | * Update to Telethon 1.0 157 | 158 | 0.1.4 (2018-04-16) 159 | ------------------ 160 | 161 | * Pip 10.0 support 162 | 163 | 0.1.2 (2018-03-29) 164 | ------------------ 165 | 166 | * Best upload performance 167 | 168 | 0.1.0 (2018-03-26) 169 | ------------------ 170 | 171 | * First release on PyPI. 172 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2018, Nekmo 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | include common-requirements.txt 7 | include py2-requirements.txt 8 | include py3-requirements.txt 9 | include requirements.txt 10 | 11 | recursive-include tests * 12 | recursive-exclude * __pycache__ 13 | recursive-exclude * *.py[co] 14 | recursive-exclude * *.session 15 | 16 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | 14 | define PRINT_HELP_PYSCRIPT 15 | import re, sys 16 | 17 | for line in sys.stdin: 18 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 19 | if match: 20 | target, help = match.groups() 21 | print("%-20s %s" % (target, help)) 22 | endef 23 | export PRINT_HELP_PYSCRIPT 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | 32 | clean-build: ## remove build artifacts 33 | rm -fr build/ 34 | rm -fr dist/ 35 | rm -fr .eggs/ 36 | find . -name '*.egg-info' -exec rm -fr {} + 37 | find . -name '*.egg' -exec rm -f {} + 38 | 39 | clean-pyc: ## remove Python file artifacts 40 | find . -name '*.pyc' -exec rm -f {} + 41 | find . -name '*.pyo' -exec rm -f {} + 42 | find . -name '*~' -exec rm -f {} + 43 | find . -name '__pycache__' -exec rm -fr {} + 44 | 45 | clean-test: ## remove test and coverage artifacts 46 | rm -fr .tox/ 47 | rm -f .coverage 48 | rm -fr htmlcov/ 49 | 50 | lint: ## check style with flake8 51 | flake8 telegram_upload tests 52 | 53 | test: ## run tests quickly with the default Python 54 | 55 | python setup.py test 56 | 57 | test-all: ## run tests on every Python version with tox 58 | tox 59 | 60 | coverage: ## check code coverage quickly with the default Python 61 | coverage run --source telegram_upload setup.py test 62 | coverage report -m 63 | coverage html 64 | $(BROWSER) htmlcov/index.html 65 | 66 | docs: ## generate Sphinx HTML documentation, including API docs 67 | rm -f docs/telegram_upload.rst 68 | rm -f docs/modules.rst 69 | sphinx-apidoc -o docs/ telegram_upload 70 | $(MAKE) -C docs clean 71 | $(MAKE) -C docs html 72 | $(BROWSER) docs/_build/html/index.html 73 | 74 | servedocs: docs ## compile the docs watching for changes 75 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 76 | 77 | release: clean ## package and upload a release 78 | python setup.py sdist upload 79 | python setup.py bdist_wheel upload 80 | 81 | dist: clean ## builds source and wheel package 82 | python setup.py sdist 83 | python setup.py bdist_wheel 84 | ls -l dist 85 | 86 | install: clean ## install the package to the active Python's site-packages 87 | python setup.py install 88 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | .. image:: https://raw.githubusercontent.com/Nekmo/telegram-upload/master/logo.png 3 | :width: 100% 4 | 5 | | 6 | 7 | .. image:: https://raw.githubusercontent.com/Nekmo/telegram-upload/pip-rating-badge/pip-rating-badge.svg 8 | :target: https://github.com/Nekmo/telegram-upload/actions/workflows/pip-rating.yml 9 | :alt: pip-rating badge 10 | 11 | .. image:: https://img.shields.io/github/actions/workflow/status/Nekmo/telegram-upload/test.yml?style=flat-square&maxAge=2592000&branch=master 12 | :target: https://github.com/Nekmo/telegram-upload/actions?query=workflow%3ATests 13 | :alt: Latest Tests CI build status 14 | 15 | .. image:: https://img.shields.io/pypi/v/telegram-upload.svg?style=flat-square 16 | :target: https://pypi.org/project/telegram-upload/ 17 | :alt: Latest PyPI version 18 | 19 | .. image:: https://img.shields.io/pypi/pyversions/telegram-upload.svg?style=flat-square 20 | :target: https://pypi.org/project/telegram-upload/ 21 | :alt: Python versions 22 | 23 | .. image:: https://img.shields.io/codeclimate/maintainability/Nekmo/telegram-upload.svg?style=flat-square 24 | :target: https://codeclimate.com/github/Nekmo/telegram-upload 25 | :alt: Code Climate 26 | 27 | .. image:: https://img.shields.io/codecov/c/github/Nekmo/telegram-upload/master.svg?style=flat-square 28 | :target: https://codecov.io/github/Nekmo/telegram-upload 29 | :alt: Test coverage 30 | 31 | .. image:: https://img.shields.io/github/stars/Nekmo/telegram-upload?style=flat-square 32 | :target: https://github.com/Nekmo/telegram-upload 33 | :alt: Github stars 34 | 35 | 36 | ############### 37 | telegram-upload 38 | ############### 39 | Telegram-upload uses your **personal Telegram account** to **upload** and **download** files up to **4 GiB** (2 GiB for 40 | free users). Turn Telegram into your personal ☁ cloud! 41 | 42 | To **install 🔧 telegram-upload**, run this command in your terminal: 43 | 44 | .. code-block:: console 45 | 46 | $ sudo pip3 install -U telegram-upload 47 | 48 | This is the preferred method to install telegram-upload, as it will always install the most recent stable release. 49 | 🐍 **Python 3.7-3.11** are tested and supported. There are other installation ways available like `Docker <#-docker>`_. 50 | More info in the `📕 documentation `_ 51 | 52 | .. image:: https://raw.githubusercontent.com/Nekmo/telegram-upload/master/telegram-upload-demo.gif 53 | :target: https://asciinema.org/a/592098 54 | :width: 100% 55 | 56 | ❓ Usage 57 | ======== 58 | To use this program you need an Telegram account and your **App api_id & api_hash** (get it in 59 | `my.telegram.org `_). The first time you use telegram-upload it requests your 60 | 📱 **telephone**, **api_id** and **api_hash**. Bot tokens can not be used with this program (bot uploads are limited to 61 | 50MB). 62 | 63 | To **send ⬆️ files** (by default it is uploaded to saved messages): 64 | 65 | .. code-block:: console 66 | 67 | $ telegram-upload file1.mp4 file2.mkv 68 | 69 | You can **download ⤵️ the files** again from your saved messages (by default) or from a channel. All files will be 70 | downloaded until the last text message. 71 | 72 | .. code-block:: console 73 | 74 | $ telegram-download 75 | 76 | `Read the documentation `_ for more info about the 77 | options availables. 78 | 79 | Interactive mode 80 | ---------------- 81 | The **interactive option** (``--interactive``) allows you to choose the dialog and the files to download or upload with 82 | a **terminal 🪄 wizard**. It even **supports mouse**! 83 | 84 | .. code-block:: console 85 | 86 | $ telegram-upload --interactive # Interactive upload 87 | $ telegram-download --interactive # Interactive download 88 | 89 | `More info in the documentation `_ 90 | 91 | Set group or chat 92 | ----------------- 93 | By default when using telegram-upload without specifying the recipient or sender, telegram-upload will use your personal 94 | chat. However you can define the 👨 destination. For file upload the argument is ``--to ``. For example: 95 | 96 | .. code-block:: 97 | 98 | $ telegram-upload --to telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg video.mkv 99 | 100 | You can download files from a specific chat using the --from parameter. For example: 101 | 102 | .. code-block:: 103 | 104 | $ telegram-download --from username 105 | 106 | You can see all `the possible values for the entity in the documentation `_. 107 | 108 | Split & join files 109 | ------------------ 110 | If you try to upload a file that **exceeds the maximum supported** by Telegram by default, an error will occur. But you 111 | can enable ✂ **split mode** to upload multiple files: 112 | 113 | .. code-block:: console 114 | 115 | $ telegram-upload --large-files split large-video.mkv 116 | 117 | Files split using split can be rejoined on download using: 118 | 119 | .. code-block:: console 120 | 121 | $ telegram-download --split-files join 122 | 123 | Find more help in `the telegram-upload documentation `_. 124 | 125 | Delete on success 126 | ----------------- 127 | The ``--delete-on-success`` option allows you to ❌ **delete the Telegram message** after downloading the file. This is 128 | useful to send files to download to your saved messages and avoid downloading them again. You can use this option to 129 | download files on your computer away from home. 130 | 131 | Configuration 132 | ------------- 133 | Credentials are saved in ``~/.config/telegram-upload.json`` and ``~/.config/telegram-upload.session``. You must make 134 | sure that these files are secured. You can copy these 📁 files to authenticate ``telegram-upload`` on more machines, but 135 | it is advisable to create a session file for each machine. 136 | 137 | More options 138 | ------------ 139 | Telegram-upload has more options available, like customizing the files thumbnail, set a caption message (including 140 | variables) or configuring a proxy. 141 | `Read the documentation `_ for more info. 142 | 143 | 💡 Features 144 | =========== 145 | 146 | * **Upload** and **download** multiples files (up to 4 GiB per file for premium users). 147 | * **Interactive** mode. 148 | * Add video **thumbs**. 149 | * **Split** and **join** large files. 150 | * **Delete** local or remote file on success. 151 | * Use **variables** in the **caption** message. 152 | * ... And **more**. 153 | 154 | 🐋 Docker 155 | ========= 156 | Run telegram-upload without installing it on your system using Docker. Instead of ``telegram-upload`` 157 | and ``telegram-download`` you should use ``upload`` and ``download``. Usage:: 158 | 159 | 160 | $ docker run -v :/files/ 161 | -v :/config 162 | -it nekmo/telegram-upload:master 163 | 164 | 165 | * ````: upload or download directory. 166 | * ````: Directory that will be created to store the telegram-upload configuration. 167 | It is created automatically. 168 | * ````: ``upload`` and ``download``. 169 | * ````: ``telegram-upload`` and ``telegram-download`` arguments. 170 | 171 | For example:: 172 | 173 | $ docker run -v /media/data/:/files/ 174 | -v $PWD/config:/config 175 | -it nekmo/telegram-upload:master 176 | upload file_to_upload.txt 177 | 178 | ❤️ Thanks 179 | ========= 180 | This project developed by `Nekmo `_ & `collaborators `_ would not be possible without 181 | `Telethon `_, the library used as a Telegram client. 182 | 183 | Telegram-upload is licensed under the `MIT license `_. 184 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXAPIDOC = sphinx-apidoc 8 | PAPER = 9 | BUILDDIR = _build 10 | PROJECT_NAME = Telegram Upload 11 | DRIVE_FOLDER = 12 | 13 | # User-friendly check for sphinx-build 14 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 15 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 16 | endif 17 | 18 | # Internal variables. 19 | PAPEROPT_a4 = -D latex_paper_size=a4 20 | PAPEROPT_letter = -D latex_paper_size=letter 21 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 22 | # the i18n builder cannot share the environment and doctrees with the others 23 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 24 | 25 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 26 | 27 | help: 28 | @echo "Please use \`make ' where is one of" 29 | @echo " pdf to make standalone PDF files" 30 | @echo " html to make standalone HTML files" 31 | @echo " watch Browser Sync watcher for build HTML files on real time" 32 | @echo " dirhtml to make HTML files named index.html in directories" 33 | @echo " singlehtml to make a single large HTML file" 34 | @echo " pickle to make pickle files" 35 | @echo " json to make JSON files" 36 | @echo " htmlhelp to make HTML files and a HTML help project" 37 | @echo " qthelp to make HTML files and a qthelp project" 38 | @echo " devhelp to make HTML files and a Devhelp project" 39 | @echo " epub to make an epub" 40 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 41 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 42 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 43 | @echo " text to make text files" 44 | @echo " man to make manual pages" 45 | @echo " texinfo to make Texinfo files" 46 | @echo " info to make Texinfo files and run them through makeinfo" 47 | @echo " gettext to make PO message catalogs" 48 | @echo " changes to make an overview of all changed/added/deprecated items" 49 | @echo " xml to make Docutils-native XML files" 50 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 51 | @echo " linkcheck to check all external links for integrity" 52 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 53 | 54 | clean: 55 | rm -rf $(BUILDDIR)/* 56 | 57 | rinohpdf: 58 | $(SPHINXAPIDOC) -o . ../ 59 | $(SPHINXBUILD) -b rinoh . $(BUILDDIR)/pdf 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 62 | 63 | pdf: 64 | HTML_THEME=business_theme make singlehtml 65 | weasyprint _build/singlehtml/index.html "$(PROJECT_NAME).pdf" 66 | rm -rf _build 67 | python -m business_theme upload "$(PROJECT_NAME).pdf" "$(PROJECT_NAME).pdf" "$(DRIVE_FOLDER)" 68 | @echo 69 | @echo "Build finished. The PDF file is in $(BUILDDIR)/." 70 | 71 | html: 72 | rm -rf $(BUILDDIR) 73 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 74 | @echo 75 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 76 | 77 | dirhtml: 78 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 79 | @echo 80 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 81 | 82 | singlehtml: 83 | $(SPHINXAPIDOC) -o . ../ 84 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 85 | @echo 86 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 87 | 88 | pickle: 89 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 90 | @echo 91 | @echo "Build finished; now you can process the pickle files." 92 | 93 | json: 94 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 95 | @echo 96 | @echo "Build finished; now you can process the JSON files." 97 | 98 | htmlhelp: 99 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 100 | @echo 101 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 102 | ".hhp project file in $(BUILDDIR)/htmlhelp." 103 | 104 | qthelp: 105 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 106 | @echo 107 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 108 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 109 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/delfos.qhcp" 110 | @echo "To view the help file:" 111 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/delfos.qhc" 112 | 113 | devhelp: 114 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 115 | @echo 116 | @echo "Build finished." 117 | @echo "To view the help file:" 118 | @echo "# mkdir -p $$HOME/.local/share/devhelp/delfos" 119 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/delfos" 120 | @echo "# devhelp" 121 | 122 | epub: 123 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 124 | @echo 125 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 126 | 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | latexpdf: 135 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 136 | @echo "Running LaTeX files through pdflatex..." 137 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 138 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 139 | 140 | latexpdfja: 141 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 142 | @echo "Running LaTeX files through platex and dvipdfmx..." 143 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 144 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 145 | 146 | text: 147 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 148 | @echo 149 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 150 | 151 | man: 152 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 153 | @echo 154 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 155 | 156 | texinfo: 157 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 158 | @echo 159 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 160 | @echo "Run \`make' in that directory to run these through makeinfo" \ 161 | "(use \`make info' here to do that automatically)." 162 | 163 | info: 164 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 165 | @echo "Running Texinfo files through makeinfo..." 166 | make -C $(BUILDDIR)/texinfo info 167 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 168 | 169 | gettext: 170 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 171 | @echo 172 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 173 | 174 | changes: 175 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 176 | @echo 177 | @echo "The overview file is in $(BUILDDIR)/changes." 178 | 179 | linkcheck: 180 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 181 | @echo 182 | @echo "Link check complete; look for any errors in the above output " \ 183 | "or in $(BUILDDIR)/linkcheck/output.txt." 184 | 185 | doctest: 186 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 187 | @echo "Testing of doctests in the sources finished, look at the " \ 188 | "results in $(BUILDDIR)/doctest/output.txt." 189 | 190 | xml: 191 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 192 | @echo 193 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 194 | 195 | pseudoxml: 196 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 197 | @echo 198 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 199 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/benchmark_2.0_GiB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/docs/benchmark_2.0_GiB.png -------------------------------------------------------------------------------- /docs/benchmark_2.0_GiB.rst: -------------------------------------------------------------------------------- 1 | ========== =========== =========== =========== =========== ========== 2 | Parallel Minimum Maximum Average Median Speed 3 | ========== =========== =========== =========== =========== ========== 4 | 1 chunk 420.82 sec. 451.54 sec. 437.80 sec. 440.59 sec. 4.6 MiB/s 5 | 2 chunks 212.98 sec. 231.35 sec. 223.42 sec. 225.71 sec. 9.1 MiB/s 6 | 3 chunks 147.93 sec. 166.37 sec. 156.28 sec. 156.64 sec. 13.1 MiB/s 7 | 4 chunks 112.07 sec. 124.13 sec. 116.48 sec. 115.49 sec. 17.7 MiB/s 8 | 5 chunks 87.51 sec. 95.32 sec. 91.69 sec. 92.30 sec. 22.2 MiB/s 9 | 6 chunks 72.52 sec. 82.02 sec. 75.89 sec. 74.44 sec. 27.5 MiB/s 10 | 7 chunks 60.66 sec. 66.30 sec. 63.74 sec. 64.86 sec. 31.6 MiB/s 11 | 8 chunks 51.64 sec. 55.25 sec. 53.63 sec. 53.89 sec. 38.0 MiB/s 12 | 9 chunks 47.51 sec. 52.94 sec. 49.36 sec. 48.63 sec. 42.1 MiB/s 13 | 10 chunks 40.69 sec. 44.31 sec. 42.49 sec. 42.30 sec. 48.4 MiB/s 14 | ========== =========== =========== =========== =========== ========== 15 | 16 | * **Minimum time:** 40.69 sec. (50.3 MiB/s) 17 | * **Maximum time:** 451.54 sec. (4.5 MiB/s) 18 | * **Average time:** 131.08 sec. (15.6 MiB/s) 19 | * **Median time:** 83.37 sec. (24.6 MiB/s) 20 | -------------------------------------------------------------------------------- /docs/benchmark_20.0_MiB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/docs/benchmark_20.0_MiB.png -------------------------------------------------------------------------------- /docs/benchmark_20.0_MiB.rst: -------------------------------------------------------------------------------- 1 | ========== ========= ========= ========= ========= ========== 2 | Parallel Minimum Maximum Average Median Speed 3 | ========== ========= ========= ========= ========= ========== 4 | 1 chunk 9.00 sec. 9.87 sec. 9.54 sec. 9.55 sec. 2.1 MiB/s 5 | 2 chunks 4.62 sec. 5.30 sec. 4.92 sec. 4.94 sec. 4.0 MiB/s 6 | 3 chunks 3.10 sec. 3.53 sec. 3.28 sec. 3.25 sec. 6.1 MiB/s 7 | 4 chunks 2.37 sec. 2.74 sec. 2.51 sec. 2.45 sec. 8.1 MiB/s 8 | 5 chunks 1.92 sec. 2.33 sec. 2.03 sec. 1.98 sec. 10.1 MiB/s 9 | 6 chunks 1.65 sec. 2.11 sec. 1.81 sec. 1.73 sec. 11.6 MiB/s 10 | 7 chunks 1.45 sec. 1.71 sec. 1.54 sec. 1.53 sec. 13.1 MiB/s 11 | 8 chunks 1.26 sec. 1.72 sec. 1.43 sec. 1.37 sec. 14.6 MiB/s 12 | 9 chunks 1.18 sec. 1.61 sec. 1.28 sec. 1.22 sec. 16.4 MiB/s 13 | 10 chunks 1.08 sec. 1.78 sec. 1.24 sec. 1.18 sec. 16.9 MiB/s 14 | ========== ========= ========= ========= ========= ========== 15 | 16 | * **Minimum time:** 1.08 sec. (18.5 MiB/s) 17 | * **Maximum time:** 9.87 sec. (2.0 MiB/s) 18 | * **Average time:** 2.96 sec. (6.8 MiB/s) 19 | * **Median time:** 1.85 sec. (10.8 MiB/s) 20 | -------------------------------------------------------------------------------- /docs/benchmark_200.0_MiB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/docs/benchmark_200.0_MiB.png -------------------------------------------------------------------------------- /docs/benchmark_200.0_MiB.rst: -------------------------------------------------------------------------------- 1 | ========== =========== =========== =========== =========== ========== 2 | Parallel Minimum Maximum Average Median Speed 3 | ========== =========== =========== =========== =========== ========== 4 | 1 chunk 131.92 sec. 141.38 sec. 136.48 sec. 136.55 sec. 1.5 MiB/s 5 | 2 chunks 68.49 sec. 84.90 sec. 74.71 sec. 72.29 sec. 2.8 MiB/s 6 | 3 chunks 42.73 sec. 49.74 sec. 46.04 sec. 45.67 sec. 4.4 MiB/s 7 | 4 chunks 29.57 sec. 38.33 sec. 34.56 sec. 35.22 sec. 5.7 MiB/s 8 | 5 chunks 26.57 sec. 34.07 sec. 29.75 sec. 29.93 sec. 6.7 MiB/s 9 | 6 chunks 20.87 sec. 24.96 sec. 23.16 sec. 22.69 sec. 8.8 MiB/s 10 | 7 chunks 18.90 sec. 26.18 sec. 21.86 sec. 21.57 sec. 9.3 MiB/s 11 | 8 chunks 16.07 sec. 20.05 sec. 18.28 sec. 18.35 sec. 10.9 MiB/s 12 | 9 chunks 16.01 sec. 18.84 sec. 17.46 sec. 17.29 sec. 11.6 MiB/s 13 | 10 chunks 13.72 sec. 16.52 sec. 14.96 sec. 14.89 sec. 13.4 MiB/s 14 | ========== =========== =========== =========== =========== ========== 15 | 16 | * **Minimum time:** 13.72 sec. (14.6 MiB/s) 17 | * **Maximum time:** 141.38 sec. (1.4 MiB/s) 18 | * **Average time:** 41.73 sec. (4.8 MiB/s) 19 | * **Median time:** 26.31 sec. (7.6 MiB/s) 20 | -------------------------------------------------------------------------------- /docs/benchmark_512.0_KiB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/docs/benchmark_512.0_KiB.png -------------------------------------------------------------------------------- /docs/benchmark_512.0_KiB.rst: -------------------------------------------------------------------------------- 1 | ========== ========= ========= ========= ========= ========= 2 | Parallel Minimum Maximum Average Median Speed 3 | ========== ========= ========= ========= ========= ========= 4 | 1 chunk 0.29 sec. 0.45 sec. 0.33 sec. 0.32 sec. 1.6 MiB/s 5 | 2 chunks 0.20 sec. 0.34 sec. 0.23 sec. 0.22 sec. 2.3 MiB/s 6 | 3 chunks 0.19 sec. 0.26 sec. 0.21 sec. 0.21 sec. 2.4 MiB/s 7 | 4 chunks 0.14 sec. 0.17 sec. 0.16 sec. 0.16 sec. 3.1 MiB/s 8 | 5 chunks 0.13 sec. 0.17 sec. 0.15 sec. 0.15 sec. 3.4 MiB/s 9 | 6 chunks 0.13 sec. 0.20 sec. 0.16 sec. 0.16 sec. 3.2 MiB/s 10 | 7 chunks 0.14 sec. 0.20 sec. 0.17 sec. 0.17 sec. 3.0 MiB/s 11 | 8 chunks 0.14 sec. 0.28 sec. 0.17 sec. 0.16 sec. 3.1 MiB/s 12 | 9 chunks 0.14 sec. 0.16 sec. 0.14 sec. 0.14 sec. 3.5 MiB/s 13 | 10 chunks 0.15 sec. 0.17 sec. 0.16 sec. 0.16 sec. 3.2 MiB/s 14 | ========== ========= ========= ========= ========= ========= 15 | 16 | * **Minimum time:** 0.13 sec. (3.7 MiB/s) 17 | * **Maximum time:** 0.45 sec. (1.1 MiB/s) 18 | * **Average time:** 0.19 sec. (2.7 MiB/s) 19 | * **Median time:** 0.16 sec. (3.1 MiB/s) 20 | -------------------------------------------------------------------------------- /docs/caption_format.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _caption_format: 3 | 4 | Caption format 5 | ============== 6 | Telegram-upload has a argument to add a message with every file you send. This is the **caption argument**. The 7 | caption argument (``--caption ""``) can be personalized for every file you send using variables between braces 8 | (``{}``). The variables are replaced with the corresponding value when the file is sent. The following variables are 9 | available: 10 | 11 | * ``{file}``: file object. This variable has a lot of attributes available. 12 | * ``{now}``: current datetime. 13 | 14 | For example: 15 | 16 | .. code-block:: bash 17 | 18 | $ telegram-upload --caption "This is a file with name {file.name}" file.txt 19 | 20 | The latest caption message will be *"This is a file with name file.txt"*. In case of using a invalid variable, the 21 | variable will be keep without replacing. The rest of variables will be replaced correctly. In case of a sintax error 22 | the entire caption will be keep without replacing. 23 | 24 | The caption variables can be Python attributes of the ``{file}`` & ``{now}`` objects. Some methods without arguments are 25 | available too. In case of accesing to a unsupported method, the variable will be keep without replacing. The private 26 | attributes are prohibited for security reasons. 27 | 28 | For testing the caption variables before sending the file, you can use the next command: 29 | 30 | .. code-block:: bash 31 | 32 | $ python -m telegram_upload.caption_formatter file.txt "{file.absolute}" 33 | 34 | We recommend you to use the latest command to test the caption variables before sending the file. 35 | 36 | If you need to use a brace in the caption, you can escape it using two braces. For example: ``{{ {file.name} }}`` will 37 | be replaced with ``{ file.txt }``. 38 | 39 | The available attributes are described below. 40 | 41 | 42 | Path attributes 43 | --------------- 44 | The ``{file}`` variable has all the attributes of the ``pathlib.Path`` object. The following attributes are the most 45 | used: 46 | 47 | * ``{file.name}``: file name with extension. For example ``file.txt``. 48 | * ``{file.stem}``: file name without extension. For example ``file``. 49 | * ``{file.suffix}``: file extension including the period. For example ``.txt``. 50 | * ``{file.parent}``: parent directory of the file. For example ``/home/user``. 51 | 52 | The next methods are available too: 53 | 54 | * ``{file.absolute}``: absolute path of the file. For example ``/home/user/file.txt``. 55 | * ``{file.home}``: home directory of the user. For example ``/home/user``. 56 | 57 | The next methods are added or modified: 58 | 59 | * ``{file.relative}``: relative path of the file. For example ``file.txt``. 60 | * ``{file.mediatype}``: media type of the file. For example ``text/plain``. 61 | * ``{file.preffixes}``: all preffixes of the file. For example ``.tar.gz`` for ``file.tar.gz``. 62 | 63 | The entire list of attributes and methods can be found in the 64 | `pathlib.Path documentation `_ 65 | 66 | 67 | File size 68 | --------- 69 | The ``{file}`` variable has the ``{file.size}`` to get the file size. The size is returned in bytes. The size attribute 70 | has other attributes to get the size in other units. The following attributes are available: 71 | 72 | * ``{file.size}``: file size in bytes. 73 | * ``{file.size.as_kibibytes}``: file size in kibibytes (KiB). 74 | * ``{file.size.as_mebibytes}``: file size in mebibytes (MiB). 75 | * ``{file.size.as_gibibytes}``: file size in gibibytes (GiB). 76 | * ``{file.size.as_kilobytes}``: file size in kilobytes (KB). 77 | * ``{file.size.as_megabytes}``: file size in megabytes (MB). 78 | * ``{file.size.as_gigabytes}``: file size in gigabytes (GB). 79 | * ``{file.size.for_humans}``: file size in a human readable format. For example ``1.2 MiB``. 80 | 81 | If you don't know what is the difference between a kibibyte and a kilobyte, you can read the Wikipedia article about 82 | `binary prefixes `_. We recommend to use the binary prefixes 83 | (``KiB``, ``MiB``, ``GiB``...) because they are the correct units for binary files. 84 | 85 | 86 | Media attributes 87 | ---------------- 88 | The ``{file}`` variable has the ``{file.media}`` to get the media attributes. The media attributes are extracted from 89 | the video and audio files. The following attributes are available: 90 | 91 | * ``{file.media.width}``: width of the video in pixels *(only for video files)*. 92 | * ``{file.media.height}``: height of the video in pixels *(only for video files)*. 93 | * ``{file.media.title}``: title of the media. This is extracted from the metadata of the file. 94 | * ``{file.media.artist}``: artist of the media *(only for audio files)*. 95 | * ``{file.media.album}``: album of the media *(only for audio files)*. 96 | * ``{file.media.producer}``: producer of the media. 97 | * ``{file.media.duration}``: duration of the media in seconds. 98 | 99 | The duration attribute has other attributes to get the duration in other units. The following attributes are available: 100 | 101 | * ``{file.media.duration.as_minutes}``: duration of the media in minutes. 102 | * ``{file.media.duration.as_hours}``: duration of the media in hours. 103 | * ``{file.media.duration.as_days}``: duration of the media in days. 104 | * ``{file.media.duration.for_humans}``: duration of the media in a human readable format. For example ``1 hour and 30 minutes``. The text is in English. 105 | 106 | Notice that some of the attributes will not be available if the file doesn't have the metadata. Some video & audio 107 | doesn't have the metadata. The metadata is extracted using the 108 | `hachoir library `_ 109 | 110 | 111 | Datetime attributes 112 | ------------------- 113 | The file object has the following attributes to get the datetimes of the file: 114 | 115 | * ``{file.ctime}``: datetime when the file was created. 116 | * ``{file.mtime}``: datetime when the file was modified. 117 | * ``{file.atime}``: datetime when the file was accessed. 118 | 119 | By default the datetime is returned like ``YYYY-MM-DD HH:MM:SS.mmmmmm``. The datetime attribute has other attributes to 120 | get the datetime in other formats. All the attributes from the ``datetime.datetime`` object are available. The following 121 | attributes are the most used: 122 | 123 | * ``{file.ctime.day}``: day of the month. For example ``1``. 124 | * ``{file.ctime.month}``: month of the year. For example ``11``. 125 | * ``{file.ctime.year}``: year. For example ``2019``. 126 | * ``{file.ctime.hour}``: hour of the day. For example ``14``. 127 | * ``{file.ctime.minute}``: minute of the hour. For example ``30``. 128 | * ``{file.ctime.second}``: second of the minute. For example ``0``. 129 | 130 | The next methods are available too: 131 | 132 | * ``{file.ctime.astimezone}``: datetime with timezone. For example ``2019-11-01 14:30:00+01:00``. 133 | * ``{file.ctime.ctime}``: datetime in ctime format. For example ``Fri Nov 1 14:30:00 2019``. 134 | * ``{file.ctime.date}``: date in ISO 8601 format. For example ``2019-11-01``. 135 | * ``{file.ctime.dst}``: dst of the tzinfo datetime. 136 | * ``{file.ctime.isoformat}``: datetime in ISO 8601 format. For example ``2019-11-01T14:30:00.123456``. 137 | * ``{file.ctime.isoweekday}``: day of the week. For example ``5``. 138 | * ``{file.ctime.now}``: current datetime. For example ``2023-06-29 02:32:15.123456``. 139 | * ``{file.ctime.time}``: time in ISO 8601 format. For example ``14:30:00.123456``. 140 | * ``{file.ctime.timestamp}``: timestamp of the datetime. For example ``1572622200``. 141 | * ``{file.ctime.today}``: current datetime. For example ``2023-06-29 02:32:15.123456``. 142 | * ``{file.ctime.toordinal}``: ordinal of the datetime. For example ``737373``. 143 | * ``{file.ctime.tzname}``: name of the timezone. For example ``CET``. 144 | * ``{file.ctime.utcnow}``: current datetime in UTC. For example ``2019-11-01 13:30:00.123456``. 145 | * ``{file.ctime.utcoffset}``: offset of the timezone. For example ``3600``. 146 | * ``{file.ctime.weekday}``: day of the week. For example ``4``. 147 | 148 | The ``{file.mtime}`` and ``{file.atime}`` attributes have the same methods & attributes. Also the ``{now}`` variable. 149 | 150 | For more info about the datetime attributes and methods, you can read the 151 | `datetime.datetime documentation `_. 152 | 153 | 154 | Checksum attributes 155 | ------------------- 156 | The file object has the following attributes to get the checksums of the file: 157 | 158 | * ``{file.md5}``: MD5 checksum of the file. For example ``d41d8cd98f00b204e9800998ecf8427e``. 159 | * ``{file.sha1}``: SHA1 checksum of the file. For example ``da39a3ee5e6b4b0d3255bfef95601890afd80709``. 160 | * ``{file.sha224}``: SHA224 checksum of the file. For example ``d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f``. 161 | * ``{file.sha256}``: SHA256 checksum of the file. For example ``e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855``. 162 | * ``{file.sha384}``: SHA384 checksum of the file. 163 | * ``{file.sha512}``: SHA512 checksum of the file. 164 | * ``{file.sha3_224}``: SHA3 224 checksum of the file. 165 | * ``{file.sha3_256}``: SHA3 256 checksum of the file. 166 | * ``{file.sha3_384}``: SHA3 384 checksum of the file. 167 | * ``{file.sha3_512}``: SHA3 512 checksum of the file. 168 | * ``{file.crc32}``: CRC32 checksum of the file. For example ``00000000``. 169 | * ``{file.adler32}``: Adler32 checksum of the file. For example ``00000001``. 170 | 171 | Note that the checksums are calculated after accesing the attribute. If you access the attribute twice, the checksum 172 | will be calculated twice. Calculate the checksums can take a lot of time, so it's recommended to use the checksums only 173 | when you need them. 174 | 175 | 176 | String methods 177 | -------------- 178 | The next methods are available to manipulate the strings availables in the file object. All the examples are using the 179 | string attribute ``{file.stem}`` (with the value ``my file name``), but you can use any string attribute. 180 | 181 | * ``{file.stem.title}``: capitalize the string. For example ``My File Name``. 182 | * ``{file.stem.capitalize}``: capitalize the string. For example ``My file name``. 183 | * ``{file.stem.lower}``: convert the string to lowercase. For example ``my file name``. 184 | * ``{file.stem.upper}``: convert the string to uppercase. For example ``MY FILE NAME``. 185 | * ``{file.stem.swapcase}``: swap the case of the string. For example ``MY FILE NAME``. 186 | * ``{file.stem.strip}``: remove the leading and trailing characters. For example ``my file name``. This is useful to 187 | remove the spaces in the filename. For example if the stem is `` my file name `` (with spaces), the value will be 188 | ``my file name``. 189 | * ``{file.stem.lstrip}``: remove the leading characters. For example ``my file name``. Like strip but only remove the 190 | characters at the beginning of the string. 191 | * ``{file.stem.rstrip}``: remove the trailing characters. For example ``my file name``. Like strip but only remove the 192 | characters at the end of the string. 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Telegram Upload documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | import datetime 16 | import sys 17 | import os 18 | # import django 19 | 20 | # If extensions (or modules to document with autodoc) are in another 21 | # directory, add these directories to sys.path here. If the directory is 22 | # relative to the documentation root, use os.path.abspath to make it 23 | # absolute, like shown here. 24 | 25 | 26 | # Insert the project root dir as the first element in the PYTHONPATH. 27 | # This lets us ensure that the source package is imported, and that its 28 | # version is used. 29 | directory = os.path.dirname(os.path.abspath(__file__)) 30 | 31 | sys.path.append(os.path.abspath(os.path.join(directory, '../'))) 32 | # os.environ['DJANGO_SETTINGS_MODULE'] = 'Telegram Upload.settings.develop' 33 | # django.setup() 34 | 35 | # -- General configuration --------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | #needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | 'sphinx.ext.intersphinx', 45 | 'sphinx_click.ext' 46 | # 'sphinxcontrib.autohttp.drf', 47 | # 'sphinxcontrib_django', 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ['_templates'] 52 | 53 | # The suffix of source filenames. 54 | source_suffix = '.rst' 55 | 56 | # The encoding of source files. 57 | #source_encoding = 'utf-8-sig' 58 | 59 | # The master toctree document. 60 | master_doc = 'index' 61 | 62 | # General information about the project. 63 | project = u'Telegram Upload' 64 | copyright = u"%i, Nekmo Com" % datetime.date.today().year 65 | 66 | pdf_documents = [('index', u'rst2pdf', u'Telegram Upload', u'Nekmo'), ] 67 | 68 | rinoh_documents = [('index', # top-level file (index.rst) 69 | 'target', # output (target.pdf) 70 | 'Telegram Upload', # document title 71 | 'Nekmo')] # document author 72 | # rinoh_logo = '_static/logo.png' 73 | rinoh_domain_indices = False 74 | 75 | html_context = dict(docs_scope='external') 76 | 77 | # The version info for the project you're documenting, acts as replacement 78 | # for |version| and |release|, also used in various other places throughout 79 | # the built documents. 80 | # 81 | # The short X.Y version. 82 | version = '0.1.0' 83 | # The full version, including alpha/beta/rc tags. 84 | release = '0.1.0' 85 | 86 | # The language for content autogenerated by Sphinx. Refer to documentation 87 | # for a list of supported languages. 88 | #language = None 89 | 90 | # There are two options for replacing |today|: either, you set today to 91 | # some non-false value, then it is used: 92 | #today = '' 93 | # Else, today_fmt is used as the format for a strftime call. 94 | #today_fmt = '%B %d, %Y' 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | exclude_patterns = ['_build'] 99 | 100 | # The reST default role (used for this markup: `text`) to use for all 101 | # documents. 102 | #default_role = None 103 | 104 | # If true, '()' will be appended to :func: etc. cross-reference text. 105 | #add_function_parentheses = True 106 | 107 | # If true, the current module name will be prepended to all description 108 | # unit titles (such as .. function::). 109 | #add_module_names = True 110 | 111 | # If true, sectionauthor and moduleauthor directives will be shown in the 112 | # output. They are ignored by default. 113 | #show_authors = False 114 | 115 | # The name of the Pygments (syntax highlighting) style to use. 116 | # pygments_style = 'sphinx' 117 | 118 | # A list of ignored prefixes for module index sorting. 119 | #modindex_common_prefix = [] 120 | 121 | # If true, keep warnings as "system message" paragraphs in the built 122 | # documents. 123 | #keep_warnings = False 124 | 125 | 126 | # -- Options for HTML output ------------------------------------------- 127 | 128 | # The theme to use for HTML and HTML Help pages. See the documentation for 129 | # a list of builtin themes. 130 | html_theme = os.environ.get('HTML_THEME', 'alabaster') 131 | 132 | # Theme options are theme-specific and customize the look and feel of a 133 | # theme further. For a list of options available for each theme, see the 134 | # documentation. 135 | html_theme_options = { 136 | 'logo': 'logo.png', 137 | 'description': 'Upload and download files to Telegram up to 4 GiB', 138 | 'github_user': 'Nekmo', 139 | 'github_repo': 'telegram-upload', 140 | 'github_type': 'star', 141 | 'github_banner': True, 142 | 'travis_button': True, 143 | 'codecov_button': True, 144 | 'analytics_id': 'UA-62276079-1', 145 | 'canonical_url': 'http://docs.nekmo.org/telegram-upload/' 146 | } 147 | 148 | 149 | # Add any paths that contain custom themes here, relative to this directory. 150 | html_theme_path = ['.'] 151 | 152 | # The name for this set of Sphinx documents. If None, it defaults to 153 | # " v documentation". 154 | #html_title = None 155 | 156 | # A shorter title for the navigation bar. Default is the same as 157 | # html_title. 158 | #html_short_title = None 159 | 160 | # The name of an image file (relative to this directory) to place at the 161 | # top of the sidebar. 162 | #html_logo = None 163 | 164 | # The name of an image file (within the static path) to use as favicon 165 | # of the docs. This file should be a Windows icon file (.ico) being 166 | # 16x16 or 32x32 pixels large. 167 | #html_favicon = None 168 | 169 | # Add any paths that contain custom static files (such as style sheets) 170 | # here, relative to this directory. They are copied after the builtin 171 | # static files, so a file named "default.css" will overwrite the builtin 172 | # "default.css". 173 | html_static_path = ['_static'] 174 | 175 | # If not '', a 'Last updated on:' timestamp is inserted at every page 176 | # bottom, using the given strftime format. 177 | #html_last_updated_fmt = '%b %d, %Y' 178 | 179 | # If true, SmartyPants will be used to convert quotes and dashes to 180 | # typographically correct entities. 181 | #html_use_smartypants = True 182 | 183 | # Custom sidebar templates, maps document names to template names. 184 | # html_sidebars = { 185 | # '**': [ 186 | # 'about.html', 187 | # 'navigation.html', 188 | # 'relations.html', 189 | # 'searchbox.html', 190 | # 'donate.html', 191 | # ] 192 | # } 193 | 194 | # Additional templates that should be rendered to pages, maps page names 195 | # to template names. 196 | #html_additional_pages = {} 197 | 198 | # If false, no module index is generated. 199 | #html_domain_indices = True 200 | 201 | # If false, no index is generated. 202 | #html_use_index = True 203 | 204 | # If true, the index is split into individual pages for each letter. 205 | #html_split_index = False 206 | 207 | # If true, links to the reST sources are added to the pages. 208 | #html_show_sourcelink = True 209 | 210 | # If true, "Created using Sphinx" is shown in the HTML footer. 211 | # Default is True. 212 | #html_show_sphinx = True 213 | 214 | # If true, "(C) Copyright ..." is shown in the HTML footer. 215 | # Default is True. 216 | #html_show_copyright = True 217 | 218 | # If true, an OpenSearch description file will be output, and all pages 219 | # will contain a tag referring to it. The value of this option 220 | # must be the base URL from which the finished HTML is served. 221 | #html_use_opensearch = '' 222 | 223 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 224 | #html_file_suffix = None 225 | 226 | # Output file base name for HTML help builder. 227 | htmlhelp_basename = 'Telegram Uploaddoc' 228 | 229 | 230 | # -- Options for LaTeX output ------------------------------------------ 231 | 232 | latex_elements = { 233 | # The paper size ('letterpaper' or 'a4paper'). 234 | 'papersize': 'letterpaper', 235 | 236 | # The font size ('10pt', '11pt' or '12pt'). 237 | 'pointsize': '10pt', 238 | 239 | # Additional stuff for the LaTeX preamble. 240 | 'preamble': '', 241 | } 242 | 243 | # Grouping the document tree into LaTeX files. List of tuples 244 | # (source start file, target name, title, author, documentclass 245 | # [howto/manual]). 246 | latex_documents = [ 247 | ('index', 'Telegram Upload.tex', 248 | u'Telegram Upload Documentation', 249 | u'Nekmo', 'manual'), 250 | ] 251 | 252 | # The name of an image file (relative to this directory) to place at 253 | # the top of the title page. 254 | #latex_logo = None 255 | 256 | # For "manual" documents, if this is true, then toplevel headings 257 | # are parts, not chapters. 258 | #latex_use_parts = False 259 | 260 | # If true, show page references after internal links. 261 | #latex_show_pagerefs = False 262 | 263 | # If true, show URL addresses after external links. 264 | #latex_show_urls = False 265 | 266 | # Documents to append as an appendix to all manuals. 267 | #latex_appendices = [] 268 | 269 | # If false, no module index is generated. 270 | #latex_domain_indices = True 271 | 272 | 273 | # -- Options for manual page output ------------------------------------ 274 | 275 | # One entry per manual page. List of tuples 276 | # (source start file, name, description, authors, manual section). 277 | man_pages = [ 278 | ('index', 'Telegram Upload', 279 | u'Telegram Upload Documentation', 280 | [u'Nekmo'], 1) 281 | ] 282 | 283 | # If true, show URL addresses after external links. 284 | #man_show_urls = False 285 | 286 | 287 | # -- Options for Texinfo output ---------------------------------------- 288 | 289 | # Grouping the document tree into Texinfo files. List of tuples 290 | # (source start file, target name, title, author, 291 | # dir menu entry, description, category) 292 | texinfo_documents = [ 293 | ('index', 'Telegram Upload', 294 | u'Telegram Upload Documentation', 295 | u'Nekmo', 296 | 'Telegram Upload', 297 | 'One line description of project.', 298 | 'Miscellaneous'), 299 | ] 300 | 301 | # Documents to append as an appendix to all manuals. 302 | #texinfo_appendices = [] 303 | 304 | # If false, no module index is generated. 305 | #texinfo_domain_indices = True 306 | 307 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 308 | #texinfo_show_urls = 'footnote' 309 | 310 | # If true, do not generate a @detailmenu in the "Top" node's menu. 311 | #texinfo_no_detailmenu = False 312 | 313 | def setup(app): 314 | # app.add_stylesheet('custom.css') 315 | pass 316 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to telegram-upload's documentation! 2 | =========================================== 3 | Telegram-upload uses your personal Telegram account to upload and download files up to 4 GiB (2 GiB for free users). 4 | Turn Telegram into your personal cloud! 5 | 6 | 7 | To **install** telegram-upload, run this command in your terminal: 8 | 9 | .. code-block:: console 10 | 11 | $ pip install -U telegram-upload 12 | 13 | 14 | Contents 15 | -------- 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :glob: 20 | 21 | installation 22 | readme 23 | usage 24 | caption_format 25 | supported_file_types 26 | upload_benchmark 27 | troubleshooting 28 | contributing 29 | authors 30 | history 31 | 32 | 33 | .. 34 | _ modules 35 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: console 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install telegram-upload, run these commands in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ sudo pip3 install -U telegram-upload 16 | 17 | This is the preferred method to install telegram-upload, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | Other releases 27 | -------------- 28 | You can install other versions from Pypi using:: 29 | 30 | $ pip install telegram-upload== 31 | 32 | For versions that are not in Pypi (it is a development version):: 33 | 34 | $ pip install git+https://github.com/Nekmo/telegram-upload.git@#egg=telegram_upload 35 | 36 | 37 | If you do not have git installed:: 38 | 39 | $ pip install https://github.com/Nekmo/telegram-upload/archive/.zip 40 | 41 | Docker 42 | ====== 43 | Run telegram-upload without installing it on your system using Docker. Instead of ``telegram-upload`` 44 | and ``telegram-download`` you should use ``upload`` and ``download``. Usage:: 45 | 46 | 47 | docker run -v :/files/ 48 | -v :/config/ 49 | -it nekmo/telegram-upload:master 50 | 51 | 52 | * ````: Upload or download directory. 53 | * ````: Directory that will be created to store the telegram-upload configuration. 54 | It is created automatically. 55 | * ````: ``upload`` and ``download``. 56 | * ````: ``telegram-upload`` and ``telegram-download`` arguments. 57 | 58 | For example:: 59 | 60 | docker run -v /media/data/:/files/ 61 | -v $PWD/config:/config/ 62 | -it nekmo/telegram-upload:master 63 | upload file_to_upload.txt 64 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/supported_file_types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bien/env python 2 | import os 3 | import shutil 4 | import tempfile 5 | from typing import Tuple, List 6 | 7 | import click 8 | import requests 9 | from telethon.tl.types import Message 10 | 11 | from telegram_upload.client import TelegramManagerClient 12 | from telegram_upload.config import default_config 13 | from telegram_upload.upload_files import NoLargeFiles 14 | 15 | VIDEO_FILE_EXTENSIONS = [ 16 | "3gp", "asf", "avi", "f4v", "flv", "hevc", "m2ts", "m2v", "m4v", "mjpeg", "mkv", "mov", "mp4", "mpeg", "mpg", "mts", 17 | "mxf", "ogv", "rm", "ts", "vob", "webm", "wmv", "wtv" 18 | ] 19 | AUDIO_FILE_EXTENSIONS = [ 20 | "8svx", "aac", "ac3", "aiff", "amb", "au", "avr", "caf", "cdda", "cvs", "cvsd", "cvu", "dts", "dvms", "fap", 21 | "flac", "fssd", "gsrt", "hcom", "htk", "ima", "ircam", "m4a", "m4r", "maud", "mp2", "mp3", "nist", "oga", "ogg", 22 | "opus", "paf", "prc", "pvf", "ra", "sd2", "sln", "smp", "snd", "sndr", "sndt", "sou", "sph", "spx", "tta", "txw", 23 | "vms", "voc", "vox", "w64", "wav", "wma", "wv", "wve", 24 | ] 25 | VIDEO_CAPTION_MESSAGE = "{file.name}\n" \ 26 | "{file.media.title} ({file.media.producer})\n" \ 27 | "{file.media.width}x{file.media.height}px {file.media.duration.for_humans}" 28 | AUDIO_CAPTION_MESSAGE = "{file.name}\n" \ 29 | "{file.media.title} ({file.media.producer})\n" \ 30 | "{file.media.artist} - {file.media.album}\n" \ 31 | "{file.media.duration.for_humans}" 32 | VIDEO_URL = "https://filesamples.com/samples/video/{extension}/sample_960x400_ocean_with_audio.{extension}" 33 | AUDIO_URL = "https://filesamples.com/samples/audio/{extension}/sample4.{extension}" 34 | CHUNK_SIZE = 8192 35 | 36 | 37 | def download_file(url: str, directory: str = "") -> str: 38 | """Download a file from a URL and save it in the specified directory""" 39 | local_filename = url.split('/')[-1] 40 | local_filename = os.path.join(directory, local_filename) 41 | if os.path.lexists(local_filename): 42 | return local_filename 43 | tmp_local_filename = local_filename + '.tmp' 44 | with requests.get(url, stream=True) as r: 45 | r.raise_for_status() 46 | with open(tmp_local_filename, 'wb') as f: 47 | for chunk in r.iter_content(chunk_size=8192): 48 | f.write(chunk) 49 | shutil.move(tmp_local_filename, local_filename) 50 | return local_filename 51 | 52 | 53 | def download_video_file(extension: str, directory: str = "") -> str: 54 | """Download a video file from an URL and save it in the specified directory""" 55 | url = VIDEO_URL.format(extension=extension) 56 | return download_file(url, directory) 57 | 58 | 59 | def download_audio_file(extension: str, directory: str = "") -> str: 60 | """Download an audio file from an URL and save it in the specified directory""" 61 | url = AUDIO_URL.format(extension=extension) 62 | return download_file(url, directory) 63 | 64 | 65 | def download_extension_file(extension: str, directory: str = "") -> Tuple[str, str]: 66 | """Download a file from an URL and save it in the specified directory""" 67 | if extension in VIDEO_FILE_EXTENSIONS: 68 | return download_video_file(extension, directory), "video" 69 | elif extension in AUDIO_FILE_EXTENSIONS: 70 | return download_audio_file(extension, directory), "audio" 71 | else: 72 | raise ValueError(f"Unsupported extension {extension}") 73 | 74 | 75 | def upload_extension_file(client: TelegramManagerClient, extension: str, directory: str = "") -> List[Message]: 76 | """Upload a file to Telegram by extension""" 77 | path, media_type = download_extension_file(extension, directory) 78 | if media_type == "audio": 79 | caption = AUDIO_CAPTION_MESSAGE 80 | elif media_type == "video": 81 | caption = VIDEO_CAPTION_MESSAGE 82 | else: 83 | raise ValueError(f"Unsupported media type {media_type}") 84 | return client.send_files("me", NoLargeFiles(client, [path], caption=caption)) 85 | 86 | 87 | @click.command() 88 | @click.option('--extension', '-e', default="", help='Extension of the file to upload') 89 | @click.option('--directory', '-d', default="", help='Directory where to save the file') 90 | def upload_file(extension: str = "", directory: str = ""): 91 | """Upload a file to Telegram by extension""" 92 | extensions = [] 93 | if not directory: 94 | directory = tempfile.gettempdir() 95 | if extension: 96 | extensions.append(extension) 97 | else: 98 | extensions = VIDEO_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS 99 | client = TelegramManagerClient(default_config()) 100 | client.start() 101 | for extension in extensions: 102 | click.echo(f"Uploading {extension} file") 103 | try: 104 | upload_extension_file(client, extension, directory) 105 | except Exception as e: 106 | click.echo(f"Error uploading {extension} file: {e}", err=True) 107 | 108 | 109 | if __name__ == '__main__': 110 | upload_file() 111 | -------------------------------------------------------------------------------- /docs/troubleshooting.rst: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | Videos are not streameable in Telegram app 5 | ------------------------------------------- 6 | **Only some videos can be played on Telegram without downloading them first**. To stream your video in Telegram you must 7 | convert it before uploading it. For example you can use ffmpeg to convert your video to mp4:: 8 | 9 | $ ffmpeg -i input.mov -preset slow -codec:a libfdk_aac -b:a 128k \ 10 | -codec:v libx264 -pix_fmt yuv420p -b:v 2500k -minrate 1500k \ 11 | -maxrate 4000k -bufsize 5000k -vf scale=-1:720 output.mp4 12 | 13 | You can see the :ref:`supported_file_types` reference for more information. 14 | 15 | Database is locked 16 | ------------------ 17 | Telegram-upload is already running, or an old process **has locked the session** (``telegram-upload.session``). Only one 18 | Telegram-upload session can be run at a time. 19 | 20 | **If you need to run Telegram-upload multiple times anyway**, you need to duplicate the session and config files: 21 | 22 | 1. Copy the session file (``~/.config/telegram-upload.session``) to another path. 23 | 2. Copy the configuration file (``~/.config/telegram-upload.json``) to another path. 24 | 3. Edit this file and add the path to session file like this: ``{"api_id": 0, "api_hash": 25 | "...", "session": "/path/to/telegram-upload.json"}``. 26 | 4. Run using ``--config /path/to/telegram-upload.json``. 27 | 28 | If you are sure that Telegram-upload is not running, search for the process that is blocking the file:: 29 | 30 | fuser ~/.config/telegram-upload.session 31 | 32 | As a last resort, you can restart your machine. 33 | 34 | .. _troubleshooting_429_errors: 35 | 36 | I am getting 429 errors during upload 37 | ------------------------------------- 38 | Since version v0.7.0 Telegram-upload uploads several parts of the file in parallel. Become of this, the Telegram API 39 | can become overloaded and return 429 errors. This is normal and you don't have to worry. If you are getting too many of 40 | these errors, you can try to reduce the number of parallel uploads using the ``PARALLEL_UPLOAD_BLOCKS`` environment 41 | variable. For example:: 42 | 43 | $ PARALLEL_UPLOAD_BLOCKS=2 telegram-upload video.mkv 44 | 45 | Or exporting the variable:: 46 | 47 | $ export PARALLEL_UPLOAD_BLOCKS=2 48 | $ telegram-upload video.mkv 49 | 50 | The **default value is 4**. Values above this value can increase the number of 429 errors. Telegram-upload in case of 51 | an error will try to reconnect to the API before ``TELEGRAM_UPLOAD_MIN_RECONNECT_WAIT`` seconds. The default value is 2. 52 | This value will be increased with each retry. Each retry will decrease the number of ``PARALLEL_UPLOAD_BLOCKS``. The 53 | minimum of ``PARALLEL_UPLOAD_BLOCKS`` is one. Telegram-upload will retry connecting up to 54 | ``TELEGRAM_UPLOAD_MAX_RECONNECT_RETRIES`` times. The default value is 5. Each retry has a maximum wait time of 55 | ``TELEGRAM_UPLOAD_RECONNECT_TIMEOUT`` seconds before failing. All of these variables can be defined using environment 56 | variables. 57 | 58 | Read more about the parallel chunks in the :ref:`upload_benchmark` section. 59 | 60 | Telegram-upload does not work! An error occurs when executing it 61 | ----------------------------------------------------------------- 62 | Telegram-upload is not tested with all versions of all dependencies it uses. If you have installed Telegram-upload 63 | on your system (using root) maybe some existing dependency is on an incompatible version. You can try updating the 64 | dependencies carefully:: 65 | 66 | $ pip install -U telegram-upload Telethon hachoir cryptg click 67 | 68 | To avoid errors it is recommended to use `virtualenvs `_. 69 | 70 | Before asking for help, remember to find out if `the issue already exists `_. If you open a ticket remember to paste your system dependencies on the issue:: 72 | 73 | $ pip freeze 74 | 75 | Some problems may not be related to Telegram-upload. If possible, `Google before asking `_. 76 | 77 | Telegram-upload is very slow uploading files! 78 | --------------------------------------------- 79 | Telegram-upload since version v0.7.0 uploads several parts of the file in parallel. This can increase the upload speed 80 | of the files. By default it uploads 4 parts of the file in parallel. You can change this value using the 81 | ``PARALLEL_UPLOAD_BLOCKS`` environment variable (read more about this in the :ref:`troubleshooting_429_errors` section). 82 | Make sure you have updated Telegram-upload to the latest version and you have ``libssl`` installed on your system and 83 | ``cryptg`` installed on your Python environment. 84 | 85 | Read more about the Telegram-upload speed in the :ref:`upload_benchmark` section. 86 | -------------------------------------------------------------------------------- /docs/upload_benchmark.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import asyncio 3 | import json 4 | import os 5 | import time 6 | from itertools import groupby, chain 7 | from statistics import mean, median 8 | from tempfile import NamedTemporaryFile 9 | from typing import Callable, Optional, TypedDict, List, cast 10 | 11 | import click 12 | from tabulate import tabulate 13 | from telethon.tl.patched import Message 14 | import matplotlib.pyplot as plt 15 | 16 | from telegram_upload.caption_formatter import FileSize 17 | from telegram_upload.client import TelegramManagerClient 18 | from telegram_upload.config import default_config 19 | from telegram_upload.upload_files import NoLargeFiles 20 | 21 | CHUNK = 1024 * 4 22 | REPEATS = 5 23 | BENCHMARKS = { 24 | "small": (1024 * 512, 3, 10), 25 | "medium": (1024 * 1024 * 20, 10, 10), 26 | "large": (1024 * 1024 * 200, 30, 5), 27 | "full": (1024 * 1024 * 1024 * 2, 90, 5), 28 | } 29 | PARALLELS = range(1, 11) 30 | DEFAULT_PARALLEL = 4 31 | RESULTS_FILE = 'upload_benchmark.json' 32 | 33 | 34 | class BenchmarkResultBreakdown(TypedDict): 35 | """Benchmark result breakdown dict""" 36 | minimum: float 37 | maximum: float 38 | average: float 39 | median: float 40 | times: List[float] 41 | 42 | 43 | class BenchmarkResult(TypedDict): 44 | """Benchmark result dict""" 45 | size: int 46 | parallel: int 47 | benchmark: BenchmarkResultBreakdown 48 | 49 | 50 | def create_file(size: int) -> str: 51 | """Create a file with the specified size""" 52 | file = NamedTemporaryFile(delete=False) 53 | with file: 54 | for i in range(0, size, CHUNK): 55 | file.write(b"\x00" * CHUNK) 56 | return file.name 57 | 58 | 59 | def upload_file(client: TelegramManagerClient, path) -> Message: 60 | """Upload a file to Telegram""" 61 | messages = client.send_files("me", NoLargeFiles(client, [path])) 62 | if messages: 63 | return messages[0] 64 | 65 | 66 | class Benchmark: 67 | """Benchmark a function.""" 68 | def __init__(self, callback: Callable, repeats: int = REPEATS, wait: int = 0): 69 | """Initialize the benchmark""" 70 | self.callback = callback 71 | self.repeats = repeats 72 | self.times = [] 73 | self.results = [] 74 | self.wait = wait 75 | 76 | def __call__(self): 77 | for i in range(self.repeats): 78 | start = time.time() 79 | output = self.callback() 80 | end = time.time() 81 | self.results.append(output) 82 | self.times.append(end - start) 83 | if self.wait: 84 | time.sleep(self.wait) 85 | 86 | @property 87 | def average(self) -> float: 88 | """Return the average time""" 89 | if not self.times: 90 | return .0 91 | return mean(self.times) 92 | 93 | @property 94 | def median(self) -> float: 95 | """Return the median time""" 96 | if not self.times: 97 | return .0 98 | return median(self.times) 99 | 100 | @property 101 | def minimum(self) -> float: 102 | """Return the minimum time""" 103 | return min(self.times) 104 | 105 | @property 106 | def maximum(self) -> float: 107 | """Return the maximum time""" 108 | return max(self.times) 109 | 110 | 111 | def benchmark_file_size(client: TelegramManagerClient, size: int, repeats: int = REPEATS, wait: int = 0, 112 | parallel: Optional[int] = None) -> BenchmarkResult: 113 | """Benchmark the upload of a file of the specified size""" 114 | # reset parallel upload blocks 115 | parallel = cast(int, parallel or DEFAULT_PARALLEL) 116 | client.parallel_upload_blocks = parallel 117 | client.reconnecting_lock = asyncio.Lock() 118 | client.upload_semaphore = asyncio.Semaphore(parallel) 119 | # create file 120 | path = create_file(size) 121 | # benchmark upload 122 | benchmark = Benchmark(lambda: upload_file(client, path), repeats, wait) 123 | benchmark() 124 | click.echo(f"Size: {size} bytes - Parallel: {parallel}") 125 | click.echo(f"Median: {benchmark.median} seconds") 126 | click.echo(f"Average: {benchmark.average} seconds") 127 | click.echo(f"Minimum: {benchmark.minimum} seconds") 128 | click.echo(f"Maximum: {benchmark.maximum} seconds") 129 | click.echo("=" * 80 + "\n") 130 | os.remove(path) 131 | for message in benchmark.results: 132 | message.delete() 133 | return { 134 | "size": size, 135 | "parallel": parallel, 136 | "benchmark": { 137 | "minimum": benchmark.minimum, 138 | "maximum": benchmark.maximum, 139 | "average": benchmark.average, 140 | "median": benchmark.median, 141 | "times": benchmark.times, 142 | } 143 | } 144 | 145 | 146 | def save_rst_size_table(key: int, grouped: List[BenchmarkResult]): 147 | """Save a table with the benchmark results for a specific size in RST format""" 148 | filesize = FileSize(key) 149 | maximum = max([x["benchmark"]["maximum"] for x in grouped]) 150 | minimum = min([x["benchmark"]["minimum"] for x in grouped]) 151 | average = mean([x["benchmark"]["average"] for x in grouped]) 152 | median_ = median([x["benchmark"]["median"] for x in grouped]) 153 | table = tabulate( 154 | [ 155 | [ 156 | str(x["parallel"]) + (" chunks" if x["parallel"] > 1 else " chunk"), 157 | f"{x['benchmark']['minimum']:.2f} sec.", 158 | f"{x['benchmark']['maximum']:.2f} sec.", 159 | f"{x['benchmark']['average']:.2f} sec.", 160 | f"{x['benchmark']['median']:.2f} sec.", 161 | f"{FileSize(key / x['benchmark']['median']).for_humans}/s", 162 | ] for x in grouped 163 | ], 164 | headers=["Parallel", "Minimum", "Maximum", "Average", "Median", "Speed (MiB/s)"], 165 | tablefmt="rst", floatfmt=".3f" 166 | ) 167 | with open(f"benchmark_{filesize.for_humans.replace(' ', '_')}.rst", 'w') as file: 168 | output = f"{table}\n\n" \ 169 | f"* **Minimum time:** {minimum:.2f} sec. ({FileSize(key / minimum).for_humans}/s)\n" \ 170 | f"* **Maximum time:** {maximum:.2f} sec. ({FileSize(key / maximum).for_humans}/s)\n" \ 171 | f"* **Average time:** {average:.2f} sec. ({FileSize(key / average).for_humans}/s)\n" \ 172 | f"* **Median time:** {median_:.2f} sec. ({FileSize(key / median_).for_humans}/s)\n" 173 | file.write(output) 174 | 175 | 176 | def save_rst_table(results: List[BenchmarkResult]): 177 | """Save a table with the benchmark results in RST format""" 178 | table = tabulate( 179 | chain(*[[[ 180 | FileSize(x["size"]).for_humans, 181 | str(x["parallel"]) + (" chunks" if x["parallel"] > 1 else " chunk"), 182 | f"{t:.2f} sec.", 183 | f"{FileSize(x['size'] / t).for_humans}/s", 184 | ] for t in x["benchmark"]["times"]] for x in results]), 185 | headers=["Filesize", "Parallel", "Time", "Speed"], 186 | tablefmt="rst", floatfmt=".3f" 187 | ) 188 | with open(f"benchmark_full.rst", 'w') as file: 189 | file.write(table) 190 | 191 | 192 | @click.group() 193 | def cli(): 194 | """Console script for requirements-rating.""" 195 | pass 196 | 197 | 198 | @cli.command() 199 | @click.option('--repeats', '-r', default=None, type=int, help='Number of repeats') 200 | @click.option('--benchmark', '-b', default=None, type=click.Choice(list(BENCHMARKS.keys())), help='Benchmark name') 201 | @click.option('--parallel', '-p', default=None, type=int, help='Parallel parts uploaded') 202 | @click.option('--results-file', '-f', default=RESULTS_FILE, type=str, help='JSON results file') 203 | def benchmark(repeats, benchmark, parallel, results_file): 204 | client = TelegramManagerClient(default_config()) 205 | client.start() 206 | if benchmark: 207 | benchmarks = [BENCHMARKS[benchmark]] 208 | else: 209 | benchmarks = list(BENCHMARKS.values()) 210 | if parallel: 211 | parallels = [parallel] 212 | else: 213 | parallels = PARALLELS 214 | results = [] 215 | for size, wait, def_repeats in benchmarks: 216 | for parallel in parallels: 217 | benchmark_result = benchmark_file_size(client, size, repeats or def_repeats, wait, parallel) 218 | results.append(benchmark_result) 219 | with open(results_file, 'w') as file: 220 | json.dump(results, file, indent=4) 221 | 222 | 223 | @cli.command() 224 | @click.option('--results-file', '-f', default=RESULTS_FILE, type=click.Path(exists=True, dir_okay=False), 225 | help='JSON results file') 226 | def graphs(results_file): 227 | with open(results_file, 'r') as file: 228 | results: List[BenchmarkResult] = json.load(file) 229 | results_grouped = groupby(results, lambda x: x["size"]) 230 | for key, grouped in results_grouped: 231 | grouped = list(grouped) 232 | fig, ax = plt.subplots() 233 | filesize = FileSize(key) 234 | ax.errorbar( 235 | [x["parallel"] for x in grouped], 236 | [x["benchmark"]["median"] for x in grouped], 237 | capsize=4, 238 | yerr=[[x["benchmark"]["median"] - x["benchmark"]["minimum"] for x in grouped], 239 | [x["benchmark"]["maximum"] - x["benchmark"]["median"] for x in grouped]], 240 | marker='.', 241 | label=filesize.for_humans) 242 | plt.legend() 243 | plt.xlabel("Parallel parts") 244 | plt.ylabel("Time (seconds)") 245 | plt.grid() 246 | plt.title(f"Upload time for {filesize.for_humans} file (less time is better)") 247 | plt.savefig(f"benchmark_{filesize.for_humans.replace(' ', '_')}.png", dpi=150) 248 | 249 | 250 | @cli.command() 251 | @click.option('--results-file', '-f', default=RESULTS_FILE, type=click.Path(exists=True, dir_okay=False), 252 | help='JSON results file') 253 | def rst(results_file): 254 | with open(results_file, 'r') as file: 255 | results: List[BenchmarkResult] = json.load(file) 256 | results_grouped = groupby(results, lambda x: x["size"]) 257 | for key, grouped in results_grouped: 258 | grouped = list(grouped) 259 | save_rst_size_table(key, grouped) 260 | save_rst_table(results) 261 | 262 | 263 | if __name__ == '__main__': 264 | cli() 265 | -------------------------------------------------------------------------------- /docs/upload_benchmark.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _upload_benchmark: 3 | 4 | Upload benchmarks 5 | ================= 6 | The following results are a study about the performance of Telegram-upload uploading files. The results are not 7 | intended to be exhaustive and are subject to errors for multiple reasons. Some of them are: 8 | 9 | * The Telegram status *(e.g. the server load)* at the time of the test. 10 | * The network status at the time of the test *(the contracted bandwidth is 600 Mibps)*. 11 | * The hardware used for the test *(in my case a PC with an Intel i7-3770K CPU @ 3.50GHz and 20 GiB of RAM)*. 12 | * The machine load at the time of the test. 13 | 14 | The tests were performed using different file sizes. The file sizes were 512 KiB, 20 MiB, 200 MiB and 2 GiB. The chunk 15 | size was the default in Telegram-upload. The chunk size vary depending on the file size: 16 | 17 | * *128 KiB* for files smaller than *100 MiB*. 18 | * *256 KiB* for files smaller than *750 MiB*. 19 | * *512 KiB* for files bigger than *750 MiB*. 20 | 21 | The tests were performed using different number of parallel chunks uploaded at the same time. By default 22 | Telegram-upload uploads *4 chunks at the same time*. You can change this value using the ``PARALLEL_UPLOAD_BLOCKS`` 23 | environment variable. For example:: 24 | 25 | $ PARALLEL_UPLOAD_BLOCKS=2 telegram-upload video.mkv 26 | 27 | Or exporting the variable:: 28 | 29 | $ export PARALLEL_UPLOAD_BLOCKS=2 30 | $ telegram-upload video.mkv 31 | 32 | Note that increasing the number of parallel chunks uploaded at the same time will increase the CPU usage and can 33 | increase the number of 429 errors. These errors are caused by Telegram after exceeding the server's resource limits. 34 | 35 | These tests can help you to choose the best number of parallel chunks uploaded at the same time for your use case. All 36 | the tests were performed using 1, 2, 3, 4, 5, 6, 7, 8, 9 and 10 parallel chunks uploaded at the same time. 37 | 38 | You can run the tests yourself using the ``upload_benchmark.py`` script in the ``docs`` directory. This script will 39 | upload a file to Telegram using your account and will measure the time it takes to upload the file. To run the script:: 40 | 41 | $ python3 ./upload_benchmark.py benchmark 42 | 43 | This script will create a ``upload_benchmark.json`` file in the ``docs`` directory with the results. You can use the 44 | ``upload_benchmark.py`` script to plot the results using the ``graphs`` command:: 45 | 46 | $ python3 ./upload_benchmark.py graphs 47 | 48 | The above command will create the images in the same directory. For create the rst tables you can use the ``rst`` 49 | command:: 50 | 51 | $ python3 ./upload_benchmark.py rst 52 | 53 | The following results were obtained using the ``upload_benchmark.py`` script. 54 | 55 | 56 | Small files (512 KiB) 57 | --------------------- 58 | The following table shows the time it takes to upload a 512 KiB file using different number of parallel chunks. 59 | 60 | .. include:: benchmark_512.0_KiB.rst 61 | 62 | Each file is **uploaded 10 times** to obtain the minimum, maximum, the average and the median time. The data can be 63 | visualized in the following graph: 64 | 65 | .. image:: benchmark_512.0_KiB.png 66 | :width: 100% 67 | :alt: 512.0 KiB benchmark graph 68 | 69 | Observing the results from 4 blocks in parallel there is no improvement in the upload time. This is because the file 70 | size is 512 KiB and the chunk size is 128 KiB. This means that the file is uploaded in 4 chunks 71 | *(512 KiB / 128 KiB = 4)*. The small ups and downs are due to external factors. 72 | 73 | 74 | Medium files (20 MiB) 75 | --------------------- 76 | The following table shows the time it takes to upload a 20 MiB file using different number of parallel chunks. 77 | 78 | .. include:: benchmark_20.0_MiB.rst 79 | 80 | Each file is **uploaded 10 times** to obtain the minimum, maximum, the average and the median time. The data can be 81 | visualized in the following graph: 82 | 83 | .. image:: benchmark_20.0_MiB.png 84 | :width: 100% 85 | :alt: 20.0 MiB benchmark graph 86 | 87 | The speed boost decreases following a negative exponential curve. The improvement between 1 and 2 parts in parallel is 88 | noticeable. Increasing the number of parts in parallel the improvement is less and less. With this file size the chunk 89 | size is 128 KiB. 90 | 91 | 92 | Big files (200 MiB) 93 | ------------------- 94 | The following table shows the time it takes to upload a 200 MiB file using different number of parallel chunks. 95 | 96 | .. include:: benchmark_200.0_MiB.rst 97 | 98 | Each file is **uploaded 5 times** to obtain the minimum, maximum, the average and the median time. The data can be 99 | visualized in the following graph: 100 | 101 | .. image:: benchmark_200.0_MiB.png 102 | :width: 100% 103 | :alt: 200.0 MiB benchmark graph 104 | 105 | The speed boost decreases following a negative exponential curve. The improvement between 1 and 2 parts in parallel is 106 | noticeable. Increasing the number of parts in parallel the improvement is less and less. With this file size the chunk 107 | size is 256 KiB. 108 | 109 | 110 | Full size files (2 GiB) 111 | ----------------------- 112 | The following table shows the time it takes to upload a 2 GiB file using different number of parallel chunks. 113 | 114 | .. include:: benchmark_2.0_GiB.rst 115 | 116 | Each file is **uploaded 5 times** to obtain the minimum, maximum, the average and the median time. The data can be 117 | visualized in the following graph: 118 | 119 | .. image:: benchmark_2.0_GiB.png 120 | :width: 100% 121 | :alt: 2.0 GiB benchmark graph 122 | 123 | The speed boost decreases following a negative exponential curve. The improvement between 1 and 2 parts in parallel is 124 | noticeable. Increasing the number of parts in parallel the improvement is less and less. With this file size the chunk 125 | size is 512 KiB. 126 | 127 | 128 | Complete results 129 | ---------------- 130 | The following table shows the time it takes to upload a 512 KiB, 20 MiB, 200 MiB and 2 GiB files using different number 131 | of parallel chunks. Unlike the previous tables, all the data is included. 132 | 133 | .. include:: benchmark_full.rst 134 | 135 | The results are also available in the ``docs`` directory in the ``upload_benchmark.json`` file. 136 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | 2 | Usage 3 | ##### 4 | 5 | .. click:: telegram_upload.management:upload 6 | :prog: telegram-upload 7 | :show-nested: 8 | 9 | 10 | .. click:: telegram_upload.management:download 11 | :prog: telegram-download 12 | :show-nested: 13 | 14 | Set recipient or sender 15 | ======================= 16 | By default when using *telegram-upload* without specifying the recipient or sender, *telegram-upload* will use your 17 | personal chat. This is especially useful because you can use it to upload files from telegram-upload and then forward 18 | them from your personal chat to as many groups as you like. However you can define the destination. For file upload the 19 | argument is ``--to ``: 20 | 21 | .. code-block:: 22 | 23 | ~ $ telegram-upload --to [ ] 24 | 25 | You can *download files* from a specific chat using the ``--from `` parameter: 26 | 27 | .. code-block:: 28 | 29 | ~ $ telegram-download --from 30 | 31 | The entity can be defined in multiple ways: 32 | 33 | * **Username or groupname**: use the public username or groupname. For example: *john*. 34 | * **Public link**: the public user or group link. For example: *https://telegram.dog/john*. 35 | * **Private link**: the private group link. For example: *telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg*. 36 | * **Telephone**: the user telephone. For example: *+34600000000*. 37 | * **Telegram id**: the user or group telegram id. Use a bot like *@getidsbot* for get the id. For example: *-987654321* 38 | or *123456789*. 39 | 40 | Interactive mode 41 | ================ 42 | Use the ``-i`` (or ``--interactive``) option to activate the **interactive mode** to choose the dialog (chat, 43 | channel...) and the files. To **upload files** using interactive mode: 44 | 45 | $ telegram-upload -i 46 | 47 | To **download files** using interactive mode: 48 | 49 | $ telegram-download -i 50 | 51 | The following keys are available in this mode: 52 | 53 | * **Up arrow**: previous option in the list. 54 | * **Down arrow**: next option in the list. 55 | * **Spacebar**: select the current option. The selected option is marked with an asterisk. 56 | * **mouse click**: also to select the option. Some terminals may not support it. 57 | * **Enter**: go to the next wizard step. 58 | * **pageup**: go to the previous page of items. Allows quick navigation.. 59 | * **pagedown**: go to the next page of items. Allows quick navigation.. 60 | 61 | Interactive upload 62 | ------------------ 63 | This wizard has two steps. The *first step* chooses the files to upload. You can choose several files:: 64 | 65 | Select the local files to upload: 66 | [SPACE] Select file [ENTER] Next step 67 | [ ] myphoto1.jpg 68 | [ ] myphoto2.jpg 69 | [ ] myphoto3.jpg 70 | 71 | The *second step* chooses the conversation:: 72 | 73 | Select the dialog of the files to download: 74 | [SPACE] Select dialog [ENTER] Next step 75 | ( ) Groupchat 1 76 | ( ) Bob's chat 77 | ( ) A channel 78 | ( ) Me 79 | 80 | 81 | Interactive download 82 | -------------------- 83 | This wizard has two steps. The *first step* chooses the conversation:: 84 | 85 | Select the dialog of the files to download: 86 | [SPACE] Select dialog [ENTER] Next step 87 | ( ) Groupchat 1 88 | ( ) Bob's chat 89 | ( ) A channel 90 | ( ) Me 91 | 92 | 93 | The *second step* chooses the files to download. You can choose several files:: 94 | 95 | Select all files to download: 96 | [SPACE] Select files [ENTER] Download selected files 97 | [ ] image myphoto3.jpg by My Username @username 2022-01-31 02:15:07+00:00 98 | [ ] image myphoto2.jpg by My Username @username 2022-01-31 02:15:05+00:00 99 | [ ] image myphoto1.png by My Username @username 2022-01-31 02:15:03+00:00 100 | 101 | 102 | Proxies 103 | ======= 104 | You can use **mtproto proxies** without additional dependencies or **socks4**, **socks5** or **http** proxies 105 | installing ``pysocks``. To install it:: 106 | 107 | $ pip install pysocks 108 | 109 | To define the proxy you can use the ``--proxy`` parameter:: 110 | 111 | $ telegram-upload image.jpg --proxy mtproxy://secret@proxy.my.site:443 112 | 113 | Or you can define one of these variables: ``TELEGRAM_UPLOAD_PROXY``, ``HTTPS_PROXY`` or ``HTTP_PROXY``. To define the 114 | environment variable from terminal:: 115 | 116 | $ export HTTPS_PROXY=socks5://user:pass@proxy.my.site:1080 117 | $ telegram-upload image.jpg 118 | 119 | 120 | Parameter ``--proxy`` has higher priority over environment variables. The environment variable 121 | ``TELEGRAM_UPLOAD_PROXY`` takes precedence over ``HTTPS_PROXY`` and it takes precedence over ``HTTP_PROXY``. To disable 122 | the OS proxy:: 123 | 124 | $ export TELEGRAM_UPLOAD_PROXY= 125 | $ telegram-upload image.jpg 126 | 127 | The syntax for **mproto proxy** is:: 128 | 129 | mtproxy://@
: 130 | 131 | For example:: 132 | 133 | mtproxy://secret@proxy.my.site:443 134 | 135 | The syntax for **socks4**, **socks5** and **http** proxy is:: 136 | 137 | ://[:@]
: 138 | 139 | An example without credentials:: 140 | 141 | http://1.2.3.4:80 142 | 143 | An example with credentials:: 144 | 145 | socks4://user:pass@proxy.my.site:1080 146 | 147 | Caption message 148 | =============== 149 | You can add a caption message to the file to upload using the ``--caption`` parameter:: 150 | 151 | $ telegram-upload image.jpg --caption "This is a caption" 152 | 153 | This parameter support variables using the ``{}`` syntax. For example:: 154 | 155 | $ telegram-upload image.jpg --caption "This is a caption for {file.stem.capitalize}" 156 | 157 | The ``{file}`` variable is the file path. The ``{file.stem}`` variable is the file name without extension. The 158 | ``{file.stem.capitalize}`` variable is the file name without extension with the first letter in uppercase. The 159 | ``{file}`` variable has attributes for get info about the file like their size, their creation date, their checksums 160 | (md5, sha1, sha256...), their media info (width, height, artist...) and more. For example:: 161 | 162 | $ telegram-upload image.jpg --caption "{file.media.width}x{file.media.height}px {file.media.duration.for_humans}" 163 | 164 | If you want to use the ``{}`` syntax in the caption message, you can escape it using the brace twice. For example:: 165 | 166 | $ telegram-upload image.jpg --caption "This is a caption with {{}}" 167 | 168 | For get more info about the variables, see the :ref:`caption_format` section. 169 | 170 | Split files 171 | =========== 172 | By default, when trying to **upload** a file larger than the supported size by Telegram, an error will occur. However, 173 | *Telegram-upload* has different policies for large files using the ``--large-files`` parameter: 174 | 175 | * ``fail`` (default): The execution of telegram-upload is stopped and the uploads are not continued. 176 | * ``split``: The files are split as parts. For example *myfile.tar.00*, *myfile.tar.01*... 177 | 178 | The syntax is: 179 | 180 | .. code-block:: 181 | 182 | ~$ telegram-upload --large-files 183 | 184 | To join the split files using the *split* option, you can use in GNU/Linux: 185 | 186 | .. code-block:: bash 187 | 188 | $ cat myfile.tar.* > myfile.tar 189 | 190 | In windows there are different programs like `7z `_ or `GSplit `_. 191 | 192 | *Telegram-upload* when downloading split files by default will download the files without joining them. However, the 193 | **download** policy can be changed using the ``--split-files`` parameter: 194 | 195 | * ``keep`` (default): Files are downloaded without joining. 196 | * ``join``: Downloaded files are merged after downloading. In case of errors, such as missing files, the keep policy 197 | is used. 198 | 199 | The syntax is: 200 | 201 | .. code-block:: 202 | 203 | $ telegram-download --split-files 204 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/logo.png -------------------------------------------------------------------------------- /logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/logo.xcf -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | bumpversion 3 | sphinx-click 4 | tox>=1.8 5 | codecov 6 | pysocks 7 | mock; python_version < '3.6' 8 | asyncmock; python_version < '3.8' 9 | async-case; python_version < '3.8' 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | telethon 2 | click>=6.0 3 | cryptg 4 | hachoir 5 | scandir; python_version<'3.6' 6 | prompt_toolkit 7 | pysocks 8 | more-itertools 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:telegram_upload/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | 22 | # Define setup.py command aliases here 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Upload (and download) files to Telegram up to 4 GiB using your account. 4 | """ 5 | import copy 6 | import os 7 | import glob 8 | from itertools import chain 9 | from setuptools import setup, find_packages 10 | 11 | AUTHOR = "Nekmo" 12 | EMAIL = 'contacto@nekmo.com' 13 | URL = 'https://github.com/Nekmo/telegram-upload/' 14 | 15 | PACKAGE_NAME = 'telegram-upload' 16 | PACKAGE_DOWNLOAD_URL = 'https://github.com/Nekmo/telegram-upload/archive/master.zip' 17 | MODULE = 'telegram_upload' 18 | REQUIREMENT_FILE = 'requirements.txt' 19 | STATUS_LEVEL = 5 # 1:Planning 2:Pre-Alpha 3:Alpha 4:Beta 5:Production/Stable 6:Mature 7:Inactive 20 | KEYWORDS = ['telegram-upload', 'telegram', 'upload', 'video'] 21 | LICENSE = 'MIT license' 22 | 23 | CLASSIFIERS = [ # https://github.com/github/choosealicense.com/tree/gh-pages/_licenses 24 | 'License :: OSI Approved :: MIT License', 25 | # 'License :: OSI Approved :: BSD License', 26 | # 'License :: OSI Approved :: ISC License (ISCL)', 27 | # 'License :: OSI Approved :: Apache Software License', 28 | # 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 29 | ] # https://pypi.python.org/pypi?%3Aaction=list_classifiers 30 | NATURAL_LANGUAGE = 'English' 31 | 32 | PLATFORMS = [ 33 | # 'universal', 34 | 'linux', 35 | # 'macosx', 36 | # 'solaris', 37 | # 'irix', 38 | # 'win' 39 | # 'bsd' 40 | # 'ios' 41 | # 'android' 42 | ] 43 | PYTHON_VERSIONS = ['3.7-3.9', '3.10', '3.11'] 44 | 45 | 46 | def read_requirement_file(path): 47 | with open(path) as f: 48 | return f.readlines() 49 | 50 | 51 | def get_package_version(module_name): 52 | return __import__(module_name).__version__ 53 | 54 | 55 | def get_packages(directory): 56 | # Search modules and submodules to install (module, module.submodule, module.submodule2...) 57 | packages_list = find_packages(directory) 58 | # Prevent include symbolic links 59 | for package in tuple(packages_list): 60 | path = os.path.join(directory, package.replace('.', '/')) 61 | if not os.path.exists(path) or os.path.islink(path): 62 | packages_list.remove(package) 63 | return packages_list 64 | 65 | 66 | def get_python_versions(string_range): 67 | if '-' not in string_range: 68 | return [string_range] 69 | return ['{0:.1f}'.format(version * 0.1) for version 70 | in range(*[int(x * 10) + (1 * i) for i, x in enumerate(map(float, string_range.split('-')))])] 71 | 72 | 73 | def get_python_classifiers(versions): 74 | for version in range(2, 4): 75 | if not next(iter(filter(lambda x: int(float(x)) != version, versions.copy())), False): 76 | versions.add('{} :: Only'.format(version)) 77 | break 78 | return ['Programming Language :: Python :: %s' % version for version in versions] 79 | 80 | 81 | def get_platform_classifiers(platform): 82 | parts = { 83 | 'linux': ('POSIX', 'Linux'), 84 | 'win': ('Microsoft', 'Windows'), 85 | 'solaris': ('POSIX', 'SunOS/Solaris'), 86 | 'aix': ('POSIX', 'Linux'), 87 | 'unix': ('Unix',), 88 | 'bsd': ('POSIX', 'BSD') 89 | }[platform] 90 | return ['Operating System :: {}'.format(' :: '.join(parts[:i+1])) 91 | for i in range(len(parts))] 92 | 93 | 94 | # paths 95 | here = os.path.abspath(os.path.dirname(__file__)) 96 | readme = glob.glob('{}/{}*'.format(here, 'README'))[0] 97 | scripts = [os.path.join('scripts', os.path.basename(script)) for script in glob.glob('{}/scripts/*'.format(here))] 98 | 99 | # Package data 100 | packages = get_packages(here) 101 | modules = list(filter(lambda x: '.' not in x, packages)) 102 | module = MODULE if MODULE else modules[0] 103 | python_versions = set(chain(*[get_python_versions(versions) for versions in PYTHON_VERSIONS])) - {2.8, 2.9} 104 | status_name = ['Planning', 'Pre-Alpha', 'Alpha', 'Beta', 105 | 'Production/Stable', 'Mature', 'Inactive'][STATUS_LEVEL - 1] 106 | 107 | # Classifiers 108 | classifiers = copy.copy(CLASSIFIERS) 109 | classifiers.extend(get_python_classifiers(python_versions)) 110 | classifiers.extend(chain(*[get_platform_classifiers(platform) for platform in PLATFORMS])) 111 | classifiers.extend([ 112 | 'Natural Language :: {}'.format(NATURAL_LANGUAGE), 113 | 'Development Status :: {} - {}'.format(STATUS_LEVEL, status_name), 114 | ]) 115 | 116 | 117 | setup( 118 | name=PACKAGE_NAME, 119 | version=get_package_version(module), 120 | packages=packages, 121 | provides=modules, 122 | scripts=scripts, 123 | include_package_data=True, 124 | 125 | description=__doc__.replace('\n', ' '), 126 | long_description=open(readme, 'r').read(), 127 | keywords=KEYWORDS, 128 | download_url=PACKAGE_DOWNLOAD_URL, 129 | 130 | author=AUTHOR, 131 | author_email=EMAIL, 132 | url=URL, 133 | 134 | classifiers=classifiers, 135 | platforms=PLATFORMS, 136 | 137 | install_requires=read_requirement_file(REQUIREMENT_FILE), 138 | 139 | entry_points={ 140 | "console_scripts": [ 141 | "telegram-upload = telegram_upload.management:upload_cli", 142 | "telegram-download = telegram_upload.management:download_cli", 143 | ], 144 | }, 145 | 146 | zip_safe=False, 147 | ) 148 | -------------------------------------------------------------------------------- /telegram-upload-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/telegram-upload-demo.gif -------------------------------------------------------------------------------- /telegram_upload/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for telegram-upload.""" 4 | 5 | __author__ = """Nekmo""" 6 | __email__ = 'contacto@nekmo.com' 7 | __version__ = '0.7.1' 8 | -------------------------------------------------------------------------------- /telegram_upload/_compat.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import sys 3 | 4 | import typing 5 | 6 | try: 7 | from os import scandir 8 | except ImportError: 9 | from scandir import scandir 10 | 11 | 12 | # https://pypi.org/project/asyncio_utils/ 13 | 14 | async def anext(iterator: typing.AsyncIterator[typing.Any], *args, **kwargs 15 | ) -> typing.Any: 16 | """Mimics the builtin ``next`` for an ``AsyncIterator``. 17 | 18 | :param iterator: An ``AsyncIterator`` to get the next value from. 19 | :param default: Can be supplied as second arg or as a kwarg. If a value is 20 | supplied in either of those positions then a 21 | ``StopAsyncIteration`` will not be raised and the 22 | ``default`` will be returned. 23 | 24 | :raises TypeError: If the input is not a :class:`collections.AsyncIterator` 25 | 26 | 27 | Example:: 28 | 29 | >>> async def main(): 30 | myrange = await arange(1, 5) 31 | for n in range(1, 5): 32 | print(n, n == await anext(myrange)) 33 | try: 34 | n = await anext(myrange) 35 | print("This should not be shown") 36 | except StopAsyncIteration: 37 | print('Sorry no more values!') 38 | 39 | >>> loop.run_until_complete(main()) 40 | 1 True 41 | 2 True 42 | 3 True 43 | 4 True 44 | Sorry no more values! 45 | 46 | 47 | """ 48 | if not isinstance(iterator, collections.AsyncIterator): 49 | raise TypeError(f'Not an AsyncIterator: {iterator}') 50 | 51 | use_default = False 52 | default = None 53 | 54 | if len(args) > 0: 55 | default = args[0] 56 | use_default = True 57 | else: 58 | if 'default' in kwargs: 59 | default = kwargs['default'] 60 | use_default = True 61 | 62 | try: 63 | return await iterator.__anext__() 64 | except StopAsyncIteration: 65 | if use_default: 66 | return default 67 | raise StopAsyncIteration 68 | -------------------------------------------------------------------------------- /telegram_upload/caption_formatter.py: -------------------------------------------------------------------------------- 1 | import _string 2 | import datetime 3 | import hashlib 4 | import mimetypes 5 | import os 6 | import sys 7 | import zlib 8 | from pathlib import Path, PosixPath, WindowsPath 9 | from string import Formatter 10 | from typing import Any, Sequence, Mapping, Tuple, Optional 11 | 12 | import click 13 | 14 | from telegram_upload.video import video_metadata 15 | 16 | try: 17 | from typing import LiteralString 18 | except ImportError: 19 | LiteralString = str 20 | 21 | 22 | if sys.version_info < (3, 8): 23 | cached_property = property 24 | else: 25 | from functools import cached_property 26 | 27 | 28 | CHUNK_SIZE = 4096 29 | VALID_TYPES: Tuple[Any, ...] = (str, int, float, complex, bool, datetime.datetime, datetime.date, datetime.time) 30 | AUTHORIZED_METHODS = (Path.home,) 31 | AUTHORIZED_STRING_METHODS = ("title", "capitalize", "lower", "upper", "swapcase", "strip", "lstrip", "rstrip") 32 | AUTHORIZED_DT_METHODS = ( 33 | "astimezone", "ctime", "date", "dst", "isoformat", "isoweekday", "now", "time", 34 | "timestamp", "today", "toordinal", "tzname", "utcnow", "utcoffset", "weekday" 35 | ) 36 | 37 | 38 | class Duration: 39 | def __init__(self, seconds: int): 40 | self.seconds = seconds 41 | 42 | @property 43 | def as_minutes(self) -> int: 44 | return self.seconds // 60 45 | 46 | @property 47 | def as_hours(self) -> int: 48 | return self.as_minutes // 60 49 | 50 | @property 51 | def as_days(self) -> int: 52 | return self.as_hours // 24 53 | 54 | @property 55 | def for_humans(self) -> str: 56 | words = ["year", "day", "hour", "minute", "second"] 57 | 58 | if not self.seconds: 59 | return "now" 60 | else: 61 | m, s = divmod(self.seconds, 60) 62 | h, m = divmod(m, 60) 63 | d, h = divmod(h, 24) 64 | y, d = divmod(d, 365) 65 | 66 | time = [y, d, h, m, s] 67 | 68 | duration = [] 69 | 70 | for x, i in enumerate(time): 71 | if i == 1: 72 | duration.append(f"{i} {words[x]}") 73 | elif i > 1: 74 | duration.append(f"{i} {words[x]}s") 75 | 76 | if len(duration) == 1: 77 | return duration[0] 78 | elif len(duration) == 2: 79 | return f"{duration[0]} and {duration[1]}" 80 | else: 81 | return ", ".join(duration[:-1]) + " and " + duration[-1] 82 | 83 | def __int__(self) -> int: 84 | return self.seconds 85 | 86 | def __str__(self) -> str: 87 | return str(self.seconds) 88 | 89 | 90 | class FileSize: 91 | def __init__(self, size: int): 92 | self.size = size 93 | 94 | @property 95 | def as_kilobytes(self) -> int: 96 | return self.size // 1000 97 | 98 | @property 99 | def as_megabytes(self) -> int: 100 | return self.as_kilobytes // 1000 101 | 102 | @property 103 | def as_gigabytes(self) -> int: 104 | return self.as_megabytes // 1000 105 | 106 | @property 107 | def as_kibibytes(self) -> int: 108 | return self.size // 1024 109 | 110 | @property 111 | def as_mebibytes(self) -> int: 112 | return self.as_kibibytes // 1024 113 | 114 | @property 115 | def as_gibibytes(self) -> int: 116 | return self.as_mebibytes // 1024 117 | 118 | @property 119 | def for_humans(self, suffix="B") -> str: 120 | num = self.size 121 | for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): 122 | if abs(num) < 1024.0: 123 | return f"{num:3.1f} {unit}{suffix}" 124 | num /= 1024.0 125 | return f"{num:.1f} Yi{suffix}" 126 | 127 | def __int__(self) -> int: 128 | return self.size 129 | 130 | def __str__(self) -> str: 131 | return str(self.size) 132 | 133 | 134 | class FileMedia: 135 | def __init__(self, path: str): 136 | self.path = path 137 | self.metadata = video_metadata(path) 138 | 139 | @cached_property 140 | def video_metadata(self) -> Any: 141 | metadata = self.metadata 142 | meta_groups = None 143 | if hasattr(metadata, '_MultipleMetadata__groups'): 144 | # Is mkv 145 | meta_groups = metadata._MultipleMetadata__groups 146 | if metadata is not None and not metadata.has('width') and meta_groups: 147 | return meta_groups[next(filter(lambda x: x.startswith('video'), meta_groups._key_list))] 148 | return metadata 149 | 150 | @property 151 | def duration(self) -> Optional[Duration]: 152 | if self.metadata and self.metadata.has('duration'): 153 | return Duration(self.metadata.get('duration').seconds) 154 | 155 | def _get_video_metadata(self, key: str) -> Optional[Any]: 156 | if self.video_metadata and self.video_metadata.has(key): 157 | return self.video_metadata.get(key) 158 | 159 | def _get_metadata(self, key: str) -> Optional[Any]: 160 | if self.metadata and self.metadata.has(key): 161 | return self.metadata.get(key) 162 | 163 | @property 164 | def width(self) -> Optional[int]: 165 | return self._get_video_metadata('width') 166 | 167 | @property 168 | def height(self) -> Optional[int]: 169 | return self._get_video_metadata('height') 170 | 171 | @property 172 | def title(self) -> Optional[str]: 173 | return self._get_metadata('title') 174 | 175 | @property 176 | def artist(self) -> Optional[str]: 177 | return self._get_metadata('artist') 178 | 179 | @property 180 | def album(self) -> Optional[str]: 181 | return self._get_metadata('album') 182 | 183 | @property 184 | def producer(self) -> Optional[str]: 185 | return self._get_metadata('producer') 186 | 187 | 188 | class FileMixin: 189 | 190 | def _calculate_hash(self, hash_calculator: Any) -> str: 191 | with open(str(self), "rb") as f: 192 | # Read and update hash string value in blocks 193 | for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""): 194 | hash_calculator.update(byte_block) 195 | return hash_calculator.hexdigest() 196 | 197 | @property 198 | def md5(self) -> str: 199 | return self._calculate_hash(hashlib.md5()) 200 | 201 | @property 202 | def sha1(self) -> str: 203 | return self._calculate_hash(hashlib.sha1()) 204 | 205 | @property 206 | def sha224(self) -> str: 207 | return self._calculate_hash(hashlib.sha224()) 208 | 209 | @property 210 | def sha256(self) -> str: 211 | return self._calculate_hash(hashlib.sha256()) 212 | 213 | @property 214 | def sha384(self) -> str: 215 | return self._calculate_hash(hashlib.sha384()) 216 | 217 | @property 218 | def sha512(self) -> str: 219 | return self._calculate_hash(hashlib.sha512()) 220 | 221 | @property 222 | def sha3_224(self) -> str: 223 | return self._calculate_hash(hashlib.sha3_224()) 224 | 225 | @property 226 | def sha3_256(self) -> str: 227 | return self._calculate_hash(hashlib.sha3_256()) 228 | 229 | @property 230 | def sha3_384(self) -> str: 231 | return self._calculate_hash(hashlib.sha3_384()) 232 | 233 | @property 234 | def sha3_512(self) -> str: 235 | return self._calculate_hash(hashlib.sha3_512()) 236 | 237 | @property 238 | def crc32(self) -> str: 239 | with open(str(self), "rb") as f: 240 | calculated_hash = 0 241 | # Read and update hash string value in blocks 242 | for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""): 243 | calculated_hash = zlib.crc32(byte_block, calculated_hash) 244 | return "%08X" % (calculated_hash & 0xFFFFFFFF) 245 | 246 | @property 247 | def adler32(self) -> str: 248 | with open(str(self), "rb") as f: 249 | calculated_hash = 1 250 | # Read and update hash string value in blocks 251 | for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""): 252 | calculated_hash = zlib.adler32(byte_block, calculated_hash) 253 | if calculated_hash < 0: 254 | calculated_hash += 2 ** 32 255 | return hex(calculated_hash)[2:10].zfill(8) 256 | 257 | @cached_property 258 | def _file_stat(self) -> os.stat_result: 259 | return os.stat(str(self)) 260 | 261 | @cached_property 262 | def ctime(self) -> datetime.datetime: 263 | return datetime.datetime.fromtimestamp(self._file_stat.st_ctime) 264 | 265 | @cached_property 266 | def mtime(self) -> datetime.datetime: 267 | return datetime.datetime.fromtimestamp(self._file_stat.st_mtime) 268 | 269 | @cached_property 270 | def atime(self) -> datetime.datetime: 271 | return datetime.datetime.fromtimestamp(self._file_stat.st_atime) 272 | 273 | @cached_property 274 | def size(self) -> FileSize: 275 | return FileSize(self._file_stat.st_size) 276 | 277 | @cached_property 278 | def media(self) -> FileMedia: 279 | return FileMedia(str(self)) 280 | 281 | @cached_property 282 | def mimetype(self) -> Optional[str]: 283 | mimetypes.init() 284 | return mimetypes.guess_type(str(self))[0] 285 | 286 | @cached_property 287 | def suffixes(self) -> str: 288 | return "".join(super().suffixes) 289 | 290 | @property 291 | def absolute(self) -> "FilePath": 292 | return super().absolute() 293 | 294 | @property 295 | def relative(self) -> "FilePath": 296 | return self.relative_to(Path.cwd()) 297 | 298 | 299 | class FilePath(FileMixin, Path): 300 | def __new__(cls, *args, **kwargs): 301 | if cls is FilePath: 302 | cls = WindowsFilePath if os.name == 'nt' else PosixFilePath 303 | self = cls._from_parts(args) 304 | if not self._flavour.is_supported: 305 | raise NotImplementedError("cannot instantiate %r on your system" 306 | % (cls.__name__,)) 307 | return self 308 | 309 | 310 | class WindowsFilePath(FileMixin, WindowsPath): 311 | pass 312 | 313 | 314 | class PosixFilePath(FileMixin, PosixPath): 315 | pass 316 | 317 | 318 | class CaptionFormatter(Formatter): 319 | 320 | def get_field(self, field_name: str, args: Sequence[Any], kwargs: Mapping[str, Any]) -> Any: 321 | try: 322 | if "._" in field_name: 323 | raise TypeError(f'Access to private property in {field_name}') 324 | obj, first = super().get_field(field_name, args, kwargs) 325 | has_func = hasattr(obj, "__func__") 326 | has_self = hasattr(obj, "__self__") 327 | if (has_func and obj.__func__ in AUTHORIZED_METHODS) or \ 328 | (has_self and isinstance(obj.__self__, str) and obj.__name__ in AUTHORIZED_STRING_METHODS) or \ 329 | (has_self and isinstance(obj.__self__, datetime.datetime) 330 | and obj.__name__ in AUTHORIZED_DT_METHODS): 331 | obj = obj() 332 | if not isinstance(obj, VALID_TYPES + (WindowsFilePath, PosixFilePath, FilePath, FileSize, Duration)): 333 | raise TypeError(f'Invalid type for {field_name}: {type(obj)}') 334 | return obj, first 335 | except Exception: 336 | first, rest = _string.formatter_field_name_split(field_name) 337 | return '{' + field_name + '}', first 338 | 339 | def format(self, __format_string: LiteralString, *args: LiteralString, **kwargs: LiteralString) -> LiteralString: 340 | try: 341 | return super().format(__format_string, *args, **kwargs) 342 | except ValueError: 343 | return __format_string 344 | 345 | 346 | @click.command() 347 | @click.argument('file', type=click.Path(exists=True)) 348 | @click.argument('caption_format', type=str) 349 | def test_caption_format(file: str, caption_format: str) -> None: 350 | """Test the caption format on a given file""" 351 | file_path = FilePath(file) 352 | formatter = CaptionFormatter() 353 | print(formatter.format(caption_format, file=file_path, now=datetime.datetime.now())) 354 | 355 | 356 | if __name__ == '__main__': 357 | # Testing mode 358 | test_caption_format() 359 | -------------------------------------------------------------------------------- /telegram_upload/cli.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Sequence, Tuple, List, TypeVar 3 | 4 | import click 5 | from prompt_toolkit.filters import Condition 6 | from prompt_toolkit.formatted_text import AnyFormattedText 7 | from prompt_toolkit.key_binding import KeyBindings 8 | from prompt_toolkit.layout import FormattedTextControl, Window, ConditionalMargin, ScrollbarMargin 9 | from prompt_toolkit.widgets import CheckboxList, RadioList 10 | from prompt_toolkit.widgets.base import E, _DialogList 11 | 12 | from telegram_upload.utils import aislice 13 | 14 | _T = TypeVar("_T") 15 | 16 | PAGE_SIZE = 10 17 | 18 | 19 | async def async_handler(handler, event): 20 | if handler: 21 | await handler(event) 22 | 23 | # Tell the application to redraw. We need to do this, 24 | # because the below event handler won't be able to 25 | # wait for the task to finish. 26 | event.app.invalidate() 27 | 28 | 29 | class IterableDialogList(_DialogList): 30 | many = False 31 | 32 | def __init__(self, values: Sequence[Tuple[_T, AnyFormattedText]]) -> None: 33 | pass 34 | 35 | async def _init(self, values: Sequence[Tuple[_T, AnyFormattedText]]) -> None: 36 | started_values = await aislice(values, PAGE_SIZE) 37 | 38 | # started_values = await aislice(values, PAGE_SIZE) 39 | if not started_values: 40 | raise IndexError('Values is empty.') 41 | self.values = started_values 42 | # current_values will be used in multiple_selection, 43 | # current_value will be used otherwise. 44 | self.current_values: List[_T] = [] 45 | self.current_value: _T = started_values[0][0] 46 | self._selected_index = 0 47 | 48 | # Key bindings. 49 | kb = KeyBindings() 50 | 51 | @kb.add("up") 52 | def _up(event: E) -> None: 53 | self._selected_index = max(0, self._selected_index - 1) 54 | 55 | @kb.add("down") 56 | def _down(event: E) -> None: 57 | async def handler(event): 58 | if self._selected_index + 1 >= len(self.values): 59 | self.values.extend(await aislice(values, PAGE_SIZE)) 60 | self._selected_index = min(len(self.values) - 1, self._selected_index + 1) 61 | asyncio.get_event_loop().create_task(async_handler(handler, event)) 62 | 63 | @kb.add("pageup") 64 | def _pageup(event: E) -> None: 65 | w = event.app.layout.current_window 66 | if w.render_info: 67 | self._selected_index = max( 68 | 0, self._selected_index - len(w.render_info.displayed_lines) 69 | ) 70 | 71 | @kb.add("pagedown") 72 | def _pagedown(event: E) -> None: 73 | async def handler(event): 74 | w = event.app.layout.current_window 75 | if self._selected_index + len(w.render_info.displayed_lines) >= len(self.values): 76 | self.values.extend(await aislice(values, PAGE_SIZE)) 77 | if w.render_info: 78 | self._selected_index = min( 79 | len(self.values) - 1, 80 | self._selected_index + len(w.render_info.displayed_lines), 81 | ) 82 | asyncio.get_event_loop().create_task(async_handler(handler, event)) 83 | 84 | @kb.add("enter") 85 | def _enter(event: E) -> None: 86 | if self.many: 87 | event.app.exit(result=self.current_values) 88 | else: 89 | event.app.exit(result=self.current_value) 90 | 91 | @kb.add(" ") 92 | def _enter(event: E) -> None: 93 | self._handle_enter() 94 | 95 | # Control and window. 96 | self.control = FormattedTextControl( 97 | self._get_text_fragments, key_bindings=kb, focusable=True 98 | ) 99 | 100 | self.window = Window( 101 | content=self.control, 102 | style=self.container_style, 103 | right_margins=[ 104 | ConditionalMargin( 105 | margin=ScrollbarMargin(display_arrows=True), 106 | filter=Condition(lambda: self.show_scrollbar), 107 | ), 108 | ], 109 | dont_extend_height=True, 110 | ) 111 | 112 | 113 | 114 | class IterableCheckboxList(IterableDialogList, CheckboxList): 115 | many = True 116 | 117 | 118 | class IterableRadioList(IterableDialogList, RadioList): 119 | pass 120 | 121 | 122 | async def show_cli_widget(widget): 123 | from prompt_toolkit import Application 124 | from prompt_toolkit.layout import Layout 125 | app = Application(full_screen=False, layout=Layout(widget), mouse_support=True) 126 | return await app.run_async() 127 | 128 | 129 | async def show_checkboxlist(iterator, not_items_error='No items were found. Exiting...'): 130 | # iterator = map(lambda x: (x, f'{x.text} by {x.chat.first_name}'), iterator) 131 | try: 132 | checkbox_list = IterableCheckboxList(iterator) 133 | await checkbox_list._init(iterator) 134 | except IndexError: 135 | click.echo(not_items_error, err=True) 136 | return [] 137 | return await show_cli_widget(checkbox_list) 138 | 139 | 140 | async def show_radiolist(iterator, not_items_error='No items were found. Exiting...'): 141 | try: 142 | radio_list = IterableRadioList(iterator) 143 | await radio_list._init(iterator) 144 | except IndexError: 145 | click.echo(not_items_error, err=True) 146 | return None 147 | return await show_cli_widget(radio_list) 148 | -------------------------------------------------------------------------------- /telegram_upload/client/__init__.py: -------------------------------------------------------------------------------- 1 | from telegram_upload.client.telegram_manager_client import TelegramManagerClient, get_message_file_attribute 2 | 3 | 4 | __all__ = ["TelegramManagerClient", "get_message_file_attribute"] 5 | -------------------------------------------------------------------------------- /telegram_upload/client/progress_bar.py: -------------------------------------------------------------------------------- 1 | from ctypes import c_int64 2 | 3 | import click 4 | 5 | 6 | def get_progress_bar(action, file, length): 7 | bar = click.progressbar(label='{} "{}"'.format(action, file), length=length) 8 | last_current = c_int64(0) 9 | 10 | def progress(current, total): 11 | if current < last_current.value: 12 | return 13 | bar.pos = 0 14 | bar.update(current) 15 | last_current.value = current 16 | return progress, bar 17 | -------------------------------------------------------------------------------- /telegram_upload/client/telegram_download_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | import io 4 | import pathlib 5 | import sys 6 | from typing import Iterable 7 | 8 | import typing 9 | 10 | from more_itertools import grouper 11 | from telethon import TelegramClient, utils, helpers 12 | from telethon.client.downloads import MIN_CHUNK_SIZE 13 | from telethon.crypto import AES 14 | 15 | from telegram_upload.client.progress_bar import get_progress_bar 16 | from telegram_upload.download_files import DownloadFile 17 | from telegram_upload.exceptions import TelegramUploadNoSpaceError 18 | from telegram_upload.utils import free_disk_usage, sizeof_fmt, get_environment_integer 19 | 20 | 21 | if sys.version_info < (3, 10): 22 | from telegram_upload._compat import anext 23 | 24 | 25 | PARALLEL_DOWNLOAD_BLOCKS = get_environment_integer('TELEGRAM_UPLOAD_PARALLEL_DOWNLOAD_BLOCKS', 10) 26 | 27 | 28 | class TelegramDownloadClient(TelegramClient): 29 | def find_files(self, entity): 30 | for message in self.iter_messages(entity): 31 | if message.document: 32 | yield message 33 | else: 34 | break 35 | 36 | async def iter_files(self, entity): 37 | async for message in self.iter_messages(entity=entity): 38 | if message.document: 39 | yield message 40 | 41 | def download_files(self, entity, download_files: Iterable[DownloadFile], delete_on_success: bool = False): 42 | for download_file in download_files: 43 | if download_file.size > free_disk_usage(): 44 | raise TelegramUploadNoSpaceError( 45 | 'There is no disk space to download "{}". Space required: {}'.format( 46 | download_file.file_name, sizeof_fmt(download_file.size - free_disk_usage()) 47 | ) 48 | ) 49 | progress, bar = get_progress_bar('Downloading', download_file.file_name, download_file.size) 50 | file_name = download_file.file_name 51 | try: 52 | file_name = self.download_media(download_file.message, progress_callback=progress) 53 | download_file.set_download_file_name(file_name) 54 | finally: 55 | bar.label = f'Downloaded "{file_name}"' 56 | bar.update(1, 1) 57 | bar.render_finish() 58 | if delete_on_success: 59 | self.delete_messages(entity, [download_file.message]) 60 | 61 | async def _download_file( 62 | self: 'TelegramClient', 63 | input_location: 'hints.FileLike', 64 | file: 'hints.OutFileLike' = None, 65 | *, 66 | part_size_kb: float = None, 67 | file_size: int = None, 68 | progress_callback: 'hints.ProgressCallback' = None, 69 | dc_id: int = None, 70 | key: bytes = None, 71 | iv: bytes = None, 72 | msg_data: tuple = None) -> typing.Optional[bytes]: 73 | if not part_size_kb: 74 | if not file_size: 75 | part_size_kb = 64 # Reasonable default 76 | else: 77 | part_size_kb = utils.get_appropriated_part_size(file_size) 78 | 79 | part_size = int(part_size_kb * 1024) 80 | if part_size % MIN_CHUNK_SIZE != 0: 81 | raise ValueError( 82 | 'The part size must be evenly divisible by 4096.') 83 | 84 | if isinstance(file, pathlib.Path): 85 | file = str(file.absolute()) 86 | 87 | in_memory = file is None or file is bytes 88 | if in_memory: 89 | f = io.BytesIO() 90 | elif isinstance(file, str): 91 | # Ensure that we'll be able to download the media 92 | helpers.ensure_parent_dir_exists(file) 93 | f = open(file, 'wb') 94 | else: 95 | f = file 96 | 97 | try: 98 | # The speed of this code can be improved. 10 requests are made in parallel, but it waits for all 10 to 99 | # finish before launching another 10. 100 | for tasks in grouper(self._iter_download_chunk_tasks(input_location, part_size, dc_id, msg_data, file_size), 101 | PARALLEL_DOWNLOAD_BLOCKS): 102 | tasks = list(filter(bool, tasks)) 103 | await asyncio.wait(tasks) 104 | chunk = b''.join(filter(bool, [task.result() for task in tasks])) 105 | if not chunk: 106 | break 107 | if iv and key: 108 | chunk = AES.decrypt_ige(chunk, key, iv) 109 | r = f.write(chunk) 110 | if inspect.isawaitable(r): 111 | await r 112 | 113 | if progress_callback: 114 | r = progress_callback(f.tell(), file_size) 115 | if inspect.isawaitable(r): 116 | await r 117 | 118 | # Not all IO objects have flush (see #1227) 119 | if callable(getattr(f, 'flush', None)): 120 | f.flush() 121 | 122 | if in_memory: 123 | return f.getvalue() 124 | finally: 125 | if isinstance(file, str) or in_memory: 126 | f.close() 127 | 128 | def _iter_download_chunk_tasks(self, input_location, part_size, dc_id, msg_data, file_size): 129 | for i in range(0, file_size, part_size): 130 | yield self.loop.create_task( 131 | anext(self._iter_download(input_location, offset=i, request_size=part_size, dc_id=dc_id, 132 | msg_data=msg_data)) 133 | ) 134 | -------------------------------------------------------------------------------- /telegram_upload/client/telegram_manager_client.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import json 3 | import os 4 | import re 5 | import sys 6 | from distutils.version import StrictVersion 7 | from typing import Union 8 | from urllib.parse import urlparse 9 | 10 | import click 11 | from telethon.errors import ApiIdInvalidError 12 | from telethon.network import ConnectionTcpMTProxyRandomizedIntermediate 13 | from telethon.tl.types import DocumentAttributeFilename, User, InputPeerUser 14 | from telethon.version import __version__ as telethon_version 15 | 16 | from telegram_upload.client.telegram_download_client import TelegramDownloadClient 17 | from telegram_upload.client.telegram_upload_client import TelegramUploadClient 18 | from telegram_upload.config import SESSION_FILE 19 | from telegram_upload.exceptions import TelegramProxyError, InvalidApiFileError 20 | 21 | if StrictVersion(telethon_version) >= StrictVersion('1.0'): 22 | import telethon.sync # noqa 23 | 24 | 25 | if sys.version_info < (3, 8): 26 | cached_property = property 27 | else: 28 | from functools import cached_property 29 | 30 | 31 | BOT_USER_MAX_FILE_SIZE = 52428800 # 50MB 32 | USER_MAX_FILE_SIZE = 2097152000 # 2GB 33 | PREMIUM_USER_MAX_FILE_SIZE = 4194304000 # 4GB 34 | USER_MAX_CAPTION_LENGTH = 1024 35 | PREMIUM_USER_MAX_CAPTION_LENGTH = 2048 36 | PROXY_ENVIRONMENT_VARIABLE_NAMES = [ 37 | 'TELEGRAM_UPLOAD_PROXY', 38 | 'HTTPS_PROXY', 39 | 'HTTP_PROXY', 40 | ] 41 | 42 | 43 | def get_message_file_attribute(message): 44 | return next(filter(lambda x: isinstance(x, DocumentAttributeFilename), 45 | message.document.attributes), None) 46 | 47 | 48 | def phone_match(value): 49 | match = re.match(r'\+?[0-9.()\[\] \-]+', value) 50 | if match is None: 51 | raise ValueError('{} is not a valid phone'.format(value)) 52 | return value 53 | 54 | 55 | def get_proxy_environment_variable(): 56 | for env_name in PROXY_ENVIRONMENT_VARIABLE_NAMES: 57 | if env_name in os.environ: 58 | return os.environ[env_name] 59 | 60 | 61 | def parse_proxy_string(proxy: Union[str, None]): 62 | if not proxy: 63 | return None 64 | proxy_parsed = urlparse(proxy) 65 | if not proxy_parsed.scheme or not proxy_parsed.hostname or not proxy_parsed.port: 66 | raise TelegramProxyError('Malformed proxy address: {}'.format(proxy)) 67 | if proxy_parsed.scheme == 'mtproxy': 68 | return ('mtproxy', proxy_parsed.hostname, proxy_parsed.port, proxy_parsed.username) 69 | try: 70 | import socks 71 | except ImportError: 72 | raise TelegramProxyError('pysocks module is required for use HTTP/socks proxies. ' 73 | 'Install it using: pip install pysocks') 74 | proxy_type = { 75 | 'http': socks.HTTP, 76 | 'socks4': socks.SOCKS4, 77 | 'socks5': socks.SOCKS5, 78 | }.get(proxy_parsed.scheme) 79 | if proxy_type is None: 80 | raise TelegramProxyError('Unsupported proxy type: {}'.format(proxy_parsed.scheme)) 81 | return (proxy_type, proxy_parsed.hostname, proxy_parsed.port, True, 82 | proxy_parsed.username, proxy_parsed.password) 83 | 84 | 85 | class TelegramManagerClient(TelegramUploadClient, TelegramDownloadClient): 86 | def __init__(self, config_file, proxy=None, **kwargs): 87 | with open(config_file) as f: 88 | config = json.load(f) 89 | self.config_file = config_file 90 | proxy = proxy if proxy is not None else get_proxy_environment_variable() 91 | proxy = parse_proxy_string(proxy) 92 | if proxy and proxy[0] == 'mtproxy': 93 | proxy = proxy[1:] 94 | kwargs['connection'] = ConnectionTcpMTProxyRandomizedIntermediate 95 | super().__init__(config.get('session', SESSION_FILE), config['api_id'], config['api_hash'], 96 | proxy=proxy, **kwargs) 97 | 98 | def start( 99 | self, 100 | phone=lambda: click.prompt('Please enter your phone', type=phone_match), 101 | password=lambda: getpass.getpass('Please enter your password: '), 102 | *, 103 | bot_token=None, force_sms=False, code_callback=None, 104 | first_name='New User', last_name='', max_attempts=3): 105 | try: 106 | return super().start(phone=phone, password=password, bot_token=bot_token, force_sms=force_sms, 107 | first_name=first_name, last_name=last_name, max_attempts=max_attempts) 108 | except ApiIdInvalidError: 109 | raise InvalidApiFileError(self.config_file) 110 | 111 | @cached_property 112 | def me(self) -> Union[User, InputPeerUser]: 113 | return self.get_me() 114 | 115 | @property 116 | def max_file_size(self): 117 | if hasattr(self.me, 'premium') and self.me.premium: 118 | return PREMIUM_USER_MAX_FILE_SIZE 119 | elif self.me.bot: 120 | return BOT_USER_MAX_FILE_SIZE 121 | else: 122 | return USER_MAX_FILE_SIZE 123 | 124 | @property 125 | def max_caption_length(self): 126 | if hasattr(self.me, 'premium') and self.me.premium: 127 | return PREMIUM_USER_MAX_CAPTION_LENGTH 128 | else: 129 | return USER_MAX_CAPTION_LENGTH 130 | -------------------------------------------------------------------------------- /telegram_upload/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import click 5 | 6 | CONFIG_DIRECTORY = os.environ.get('TELEGRAM_UPLOAD_CONFIG_DIRECTORY', '~/.config') 7 | CONFIG_FILE = os.path.expanduser('{}/telegram-upload.json'.format(CONFIG_DIRECTORY)) 8 | SESSION_FILE = os.path.expanduser('{}/telegram-upload'.format(CONFIG_DIRECTORY)) 9 | 10 | 11 | def prompt_config(config_file): 12 | os.makedirs(os.path.dirname(config_file), exist_ok=True) 13 | click.echo('Go to https://my.telegram.org and create a App in API development tools') 14 | api_id = click.prompt('Please Enter api_id', type=int) 15 | api_hash = click.prompt('Now enter api_hash') 16 | with open(config_file, 'w') as f: 17 | json.dump({'api_id': api_id, 'api_hash': api_hash}, f) 18 | return config_file 19 | 20 | 21 | def default_config(): 22 | if os.path.lexists(CONFIG_FILE): 23 | return CONFIG_FILE 24 | return prompt_config(CONFIG_FILE) 25 | -------------------------------------------------------------------------------- /telegram_upload/download_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Iterable, Iterator, Optional, BinaryIO 4 | 5 | from telethon.tl.types import Message, DocumentAttributeFilename 6 | 7 | 8 | if sys.version_info < (3, 8): 9 | cached_property = property 10 | else: 11 | from functools import cached_property 12 | 13 | 14 | CHUNK_FILE_SIZE = 1024 * 1024 15 | 16 | 17 | def pipe_file(read_file_name: str, write_file: BinaryIO): 18 | """Read a file by its file name and write in another file already open.""" 19 | with open(read_file_name, "rb") as read_file: 20 | while True: 21 | data = read_file.read(CHUNK_FILE_SIZE) 22 | if data: 23 | write_file.write(data) 24 | else: 25 | break 26 | 27 | 28 | class JoinStrategyBase: 29 | """Base class to inherit join strategies. The strategies depend on the file type. 30 | For example, zip files and rar files do not merge in the same way. 31 | """ 32 | def __init__(self): 33 | self.download_files = [] 34 | 35 | def is_part(self, download_file: 'DownloadFile') -> bool: 36 | """Returns if the download file is part of this bundle.""" 37 | raise NotImplementedError 38 | 39 | def add_download_file(self, download_file: 'DownloadFile') -> None: 40 | """Add a download file to this bundle.""" 41 | if download_file in self.download_files: 42 | return 43 | self.download_files.append(download_file) 44 | 45 | @classmethod 46 | def is_applicable(cls, download_file: 'DownloadFile') -> bool: 47 | """Returns if this strategy is applicable to the download file.""" 48 | raise NotImplementedError 49 | 50 | def join_download_files(self): 51 | """Join the downloaded files in the bundle.""" 52 | raise NotImplementedError 53 | 54 | 55 | class UnionJoinStrategy(JoinStrategyBase): 56 | """Join separate files without any application. These files have extension 57 | 01, 02, 03... 58 | """ 59 | base_name: Optional[str] = None 60 | 61 | @staticmethod 62 | def get_base_name(download_file: 'DownloadFile'): 63 | """Returns the file name without extension.""" 64 | return download_file.file_name.rsplit(".", 1)[0] 65 | 66 | def add_download_file(self, download_file: 'DownloadFile') -> None: 67 | """Add a download file to this bundle.""" 68 | if self.base_name is None: 69 | self.base_name = self.get_base_name(download_file) 70 | super().add_download_file(download_file) 71 | 72 | def is_part(self, download_file: 'DownloadFile') -> bool: 73 | """Returns if the download file is part of this bundle.""" 74 | return self.base_name == self.get_base_name(download_file) 75 | 76 | @classmethod 77 | def is_applicable(cls, download_file: 'DownloadFile') -> bool: 78 | """Returns if this strategy is applicable to the download file.""" 79 | return download_file.file_name_extension.isdigit() 80 | 81 | def join_download_files(self): 82 | """Join the downloaded files in the bundle.""" 83 | download_files = self.download_files 84 | sorted_files = sorted(download_files, key=lambda x: x.file_name_extension) 85 | sorted_files = [file for file in sorted_files if os.path.lexists(file.downloaded_file_name or "")] 86 | if not sorted_files or len(sorted_files) - 1 != int(sorted_files[-1].file_name_extension): 87 | # There are parts of the file missing. Stopping... 88 | return 89 | with open(self.get_base_name(sorted_files[0]), "wb") as new_file: 90 | for download_file in sorted_files: 91 | pipe_file(download_file.downloaded_file_name, new_file) 92 | for download_file in sorted_files: 93 | os.remove(download_file.downloaded_file_name) 94 | 95 | 96 | JOIN_STRATEGIES = [ 97 | UnionJoinStrategy, 98 | ] 99 | 100 | 101 | def get_join_strategy(download_file: 'DownloadFile') -> Optional[JoinStrategyBase]: 102 | """Get join strategy for the download file. An instance is returned if a strategy 103 | is available. Otherwise, None is returned. 104 | """ 105 | for strategy_cls in JOIN_STRATEGIES: 106 | if strategy_cls.is_applicable(download_file): 107 | strategy = strategy_cls() 108 | strategy.add_download_file(download_file) 109 | return strategy 110 | 111 | 112 | class DownloadFile: 113 | """File to download. This includes the Telethon message with the file.""" 114 | downloaded_file_name: Optional[str] = None 115 | 116 | def __init__(self, message: Message): 117 | """Creates the download file instance from the message.""" 118 | self.message = message 119 | 120 | def set_download_file_name(self, file_name): 121 | """After download the file, set the final download file name.""" 122 | self.downloaded_file_name = file_name 123 | 124 | @cached_property 125 | def filename_attr(self) -> Optional[DocumentAttributeFilename]: 126 | """Get the document attribute file name attribute in the document.""" 127 | return next(filter(lambda x: isinstance(x, DocumentAttributeFilename), 128 | self.document.attributes), None) 129 | 130 | @cached_property 131 | def file_name(self) -> str: 132 | """Get the file name.""" 133 | return self.filename_attr.file_name if self.filename_attr else 'Unknown' 134 | 135 | @property 136 | def file_name_extension(self) -> str: 137 | """Get the file name extension.""" 138 | parts = self.file_name.rsplit(".", 1) 139 | return parts[-1] if len(parts) >= 2 else "" 140 | 141 | @property 142 | def document(self): 143 | """Get the message document.""" 144 | return self.message.document 145 | 146 | @property 147 | def size(self) -> int: 148 | """Get the file size.""" 149 | return self.document.size 150 | 151 | def __eq__(self, other: 'DownloadFile'): 152 | """Compare download files by their file name.""" 153 | return self.file_name == other.file_name 154 | 155 | 156 | class DownloadSplitFilesBase: 157 | """Iterate over complete and split files. Base class to inherit.""" 158 | def __init__(self, messages: Iterable[Message]): 159 | self.messages = messages 160 | 161 | def get_iterator(self) -> Iterator[DownloadFile]: 162 | """Get an iterator with the download files.""" 163 | raise NotImplementedError 164 | 165 | def __iter__(self) -> 'DownloadSplitFilesBase': 166 | """Set the iterator from the get_iterator method.""" 167 | self._iterator = self.get_iterator() 168 | return self 169 | 170 | def __next__(self) -> 'DownloadFile': 171 | """Get the next download file in the iterator.""" 172 | if self._iterator is None: 173 | self._iterator = self.get_iterator() 174 | return next(self._iterator) 175 | 176 | 177 | class KeepDownloadSplitFiles(DownloadSplitFilesBase): 178 | """Download split files without join it.""" 179 | def get_iterator(self) -> Iterator[DownloadFile]: 180 | """Get an iterator with the download files.""" 181 | return map(lambda message: DownloadFile(message), self.messages) 182 | 183 | 184 | class JoinDownloadSplitFiles(DownloadSplitFilesBase): 185 | """Download split files and join it.""" 186 | def get_iterator(self) -> Iterator[DownloadFile]: 187 | """Get an iterator with the download files. This method applies the join strategy and 188 | joins the files after download it. 189 | """ 190 | current_join_strategy: Optional[JoinStrategyBase] = None 191 | for message in self.messages: 192 | download_file = DownloadFile(message) 193 | yield download_file 194 | if current_join_strategy and current_join_strategy.is_part(download_file): 195 | # There is a bundle in process and the download file is part of it. Add the download 196 | # file to the bundle. 197 | current_join_strategy.add_download_file(download_file) 198 | elif current_join_strategy and not current_join_strategy.is_part(download_file): 199 | # There is a bundle in process and the download file is not part of it. Join the files 200 | # in the bundle and finish it. 201 | current_join_strategy.join_download_files() 202 | current_join_strategy = None 203 | if current_join_strategy is None: 204 | # There is no bundle in process. Get the current bundle if the file has a strategy 205 | # available. 206 | current_join_strategy = get_join_strategy(download_file) 207 | else: 208 | # After finish all the files, join the latest bundle. 209 | if current_join_strategy: 210 | current_join_strategy.join_download_files() 211 | -------------------------------------------------------------------------------- /telegram_upload/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Exceptions for telegram-upload.""" 4 | import sys 5 | 6 | import click 7 | 8 | from telegram_upload.config import prompt_config 9 | 10 | 11 | class ThumbError(Exception): 12 | pass 13 | 14 | 15 | class ThumbVideoError(ThumbError): 16 | pass 17 | 18 | 19 | class TelegramUploadError(Exception): 20 | body = '' 21 | error_code = 1 22 | 23 | def __init__(self, extra_body=''): 24 | self.extra_body = extra_body 25 | 26 | def __str__(self): 27 | msg = self.__class__.__name__ 28 | if self.body: 29 | msg += ': {}'.format(self.body) 30 | if self.extra_body: 31 | msg += ('. {}' if self.body else ': {}').format(self.extra_body) 32 | return msg 33 | 34 | 35 | class MissingFileError(TelegramUploadError): 36 | pass 37 | 38 | 39 | class InvalidApiFileError(TelegramUploadError): 40 | def __init__(self, config_file, extra_body=''): 41 | self.config_file = config_file 42 | super().__init__(extra_body) 43 | 44 | 45 | class TelegramInvalidFile(TelegramUploadError): 46 | error_code = 3 47 | 48 | 49 | class TelegramUploadNoSpaceError(TelegramUploadError): 50 | error_code = 28 51 | 52 | 53 | class TelegramUploadDataLoss(TelegramUploadError): 54 | error_code = 29 55 | 56 | 57 | class TelegramProxyError(TelegramUploadError): 58 | error_code = 30 59 | 60 | 61 | class TelegramEnvironmentError(TelegramUploadError): 62 | error_code = 31 63 | 64 | 65 | def catch(fn): 66 | def wrap(*args, **kwargs): 67 | try: 68 | return fn(*args, **kwargs) 69 | except InvalidApiFileError as e: 70 | click.echo('The api_id/api_hash combination is invalid. Re-enter both values.') 71 | prompt_config(e.config_file) 72 | return catch(fn)(*args, **kwargs) 73 | except TelegramUploadError as e: 74 | sys.stderr.write('[Error] telegram-upload Exception:\n{}\n'.format(e)) 75 | exit(e.error_code) 76 | return wrap 77 | -------------------------------------------------------------------------------- /telegram_upload/upload_files.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import os 4 | 5 | 6 | import mimetypes 7 | from io import FileIO, SEEK_SET 8 | from typing import Union, TYPE_CHECKING 9 | 10 | import click 11 | from hachoir.metadata.metadata import RootMetadata 12 | from hachoir.metadata.video import MP4Metadata 13 | from telethon.tl.types import DocumentAttributeVideo, DocumentAttributeFilename 14 | 15 | from telegram_upload.caption_formatter import CaptionFormatter, FilePath 16 | from telegram_upload.exceptions import TelegramInvalidFile, ThumbError 17 | from telegram_upload.utils import scantree, truncate 18 | from telegram_upload.video import get_video_thumb, video_metadata 19 | 20 | mimetypes.init() 21 | 22 | 23 | if TYPE_CHECKING: 24 | from telegram_upload.client import TelegramManagerClient 25 | 26 | 27 | def is_valid_file(file, error_logger=None): 28 | error_message = None 29 | if not os.path.lexists(file): 30 | error_message = 'File "{}" does not exist.'.format(file) 31 | elif not os.path.getsize(file): 32 | error_message = 'File "{}" is empty.'.format(file) 33 | if error_message and error_logger is not None: 34 | error_logger(error_message) 35 | return error_message is None 36 | 37 | 38 | def get_file_mime(file): 39 | return (mimetypes.guess_type(file)[0] or ('')).split('/')[0] 40 | 41 | 42 | def metadata_has(metadata: RootMetadata, key: str): 43 | try: 44 | return metadata.has(key) 45 | except ValueError: 46 | return False 47 | 48 | 49 | def get_file_attributes(file): 50 | attrs = [] 51 | mime = get_file_mime(file) 52 | if mime == 'video': 53 | metadata = video_metadata(file) 54 | video_meta = metadata 55 | meta_groups = None 56 | if hasattr(metadata, '_MultipleMetadata__groups'): 57 | # Is mkv 58 | meta_groups = metadata._MultipleMetadata__groups 59 | if metadata is not None and not metadata.has('width') and meta_groups: 60 | video_meta = meta_groups[next(filter(lambda x: x.startswith('video'), meta_groups._key_list))] 61 | if metadata is not None: 62 | supports_streaming = isinstance(video_meta, MP4Metadata) 63 | attrs.append(DocumentAttributeVideo( 64 | (0, metadata.get('duration').seconds)[metadata_has(metadata, 'duration')], 65 | (0, video_meta.get('width'))[metadata_has(video_meta, 'width')], 66 | (0, video_meta.get('height'))[metadata_has(video_meta, 'height')], 67 | False, 68 | supports_streaming, 69 | )) 70 | return attrs 71 | 72 | 73 | def get_file_thumb(file): 74 | if get_file_mime(file) == 'video': 75 | return get_video_thumb(file) 76 | 77 | 78 | class UploadFilesBase: 79 | def __init__(self, client: 'TelegramManagerClient', files, thumbnail: Union[str, bool, None] = None, 80 | force_file: bool = False, caption: Union[str, None] = None): 81 | self._iterator = None 82 | self.client = client 83 | self.files = files 84 | self.thumbnail = thumbnail 85 | self.force_file = force_file 86 | self.caption = caption 87 | 88 | def get_iterator(self): 89 | raise NotImplementedError 90 | 91 | def __iter__(self): 92 | self._iterator = self.get_iterator() 93 | return self 94 | 95 | def __next__(self): 96 | if self._iterator is None: 97 | self._iterator = self.get_iterator() 98 | return next(self._iterator) 99 | 100 | 101 | class RecursiveFiles(UploadFilesBase): 102 | 103 | def get_iterator(self): 104 | for file in self.files: 105 | if os.path.isdir(file): 106 | yield from map(lambda file: file.path, 107 | filter(lambda x: not x.is_dir(), scantree(file, True))) 108 | else: 109 | yield file 110 | 111 | 112 | class NoDirectoriesFiles(UploadFilesBase): 113 | def get_iterator(self): 114 | for file in self.files: 115 | if os.path.isdir(file): 116 | raise TelegramInvalidFile('"{}" is a directory.'.format(file)) 117 | else: 118 | yield file 119 | 120 | 121 | class LargeFilesBase(UploadFilesBase): 122 | def get_iterator(self): 123 | for file in self.files: 124 | if os.path.getsize(file) > self.client.max_file_size: 125 | yield from self.process_large_file(file) 126 | else: 127 | yield self.process_normal_file(file) 128 | 129 | def process_normal_file(self, file: str) -> 'File': 130 | return File(self.client, file, force_file=self.force_file, thumbnail=self.thumbnail, caption=self.caption) 131 | 132 | def process_large_file(self, file): 133 | raise NotImplementedError 134 | 135 | 136 | class NoLargeFiles(LargeFilesBase): 137 | def process_large_file(self, file): 138 | raise TelegramInvalidFile('"{}" file is too large for Telegram.'.format(file)) 139 | 140 | 141 | class File(FileIO): 142 | force_file = False 143 | 144 | def __init__(self, client: 'TelegramManagerClient', path: str, force_file: Union[bool, None] = None, 145 | thumbnail: Union[str, bool, None] = None, caption: Union[str, None] = None): 146 | super().__init__(path) 147 | self.client = client 148 | self.path = path 149 | self.force_file = self.force_file if force_file is None else force_file 150 | self._thumbnail = thumbnail 151 | self._caption = caption 152 | 153 | @property 154 | def file_name(self): 155 | return os.path.basename(self.path) 156 | 157 | @property 158 | def file_size(self): 159 | return os.path.getsize(self.path) 160 | 161 | @property 162 | def short_name(self): 163 | return '.'.join(self.file_name.split('.')[:-1]) 164 | 165 | @property 166 | def is_custom_thumbnail(self): 167 | return self._thumbnail is not False and self._thumbnail is not None 168 | 169 | @property 170 | def file_caption(self) -> str: 171 | """Get file caption. If caption parameter is not set, return file name. 172 | If caption is set, format it with CaptionFormatter. 173 | Anyways, truncate caption to max_caption_length. 174 | """ 175 | if self._caption is not None: 176 | formatter = CaptionFormatter() 177 | caption = formatter.format(self._caption, file=FilePath(self.path), now=datetime.datetime.now()) 178 | else: 179 | caption = self.short_name 180 | return truncate(caption, self.client.max_caption_length) 181 | 182 | def get_thumbnail(self): 183 | thumb = None 184 | if self._thumbnail is None and not self.force_file: 185 | try: 186 | thumb = get_file_thumb(self.path) 187 | except ThumbError as e: 188 | click.echo('{}'.format(e), err=True) 189 | elif self.is_custom_thumbnail: 190 | if not isinstance(self._thumbnail, str): 191 | raise TypeError('Invalid type for thumbnail: {}'.format(type(self._thumbnail))) 192 | elif not os.path.lexists(self._thumbnail): 193 | raise TelegramInvalidFile('{} thumbnail file does not exists.'.format(self._thumbnail)) 194 | thumb = self._thumbnail 195 | return thumb 196 | 197 | @property 198 | def file_attributes(self): 199 | if self.force_file: 200 | return [DocumentAttributeFilename(self.file_name)] 201 | else: 202 | return get_file_attributes(self.path) 203 | 204 | 205 | class SplitFile(File, FileIO): 206 | force_file = True 207 | 208 | def __init__(self, client: 'TelegramManagerClient', file: Union[str, bytes, int], max_read_size: int, name: str): 209 | super().__init__(client, file) 210 | self.max_read_size = max_read_size 211 | self.remaining_size = max_read_size 212 | self._name = name 213 | 214 | def read(self, size: int = -1) -> bytes: 215 | if size == -1: 216 | size = self.remaining_size 217 | if not self.remaining_size: 218 | return b'' 219 | size = min(self.remaining_size, size) 220 | self.remaining_size -= size 221 | return super().read(size) 222 | 223 | def readall(self) -> bytes: 224 | return self.read() 225 | 226 | @property 227 | def file_name(self): 228 | return self._name 229 | 230 | @property 231 | def file_size(self): 232 | return self.max_read_size 233 | 234 | def seek(self, offset: int, whence: int = SEEK_SET, split_seek: bool = False) -> int: 235 | if not split_seek: 236 | self.remaining_size += self.tell() - offset 237 | return super().seek(offset, whence) 238 | 239 | @property 240 | def short_name(self): 241 | return self.file_name.split('/')[-1] 242 | 243 | 244 | class SplitFiles(LargeFilesBase): 245 | def process_large_file(self, file): 246 | file_name = os.path.basename(file) 247 | total_size = os.path.getsize(file) 248 | parts = math.ceil(total_size / self.client.max_file_size) 249 | zfill = int(math.log10(10)) + 1 250 | for part in range(parts): 251 | size = total_size - (part * self.client.max_file_size) if part >= parts - 1 else self.client.max_file_size 252 | splitted_file = SplitFile(self.client, file, size, '{}.{}'.format(file_name, str(part).zfill(zfill))) 253 | splitted_file.seek(self.client.max_file_size * part, split_seek=True) 254 | yield splitted_file 255 | -------------------------------------------------------------------------------- /telegram_upload/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import itertools 3 | import os 4 | import shutil 5 | from telegram_upload._compat import scandir 6 | from telegram_upload.exceptions import TelegramEnvironmentError 7 | 8 | 9 | def free_disk_usage(directory='.'): 10 | return shutil.disk_usage(directory)[2] 11 | 12 | 13 | def truncate(text, max_length): 14 | return (text[:max_length - 3] + '...') if len(text) > max_length else text 15 | 16 | 17 | def grouper(n, iterable): 18 | it = iter(iterable) 19 | while True: 20 | chunk = tuple(itertools.islice(it, n)) 21 | if not chunk: 22 | return 23 | yield chunk 24 | 25 | 26 | def sizeof_fmt(num, suffix='B'): 27 | for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']: 28 | if abs(num) < 1024.0: 29 | return "%3.1f%s%s" % (num, unit, suffix) 30 | num /= 1024.0 31 | return "%.1f%s%s" % (num, 'Yi', suffix) 32 | 33 | 34 | def scantree(path, follow_symlinks=False): 35 | """Recursively yield DirEntry objects for given directory.""" 36 | for entry in scandir(path): 37 | if entry.is_dir(follow_symlinks=follow_symlinks): 38 | yield from scantree(entry.path, follow_symlinks) # see below for Python 2.x 39 | else: 40 | yield entry 41 | 42 | 43 | def async_to_sync(coro): 44 | loop = asyncio.get_event_loop() 45 | if loop.is_running(): 46 | return coro 47 | else: 48 | return loop.run_until_complete(coro) 49 | 50 | 51 | async def aislice(iterator, limit): 52 | items = [] 53 | i = 0 54 | async for value in iterator: 55 | if i > limit: 56 | break 57 | i += 1 58 | items.append(value) 59 | return items 60 | 61 | 62 | async def amap(fn, iterator): 63 | async for value in iterator: 64 | yield fn(value) 65 | 66 | 67 | async def sync_to_async_iterator(iterator): 68 | for value in iterator: 69 | yield value 70 | 71 | 72 | def get_environment_integer(environment_name: str, default_value: int): 73 | """Get an integer from an environment variable.""" 74 | value = os.environ.get(environment_name, default_value) 75 | if isinstance(value, int) or value.isdigit(): 76 | return int(value) 77 | raise TelegramEnvironmentError(f"Environment variable {environment_name} must be an integer") 78 | -------------------------------------------------------------------------------- /telegram_upload/video.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import re 3 | import subprocess 4 | import tempfile 5 | import os 6 | 7 | from hachoir.metadata import extractMetadata 8 | from hachoir.parser import createParser 9 | from hachoir.core import config as hachoir_config 10 | 11 | from telegram_upload.exceptions import ThumbVideoError 12 | 13 | 14 | hachoir_config.quiet = True 15 | 16 | 17 | def video_metadata(file): 18 | return extractMetadata(createParser(file)) 19 | 20 | 21 | def call_ffmpeg(args): 22 | try: 23 | return subprocess.Popen([get_ffmpeg_command()] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 24 | except FileNotFoundError: 25 | raise ThumbVideoError('ffmpeg command is not available. Thumbnails for videos are not available!') 26 | 27 | 28 | def get_ffmpeg_command(): 29 | return os.environ.get('FFMPEG_COMMAND', 30 | 'ffmpeg.exe' if platform.system() == 'Windows' else 'ffmpeg') 31 | 32 | 33 | def get_video_size(file): 34 | p = call_ffmpeg([ 35 | '-i', file, 36 | ]) 37 | stdout, stderr = p.communicate() 38 | video_lines = re.findall(': Video: ([^\n]+)', stderr.decode('utf-8', errors='ignore')) 39 | if not video_lines: 40 | return 41 | matchs = re.findall("(\d{2,6})x(\d{2,6})", video_lines[0]) 42 | if matchs: 43 | return [int(x) for x in matchs[0]] 44 | 45 | 46 | def get_video_thumb(file, output=None, size=200): 47 | output = output or tempfile.NamedTemporaryFile(suffix='.jpg').name 48 | metadata = video_metadata(file) 49 | if metadata is None: 50 | return 51 | duration = metadata.get('duration').seconds if metadata.has('duration') else 0 52 | ratio = get_video_size(file) 53 | if ratio is None: 54 | raise ThumbVideoError('Video ratio is not available.') 55 | if ratio[0] / ratio[1] > 1: 56 | width, height = size, -1 57 | else: 58 | width, height = -1, size 59 | p = call_ffmpeg([ 60 | '-ss', str(int(duration / 2)), 61 | '-i', file, 62 | '-filter:v', 63 | 'scale={}:{}'.format(width, height), 64 | '-vframes:v', '1', 65 | output, 66 | ]) 67 | p.communicate() 68 | if not p.returncode and os.path.lexists(file): 69 | return output 70 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/tests/__init__.py -------------------------------------------------------------------------------- /tests/_compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | from mock import patch 3 | except ImportError: 4 | from unittest.mock import patch 5 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | from telegram_upload.cli import show_checkboxlist, show_radiolist 6 | from telegram_upload.utils import async_to_sync 7 | 8 | 9 | class TestShowCheckboxList(unittest.TestCase): 10 | @unittest.skipIf(sys.version_info < (3, 8), "Python 3.8 is required") 11 | @patch('prompt_toolkit.application.application.Application.run_async') 12 | def test_show_checkbox_list(self, m): 13 | async def aiterator(): 14 | iterator = iter([(x, x) for x in map(str, range(10))]) 15 | for item in iterator: 16 | yield item 17 | 18 | async_to_sync(show_checkboxlist(aiterator())) 19 | 20 | @patch('click.echo') 21 | def test_empty(self, m): 22 | async def aiterator(): 23 | for item in []: 24 | yield item 25 | 26 | async_to_sync(show_checkboxlist(aiterator())) 27 | m.assert_called_once() 28 | 29 | 30 | class TestShowRadioList(unittest.TestCase): 31 | @unittest.skipIf(sys.version_info < (3, 8), "Python 3.8 is required") 32 | @patch('prompt_toolkit.application.application.Application.run_async') 33 | def test_show_radio_list(self, m): 34 | async def aiterator(): 35 | iterator = iter([(x, x) for x in map(str, range(10))]) 36 | for item in iterator: 37 | yield item 38 | 39 | async_to_sync(show_radiolist(aiterator())) 40 | 41 | @patch('click.echo') 42 | def test_empty(self, m): 43 | async def aiterator(): 44 | for item in []: 45 | yield item 46 | 47 | async_to_sync(show_radiolist(aiterator())) 48 | m.assert_called_once() 49 | -------------------------------------------------------------------------------- /tests/test_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nekmo/telegram-upload/c700f86dd72ec97d0ca782b8d504e7eb502f04dc/tests/test_client/__init__.py -------------------------------------------------------------------------------- /tests/test_client/test_progress_bar.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | 4 | from telegram_upload.client.progress_bar import get_progress_bar 5 | 6 | 7 | class TestGetProgressBar(unittest.TestCase): 8 | @patch("telegram_upload.client.progress_bar.click") 9 | def test_get_progress_bar(self, mock_click: MagicMock): 10 | action = "action" 11 | file = "file" 12 | length = 100 13 | current = 10 14 | progress, bar = get_progress_bar(action, file, length) 15 | progress(current, None) 16 | progress(5, None) # The bar should not go back 17 | mock_click.progressbar.assert_called_once_with(label=f"{action} \"{file}\"", length=length) 18 | self.assertEqual(0, mock_click.progressbar.return_value.pos) 19 | mock_click.progressbar.return_value.update.assert_called_once_with(current) 20 | self.assertEqual(mock_click.progressbar.return_value, bar) 21 | -------------------------------------------------------------------------------- /tests/test_client/test_telegram_download_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import sys 4 | import unittest 5 | from unittest.mock import patch, mock_open, Mock, MagicMock, call 6 | 7 | from telethon.tl.types import DocumentAttributeFilename 8 | 9 | from telegram_upload.client.telegram_download_client import TelegramDownloadClient 10 | from telegram_upload.exceptions import TelegramUploadNoSpaceError 11 | 12 | CONFIG_DATA = {'api_hash': '', 'api_id': ''} 13 | 14 | 15 | class TestTelegramDownloadClient(unittest.TestCase): 16 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 17 | @patch('telegram_upload.client.telegram_download_client.TelegramClient.__init__', return_value=None) 18 | def setUp(self, m1) -> None: 19 | self.client = TelegramDownloadClient(Mock(), Mock(), Mock()) 20 | 21 | def test_find_files(self): 22 | entity = "entity" 23 | mock_iter_messages = MagicMock() 24 | mock_iter_messages.return_value = [ 25 | MagicMock(**{"document": MagicMock()}), 26 | MagicMock(**{"document": MagicMock()}), 27 | MagicMock(**{"document": None}), 28 | ] 29 | self.client.iter_messages = mock_iter_messages 30 | files = list(self.client.find_files(entity)) 31 | self.assertEqual(mock_iter_messages.return_value[0:2], files) 32 | 33 | def test_download_files(self): 34 | m = Mock() 35 | m.document.attributes = [DocumentAttributeFilename('download.png')] 36 | m.size = 0 37 | self.client.download_files('foo', [m]) 38 | 39 | def test_no_space_error(self): 40 | m = Mock() 41 | m.document.attributes = [DocumentAttributeFilename('download.png')] 42 | m.size = 1000 43 | with patch('telegram_upload.client.telegram_download_client.free_disk_usage', return_value=0), \ 44 | self.assertRaises(TelegramUploadNoSpaceError): 45 | self.client.download_files('foo', [m]) 46 | 47 | @patch("telegram_upload.client.telegram_download_client.TelegramDownloadClient._iter_download_chunk_tasks") 48 | @patch("telegram_upload.client.telegram_download_client.asyncio.wait") 49 | @unittest.skipIf(sys.version_info < (3, 8), "object MagicMock can't be used in 'await' expression") 50 | def test_download_file(self, mock_wait: MagicMock, mock_iter_download_chunk_tasks: MagicMock): 51 | mock_iter_download_chunk_tasks.return_value = [ 52 | MagicMock(**{"result.return_value": f"foo{i}".encode("utf-8")}) for i in range(2) 53 | ] 54 | mock_input_location = MagicMock() 55 | mock_file = MagicMock() 56 | mock_progress_callback = MagicMock() 57 | file_size = 2048 58 | part_size = 4 59 | self.client.download_file( 60 | mock_input_location, mock_file, file_size=file_size, part_size_kb=part_size, 61 | progress_callback=mock_progress_callback, 62 | ) 63 | mock_file.write.assert_called_once_with(b"foo0foo1") 64 | mock_iter_download_chunk_tasks.assert_called_once_with( 65 | mock_input_location, part_size * 1024, None, None, file_size, 66 | ) 67 | mock_wait.assert_called_once_with(mock_iter_download_chunk_tasks.return_value) 68 | 69 | @patch("telegram_upload.client.telegram_download_client.TelegramDownloadClient.loop") 70 | @patch("telegram_upload.client.telegram_download_client.TelegramDownloadClient._iter_download") 71 | def test_iter_download_chunk_tasks(self, mock_iter_download: MagicMock, mock_loop: MagicMock): 72 | mock_input_location = MagicMock() 73 | part_size = 1024 74 | dc_id = 1 75 | msg_data = "msg_data" 76 | file_size = 2048 77 | tasks = list(self.client._iter_download_chunk_tasks( 78 | mock_input_location, part_size, dc_id, msg_data, file_size 79 | )) 80 | self.assertEqual([mock_loop.create_task.return_value] * 2, tasks) 81 | mock_iter_download.assert_has_calls([ 82 | call(mock_input_location, offset=0, request_size=part_size, dc_id=dc_id, msg_data=msg_data), 83 | call(mock_input_location, offset=1024, request_size=part_size, dc_id=dc_id, msg_data=msg_data), 84 | ], any_order=True) 85 | -------------------------------------------------------------------------------- /tests/test_client/test_telegram_manager_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import patch, MagicMock, mock_open 4 | 5 | import socks 6 | from telethon.network import ConnectionTcpMTProxyRandomizedIntermediate 7 | 8 | from telegram_upload.client import TelegramManagerClient 9 | from telegram_upload.client.telegram_manager_client import phone_match, parse_proxy_string, USER_MAX_FILE_SIZE, \ 10 | BOT_USER_MAX_FILE_SIZE, PREMIUM_USER_MAX_FILE_SIZE, USER_MAX_CAPTION_LENGTH, PREMIUM_USER_MAX_CAPTION_LENGTH 11 | from telegram_upload.config import SESSION_FILE 12 | from telegram_upload.exceptions import TelegramProxyError 13 | 14 | 15 | CONFIG_DATA = {'api_hash': '', 'api_id': ''} 16 | 17 | 18 | class TestPhoneMatch(unittest.TestCase): 19 | def test_not_valid_phone(self): 20 | with self.assertRaises(ValueError): 21 | phone_match('foo') 22 | 23 | def test_number(self): 24 | number = '+34612345678' 25 | self.assertEqual(phone_match(number), number) 26 | 27 | 28 | class TestParseProxyString(unittest.TestCase): 29 | def test_none(self): 30 | self.assertIsNone(parse_proxy_string(None)) 31 | 32 | def test_malformed_url(self): 33 | with self.assertRaises(TelegramProxyError): 34 | parse_proxy_string('foo') 35 | 36 | def test_mtproxy(self): 37 | s = parse_proxy_string('mtproxy://secret@foo:123') 38 | self.assertEqual(s, ('mtproxy', 'foo', 123, 'secret')) 39 | 40 | @patch('builtins.__import__', side_effect=ImportError) 41 | def test_socks_import_error(self, m): 42 | with self.assertRaises(TelegramProxyError): 43 | parse_proxy_string('socks4://user:pass@foo:123') 44 | 45 | def test_unsupported_proxy_type(self): 46 | with self.assertRaises(TelegramProxyError): 47 | parse_proxy_string('foo://user:pass@foo:123') 48 | 49 | def test_proxy(self): 50 | self.assertEqual( 51 | parse_proxy_string('http://user:pass@foo:123'), 52 | (socks.HTTP, 'foo', 123, True, 'user', 'pass') 53 | ) 54 | 55 | 56 | class TestTelegramManagerClient(unittest.TestCase): 57 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 58 | @patch('telegram_upload.client.telegram_manager_client.TelegramUploadClient.__init__') 59 | def test_init(self, mock_init: MagicMock): 60 | config_file = "config_file" 61 | proxy = "mtproxy://secret@proxy.my.site:443" 62 | TelegramManagerClient(config_file, proxy=proxy) 63 | mock_init.assert_called_once_with( 64 | SESSION_FILE, CONFIG_DATA["api_id"], CONFIG_DATA["api_hash"], proxy=("proxy.my.site", 443, "secret"), 65 | connection=ConnectionTcpMTProxyRandomizedIntermediate 66 | ) 67 | 68 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 69 | @patch('telegram_upload.client.telegram_manager_client.TelegramUploadClient.__init__') 70 | @patch('telegram_upload.client.telegram_manager_client.TelegramUploadClient.start') 71 | def test_start(self, mock_start: MagicMock, _: MagicMock): 72 | config_file = "config_file" 73 | phone = "phone" 74 | password = "password" 75 | bot_token = "bot_token" 76 | force_sms = True 77 | first_name = "first_name" 78 | last_name = "last_name" 79 | max_attempts = 3 80 | TelegramManagerClient(config_file).start( 81 | phone, password, bot_token=bot_token, force_sms=force_sms, first_name=first_name, last_name=last_name, 82 | max_attempts=max_attempts 83 | ) 84 | mock_start.assert_called_once_with( 85 | phone=phone, password=password, bot_token=bot_token, force_sms=force_sms, first_name=first_name, 86 | last_name=last_name, max_attempts=max_attempts, 87 | ) 88 | 89 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 90 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.__init__', return_value=None) 91 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.get_me') 92 | def test_me(self, mock_get_me: MagicMock, _: MagicMock): 93 | me_result = TelegramManagerClient(MagicMock()).me 94 | mock_get_me.assert_called_once_with() 95 | self.assertEqual(mock_get_me.return_value, me_result) 96 | 97 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 98 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.__init__', return_value=None) 99 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.me') 100 | def test_max_file_size(self, mock_me: MagicMock, _: MagicMock): 101 | with self.subTest("Test user max file size"): 102 | mock_me.premium = False 103 | mock_me.bot = False 104 | self.assertEqual(TelegramManagerClient(MagicMock()).max_file_size, USER_MAX_FILE_SIZE) 105 | with self.subTest("Test bot max file size"): 106 | mock_me.premium = False 107 | mock_me.bot = True 108 | self.assertEqual(TelegramManagerClient(MagicMock()).max_file_size, BOT_USER_MAX_FILE_SIZE) 109 | with self.subTest("Test premium user max file size"): 110 | mock_me.premium = True 111 | mock_me.bot = False 112 | self.assertEqual(TelegramManagerClient(MagicMock()).max_file_size, PREMIUM_USER_MAX_FILE_SIZE) 113 | 114 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 115 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.__init__', return_value=None) 116 | @patch('telegram_upload.client.telegram_manager_client.TelegramManagerClient.me') 117 | def test_max_caption_length(self, mock_me: MagicMock, _: MagicMock): 118 | with self.subTest("Test user max caption length"): 119 | mock_me.premium = False 120 | self.assertEqual(TelegramManagerClient(MagicMock()).max_caption_length, USER_MAX_CAPTION_LENGTH) 121 | with self.subTest("Test premium user max caption length"): 122 | mock_me.premium = True 123 | self.assertEqual(TelegramManagerClient(MagicMock()).max_caption_length, PREMIUM_USER_MAX_CAPTION_LENGTH) 124 | -------------------------------------------------------------------------------- /tests/test_client/test_telegram_upload_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import unittest 5 | 6 | from unittest.mock import patch, mock_open, Mock, MagicMock, call 7 | 8 | from telethon import types 9 | from telethon.errors import FloodWaitError, RPCError 10 | 11 | from telegram_upload.client.telegram_upload_client import TelegramUploadClient 12 | from telegram_upload.exceptions import TelegramUploadDataLoss, MissingFileError 13 | from telegram_upload.upload_files import File 14 | 15 | 16 | try: 17 | from unittest.mock import AsyncMock 18 | from unittest import IsolatedAsyncioTestCase 19 | except ImportError: 20 | from asyncmock import AsyncMock 21 | from async_case import IsolatedAsyncioTestCase 22 | 23 | CONFIG_DATA = {'api_hash': '', 'api_id': ''} 24 | 25 | directory = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../') 26 | 27 | 28 | class AnyArg(object): 29 | """https://stackoverflow.com/questions/20428750/pythons-assert-called-with-is-there-a-wildcard-character""" 30 | def __eq__(a, b): 31 | return True 32 | 33 | 34 | class TestTelegramUploadClient(IsolatedAsyncioTestCase): 35 | @patch('builtins.open', mock_open(read_data=json.dumps(CONFIG_DATA))) 36 | @patch('telegram_upload.client.telegram_upload_client.TelegramClient.__init__', return_value=None) 37 | def setUp(self, m1) -> None: 38 | self.upload_file_path = os.path.abspath(os.path.join(directory, 'logo.png')) 39 | self.client = TelegramUploadClient(Mock(), Mock(), Mock()) 40 | self.client.send_file = Mock() 41 | self.client.send_file.return_value.media.document.size = os.path.getsize(self.upload_file_path) 42 | 43 | @patch("telegram_upload.client.telegram_upload_client.TelegramUploadClient.forward_messages") 44 | def test_forward_to(self, mock_forward_messages: MagicMock): 45 | mock_message = MagicMock() 46 | mock_destinations = [MagicMock(), MagicMock()] 47 | self.client.forward_to(mock_message, mock_destinations) 48 | mock_forward_messages.assert_has_calls([ 49 | call(mock_destinations[0], [mock_message]), 50 | call(mock_destinations[1], [mock_message]), 51 | ]) 52 | 53 | async def test_send_album_media(self): 54 | self.client.get_input_entity = AsyncMock() 55 | self.client._call = AsyncMock() 56 | self.client._sender = MagicMock() 57 | self.client._get_response_message = MagicMock() 58 | entity = "entity" 59 | mock_media = [MagicMock(), MagicMock()] 60 | response_message = await self.client._send_album_media(entity, mock_media) 61 | self.assertEqual(self.client._get_response_message.return_value, response_message) 62 | self.client._get_response_message.assert_called_once_with( 63 | [m.random_id for m in mock_media], self.client._call.return_value, 64 | self.client.get_input_entity.return_value, 65 | ) 66 | 67 | @patch('telegram_upload.client.telegram_upload_client.TelegramUploadClient.send_files') 68 | @patch('telegram_upload.client.telegram_upload_client.TelegramUploadClient._send_album_media') 69 | @unittest.skipIf(sys.version_info < (3, 8), "TypeError: An asyncio.Future, a coroutine or an awaitable is required") 70 | def test_send_files_as_album(self, mock_send_album_media: MagicMock, mock_send_files: MagicMock): 71 | entity = "entity" 72 | mock_files = [MagicMock(), MagicMock()] 73 | self.client.send_files_as_album(entity, mock_files) 74 | mock_send_files.assert_called_once_with( 75 | entity, tuple(mock_files), False, False, (), send_as_media=True 76 | ) 77 | mock_send_album_media.assert_called_once_with(entity, mock_send_files.return_value) 78 | 79 | @patch('telegram_upload.management.default_config') 80 | def test_missing_file(self, m1): 81 | with self.assertRaises(MissingFileError): 82 | self.client.send_files('foo', []) 83 | 84 | def test_one_file(self): 85 | with self.subTest("Test send one file"): 86 | entity = 'foo' 87 | file = File(MagicMock(max_caption_length=200), self.upload_file_path) 88 | self.client.send_one_file(entity, file, False, None) 89 | self.client.send_file.assert_called_once_with( 90 | entity, file, thumb=None, file_size=file.file_size, caption="logo", force_document=False, 91 | progress_callback=AnyArg(), attributes=[] 92 | ) 93 | original_send_file_message = self.client._send_file_message 94 | with self.subTest("Test send one file with one flood retry"), patch('time.sleep') as mock_sleep: 95 | wait = 1 96 | 97 | self.client._send_file_message = MagicMock() 98 | self.client._send_file_message.side_effect = [FloodWaitError(None, wait), original_send_file_message] 99 | entity = 'foo' 100 | file = File(MagicMock(), self.upload_file_path) 101 | self.client.send_one_file(entity, file, False, None) 102 | self.client._send_file_message.assert_has_calls([call(entity, file, None, AnyArg())] * 2) 103 | mock_sleep.assert_called_once_with(wait) 104 | with self.subTest("Test send one file with rpcError"): 105 | self.client._send_file_message = MagicMock() 106 | self.client._send_file_message.side_effect = [RPCError(None, "")] * 4 107 | entity = 'foo' 108 | file = File(MagicMock(), self.upload_file_path) 109 | self.client.send_one_file(entity, file, False, None, 3) 110 | self.client._send_file_message.assert_has_calls([call(entity, file, None, AnyArg())] * 3) 111 | 112 | def test_send_files(self): 113 | with self.subTest("Test send files"): 114 | entity = 'foo' 115 | file = File(MagicMock(max_caption_length=200), self.upload_file_path) 116 | self.client.send_files(entity, [file]) 117 | self.client.send_file.assert_called_once_with( 118 | entity, file, thumb=None, file_size=file.file_size, 119 | caption=os.path.basename(self.upload_file_path).split('.')[0], force_document=False, 120 | progress_callback=AnyArg(), attributes=[], 121 | ) 122 | with self.subTest("Test send files with thumb"), \ 123 | patch.object(File, "get_thumbnail", return_value="thumb.jpg"), \ 124 | patch('telegram_upload.client.telegram_upload_client.os') as mock_os: 125 | mock_os.path.lexists.return_value = True 126 | entity = 'foo' 127 | file = File(MagicMock(max_caption_length=200), self.upload_file_path) 128 | self.client.send_files(entity, [file]) 129 | self.client.send_file.assert_called_with( 130 | entity, file, thumb="thumb.jpg", file_size=file.file_size, 131 | caption=os.path.basename(self.upload_file_path).split('.')[0], force_document=False, 132 | progress_callback=AnyArg(), attributes=[], 133 | ) 134 | mock_os.remove.assert_called_once_with("thumb.jpg") 135 | with self.subTest("Test send files with delete mode"), patch('os.remove') as mock_remove: 136 | file = File(MagicMock(max_caption_length=200), self.upload_file_path) 137 | self.client.send_files(entity, [file], delete_on_success=True) 138 | self.client.send_file.assert_called_with( 139 | entity, file, thumb=None, file_size=file.file_size, 140 | caption=os.path.basename(self.upload_file_path).split('.')[0], force_document=False, 141 | progress_callback=AnyArg(), attributes=[], 142 | ) 143 | mock_remove.assert_called_once_with(self.upload_file_path) 144 | 145 | def test_send_files_data_loss(self): 146 | mock_client = MagicMock(max_caption_length=200) 147 | file = File(mock_client, self.upload_file_path) 148 | self.client.send_file.return_value.media.document.size = 200 149 | with self.assertRaises(TelegramUploadDataLoss): 150 | self.client.send_files('foo', [file]) 151 | 152 | @patch('telegram_upload.client.telegram_upload_client.utils') 153 | @unittest.skipIf(sys.version_info < (3, 8), "TypeError: Cannot cast AsyncMock to any kind of InputMedia.") 154 | async def test_send_media(self, mock_utils: MagicMock): 155 | mock_client = MagicMock(max_caption_length=200) 156 | mock_utils.get_appropriated_part_size.return_value = 512 157 | self.client.get_input_entity = AsyncMock() 158 | self.client._log = AsyncMock() 159 | self.client._call = AsyncMock() 160 | self.client._sender = MagicMock() 161 | entity = 'entity' 162 | mock_progress = MagicMock() 163 | file = File(mock_client, self.upload_file_path) 164 | with patch('telegram_upload.client.telegram_upload_client.isinstance', return_value=True), \ 165 | self.subTest("Test photo"): 166 | await self.client._send_media(entity, file, mock_progress) 167 | isinstance_result = {types.InputMediaUploadedPhoto: False, types.InputMediaUploadedDocument: True} 168 | with patch('telegram_upload.client.telegram_upload_client.isinstance', 169 | side_effect=lambda obj, target: isinstance_result.get(target, isinstance(obj, target))), \ 170 | self.subTest("Test Document"): 171 | await self.client._send_media(entity, file, mock_progress) 172 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open 3 | 4 | from telegram_upload.config import default_config, CONFIG_FILE 5 | 6 | 7 | class TestDefaultConfig(unittest.TestCase): 8 | @patch('telegram_upload.config.os.path.lexists', return_value=True) 9 | def test_exists(self, m): 10 | self.assertEqual(default_config(), CONFIG_FILE) 11 | self.assertEqual(m.call_count, 1) 12 | 13 | @patch('builtins.open', mock_open()) 14 | @patch('telegram_upload.config.os') 15 | @patch('telegram_upload.config.click') 16 | @patch('telegram_upload.config.json') 17 | def test_create(self, m_json, m_click, m_os): 18 | m_os.path.lexists.return_value = False 19 | m_click.prompt.side_effect = ['api_id', 'api_hash'] 20 | self.assertEqual(default_config(), CONFIG_FILE) 21 | self.assertEqual(m_json.dump.call_count, 1) 22 | self.assertEqual(m_json.dump.call_args[0][0], {'api_id': 'api_id', 'api_hash': 'api_hash'}) 23 | -------------------------------------------------------------------------------- /tests/test_download_files.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | from io import BytesIO 4 | from unittest.mock import patch, MagicMock, call 5 | 6 | from telethon.tl.types import DocumentAttributeFilename 7 | 8 | from telegram_upload.download_files import pipe_file, CHUNK_FILE_SIZE, JoinStrategyBase, UnionJoinStrategy, \ 9 | get_join_strategy, DownloadFile, KeepDownloadSplitFiles, JoinDownloadSplitFiles 10 | 11 | 12 | class TestPipeFile(unittest.TestCase): 13 | @patch('builtins.open') 14 | def test_pipe(self, mock_open: MagicMock): 15 | mock_open.return_value.__enter__.return_value.read.side_effect = [b"foo", b"bar", b""] 16 | read_file_name = "read_file_name" 17 | write_file = BytesIO() 18 | pipe_file(read_file_name, write_file) 19 | write_file.seek(0) 20 | self.assertEqual(b"foobar", write_file.read()) 21 | mock_open.assert_called_once_with(read_file_name, "rb") 22 | mock_open.return_value.__enter__.return_value.read.assert_has_calls([call(CHUNK_FILE_SIZE)] * 3) 23 | 24 | 25 | class TestJoinStrategyBase(unittest.TestCase): 26 | def test_add_download_file(self): 27 | strategy = JoinStrategyBase() 28 | mock_download_file = MagicMock() 29 | with self.subTest("Test to add new download_file"): 30 | strategy.add_download_file(mock_download_file) 31 | self.assertEqual([mock_download_file], strategy.download_files) 32 | with self.subTest("Test to add existing download_file"): 33 | strategy.add_download_file(mock_download_file) 34 | self.assertEqual([mock_download_file], strategy.download_files) 35 | 36 | 37 | class TestUnionJoinStrategy(unittest.TestCase): 38 | def test_get_base_name(self): 39 | with self.subTest("Test file with extension."): 40 | mock_download_file = MagicMock(file_name="file.tar.gz") 41 | base_name = UnionJoinStrategy.get_base_name(mock_download_file) 42 | self.assertEqual("file.tar", base_name) 43 | with self.subTest("Test file without extension."): 44 | mock_download_file = MagicMock(file_name="file") 45 | base_name = UnionJoinStrategy.get_base_name(mock_download_file) 46 | self.assertEqual("file", base_name) 47 | 48 | @patch("telegram_upload.download_files.JoinStrategyBase.add_download_file") 49 | def test_add_download_file(self, mock_add_download_file: MagicMock): 50 | mock_download_file = MagicMock(file_name="file.tar.gz") 51 | strategy = UnionJoinStrategy() 52 | strategy.add_download_file(mock_download_file) 53 | self.assertEqual("file.tar", strategy.base_name) 54 | mock_add_download_file.assert_called_once_with(mock_download_file) 55 | 56 | def test_is_part(self): 57 | mock_download_file = MagicMock(file_name="file.tar.gz") 58 | strategy = UnionJoinStrategy() 59 | strategy.base_name = "file.tar" 60 | self.assertTrue(strategy.is_part(mock_download_file)) 61 | 62 | def test_is_applicable(self): 63 | strategy = UnionJoinStrategy() 64 | with self.subTest("Test applicable"): 65 | mock_download_file = MagicMock(file_name_extension="00") 66 | self.assertTrue(strategy.is_applicable(mock_download_file)) 67 | with self.subTest("Test not applicable"): 68 | mock_download_file = MagicMock(file_name_extension="tar") 69 | self.assertFalse(strategy.is_applicable(mock_download_file)) 70 | 71 | @patch('builtins.open') 72 | @patch('telegram_upload.download_files.os') 73 | @patch('telegram_upload.download_files.pipe_file') 74 | def test_join_download_files(self, mock_pipe_file: MagicMock, mock_os: MagicMock, mock_open: MagicMock): 75 | strategy = UnionJoinStrategy() 76 | download_files = [ 77 | MagicMock(file_name="file.01", downloaded_file_name="file.01", file_name_extension="01"), 78 | MagicMock(file_name="file.00", downloaded_file_name="file.00", file_name_extension="00"), 79 | ] 80 | strategy.download_files = list(download_files) 81 | with self.subTest("Test successful join"): 82 | strategy.join_download_files() 83 | mock_pipe_file.assert_has_calls([ 84 | call("file.00", mock_open.return_value.__enter__.return_value), 85 | call("file.01", mock_open.return_value.__enter__.return_value), 86 | ]) 87 | mock_os.remove.assert_has_calls([call("file.00"), call("file.01")]) 88 | mock_os.path.lexists.assert_has_calls([call("file.00"), call("file.01")], any_order=True) 89 | mock_open.assert_called_once_with("file", "wb") 90 | with self.subTest("Test join with missing files"): 91 | strategy.download_files = [download_files[0]] 92 | mock_open.reset_mock() 93 | strategy.join_download_files() 94 | mock_open.assert_not_called() 95 | 96 | 97 | class TestGetJoinStrategy(unittest.TestCase): 98 | def test_get_join_strategy(self): 99 | mock_download_file = MagicMock() 100 | strategies = [MagicMock()] 101 | with patch("telegram_upload.download_files.JOIN_STRATEGIES", strategies): 102 | strategy = get_join_strategy(mock_download_file) 103 | self.assertEqual(strategies[0].return_value, strategy) 104 | strategies[0].is_applicable.assert_called_once_with(mock_download_file) 105 | strategies[0].return_value.add_download_file.assert_called_once_with(mock_download_file) 106 | 107 | 108 | class TestDownloadFile(unittest.TestCase): 109 | def test_set_download_file_name(self): 110 | download_file = DownloadFile(MagicMock()) 111 | download_file_name = "download_file_name" 112 | download_file.set_download_file_name(download_file_name) 113 | self.assertEqual(download_file_name, download_file.downloaded_file_name) 114 | 115 | def test_filename_attr(self): 116 | with self.subTest("Found attribute"): 117 | attribute = DocumentAttributeFilename("file_name") 118 | mock_download_file = MagicMock(**{'document.attributes': [attribute]}) 119 | download_file = DownloadFile(mock_download_file) 120 | self.assertEqual(attribute, download_file.filename_attr) 121 | with self.subTest("Missing attribute"): 122 | mock_download_file = MagicMock(**{'document.attributes': []}) 123 | download_file = DownloadFile(mock_download_file) 124 | self.assertIsNone(download_file.filename_attr) 125 | 126 | @unittest.skipIf(sys.version_info < (3, 8), "Unsupported in Python 3.7") 127 | def test_file_name(self): 128 | file_name = "file_name" 129 | download_file = DownloadFile(MagicMock()) 130 | with patch.object(download_file, 'filename_attr', file_name=file_name), self.subTest("Return file name"): 131 | self.assertEqual(file_name, download_file.file_name) 132 | download_file = DownloadFile(MagicMock(**{'document.attributes': []})) 133 | with self.subTest("Return unknown"): 134 | self.assertEqual("Unknown", download_file.file_name) 135 | 136 | @unittest.skipIf(sys.version_info < (3, 8), "Unsupported in Python 3.7") 137 | def test_file_name_extension(self): 138 | file_name = "file_name.tar" 139 | download_file = DownloadFile(MagicMock()) 140 | with patch.object(download_file, 'filename_attr', file_name=file_name), \ 141 | self.subTest("Return extension file name"): 142 | self.assertEqual("tar", download_file.file_name_extension) 143 | download_file = DownloadFile(MagicMock(**{'document.attributes': []})) 144 | with self.subTest("Empty extension"): 145 | self.assertEqual("", download_file.file_name_extension) 146 | 147 | def test_document(self): 148 | mock_mesage = MagicMock() 149 | download_file = DownloadFile(mock_mesage) 150 | self.assertEqual(mock_mesage.document, download_file.document) 151 | 152 | def test_size(self): 153 | mock_mesage = MagicMock() 154 | download_file = DownloadFile(mock_mesage) 155 | self.assertEqual(mock_mesage.document.size, download_file.document.size) 156 | 157 | def test_eq(self): 158 | mock_mesage = MagicMock() 159 | download_file = DownloadFile(mock_mesage) 160 | download_file2 = DownloadFile(mock_mesage) 161 | self.assertEqual(download_file, download_file2) 162 | 163 | 164 | class TestKeepDownloadSplitFiles(unittest.TestCase): 165 | def test_get_iterator(self): 166 | mock_messages = [MagicMock()] 167 | keep_download_split_files = KeepDownloadSplitFiles(mock_messages) 168 | download_files = list(keep_download_split_files) 169 | self.assertIsInstance(download_files[0], DownloadFile) 170 | self.assertEqual(mock_messages[0], download_files[0].message) 171 | 172 | 173 | class TestJoinDownloadSplitFiles(unittest.TestCase): 174 | @patch("telegram_upload.download_files.get_join_strategy") 175 | def test_get_iterator_without_strategy(self, mock_get_join_strategy: MagicMock): 176 | """Test a download file without a valid strategy. The file is outside the supported 177 | files to unzip. 178 | """ 179 | mock_messages = [MagicMock()] 180 | mock_get_join_strategy.return_value = False 181 | join_download_split_files = JoinDownloadSplitFiles(mock_messages) 182 | download_files = list(join_download_split_files) 183 | self.assertIsInstance(download_files[0], DownloadFile) 184 | self.assertEqual(mock_messages[0], download_files[0].message) 185 | 186 | @patch("telegram_upload.download_files.get_join_strategy") 187 | def test_get_iterator_with_strategy(self, mock_get_join_strategy: MagicMock): 188 | """Test two related download files with a valid strategy. The files are unzipped.""" 189 | mock_messages = [MagicMock(), MagicMock()] 190 | mock_strategy = MagicMock() 191 | mock_get_join_strategy.return_value = mock_strategy 192 | join_download_split_files = JoinDownloadSplitFiles(mock_messages) 193 | download_files = list(join_download_split_files) 194 | self.assertIsInstance(download_files[0], DownloadFile) 195 | self.assertEqual(mock_messages[0], download_files[0].message) 196 | mock_strategy.add_download_file.assert_called_once_with(download_files[1]) 197 | mock_strategy.join_download_files.assert_called_once() 198 | 199 | @patch("telegram_upload.download_files.get_join_strategy") 200 | def test_get_iterator_with_strategy_and_other_file(self, mock_get_join_strategy: MagicMock): 201 | """Test two related download files with a valid strategy, and other unsupported file. 202 | Unzip the latest download file after detect an unsupported file. 203 | """ 204 | mock_messages = [MagicMock(), MagicMock(), MagicMock()] 205 | mock_strategy = MagicMock() 206 | mock_strategy.is_part.side_effect = [True, False, False] 207 | mock_get_join_strategy.side_effect = [mock_strategy, False] 208 | join_download_split_files = JoinDownloadSplitFiles(mock_messages) 209 | download_files = list(join_download_split_files) 210 | self.assertIsInstance(download_files[0], DownloadFile) 211 | self.assertEqual(mock_messages[0], download_files[0].message) 212 | mock_strategy.add_download_file.assert_called_once_with(download_files[1]) 213 | mock_strategy.join_download_files.assert_called_once() 214 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from ._compat import patch 3 | 4 | from telegram_upload.exceptions import TelegramUploadError, catch 5 | 6 | 7 | class TestTelegramUploadError(unittest.TestCase): 8 | def test_exception(self): 9 | self.assertEqual(str(TelegramUploadError()), 'TelegramUploadError') 10 | 11 | def test_body(self): 12 | error = TelegramUploadError() 13 | error.body = 'body' 14 | self.assertEqual(str(error), 'TelegramUploadError: body') 15 | 16 | def test_extra_body(self): 17 | self.assertEqual(str(TelegramUploadError('extra_body')), 'TelegramUploadError: extra_body') 18 | 19 | def test_all(self): 20 | error = TelegramUploadError('extra_body') 21 | error.body = 'body' 22 | self.assertEqual(str(error), 'TelegramUploadError: body. extra_body') 23 | 24 | 25 | class TestCatch(unittest.TestCase): 26 | def test_call(self): 27 | self.assertEqual(catch(lambda: 'foo')(), 'foo') 28 | 29 | @patch('telegram_upload.exceptions.sys.stderr.write') 30 | def test_raise(self, m): 31 | def raise_error(): 32 | raise TelegramUploadError('Error') 33 | with self.assertRaises(SystemExit): 34 | catch(raise_error)() 35 | m.assert_called_once() 36 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest.mock import patch, Mock, MagicMock 4 | 5 | from telegram_upload.client.telegram_manager_client import USER_MAX_FILE_SIZE 6 | from telegram_upload.exceptions import TelegramInvalidFile 7 | from telegram_upload.upload_files import get_file_attributes, RecursiveFiles, NoDirectoriesFiles, NoLargeFiles, \ 8 | SplitFiles, SplitFile 9 | 10 | 11 | class TestGetFileAttributes(unittest.TestCase): 12 | def test_not_video(self): 13 | self.assertEqual(get_file_attributes('foo.png'), []) 14 | 15 | @patch('telegram_upload.upload_files.video_metadata') 16 | def test_video(self, m_video_metadata): 17 | m_video_metadata.return_value.has.return_value = True 18 | duration = Mock() 19 | duration.seconds = 1000 20 | m_video_metadata.return_value.get.side_effect = [ 21 | duration, 1920, 1080 22 | ] 23 | attrs = get_file_attributes('foo.mp4') 24 | self.assertEqual(attrs[0].w, 1920) 25 | self.assertEqual(attrs[0].h, 1080) 26 | self.assertEqual(attrs[0].duration, 1000) 27 | 28 | 29 | class TestRecursiveFiles(unittest.TestCase): 30 | @patch('telegram_upload.upload_files.scantree', return_value=[]) 31 | @patch('telegram_upload.upload_files.os.path.isdir', return_value=False) 32 | def test_one_file(self, m1, m2): 33 | self.assertEqual(list(RecursiveFiles(MagicMock(), ['foo'])), ['foo']) 34 | 35 | @patch('telegram_upload.upload_files.scantree') 36 | @patch('telegram_upload.upload_files.os.path.isdir', return_value=True) 37 | def test_directory(self, m1, m2): 38 | directory = Mock() 39 | directory.is_dir.side_effect = [True, False] 40 | file = Mock() 41 | file.is_dir.return_value = False 42 | side_effect = [file] * 3 43 | m2.return_value = side_effect 44 | self.assertEqual(list(RecursiveFiles(MagicMock(), ['foo'])), [x.path for x in side_effect]) 45 | 46 | 47 | class TestNoDirectoriesFiles(unittest.TestCase): 48 | @patch('telegram_upload.upload_files.scantree', return_value=[]) 49 | @patch('telegram_upload.upload_files.os.path.isdir', return_value=False) 50 | def test_one_file(self, m1, m2): 51 | self.assertEqual(list(NoDirectoriesFiles(MagicMock(), ['foo'])), ['foo']) 52 | 53 | @patch('telegram_upload.upload_files.os.path.isdir', return_value=True) 54 | def test_directory(self, m): 55 | with self.assertRaises(TelegramInvalidFile): 56 | next(NoDirectoriesFiles(MagicMock(), ['foo'])) 57 | 58 | 59 | class TestNoLargeFiles(unittest.TestCase): 60 | @patch('telegram_upload.upload_files.os.path.getsize', return_value=USER_MAX_FILE_SIZE - 1) 61 | @patch('telegram_upload.upload_files.File') 62 | def test_small_file(self, m1, m2): 63 | self.assertEqual(len(list(NoLargeFiles(MagicMock(max_file_size=USER_MAX_FILE_SIZE), ['foo']))), 1) 64 | 65 | @patch('telegram_upload.upload_files.os.path.getsize', return_value=USER_MAX_FILE_SIZE + 1) 66 | def test_big_file(self, m): 67 | with self.assertRaises(TelegramInvalidFile): 68 | next(NoLargeFiles(MagicMock(max_file_size=1024 ** 3), ['foo'])) 69 | 70 | 71 | class TestSplitFile(unittest.TestCase): 72 | def test_file(self): 73 | this_file = os.path.abspath(__file__) 74 | size = os.path.getsize(this_file) 75 | file0 = SplitFile(MagicMock(), this_file, size - 100, 'test.py.00') 76 | file1 = SplitFile(MagicMock(), this_file, 100, 'test.py.01') 77 | file1.seek(size - 100, split_seek=True) 78 | with open(this_file, 'rb') as f: 79 | content = f.read() 80 | self.assertEqual(file0.readall() + file1.readall(), content) 81 | self.assertEqual(file0.file_name, 'test.py.00') 82 | self.assertEqual(file1.file_size, 100) 83 | file0.close() 84 | file1.close() 85 | 86 | 87 | class TestSplitFiles(unittest.TestCase): 88 | @patch('telegram_upload.upload_files.os.path.getsize', return_value=USER_MAX_FILE_SIZE - 1) 89 | @patch('telegram_upload.upload_files.File') 90 | def test_small_file(self, m1, m2): 91 | self.assertEqual(len(list(SplitFiles(MagicMock(max_file_size=USER_MAX_FILE_SIZE), ['foo']))), 1) 92 | 93 | @patch('telegram_upload.upload_files.os.path.getsize', return_value=USER_MAX_FILE_SIZE + 1000) 94 | @patch('telegram_upload.upload_files.SplitFile.__init__', return_value=None) 95 | @patch('telegram_upload.upload_files.SplitFile.seek') 96 | def test_big_file(self, m_getsize, m_init, m_seek): 97 | mock_client = MagicMock(max_file_size=USER_MAX_FILE_SIZE) 98 | files = list(SplitFiles(mock_client, ['foo'])) 99 | self.assertEqual(len(files), 2) 100 | self.assertEqual(m_init.call_args_list[0][0], (mock_client, 'foo', USER_MAX_FILE_SIZE, 'foo.00')) 101 | self.assertEqual(m_init.call_args_list[1][0], (mock_client, 'foo', 1000, 'foo.01')) 102 | -------------------------------------------------------------------------------- /tests/test_management.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest.mock import MagicMock 4 | 5 | from telethon.tl.types import DocumentAttributeFilename, User 6 | 7 | from ._compat import patch 8 | 9 | from click.testing import CliRunner 10 | 11 | from telegram_upload.management import upload, download, get_file_display_name 12 | 13 | directory = os.path.dirname(os.path.abspath(__file__)) 14 | 15 | 16 | class TestGetFileDisplayName(unittest.TestCase): 17 | def test_get_file_display_name(self): 18 | mock_message = MagicMock() 19 | mock_message.document.mime_type = "text/plain" 20 | mock_message.document.attributes = [DocumentAttributeFilename("test.txt")] 21 | mock_message.text = "text" 22 | mock_message.sender = User( 23 | 1000, first_name="first_name", last_name="last_name", username="username", 24 | ) 25 | mock_message.date = "date" 26 | display_name = get_file_display_name(mock_message) 27 | self.assertEqual('text test.txt [text] by first_name last_name @username date', display_name) 28 | 29 | 30 | class TestUpload(unittest.TestCase): 31 | 32 | @patch('telegram_upload.management.default_config') 33 | @patch('telegram_upload.management.TelegramManagerClient') 34 | def test_upload(self, mock_client: MagicMock, _: MagicMock): 35 | mock_client.return_value.max_caption_length = 200 36 | mock_client.return_value.max_file_size = 1024 * 1024 * 1024 37 | test_file = os.path.join(directory, 'test_management.py') 38 | runner = CliRunner() 39 | result = runner.invoke(upload, [test_file]) 40 | self.assertEqual(result.exit_code, 0) 41 | mock_client.assert_called_once() 42 | mock_client.return_value.send_files.assert_called_once() 43 | 44 | @patch('telegram_upload.management.default_config') 45 | @patch('telegram_upload.management.TelegramManagerClient') 46 | def test_exclusive(self, m1, m2): 47 | runner = CliRunner() 48 | result = runner.invoke(upload, ['missing_file.txt', '--thumbnail-file', 'cara128.png', '--no-thumbnail']) 49 | self.assertEqual(result.exit_code, 2) 50 | m1.return_value.send_files.assert_not_called() 51 | 52 | 53 | class TestDownload(unittest.TestCase): 54 | @patch('telegram_upload.management.default_config') 55 | @patch('telegram_upload.management.TelegramManagerClient') 56 | def test_download(self, m1, m2): 57 | runner = CliRunner() 58 | result = runner.invoke(download, []) 59 | self.assertEqual(result.exit_code, 0) 60 | m1.assert_called_once() 61 | m1.return_value.download_files.assert_called_once() 62 | -------------------------------------------------------------------------------- /tests/test_upload_files.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | from telegram_upload.upload_files import File 5 | 6 | 7 | class TestFile(unittest.TestCase): 8 | """Test File class.""" 9 | 10 | @patch("telegram_upload.upload_files.File.__init__", return_value=None) 11 | def setUp(self, mock_file_init: MagicMock) -> None: 12 | """Set up test.""" 13 | self.max_caption_length = 256 14 | self.mock_client = MagicMock(max_caption_length=self.max_caption_length) 15 | self.file = File() 16 | self.file.client = self.mock_client 17 | self.file.path = "path/to/file.txt" 18 | 19 | def test_file_caption(self): 20 | """Test file_caption method.""" 21 | with self.subTest("Test file_caption with caption"): 22 | self.file._caption = "test {file.stem}" 23 | self.assertEqual("test file", self.file.file_caption) 24 | with self.subTest("Test file_caption without caption"): 25 | self.file._caption = None 26 | self.assertEqual("path/to/file.txt", self.file.path) 27 | with self.subTest("Test file_caption with long caption"): 28 | self.file._caption = "a" * (self.max_caption_length + 1) 29 | self.assertEqual(self.max_caption_length, len(self.file.file_caption)) 30 | self.assertTrue(self.file.file_caption.endswith("...")) 31 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, Mock 3 | 4 | from telegram_upload.utils import sizeof_fmt, scantree 5 | 6 | 7 | class TestSizeOfFmt(unittest.TestCase): 8 | def test_bytes(self): 9 | self.assertEqual(sizeof_fmt(1023), '1023.0B') 10 | 11 | def test_kibibytes(self): 12 | self.assertEqual(sizeof_fmt(2400), '2.3KiB') 13 | 14 | def test_exact_mebibytes(self): 15 | self.assertEqual(sizeof_fmt((1024 ** 2) * 3), '3.0MiB') 16 | 17 | 18 | class TestScanTree(unittest.TestCase): 19 | @patch('telegram_upload.utils.scandir', return_value=[]) 20 | def test_empty_directory(self, m): 21 | self.assertEqual(list(scantree('foo')), []) 22 | 23 | @patch('telegram_upload.utils.scandir') 24 | def test_files(self, m): 25 | file = Mock() 26 | file.is_dir.return_value = False 27 | m.return_value = [file] * 3 28 | self.assertEqual(list(scantree('foo')), m.return_value) 29 | 30 | @patch('telegram_upload.utils.scandir') 31 | def test_directory(self, m): 32 | directory = Mock() 33 | directory.is_dir.side_effect = [True, False] 34 | file = Mock() 35 | file.is_dir.return_value = False 36 | side_effect = [[directory], [file] * 3] 37 | m.side_effect = side_effect 38 | self.assertEqual(list(scantree('foo')), side_effect[-1]) 39 | -------------------------------------------------------------------------------- /tests/test_video.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from telegram_upload.exceptions import ThumbVideoError 5 | from telegram_upload.video import call_ffmpeg, get_video_size, get_video_thumb 6 | 7 | 8 | class TestcallFfmpeg(unittest.TestCase): 9 | @patch('telegram_upload.video.subprocess.Popen', side_effect=FileNotFoundError) 10 | def test_ffmpeg(self, m): 11 | with self.assertRaises(ThumbVideoError): 12 | call_ffmpeg([]) 13 | 14 | 15 | class TestGetVideoSize(unittest.TestCase): 16 | @patch('telegram_upload.video.call_ffmpeg') 17 | def test_size(self, m): 18 | m.return_value.communicate.return_value = (b'', b': Video: 1920x1080') 19 | self.assertEqual(get_video_size('foo'), [1920, 1080]) 20 | 21 | @patch('telegram_upload.video.call_ffmpeg') 22 | def test_invalid_output(self, m): 23 | m.return_value.communicate.return_value = (b'', b'foo') 24 | self.assertIsNone(get_video_size('foo')) 25 | 26 | 27 | class TestGetVideoThumb(unittest.TestCase): 28 | @patch('telegram_upload.video.video_metadata') 29 | @patch('telegram_upload.video.get_video_size', return_value=[1920, 1080]) 30 | @patch('telegram_upload.video.call_ffmpeg') 31 | def test_video_thumb(self, m1, m2, m3): 32 | get_video_thumb('foo') 33 | 34 | @patch('telegram_upload.video.video_metadata') 35 | def test_no_ratio(self, m): 36 | with self.assertRaises(ThumbVideoError): 37 | get_video_thumb('foo') 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # content of: tox.ini , put in same dir as setup.py 2 | [tox] 3 | envlist = pep8,py{310,39,38,37} 4 | 5 | [gh-actions] 6 | python = 7 | 3.7: py37 8 | 3.8: py38, mypy 9 | 3.9: py39 10 | 3.10: py310 11 | 3.11: py311 12 | 13 | [testenv] 14 | passenv=* 15 | deps = 16 | -rrequirements-dev.txt 17 | 18 | commands= 19 | {env:COMMAND:python} -m unittest discover 20 | -------------------------------------------------------------------------------- /travis_pypi_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Update encrypted deploy password in Travis config file.""" 4 | 5 | 6 | from __future__ import print_function 7 | import base64 8 | import json 9 | import os 10 | from getpass import getpass 11 | import yaml 12 | from cryptography.hazmat.primitives.serialization import load_pem_public_key 13 | from cryptography.hazmat.backends import default_backend 14 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 15 | 16 | 17 | try: 18 | from urllib import urlopen 19 | except ImportError: 20 | from urllib.request import urlopen 21 | 22 | 23 | GITHUB_REPO = 'Nekmo/telegram_upload' 24 | TRAVIS_CONFIG_FILE = os.path.join( 25 | os.path.dirname(os.path.abspath(__file__)), '.travis.yml') 26 | 27 | 28 | def load_key(pubkey): 29 | """Load public RSA key. 30 | 31 | Work around keys with incorrect header/footer format. 32 | 33 | Read more about RSA encryption with cryptography: 34 | https://cryptography.io/latest/hazmat/primitives/asymmetric/rsa/ 35 | """ 36 | try: 37 | return load_pem_public_key(pubkey.encode(), default_backend()) 38 | except ValueError: 39 | # workaround for https://github.com/travis-ci/travis-api/issues/196 40 | pubkey = pubkey.replace('BEGIN RSA', 'BEGIN').replace('END RSA', 'END') 41 | return load_pem_public_key(pubkey.encode(), default_backend()) 42 | 43 | 44 | def encrypt(pubkey, password): 45 | """Encrypt password using given RSA public key and encode it with base64. 46 | 47 | The encrypted password can only be decrypted by someone with the 48 | private key (in this case, only Travis). 49 | """ 50 | key = load_key(pubkey) 51 | encrypted_password = key.encrypt(password, PKCS1v15()) 52 | return base64.b64encode(encrypted_password) 53 | 54 | 55 | def fetch_public_key(repo): 56 | """Download RSA public key Travis will use for this repo. 57 | 58 | Travis API docs: http://docs.travis-ci.com/api/#repository-keys 59 | """ 60 | keyurl = 'https://api.travis-ci.org/repos/{0}/key'.format(repo) 61 | data = json.loads(urlopen(keyurl).read().decode()) 62 | if 'key' not in data: 63 | errmsg = "Could not find public key for repo: {}.\n".format(repo) 64 | errmsg += "Have you already added your GitHub repo to Travis?" 65 | raise ValueError(errmsg) 66 | return data['key'] 67 | 68 | 69 | def prepend_line(filepath, line): 70 | """Rewrite a file adding a line to its beginning.""" 71 | with open(filepath) as f: 72 | lines = f.readlines() 73 | 74 | lines.insert(0, line) 75 | 76 | with open(filepath, 'w') as f: 77 | f.writelines(lines) 78 | 79 | 80 | def load_yaml_config(filepath): 81 | """Load yaml config file at the given path.""" 82 | with open(filepath) as f: 83 | return yaml.load(f) 84 | 85 | 86 | def save_yaml_config(filepath, config): 87 | """Save yaml config file at the given path.""" 88 | with open(filepath, 'w') as f: 89 | yaml.dump(config, f, default_flow_style=False) 90 | 91 | 92 | def update_travis_deploy_password(encrypted_password): 93 | """Put `encrypted_password` into the deploy section of .travis.yml.""" 94 | config = load_yaml_config(TRAVIS_CONFIG_FILE) 95 | 96 | config['deploy']['password'] = dict(secure=encrypted_password) 97 | 98 | save_yaml_config(TRAVIS_CONFIG_FILE, config) 99 | 100 | line = ('# This file was autogenerated and will overwrite' 101 | ' each time you run travis_pypi_setup.py\n') 102 | prepend_line(TRAVIS_CONFIG_FILE, line) 103 | 104 | 105 | def main(args): 106 | """Add a PyPI password to .travis.yml so that Travis can deploy to PyPI. 107 | 108 | Fetch the Travis public key for the repo, and encrypt the PyPI password 109 | with it before adding, so that only Travis can decrypt and use the PyPI 110 | password. 111 | """ 112 | public_key = fetch_public_key(args.repo) 113 | password = args.password or getpass('PyPI password: ') 114 | update_travis_deploy_password(encrypt(public_key, password.encode())) 115 | print("Wrote encrypted password to .travis.yml -- you're ready to deploy") 116 | 117 | 118 | if '__main__' == __name__: 119 | import argparse 120 | parser = argparse.ArgumentParser(description=__doc__) 121 | parser.add_argument('--repo', default=GITHUB_REPO, 122 | help='GitHub repo (default: %s)' % GITHUB_REPO) 123 | parser.add_argument('--password', 124 | help='PyPI password (will prompt if not provided)') 125 | 126 | args = parser.parse_args() 127 | main(args) 128 | --------------------------------------------------------------------------------