├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation-improvement.md │ └── feature_request.md ├── dependabot.yml ├── issue_label_bot.yaml ├── stale.yml └── workflows │ ├── code-checks.yml │ └── unittest.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── README_CN.md ├── codecov.yml ├── config.yaml ├── data.yaml ├── dev-requirements.txt ├── media_downloader.py ├── module ├── __init__.py ├── app.py ├── cloud_drive.py ├── filter.py ├── static │ └── layui │ │ ├── css │ │ ├── layui.css │ │ └── modules │ │ │ ├── code.css │ │ │ ├── laydate │ │ │ └── default │ │ │ │ └── laydate.css │ │ │ └── layer │ │ │ └── default │ │ │ ├── icon-ext.png │ │ │ ├── icon.png │ │ │ ├── layer.css │ │ │ ├── loading-0.gif │ │ │ ├── loading-1.gif │ │ │ └── loading-2.gif │ │ ├── font │ │ ├── iconfont.eot │ │ ├── iconfont.svg │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 │ │ └── layui.js ├── templates │ └── index.html └── web.py ├── mypy.ini ├── pylintrc ├── requirements.txt ├── screenshot └── web_ui.gif ├── setup.py ├── tests ├── __init__.py ├── test_app │ ├── __init__.py │ └── test_app.py ├── test_common.py ├── test_media_downloader.py └── utils │ ├── __init__.py │ ├── test_file_management.py │ ├── test_filter.py │ ├── test_format.py │ ├── test_log.py │ ├── test_meta.py │ └── test_updates.py └── utils ├── __init__.py ├── file_management.py ├── format.py ├── log.py ├── meta.py ├── meta_data.py ├── platform.py └── updates.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve by fixing bugs 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Share the config: `Please don't share your api_hash & api_id` 15 | ```yaml 16 | chat_id: telegram_chat_id 17 | last_read_message_id: 0 18 | media_types: 19 | - audio 20 | - photo 21 | - video 22 | - document 23 | - voice 24 | file_formats: 25 | audio: 26 | - all 27 | document: 28 | - all 29 | video: 30 | - all 31 | ``` 32 | 33 | **Python Version** 34 | Python: [e.g. 3.7.7] 35 | 36 | **OS:** 37 | The OS and its version: [e.g. Ubuntu 20.04] 38 | 39 | **Logs** 40 | Logs showing the exception 41 | 42 | **Additional context** 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Improvement 3 | about: Report wrong or missing documentation. 4 | title: 'DOC:' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Location of the documentation 11 | 12 | [this should provide the location of the documentation, e.g. "CONTRIBUTION.md" or the URL of the documentation, e.g. "https://github.com/tangyoha/telegram_media_downloader/blob/master/CONTRIBUTING.md"] 13 | 14 | #### Documentation problem 15 | 16 | [this should provide a description of what documentation you believe needs to be fixed/improved] 17 | 18 | #### Suggested fix for documentation 19 | 20 | [this should explain the suggested fix and **why** it's better than the existing documentation] 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "12:00" 8 | timezone: CET 9 | open-pull-requests-limit: 10 10 | reviewers: 11 | - tangyoha 12 | -------------------------------------------------------------------------------- /.github/issue_label_bot.yaml: -------------------------------------------------------------------------------- 1 | label-alias: 2 | bug: 'kind/bug' 3 | feature_request: 'enhancement' 4 | question: 'question' 5 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - enhancement 8 | - feature_request 9 | - help wanted 10 | - good first issue 11 | - dependencies 12 | - bug 13 | # Label to use when marking as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity in the past 45 days. It will be closed if no further activity 19 | occurs in the next 7 days. Thank you for your contributions. 20 | 21 | # Limit to only `issues` 22 | only: issues 23 | -------------------------------------------------------------------------------- /.github/workflows/code-checks.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | paths-ignore: 7 | - 'README.md' 8 | push: 9 | branches: [ master ] 10 | paths-ignore: 11 | - 'README.md' 12 | 13 | jobs: 14 | pre-commit: 15 | name: Linting 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.10' 22 | - name: Install dependencies 23 | run: make dev_install 24 | - uses: pre-commit/action@v3.0.0 25 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | name: Unittest 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - 'README.md' 8 | pull_request: 9 | branches: [ master ] 10 | paths-ignore: 11 | - 'README.md' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11.0-beta.4' ] 21 | name: Test - Python ${{ matrix.python-version }} on ${{ matrix.os }} 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Get setuptools Unix 30 | if: ${{ matrix.os != 'windows-latest' }} 31 | run: python -m pip install --upgrade pip setuptools codecov 32 | - name: Get setuptools Windows 33 | if: ${{ matrix.os == 'windows-latest' }} 34 | run: python -m pip install --upgrade pip setuptools codecov 35 | - name: Install dependencies 36 | run: make dev_install 37 | - name: Test with pytest 38 | run: | 39 | make -e test 40 | codecov 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.pyc 3 | *.pid 4 | *.cfg 5 | *.db 6 | *.env 7 | .DS_Store 8 | .cache/ 9 | .mypy_cache/ 10 | .coverage 11 | settings.json 12 | 13 | # Distribution / packaging 14 | .Python 15 | .pytest_cache 16 | .python-version 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | logs/ 27 | parts/ 28 | sdist/ 29 | share/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | 36 | 37 | # Jupyter Notebook 38 | .ipynb_checkpoints 39 | *.ipynb 40 | 41 | # virtualenv 42 | .venv 43 | venv/ 44 | ENV/ 45 | bin/ 46 | include/ 47 | pip-selfcheck.json 48 | lib64 49 | 50 | #Telegram Sessions 51 | *.session 52 | *.session-journal 53 | 54 | #Downloaded documents 55 | documents/ 56 | audio/ 57 | document/ 58 | photo/ 59 | voice/ 60 | video/ 61 | video_note/ 62 | parser.out 63 | parsetab.py 64 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - repo: https://github.com/psf/black 10 | rev: 22.3.0 11 | hooks: 12 | - id: black 13 | name: black 14 | entry: black 15 | types: [python] 16 | - repo: https://github.com/pycqa/isort 17 | rev: 5.10.1 18 | hooks: 19 | - id: isort 20 | name: isort 21 | entry: isort 22 | types: [python] 23 | args: ["--profile", "black", "--filter-files"] 24 | - repo: https://github.com/pre-commit/mirrors-mypy 25 | rev: v0.961 26 | hooks: 27 | - id: mypy 28 | name: mypy 29 | entry: mypy 30 | types: [python] 31 | args: [--ignore-missing-imports] 32 | files: utils/|media_downloader.py|module/ 33 | exclude: tests/|module/static/|module/templates 34 | - repo: https://github.com/pycqa/pylint 35 | rev: v2.14.5 36 | hooks: 37 | - id: pylint 38 | name: pylint 39 | entry: pylint 40 | language: system 41 | types: [python] 42 | args: [ 43 | "-rn", # Only display messages 44 | "-sn", # Don't display the score 45 | "--rcfile=pylintrc" # Link to your config file 46 | ] 47 | files: utils/|media_downloader.py|module/ 48 | exclude: tests/|module/static/|module/templates 49 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@dineshkarthik.me. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | First off, thank you for considering contributing to Telegram Media Downloader. It's people like you that make telegram-media-downloader such a great tool. 4 | Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved. 5 | 6 | ### Where do I go from here? 7 | 8 | If you've noticed a bug or have a feature request, [make one](https://github.com/tangyoha/telegram_media_downloader/issues)! It's generally best if you get confirmation of your bug or approval for your feature request this way before starting to code. 9 | 10 | If you have a general question about telegram-media-downloader, you can ask it on [Discussion](https://github.com/tangyoha/telegram_media_downloader/discussions) under `Q&A` category and any ideas/suggestions goes under `Ideas` category, the issue tracker is only for bugs and feature requests. 11 | 12 | ### Fork & create a branch 13 | 14 | If this is something you think you can fix, then [fork telegram-media-downloader](https://help.github.com/articles/fork-a-repo) and create a branch with a descriptive name. 15 | 16 | A good branch name would be (where issue #52 is the ticket you're working on): 17 | 18 | ```sh 19 | git checkout -b 52-fix-expired-file-reference 20 | ``` 21 | 22 | ### For new Contributors 23 | 24 | If you never created a pull request before, welcome [Here is a great tutorial](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) on how to send one :) 25 | 26 | 1. [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork, and configure the remotes: 27 | ```sh 28 | # Clone your fork of the repo into the current directory 29 | git clone https://github.com// 30 | # Navigate to the newly cloned directory 31 | cd 32 | # Install dependencies 33 | make dev_install 34 | # Assign the original repo to a remote called "upstream" 35 | git remote add upstream https://github.com/Dineshkkarthik/ 36 | ``` 37 | 38 | 2. If you cloned a while ago, get the latest changes from upstream: 39 | ```sh 40 | git checkout master 41 | git pull upstream master 42 | ``` 43 | 44 | 3. Create a new branch (off the main project master branch) to contain your feature, change, or fix based on the branch name convention described above: 45 | ```sh 46 | git checkout -b 47 | ``` 48 | 49 | 4. Make sure to update, or add to the tests when appropriate. Patches and features will not be accepted without tests. Run `make test` to check that all tests pass after you've made changes. 50 | 51 | 5. If you added or changed a feature, make sure to document it accordingly in the `README.md` file. 52 | 53 | 6. Push your branch up to your fork: 54 | ```sh 55 | git push origin 56 | ``` 57 | 58 | 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) with a clear title and description. 59 | 60 | 61 | ### Coding Standards 62 | 63 | #### Python style 64 | 65 | Please follow these coding standards when writing code for inclusion in telegram-media-downloader. 66 | 67 | Telegram-media-downloader follows the [PEP8](https://www.python.org/dev/peps/pep-0008/) standard and uses [Black](https://black.readthedocs.io/en/stable/) and [Pylint](https://pylint.pycqa.org/en/latest/) to ensure a consistent code format throughout the project. 68 | 69 | [Continuous Integration](https://github.com/tangyoha/telegram_media_downloader/actions) using GitHub Actions will run those tools and report any stylistic errors in your code. Therefore, it is helpful before submitting code to run the check yourself: 70 | ```sh 71 | black media_downloader.py utils 72 | ``` 73 | to auto-format your code. Additionally, many editors have plugins that will apply `black` as you edit files. 74 | 75 | Writing good code is not just about what you write. It is also about _how_ you write it. During [Continuous Integration](https://github.com/tangyoha/telegram_media_downloader/actions) testing, several tools will be run to check your code for stylistic errors. Generating any warnings will cause the test to fail. Thus, good style is a requirement for submitting code to telegram-media-downloader. 76 | 77 | This is already added in the repo to help contributors verify their changes before contributing them to the project: 78 | ```sh 79 | make style_check 80 | ``` 81 | 82 | #### Type hints 83 | 84 | Telegram-media-downloader strongly encourages the use of [**PEP 484**](https://www.python.org/dev/peps/pep-0484) style type hints. New development should contain type hints and pull requests to annotate existing code are accepted as well! 85 | 86 | Types imports should follow the `from typing import ...` convention. So rather than 87 | ```py 88 | import typing 89 | 90 | primes: typing.List[int] = [] 91 | ``` 92 | You should write 93 | ```py 94 | from typing import List, Optional, Union 95 | 96 | primes: List[int] = [] 97 | ``` 98 | 99 | `Optional` should be used where applicable, so instead of 100 | ```py 101 | maybe_primes: List[Union[int, None]] = [] 102 | ``` 103 | You should write 104 | ```py 105 | maybe_primes: List[Optional[int]] = [] 106 | ``` 107 | 108 | #### Validating type hints 109 | 110 | telegram-media-downloader uses [mypy](http://mypy-lang.org/) to statically analyze the code base and type hints. After making any change you can ensure your type hints are correct by running 111 | ```sh 112 | make static_type_check 113 | ``` 114 | 115 | #### Docstrings and standards 116 | 117 | A Python docstring is a string used to document a Python module, class, function or method, so programmers can understand what it does without having to read the details of the implementation. 118 | 119 | The next example gives an idea of what a docstring looks like: 120 | ```py 121 | def add(num1: int, num2: int) -> int: 122 | """ 123 | Add up two integer numbers. 124 | 125 | This function simply wraps the ``+`` operator, and does not 126 | do anything interesting, except for illustrating what 127 | the docstring of a very simple function looks like. 128 | 129 | Parameters 130 | ---------- 131 | num1: int 132 | First number to add. 133 | num2: int 134 | Second number to add. 135 | 136 | Returns 137 | ------- 138 | int 139 | The sum of ``num1`` and ``num2``. 140 | 141 | See Also 142 | -------- 143 | subtract : Subtract one integer from another. 144 | 145 | Examples 146 | -------- 147 | >>> add(2, 2) 148 | 4 149 | >>> add(25, 0) 150 | 25 151 | >>> add(10, -10) 152 | 0 153 | """ 154 | return num1 + num2 155 | ``` 156 | Some standards regarding docstrings exist, which make them easier to read, and allow them be easily exported to other formats such as html or pdf. 157 | 158 | ### Commit Message 159 | 160 | telegram-media-downloader uses a convention for commit message prefixes and layout. Here are some common prefixes along with general guidelines for when to use them: 161 | ``` 162 | : 163 | <-- OPTIONAL --> 164 | 165 | 166 | ``` 167 | 168 | #### Prefix: 169 | 170 | Must be one of the following: 171 | - **add**: Adding a new file 172 | - **ci**: Changes to CI configuration files and scripts (example: files inside `.github` folder) 173 | - **clean**: Code cleanup 174 | - **docs**: Additions/updates to documentation 175 | - **enh**: Enhancement, new functionality 176 | - **fix**: Bug fix 177 | - **perf**: A code change that improves performance 178 | - **refactor**: A code change that neither fixes a bug nor adds a feature 179 | - **style**: Changes that do not affect the meaning of the code (white-space, formatting, etc) 180 | - **test**: Additions/updates to tests 181 | - **type**: Type annotations 182 | 183 | #### Subject: 184 | 185 | Please reference the relevant GitHub issues in your commit message using #1234. 186 | - a subject line with `< 80` chars. 187 | - summary in present tense. 188 | - not capitalized. 189 | - no period at the end. 190 | 191 | #### Commit Message Body 192 | 193 | Just as in the summary, use the imperative, present tense. 194 | 195 | Explain the motivation for the change in the commit message body. This commit message should explain _why_ you are making the change. You can include a comparison of the previous behavior with the new behavior in order to illustrate the impact of the change. 196 | 197 | ### Code of Conduct 198 | 199 | As a contributor, you can help us keep the community open and inclusive. Please read and follow our [Code of Conduct](https://github.com/tangyoha/telegram_media_downloader/blob/master/CODE_OF_CONDUCT.md). 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dineshkarthik R 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST_ARTIFACTS ?= /tmp/coverage 2 | 3 | .PHONY: install dev_install static_type_check pylint style_check test 4 | 5 | install: 6 | python3 -m pip install --upgrade pip setuptools 7 | python3 -m pip install -r requirements.txt 8 | 9 | dev_install: install 10 | python3 -m pip install -r dev-requirements.txt 11 | 12 | static_type_check: 13 | mypy media_downloader.py utils module --ignore-missing-imports 14 | 15 | pylint: 16 | pylint media_downloader.py utils module -r y 17 | 18 | style_check: static_type_check pylint 19 | 20 | test: 21 | py.test --cov media_downloader --doctest-modules \ 22 | --cov utils \ 23 | --cov module/app.py \ 24 | --cov-report term-missing \ 25 | --cov-report html:${TEST_ARTIFACTS} \ 26 | --junit-xml=${TEST_ARTIFACTS}/media-downloader.xml \ 27 | tests/ 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Telegram Media Downloader

3 | 4 |

5 | Unittest 6 | Coverage Status 7 | License: MIT 8 | Code style: black 9 | 10 | Code style: black 11 |

12 | 13 |

14 | 中文 · 15 | Feature request 16 | · 17 | Report a bug 18 | · 19 | Support: Discussions 20 | & 21 | Telegram Community 22 |

23 | 24 | 25 | ### Overview 26 | 27 | Download all media files from a conversation or a channel that you are a part of from telegram. 28 | A meta of last read/downloaded message is stored in the config file so that in such a way it won't download the same media file again. 29 | 30 | ### UI 31 | 32 | ![web](./screenshot/web_ui.gif) 33 | 34 | ### 项目已经永久移动到 https://github.com/tangyoha/telegram_media_downloader 35 | ### This project has been migrated to https://github.com/tangyoha/telegram_media_downloader 36 | 37 | 38 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | 2 |

电报资源下载

3 | 4 |

5 | Unittest 6 | Coverage Status 7 | License: MIT 8 | Code style: black 9 | 10 | Code style: black 11 | 12 |

13 | 14 |

15 | English · 16 | 新功能请求 17 | · 18 | 报告bug 19 | · 20 | 帮助: 讨论 21 | & 22 | 电报讨论群 23 |

