tools-extra 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tools_extra-0.1.0/PKG-INFO +158 -0
- tools_extra-0.1.0/README.md +141 -0
- tools_extra-0.1.0/pyproject.toml +37 -0
- tools_extra-0.1.0/setup.cfg +4 -0
- tools_extra-0.1.0/src/tg_uploader.py +354 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/PKG-INFO +158 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/SOURCES.txt +14 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/dependency_links.txt +1 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/entry_points.txt +4 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/requires.txt +6 -0
- tools_extra-0.1.0/src/tools_extra.egg-info/top_level.txt +3 -0
- tools_extra-0.1.0/src/toolsx/__init__.py +3 -0
- tools_extra-0.1.0/src/toolsx/__main__.py +5 -0
- tools_extra-0.1.0/src/toolsx/cli.py +63 -0
- tools_extra-0.1.0/src/toolsx/registry.py +37 -0
- tools_extra-0.1.0/src/ytm_dl.py +1442 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tools_extra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI toolbox with YouTube Music downloading and Telegram uploading tools.
|
|
5
|
+
Author: Zaid
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zaid/toolsx
|
|
8
|
+
Project-URL: Repository, https://github.com/zaid/toolsx
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: kurigram~=2.2.19
|
|
12
|
+
Requires-Dist: mutagen~=1.47.0
|
|
13
|
+
Requires-Dist: requests~=2.33.0
|
|
14
|
+
Requires-Dist: rich~=14.3.3
|
|
15
|
+
Requires-Dist: yt-dlp[default]~=2026.3.17
|
|
16
|
+
Requires-Dist: ytmusicapi~=1.11.5
|
|
17
|
+
|
|
18
|
+
# toolsx
|
|
19
|
+
|
|
20
|
+
`toolsx` packages a small CLI toolbox with two ready-to-use commands:
|
|
21
|
+
|
|
22
|
+
- `ytm-dl` - download a single YouTube Music song or a full playlist as tagged MP3 files.
|
|
23
|
+
- `tg-uploader` - upload a file to Telegram with a bot session.
|
|
24
|
+
- `toolsx` - list the installed tools and dispatch to a tool by name.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install tools_extra
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The package is published as `tools_extra`, but the installed CLI commands stay `toolsx`, `ytm-dl`, and `tg-uploader`.
|
|
33
|
+
|
|
34
|
+
From the repo:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m pip install .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
Running `toolsx` prints the available tools:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
toolsx
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
You can also dispatch through the umbrella command:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
toolsx ytm-dl --help
|
|
52
|
+
toolsx tg-uploader --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Direct commands stay available too:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
ytm-dl --help
|
|
59
|
+
tg-uploader --help
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## ytm-dl
|
|
63
|
+
|
|
64
|
+
### Public song or playlist
|
|
65
|
+
|
|
66
|
+
No `browser.json` file is required for public URLs or IDs.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
ytm-dl --url "https://music.youtube.com/playlist?list=PL..."
|
|
70
|
+
ytm-dl --url "https://music.youtube.com/watch?v=VIDEO_ID"
|
|
71
|
+
ytm-dl --id VIDEO_ID
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Private playlists or library access
|
|
75
|
+
|
|
76
|
+
For private playlists, liked songs, or library selection, create `browser.json` first with the ytmusicapi browser auth guide:
|
|
77
|
+
|
|
78
|
+
- https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html
|
|
79
|
+
|
|
80
|
+
Then run:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ytm-dl --auth-file browser.json --library-index 1
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Useful flags
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
ytm-dl \
|
|
90
|
+
--url "https://music.youtube.com/playlist?list=PL..." \
|
|
91
|
+
--output-dir ./exports \
|
|
92
|
+
--yes-all \
|
|
93
|
+
--songs-limit 25 \
|
|
94
|
+
--lyrics-metadata \
|
|
95
|
+
--zip \
|
|
96
|
+
--zip-max-size 2000000000
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `--cookies-file` uses a `cookies.txt` file instead of browser cookie extraction.
|
|
100
|
+
- `--browser` and `--browser-profile` use cookies directly from a browser when needed.
|
|
101
|
+
- `--yes-all` skips the first-song confirmation and downloads the whole playlist immediately.
|
|
102
|
+
- `--output-dir` sets the base export directory; by default files go to `./[Album-Name]`.
|
|
103
|
+
- `--songs-limit` defaults to all songs when omitted.
|
|
104
|
+
- `--lyrics-metadata` fetches lyrics with timestamps and saves them into MP3 metadata.
|
|
105
|
+
- `--keep-original-audio` skips MP3 conversion/tagging and keeps the downloaded source audio extension.
|
|
106
|
+
- `--mp3-bitrate` controls MP3 conversion bitrate when MP3 conversion is enabled.
|
|
107
|
+
- `--debug` enables verbose logs for lyrics, thumbnails, metadata, and full yt-dlp output.
|
|
108
|
+
- `--zip-max-size` splits archives into `.partNN.zip` files once the source bytes per archive reach the limit.
|
|
109
|
+
|
|
110
|
+
If required input is missing in interactive mode, `ytm-dl` asks for it.
|
|
111
|
+
|
|
112
|
+
## tg-uploader
|
|
113
|
+
|
|
114
|
+
`tg-uploader` accepts values from CLI args first, then environment variables, then prompts.
|
|
115
|
+
|
|
116
|
+
Supported environment variables:
|
|
117
|
+
|
|
118
|
+
- `TOOLSX_TG_API_ID`, `TG_API_ID`, `TELEGRAM_API_ID`
|
|
119
|
+
- `TOOLSX_TG_API_HASH`, `TG_API_HASH`, `TELEGRAM_API_HASH`
|
|
120
|
+
- `TOOLSX_TG_BOT_TOKEN`, `TG_BOT_TOKEN`, `TELEGRAM_BOT_TOKEN`
|
|
121
|
+
- `TOOLSX_TG_CHAT_ID`, `TG_CHAT_ID`, `TELEGRAM_CHAT_ID`
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
export TOOLSX_TG_API_ID=12345
|
|
127
|
+
export TOOLSX_TG_API_HASH=your_api_hash
|
|
128
|
+
export TOOLSX_TG_BOT_TOKEN=123:token
|
|
129
|
+
export TOOLSX_TG_CHAT_ID=-1001234567890
|
|
130
|
+
|
|
131
|
+
tg-uploader --file ./archive.zip --caption "Nightly build"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Debug mode:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
tg-uploader --file ./archive.zip --debug
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Local development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/z44d/toolsx
|
|
144
|
+
cd toolsx
|
|
145
|
+
python -m pip install -e .
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Adding more tools
|
|
149
|
+
|
|
150
|
+
1. Add the new module under `src/`.
|
|
151
|
+
2. Register it in `src/toolsx/registry.py` so `toolsx` lists it.
|
|
152
|
+
3. Add one console script entry under `[project.scripts]` in `pyproject.toml`.
|
|
153
|
+
|
|
154
|
+
## Release workflow
|
|
155
|
+
|
|
156
|
+
The GitHub workflow at `.github/workflows/publish.yml` builds and publishes to PyPI on version tags like `v0.1.0`.
|
|
157
|
+
|
|
158
|
+
Before using it, configure PyPI trusted publishing for the repository or provide the required PyPI credentials in GitHub.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# toolsx
|
|
2
|
+
|
|
3
|
+
`toolsx` packages a small CLI toolbox with two ready-to-use commands:
|
|
4
|
+
|
|
5
|
+
- `ytm-dl` - download a single YouTube Music song or a full playlist as tagged MP3 files.
|
|
6
|
+
- `tg-uploader` - upload a file to Telegram with a bot session.
|
|
7
|
+
- `toolsx` - list the installed tools and dispatch to a tool by name.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install tools_extra
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package is published as `tools_extra`, but the installed CLI commands stay `toolsx`, `ytm-dl`, and `tg-uploader`.
|
|
16
|
+
|
|
17
|
+
From the repo:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
Running `toolsx` prints the available tools:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
toolsx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You can also dispatch through the umbrella command:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
toolsx ytm-dl --help
|
|
35
|
+
toolsx tg-uploader --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Direct commands stay available too:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ytm-dl --help
|
|
42
|
+
tg-uploader --help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## ytm-dl
|
|
46
|
+
|
|
47
|
+
### Public song or playlist
|
|
48
|
+
|
|
49
|
+
No `browser.json` file is required for public URLs or IDs.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ytm-dl --url "https://music.youtube.com/playlist?list=PL..."
|
|
53
|
+
ytm-dl --url "https://music.youtube.com/watch?v=VIDEO_ID"
|
|
54
|
+
ytm-dl --id VIDEO_ID
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Private playlists or library access
|
|
58
|
+
|
|
59
|
+
For private playlists, liked songs, or library selection, create `browser.json` first with the ytmusicapi browser auth guide:
|
|
60
|
+
|
|
61
|
+
- https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html
|
|
62
|
+
|
|
63
|
+
Then run:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ytm-dl --auth-file browser.json --library-index 1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Useful flags
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
ytm-dl \
|
|
73
|
+
--url "https://music.youtube.com/playlist?list=PL..." \
|
|
74
|
+
--output-dir ./exports \
|
|
75
|
+
--yes-all \
|
|
76
|
+
--songs-limit 25 \
|
|
77
|
+
--lyrics-metadata \
|
|
78
|
+
--zip \
|
|
79
|
+
--zip-max-size 2000000000
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `--cookies-file` uses a `cookies.txt` file instead of browser cookie extraction.
|
|
83
|
+
- `--browser` and `--browser-profile` use cookies directly from a browser when needed.
|
|
84
|
+
- `--yes-all` skips the first-song confirmation and downloads the whole playlist immediately.
|
|
85
|
+
- `--output-dir` sets the base export directory; by default files go to `./[Album-Name]`.
|
|
86
|
+
- `--songs-limit` defaults to all songs when omitted.
|
|
87
|
+
- `--lyrics-metadata` fetches lyrics with timestamps and saves them into MP3 metadata.
|
|
88
|
+
- `--keep-original-audio` skips MP3 conversion/tagging and keeps the downloaded source audio extension.
|
|
89
|
+
- `--mp3-bitrate` controls MP3 conversion bitrate when MP3 conversion is enabled.
|
|
90
|
+
- `--debug` enables verbose logs for lyrics, thumbnails, metadata, and full yt-dlp output.
|
|
91
|
+
- `--zip-max-size` splits archives into `.partNN.zip` files once the source bytes per archive reach the limit.
|
|
92
|
+
|
|
93
|
+
If required input is missing in interactive mode, `ytm-dl` asks for it.
|
|
94
|
+
|
|
95
|
+
## tg-uploader
|
|
96
|
+
|
|
97
|
+
`tg-uploader` accepts values from CLI args first, then environment variables, then prompts.
|
|
98
|
+
|
|
99
|
+
Supported environment variables:
|
|
100
|
+
|
|
101
|
+
- `TOOLSX_TG_API_ID`, `TG_API_ID`, `TELEGRAM_API_ID`
|
|
102
|
+
- `TOOLSX_TG_API_HASH`, `TG_API_HASH`, `TELEGRAM_API_HASH`
|
|
103
|
+
- `TOOLSX_TG_BOT_TOKEN`, `TG_BOT_TOKEN`, `TELEGRAM_BOT_TOKEN`
|
|
104
|
+
- `TOOLSX_TG_CHAT_ID`, `TG_CHAT_ID`, `TELEGRAM_CHAT_ID`
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
export TOOLSX_TG_API_ID=12345
|
|
110
|
+
export TOOLSX_TG_API_HASH=your_api_hash
|
|
111
|
+
export TOOLSX_TG_BOT_TOKEN=123:token
|
|
112
|
+
export TOOLSX_TG_CHAT_ID=-1001234567890
|
|
113
|
+
|
|
114
|
+
tg-uploader --file ./archive.zip --caption "Nightly build"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Debug mode:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
tg-uploader --file ./archive.zip --debug
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Local development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
git clone https://github.com/z44d/toolsx
|
|
127
|
+
cd toolsx
|
|
128
|
+
python -m pip install -e .
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Adding more tools
|
|
132
|
+
|
|
133
|
+
1. Add the new module under `src/`.
|
|
134
|
+
2. Register it in `src/toolsx/registry.py` so `toolsx` lists it.
|
|
135
|
+
3. Add one console script entry under `[project.scripts]` in `pyproject.toml`.
|
|
136
|
+
|
|
137
|
+
## Release workflow
|
|
138
|
+
|
|
139
|
+
The GitHub workflow at `.github/workflows/publish.yml` builds and publishes to PyPI on version tags like `v0.1.0`.
|
|
140
|
+
|
|
141
|
+
Before using it, configure PyPI trusted publishing for the repository or provide the required PyPI credentials in GitHub.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tools_extra"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI toolbox with YouTube Music downloading and Telegram uploading tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Zaid" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"kurigram~=2.2.19",
|
|
15
|
+
"mutagen~=1.47.0",
|
|
16
|
+
"requests~=2.33.0",
|
|
17
|
+
"rich~=14.3.3",
|
|
18
|
+
"yt-dlp[default]~=2026.3.17",
|
|
19
|
+
"ytmusicapi~=1.11.5",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/zaid/toolsx"
|
|
24
|
+
Repository = "https://github.com/zaid/toolsx"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
toolsx = "toolsx.cli:main"
|
|
28
|
+
ytm-dl = "ytm_dl:main"
|
|
29
|
+
tg-uploader = "tg_uploader:sync_main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
package-dir = { "" = "src" }
|
|
33
|
+
py-modules = ["ytm_dl", "tg_uploader"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
include = ["toolsx*"]
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.progress import (
|
|
18
|
+
BarColumn,
|
|
19
|
+
DownloadColumn,
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
TaskProgressColumn,
|
|
23
|
+
TextColumn,
|
|
24
|
+
TimeElapsedColumn,
|
|
25
|
+
TimeRemainingColumn,
|
|
26
|
+
TransferSpeedColumn,
|
|
27
|
+
)
|
|
28
|
+
from rich.prompt import Prompt
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
ENV_KEYS = {
|
|
35
|
+
"api_id": ("TOOLSX_TG_API_ID", "TG_API_ID", "TELEGRAM_API_ID"),
|
|
36
|
+
"api_hash": ("TOOLSX_TG_API_HASH", "TG_API_HASH", "TELEGRAM_API_HASH"),
|
|
37
|
+
"bot_token": ("TOOLSX_TG_BOT_TOKEN", "TG_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"),
|
|
38
|
+
"chat_id": ("TOOLSX_TG_CHAT_ID", "TG_CHAT_ID", "TELEGRAM_CHAT_ID"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class UploadConfig:
|
|
44
|
+
api_id: int
|
|
45
|
+
api_hash: str
|
|
46
|
+
bot_token: str
|
|
47
|
+
chat_id: Union[int, str]
|
|
48
|
+
file_path: Path
|
|
49
|
+
caption: Optional[str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def debug_log(enabled: bool, message: str) -> None:
|
|
53
|
+
if enabled:
|
|
54
|
+
console.print(f"[dim][debug][/dim] {message}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_bytes(size: float) -> str:
|
|
58
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
59
|
+
value = float(size)
|
|
60
|
+
for unit in units:
|
|
61
|
+
if value < 1024 or unit == units[-1]:
|
|
62
|
+
if unit == "B":
|
|
63
|
+
return f"{int(value)} {unit}"
|
|
64
|
+
return f"{value:.2f} {unit}"
|
|
65
|
+
value /= 1024
|
|
66
|
+
return f"{value:.2f} TB"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_file_date(timestamp: float) -> str:
|
|
70
|
+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
description="Upload a file to Telegram using a bot session."
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("--api-id", help="Telegram API ID")
|
|
78
|
+
parser.add_argument("--api-hash", help="Telegram API hash")
|
|
79
|
+
parser.add_argument("--bot-token", help="Telegram bot token")
|
|
80
|
+
parser.add_argument("--chat-id", help="Target chat ID or username")
|
|
81
|
+
parser.add_argument("--file", dest="file_path", help="Path to the file to upload")
|
|
82
|
+
parser.add_argument("--caption", help="Optional document caption")
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--disable-color", action="store_true", help="Disable ANSI colors"
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--debug", action="store_true", help="Print verbose debug logs for every step"
|
|
88
|
+
)
|
|
89
|
+
return parser.parse_args(argv)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def env_value(*names: str) -> Optional[str]:
|
|
93
|
+
for name in names:
|
|
94
|
+
value = os.getenv(name)
|
|
95
|
+
if value and value.strip():
|
|
96
|
+
return value.strip()
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def prompt_value(
|
|
101
|
+
label: str, current: Optional[str], env_names: Sequence[str], secret: bool = False
|
|
102
|
+
) -> str:
|
|
103
|
+
if current and current.strip():
|
|
104
|
+
return current.strip()
|
|
105
|
+
from_env = env_value(*env_names)
|
|
106
|
+
if from_env:
|
|
107
|
+
return from_env
|
|
108
|
+
return Prompt.ask(label, password=secret).strip()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_pickable_files(directory: Path) -> list[Path]:
|
|
112
|
+
return sorted(
|
|
113
|
+
[
|
|
114
|
+
item
|
|
115
|
+
for item in directory.iterdir()
|
|
116
|
+
if item.is_file() and not item.name.startswith(".")
|
|
117
|
+
],
|
|
118
|
+
key=lambda item: (item.suffix.lower(), item.name.lower()),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def prompt_for_file_path(current: Optional[str]) -> str:
|
|
123
|
+
if current and current.strip():
|
|
124
|
+
return current.strip()
|
|
125
|
+
|
|
126
|
+
files = list_pickable_files(Path.cwd())
|
|
127
|
+
if not files:
|
|
128
|
+
raise FileNotFoundError("No files found in the current directory.")
|
|
129
|
+
|
|
130
|
+
table = Table(title="Choose a File", header_style="bold bright_cyan")
|
|
131
|
+
table.add_column("#", justify="right", style="bold yellow")
|
|
132
|
+
table.add_column("Name", style="bold white")
|
|
133
|
+
table.add_column("Type", style="green")
|
|
134
|
+
table.add_column("Size", justify="right", style="cyan")
|
|
135
|
+
table.add_column("Modified", style="dim")
|
|
136
|
+
|
|
137
|
+
for index, file_path in enumerate(files, start=1):
|
|
138
|
+
stat = file_path.stat()
|
|
139
|
+
table.add_row(
|
|
140
|
+
str(index),
|
|
141
|
+
file_path.name,
|
|
142
|
+
file_path.suffix.lstrip(".") or "file",
|
|
143
|
+
format_bytes(stat.st_size),
|
|
144
|
+
format_file_date(stat.st_mtime),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
console.print(table)
|
|
148
|
+
console.print("[dim]Pick a number or paste a custom file path.[/]")
|
|
149
|
+
|
|
150
|
+
while True:
|
|
151
|
+
choice = Prompt.ask("File path / number").strip()
|
|
152
|
+
if not choice:
|
|
153
|
+
console.print("[yellow]Please enter a file number or file path.[/]")
|
|
154
|
+
continue
|
|
155
|
+
if choice.isdigit():
|
|
156
|
+
index = int(choice)
|
|
157
|
+
if 1 <= index <= len(files):
|
|
158
|
+
return str(files[index - 1])
|
|
159
|
+
console.print("[yellow]Invalid file number.[/]")
|
|
160
|
+
continue
|
|
161
|
+
return choice
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_chat_id(value: str) -> Union[int, str]:
|
|
165
|
+
raw = value.strip()
|
|
166
|
+
if not raw:
|
|
167
|
+
raise ValueError("Chat ID is required.")
|
|
168
|
+
if raw.lstrip("-").isdigit():
|
|
169
|
+
return int(raw)
|
|
170
|
+
return raw
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def collect_config(args: argparse.Namespace) -> UploadConfig:
|
|
174
|
+
api_id_raw = prompt_value("API ID", args.api_id, ENV_KEYS["api_id"])
|
|
175
|
+
api_hash = prompt_value(
|
|
176
|
+
"API hash", args.api_hash, ENV_KEYS["api_hash"], secret=True
|
|
177
|
+
)
|
|
178
|
+
bot_token = prompt_value(
|
|
179
|
+
"Bot token", args.bot_token, ENV_KEYS["bot_token"], secret=True
|
|
180
|
+
)
|
|
181
|
+
chat_id_raw = prompt_value("Chat ID", args.chat_id, ENV_KEYS["chat_id"])
|
|
182
|
+
file_path_raw = prompt_for_file_path(args.file_path)
|
|
183
|
+
debug_log(
|
|
184
|
+
args.debug,
|
|
185
|
+
f"Collected config inputs: api_id={'set' if api_id_raw else 'missing'} api_hash={'set' if api_hash else 'missing'} bot_token={'set' if bot_token else 'missing'} chat_id={chat_id_raw!r} file={file_path_raw!r}",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not api_id_raw.isdigit():
|
|
189
|
+
raise ValueError("API ID must be numeric.")
|
|
190
|
+
|
|
191
|
+
file_path = Path(file_path_raw).expanduser().resolve()
|
|
192
|
+
if not file_path.exists() or not file_path.is_file():
|
|
193
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
194
|
+
|
|
195
|
+
return UploadConfig(
|
|
196
|
+
api_id=int(api_id_raw),
|
|
197
|
+
api_hash=api_hash,
|
|
198
|
+
bot_token=bot_token,
|
|
199
|
+
chat_id=parse_chat_id(chat_id_raw),
|
|
200
|
+
file_path=file_path,
|
|
201
|
+
caption=args.caption,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def print_banner() -> None:
|
|
206
|
+
title = Text("Telegram Uploader", style="bold bright_white")
|
|
207
|
+
subtitle = Text(
|
|
208
|
+
"Rich bot upload flow with CLI args, env vars, and prompts", style="cyan"
|
|
209
|
+
)
|
|
210
|
+
console.print(
|
|
211
|
+
Panel.fit(Text.assemble(title, "\n", subtitle), border_style="bright_blue")
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def print_upload_plan(config: UploadConfig) -> None:
|
|
216
|
+
file_size = config.file_path.stat().st_size
|
|
217
|
+
console.print(
|
|
218
|
+
Panel(
|
|
219
|
+
f"[cyan]Chat:[/] {config.chat_id}\n"
|
|
220
|
+
f"[green]File:[/] {config.file_path.name}\n"
|
|
221
|
+
f"[cyan]Size:[/] {format_bytes(file_size)}\n"
|
|
222
|
+
f"[green]Caption:[/] {config.caption or '-'}",
|
|
223
|
+
title="Upload Plan",
|
|
224
|
+
border_style="green",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def upload_file(config: UploadConfig, debug: bool) -> None:
|
|
230
|
+
Client = importlib.import_module("pyrogram").Client
|
|
231
|
+
file_size = config.file_path.stat().st_size
|
|
232
|
+
|
|
233
|
+
print_upload_plan(config)
|
|
234
|
+
console.print("[dim]Connecting to Telegram...[/]")
|
|
235
|
+
debug_log(
|
|
236
|
+
debug,
|
|
237
|
+
f"Preparing Telegram client for chat={config.chat_id!r} file={str(config.file_path)!r} size={file_size}",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
app = Client(
|
|
241
|
+
name="toolsx_tg_uploader",
|
|
242
|
+
api_id=config.api_id,
|
|
243
|
+
api_hash=config.api_hash,
|
|
244
|
+
bot_token=config.bot_token,
|
|
245
|
+
in_memory=True,
|
|
246
|
+
no_updates=True,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
with Progress(
|
|
250
|
+
SpinnerColumn(style="cyan"),
|
|
251
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
252
|
+
BarColumn(bar_width=36),
|
|
253
|
+
TaskProgressColumn(),
|
|
254
|
+
DownloadColumn(),
|
|
255
|
+
TransferSpeedColumn(),
|
|
256
|
+
TimeElapsedColumn(),
|
|
257
|
+
TimeRemainingColumn(),
|
|
258
|
+
console=console,
|
|
259
|
+
) as progress:
|
|
260
|
+
task_id = progress.add_task("Uploading file", total=max(file_size, 1))
|
|
261
|
+
|
|
262
|
+
def update_progress(current: int, total: int) -> None:
|
|
263
|
+
progress.update(task_id, total=max(total, 1), completed=current)
|
|
264
|
+
if debug:
|
|
265
|
+
console.print(
|
|
266
|
+
f"[dim]upload progress: {current}/{max(total, 1)} bytes[/]"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async with app:
|
|
270
|
+
debug_log(debug, "Opening Telegram session")
|
|
271
|
+
me = await app.get_me()
|
|
272
|
+
username = f"@{me.username}" if me.username else "none"
|
|
273
|
+
debug_log(debug, f"Authenticated as bot id={me.id} username={username}")
|
|
274
|
+
console.print(
|
|
275
|
+
Panel(
|
|
276
|
+
f"[cyan]Name:[/] {me.first_name}\n"
|
|
277
|
+
f"[green]Username:[/] {username}\n"
|
|
278
|
+
f"[cyan]Bot ID:[/] {me.id}",
|
|
279
|
+
title="Bot Session",
|
|
280
|
+
border_style="magenta",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
debug_log(debug, "Sending document to Telegram")
|
|
284
|
+
message = await app.send_document(
|
|
285
|
+
chat_id=config.chat_id,
|
|
286
|
+
document=str(config.file_path),
|
|
287
|
+
caption=config.caption,
|
|
288
|
+
force_document=True,
|
|
289
|
+
progress=update_progress,
|
|
290
|
+
)
|
|
291
|
+
debug_log(
|
|
292
|
+
debug,
|
|
293
|
+
f"Upload finished with message id={message.id if message else 'none'}",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if message is None:
|
|
297
|
+
raise RuntimeError("Upload stopped before completion.")
|
|
298
|
+
|
|
299
|
+
console.print(
|
|
300
|
+
Panel(
|
|
301
|
+
f"[green]Message ID:[/] {message.id}\n[cyan]Chat ID:[/] {message.chat.id}",
|
|
302
|
+
title="Upload Complete",
|
|
303
|
+
border_style="bright_green",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def async_main(argv: Optional[Sequence[str]] = None) -> int:
|
|
309
|
+
global console
|
|
310
|
+
args = parse_args(argv)
|
|
311
|
+
console = Console(no_color=args.disable_color)
|
|
312
|
+
print_banner()
|
|
313
|
+
debug_log(
|
|
314
|
+
args.debug,
|
|
315
|
+
f"Arguments parsed: file={args.file_path!r} chat_id={args.chat_id!r} disable_color={args.disable_color}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
config = collect_config(args)
|
|
320
|
+
await upload_file(config, args.debug)
|
|
321
|
+
return 0
|
|
322
|
+
except ModuleNotFoundError as error:
|
|
323
|
+
if error.name == "pyrogram":
|
|
324
|
+
console.print(
|
|
325
|
+
"[red]Pyrogram is not installed. Run `pip install -r requirements.txt`.[/]"
|
|
326
|
+
)
|
|
327
|
+
return 1
|
|
328
|
+
console.print(f"[red]Unexpected error:[/] {error}")
|
|
329
|
+
return 1
|
|
330
|
+
except (FileNotFoundError, ValueError) as error:
|
|
331
|
+
console.print(f"[red]{error}[/]")
|
|
332
|
+
return 1
|
|
333
|
+
except KeyboardInterrupt:
|
|
334
|
+
console.print("[yellow]Upload cancelled by user.[/]")
|
|
335
|
+
return 130
|
|
336
|
+
except Exception as error:
|
|
337
|
+
if error.__class__.__name__ == "FloodWait" and hasattr(error, "value"):
|
|
338
|
+
console.print(
|
|
339
|
+
f"[red]Telegram asked to wait {getattr(error, 'value')} seconds.[/]"
|
|
340
|
+
)
|
|
341
|
+
return 1
|
|
342
|
+
if error.__class__.__module__.startswith("pyrogram"):
|
|
343
|
+
console.print(f"[red]Telegram RPC error:[/] {error}")
|
|
344
|
+
return 1
|
|
345
|
+
console.print(f"[red]Unexpected error:[/] {error}")
|
|
346
|
+
return 1
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def sync_main(argv: Optional[Sequence[str]] = None) -> int:
|
|
350
|
+
return asyncio.run(async_main(argv))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
raise SystemExit(sync_main())
|