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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())