24 | 25 | ### 概述 26 | 27 | 从您所属的电报对话或频道下载所有媒体文件。 28 | 最后读取/下载消息的元数据存储在配置文件中,这样它就不会再次下载相同的媒体文件。 29 | 30 | ### 界面 31 | 32 | ![web](./screenshot/web_ui.gif) 33 | 34 | 运行后打开浏览器访问`localhost:5000` 35 | 36 | ### 支持 37 | 38 | | 类别 | 支持 | 39 | | ------------ | ---------------------------------------- | 40 | | 语言 | `Python 3.7` 及以上 | 41 | | 下载媒体类型 | 音频、文档、照片、视频、video_note、语音 | 42 | 43 | ### 版本发布计划 44 | 45 | * [v2.2.0](https://github.com/tangyoha/telegram_media_downloader/issues/28) 46 | 47 | ### 安装 48 | 49 | 对于具有 `make` 可用性的 *nix 操作系统发行版 50 | 51 | ```sh 52 | git clone https://github.com/tangyoha/telegram_media_downloader.git 53 | cd telegram_media_downloader 54 | make install 55 | ``` 56 | 57 | 对于没有内置 `make` 的 Windows 58 | 59 | ```sh 60 | git clone https://github.com/tangyoha/telegram_media_downloader.git 61 | cd telegram_media_downloader 62 | pip3 install -r requirements.txt 63 | ``` 64 | 65 | ## 升级安装 66 | 67 | ```sh 68 | cd telegram_media_downloader 69 | pip3 install -r requirements.txt 70 | ``` 71 | 72 | ## 配置 73 | 74 | 所有配置都通过 config.yaml 文件传递​​给 `Telegram Media Downloader`。 75 | 76 | **获取您的 API 密钥:** 77 | 第一步需要您获得有效的 Telegram API 密钥(API id/hash pair): 78 | 79 | 1. 访问 [https://my.telegram.org/apps](https://my.telegram.org/apps) 并使用您的 Telegram 帐户登录。 80 | 2. 填写表格以注册新的 Telegram 应用程序。 81 | 3. 完成! API 密钥由两部分组成:**api_id** 和**api_hash**。 82 | 83 | **获取聊天ID:** 84 | > 如果你需要下载收藏夹的内容请填`me` 85 | 86 | **1。使用网络电报:** 87 | 88 | 1. 打开 89 | 2. 现在转到聊天/频道,您将看到 URL 类似 90 | 91 | - `https://web.telegram.org/?legacy=1#/im?p=u853521067_2449618633394` 这里 `853521067` 是聊天 ID。 92 | - `https://web.telegram.org/?legacy=1#/im?p=@somename` 这里的 `somename` 是聊天 ID。 93 | - `https://web.telegram.org/?legacy=1#/im?p=s1301254321_6925449697188775560` 此处取 `1301254321` 并将 `-100` 添加到 id => `-1001301254321` 的开头。 94 | - `https://web.telegram.org/?legacy=1#/im?p=c1301254321_6925449697188775560` 此处取 `1301254321` 并将 `-100` 添加到 id => `-1001301254321` 的开头。 95 | 96 | **2。使用机器人:** 97 | 1.使用[@username_to_id_bot](https://t.me/username_to_id_bot)获取chat_id 98 | - 几乎所有电报用户:将用户名发送给机器人或将他们的消息转发给机器人 99 | - 任何聊天:发送聊天用户名或复制并发送其加入聊天链接到机器人 100 | - 公共或私人频道:与聊天相同,只需复制并发送给机器人 101 | - 任何电报机器人的 ID 102 | 103 | ### 配置文件 104 | 105 | ```yaml 106 | api_hash: your_api_hash 107 | api_id: your_api_id 108 | chat_id: telegram_chat_id 109 | last_read_message_id: 0 110 | ids_to_retry: [] 111 | media_types: 112 | - audio 113 | - document 114 | - photo 115 | - video 116 | - voice 117 | file_formats: 118 | audio: 119 | - all 120 | document: 121 | - pdf 122 | - epub 123 | video: 124 | - mp4 125 | save_path: D:\telegram_media_downloader 126 | file_path_prefix: 127 | - chat_title 128 | - media_datetime 129 | disable_syslog: 130 | - INFO 131 | upload_drive: 132 | enable_upload_file: true 133 | remote_dir: drive:/telegram 134 | before_upload_file_zip: True 135 | after_upload_file_delete: True 136 | hide_file_name: true 137 | file_name_prefix: 138 | - message_id 139 | - file_name 140 | file_name_prefix_split: ' - ' 141 | max_concurrent_transmissions: 1 142 | web_host: 127.0.0.1 143 | web_port: 5000 144 | download_filter: 145 | 'telegram_chat_id': message_date >= 2022-12-01 00:00:00 and message_date <= 2023-01-17 00:00:00 146 | ``` 147 | 148 | - **api_hash** - 你从电报应用程序获得的 api_hash 149 | - **api_id** - 您从电报应用程序获得的 api_id 150 | - **chat_id** - 您要下载媒体的聊天/频道的 ID。你从上述步骤中得到的。 151 | - **last_read_message_id** - 如果这是您第一次阅读频道,请将其设置为“0”,或者如果您已经使用此脚本下载媒体,它将有一些数字,这些数字会在脚本成功执行后自动更新。不要改变它。如果你需要下载收藏夹的内容,请填`me`。 152 | - **ids_to_retry** - `保持原样。`下载器脚本使用它来跟踪所有跳过的下载,以便在下次执行脚本时可以下载它。 153 | - **media_types** - 要下载的媒体类型,您可以更新要下载的媒体类型,它可以是一种或任何可用类型。 154 | - **file_formats** - 为支持的媒体类型(“音频”、“文档”和“视频”)下载的文件类型。默认格式为“all”,下载所有文件。 155 | - **save_path** - 你想存储下载文件的根目录 156 | - **file_path_prefix** - 存储文件子文件夹,列表的顺序不定,可以随机组合 157 | - `chat_title` - 聊天频道或者群组标题, 如果找不到标题则为配置文件中的`chat_id` 158 | - `media_datetime` - 资源的发布时间 159 | - `media_type` - 资源类型,类型查阅 `media_types` 160 | - **disable_syslog** - 您可以选择要禁用的日志类型,请参阅 `logging._nameToLevel` 161 | - **upload_drive** - 您可以将文件上传到云盘 162 | - `enable_upload_file` - [必填]启用上传文件,默认为`false` 163 | - `remote_dir` - [必填]你上传的地方 164 | - `upload_adapter` - [必填]上传文件适配器,可以为`rclone`,`aligo`。如果为`rclone`,则支持rclone所有支持上传的服务器,如果为aligo,则支持上传阿里云盘 165 | - `rclone_path`,如果配置`upload_adapter`为`rclone`则为必填,`rclone`的可执行目录,查阅 [如何使用rclone](https://github.com/tangyoha/telegram_media_downloader/wiki/Rclone) 166 | - `before_upload_file_zip` - 上传前压缩文件,默认为`false` 167 | - `after_upload_file_delete` - 上传成功后删除文件,默认为`false` 168 | - **file_name_prefix** - 自定义文件名称,使用和 **file_path_prefix** 一样 169 | - `message_id` - 消息id 170 | - `file_name` - 文件名称(可能为空) 171 | - `caption` - 消息的标题(可能为空) 172 | - **file_name_prefix_split** - 自定义文件名称分割符号,默认为` - ` 173 | - **max_concurrent_transmissions** - 设置最大并发传输量(上传和下载)。 太高的值可能会导致与网络相关的问题。 默认为 1。 174 | - **hide_file_name** - 是否隐藏web界面文件名称,默认`false` 175 | - **web_host** - web界面地址 176 | - **web_port** - web界面端口 177 | - **download_filter** - 下载过滤器, 查阅 [How to use Filter](https://github.com/tangyoha/telegram_media_downloader/wiki/How-to-use-Filter) 178 | 179 | ## 执行 180 | 181 | ```sh 182 | python3 media_downloader.py 183 | ``` 184 | 185 | 所有下载的媒体都将存储在`save_path`根目录下。 186 | 具体位置参考如下: 187 | 188 | ```yaml 189 | file_path_prefix: 190 | - chat_title 191 | - media_datetime 192 | - media_type 193 | ``` 194 | 195 | 视频下载完整目录为:`save_path`/`chat_title`/`media_datetime`/`media_type`。 196 | 列表的顺序不定,可以随机组合。 197 | 如果配置为空,则所有文件保存在`save_path`下。 198 | 199 | ## 代理 200 | 201 | 该项目目前支持 socks4、socks5、http 代理。要使用它,请将以下内容添加到`config.yaml`文件的底部 202 | 203 | ```yaml 204 | proxy: 205 | scheme: socks5 206 | hostname: 127.0.0.1 207 | port: 1234 208 | username: 你的用户名(无则删除该行) 209 | password: 你的密码(无则删除该行) 210 | ``` 211 | 212 | 如果您的代理不需要授权,您可以省略用户名和密码。然后代理将自动启用。 213 | 214 | ## 贡献 215 | 216 | ### 贡献指南 217 | 218 | 通读我们的[贡献指南](./CONTRIBUTING.md),了解我们的提交流程、编码规则等。 219 | 220 | ### 想帮忙? 221 | 222 | 想要提交错误、贡献一些代码或改进文档?出色的!阅读我们的 [贡献指南](./CONTRIBUTING.md)。 223 | 224 | ### 行为守则 225 | 226 | 帮助我们保持 Telegram Media Downloader 的开放性和包容性。请阅读并遵守我们的[行为准则](./CODE_OF_CONDUCT.md)。 227 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | if_no_uploads: error 8 | if_not_found: success 9 | if_ci_failed: error 10 | patch: no 11 | 12 | comment: 13 | require_changes: true 14 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | api_hash: your_api_hash 2 | api_id: your_api_id 3 | chat_id: telegram_chat_id 4 | disable_syslog: 5 | - INFO 6 | file_formats: 7 | audio: 8 | - all 9 | document: 10 | - all 11 | video: 12 | - all 13 | file_path_prefix: 14 | - chat_title 15 | - media_datetime 16 | ids_to_retry: [] 17 | last_read_message_id: 0 18 | media_types: 19 | - audio 20 | - photo 21 | - video 22 | - document 23 | - voice 24 | - video_note 25 | save_path: E:\github\telegram_media_downloader 26 | -------------------------------------------------------------------------------- /data.yaml: -------------------------------------------------------------------------------- 1 | ids_to_retry: [] 2 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | black==22.6.0 2 | isort==5.10.1 3 | mock==4.0.3 4 | mypy==0.971 5 | pre-commit==2.21.0 6 | pylint==2.14.5 7 | pytest==7.2.1 8 | pytest-cov==3.0.0 9 | types-PyYAML==6.0.11 10 | -------------------------------------------------------------------------------- /media_downloader.py: -------------------------------------------------------------------------------- 1 | """Downloads media from telegram.""" 2 | import asyncio 3 | import logging 4 | import os 5 | import re 6 | import threading 7 | import time 8 | from typing import List, Optional, Tuple, Union 9 | 10 | import pyrogram 11 | from loguru import logger 12 | from pyrogram.types import Audio, Document, Photo, Video, VideoNote, Voice 13 | from rich.logging import RichHandler 14 | 15 | from module.app import Application 16 | from module.web import get_flask_app, update_download_status 17 | from utils.log import LogFilter 18 | from utils.meta import print_meta 19 | from utils.meta_data import MetaData 20 | from utils.updates import check_for_updates 21 | 22 | logging.basicConfig( 23 | level=logging.INFO, 24 | format="%(message)s", 25 | datefmt="[%X]", 26 | handlers=[RichHandler()], 27 | ) 28 | 29 | CONFIG_NAME = "config.yaml" 30 | DATA_FILE_NAME = "data.yaml" 31 | APPLICATION_NAME = "media_downloader" 32 | app = Application(CONFIG_NAME, DATA_FILE_NAME, APPLICATION_NAME) 33 | 34 | RETRY_TIME_OUT = 5 35 | 36 | logging.getLogger("pyrogram.session.session").addFilter(LogFilter()) 37 | logging.getLogger("pyrogram.client").addFilter(LogFilter()) 38 | 39 | logging.getLogger("pyrogram").setLevel(logging.WARNING) 40 | 41 | 42 | def _check_download_finish(media_size: int, download_path: str, message_id: int): 43 | """Check download task if finish 44 | 45 | Parameters 46 | ---------- 47 | media_size: int 48 | The size of the downloaded resource 49 | download_path: str 50 | Resource download hold path 51 | message_id: int 52 | Download message id 53 | 54 | """ 55 | download_size = os.path.getsize(download_path) 56 | if media_size == download_size: 57 | logger.success("Media downloaded - {}", download_path) 58 | app.downloaded_ids.append(message_id) 59 | app.total_download_task += 1 60 | else: 61 | logger.error("Media downloaded with wrong size - {}", download_path) 62 | os.remove(download_path) 63 | raise TypeError("Media downloaded with wrong size") 64 | 65 | 66 | def _check_timeout(retry: int, message_id: int): 67 | """Check if message download timeout, then add message id into failed_ids 68 | 69 | Parameters 70 | ---------- 71 | retry: int 72 | Retry download message times 73 | 74 | message_id: int 75 | Try to download message 's id 76 | 77 | """ 78 | if retry == 2: 79 | app.failed_ids.append(message_id) 80 | return True 81 | return False 82 | 83 | 84 | def _validate_title(title: str): 85 | """Fix if title validation fails 86 | 87 | Parameters 88 | ---------- 89 | title: str 90 | Chat title 91 | 92 | """ 93 | 94 | r_str = r"[\//\:\*\?\"\<\>\|\n]" # '/ \ : * ? " < > |' 95 | new_title = re.sub(r_str, "_", title) 96 | return new_title 97 | 98 | 99 | def _can_download(_type: str, file_formats: dict, file_format: Optional[str]) -> bool: 100 | """ 101 | Check if the given file format can be downloaded. 102 | 103 | Parameters 104 | ---------- 105 | _type: str 106 | Type of media object. 107 | file_formats: dict 108 | Dictionary containing the list of file_formats 109 | to be downloaded for `audio`, `document` & `video` 110 | media types 111 | file_format: str 112 | Format of the current file to be downloaded. 113 | 114 | Returns 115 | ------- 116 | bool 117 | True if the file format can be downloaded else False. 118 | """ 119 | if _type in ["audio", "document", "video"]: 120 | allowed_formats: list = file_formats[_type] 121 | if not file_format in allowed_formats and allowed_formats[0] != "all": 122 | return False 123 | return True 124 | 125 | 126 | def _is_exist(file_path: str) -> bool: 127 | """ 128 | Check if a file exists and it is not a directory. 129 | 130 | Parameters 131 | ---------- 132 | file_path: str 133 | Absolute path of the file to be checked. 134 | 135 | Returns 136 | ------- 137 | bool 138 | True if the file exists else False. 139 | """ 140 | return not os.path.isdir(file_path) and os.path.exists(file_path) 141 | 142 | 143 | async def _get_media_meta( 144 | message: pyrogram.types.Message, 145 | media_obj: Union[Audio, Document, Photo, Video, VideoNote, Voice], 146 | _type: str, 147 | ) -> Tuple[str, Optional[str]]: 148 | """Extract file name and file id from media object. 149 | 150 | Parameters 151 | ---------- 152 | media_obj: Union[Audio, Document, Photo, Video, VideoNote, Voice] 153 | Media object to be extracted. 154 | _type: str 155 | Type of media object. 156 | 157 | Returns 158 | ------- 159 | Tuple[str, Optional[str]] 160 | file_name, file_format 161 | """ 162 | if _type in ["audio", "document", "video"]: 163 | # pylint: disable = C0301 164 | file_format: Optional[str] = media_obj.mime_type.split("/")[-1] # type: ignore 165 | else: 166 | file_format = None 167 | 168 | file_name = None 169 | dirname = _validate_title(f"{app.chat_id}") 170 | if message.chat and message.chat.title: 171 | dirname = _validate_title(f"{message.chat.title}") 172 | 173 | if message.date: 174 | datetime_dir_name = message.date.strftime("%Y_%m") 175 | else: 176 | datetime_dir_name = "0" 177 | 178 | if _type in ["voice", "video_note"]: 179 | # pylint: disable = C0209 180 | file_format = media_obj.mime_type.split("/")[-1] # type: ignore 181 | file_save_path = app.get_file_save_path(_type, dirname, datetime_dir_name) 182 | 183 | file_name = os.path.join( 184 | file_save_path, 185 | "{} - {}_{}.{}".format( 186 | message.id, 187 | _type, 188 | media_obj.date.isoformat(), # type: ignore 189 | file_format, 190 | ), 191 | ) 192 | else: 193 | file_name = getattr(media_obj, "file_name", None) 194 | caption = getattr(message, "caption", None) 195 | 196 | file_name_suffix = "" 197 | if not file_name: 198 | if message.photo: 199 | file_format = "jpg" 200 | file_name_suffix = f".{file_format}" 201 | 202 | if caption: 203 | caption = _validate_title(caption) 204 | app.set_caption_name(app.chat_id, message.media_group_id, caption) 205 | else: 206 | caption = app.get_caption_name(app.chat_id, message.media_group_id) 207 | 208 | if not file_name and message.photo: 209 | file_name = f"{message.photo.file_unique_id}" 210 | 211 | gen_file_name = ( 212 | app.get_file_name(message.id, file_name, caption) + file_name_suffix 213 | ) 214 | 215 | file_save_path = app.get_file_save_path(_type, dirname, datetime_dir_name) 216 | file_name = os.path.join(file_save_path, gen_file_name) 217 | return file_name, file_format 218 | 219 | 220 | # pylint: disable = R0915 221 | async def download_media( 222 | client: pyrogram.client.Client, 223 | message: pyrogram.types.Message, 224 | media_types: List[str], 225 | file_formats: dict, 226 | ): 227 | """ 228 | Download media from Telegram. 229 | 230 | Each of the files to download are retried 3 times with a 231 | delay of 5 seconds each. 232 | 233 | Parameters 234 | ---------- 235 | client: pyrogram.client.Client 236 | Client to interact with Telegram APIs. 237 | message: pyrogram.types.Message 238 | Message object retrieved from telegram. 239 | media_types: list 240 | List of strings of media types to be downloaded. 241 | Ex : `["audio", "photo"]` 242 | Supported formats: 243 | * audio 244 | * document 245 | * photo 246 | * video 247 | * voice 248 | file_formats: dict 249 | Dictionary containing the list of file_formats 250 | to be downloaded for `audio`, `document` & `video` 251 | media types. 252 | 253 | Returns 254 | ------- 255 | int 256 | Current message id. 257 | """ 258 | # pylint: disable = R0912 259 | file_name: str = "" 260 | ui_file_name: str = "" 261 | task_start_time: float = time.time() 262 | media_size = 0 263 | _media = None 264 | try: 265 | if message.media is None: 266 | return message.id 267 | for _type in media_types: 268 | _media = getattr(message, _type, None) 269 | if _media is None: 270 | continue 271 | file_name, file_format = await _get_media_meta(message, _media, _type) 272 | media_size = getattr(_media, "file_size", 0) 273 | if _can_download(_type, file_formats, file_format): 274 | if _is_exist(file_name): 275 | # TODO: check if the file download complete 276 | # file_size = os.path.getsize(file_name) 277 | # media_size = getattr(_media, 'file_size') 278 | # if media_size is not None and file_size != media_size: 279 | 280 | # FIXME: if exist and not empty file skip 281 | logger.info( 282 | "{} already download,download skipped.\n", 283 | file_name, 284 | ) 285 | 286 | return message.id 287 | 288 | ui_file_name = file_name 289 | if app.hide_file_name: 290 | ui_file_name = ( 291 | os.path.dirname(file_name) 292 | + "/****" 293 | + os.path.splitext(file_name)[-1] 294 | ) 295 | break 296 | except Exception as e: 297 | logger.error( 298 | "Message[{}]: could not be downloaded due to following exception:\n[{}].", 299 | message.id, 300 | e, 301 | exc_info=True, 302 | ) 303 | app.failed_ids.append(message.id) 304 | return message.id 305 | 306 | if _media is None: 307 | return message.id 308 | 309 | for retry in range(3): 310 | try: 311 | download_path = await client.download_media( 312 | message, 313 | file_name=file_name, 314 | progress=lambda down_byte, total_byte: update_download_status( 315 | message.id, 316 | down_byte, 317 | total_byte, 318 | ui_file_name, 319 | task_start_time, 320 | ), 321 | ) 322 | 323 | if download_path and isinstance(download_path, str): 324 | # TODO: if not exist file size or media 325 | _check_download_finish(media_size, download_path, message.id) 326 | await app.upload_file(file_name) 327 | 328 | break 329 | except pyrogram.errors.exceptions.bad_request_400.BadRequest: 330 | logger.warning( 331 | "Message[{}]: file reference expired, refetching...", 332 | message.id, 333 | ) 334 | message = await client.get_messages( # type: ignore 335 | chat_id=message.chat.id, # type: ignore 336 | message_ids=message.id, 337 | ) 338 | if _check_timeout(retry, message.id): 339 | # pylint: disable = C0301 340 | logger.error( 341 | "Message[{}]: file reference expired for 3 retries, download skipped.", 342 | message.id, 343 | ) 344 | except pyrogram.errors.exceptions.flood_420.FloodWait as wait_err: 345 | await asyncio.sleep(wait_err.value) 346 | logger.warning("Message[{}]: FlowWait {}", message.id, wait_err.value) 347 | _check_timeout(retry, message.id) 348 | except TypeError: 349 | # pylint: disable = C0301 350 | logger.warning( 351 | "Timeout Error occurred when downloading Message[{}], retrying after 5 seconds", 352 | message.id, 353 | ) 354 | await asyncio.sleep(RETRY_TIME_OUT) 355 | if _check_timeout(retry, message.id): 356 | logger.error( 357 | "Message[{}]: Timing out after 3 reties, download skipped.", 358 | message.id, 359 | ) 360 | except Exception as e: 361 | # pylint: disable = C0301 362 | logger.error( 363 | "Message[{}]: could not be downloaded due to following exception:\n[{}].", 364 | message.id, 365 | e, 366 | exc_info=True, 367 | ) 368 | app.failed_ids.append(message.id) 369 | break 370 | return message.id 371 | 372 | 373 | async def process_messages( 374 | client: pyrogram.client.Client, 375 | messages: List[pyrogram.types.Message], 376 | media_types: List[str], 377 | file_formats: dict, 378 | ) -> int: 379 | """ 380 | Download media from Telegram. 381 | 382 | Parameters 383 | ---------- 384 | client: pyrogram.client.Client 385 | Client to interact with Telegram APIs. 386 | messages: list 387 | List of telegram messages. 388 | media_types: list 389 | List of strings of media types to be downloaded. 390 | Ex : `["audio", "photo"]` 391 | Supported formats: 392 | * audio 393 | * document 394 | * photo 395 | * video 396 | * voice 397 | file_formats: dict 398 | Dictionary containing the list of file_formats 399 | to be downloaded for `audio`, `document` & `video` 400 | media types. 401 | 402 | Returns 403 | ------- 404 | int 405 | Max value of list of message ids. 406 | """ 407 | message_ids = await asyncio.gather( 408 | *[ 409 | download_media(client, message, media_types, file_formats) 410 | for message in messages 411 | ] 412 | ) 413 | 414 | last_message_id: int = max(message_ids) 415 | return last_message_id 416 | 417 | 418 | async def begin_import(pagination_limit: int): 419 | """ 420 | Create pyrogram client and initiate download. 421 | 422 | The pyrogram client is created using the ``api_id``, ``api_hash`` 423 | from the config and iter through message offset on the 424 | ``last_message_id`` and the requested file_formats. 425 | 426 | Parameters 427 | ---------- 428 | pagination_limit: int 429 | Number of message to download asynchronously as a batch. 430 | """ 431 | client = pyrogram.Client( 432 | "media_downloader", 433 | api_id=app.api_id, 434 | api_hash=app.api_hash, 435 | proxy=app.proxy, 436 | ) 437 | 438 | if getattr(client, "max_concurrent_transmissions", None): 439 | client.max_concurrent_transmissions = app.max_concurrent_transmissions 440 | 441 | await client.start() 442 | print("Successfully started (Press Ctrl+C to stop)") 443 | 444 | last_read_message_id: int = app.last_read_message_id 445 | messages_iter = client.get_chat_history( 446 | app.chat_id, offset_id=app.last_read_message_id, reverse=True 447 | ) 448 | messages_list: list = [] 449 | pagination_count: int = 0 450 | if app.ids_to_retry: 451 | logger.info("Downloading files failed during last run...") 452 | skipped_messages: list = await client.get_messages( # type: ignore 453 | chat_id=app.chat_id, message_ids=app.ids_to_retry 454 | ) 455 | for message in skipped_messages: 456 | if pagination_count != pagination_limit: 457 | pagination_count += 1 458 | messages_list.append(message) 459 | else: 460 | last_read_message_id = await process_messages( 461 | client, 462 | messages_list, 463 | app.media_types, 464 | app.file_formats, 465 | ) 466 | pagination_count = 0 467 | messages_list = [] 468 | messages_list.append(message) 469 | app.last_read_message_id = last_read_message_id 470 | 471 | async for message in messages_iter: # type: ignore 472 | meta_data = MetaData() 473 | meta_data.get_meta_data(message) 474 | if pagination_count != pagination_limit: 475 | if not app.need_skip_message(str(app.chat_id), message.id, meta_data): 476 | pagination_count += 1 477 | messages_list.append(message) 478 | else: 479 | last_read_message_id = await process_messages( 480 | client, 481 | messages_list, 482 | app.media_types, 483 | app.file_formats, 484 | ) 485 | pagination_count = 0 486 | messages_list = [] 487 | messages_list.append(message) 488 | app.last_read_message_id = last_read_message_id 489 | if messages_list: 490 | last_read_message_id = await process_messages( 491 | client, 492 | messages_list, 493 | app.media_types, 494 | app.file_formats, 495 | ) 496 | 497 | await client.stop() 498 | app.last_read_message_id = last_read_message_id 499 | 500 | 501 | def main(): 502 | """Main function of the downloader.""" 503 | try: 504 | app.pre_run() 505 | threading.Thread( 506 | target=get_flask_app().run, daemon=True, args=(app.web_host, app.web_port) 507 | ).start() 508 | asyncio.get_event_loop().run_until_complete(begin_import(pagination_limit=10)) 509 | if app.failed_ids: 510 | logger.error( 511 | "Downloading of {} files failed. " 512 | "Failed message ids are added to config file.\n" 513 | "These files will be downloaded on the next run.", 514 | len(set(app.failed_ids)), 515 | ) 516 | check_for_updates() 517 | except KeyboardInterrupt: 518 | logger.info("Stopped!") 519 | except Exception as e: 520 | logger.exception("{}", e) 521 | finally: 522 | logger.info("update config......") 523 | app.update_config() 524 | 525 | 526 | def exec_main(): 527 | """main""" 528 | app.pre_run() 529 | print_meta(logger) 530 | main() 531 | logger.success( 532 | "Updated last read message_id to config file," 533 | "total download {}, total upload file {}", 534 | app.total_download_task, 535 | app.cloud_drive_config.total_upload_success_file_count, 536 | ) 537 | 538 | 539 | if __name__ == "__main__": 540 | exec_main() 541 | -------------------------------------------------------------------------------- /module/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/__init__.py -------------------------------------------------------------------------------- /module/app.py: -------------------------------------------------------------------------------- 1 | """Application module""" 2 | import os 3 | from typing import List, Optional 4 | 5 | import yaml 6 | from loguru import logger 7 | 8 | from module.cloud_drive import CloudDrive, CloudDriveConfig 9 | from module.filter import Filter 10 | from utils.format import replace_date_time 11 | from utils.meta_data import MetaData 12 | 13 | # pylint: disable = R0902 14 | 15 | 16 | class Application: 17 | """Application load config and update config.""" 18 | 19 | def __init__( 20 | self, 21 | config_file: str, 22 | app_data_file: str, 23 | application_name: str = "UndefineApp", 24 | ): 25 | """ 26 | Init and update telegram media downloader config 27 | 28 | Parameters 29 | ---------- 30 | config_file: str 31 | Config file name 32 | 33 | app_data_file: str 34 | App data file 35 | 36 | application_name: str 37 | Application Name 38 | 39 | """ 40 | self.config_file: str = config_file 41 | self.app_data_file: str = app_data_file 42 | self.application_name: str = application_name 43 | self.download_filter = Filter() 44 | 45 | self.reset() 46 | 47 | try: 48 | with open(os.path.join(os.path.abspath("."), self.config_file)) as f: 49 | self.config = yaml.safe_load(f) 50 | self.load_config(self.config) 51 | except Exception as e: 52 | logger.error(f"load {self.config_file} error, {e}!") 53 | 54 | try: 55 | with open(os.path.join(os.path.abspath("."), self.app_data_file)) as f: 56 | self.app_data = yaml.safe_load(f) 57 | self.load_app_data(self.app_data) 58 | except Exception as e: 59 | logger.error(f"load {self.app_data_file} error, {e}!") 60 | 61 | def reset(self): 62 | """reset Application""" 63 | # TODO: record total download task 64 | self.total_download_task = 0 65 | self.downloaded_ids: list = [] 66 | # self.already_download_ids_set = set() 67 | self.failed_ids: list = [] 68 | self.disable_syslog: list = [] 69 | self.save_path = os.path.abspath(".") 70 | self.ids_to_retry: list = [] 71 | self.api_id: str = "" 72 | self.api_hash: str = "" 73 | self.chat_id: str = "" 74 | self.media_types: List[str] = [] 75 | self.file_formats: dict = {} 76 | self.proxy: dict = {} 77 | self.last_read_message_id = 0 78 | self.restart_program = False 79 | self.config: dict = {} 80 | self.app_data: dict = {} 81 | self.file_path_prefix: List[str] = ["chat_title", "media_datetime"] 82 | self.file_name_prefix: List[str] = ["message_id", "file_name"] 83 | self.file_name_prefix_split: str = " - " 84 | self.log_file_path = os.path.join(os.path.abspath("."), "log") 85 | self.cloud_drive_config = CloudDriveConfig() 86 | self.hide_file_name = False 87 | self.caption_name_dict: dict = {} 88 | self.max_concurrent_transmissions: int = 1 89 | self.web_host: str = "localhost" 90 | self.web_port: int = 5000 91 | self.download_filter_dict: dict = {} 92 | 93 | def load_config(self, _config: dict) -> bool: 94 | """load config from str. 95 | 96 | Parameters 97 | ---------- 98 | _config: dict 99 | application config dict 100 | 101 | Returns 102 | ------- 103 | bool 104 | """ 105 | # pylint: disable = R0912 106 | # TODO: judge the storage if enough,and provide more path 107 | if _config.get("save_path") is not None: 108 | self.save_path = _config["save_path"] 109 | 110 | if _config.get("disable_syslog") is not None: 111 | self.disable_syslog = _config["disable_syslog"] 112 | 113 | self.last_read_message_id = _config["last_read_message_id"] 114 | if _config.get("ids_to_retry"): 115 | self.ids_to_retry = _config["ids_to_retry"] 116 | 117 | self.ids_to_retry_dict: dict = {} 118 | for it in self.ids_to_retry: 119 | self.ids_to_retry_dict[it] = True 120 | 121 | self.api_id = _config["api_id"] 122 | self.api_hash = _config["api_hash"] 123 | self.chat_id = _config["chat_id"] 124 | self.media_types = _config["media_types"] 125 | self.file_formats = _config["file_formats"] 126 | 127 | self.hide_file_name = _config.get("hide_file_name", False) 128 | 129 | # option 130 | if _config.get("proxy"): 131 | self.proxy = _config["proxy"] 132 | if _config.get("restart_program"): 133 | self.restart_program = _config["restart_program"] 134 | if _config.get("file_path_prefix"): 135 | self.file_path_prefix = _config["file_path_prefix"] 136 | if _config.get("file_name_prefix"): 137 | self.file_name_prefix = _config["file_name_prefix"] 138 | 139 | if _config.get("upload_drive"): 140 | upload_drive_config = _config["upload_drive"] 141 | if upload_drive_config.get("enable_upload_file"): 142 | self.cloud_drive_config.enable_upload_file = upload_drive_config[ 143 | "enable_upload_file" 144 | ] 145 | 146 | if upload_drive_config.get("rclone_path"): 147 | self.cloud_drive_config.rclone_path = upload_drive_config["rclone_path"] 148 | 149 | if upload_drive_config.get("remote_dir"): 150 | self.cloud_drive_config.remote_dir = upload_drive_config["remote_dir"] 151 | 152 | if upload_drive_config.get("before_upload_file_zip"): 153 | self.cloud_drive_config.before_upload_file_zip = upload_drive_config[ 154 | "before_upload_file_zip" 155 | ] 156 | 157 | if upload_drive_config.get("after_upload_file_delete"): 158 | self.cloud_drive_config.after_upload_file_delete = upload_drive_config[ 159 | "after_upload_file_delete" 160 | ] 161 | 162 | if upload_drive_config.get("upload_adapter"): 163 | self.cloud_drive_config.upload_adapter = upload_drive_config[ 164 | "upload_adapter" 165 | ] 166 | 167 | self.file_name_prefix_split = _config.get( 168 | "file_name_prefix_split", self.file_name_prefix_split 169 | ) 170 | self.web_host = _config.get("web_host", self.web_host) 171 | self.web_port = _config.get("web_port", self.web_port) 172 | 173 | self.download_filter_dict = _config.get( 174 | "download_filter", self.download_filter_dict 175 | ) 176 | 177 | for key, value in self.download_filter_dict.items(): 178 | self.download_filter_dict[key] = replace_date_time(value) 179 | 180 | # TODO: add check if expression exist syntax error 181 | 182 | self.max_concurrent_transmissions = _config.get( 183 | "max_concurrent_transmissions", self.max_concurrent_transmissions 184 | ) 185 | 186 | return True 187 | 188 | def load_app_data(self, app_data: dict) -> bool: 189 | """load config from str. 190 | 191 | Parameters 192 | ---------- 193 | app_data: dict 194 | application data dict 195 | 196 | Returns 197 | ------- 198 | bool 199 | """ 200 | 201 | if app_data.get("ids_to_retry"): 202 | # check old config.yaml exist 203 | if len(self.ids_to_retry) > 0: 204 | self.ids_to_retry.extend(list(app_data["ids_to_retry"])) 205 | else: 206 | self.ids_to_retry = app_data["ids_to_retry"] 207 | 208 | # if app_data.get("already_download_ids"): 209 | # self.already_download_ids_set = set(app_data["already_download_ids"]) 210 | return True 211 | 212 | def upload_file(self, local_file_path: str): 213 | """Upload file""" 214 | return CloudDrive.upload_file( 215 | self.cloud_drive_config, self.save_path, local_file_path 216 | ) 217 | 218 | def get_file_save_path( 219 | self, media_type: str, chat_title: str, media_datetime: str 220 | ) -> str: 221 | """Get file save path prefix. 222 | 223 | Parameters 224 | ---------- 225 | media_type: str 226 | see config.yaml media_types 227 | 228 | chat_title: str 229 | see channel or group title 230 | 231 | media_datetime: str 232 | media datetime 233 | 234 | Returns 235 | ------- 236 | str 237 | file save path prefix 238 | """ 239 | 240 | res: str = self.save_path 241 | for prefix in self.file_path_prefix: 242 | if prefix == "chat_title": 243 | res = os.path.join(res, chat_title) 244 | elif prefix == "media_datetime": 245 | res = os.path.join(res, media_datetime) 246 | elif prefix == "media_type": 247 | res = os.path.join(res, media_type) 248 | return res 249 | 250 | def get_file_name( 251 | self, message_id: int, file_name: Optional[str], caption: Optional[str] 252 | ) -> str: 253 | """Get file save path prefix. 254 | 255 | Parameters 256 | ---------- 257 | message_id: int 258 | Message id 259 | 260 | file_name: Optional[str] 261 | File name 262 | 263 | caption: Optional[str] 264 | Message caption 265 | 266 | Returns 267 | ------- 268 | str 269 | File name 270 | """ 271 | 272 | res: str = "" 273 | for prefix in self.file_name_prefix: 274 | if prefix == "message_id": 275 | if res != "": 276 | res += self.file_name_prefix_split 277 | res += f"{message_id}" 278 | elif prefix == "file_name" and file_name: 279 | if res != "": 280 | res += self.file_name_prefix_split 281 | res += f"{file_name}" 282 | elif prefix == "caption" and caption: 283 | if res != "": 284 | res += self.file_name_prefix_split 285 | res += f"{caption}" 286 | if res == "": 287 | res = f"{message_id}" 288 | return res 289 | 290 | def need_skip_message( 291 | self, chat_id: str, message_id: int, meta_data: MetaData 292 | ) -> bool: 293 | """if need skip download message. 294 | 295 | Parameters 296 | ---------- 297 | chat_id: str 298 | Config.yaml defined 299 | 300 | message_id: int 301 | Readily to download message id 302 | 303 | meta_data: MetaData 304 | Ready to match filter 305 | 306 | Returns 307 | ------- 308 | bool 309 | """ 310 | if message_id in self.ids_to_retry_dict: 311 | return True 312 | 313 | if chat_id in self.download_filter_dict: 314 | self.download_filter.set_meta_data(meta_data) 315 | return not self.download_filter.exec(self.download_filter_dict[chat_id]) 316 | 317 | return False 318 | 319 | def update_config(self, immediate: bool = True): 320 | """update config 321 | 322 | Parameters 323 | ---------- 324 | immediate: bool 325 | If update config immediate,default True 326 | """ 327 | 328 | # pylint: disable = W0201 329 | self.ids_to_retry = ( 330 | list(set(self.ids_to_retry) - set(self.downloaded_ids)) + self.failed_ids 331 | ) 332 | 333 | self.config["last_read_message_id"] = self.last_read_message_id 334 | self.config["disable_syslog"] = self.disable_syslog 335 | self.config["save_path"] = self.save_path 336 | self.config["file_path_prefix"] = self.file_path_prefix 337 | 338 | if self.config.get("ids_to_retry"): 339 | self.config.pop("ids_to_retry") 340 | 341 | self.app_data["ids_to_retry"] = self.ids_to_retry 342 | 343 | # for it in self.downloaded_ids: 344 | # self.already_download_ids_set.add(it) 345 | 346 | # self.app_data["already_download_ids"] = list(self.already_download_ids_set) 347 | 348 | if immediate: 349 | with open(self.config_file, "w") as yaml_file: 350 | yaml.dump(self.config, yaml_file, default_flow_style=False) 351 | 352 | if immediate: 353 | with open(self.app_data_file, "w") as yaml_file: 354 | yaml.dump(self.app_data, yaml_file, default_flow_style=False) 355 | 356 | def pre_run(self): 357 | """before run application do""" 358 | self.cloud_drive_config.pre_run() 359 | 360 | def set_caption_name( 361 | self, chat_id: str, media_group_id: Optional[str], caption: str 362 | ): 363 | """set caption name map 364 | 365 | Parameters 366 | ---------- 367 | chat_id: str 368 | Unique identifier for this chat. 369 | 370 | media_group_id: Optional[str] 371 | The unique identifier of a media message group this message belongs to. 372 | 373 | caption: str 374 | Caption for the audio, document, photo, video or voice, 0-1024 characters. 375 | """ 376 | if not media_group_id: 377 | return 378 | 379 | if chat_id in self.caption_name_dict: 380 | self.caption_name_dict[chat_id][media_group_id] = caption 381 | else: 382 | self.caption_name_dict[chat_id] = {media_group_id: caption} 383 | 384 | def get_caption_name( 385 | self, chat_id: str, media_group_id: Optional[str] 386 | ) -> Optional[str]: 387 | """set caption name map 388 | media_group_id: Optional[str] 389 | The unique identifier of a media message group this message belongs to. 390 | 391 | caption: str 392 | Caption for the audio, document, photo, video or voice, 0-1024 characters. 393 | """ 394 | 395 | if ( 396 | not media_group_id 397 | or chat_id not in self.caption_name_dict 398 | or media_group_id not in self.caption_name_dict[chat_id] 399 | ): 400 | return None 401 | 402 | return str(self.caption_name_dict[chat_id][media_group_id]) 403 | -------------------------------------------------------------------------------- /module/cloud_drive.py: -------------------------------------------------------------------------------- 1 | """provide upload cloud drive""" 2 | import asyncio 3 | import os 4 | from asyncio import subprocess 5 | from subprocess import Popen 6 | from zipfile import ZipFile 7 | 8 | from aligo import Aligo 9 | from loguru import logger 10 | 11 | from utils import platform 12 | 13 | 14 | # pylint: disable = R0902 15 | class CloudDriveConfig: 16 | """Rclone Config""" 17 | 18 | def __init__( 19 | self, 20 | enable_upload_file: bool = False, 21 | before_upload_file_zip: bool = False, 22 | after_upload_file_delete: bool = True, 23 | rclone_path: str = os.path.join( 24 | os.path.abspath("."), "rclone", f"rclone{platform.get_exe_ext()}" 25 | ), 26 | remote_dir: str = "", 27 | upload_adapter: str = "rclone", 28 | ): 29 | self.enable_upload_file = enable_upload_file 30 | self.before_upload_file_zip = before_upload_file_zip 31 | self.after_upload_file_delete = after_upload_file_delete 32 | self.rclone_path = rclone_path 33 | self.remote_dir = remote_dir 34 | self.upload_adapter = upload_adapter 35 | self.dir_cache: dict = {} # for remote mkdir 36 | self.total_upload_success_file_count = 0 37 | self.aligo = None 38 | 39 | def initAligo(self): 40 | """init aliyun upload""" 41 | self.aligo = Aligo() 42 | 43 | def pre_run(self): 44 | """pre run init aligo""" 45 | if self.enable_upload_file and self.upload_adapter == "aligo": 46 | self.initAligo() 47 | 48 | 49 | class CloudDrive: 50 | """rclone support""" 51 | 52 | @staticmethod 53 | def rclone_mkdir(drive_config: CloudDriveConfig, remote_dir: str): 54 | """mkdir in remote""" 55 | with Popen( 56 | f'"{drive_config.rclone_path}" mkdir {remote_dir}/', 57 | shell=True, 58 | stdout=subprocess.PIPE, 59 | stderr=subprocess.STDOUT, 60 | ): 61 | pass 62 | 63 | @staticmethod 64 | def aligo_mkdir(drive_config: CloudDriveConfig, remote_dir: str): 65 | """mkdir in remote by aligo""" 66 | if drive_config.aligo and not drive_config.aligo.get_folder_by_path(remote_dir): 67 | drive_config.aligo.create_folder(name=remote_dir, check_name_mode="refuse") 68 | 69 | @staticmethod 70 | def zip_file(local_file_path: str) -> str: 71 | """ 72 | Zip local file 73 | """ 74 | 75 | zip_file_name = local_file_path.split(".")[0] + ".zip" 76 | with ZipFile(zip_file_name, "w") as zip_writer: 77 | zip_writer.write(local_file_path) 78 | 79 | return zip_file_name 80 | 81 | @staticmethod 82 | async def rclone_upload_file( 83 | drive_config: CloudDriveConfig, save_path: str, local_file_path: str 84 | ): 85 | """Use Rclone upload file""" 86 | try: 87 | remote_dir = ( 88 | drive_config.remote_dir 89 | + "/" 90 | + os.path.dirname(local_file_path).replace(save_path, "") 91 | + "/" 92 | ).replace("\\", "/") 93 | 94 | if not drive_config.dir_cache.get(remote_dir): 95 | CloudDrive.rclone_mkdir(drive_config, remote_dir) 96 | drive_config.dir_cache[remote_dir] = True 97 | 98 | zip_file_path: str = "" 99 | file_path = local_file_path 100 | if drive_config.before_upload_file_zip: 101 | zip_file_path = CloudDrive.zip_file(local_file_path) 102 | file_path = zip_file_path 103 | else: 104 | file_path = local_file_path 105 | 106 | cmd = ( 107 | f'"{drive_config.rclone_path}" copy "{file_path}" ' 108 | f"{remote_dir}/ --create-empty-src-dirs --ignore-existing --progress" 109 | ) 110 | proc = await asyncio.create_subprocess_shell( 111 | cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT 112 | ) 113 | if proc.stdout: 114 | async for output in proc.stdout: 115 | s = output.decode() 116 | if "Transferred" in s and "100%" in s and "1 / 1" in s: 117 | logger.info(f"upload file {local_file_path} success") 118 | drive_config.total_upload_success_file_count += 1 119 | if drive_config.after_upload_file_delete: 120 | os.remove(local_file_path) 121 | if drive_config.before_upload_file_zip: 122 | os.remove(zip_file_path) 123 | 124 | await proc.wait() 125 | except Exception as e: 126 | logger.error(f"{e.__class__} {e}") 127 | 128 | @staticmethod 129 | async def aligo_upload_file( 130 | drive_config: CloudDriveConfig, save_path: str, local_file_path: str 131 | ): 132 | """aliyun upload file""" 133 | if not drive_config.aligo: 134 | logger.warning("please config aligo! see README.md") 135 | return 136 | 137 | try: 138 | remote_dir = ( 139 | drive_config.remote_dir 140 | + "/" 141 | + os.path.dirname(local_file_path).replace(save_path, "") 142 | + "/" 143 | ).replace("\\", "/") 144 | if not drive_config.dir_cache.get(remote_dir): 145 | CloudDrive.aligo_mkdir(drive_config, remote_dir) 146 | aligo_dir = drive_config.aligo.get_folder_by_path(remote_dir) 147 | if aligo_dir: 148 | drive_config.dir_cache[remote_dir] = aligo_dir.file_id 149 | 150 | zip_file_path: str = "" 151 | file_paths = [] 152 | if drive_config.before_upload_file_zip: 153 | zip_file_path = CloudDrive.zip_file(local_file_path) 154 | file_paths.append(zip_file_path) 155 | else: 156 | file_paths.append(local_file_path) 157 | 158 | res = drive_config.aligo.upload_files( 159 | file_paths=file_paths, 160 | parent_file_id=drive_config.dir_cache[remote_dir], 161 | check_name_mode="refuse", 162 | ) 163 | 164 | if len(res) > 0: 165 | drive_config.total_upload_success_file_count += len(res) 166 | if drive_config.after_upload_file_delete: 167 | os.remove(local_file_path) 168 | 169 | if drive_config.before_upload_file_zip: 170 | os.remove(zip_file_path) 171 | 172 | except Exception as e: 173 | logger.error(f"{e.__class__} {e}") 174 | 175 | @staticmethod 176 | async def upload_file( 177 | drive_config: CloudDriveConfig, save_path: str, local_file_path: str 178 | ): 179 | """Upload file 180 | Parameters 181 | ---------- 182 | drive_config: CloudDriveConfig 183 | see @CloudDriveConfig 184 | 185 | save_path: str 186 | Local file save path config 187 | 188 | local_file_path: str 189 | Local file path 190 | """ 191 | if not drive_config.enable_upload_file: 192 | return 193 | 194 | if drive_config.upload_adapter == "rclone": 195 | await CloudDrive.rclone_upload_file( 196 | drive_config, save_path, local_file_path 197 | ) 198 | elif drive_config.upload_adapter == "aligo": 199 | await CloudDrive.aligo_upload_file(drive_config, save_path, local_file_path) 200 | -------------------------------------------------------------------------------- /module/filter.py: -------------------------------------------------------------------------------- 1 | """Filter for download""" 2 | 3 | import re 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from ply import lex, yacc 8 | 9 | from utils.meta_data import MetaData, NoneObj, ReString 10 | 11 | 12 | class Parser: 13 | """ 14 | Base class for a lexer/parser that has the rules defined as methods 15 | """ 16 | 17 | def __init__(self, debug: bool = False): 18 | self.names: dict = {} 19 | self.debug = debug 20 | # Build the lexer and parser 21 | lex.lex(module=self) 22 | yacc.yacc(module=self) 23 | 24 | def reset(self): 25 | """Reset all symbol""" 26 | self.names.clear() 27 | 28 | def exec(self, filter_str: str) -> Any: 29 | """Exec filter str""" 30 | # ) # 31 | return yacc.parse(filter_str, debug=self.debug) 32 | 33 | 34 | # pylint: disable = R0904 35 | class BaseFilter(Parser): 36 | """for normal filter""" 37 | 38 | def __init__(self, debug: bool = False): 39 | """ 40 | Parameters 41 | ---------- 42 | debug: bool 43 | If output debug info 44 | 45 | """ 46 | super().__init__(debug=debug) 47 | 48 | def _output(self, output_str: str): 49 | """For print debug info""" 50 | if self.debug: 51 | print(output_str) 52 | 53 | reserved = { 54 | "and": "AND", 55 | "or": "OR", 56 | } 57 | 58 | tokens = ( 59 | "NAME", 60 | "NUMBER", 61 | "GE", 62 | "LE", 63 | "LOR", 64 | "LAND", 65 | "STRING", 66 | "RESTRING", 67 | "EQ", 68 | "NE", 69 | "TIME", 70 | "AND", 71 | "OR", 72 | ) 73 | 74 | literals = ["=", "+", "-", "*", "/", "(", ")", ">", "<"] 75 | 76 | # t_NAME = r'[a-zA-Z_][a-zA-Z0-9_]*' 77 | t_GE = r">=" 78 | t_LE = r"<=" 79 | t_LOR = r"\|\|" 80 | t_LAND = r"&&" 81 | t_EQ = r"==" 82 | t_NE = r"!=" 83 | 84 | def t_TIME(self, t): 85 | r"\d{4}-\d{1,2}-\d{1,2}[ ]{1,}\d{1,2}:\d{1,2}:\d{1,2}" 86 | t.value = datetime.strptime(t.value, "%Y-%m-%d %H:%M:%S") 87 | return t 88 | 89 | def t_STRING(self, t): 90 | r"'([^\\']+|\\'|\\\\)*'" 91 | t.value = t.value[1:-1].encode().decode("unicode_escape") 92 | return t 93 | 94 | def t_RESTRING(self, t): 95 | r"r'([^\\']+|\\'|\\\\)*'" 96 | t.value = t.value[2:-1].encode().decode("unicode_escape") 97 | return t 98 | 99 | def t_NAME(self, t): 100 | r"[a-zA-Z_][a-zA-Z0-9_]*" 101 | t.type = BaseFilter.reserved.get(t.value, "NAME") 102 | return t 103 | 104 | def t_NUMBER(self, t): 105 | r"\d+" 106 | t.value = int(t.value) 107 | return t 108 | 109 | t_ignore = " \t" 110 | 111 | def t_newline(self, t): 112 | r"\n+" 113 | t.lexer.lineno += t.value.count("\n") 114 | 115 | def t_error(self, t): 116 | """print error""" 117 | print(f"Illegal character '{t.value[0]}'") 118 | t.lexer.skip(1) 119 | 120 | precedence = ( 121 | ("left", "LOR", "OR"), 122 | ("left", "LAND", "AND"), 123 | ("left", "EQ", "NE"), 124 | ("nonassoc", ">", "<", "GE", "LE"), 125 | ("left", "+", "-"), 126 | ("left", "*", "/"), 127 | ("right", "UMINUS"), 128 | ) 129 | 130 | def p_statement_assign(self, p): 131 | 'statement : NAME "=" expression' 132 | self.names[p[1]] = p[3] 133 | 134 | def p_statement_expr(self, p): 135 | "statement : expression" 136 | self._output(p[1]) 137 | p[0] = p[1] 138 | 139 | def p_expression_binop(self, p): 140 | """expression : expression '+' expression 141 | | expression '-' expression 142 | | expression '*' expression 143 | | expression '/' expression""" 144 | if isinstance(p[1], NoneObj): 145 | p[1] = 0 146 | if isinstance(p[3], NoneObj): 147 | p[3] = 0 148 | 149 | if p[2] == "+": 150 | p[0] = p[1] + p[3] 151 | elif p[2] == "-": 152 | p[0] = p[1] - p[3] 153 | elif p[2] == "*": 154 | p[0] = p[1] * p[3] 155 | elif p[2] == "/": 156 | p[0] = p[1] / p[3] 157 | 158 | self._output(f"binop {p[1]} {p[2]} {p[3]} = {p[0]}") 159 | 160 | def p_expression_comp(self, p): 161 | """expression : expression '>' expression 162 | | expression '<' expression""" 163 | 164 | if isinstance(p[1], NoneObj) or isinstance(p[3], NoneObj): 165 | p[0] = True 166 | return 167 | 168 | if p[1] is None or p[3] is None: 169 | p[0] = True 170 | return 171 | if p[2] == ">": 172 | p[0] = p[1] > p[3] 173 | elif p[2] == "<": 174 | p[0] = p[1] < p[3] 175 | 176 | def p_expression_uminus(self, p): 177 | "expression : '-' expression %prec UMINUS" 178 | p[0] = -p[2] 179 | 180 | def p_expression_ge(self, p): 181 | "expression : expression GE expression" 182 | if isinstance(p[1], NoneObj) or isinstance(p[3], NoneObj): 183 | p[0] = True 184 | return 185 | 186 | if p[1] is None or p[3] is None: 187 | p[0] = True 188 | return 189 | 190 | p[0] = p[1] >= p[3] 191 | self._output(f"{p[1]} {p[2]} {p[3]} {p[0]}") 192 | 193 | def p_expression_le(self, p): 194 | "expression : expression LE expression" 195 | if isinstance(p[1], NoneObj) or isinstance(p[3], NoneObj): 196 | p[0] = True 197 | return 198 | 199 | if p[1] is None or p[3] is None: 200 | p[0] = True 201 | return 202 | 203 | p[0] = p[1] <= p[3] 204 | self._output(f"{p[1]} {p[2]} {p[3]} = {p[0]}") 205 | 206 | def p_expression_eq(self, p): 207 | "expression : expression EQ expression" 208 | if isinstance(p[1], NoneObj) or isinstance(p[3], NoneObj): 209 | p[0] = True 210 | return 211 | 212 | if p[1] is None or p[3] is None: 213 | p[0] = True 214 | return 215 | 216 | if isinstance(p[3], ReString): 217 | if not isinstance(p[1], str): 218 | p[0] = 0 219 | return 220 | p[0] = re.fullmatch(p[3].re_string, p[1]) is not None 221 | self._output(f"{p[1]} {p[2]} {p[3].re_string} {p[0]}") 222 | elif isinstance(p[1], ReString): 223 | if not isinstance(p[3], str): 224 | p[0] = 0 225 | return 226 | p[0] = re.fullmatch(p[1].re_string, p[3]) is not None 227 | self._output(f"{p[1]} {p[2]} {p[3].re_string} {p[0]}") 228 | else: 229 | p[0] = p[1] == p[3] 230 | self._output(f"{p[1]} {p[2]} {p[3]} {p[0]}") 231 | 232 | def p_expression_ne(self, p): 233 | "expression : expression NE expression" 234 | if isinstance(p[1], NoneObj) or isinstance(p[3], NoneObj): 235 | p[0] = True 236 | return 237 | 238 | if p[1] is None or p[3] is None: 239 | p[0] = True 240 | return 241 | if isinstance(p[3], ReString): 242 | if not isinstance(p[1], str): 243 | p[0] = 0 244 | return 245 | p[0] = re.fullmatch(p[3].re_string, p[1]) is None 246 | self._output(f"{p[1]} {p[2]} {p[3].re_string} {p[0]}") 247 | elif isinstance(p[1], ReString): 248 | if not isinstance(p[3], str): 249 | p[0] = 0 250 | return 251 | p[0] = re.fullmatch(p[1].re_string, p[3]) is None 252 | self._output(f"{p[1]} {p[2]} {p[3].re_string} {p[0]}") 253 | else: 254 | p[0] = p[1] != p[3] 255 | self._output(f"{p[1]} {p[2]} {p[3]} = {p[0]}") 256 | 257 | def p_expression_group(self, p): 258 | "expression : '(' expression ')'" 259 | p[0] = p[2] 260 | 261 | def p_expression_number(self, p): 262 | "expression : NUMBER" 263 | p[0] = p[1] 264 | 265 | def p_expression_time(self, p): 266 | "expression : TIME" 267 | p[0] = p[1] 268 | 269 | def p_expression_name(self, p): 270 | "expression : NAME" 271 | try: 272 | p[0] = self.names[p[1]] 273 | except LookupError: 274 | self._output(f"Undefined name '{p[1]}'") 275 | p[0] = NoneObj() 276 | 277 | def p_expression_lor(self, p): 278 | "expression : expression LOR expression" 279 | p[0] = p[1] or p[3] 280 | 281 | def p_expression_land(self, p): 282 | "expression : expression LAND expression" 283 | p[0] = p[1] and p[3] 284 | 285 | def p_expression_or(self, p): 286 | "expression : expression OR expression" 287 | p[0] = p[1] or p[3] 288 | 289 | def p_expression_and(self, p): 290 | "expression : expression AND expression" 291 | p[0] = p[1] and p[3] 292 | 293 | def p_expression_string(self, p): 294 | "expression : STRING" 295 | p[0] = p[1] 296 | 297 | def p_expression_restring(self, p): 298 | "expression : RESTRING" 299 | p[0] = ReString(p[1]) 300 | self._output("RESTRING : " + p[0].re_string) 301 | 302 | # pylint: disable = C0116 303 | def p_error(self, p): 304 | if p: 305 | print(f"Syntax error at '{p.value}'") 306 | else: 307 | print("Syntax error at EOF") 308 | 309 | 310 | class Filter: 311 | """filter for telegram download""" 312 | 313 | def __init__(self): 314 | self.filter = BaseFilter() 315 | 316 | def set_meta_data(self, meta_data: MetaData): 317 | """Set meta data for filter""" 318 | self.filter.reset() 319 | self.filter.names = meta_data.data() 320 | 321 | def exec(self, filter_str: str) -> Any: 322 | """Exec filter str""" 323 | 324 | if self.filter.names: 325 | return self.filter.exec(filter_str) 326 | raise ValueError("meta data cannot be empty!") 327 | -------------------------------------------------------------------------------- /module/static/layui/css/modules/code.css: -------------------------------------------------------------------------------- 1 | html #layuicss-skincodecss{display:none;position:absolute;width:1989px}.layui-code-view{display:block;position:relative;margin:10px 0;padding:0;border:1px solid #eee;border-left-width:6px;background-color:#fafafa;color:#333;font-family:Courier New;font-size:13px}.layui-code-title{position:relative;padding:0 10px;height:40px;line-height:40px;border-bottom:1px solid #eee;font-size:12px}.layui-code-title>.layui-code-about{position:absolute;right:10px;top:0;color:#b7b7b7}.layui-code-about>a{padding-left:10px}.layui-code-view>.layui-code-ol,.layui-code-view>.layui-code-ul{position:relative;overflow:auto}.layui-code-view>.layui-code-ol>li{position:relative;margin-left:45px;line-height:20px;padding:0 10px;border-left:1px solid #e2e2e2;list-style-type:decimal-leading-zero;*list-style-type:decimal;background-color:#fff}.layui-code-view>.layui-code-ol>li:first-child,.layui-code-view>.layui-code-ul>li:first-child{padding-top:10px}.layui-code-view>.layui-code-ol>li:last-child,.layui-code-view>.layui-code-ul>li:last-child{padding-bottom:10px}.layui-code-view>.layui-code-ul>li{position:relative;line-height:20px;padding:0 10px;list-style-type:none;*list-style-type:none;background-color:#fff}.layui-code-view pre{margin:0}.layui-code-dark{border:1px solid #0c0c0c;border-left-color:#3f3f3f;background-color:#0c0c0c;color:#c2be9e}.layui-code-dark>.layui-code-title{border-bottom:none}.layui-code-dark>.layui-code-ol>li,.layui-code-dark>.layui-code-ul>li{background-color:#3f3f3f;border-left:none}.layui-code-dark>.layui-code-ul>li{margin-left:6px}.layui-code-demo .layui-code{visibility:visible!important;margin:-15px;border-top:none;border-right:none;border-bottom:none}.layui-code-demo .layui-tab-content{padding:15px;border-top:none} 2 | -------------------------------------------------------------------------------- /module/static/layui/css/modules/laydate/default/laydate.css: -------------------------------------------------------------------------------- 1 | html #layuicss-laydate{display:none;position:absolute;width:1989px}.layui-laydate *{margin:0;padding:0}.layui-laydate,.layui-laydate *{box-sizing:border-box}.layui-laydate{position:absolute;z-index:66666666;margin:5px 0;border-radius:2px;font-size:14px;-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-fill-mode:both;animation-fill-mode:both}.layui-laydate-main{width:272px}.layui-laydate-content td,.layui-laydate-header *,.layui-laydate-list li{transition-duration:.3s;-webkit-transition-duration:.3s}@keyframes laydate-downbit{0%{opacity:.3;transform:translate3d(0,-5px,0)}100%{opacity:1;transform:translate3d(0,0,0)}}.layui-laydate{animation-name:laydate-downbit}.layui-laydate-static{position:relative;z-index:0;display:inline-block;margin:0;-webkit-animation:none;animation:none}.laydate-ym-show .laydate-next-m,.laydate-ym-show .laydate-prev-m{display:none!important}.laydate-ym-show .laydate-next-y,.laydate-ym-show .laydate-prev-y{display:inline-block!important}.laydate-ym-show .laydate-set-ym span[lay-type=month]{display:none!important}.laydate-time-show .laydate-set-ym span[lay-type=month],.laydate-time-show .laydate-set-ym span[lay-type=year],.laydate-time-show .layui-laydate-header .layui-icon{display:none!important}.layui-laydate-header{position:relative;line-height:30px;padding:10px 70px 5px}.layui-laydate-header *{display:inline-block;vertical-align:bottom}.layui-laydate-header i{position:absolute;top:10px;padding:0 5px;color:#999;font-size:18px;cursor:pointer}.layui-laydate-header i.laydate-prev-y{left:15px}.layui-laydate-header i.laydate-prev-m{left:45px}.layui-laydate-header i.laydate-next-y{right:15px}.layui-laydate-header i.laydate-next-m{right:45px}.laydate-set-ym{width:100%;text-align:center;box-sizing:border-box;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.laydate-set-ym span{padding:0 10px;cursor:pointer}.laydate-time-text{cursor:default!important}.layui-laydate-content{position:relative;padding:10px;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.layui-laydate-content table{border-collapse:collapse;border-spacing:0}.layui-laydate-content td,.layui-laydate-content th{width:36px;height:30px;padding:5px;text-align:center}.layui-laydate-content th{font-weight:400}.layui-laydate-content td{position:relative;cursor:pointer}.laydate-day-mark{position:absolute;left:0;top:0;width:100%;line-height:30px;font-size:12px;overflow:hidden}.laydate-day-mark::after{position:absolute;content:'';right:2px;top:2px;width:5px;height:5px;border-radius:50%}.laydate-day-holidays:before{position:absolute;left:0;top:0;font-size:12px;transform:scale(.7)}.laydate-day-holidays:before{content:'\4F11';color:#ff5722}.laydate-day-holidays[type=work]:before{content:'\73ED';color:inherit}.layui-laydate .layui-this .laydate-day-holidays:before{color:#fff}.layui-laydate-footer{position:relative;height:46px;line-height:26px;padding:10px}.layui-laydate-footer span{display:inline-block;vertical-align:top;height:26px;line-height:24px;padding:0 10px;border:1px solid #c9c9c9;border-radius:2px;background-color:#fff;font-size:12px;cursor:pointer;white-space:nowrap;transition:all .3s}.layui-laydate-footer span:hover{color:#5fb878}.layui-laydate-footer span.layui-laydate-preview{cursor:default;border-color:transparent!important}.layui-laydate-footer span.layui-laydate-preview:hover{color:#666}.layui-laydate-footer span:first-child.layui-laydate-preview{padding-left:0}.laydate-footer-btns{position:absolute;right:10px;top:10px}.laydate-footer-btns span{margin:0 0 0 -1px}.layui-laydate-list{position:absolute;left:0;top:0;width:100%;height:100%;padding:10px;box-sizing:border-box;background-color:#fff}.layui-laydate-list>li{position:relative;display:inline-block;width:33.3%;height:36px;line-height:36px;margin:3px 0;vertical-align:middle;text-align:center;cursor:pointer}.laydate-month-list>li{width:25%;margin:17px 0}.laydate-time-list>li{height:100%;margin:0;line-height:normal;cursor:default}.laydate-time-list p{position:relative;top:-4px;line-height:29px}.laydate-time-list ol{height:181px;overflow:hidden}.laydate-time-list>li:hover ol{overflow-y:auto}.laydate-time-list ol li{width:130%;padding-left:33px;height:30px;line-height:30px;text-align:left;cursor:pointer}.layui-laydate-hint{position:absolute;top:115px;left:50%;width:250px;margin-left:-125px;line-height:20px;padding:15px;text-align:center;font-size:12px;color:#ff5722}.layui-laydate-range{width:546px}.layui-laydate-range .layui-laydate-main{display:inline-block;vertical-align:middle}.layui-laydate-range .laydate-main-list-1 .layui-laydate-content,.layui-laydate-range .laydate-main-list-1 .layui-laydate-header{border-left:1px solid #e2e2e2}.layui-laydate,.layui-laydate-hint{border:1px solid #d2d2d2;box-shadow:0 2px 4px rgba(0,0,0,.12);background-color:#fff;color:#666}.layui-laydate-header{border-bottom:1px solid #e2e2e2}.layui-laydate-header i:hover,.layui-laydate-header span:hover{color:#5fb878}.layui-laydate-content{border-top:none 0;border-bottom:none 0}.layui-laydate-content th{color:#333}.layui-laydate-content td{color:#666}.layui-laydate-content td.laydate-selected{background-color:#b5fff8}.laydate-selected:hover{background-color:#00f7de!important}.layui-laydate-content td:hover,.layui-laydate-list li:hover{background-color:#eee;color:#333}.laydate-time-list li ol{margin:0;padding:0;border:1px solid #e2e2e2;border-left-width:0}.laydate-time-list li:first-child ol{border-left-width:1px}.laydate-time-list>li:hover{background:0 0}.layui-laydate-content .laydate-day-next,.layui-laydate-content .laydate-day-prev{color:#d2d2d2}.laydate-selected.laydate-day-next,.laydate-selected.laydate-day-prev{background-color:#f8f8f8!important}.layui-laydate-footer{border-top:1px solid #e2e2e2}.layui-laydate-hint{color:#ff5722}.laydate-day-mark::after{background-color:#5fb878}.layui-laydate-content td.layui-this .laydate-day-mark::after{display:none}.layui-laydate-footer span[lay-type=date]{color:#5fb878}.layui-laydate .layui-this{background-color:#009688!important;color:#fff!important}.layui-laydate .laydate-disabled,.layui-laydate .laydate-disabled:hover{background:0 0!important;color:#d2d2d2!important;cursor:not-allowed!important;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.laydate-theme-molv{border:none}.laydate-theme-molv.layui-laydate-range{width:548px}.laydate-theme-molv .layui-laydate-main{width:274px}.laydate-theme-molv .layui-laydate-header{border:none;background-color:#009688}.laydate-theme-molv .layui-laydate-header i,.laydate-theme-molv .layui-laydate-header span{color:#f6f6f6}.laydate-theme-molv .layui-laydate-header i:hover,.laydate-theme-molv .layui-laydate-header span:hover{color:#fff}.laydate-theme-molv .layui-laydate-content{border:1px solid #e2e2e2;border-top:none;border-bottom:none}.laydate-theme-molv .laydate-main-list-1 .layui-laydate-content{border-left:none}.laydate-theme-molv .layui-laydate-footer{border:1px solid #e2e2e2}.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li,.laydate-theme-grid .layui-laydate-content td,.laydate-theme-grid .layui-laydate-content thead{border:1px solid #e2e2e2}.laydate-theme-grid .laydate-selected,.laydate-theme-grid .laydate-selected:hover{background-color:#f2f2f2!important;color:#009688!important}.laydate-theme-grid .laydate-selected.laydate-day-next,.laydate-theme-grid .laydate-selected.laydate-day-prev{color:#d2d2d2!important}.laydate-theme-grid .laydate-month-list,.laydate-theme-grid .laydate-year-list{margin:1px 0 0 1px}.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li{margin:0 -1px -1px 0}.laydate-theme-grid .laydate-year-list>li{height:43px;line-height:43px}.laydate-theme-grid .laydate-month-list>li{height:71px;line-height:71px} 2 | -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/icon-ext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/css/modules/layer/default/icon-ext.png -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/css/modules/layer/default/icon.png -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/layer.css: -------------------------------------------------------------------------------- 1 | html #layuicss-layer{display:none;position:absolute;width:1989px}.layui-layer,.layui-layer-shade{position:fixed;_position:absolute;pointer-events:auto}.layui-layer-shade{top:0;left:0;width:100%;height:100%;_height:expression(document.body.offsetHeight+"px")}.layui-layer{-webkit-overflow-scrolling:touch}.layui-layer{top:150px;left:0;margin:0;padding:0;background-color:#fff;-webkit-background-clip:content;border-radius:2px;box-shadow:1px 1px 50px rgba(0,0,0,.3)}.layui-layer-close{position:absolute}.layui-layer-content{position:relative}.layui-layer-border{border:1px solid #b2b2b2;border:1px solid rgba(0,0,0,.1);box-shadow:1px 1px 5px rgba(0,0,0,.2)}.layui-layer-load{background:url(loading-1.gif) #eee center center no-repeat}.layui-layer-ico{background:url(icon.png) no-repeat}.layui-layer-btn a,.layui-layer-dialog .layui-layer-ico,.layui-layer-setwin a{display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-move{display:none;position:fixed;*position:absolute;left:0;top:0;width:100%;height:100%;cursor:move;opacity:0;filter:alpha(opacity=0);background-color:#fff;z-index:2147483647}.layui-layer-resize{position:absolute;width:15px;height:15px;right:0;bottom:0;cursor:se-resize}.layer-anim{-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.3s;animation-duration:.3s}@-webkit-keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);-ms-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-00{-webkit-animation-name:layer-bounceIn;animation-name:layer-bounceIn}@-webkit-keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);-ms-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);-ms-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-01{-webkit-animation-name:layer-zoomInDown;animation-name:layer-zoomInDown}@-webkit-keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);-ms-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0)}}.layer-anim-02{-webkit-animation-name:layer-fadeInUpBig;animation-name:layer-fadeInUpBig}@-webkit-keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);-ms-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);-ms-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-03{-webkit-animation-name:layer-zoomInLeft;animation-name:layer-zoomInLeft}@-webkit-keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}@keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);-ms-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);-ms-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}.layer-anim-04{-webkit-animation-name:layer-rollIn;animation-name:layer-rollIn}@keyframes layer-fadeIn{0%{opacity:0}100%{opacity:1}}.layer-anim-05{-webkit-animation-name:layer-fadeIn;animation-name:layer-fadeIn}@-webkit-keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);transform:translateX(10px)}}@keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);-ms-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);-ms-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);-ms-transform:translateX(10px);transform:translateX(10px)}}.layer-anim-06{-webkit-animation-name:layer-shake;animation-name:layer-shake}@-webkit-keyframes fadeIn{0%{opacity:0}100%{opacity:1}}.layui-layer-title{padding:0 80px 0 20px;height:50px;line-height:50px;border-bottom:1px solid #f0f0f0;font-size:14px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:2px 2px 0 0}.layui-layer-setwin{position:absolute;right:15px;*right:0;top:17px;font-size:0;line-height:initial}.layui-layer-setwin a{position:relative;width:16px;height:16px;margin-left:10px;font-size:12px;_overflow:hidden}.layui-layer-setwin .layui-layer-min cite{position:absolute;width:14px;height:2px;left:0;top:50%;margin-top:-1px;background-color:#2e2d3c;cursor:pointer;_overflow:hidden}.layui-layer-setwin .layui-layer-min:hover cite{background-color:#2d93ca}.layui-layer-setwin .layui-layer-max{background-position:-32px -40px}.layui-layer-setwin .layui-layer-max:hover{background-position:-16px -40px}.layui-layer-setwin .layui-layer-maxmin{background-position:-65px -40px}.layui-layer-setwin .layui-layer-maxmin:hover{background-position:-49px -40px}.layui-layer-setwin .layui-layer-close1{background-position:1px -40px;cursor:pointer}.layui-layer-setwin .layui-layer-close1:hover{opacity:.7}.layui-layer-setwin .layui-layer-close2{position:absolute;right:-28px;top:-28px;width:30px;height:30px;margin-left:0;background-position:-149px -31px;*right:-18px;_display:none}.layui-layer-setwin .layui-layer-close2:hover{background-position:-180px -31px}.layui-layer-btn{text-align:right;padding:0 15px 12px;pointer-events:auto;user-select:none;-webkit-user-select:none}.layui-layer-btn a{height:28px;line-height:28px;margin:5px 5px 0;padding:0 15px;border:1px solid #dedede;background-color:#fff;color:#333;border-radius:2px;font-weight:400;cursor:pointer;text-decoration:none}.layui-layer-btn a:hover{opacity:.9;text-decoration:none}.layui-layer-btn a:active{opacity:.8}.layui-layer-btn .layui-layer-btn0{border-color:#1e9fff;background-color:#1e9fff;color:#fff}.layui-layer-btn-l{text-align:left}.layui-layer-btn-c{text-align:center}.layui-layer-dialog{min-width:300px}.layui-layer-dialog .layui-layer-content{position:relative;padding:20px;line-height:24px;word-break:break-all;overflow:hidden;font-size:14px;overflow-x:hidden;overflow-y:auto}.layui-layer-dialog .layui-layer-content .layui-layer-ico{position:absolute;top:16px;left:15px;_left:-40px;width:30px;height:30px}.layui-layer-ico1{background-position:-30px 0}.layui-layer-ico2{background-position:-60px 0}.layui-layer-ico3{background-position:-90px 0}.layui-layer-ico4{background-position:-120px 0}.layui-layer-ico5{background-position:-150px 0}.layui-layer-ico6{background-position:-180px 0}.layui-layer-rim{border:6px solid #8d8d8d;border:6px solid rgba(0,0,0,.3);border-radius:5px;box-shadow:none}.layui-layer-msg{min-width:180px;border:1px solid #d3d4d3;box-shadow:none}.layui-layer-hui{min-width:100px;background-color:#000;filter:alpha(opacity=60);background-color:rgba(0,0,0,.6);color:#fff;border:none}.layui-layer-hui .layui-layer-content{padding:12px 25px;text-align:center}.layui-layer-dialog .layui-layer-padding{padding:20px 20px 20px 55px;text-align:left}.layui-layer-page .layui-layer-content{position:relative;overflow:auto}.layui-layer-iframe .layui-layer-btn,.layui-layer-page .layui-layer-btn{padding-top:10px}.layui-layer-nobg{background:0 0}.layui-layer-iframe iframe{display:block;width:100%}.layui-layer-loading{border-radius:100%;background:0 0;box-shadow:none;border:none}.layui-layer-loading .layui-layer-content{width:60px;height:24px;background:url(loading-0.gif) no-repeat}.layui-layer-loading .layui-layer-loading1{width:37px;height:37px;background:url(loading-1.gif) no-repeat}.layui-layer-ico16,.layui-layer-loading .layui-layer-loading2{width:32px;height:32px;background:url(loading-2.gif) no-repeat}.layui-layer-tips{background:0 0;box-shadow:none;border:none}.layui-layer-tips .layui-layer-content{position:relative;line-height:22px;min-width:12px;padding:8px 15px;font-size:12px;_float:left;border-radius:2px;box-shadow:1px 1px 3px rgba(0,0,0,.2);background-color:#000;color:#fff}.layui-layer-tips .layui-layer-close{right:-2px;top:-1px}.layui-layer-tips i.layui-layer-TipsG{position:absolute;width:0;height:0;border-width:8px;border-color:transparent;border-style:dashed;*overflow:hidden}.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{left:5px;border-right-style:solid;border-right-color:#000}.layui-layer-tips i.layui-layer-TipsT{bottom:-8px}.layui-layer-tips i.layui-layer-TipsB{top:-8px}.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{top:5px;border-bottom-style:solid;border-bottom-color:#000}.layui-layer-tips i.layui-layer-TipsR{left:-8px}.layui-layer-tips i.layui-layer-TipsL{right:-8px}.layui-layer-lan[type=dialog]{min-width:280px}.layui-layer-lan .layui-layer-title{background:#4476a7;color:#fff;border:none}.layui-layer-lan .layui-layer-btn{padding:5px 10px 10px;text-align:right;border-top:1px solid #e9e7e7}.layui-layer-lan .layui-layer-btn a{background:#fff;border-color:#e9e7e7;color:#333}.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background:#c9c5c5}.layui-layer-molv .layui-layer-title{background:#009f95;color:#fff;border:none}.layui-layer-molv .layui-layer-btn a{background:#009f95;border-color:#009f95}.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:#92b8b1}.layui-layer-iconext{background:url(icon-ext.png) no-repeat}.layui-layer-prompt .layui-layer-input{display:block;width:260px;height:36px;margin:0 auto;line-height:30px;padding-left:10px;border:1px solid #e6e6e6;color:#333}.layui-layer-prompt textarea.layui-layer-input{width:300px;height:100px;line-height:20px;padding:6px 10px}.layui-layer-prompt .layui-layer-content{padding:20px}.layui-layer-prompt .layui-layer-btn{padding-top:0}.layui-layer-tab{box-shadow:1px 1px 50px rgba(0,0,0,.4)}.layui-layer-tab .layui-layer-title{padding-left:0;overflow:visible}.layui-layer-tab .layui-layer-title span{position:relative;float:left;min-width:80px;max-width:300px;padding:0 20px;text-align:center;cursor:default;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;cursor:pointer}.layui-layer-tab .layui-layer-title span.layui-this{height:51px;border-left:1px solid #eee;border-right:1px solid #eee;background-color:#fff;z-index:10}.layui-layer-tab .layui-layer-title span:first-child{border-left:none}.layui-layer-tabmain{line-height:24px;clear:both}.layui-layer-tabmain .layui-layer-tabli{display:none}.layui-layer-tabmain .layui-layer-tabli.layui-this{display:block}.layui-layer-photos{background:0 0;box-shadow:none}.layui-layer-photos .layui-layer-content{overflow:hidden;text-align:center}.layui-layer-photos .layui-layer-phimg img{position:relative;width:100%;display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-imgnext,.layui-layer-imgprev{position:fixed;top:50%;width:27px;_width:44px;height:44px;margin-top:-22px;outline:0;blr:expression(this.onFocus=this.blur())}.layui-layer-imgprev{left:30px;background-position:-5px -5px;_background-position:-70px -5px}.layui-layer-imgprev:hover{background-position:-33px -5px;_background-position:-120px -5px}.layui-layer-imgnext{right:30px;_right:8px;background-position:-5px -50px;_background-position:-70px -50px}.layui-layer-imgnext:hover{background-position:-33px -50px;_background-position:-120px -50px}.layui-layer-imgbar{position:fixed;left:0;right:0;bottom:0;width:100%;height:40px;line-height:40px;background-color:#000\9;filter:Alpha(opacity=60);background-color:rgba(2,0,0,.35);color:#fff;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;font-size:0}.layui-layer-imgtit *{display:inline-block;*display:inline;*zoom:1;vertical-align:top;font-size:12px}.layui-layer-imgtit a{max-width:65%;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;color:#fff}.layui-layer-imgtit a:hover{color:#fff;text-decoration:underline}.layui-layer-imgtit em{padding-left:10px;font-style:normal}@-webkit-keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);-ms-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);-ms-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-close{-webkit-animation-name:layer-bounceOut;animation-name:layer-bounceOut;-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.2s;animation-duration:.2s}@media screen and (max-width:1100px){.layui-layer-iframe{overflow-y:auto;-webkit-overflow-scrolling:touch}} 2 | -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/loading-0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/css/modules/layer/default/loading-0.gif -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/loading-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/css/modules/layer/default/loading-1.gif -------------------------------------------------------------------------------- /module/static/layui/css/modules/layer/default/loading-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/css/modules/layer/default/loading-2.gif -------------------------------------------------------------------------------- /module/static/layui/font/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/font/iconfont.eot -------------------------------------------------------------------------------- /module/static/layui/font/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/font/iconfont.ttf -------------------------------------------------------------------------------- /module/static/layui/font/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/font/iconfont.woff -------------------------------------------------------------------------------- /module/static/layui/font/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/module/static/layui/font/iconfont.woff2 -------------------------------------------------------------------------------- /module/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Telegram Media Downloader 8 | 9 | 14 | 15 | 16 | 17 |
18 |
    19 |
  • Downloading
  • 20 |
  • Downloaded
  • 21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | 35 |
36 |
37 | 38 | 60 | 61 | 62 | 292 | 293 | 294 | 295 | -------------------------------------------------------------------------------- /module/web.py: -------------------------------------------------------------------------------- 1 | """web ui for media download""" 2 | 3 | import logging 4 | import os 5 | import time 6 | 7 | from flask import Flask, render_template, request 8 | 9 | import utils 10 | from utils.format import format_byte 11 | 12 | log = logging.getLogger("werkzeug") 13 | log.setLevel(logging.ERROR) 14 | 15 | _flask_app = Flask(__name__) 16 | 17 | _download_result: dict = {} 18 | _total_download_speed: int = 0 19 | _total_download_size: int = 0 20 | _last_download_time: float = time.time() 21 | 22 | 23 | def get_flask_app() -> Flask: 24 | """get flask app instance""" 25 | return _flask_app 26 | 27 | 28 | def get_download_result() -> dict: 29 | """get global download result""" 30 | return _download_result 31 | 32 | 33 | def get_total_download_speed() -> int: 34 | """get total download speed""" 35 | return _total_download_speed 36 | 37 | 38 | def update_download_status( 39 | message_id: int, down_byte: int, total_size: int, file_name: str, start_time: float 40 | ): 41 | """update_download_status""" 42 | cur_time = time.time() 43 | # pylint: disable = W0603 44 | global _total_download_speed 45 | global _total_download_size 46 | global _last_download_time 47 | 48 | if _download_result.get(message_id): 49 | last_download_byte = _download_result[message_id]["down_byte"] 50 | last_time = _download_result[message_id]["end_time"] 51 | download_speed = _download_result[message_id]["download_speed"] 52 | each_second_total_download = _download_result[message_id][ 53 | "each_second_total_download" 54 | ] 55 | end_time = _download_result[message_id]["end_time"] 56 | 57 | _total_download_size += down_byte - last_download_byte 58 | each_second_total_download += down_byte - last_download_byte 59 | 60 | if cur_time - last_time >= 1.0: 61 | download_speed = int(each_second_total_download / (cur_time - last_time)) 62 | end_time = cur_time 63 | each_second_total_download = 0 64 | 65 | download_speed = max(download_speed, 0) 66 | 67 | _download_result[message_id]["down_byte"] = down_byte 68 | _download_result[message_id]["end_time"] = end_time 69 | _download_result[message_id]["download_speed"] = download_speed 70 | _download_result[message_id][ 71 | "each_second_total_download" 72 | ] = each_second_total_download 73 | else: 74 | each_second_total_download = down_byte 75 | _download_result[message_id] = { 76 | "down_byte": down_byte, 77 | "total_size": total_size, 78 | "file_name": file_name, 79 | "start_time": start_time, 80 | "end_time": cur_time, 81 | "download_speed": down_byte / (cur_time - start_time), 82 | "each_second_total_download": each_second_total_download, 83 | } 84 | _total_download_size += down_byte 85 | 86 | if cur_time - _last_download_time >= 1.0: 87 | # update speed 88 | _total_download_speed = int( 89 | _total_download_size / (cur_time - _last_download_time) 90 | ) 91 | _total_download_speed = max(_total_download_speed, 0) 92 | _total_download_size = 0 93 | _last_download_time = cur_time 94 | 95 | 96 | @_flask_app.route("/") 97 | def index(): 98 | """Index html""" 99 | return render_template("index.html") 100 | 101 | 102 | @_flask_app.route("/get_download_status") 103 | def get_download_speed(): 104 | """Get download speed""" 105 | return ( 106 | '{ "download_speed" : "' 107 | + format_byte(get_total_download_speed()) 108 | + '/s" , "upload_speed" : "0.00 B/s" } ' 109 | ) 110 | 111 | 112 | @_flask_app.route("/get_app_version") 113 | def get_app_version(): 114 | """Get telegram_media_downloader version""" 115 | return utils.__version__ 116 | 117 | 118 | @_flask_app.route("/get_download_list") 119 | def get_download_list(): 120 | """get download list""" 121 | if request.args.get("already_down") is None: 122 | return "[]" 123 | 124 | already_down = request.args.get("already_down") == "true" 125 | 126 | download_result = get_download_result() 127 | result = "[" 128 | for idx, value in download_result.items(): 129 | is_already_down = value["down_byte"] == value["total_size"] 130 | 131 | if already_down and not is_already_down: 132 | continue 133 | 134 | if result != "[": 135 | result += "," 136 | download_speed = format_byte(value["download_speed"]) + "/s" 137 | result += ( 138 | '{ "id":"' 139 | + f"{idx}" 140 | + '", "filename":"' 141 | + os.path.basename(value["file_name"]) 142 | + '", "total_size":"' 143 | + f'{format_byte(value["total_size"])}' 144 | + '" ,"download_progress":"' 145 | ) 146 | result += ( 147 | f'{round(value["down_byte"] / value["total_size"] * 100, 1)}' 148 | + '" ,"download_speed":"' 149 | + download_speed 150 | + '" ,"save_path":"' 151 | + value["file_name"].replace("\\", "/") 152 | + '"}' 153 | ) 154 | 155 | result += "]" 156 | return result 157 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_return_any = True 3 | 4 | [mypy-yaml.*] 5 | ignore_missing_imports = True 6 | 7 | [mypy-tests.*] 8 | ignore_errors = True 9 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # pylint-version: 2.2 2 | 3 | [MASTER] 4 | 5 | # A comma-separated list of package or module names from where C extensions may 6 | # be loaded. Extensions are loading into the active Python interpreter and may 7 | # run arbitrary code. 8 | extension-pkg-whitelist= 9 | pycurl, 10 | cdecimal, 11 | 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS .git 16 | 17 | # Add files or directories matching the regex patterns to the blacklist. The 18 | # regex matches against base names, not paths. 19 | ignore-patterns= 20 | 21 | # Python code to execute, usually for sys.path manipulation such as 22 | # pygtk.require(). 23 | #init-hook= 24 | 25 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 26 | # number of processors available to use. 27 | jobs=1 28 | 29 | # Control the amount of potential inferred values when inferring a single 30 | # object. This can help the performance when dealing with large functions or 31 | # complex, nested conditions. 32 | limit-inference-results=100 33 | 34 | # List of plugins (as comma separated values of python modules names) to load, 35 | # usually to register additional checkers. 36 | load-plugins= 37 | 38 | # Pickle collected data for later comparisons. 39 | persistent=no 40 | 41 | # Specify a configuration file. 42 | #rcfile= 43 | 44 | # When enabled, pylint would attempt to guess common misconfiguration and emit 45 | # user-friendly hints instead of false-positive error messages. 46 | suggestion-mode=yes 47 | 48 | # Allow loading of arbitrary C extensions. Extensions are imported into the 49 | # active Python interpreter and may run arbitrary code. 50 | unsafe-load-any-extension=no 51 | 52 | 53 | [MESSAGES CONTROL] 54 | 55 | # Only show warnings with the listed confidence levels. Leave empty to show 56 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 57 | confidence= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once). You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use "--disable=all --enable=classes 67 | # --disable=W". 68 | disable= 69 | locally-disabled, 70 | file-ignored, 71 | fixme, 72 | useless-object-inheritance, 73 | 74 | redefined-variable-type, 75 | redefined-argument-from-local, 76 | wrong-import-position, 77 | consider-using-ternary, 78 | redefined-outer-name, 79 | 80 | invalid-name, 81 | bad-continuation, 82 | import-error, 83 | broad-except, 84 | 85 | unspecified-encoding, 86 | 87 | 88 | # Enable the message, report, category or checker with the given id(s). You can 89 | # either give multiple identifier separated by comma (,) or put this option 90 | # multiple time (only on the command line, not in the configuration file where 91 | # it should appear only once). See also the "--disable" option for examples. 92 | enable= 93 | 94 | 95 | [REPORTS] 96 | 97 | # Python expression which should return a note less than 10 (10 is the highest 98 | # note). You have access to the variables errors warning, statement which 99 | # respectively contain the number of errors / warnings messages and the total 100 | # number of statements analyzed. This is used by the global evaluation report 101 | # (RP0004). 102 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 103 | 104 | # Template used to display messages. This is a python new-style format string 105 | # used to format the message information. See doc for all details. 106 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 107 | 108 | # Set the output format. Available formats are text, parseable, colorized, json 109 | # and msvs (visual studio). You can also give a reporter class, e.g. 110 | # mypackage.mymodule.MyReporterClass. 111 | output-format=text 112 | 113 | # Tells whether to display a full report or only the messages. 114 | reports=no 115 | 116 | # Activate the evaluation score. 117 | score=no 118 | 119 | 120 | [REFACTORING] 121 | 122 | # Maximum number of nested blocks for function / method body 123 | max-nested-blocks=5 124 | 125 | # Complete name of functions that never returns. When checking for 126 | # inconsistent-return-statements if a never returning function is called then 127 | # it will be considered as an explicit return statement and no message will be 128 | # printed. 129 | never-returning-functions=sys.exit 130 | 131 | 132 | [MISCELLANEOUS] 133 | 134 | # List of note tags to take in consideration, separated by a comma. 135 | notes=FIXME, XXX, TODO 136 | 137 | 138 | [LOGGING] 139 | 140 | # Format style used to check logging format string. `old` means using % 141 | # formatting, while `new` is for `{}` formatting. 142 | logging-format-style=old 143 | 144 | # Logging modules to check that the string format arguments are in logging 145 | # function parameter format. 146 | logging-modules=logging 147 | 148 | 149 | [SIMILARITIES] 150 | 151 | # Ignore comments when computing similarities. 152 | ignore-comments=yes 153 | 154 | # Ignore docstrings when computing similarities. 155 | ignore-docstrings=yes 156 | 157 | # Ignore imports when computing similarities. 158 | ignore-imports=no 159 | 160 | # Minimum lines number of a similarity. 161 | min-similarity-lines=4 162 | 163 | 164 | [SPELLING] 165 | 166 | # Limits count of emitted suggestions for spelling mistakes. 167 | max-spelling-suggestions=4 168 | 169 | # Spelling dictionary name. Available dictionaries: none. To make it working 170 | # install python-enchant package.. 171 | spelling-dict= 172 | 173 | # List of comma separated words that should not be checked. 174 | spelling-ignore-words= 175 | 176 | # A path to a file that contains private dictionary; one word per line. 177 | spelling-private-dict-file= 178 | 179 | # Tells whether to store unknown words to indicated private dictionary in 180 | # --spelling-private-dict-file option instead of raising a message. 181 | spelling-store-unknown-words=no 182 | 183 | 184 | [TYPECHECK] 185 | 186 | # List of decorators that produce context managers, such as 187 | # contextlib.contextmanager. Add to this list to register other decorators that 188 | # produce valid context managers. 189 | contextmanager-decorators=contextlib.contextmanager 190 | 191 | # List of members which are set dynamically and missed by pylint inference 192 | # system, and so shouldn't trigger E1101 when accessed. Python regular 193 | # expressions are accepted. 194 | generated-members= 195 | 196 | # Tells whether missing members accessed in mixin class should be ignored. A 197 | # mixin class is detected if its name ends with "mixin" (case insensitive). 198 | ignore-mixin-members=yes 199 | 200 | # Tells whether to warn about missing members when the owner of the attribute 201 | # is inferred to be None. 202 | ignore-none=yes 203 | 204 | # This flag controls whether pylint should warn about no-member and similar 205 | # checks whenever an opaque object is returned when inferring. The inference 206 | # can return multiple potential results while evaluating a Python object, but 207 | # some branches might not be evaluated, which results in partial inference. In 208 | # that case, it might be useful to still emit no-member and other checks for 209 | # the rest of the inferred objects. 210 | ignore-on-opaque-inference=yes 211 | 212 | # List of class names for which member attributes should not be checked (useful 213 | # for classes with dynamically set attributes). This supports the use of 214 | # qualified names. 215 | ignored-classes= 216 | st.config._config._section._unset, 217 | 218 | 219 | # List of module names for which member attributes should not be checked 220 | # (useful for modules/projects where namespaces are manipulated during runtime 221 | # and thus existing member attributes cannot be deduced by static analysis. It 222 | # supports qualified module names, as well as Unix pattern matching. 223 | ignored-modules= 224 | 225 | # Show a hint with possible names when a member name was not found. The aspect 226 | # of finding the hint is based on edit distance. 227 | missing-member-hint=yes 228 | 229 | # The minimum edit distance a name should have in order to be considered a 230 | # similar match for a missing member name. 231 | missing-member-hint-distance=1 232 | 233 | # The total number of similar names that should be taken in consideration when 234 | # showing a hint for a missing member. 235 | missing-member-max-choices=1 236 | 237 | 238 | [FORMAT] 239 | 240 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 241 | expected-line-ending-format= 242 | 243 | # Regexp for a line that is allowed to be longer than the limit. 244 | ignore-long-lines=^\s*(# )??$ 245 | 246 | # Number of spaces of indent required inside a hanging or continued line. 247 | indent-after-paren=4 248 | 249 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 250 | # tab). 251 | indent-string=' ' 252 | 253 | # Maximum number of characters on a single line. 254 | max-line-length=90 255 | 256 | # Maximum number of lines in a module. 257 | max-module-lines= 258 | 1500 259 | 260 | # Allow the body of a class to be on the same line as the declaration if body 261 | # contains single statement. 262 | single-line-class-stmt=no 263 | 264 | # Allow the body of an if to be on the same line as the test if there is no 265 | # else. 266 | single-line-if-stmt=no 267 | 268 | 269 | [VARIABLES] 270 | 271 | # List of additional names supposed to be defined in builtins. Remember that 272 | # you should avoid defining new builtins when possible. 273 | additional-builtins= 274 | 275 | # Tells whether unused global variables should be treated as a violation. 276 | allow-global-unused-variables=yes 277 | 278 | # List of strings which can identify a callback function by name. A callback 279 | # name must start or end with one of those strings. 280 | callbacks=cb_, _cb 281 | 282 | # A regular expression matching the name of dummy variables (i.e. expected to 283 | # not be used). 284 | dummy-variables-rgx=(?x) 285 | (_|dummy)$ 286 | 287 | 288 | # Argument names that match this expression will be ignored. Default to name 289 | # with leading underscore. 290 | ignored-argument-names=(?x) 291 | _|(?:dummy|(?:kw)?args|request|response|context|ctx)$ 292 | 293 | 294 | # Tells whether we should check for unused import in __init__ files. 295 | init-import=no 296 | 297 | # List of qualified module names which can have objects that can redefine 298 | # builtins. 299 | redefining-builtins-modules=six.moves,future.builtins 300 | 301 | 302 | [BASIC] 303 | 304 | # Naming style matching correct argument names. 305 | argument-naming-style=snake_case 306 | 307 | # Regular expression matching correct argument names. Overrides argument- 308 | # naming-style. 309 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 310 | 311 | # Naming style matching correct attribute names. 312 | attr-naming-style=snake_case 313 | 314 | # Regular expression matching correct attribute names. Overrides attr-naming- 315 | # style. 316 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 317 | 318 | # Bad variable names which should always be refused, separated by a comma. 319 | bad-names=foo, bar, baz, toto, tutu, tata 320 | 321 | # Naming style matching correct class attribute names. 322 | class-attribute-naming-style=any 323 | 324 | # Regular expression matching correct class attribute names. Overrides class- 325 | # attribute-naming-style. 326 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 327 | 328 | # Naming style matching correct class names. 329 | class-naming-style=PascalCase 330 | 331 | # Regular expression matching correct class names. Overrides class-naming- 332 | # style. 333 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 334 | 335 | # Naming style matching correct constant names. 336 | const-naming-style=UPPER_CASE 337 | 338 | # Regular expression matching correct constant names. Overrides const-naming- 339 | # style. 340 | const-rgx=(?x)( 341 | ([A-Z_][A-Z0-9_]*) 342 | |(__.*__) 343 | |(.+_)?logger 344 | |(.+_)?predicate 345 | |t_[a-z0-9]+(_[a-z0-9]+)* 346 | |(.*_)?templates 347 | )$ 348 | 349 | 350 | # Minimum line length for functions/classes that require docstrings, shorter 351 | # ones are exempt. 352 | docstring-min-length=-1 353 | 354 | # Naming style matching correct function names. 355 | function-naming-style=snake_case 356 | 357 | # Regular expression matching correct function names. Overrides function- 358 | # naming-style. 359 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 360 | 361 | # Good variable names which should always be accepted, separated by a comma. 362 | good-names= 363 | _, j, db, e, fd, fp, 364 | 365 | 366 | # Include a hint for the correct naming format with invalid-name. 367 | include-naming-hint=no 368 | 369 | # Naming style matching correct inline iteration names. 370 | inlinevar-naming-style=any 371 | 372 | # Regular expression matching correct inline iteration names. Overrides 373 | # inlinevar-naming-style. 374 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 375 | 376 | # Naming style matching correct method names. 377 | method-naming-style=snake_case 378 | 379 | # Regular expression matching correct method names. Overrides method-naming- 380 | # style. 381 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 382 | 383 | # Naming style matching correct module names. 384 | module-naming-style=snake_case 385 | 386 | # Regular expression matching correct module names. Overrides module-naming- 387 | # style. 388 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 389 | 390 | # Colon-delimited sets of names that determine each other's naming style when 391 | # the name regexes allow several styles. 392 | name-group= 393 | 394 | # Regular expression which should only match function or class names that do 395 | # not require a docstring. 396 | no-docstring-rgx=(?x)( 397 | __.*__ 398 | |test_.* 399 | |.+Test 400 | |render_.+ 401 | |repeat_.+ 402 | |(?:Pre)?Render 403 | )$ 404 | 405 | 406 | # List of decorators that produce properties, such as abc.abstractproperty. Add 407 | # to this list to register other decorators that produce valid properties. 408 | # These decorators are taken in consideration only for invalid-name. 409 | property-classes=abc.abstractproperty 410 | 411 | # Naming style matching correct variable names. 412 | variable-naming-style=snake_case 413 | 414 | # Regular expression matching correct variable names. Overrides variable- 415 | # naming-style. 416 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 417 | 418 | 419 | [IMPORTS] 420 | 421 | # Allow wildcard imports from modules that define __all__. 422 | allow-wildcard-with-all=no 423 | 424 | # Analyse import fallback blocks. This can be used to support both Python 2 and 425 | # 3 compatible code, which means that the block might have code that exists 426 | # only in one or another interpreter, leading to false positives when analysed. 427 | analyse-fallback-blocks=no 428 | 429 | # Deprecated modules which should not be used, separated by a comma. 430 | deprecated-modules=regsub, TERMIOS, Bastion, rexec 431 | 432 | # Create a graph of external dependencies in the given file (report RP0402 must 433 | # not be disabled). 434 | ext-import-graph= 435 | 436 | # Create a graph of every (i.e. internal and external) dependencies in the 437 | # given file (report RP0402 must not be disabled). 438 | import-graph= 439 | 440 | # Create a graph of internal dependencies in the given file (report RP0402 must 441 | # not be disabled). 442 | int-import-graph= 443 | 444 | # Force import order to recognize a module as part of the standard 445 | # compatibility libraries. 446 | known-standard-library= 447 | 448 | # Force import order to recognize a module as part of a third party library. 449 | known-third-party=enchant 450 | 451 | 452 | [CLASSES] 453 | 454 | # List of method names used to declare (i.e. assign) instance attributes. 455 | defining-attr-methods= 456 | __init__, 457 | __new__, 458 | setUp, 459 | 460 | 461 | # List of member names, which should be excluded from the protected access 462 | # warning. 463 | exclude-protected=_asdict, _fields, _replace, _source, _make 464 | 465 | # List of valid names for the first argument in a class method. 466 | valid-classmethod-first-arg=cls 467 | 468 | # List of valid names for the first argument in a metaclass class method. 469 | valid-metaclass-classmethod-first-arg=mcs 470 | 471 | 472 | [DESIGN] 473 | 474 | # Maximum number of arguments for function / method. 475 | max-args=10 476 | 477 | # Maximum number of attributes for a class (see R0902). 478 | max-attributes=8 479 | 480 | # Maximum number of boolean expressions in an if statement. 481 | max-bool-expr=5 482 | 483 | # Maximum number of branch for function / method body. 484 | max-branches=13 485 | 486 | # Maximum number of locals for function / method body. 487 | max-locals=15 488 | 489 | # Maximum number of parents for a class (see R0901). 490 | max-parents=7 491 | 492 | # Maximum number of public methods for a class (see R0904). 493 | max-public-methods=20 494 | 495 | # Maximum number of return / yield for function / method body. 496 | max-returns=6 497 | 498 | # Maximum number of statements in function / method body. 499 | max-statements=50 500 | 501 | # Minimum number of public methods for a class (see R0903). 502 | min-public-methods=0 503 | 504 | 505 | [EXCEPTIONS] 506 | 507 | # Exceptions that will emit a warning when being caught. Defaults to 508 | # "Exception". 509 | overgeneral-exceptions=Exception 510 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # https://github.com/tangyoha/pyrogram/archive/refs/tags/v2.0.97.zip 2 | https://github.com/tangyoha/pyrogram/archive/refs/tags/v2.0.69.zip 3 | PyYAML==6.0 4 | rich==12.5.1 5 | TgCrypto==1.2.5 6 | loguru==0.6.0 7 | aligo==5.4.0 8 | flask==2.2.2 9 | ply==3.11 10 | -------------------------------------------------------------------------------- /screenshot/web_ui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/screenshot/web_ui.gif -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | from utils import __version__ 4 | 5 | setup( 6 | name="telegram-media-downloader", 7 | version=__version__, 8 | author="tangyoha", 9 | author_email="tangyoha@outlook.com", 10 | description="A simple script to download media from telegram", 11 | url="https://github.com/tangyoha/telegram_media_downloader", 12 | download_url="https://github.com/tangyoha/telegram_media_downloader/releases/latest", 13 | py_modules=["media_downloader"], 14 | classifiers=[ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Operating System :: OS Independent", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: End Users/Desktop", 20 | "Intended Audience :: Science/Research", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Topic :: Internet", 31 | "Topic :: Communications", 32 | "Topic :: Communications :: Chat", 33 | "Topic :: Software Development :: Libraries", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | ], 36 | project_urls={ 37 | "Tracker": "https://github.com/tangyoha/telegram_media_downloader/issues", 38 | "Community": "https://t.me/TeegramMediaDownload", 39 | "Source": "https://github.com/tangyoha/telegram_media_downloader", 40 | }, 41 | python_requires="~=3.7", 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/test_app.py: -------------------------------------------------------------------------------- 1 | """test app""" 2 | 3 | import os 4 | import sys 5 | import unittest 6 | from unittest import mock 7 | 8 | from module.app import Application 9 | 10 | sys.path.append("..") # Adds higher directory to python modules path. 11 | 12 | 13 | class AppTestCase(unittest.TestCase): 14 | @classmethod 15 | def tearDownClass(cls): 16 | config_test = os.path.join(os.path.abspath("."), "config_test.yaml") 17 | data_test = os.path.join(os.path.abspath("."), "data_test.yaml") 18 | if os.path.exists(config_test): 19 | os.remove(config_test) 20 | if os.path.exists(data_test): 21 | os.remove(data_test) 22 | 23 | def test_app(self): 24 | app = Application("", "") 25 | self.assertEqual(app.save_path, os.path.abspath(".")) 26 | self.assertEqual(app.proxy, {}) 27 | self.assertEqual(app.restart_program, False) 28 | 29 | app.last_read_message_id = 3 30 | app.failed_ids.append(1) 31 | app.downloaded_ids.append(2) 32 | 33 | app.update_config(False) 34 | 35 | self.assertEqual(app.last_read_message_id, app.config["last_read_message_id"]) 36 | self.assertEqual(app.ids_to_retry, app.app_data["ids_to_retry"]) 37 | 38 | @mock.patch("__main__.__builtins__.open", new_callable=mock.mock_open) 39 | @mock.patch("module.app.yaml", autospec=True) 40 | def test_update_config(self, mock_yaml, mock_open): 41 | app = Application("", "") 42 | app.config_file = "config_test.yaml" 43 | app.app_data_file = "data_test.yaml" 44 | app.update_config() 45 | mock_open.assert_called_with("data_test.yaml", "w") 46 | mock_yaml.dump.assert_called_with( 47 | app.config, mock.ANY, default_flow_style=False 48 | ) 49 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | class Chat: 5 | def __init__(self, chat_id, chat_title): 6 | self.id = chat_id 7 | self.title = chat_title 8 | 9 | 10 | class Date: 11 | def __init__(self, date): 12 | self.date = date 13 | 14 | def strftime(self, str) -> str: 15 | return "" 16 | 17 | 18 | class MockMessage: 19 | def __init__(self, **kwargs): 20 | self.id = kwargs.get("id") 21 | self.media = kwargs.get("media") 22 | self.audio = kwargs.get("audio", None) 23 | self.document = kwargs.get("document", None) 24 | self.photo = kwargs.get("photo", None) 25 | self.video = kwargs.get("video", None) 26 | self.voice = kwargs.get("voice", None) 27 | self.video_note = kwargs.get("video_note", None) 28 | self.media_group_id = kwargs.get("media_group_id", None) 29 | self.caption = kwargs.get("caption", None) 30 | 31 | if kwargs.get("dis_chat") == None: 32 | self.chat = Chat( 33 | kwargs.get("chat_id", None), kwargs.get("chat_title", None) 34 | ) 35 | else: 36 | self.chat = None 37 | self.date: datetime = None 38 | if kwargs.get("date") != None: 39 | self.date = kwargs["date"] 40 | 41 | 42 | class MockAudio: 43 | def __init__(self, **kwargs): 44 | self.file_name = kwargs["file_name"] 45 | self.mime_type = kwargs["mime_type"] 46 | if kwargs.get("file_size"): 47 | self.file_size = kwargs["file_size"] 48 | else: 49 | self.file_size = 1024 50 | 51 | 52 | class MockDocument: 53 | def __init__(self, **kwargs): 54 | self.file_name = kwargs["file_name"] 55 | self.mime_type = kwargs["mime_type"] 56 | if kwargs.get("file_size"): 57 | self.file_size = kwargs["file_size"] 58 | else: 59 | self.file_size = 1024 60 | 61 | 62 | class MockPhoto: 63 | def __init__(self, **kwargs): 64 | self.date = kwargs["date"] 65 | self.file_unique_id = kwargs["file_unique_id"] 66 | if kwargs.get("file_size"): 67 | self.file_size = kwargs["file_size"] 68 | else: 69 | self.file_size = 1024 70 | 71 | 72 | class MockVoice: 73 | def __init__(self, **kwargs): 74 | self.mime_type = kwargs["mime_type"] 75 | self.date = kwargs["date"] 76 | if kwargs.get("file_size"): 77 | self.file_size = kwargs["file_size"] 78 | else: 79 | self.file_size = 1024 80 | 81 | 82 | class MockVideo: 83 | def __init__(self, **kwargs): 84 | self.file_name = kwargs.get("file_name") 85 | self.mime_type = kwargs["mime_type"] 86 | if kwargs.get("file_size"): 87 | self.file_size = kwargs["file_size"] 88 | else: 89 | self.file_size = 1024 90 | 91 | if kwargs.get("width"): 92 | self.width = kwargs["width"] 93 | else: 94 | self.width = 1920 95 | 96 | if kwargs.get("height"): 97 | self.height = kwargs["height"] 98 | else: 99 | self.height = 1080 100 | 101 | if kwargs.get("duration"): 102 | self.duration = kwargs["duration"] 103 | else: 104 | self.duration = 1024 105 | 106 | 107 | class MockVideoNote: 108 | def __init__(self, **kwargs): 109 | self.mime_type = kwargs["mime_type"] 110 | self.date = kwargs["date"] 111 | -------------------------------------------------------------------------------- /tests/test_media_downloader.py: -------------------------------------------------------------------------------- 1 | """Unittest module for media downloader.""" 2 | import asyncio 3 | import os 4 | import platform 5 | import unittest 6 | from datetime import datetime 7 | 8 | import mock 9 | import pyrogram 10 | 11 | from media_downloader import ( 12 | _can_download, 13 | _get_media_meta, 14 | _is_exist, 15 | app, 16 | begin_import, 17 | download_media, 18 | exec_main, 19 | main, 20 | process_messages, 21 | ) 22 | 23 | from .test_common import ( 24 | Chat, 25 | Date, 26 | MockAudio, 27 | MockDocument, 28 | MockMessage, 29 | MockPhoto, 30 | MockVideo, 31 | MockVideoNote, 32 | MockVoice, 33 | ) 34 | 35 | MOCK_DIR: str = "/root/project" 36 | if platform.system() == "Windows": 37 | MOCK_DIR = "\\root\\project" 38 | MOCK_CONF = { 39 | "api_id": 123, 40 | "api_hash": "hasw5Tgawsuj67", 41 | "last_read_message_id": 0, 42 | "chat_id": 8654123, 43 | "ids_to_retry": [1, 2], 44 | "media_types": ["audio", "voice"], 45 | "file_formats": {"audio": ["all"], "voice": ["all"]}, 46 | "save_path": MOCK_DIR, 47 | "file_name_prefix": ["message_id", "caption", "file_name"], 48 | } 49 | 50 | 51 | def os_remove(_: str): 52 | pass 53 | 54 | 55 | def is_exist(file: str): 56 | if os.path.basename(file).find("311 - sucess_exist_down.mp4") != -1: 57 | return True 58 | elif os.path.basename(file).find("422 - exception.mov") != -1: 59 | raise Exception 60 | return False 61 | 62 | 63 | def os_get_file_size(file: str) -> int: 64 | if os.path.basename(file).find("311 - failed_down.mp4") != -1: 65 | return 0 66 | elif os.path.basename(file).find("311 - sucess_down.mp4") != -1: 67 | return 1024 68 | return 0 69 | 70 | 71 | def rest_app(conf: dict): 72 | app.reset() 73 | app.config_file = "config_test.yaml" 74 | app.app_data_file = "data_test.yaml" 75 | app.load_config(conf) 76 | 77 | 78 | def platform_generic_path(_path: str) -> str: 79 | platform_specific_path: str = _path 80 | if platform.system() == "Windows": 81 | platform_specific_path = platform_specific_path.replace("/", "\\") 82 | return platform_specific_path 83 | 84 | 85 | def mock_manage_duplicate_file(file_path: str) -> str: 86 | return file_path 87 | 88 | 89 | def raise_keyboard_interrupt(): 90 | raise KeyboardInterrupt 91 | 92 | 93 | def raise_exception(): 94 | raise Exception 95 | 96 | 97 | class MockEventLoop: 98 | def __init__(self): 99 | pass 100 | 101 | def run_until_complete(self, *args, **kwargs): 102 | return {"api_id": 1, "api_hash": "asdf", "ids_to_retry": [1, 2, 3]} 103 | 104 | 105 | class MockAsync: 106 | def __init__(self): 107 | pass 108 | 109 | def get_event_loop(self): 110 | return MockEventLoop() 111 | 112 | 113 | async def async_get_media_meta(message, message_media, _type): 114 | result = await _get_media_meta(message, message_media, _type) 115 | return result 116 | 117 | 118 | async def async_download_media(client, message, media_types, file_formats): 119 | result = await download_media(client, message, media_types, file_formats) 120 | return result 121 | 122 | 123 | async def async_begin_import(pagination_limit): 124 | result = await begin_import(pagination_limit) 125 | return result 126 | 127 | 128 | async def mock_process_message(*args, **kwargs): 129 | return 5 130 | 131 | 132 | async def async_process_messages(client, messages, media_types, file_formats): 133 | result = await process_messages(client, messages, media_types, file_formats) 134 | return result 135 | 136 | 137 | class MockClient: 138 | def __init__(self, *args, **kwargs): 139 | pass 140 | 141 | def __aiter__(self): 142 | return self 143 | 144 | async def start(self): 145 | pass 146 | 147 | async def stop(self): 148 | pass 149 | 150 | async def get_chat_history(self, *args, **kwargs): 151 | items = [ 152 | MockMessage( 153 | id=1213, 154 | media=True, 155 | voice=MockVoice( 156 | mime_type="audio/ogg", 157 | date=datetime(2019, 7, 25, 14, 53, 50), 158 | ), 159 | ), 160 | MockMessage( 161 | id=1214, 162 | media=False, 163 | text="test message 1", 164 | ), 165 | MockMessage( 166 | id=1215, 167 | media=False, 168 | text="test message 2", 169 | ), 170 | MockMessage( 171 | id=1216, 172 | media=False, 173 | text="test message 3", 174 | ), 175 | ] 176 | for item in items: 177 | yield item 178 | 179 | async def get_messages(self, *args, **kwargs): 180 | if kwargs["message_ids"] == 7: 181 | return MockMessage( 182 | id=7, 183 | media=True, 184 | chat_id=123456, 185 | chat_title="123456", 186 | date=datetime.now(), 187 | video=MockVideo( 188 | file_name="sample_video.mov", 189 | mime_type="video/mov", 190 | ), 191 | ) 192 | elif kwargs["message_ids"] == 8: 193 | return MockMessage( 194 | id=8, 195 | media=True, 196 | chat_id=234567, 197 | chat_title="234567", 198 | date=datetime.now(), 199 | video=MockVideo( 200 | file_name="sample_video.mov", 201 | mime_type="video/mov", 202 | ), 203 | ) 204 | elif kwargs["message_ids"] == [1, 2]: 205 | return [ 206 | MockMessage( 207 | id=1, 208 | media=True, 209 | chat_id=234568, 210 | chat_title="234568", 211 | date=datetime.now(), 212 | video=MockVideo( 213 | file_name="sample_video.mov", 214 | mime_type="video/mov", 215 | ), 216 | ), 217 | MockMessage( 218 | id=2, 219 | media=True, 220 | chat_id=234568, 221 | chat_title="234568", 222 | date=datetime.now(), 223 | video=MockVideo( 224 | file_name="sample_video2.mov", 225 | mime_type="video/mov", 226 | ), 227 | ), 228 | ] 229 | return [] 230 | 231 | async def download_media(self, *args, **kwargs): 232 | mock_message = args[0] 233 | if mock_message.id in [7, 8]: 234 | raise pyrogram.errors.exceptions.bad_request_400.BadRequest 235 | elif mock_message.id == 9: 236 | raise pyrogram.errors.exceptions.unauthorized_401.Unauthorized 237 | elif mock_message.id == 11: 238 | raise TypeError 239 | elif mock_message.id == 420: 240 | raise pyrogram.errors.exceptions.flood_420.FloodWait(value=420) 241 | elif mock_message.id == 421: 242 | raise Exception 243 | return kwargs["file_name"] 244 | 245 | 246 | class MediaDownloaderTestCase(unittest.TestCase): 247 | @classmethod 248 | def setUpClass(cls): 249 | cls.loop = asyncio.get_event_loop() 250 | rest_app(MOCK_CONF) 251 | 252 | def test_get_media_meta(self): 253 | rest_app(MOCK_CONF) 254 | app.save_path = MOCK_DIR 255 | # Test Voice notes 256 | message = MockMessage( 257 | id=1, 258 | media=True, 259 | chat_title="test1", 260 | date=datetime(2019, 7, 25, 14, 53, 50), 261 | voice=MockVoice( 262 | mime_type="audio/ogg", 263 | date=datetime(2019, 7, 25, 14, 53, 50), 264 | ), 265 | ) 266 | result = self.loop.run_until_complete( 267 | async_get_media_meta(message, message.voice, "voice") 268 | ) 269 | 270 | self.assertEqual( 271 | ( 272 | platform_generic_path( 273 | "/root/project/test1/2019_07/1 - voice_2019-07-25T14:53:50.ogg" 274 | ), 275 | "ogg", 276 | ), 277 | result, 278 | ) 279 | 280 | # Test photos 281 | message = MockMessage( 282 | id=2, 283 | media=True, 284 | date=datetime(2019, 8, 5, 14, 35, 12), 285 | chat_title="test2", 286 | photo=MockPhoto( 287 | date=datetime(2019, 8, 5, 14, 35, 12), file_unique_id="ADAVKJYIFV" 288 | ), 289 | ) 290 | result = self.loop.run_until_complete( 291 | async_get_media_meta(message, message.photo, "photo") 292 | ) 293 | self.assertEqual( 294 | ( 295 | platform_generic_path("/root/project/test2/2019_08/2 - ADAVKJYIFV.jpg"), 296 | "jpg", 297 | ), 298 | result, 299 | ) 300 | 301 | message = MockMessage( 302 | id=2, 303 | media=True, 304 | date=datetime(2019, 8, 5, 14, 35, 12), 305 | chat_title="test2", 306 | media_group_id="AAA213213", 307 | caption="#home #book", 308 | photo=MockPhoto( 309 | date=datetime(2019, 8, 5, 14, 35, 12), file_unique_id="ADAVKJYIFV" 310 | ), 311 | ) 312 | result = self.loop.run_until_complete( 313 | async_get_media_meta(message, message.photo, "photo") 314 | ) 315 | self.assertEqual( 316 | ( 317 | platform_generic_path( 318 | "/root/project/test2/2019_08/2 - #home #book - ADAVKJYIFV.jpg" 319 | ), 320 | "jpg", 321 | ), 322 | result, 323 | ) 324 | 325 | # Test Documents 326 | message = MockMessage( 327 | id=3, 328 | media=True, 329 | chat_title="test2", 330 | document=MockDocument( 331 | file_name="sample_document.pdf", 332 | mime_type="application/pdf", 333 | ), 334 | ) 335 | result = self.loop.run_until_complete( 336 | async_get_media_meta(message, message.document, "document") 337 | ) 338 | self.assertEqual( 339 | ( 340 | platform_generic_path("/root/project/test2/0/3 - sample_document.pdf"), 341 | "pdf", 342 | ), 343 | result, 344 | ) 345 | 346 | before_file_name_prefix_split = app.file_name_prefix_split 347 | app.file_name_prefix_split = "-" 348 | 349 | message = MockMessage( 350 | id=3, 351 | media=True, 352 | chat_title="test2", 353 | media_group_id="BBB213213", 354 | caption="#work", 355 | document=MockDocument( 356 | file_name="sample_document.pdf", 357 | mime_type="application/pdf", 358 | ), 359 | ) 360 | result = self.loop.run_until_complete( 361 | async_get_media_meta(message, message.document, "document") 362 | ) 363 | self.assertEqual( 364 | ( 365 | platform_generic_path( 366 | "/root/project/test2/0/3-#work-sample_document.pdf" 367 | ), 368 | "pdf", 369 | ), 370 | result, 371 | ) 372 | 373 | app.file_name_prefix_split = before_file_name_prefix_split 374 | # Test audio 375 | message = MockMessage( 376 | id=4, 377 | media=True, 378 | date=datetime(2021, 8, 5, 14, 35, 12), 379 | chat_title="test2", 380 | audio=MockAudio( 381 | file_name="sample_audio.mp3", 382 | mime_type="audio/mp3", 383 | ), 384 | ) 385 | result = self.loop.run_until_complete( 386 | async_get_media_meta(message, message.audio, "audio") 387 | ) 388 | self.assertEqual( 389 | ( 390 | platform_generic_path( 391 | "/root/project/test2/2021_08/4 - sample_audio.mp3" 392 | ), 393 | "mp3", 394 | ), 395 | result, 396 | ) 397 | 398 | # Test Video 1 399 | message = MockMessage( 400 | id=5, 401 | media=True, 402 | date=datetime(2022, 8, 5, 14, 35, 12), 403 | chat_title="test2", 404 | video=MockVideo( 405 | mime_type="video/mp4", 406 | ), 407 | ) 408 | result = self.loop.run_until_complete( 409 | async_get_media_meta(message, message.video, "video") 410 | ) 411 | self.assertEqual( 412 | ( 413 | platform_generic_path("/root/project/test2/2022_08/5.mp4"), 414 | "mp4", 415 | ), 416 | result, 417 | ) 418 | 419 | # Test Video 2 420 | message = MockMessage( 421 | id=5, 422 | media=True, 423 | date=datetime(2022, 8, 5, 14, 35, 12), 424 | chat_title="test2", 425 | video=MockVideo( 426 | file_name="test.mp4", 427 | mime_type="video/mp4", 428 | ), 429 | ) 430 | result = self.loop.run_until_complete( 431 | async_get_media_meta(message, message.video, "video") 432 | ) 433 | self.assertEqual( 434 | ( 435 | platform_generic_path("/root/project/test2/2022_08/5 - test.mp4"), 436 | "mp4", 437 | ), 438 | result, 439 | ) 440 | 441 | # Test Video 3: not exist chat_title 442 | message = MockMessage( 443 | id=5, 444 | media=True, 445 | dis_chat=True, 446 | date=datetime(2022, 8, 5, 14, 35, 12), 447 | video=MockVideo( 448 | file_name="test.mp4", 449 | mime_type="video/mp4", 450 | ), 451 | ) 452 | result = self.loop.run_until_complete( 453 | async_get_media_meta(message, message.video, "video") 454 | ) 455 | 456 | print(app.chat_id) 457 | self.assertEqual( 458 | ( 459 | platform_generic_path("/root/project/8654123/2022_08/5 - test.mp4"), 460 | "mp4", 461 | ), 462 | result, 463 | ) 464 | 465 | # Test VideoNote 466 | message = MockMessage( 467 | id=6, 468 | media=True, 469 | date=datetime(2019, 7, 25, 14, 53, 50), 470 | chat_title="test2", 471 | video_note=MockVideoNote( 472 | mime_type="video/mp4", 473 | date=datetime(2019, 7, 25, 14, 53, 50), 474 | ), 475 | ) 476 | result = self.loop.run_until_complete( 477 | async_get_media_meta(message, message.video_note, "video_note") 478 | ) 479 | self.assertEqual( 480 | ( 481 | platform_generic_path( 482 | "/root/project/test2/2019_07/6 - video_note_2019-07-25T14:53:50.mp4" 483 | ), 484 | "mp4", 485 | ), 486 | result, 487 | ) 488 | 489 | @mock.patch("media_downloader.app.save_path", new=MOCK_DIR) 490 | @mock.patch("media_downloader.asyncio.sleep", return_value=None) 491 | @mock.patch("media_downloader.logger") 492 | @mock.patch("media_downloader.RETRY_TIME_OUT", new=1) 493 | @mock.patch("media_downloader._is_exist", new=is_exist) 494 | def test_download_media(self, mock_logger, patched_time_sleep): 495 | 496 | client = MockClient() 497 | message = MockMessage( 498 | id=5, 499 | media=True, 500 | video=MockVideo( 501 | file_name="sample_video.mp4", 502 | mime_type="video/mp4", 503 | ), 504 | ) 505 | result = self.loop.run_until_complete( 506 | async_download_media( 507 | client, message, ["video", "photo"], {"video": ["mp4"]} 508 | ) 509 | ) 510 | self.assertEqual(5, result) 511 | 512 | message_1 = MockMessage( 513 | id=6, 514 | media=True, 515 | video=MockVideo( 516 | file_name="sample_video.mov", 517 | mime_type="video/mov", 518 | ), 519 | ) 520 | result = self.loop.run_until_complete( 521 | async_download_media( 522 | client, message_1, ["video", "photo"], {"video": ["all"]} 523 | ) 524 | ) 525 | self.assertEqual(6, result) 526 | 527 | # Test re-fetch message success 528 | message_2 = MockMessage( 529 | id=7, 530 | media=True, 531 | video=MockVideo( 532 | file_name="sample_video.mov", 533 | mime_type="video/mov", 534 | ), 535 | ) 536 | result = self.loop.run_until_complete( 537 | async_download_media( 538 | client, message_2, ["video", "photo"], {"video": ["all"]} 539 | ) 540 | ) 541 | self.assertEqual(7, result) 542 | mock_logger.warning.assert_called_with( 543 | "Message[{}]: file reference expired, refetching...", 7 544 | ) 545 | 546 | # Test re-fetch message failure 547 | message_3 = MockMessage( 548 | id=8, 549 | media=True, 550 | video=MockVideo( 551 | file_name="sample_video.mov", 552 | mime_type="video/mov", 553 | ), 554 | ) 555 | result = self.loop.run_until_complete( 556 | async_download_media( 557 | client, message_3, ["video", "photo"], {"video": ["all"]} 558 | ) 559 | ) 560 | self.assertEqual(8, result) 561 | mock_logger.error.assert_called_with( 562 | "Message[{}]: file reference expired for 3 retries, download skipped.", 563 | 8, 564 | ) 565 | 566 | # Test other exception 567 | message_4 = MockMessage( 568 | id=9, 569 | media=True, 570 | video=MockVideo( 571 | file_name="sample_video.mov", 572 | mime_type="video/mov", 573 | ), 574 | ) 575 | result = self.loop.run_until_complete( 576 | async_download_media( 577 | client, message_4, ["video", "photo"], {"video": ["all"]} 578 | ) 579 | ) 580 | self.assertEqual(9, result) 581 | 582 | # Check no media 583 | message_5 = MockMessage( 584 | id=10, 585 | media=None, 586 | ) 587 | result = self.loop.run_until_complete( 588 | async_download_media( 589 | client, message_5, ["video", "photo"], {"video": ["all"]} 590 | ) 591 | ) 592 | self.assertEqual(10, result) 593 | 594 | # Test timeout 595 | message_6 = MockMessage( 596 | id=11, 597 | media=True, 598 | video=MockVideo( 599 | file_name="sample_video.mov", 600 | mime_type="video/mov", 601 | ), 602 | ) 603 | result = self.loop.run_until_complete( 604 | async_download_media( 605 | client, message_6, ["video", "photo"], {"video": ["all"]} 606 | ) 607 | ) 608 | self.assertEqual(11, result) 609 | mock_logger.error.assert_called_with( 610 | "Message[{}]: Timing out after 3 reties, download skipped.", 11 611 | ) 612 | 613 | # Test FloodWait 420 614 | message_7 = MockMessage( 615 | id=420, 616 | media=True, 617 | video=MockVideo( 618 | file_name="sample_video.mov", 619 | mime_type="video/mov", 620 | ), 621 | ) 622 | result = self.loop.run_until_complete( 623 | async_download_media( 624 | client, message_7, ["video", "photo"], {"video": ["all"]} 625 | ) 626 | ) 627 | self.assertEqual(420, result) 628 | mock_logger.warning.assert_called_with("Message[{}]: FlowWait {}", 420, 420) 629 | self.assertEqual(app.failed_ids.count(420), 1) 630 | 631 | # Test other Exception 632 | message_8 = MockMessage( 633 | id=421, 634 | media=True, 635 | video=MockVideo( 636 | file_name="sample_video.mov", 637 | mime_type="video/mov", 638 | ), 639 | ) 640 | result = self.loop.run_until_complete( 641 | async_download_media( 642 | client, message_8, ["video", "photo"], {"video": ["all"]} 643 | ) 644 | ) 645 | self.assertEqual(421, result) 646 | self.assertEqual(app.failed_ids.count(421), 1) 647 | 648 | # Test other Exception 649 | message_9 = MockMessage( 650 | id=422, 651 | media=True, 652 | video=MockVideo( 653 | file_name="422 - exception.mov", 654 | mime_type="video/mov", 655 | ), 656 | ) 657 | result = self.loop.run_until_complete( 658 | async_download_media( 659 | client, message_9, ["video", "photo"], {"video": ["all"]} 660 | ) 661 | ) 662 | self.assertEqual(422, result) 663 | self.assertEqual(app.failed_ids.count(422), 1) 664 | 665 | @mock.patch("media_downloader.pyrogram.Client", new=MockClient) 666 | @mock.patch("media_downloader.process_messages", new=mock_process_message) 667 | def test_begin_import(self): 668 | rest_app(MOCK_CONF) 669 | self.loop.run_until_complete(async_begin_import(1)) 670 | self.assertEqual(5, app.last_read_message_id) 671 | 672 | def test_process_message(self): 673 | client = MockClient() 674 | result = self.loop.run_until_complete( 675 | async_process_messages( 676 | client, 677 | [ 678 | MockMessage( 679 | id=1213, 680 | media=True, 681 | voice=MockVoice( 682 | mime_type="audio/ogg", 683 | date=datetime(2019, 7, 25, 14, 53, 50), 684 | ), 685 | ), 686 | MockMessage( 687 | id=1214, 688 | media=False, 689 | text="test message 1", 690 | ), 691 | MockMessage( 692 | id=1215, 693 | media=False, 694 | text="test message 2", 695 | ), 696 | MockMessage( 697 | id=1216, 698 | media=False, 699 | text="test message 3", 700 | ), 701 | ], 702 | ["voice", "photo"], 703 | {"audio": ["all"], "voice": ["all"]}, 704 | ) 705 | ) 706 | self.assertEqual(result, 1216) 707 | 708 | def test_can_download(self): 709 | file_formats = { 710 | "audio": ["mp3"], 711 | "video": ["mp4"], 712 | "document": ["all"], 713 | } 714 | result = _can_download("audio", file_formats, "mp3") 715 | self.assertEqual(result, True) 716 | 717 | result1 = _can_download("audio", file_formats, "ogg") 718 | self.assertEqual(result1, False) 719 | 720 | result2 = _can_download("document", file_formats, "pdf") 721 | self.assertEqual(result2, True) 722 | 723 | result3 = _can_download("document", file_formats, "epub") 724 | self.assertEqual(result3, True) 725 | 726 | def test_is_exist(self): 727 | this_dir = os.path.dirname(os.path.abspath(__file__)) 728 | result = _is_exist(os.path.join(this_dir, "__init__.py")) 729 | self.assertEqual(result, True) 730 | 731 | result1 = _is_exist(os.path.join(this_dir, "init.py")) 732 | self.assertEqual(result1, False) 733 | 734 | result2 = _is_exist(this_dir) 735 | self.assertEqual(result2, False) 736 | 737 | @mock.patch("media_downloader.RETRY_TIME_OUT", new=1) 738 | @mock.patch("media_downloader.os.path.getsize", new=os_get_file_size) 739 | @mock.patch("media_downloader.os.remove", new=os_remove) 740 | @mock.patch("media_downloader._is_exist", new=is_exist) 741 | def test_issues_311(self): 742 | # see https://github.com/Dineshkarthik/telegram_media_downloader/issues/311 743 | rest_app(MOCK_CONF) 744 | 745 | client = MockClient() 746 | # 1. test `TimeOutError` 747 | message = MockMessage( 748 | id=311, 749 | media=True, 750 | video=MockVideo( 751 | file_name="failed_down.mp4", 752 | mime_type="video/mp4", 753 | file_size=1024, 754 | ), 755 | ) 756 | 757 | media_size = getattr(message.video, "file_size") 758 | self.assertEqual(media_size, 1024) 759 | 760 | self.loop.run_until_complete( 761 | async_download_media( 762 | client, message, ["video", "photo"], {"video": ["mp4"]} 763 | ) 764 | ) 765 | self.assertEqual(app.failed_ids, [311]) 766 | app.update_config(False) 767 | 768 | self.assertEqual(app.ids_to_retry, [1, 2, 311]) 769 | 770 | # 2. test sucess download 771 | rest_app(MOCK_CONF) 772 | message = MockMessage( 773 | id=311, 774 | media=True, 775 | video=MockVideo( 776 | file_name="sucess_down.mp4", 777 | mime_type="video/mp4", 778 | file_size=1024, 779 | ), 780 | ) 781 | 782 | self.loop.run_until_complete( 783 | async_download_media( 784 | client, message, ["video", "photo"], {"video": ["mp4"]} 785 | ) 786 | ) 787 | 788 | self.assertEqual(app.failed_ids, []) 789 | 790 | app.update_config(False) 791 | 792 | self.assertEqual(app.total_download_task, 1) 793 | self.assertEqual(app.ids_to_retry, [1, 2]) 794 | 795 | rest_app(MOCK_CONF) 796 | # 3. test already download 797 | message = MockMessage( 798 | id=311, 799 | media=True, 800 | video=MockVideo( 801 | file_name="sucess_exist_down.mp4", 802 | mime_type="video/mp4", 803 | file_size=1024, 804 | ), 805 | ) 806 | 807 | self.loop.run_until_complete( 808 | async_download_media( 809 | client, message, ["video", "photo"], {"video": ["mp4"]} 810 | ) 811 | ) 812 | 813 | self.assertEqual(app.failed_ids, []) 814 | 815 | app.update_config(False) 816 | 817 | self.assertEqual(app.total_download_task, 0) 818 | self.assertEqual(app.ids_to_retry, [1, 2]) 819 | 820 | @mock.patch("media_downloader.check_for_updates", new=raise_keyboard_interrupt) 821 | @mock.patch("media_downloader.pyrogram.Client", new=MockClient) 822 | @mock.patch("media_downloader.process_messages", new=mock_process_message) 823 | @mock.patch("media_downloader.RETRY_TIME_OUT", new=1) 824 | @mock.patch("media_downloader.begin_import", new=async_begin_import) 825 | def test_keyboard_interrupt(self): 826 | rest_app(MOCK_CONF) 827 | app.failed_ids.append(3) 828 | app.failed_ids.append(4) 829 | 830 | try: 831 | main() 832 | except: 833 | pass 834 | 835 | self.assertEqual(app.ids_to_retry, [1, 2, 3, 4]) 836 | 837 | @mock.patch("media_downloader.check_for_updates", new=raise_exception) 838 | @mock.patch("media_downloader.pyrogram.Client", new=MockClient) 839 | @mock.patch("media_downloader.process_messages", new=mock_process_message) 840 | @mock.patch("media_downloader.RETRY_TIME_OUT", new=1) 841 | @mock.patch("media_downloader.begin_import", new=async_begin_import) 842 | def test_other_exception(self): 843 | rest_app(MOCK_CONF) 844 | app.failed_ids.append(3) 845 | app.failed_ids.append(4) 846 | 847 | try: 848 | exec_main() 849 | except: 850 | pass 851 | 852 | self.assertEqual(app.ids_to_retry, [1, 2, 3, 4]) 853 | 854 | @classmethod 855 | def tearDownClass(cls): 856 | cls.loop.close() 857 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangyoha/telegram_media_downloader_bak/bb5a9ccebcf76313b3a9c25d050f3be9a5f17dad/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/test_file_management.py: -------------------------------------------------------------------------------- 1 | """Unittest module for media downloader.""" 2 | import os 3 | import sys 4 | import tempfile 5 | import unittest 6 | from pathlib import Path 7 | 8 | import mock 9 | 10 | sys.path.append("..") # Adds higher directory to python modules path. 11 | from utils.file_management import get_next_name, manage_duplicate_file 12 | 13 | 14 | class FileManagementTestCase(unittest.TestCase): 15 | def setUp(self): 16 | self.this_dir = os.path.dirname(os.path.abspath(__file__)) 17 | self.test_file = os.path.join(self.this_dir, "file-test.txt") 18 | self.test_file_copy_1 = os.path.join(self.this_dir, "file-test-copy1.txt") 19 | self.test_file_copy_2 = os.path.join(self.this_dir, "file-test-copy2.txt") 20 | f = open(self.test_file, "w+") 21 | f.write("dummy file") 22 | f.close() 23 | Path(self.test_file_copy_1).touch() 24 | Path(self.test_file_copy_2).touch() 25 | 26 | def test_get_next_name(self): 27 | result = get_next_name(self.test_file) 28 | excepted_result = os.path.join(self.this_dir, "file-test-copy3.txt") 29 | self.assertEqual(result, excepted_result) 30 | 31 | def test_manage_duplicate_file(self): 32 | result = manage_duplicate_file(self.test_file_copy_2) 33 | self.assertEqual(result, self.test_file_copy_1) 34 | 35 | result1 = manage_duplicate_file(self.test_file_copy_1) 36 | self.assertEqual(result1, self.test_file_copy_1) 37 | 38 | def tearDown(self): 39 | os.remove(self.test_file) 40 | os.remove(self.test_file_copy_1) 41 | -------------------------------------------------------------------------------- /tests/utils/test_filter.py: -------------------------------------------------------------------------------- 1 | """Unittest module for media downloader.""" 2 | import sys 3 | import unittest 4 | from datetime import datetime 5 | 6 | from module.filter import Filter, MetaData 7 | from tests.test_common import ( 8 | Chat, 9 | Date, 10 | MockAudio, 11 | MockDocument, 12 | MockMessage, 13 | MockPhoto, 14 | MockVideo, 15 | MockVideoNote, 16 | MockVoice, 17 | ) 18 | from utils.format import replace_date_time 19 | 20 | sys.path.append("..") # Adds higher directory to python modules path. 21 | 22 | 23 | def filter_exec(download_filter: Filter, filter_str: str) -> bool: 24 | filter_str = replace_date_time(filter_str) 25 | return download_filter.exec(filter_str) 26 | 27 | 28 | class FilterTestCase(unittest.TestCase): 29 | def test_string_filter(self): 30 | download_filter = Filter() 31 | self.assertRaises(ValueError, filter_exec, download_filter, "213") 32 | 33 | meta = MetaData() 34 | 35 | message = MockMessage( 36 | id=5, 37 | media=True, 38 | date=datetime(2022, 8, 5, 14, 35, 12), 39 | chat_title="test2", 40 | caption="", 41 | video=MockVideo( 42 | mime_type="video/mp4", 43 | file_size=1024 * 1024 * 10, 44 | file_name="test.mp4", 45 | width=1920, 46 | height=1080, 47 | duration=35, 48 | ), 49 | ) 50 | 51 | meta.get_meta_data(message) 52 | 53 | self.assertEqual(meta.message_id, 5) 54 | self.assertEqual(meta.message_date, datetime(2022, 8, 5, 14, 35, 12)) 55 | self.assertEqual(meta.message_caption, "") 56 | self.assertEqual(meta.media_file_size, 1024 * 1024 * 10) 57 | self.assertEqual(meta.media_width, 1920) 58 | self.assertEqual(meta.media_height, 1080) 59 | self.assertEqual(meta.media_file_name, "test.mp4") 60 | self.assertEqual(meta.media_duration, 35) 61 | 62 | download_filter.set_meta_data(meta) 63 | 64 | self.assertEqual(filter_exec(download_filter, "media_file_size == 1"), False) 65 | self.assertEqual(filter_exec(download_filter, "media_file_size > 1024"), True) 66 | 67 | # str 68 | self.assertEqual( 69 | filter_exec(download_filter, "media_file_name == 'test.mp4'"), True 70 | ) 71 | self.assertEqual( 72 | filter_exec(download_filter, "media_file_name == 'test2.mp4'"), False 73 | ) 74 | # re str 75 | self.assertEqual( 76 | filter_exec(download_filter, "media_file_name == r'test.*mp4'"), True 77 | ) 78 | self.assertEqual( 79 | filter_exec(download_filter, "media_file_name == r'test2.*mp4'"), False 80 | ) 81 | self.assertEqual( 82 | filter_exec(download_filter, "media_file_name != r'test2.*mp4'"), True 83 | ) 84 | self.assertEqual( 85 | filter_exec(download_filter, "media_file_name != r'test2.*mp4'"), True 86 | ) 87 | 88 | # int 89 | self.assertEqual(filter_exec(download_filter, "media_duration > 60"), False) 90 | self.assertEqual(filter_exec(download_filter, "media_duration <= 60"), True) 91 | self.assertEqual( 92 | filter_exec( 93 | download_filter, "media_width >= 1920 and media_height >= 1080" 94 | ), 95 | True, 96 | ) 97 | self.assertEqual( 98 | filter_exec(download_filter, "media_width >= 2560 && media_height >= 1440"), 99 | False, 100 | ) 101 | self.assertEqual( 102 | filter_exec( 103 | download_filter, 104 | "media_width >= 2560 && media_height >= 1440 or media_file_name == 'test.mp4'", 105 | ), 106 | True, 107 | ) 108 | 109 | # datetime 110 | # 2020.03 111 | self.assertEqual( 112 | filter_exec( 113 | download_filter, "message_date >= 2022.03 and message_date <= 2022.08" 114 | ), 115 | False, 116 | ) 117 | self.assertEqual( 118 | filter_exec( 119 | download_filter, "message_date >= 2022.03 and message_date <= 2022.09" 120 | ), 121 | True, 122 | ) 123 | 124 | # 2020.03.04 125 | self.assertEqual( 126 | filter_exec( 127 | download_filter, 128 | "message_date >= 2022.03.04 and message_date <= 2022.03.08", 129 | ), 130 | False, 131 | ) 132 | self.assertEqual( 133 | filter_exec( 134 | download_filter, 135 | "message_date >= 2022.03.04 and message_date <= 2022.08.06", 136 | ), 137 | True, 138 | ) 139 | 140 | # 2020.03.04 14:50 141 | self.assertEqual( 142 | filter_exec( 143 | download_filter, 144 | "message_date >= 2022.03.04 14:50 and message_date <= 2022.03.08", 145 | ), 146 | False, 147 | ) 148 | self.assertEqual( 149 | filter_exec( 150 | download_filter, 151 | "message_date >= 2022.03.04 and message_date <= 2022.08.05 14:36", 152 | ), 153 | True, 154 | ) 155 | 156 | # 2020.03.04 14:50:15 157 | self.assertEqual( 158 | filter_exec( 159 | download_filter, 160 | "message_date >= 2022.03.04 14:50:15 and message_date <= 2022.03.08", 161 | ), 162 | False, 163 | ) 164 | self.assertEqual( 165 | filter_exec( 166 | download_filter, 167 | "message_date >= 2022.03.04 14:50:15 and message_date <= 2022.08.05 14:35:12", 168 | ), 169 | True, 170 | ) 171 | 172 | # test not exist value 173 | self.assertEqual( 174 | filter_exec( 175 | download_filter, 176 | "message_date >= 2022.03.04 && message_date <= 2022.08.06 && not_exist == True", 177 | ), 178 | True, 179 | ) 180 | -------------------------------------------------------------------------------- /tests/utils/test_format.py: -------------------------------------------------------------------------------- 1 | """Unittest module for media downloader.""" 2 | import sys 3 | import unittest 4 | 5 | from utils.format import format_byte, replace_date_time 6 | 7 | sys.path.append("..") # Adds higher directory to python modules path. 8 | 9 | 10 | class FormatTestCase(unittest.TestCase): 11 | def test_format_byte(self): 12 | byte_list = [ 13 | "KB", 14 | "MB", 15 | "GB", 16 | "TB", 17 | "PB", 18 | "EB", 19 | "ZB", 20 | "YB", 21 | "BB", 22 | "NB", 23 | "DB", 24 | "CB", 25 | ] 26 | 27 | self.assertEqual(format_byte(0.1), "0.8b") 28 | self.assertEqual(format_byte(1), "1B") 29 | 30 | for i, value in enumerate(byte_list): 31 | self.assertEqual(format_byte(pow(1024, i + 1)), "1.0" + value) 32 | 33 | try: 34 | format_byte(-1) 35 | except Exception as e: 36 | self.assertEqual(isinstance(e, ValueError), True) 37 | 38 | def test_replace_date_time(self): 39 | # split by '.' 40 | self.assertEqual( 41 | replace_date_time("xxxxx 2020.03.08 xxxxxxxxx"), 42 | "xxxxx 2020-03-08 00:00:00 xxxxxxxxx", 43 | ) 44 | 45 | # split by '-' 46 | self.assertEqual( 47 | replace_date_time("xxxxx 2020-03-08 xxxxxxxxxxxx"), 48 | "xxxxx 2020-03-08 00:00:00 xxxxxxxxxxxx", 49 | ) 50 | 51 | # split by '/' 52 | self.assertEqual( 53 | replace_date_time("xasd as 2020/03/08 21321fszv"), 54 | "xasd as 2020-03-08 00:00:00 21321fszv", 55 | ) 56 | 57 | # more different date 58 | self.assertEqual( 59 | replace_date_time("xxxxx 2020.03.08 2020.03.09 14:51 xxxxxxxxx"), 60 | "xxxxx 2020-03-08 00:00:00 2020-03-09 14:51:00 xxxxxxxxx", 61 | ) 62 | 63 | # more space 64 | self.assertEqual( 65 | replace_date_time("xxxxx 2020.03.08 2020.03.09 14:51 xxxxxxxxx"), 66 | "xxxxx 2020-03-08 00:00:00 2020-03-09 14:51:00 xxxxxxxxx", 67 | ) 68 | 69 | # more date format 70 | self.assertEqual( 71 | replace_date_time("xasd as 2020/03 21321fszv"), 72 | "xasd as 2020-03-01 00:00:00 21321fszv", 73 | ) 74 | self.assertEqual( 75 | replace_date_time("xasd as 2020-03 21321fszv"), 76 | "xasd as 2020-03-01 00:00:00 21321fszv", 77 | ) 78 | self.assertEqual( 79 | replace_date_time("xasd as 2020.03 21321fszv"), 80 | "xasd as 2020-03-01 00:00:00 21321fszv", 81 | ) 82 | -------------------------------------------------------------------------------- /tests/utils/test_log.py: -------------------------------------------------------------------------------- 1 | """Unittest module for log handlers.""" 2 | import os 3 | import sys 4 | import unittest 5 | 6 | import mock 7 | 8 | sys.path.append("..") # Adds higher directory to python modules path. 9 | from utils.log import LogFilter 10 | 11 | 12 | class MockLog: 13 | """ 14 | Mock logs. 15 | """ 16 | 17 | def __init__(self, **kwargs): 18 | self.funcName = kwargs["funcName"] 19 | 20 | 21 | class MetaTestCase(unittest.TestCase): 22 | def test_log_filter(self): 23 | result = LogFilter().filter(MockLog(funcName="invoke")) 24 | self.assertEqual(result, False) 25 | 26 | result1 = LogFilter().filter(MockLog(funcName="get_file")) 27 | self.assertEqual(result1, True) 28 | 29 | result2 = LogFilter().filter(MockLog(funcName="Synced")) 30 | self.assertEqual(result2, True) 31 | -------------------------------------------------------------------------------- /tests/utils/test_meta.py: -------------------------------------------------------------------------------- 1 | """Unittest module for media downloader.""" 2 | import os 3 | import sys 4 | import unittest 5 | 6 | import mock 7 | 8 | sys.path.append("..") # Adds higher directory to python modules path. 9 | from utils.meta import print_meta 10 | 11 | 12 | class MetaTestCase(unittest.TestCase): 13 | @mock.patch("utils.meta.APP_VERSION", "test-version 1.0.0") 14 | @mock.patch("utils.meta.DEVICE_MODEL", "CPython X.X.X") 15 | @mock.patch("utils.meta.SYSTEM_VERSION", "System xx.x.xx") 16 | @mock.patch("media_downloader.logger") 17 | def test_print_meta(self, mock_logger): 18 | print_meta(mock_logger) 19 | calls = [ 20 | mock.call.info("Device: CPython X.X.X - test-version 1.0.0"), 21 | mock.call.info("System: System xx.x.xx (EN)"), 22 | ] 23 | mock_logger.assert_has_calls(calls, any_order=True) 24 | -------------------------------------------------------------------------------- /tests/utils/test_updates.py: -------------------------------------------------------------------------------- 1 | """Unittest module for update checker.""" 2 | import os 3 | import sys 4 | import unittest 5 | 6 | import mock 7 | from rich.markdown import Markdown 8 | 9 | sys.path.append("..") # Adds higher directory to python modules path. 10 | from utils.updates import check_for_updates 11 | 12 | 13 | class FakeHTTPSConnection: 14 | def __init__(self, status): 15 | self.status = status 16 | 17 | def request(self, *args, **kwargs): 18 | pass 19 | 20 | def getresponse(self): 21 | return FakeHTTPSResponse(self.status) 22 | 23 | 24 | class FakeHTTPSResponse: 25 | def __init__(self, status): 26 | self.status = status 27 | 28 | def read(self): 29 | if self.status == 200: 30 | return b'{"name":"v0.0.0 2022-03-02","tag_name":"v0.0.0", "html_url":"https://github.com/tangyoha/telegram_media_downloader/releases/tag/v0.0.0"}' 31 | else: 32 | return b"{error}" 33 | 34 | 35 | class UpdatesTestCase(unittest.TestCase): 36 | @mock.patch( 37 | "utils.updates.http.client.HTTPSConnection", 38 | new=mock.MagicMock(return_value=FakeHTTPSConnection(200)), 39 | ) 40 | @mock.patch("utils.updates.__version__", new="0.0.1") 41 | @mock.patch("utils.updates.Console") 42 | @mock.patch("utils.updates.Markdown") 43 | def test_update(self, mock_markdown, mock_console): 44 | check_for_updates() 45 | name: str = "v0.0.0 2022-03-02" 46 | html_url: str = ( 47 | "https://github.com/tangyoha/telegram_media_downloader/releases/tag/v0.0.0" 48 | ) 49 | expected_message: str = ( 50 | f"## New version of Telegram-Media-Downloader is available - {name}\n" 51 | "You are using an outdated version v0.0.1 please pull in the changes using `git pull` or download the latest release.\n\n" 52 | f"Find more details about the latest release here - {html_url}" 53 | ) 54 | mock_markdown.assert_called_with(expected_message) 55 | mock_console.return_value.print.assert_called_once() 56 | 57 | @mock.patch( 58 | "utils.updates.http.client.HTTPSConnection", 59 | new=mock.MagicMock(return_value=FakeHTTPSConnection(500)), 60 | ) 61 | @mock.patch("utils.updates.Console") 62 | def test_exception(self, mock_console): 63 | check_for_updates() 64 | exception_message: str = ( 65 | "Following error occurred when checking for updates\n" 66 | ", Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" 67 | ) 68 | mock_console.return_value.log.assert_called_with(exception_message) 69 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Init namespace""" 2 | 3 | __version__ = "2.1.2" 4 | __license__ = "MIT License" 5 | __copyright__ = "Copyright (C) 2022 tangyoha " 6 | -------------------------------------------------------------------------------- /utils/file_management.py: -------------------------------------------------------------------------------- 1 | """Utility functions to handle downloaded files.""" 2 | import glob 3 | import os 4 | import pathlib 5 | from hashlib import md5 6 | 7 | 8 | def get_next_name(file_path: str) -> str: 9 | """ 10 | Get next available name to download file. 11 | 12 | Parameters 13 | ---------- 14 | file_path: str 15 | Absolute path of the file for which next available name to 16 | be generated. 17 | 18 | Returns 19 | ------- 20 | str 21 | Absolute path of the next available name for the file. 22 | """ 23 | posix_path = pathlib.Path(file_path) 24 | counter: int = 1 25 | new_file_name: str = os.path.join("{0}", "{1}-copy{2}{3}") 26 | while os.path.isfile( 27 | new_file_name.format( 28 | posix_path.parent, 29 | posix_path.stem, 30 | counter, 31 | "".join(posix_path.suffixes), 32 | ) 33 | ): 34 | counter += 1 35 | return new_file_name.format( 36 | posix_path.parent, 37 | posix_path.stem, 38 | counter, 39 | "".join(posix_path.suffixes), 40 | ) 41 | 42 | 43 | def manage_duplicate_file(file_path: str): 44 | """ 45 | Check if a file is duplicate. 46 | 47 | Compare the md5 of files with copy name pattern 48 | and remove if the md5 hash is same. 49 | 50 | Parameters 51 | ---------- 52 | file_path: str 53 | Absolute path of the file for which duplicates needs to 54 | be managed. 55 | 56 | Returns 57 | ------- 58 | str 59 | Absolute path of the duplicate managed file. 60 | """ 61 | # pylint: disable = R1732 62 | posix_path = pathlib.Path(file_path) 63 | file_base_name: str = "".join(posix_path.stem.split("-copy")[0]) 64 | name_pattern: str = f"{posix_path.parent}/{file_base_name}*" 65 | # Reason for using `str.translate()` 66 | # https://stackoverflow.com/q/22055500/6730439 67 | old_files: list = glob.glob( 68 | name_pattern.translate({ord("["): "[[]", ord("]"): "[]]"}) 69 | ) 70 | if file_path in old_files: 71 | old_files.remove(file_path) 72 | current_file_md5: str = md5(open(file_path, "rb").read()).hexdigest() 73 | for old_file_path in old_files: 74 | old_file_md5: str = md5(open(old_file_path, "rb").read()).hexdigest() 75 | if current_file_md5 == old_file_md5: 76 | os.remove(file_path) 77 | return old_file_path 78 | return file_path 79 | -------------------------------------------------------------------------------- /utils/format.py: -------------------------------------------------------------------------------- 1 | """util format""" 2 | 3 | import math 4 | import re 5 | from datetime import datetime 6 | 7 | 8 | def format_byte(size: float, dot=2): 9 | """format byte""" 10 | 11 | # pylint: disable = R0912 12 | if 0 <= size < 1: 13 | human_size = str(round(size / 0.125, dot)) + "b" 14 | elif 1 <= size < 1024: 15 | human_size = str(round(size, dot)) + "B" 16 | elif math.pow(1024, 1) <= size < math.pow(1024, 2): 17 | human_size = str(round(size / math.pow(1024, 1), dot)) + "KB" 18 | elif math.pow(1024, 2) <= size < math.pow(1024, 3): 19 | human_size = str(round(size / math.pow(1024, 2), dot)) + "MB" 20 | elif math.pow(1024, 3) <= size < math.pow(1024, 4): 21 | human_size = str(round(size / math.pow(1024, 3), dot)) + "GB" 22 | elif math.pow(1024, 4) <= size < math.pow(1024, 5): 23 | human_size = str(round(size / math.pow(1024, 4), dot)) + "TB" 24 | elif math.pow(1024, 5) <= size < math.pow(1024, 6): 25 | human_size = str(round(size / math.pow(1024, 5), dot)) + "PB" 26 | elif math.pow(1024, 6) <= size < math.pow(1024, 7): 27 | human_size = str(round(size / math.pow(1024, 6), dot)) + "EB" 28 | elif math.pow(1024, 7) <= size < math.pow(1024, 8): 29 | human_size = str(round(size / math.pow(1024, 7), dot)) + "ZB" 30 | elif math.pow(1024, 8) <= size < math.pow(1024, 9): 31 | human_size = str(round(size / math.pow(1024, 8), dot)) + "YB" 32 | elif math.pow(1024, 9) <= size < math.pow(1024, 10): 33 | human_size = str(round(size / math.pow(1024, 9), dot)) + "BB" 34 | elif math.pow(1024, 10) <= size < math.pow(1024, 11): 35 | human_size = str(round(size / math.pow(1024, 10), dot)) + "NB" 36 | elif math.pow(1024, 11) <= size < math.pow(1024, 12): 37 | human_size = str(round(size / math.pow(1024, 11), dot)) + "DB" 38 | elif math.pow(1024, 12) <= size: 39 | human_size = str(round(size / math.pow(1024, 12), dot)) + "CB" 40 | else: 41 | raise ValueError( 42 | f'format_byte() takes number than or equal to 0, " \ 43 | " but less than 0 given. {size}' 44 | ) 45 | return human_size 46 | 47 | 48 | class SearchDateTimeResult: 49 | """search result for datetime""" 50 | 51 | def __init__( 52 | self, 53 | value: str = "", 54 | right_str: str = "", 55 | left_str: str = "", 56 | match: bool = False, 57 | ): 58 | self.value = value 59 | self.right_str = right_str 60 | self.left_str = left_str 61 | self.match = match 62 | 63 | 64 | def get_date_time(text: str, fmt: str) -> SearchDateTimeResult: 65 | """Get first of date time,and split two part 66 | 67 | Parameters 68 | ---------- 69 | text: str 70 | ready to search text 71 | 72 | Returns 73 | ------- 74 | SearchDateTimeResult 75 | 76 | """ 77 | res = SearchDateTimeResult() 78 | search_text = re.sub(r"\s+", " ", text) 79 | regex_list = [ 80 | # 2013.8.15 22:46:21 81 | r"\d{4}[-/\.]{1}\d{1,2}[-/\.]{1}\d{1,2}[ ]{1,}\d{1,2}:\d{1,2}:\d{1,2}", 82 | # "2013.8.15 22:46" 83 | r"\d{4}[-/\.]{1}\d{1,2}[-/\.]{1}\d{1,2}[ ]{1,}\d{1,2}:\d{1,2}", 84 | # "2014.5.11" 85 | r"\d{4}[-/\.]{1}\d{1,2}[-/\.]{1}\d{1,2}", 86 | # "2014.5" 87 | r"\d{4}[-/\.]{1}\d{1,2}", 88 | ] 89 | 90 | format_list = [ 91 | "%Y-%m-%d %H:%M:%S", 92 | "%Y-%m-%d %H:%M", 93 | "%Y-%m-%d", 94 | "%Y-%m", 95 | ] 96 | 97 | for i, value in enumerate(regex_list): 98 | search_res = re.search(value, search_text) 99 | if search_res: 100 | time_str = search_res.group(0) 101 | res.value = datetime.strptime( 102 | time_str.replace("/", "-").replace(".", "-").strip(), format_list[i] 103 | ).strftime(fmt) 104 | if search_res.start() != 0: 105 | res.left_str = search_text[0 : search_res.start()] 106 | if search_res.end() + 1 <= len(search_text): 107 | res.right_str = search_text[search_res.end() :] 108 | res.match = True 109 | return res 110 | 111 | return res 112 | 113 | 114 | def replace_date_time(text: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: 115 | """Replace text all datetime to the right fmt 116 | 117 | Parameters 118 | ---------- 119 | text: str 120 | ready to search text 121 | 122 | fmt: str 123 | the right datetime format 124 | Returns 125 | ------- 126 | str 127 | The right format datetime str 128 | 129 | """ 130 | res_str = "" 131 | res = get_date_time(text, fmt) 132 | if not res.match: 133 | return text 134 | if res.left_str: 135 | res_str += replace_date_time(res.left_str) 136 | res_str += res.value 137 | if res.right_str: 138 | res_str += replace_date_time(res.right_str) 139 | 140 | return res_str 141 | -------------------------------------------------------------------------------- /utils/log.py: -------------------------------------------------------------------------------- 1 | """Util module to handle logs.""" 2 | import logging 3 | 4 | 5 | class LogFilter(logging.Filter): 6 | """ 7 | Custom Log Filter. 8 | 9 | Ignore logs from specific functions. 10 | """ 11 | 12 | # pylint: disable = W0221 13 | def filter(self, record): 14 | if record.funcName in ("invoke"): 15 | return False 16 | return True 17 | -------------------------------------------------------------------------------- /utils/meta.py: -------------------------------------------------------------------------------- 1 | """Utility module to manage meta info.""" 2 | import platform 3 | 4 | from rich.console import Console 5 | 6 | from . import __copyright__, __license__, __version__ 7 | 8 | APP_VERSION = f"Telegram Media Downloader {__version__}" 9 | DEVICE_MODEL = f"{platform.python_implementation()} {platform.python_version()}" 10 | SYSTEM_VERSION = f"{platform.system()} {platform.release()}" 11 | LANG_CODE = "en" 12 | 13 | 14 | def print_meta(logger): 15 | """Prints meta-data of the downloader script.""" 16 | console = Console() 17 | # pylint: disable = C0301 18 | console.log( 19 | f"[bold]Telegram Media Downloader v{__version__}[/bold],\n[i]{__copyright__}[/i]" 20 | ) 21 | console.log(f"Licensed under the terms of the {__license__}", end="\n\n") 22 | logger.info(f"Device: {DEVICE_MODEL} - {APP_VERSION}") 23 | logger.info(f"System: {SYSTEM_VERSION} ({LANG_CODE.upper()})") 24 | -------------------------------------------------------------------------------- /utils/meta_data.py: -------------------------------------------------------------------------------- 1 | """Meta data for download filter""" 2 | 3 | 4 | class ReString: 5 | """for re match""" 6 | 7 | def __init__(self, re_string: str): 8 | self.re_string = re_string 9 | 10 | 11 | class NoneObj: 12 | """for None obj to match""" 13 | 14 | def __init__(self): 15 | pass 16 | 17 | 18 | class MetaData: 19 | """ 20 | * `message_date` : - Date the message was sent 21 | * like: message_date > 2022.03.04 && message_date < 2022.03.08 22 | * `message_id` : - Message 's id 23 | * `media_file_size` : - File size 24 | * `media_width` : - Include photo and video 25 | * `media_height` : - Include photo and video 26 | * `media_file_name` : - file name 27 | * `message_caption` : - message_caption 28 | * `message_duration` : - message_duration 29 | """ 30 | 31 | AVAILABLE_MEDIA = ( 32 | "audio", 33 | "document", 34 | "photo", 35 | "sticker", 36 | "animation", 37 | "video", 38 | "voice", 39 | "video_note", 40 | "new_chat_photo", 41 | ) 42 | 43 | def __init__( 44 | self, 45 | message_date: int = None, 46 | message_id: int = None, 47 | message_caption: str = None, 48 | media_file_size: int = None, 49 | media_width: int = None, 50 | media_height: int = None, 51 | media_file_name: str = None, 52 | media_duration: int = None, 53 | ): 54 | self.message_date = message_date 55 | self.message_id = message_id 56 | self.message_caption = message_caption 57 | self.media_file_size = media_file_size 58 | self.media_width = media_width 59 | self.media_height = media_height 60 | self.media_file_name = media_file_name 61 | self.media_duration = media_duration 62 | 63 | def data(self) -> dict: 64 | """Meta map""" 65 | return { 66 | "message_date": self.message_date, 67 | "message_id": self.message_id, 68 | "message_caption": self.message_caption, 69 | "media_file_size": self.media_file_size, 70 | "media_width": self.media_width, 71 | "media_height": self.media_height, 72 | "media_file_name": self.media_file_name, 73 | "media_duration": self.media_duration, 74 | } 75 | 76 | def get_meta_data(self, meta_obj): 77 | """Get all meta data""" 78 | # message 79 | self.message_date = getattr(meta_obj, "date", None) 80 | 81 | self.message_caption = getattr(meta_obj, "caption", None) 82 | self.message_id = getattr(meta_obj, "id", None) 83 | 84 | for kind in self.AVAILABLE_MEDIA: 85 | media_obj = getattr(meta_obj, kind, None) 86 | 87 | if media_obj is not None: 88 | break 89 | else: 90 | return 91 | 92 | self.media_file_name = getattr(media_obj, "file_name", None) 93 | self.media_file_size = getattr(media_obj, "file_size", None) 94 | self.media_width = getattr(media_obj, "width", None) 95 | self.media_height = getattr(media_obj, "height", None) 96 | self.media_duration = getattr(media_obj, "duration", None) 97 | -------------------------------------------------------------------------------- /utils/platform.py: -------------------------------------------------------------------------------- 1 | """for package download""" 2 | 3 | import platform 4 | 5 | # def get_platform() -> str: 6 | # """Get platform title 7 | # Returns 8 | # ------- 9 | # str 10 | # window amd64 return "windows-amd64" 11 | # """ 12 | # sys_platform = platform.system().lower() 13 | # platform_str: str = sys_platform 14 | # if "macos" in sys_platform: 15 | # platform_str = "osx" 16 | 17 | # machine = platform.machine().lower() 18 | 19 | # if "i386" in machine: 20 | # platform_str += "-386" 21 | # else: 22 | # platform_str += "-" + machine 23 | 24 | # return platform_str 25 | 26 | 27 | def get_exe_ext() -> str: 28 | """Get exe ext 29 | Returns 30 | str 31 | if in window then return "exe" other return "" 32 | """ 33 | if "windows" in platform.system().lower(): 34 | return ".exe" 35 | return "" 36 | -------------------------------------------------------------------------------- /utils/updates.py: -------------------------------------------------------------------------------- 1 | """Utility module to check for new release of telegram-media-downloader""" 2 | import http.client 3 | import json 4 | 5 | from rich.console import Console 6 | from rich.markdown import Markdown 7 | 8 | from . import __version__ 9 | 10 | 11 | # pylint: disable = C0301 12 | def check_for_updates() -> None: 13 | """Checks for new releases. 14 | 15 | Using Github API checks for new release and prints information of new release if available. 16 | """ 17 | console = Console() 18 | try: 19 | headers: dict = { 20 | "Content-Type": "application/json", 21 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36", 22 | } 23 | conn = http.client.HTTPSConnection("api.github.com") 24 | conn.request( 25 | method="GET", 26 | url="/repos/tangyoha/telegram_media_downloader/releases/latest", 27 | headers=headers, 28 | ) 29 | res = conn.getresponse() 30 | latest_release: dict = json.loads(res.read().decode("utf-8")) 31 | if f"{__version__}" != latest_release["tag_name"]: 32 | update_message: str = ( 33 | f"## New version of Telegram-Media-Downloader is available - {latest_release['name']}\n" 34 | f"You are using an outdated version v{__version__} please pull in the changes using `git pull` or download the latest release.\n\n" 35 | f"Find more details about the latest release here - {latest_release['html_url']}" 36 | ) 37 | console.print(Markdown(update_message)) 38 | except Exception as e: 39 | console.log( 40 | f"Following error occurred when checking for updates\n{e.__class__}, {e}" 41 | ) 42 | --------------------------------------------------------------------------------