tlgr-cli 2.0.1__py3-none-any.whl
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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/media.py
ADDED
|
@@ -0,0 +1,4495 @@
|
|
|
1
|
+
"""The `media` group: bytes in, bytes out, and everything that describes them.
|
|
2
|
+
|
|
3
|
+
The group exists because a file is not a message. v1 had two commands —
|
|
4
|
+
`media download <chat> <id>` and `media upload <chat> <path>` — each a single
|
|
5
|
+
Telethon call with no resume, no progress, no album, no thumbnail, no size
|
|
6
|
+
check and no way to ask what a file *is* before fetching it. Everything here
|
|
7
|
+
is organised around three facts that only show up at scale:
|
|
8
|
+
|
|
9
|
+
* **a `file_reference` expires.** Every path that touches bytes re-fetches its
|
|
10
|
+
source first (`_media.fetch_message`), because the fix for
|
|
11
|
+
`FILE_REFERENCE_EXPIRED` is never a retry.
|
|
12
|
+
* **the server's limits are the server's.** `media limit get` reads
|
|
13
|
+
`help.getAppConfig`; `media upload` refuses an oversized file *before* the
|
|
14
|
+
first part goes out rather than after twenty minutes of upload.
|
|
15
|
+
* **a transfer is a thing with a lifetime.** `--background` hands it to the
|
|
16
|
+
daemon, `media transfer list` shows it, `media transfer stop` cancels it and
|
|
17
|
+
`media transfer retry` re-fetches the message before restarting. That is
|
|
18
|
+
what the GUI's Downloads panel is, and it is client-local state in both.
|
|
19
|
+
|
|
20
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Annotated, Any, Literal
|
|
31
|
+
|
|
32
|
+
from tlgr.core.errors import (
|
|
33
|
+
EXIT_EMPTY,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
NotSupportedError,
|
|
36
|
+
PermissionError_,
|
|
37
|
+
UsageError,
|
|
38
|
+
)
|
|
39
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
40
|
+
from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
|
|
41
|
+
from tlgr.models.base import Request
|
|
42
|
+
from tlgr.models.media import (
|
|
43
|
+
AutoDownloadPreset,
|
|
44
|
+
AutoDownloadSaved,
|
|
45
|
+
AutoDownloadSettings,
|
|
46
|
+
AutoSaveException,
|
|
47
|
+
AutoSaveRule,
|
|
48
|
+
AutoSaveSaved,
|
|
49
|
+
AutoSaveSettings,
|
|
50
|
+
ContentSettings,
|
|
51
|
+
ContentSettingsSaved,
|
|
52
|
+
Downloaded,
|
|
53
|
+
FileRef,
|
|
54
|
+
MediaEdited,
|
|
55
|
+
MediaEvent,
|
|
56
|
+
MediaExportResult,
|
|
57
|
+
MediaInfo,
|
|
58
|
+
MediaItem,
|
|
59
|
+
MediaLimits,
|
|
60
|
+
MediaQuality,
|
|
61
|
+
MediaRead,
|
|
62
|
+
MediaSize,
|
|
63
|
+
PaidItem,
|
|
64
|
+
PaidPost,
|
|
65
|
+
StorageCleared,
|
|
66
|
+
StorageUsage,
|
|
67
|
+
Transfer,
|
|
68
|
+
TransferRestarted,
|
|
69
|
+
TransferStopped,
|
|
70
|
+
Uploaded,
|
|
71
|
+
Wallpaper,
|
|
72
|
+
WallpaperInstalled,
|
|
73
|
+
WallpaperRemoved,
|
|
74
|
+
WallpaperSettings,
|
|
75
|
+
WallpaperUploaded,
|
|
76
|
+
)
|
|
77
|
+
from tlgr.models.page import Page
|
|
78
|
+
from tlgr.models.peer import PeerRef
|
|
79
|
+
from tlgr.ops import _media, _send
|
|
80
|
+
from tlgr.ops._params import arg, choice, opt
|
|
81
|
+
from tlgr.ops._serialize import entity_to_peer, message_entities
|
|
82
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
83
|
+
|
|
84
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
85
|
+
|
|
86
|
+
#: The naming pattern `--out-dir` fills in.
|
|
87
|
+
DEFAULT_TEMPLATE = "{date}_{id}_{name}"
|
|
88
|
+
|
|
89
|
+
_EXAMPLE_DOWNLOAD: dict[str, Any] = {
|
|
90
|
+
"msg_id": 12345,
|
|
91
|
+
"chat_id": 777123,
|
|
92
|
+
"path": "/home/u/.tlgr/downloads/alice/2026-09-03_12345_cat.jpg",
|
|
93
|
+
"bytes": 184320,
|
|
94
|
+
"kind": "photo",
|
|
95
|
+
"mime": "image/jpeg",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_EXAMPLE_ITEM: dict[str, Any] = {
|
|
99
|
+
"msg_id": 12345,
|
|
100
|
+
"chat_id": 777123,
|
|
101
|
+
"date": "2026-09-03T09:14:07Z",
|
|
102
|
+
"date_unix": 1788340447,
|
|
103
|
+
"kind": "photo",
|
|
104
|
+
"name": "cat.jpg",
|
|
105
|
+
"size": 184320,
|
|
106
|
+
"mime": "image/jpeg",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Shared plumbing
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _client(ctx: OpContext) -> Any:
|
|
116
|
+
return _media.client(ctx)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _downloads_root(ctx: OpContext) -> Path:
|
|
120
|
+
"""`~/.tlgr/downloads`, from the daemon's own paths rather than `~`.
|
|
121
|
+
|
|
122
|
+
Reading `HOME` here would make a test write into the developer's home
|
|
123
|
+
directory; the daemon knows where its base is and hands it over.
|
|
124
|
+
"""
|
|
125
|
+
paths = getattr(ctx, "paths", None)
|
|
126
|
+
if paths is not None and getattr(paths, "downloads", None) is not None:
|
|
127
|
+
return Path(paths.downloads)
|
|
128
|
+
from tlgr.core.config import get_downloads_dir
|
|
129
|
+
|
|
130
|
+
return Path(get_downloads_dir())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _safe_name(name: str) -> str:
|
|
134
|
+
"""A server-supplied file name that cannot escape the output directory.
|
|
135
|
+
|
|
136
|
+
`../../.ssh/authorized_keys` is a legal `DocumentAttributeFilename`, and
|
|
137
|
+
joining one onto `--out-dir` is how a download becomes an overwrite.
|
|
138
|
+
"""
|
|
139
|
+
cleaned = os.path.basename((name or "").replace("\\", "/")).strip()
|
|
140
|
+
cleaned = cleaned.lstrip(".") or "file"
|
|
141
|
+
return cleaned[:180]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fill_template(template: str, *, date: str, message_id: int, name: str) -> str:
|
|
145
|
+
try:
|
|
146
|
+
return _safe_name(template.format(date=date[:10] or "0000-00-00", id=message_id, name=name))
|
|
147
|
+
except (KeyError, IndexError) as exc:
|
|
148
|
+
raise UsageError(
|
|
149
|
+
f"--name-template: unknown placeholder {exc}; use {{date}}, {{id}}, {{name}}",
|
|
150
|
+
field="name_template",
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _unique(target: Path) -> Path:
|
|
155
|
+
"""`cat.jpg` → `cat (2).jpg` rather than an overwrite."""
|
|
156
|
+
if not target.exists():
|
|
157
|
+
return target
|
|
158
|
+
stem, suffix = target.stem, target.suffix
|
|
159
|
+
for index in range(2, 1000):
|
|
160
|
+
candidate = target.with_name(f"{stem} ({index}){suffix}")
|
|
161
|
+
if not candidate.exists():
|
|
162
|
+
return candidate
|
|
163
|
+
return target
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _size_arg(value: str | None, field: str) -> int | None:
|
|
167
|
+
"""`20M`, `512k`, `1073741824` → bytes."""
|
|
168
|
+
if value in (None, ""):
|
|
169
|
+
return None
|
|
170
|
+
text = str(value).strip().lower()
|
|
171
|
+
factor = 1
|
|
172
|
+
for suffix, scale in (("k", 1024), ("m", 1024**2), ("g", 1024**3)):
|
|
173
|
+
if text.endswith(suffix):
|
|
174
|
+
factor, text = scale, text[:-1]
|
|
175
|
+
break
|
|
176
|
+
try:
|
|
177
|
+
return int(float(text) * factor)
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
raise UsageError(
|
|
180
|
+
f"--{field.replace('_', '-')}: {value!r} is not a size", field=field
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _byte_range(value: str | None) -> tuple[int, int | None]:
|
|
185
|
+
"""`0-1M`, `5M-`, `1024-2047` → `(offset, limit)`."""
|
|
186
|
+
if not value:
|
|
187
|
+
return 0, None
|
|
188
|
+
head, _, tail = str(value).partition("-")
|
|
189
|
+
start = _size_arg(head or "0", "range") or 0
|
|
190
|
+
end = _size_arg(tail, "range") if tail else None
|
|
191
|
+
return start, (end - start + 1) if end is not None and end >= start else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _elapsed(started: float) -> float:
|
|
195
|
+
return round(time.monotonic() - started, 3)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_premium(ctx: OpContext) -> bool:
|
|
199
|
+
"""Whether this account is Premium, which doubles most upload limits."""
|
|
200
|
+
session = getattr(ctx, "session", None)
|
|
201
|
+
return bool(getattr(getattr(session, "me", None), "premium", False))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _transfers(ctx: OpContext) -> Any:
|
|
205
|
+
store = getattr(ctx, "transfers", None)
|
|
206
|
+
if store is None:
|
|
207
|
+
raise NotSupportedError(
|
|
208
|
+
"this build has no transfer store; transfers live in the daemon and "
|
|
209
|
+
"this operation was not reached through it"
|
|
210
|
+
)
|
|
211
|
+
return store
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _download_bytes(
|
|
215
|
+
ctx: OpContext,
|
|
216
|
+
location: Any,
|
|
217
|
+
target: Path,
|
|
218
|
+
*,
|
|
219
|
+
size: int = 0,
|
|
220
|
+
dc_id: int = 0,
|
|
221
|
+
offset: int = 0,
|
|
222
|
+
limit: int | None = None,
|
|
223
|
+
resume: bool = True,
|
|
224
|
+
part_size: int = 512 * 1024,
|
|
225
|
+
connections: int = 1,
|
|
226
|
+
refresh: Any = None,
|
|
227
|
+
progress: Any = None,
|
|
228
|
+
) -> Path:
|
|
229
|
+
"""The daemon's download pipeline, reached the way `upload_file` is.
|
|
230
|
+
|
|
231
|
+
`ops/` may not import `daemon/` (§2.2), so the pipeline is a service on
|
|
232
|
+
the context — which is also what makes an operation testable without a
|
|
233
|
+
socket.
|
|
234
|
+
"""
|
|
235
|
+
download = getattr(ctx, "download_file", None)
|
|
236
|
+
if download is None: # pragma: no cover - the daemon always supplies one
|
|
237
|
+
raise UsageError("this context cannot download files")
|
|
238
|
+
written: Any = await download(
|
|
239
|
+
location,
|
|
240
|
+
target,
|
|
241
|
+
size=size,
|
|
242
|
+
dc_id=dc_id,
|
|
243
|
+
offset=offset,
|
|
244
|
+
limit=limit,
|
|
245
|
+
resume=resume,
|
|
246
|
+
part_size=part_size,
|
|
247
|
+
connections=connections,
|
|
248
|
+
refresh=refresh,
|
|
249
|
+
progress=progress,
|
|
250
|
+
)
|
|
251
|
+
return Path(written)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _sha256(path: Path) -> str:
|
|
255
|
+
digest = hashlib.sha256()
|
|
256
|
+
with open(path, "rb") as handle:
|
|
257
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
258
|
+
digest.update(chunk)
|
|
259
|
+
return digest.hexdigest()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# media get
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class GetReq(Request):
|
|
268
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the media.")]
|
|
269
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
270
|
+
sizes: Annotated[bool, opt("--sizes", help="List every size/thumbnail variant.")] = False
|
|
271
|
+
qualities: Annotated[
|
|
272
|
+
bool, opt("--qualities", help="List the alt_documents video transcodes.")
|
|
273
|
+
] = False
|
|
274
|
+
stickers: Annotated[
|
|
275
|
+
bool, opt("--stickers", help="List sticker sets baked into the photo/video.")
|
|
276
|
+
] = False
|
|
277
|
+
ads: Annotated[
|
|
278
|
+
bool, opt("--ads", help="List sponsored inserts (listed only, never viewed).")
|
|
279
|
+
] = False
|
|
280
|
+
refresh: Annotated[
|
|
281
|
+
bool, opt("--refresh", help="Re-fetch the message so ids and paid state are current.")
|
|
282
|
+
] = True
|
|
283
|
+
album: Annotated[bool, opt("--album", help="Report every sibling sharing grouped_id.")] = False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _media_info(message: Any, *, chat_id: int, sizes: bool, qualities: bool) -> MediaInfo:
|
|
287
|
+
"""The one function that turns a message's media into `MediaInfo`."""
|
|
288
|
+
media = getattr(message, "media", None)
|
|
289
|
+
document = _media.document_of(media)
|
|
290
|
+
photo = _media.photo_of(media) if document is None else None
|
|
291
|
+
facts = _media.attributes_of(document) if document is not None else {}
|
|
292
|
+
date, date_unix = _media.message_dates(message)
|
|
293
|
+
|
|
294
|
+
from tlgr.ops._serialize import media_summary
|
|
295
|
+
|
|
296
|
+
summary = media_summary(media)
|
|
297
|
+
info = MediaInfo(
|
|
298
|
+
chat_id=chat_id,
|
|
299
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
300
|
+
kind=summary.kind if summary is not None else "unsupported",
|
|
301
|
+
tl_type=type(media).__name__ if media is not None else "",
|
|
302
|
+
grouped_id=getattr(message, "grouped_id", None),
|
|
303
|
+
mime=getattr(document, "mime_type", None),
|
|
304
|
+
size=getattr(document, "size", None),
|
|
305
|
+
file_name=facts.get("file_name"),
|
|
306
|
+
width=facts.get("width"),
|
|
307
|
+
height=facts.get("height"),
|
|
308
|
+
duration=int(facts["duration"]) if facts.get("duration") is not None else None,
|
|
309
|
+
supports_streaming=bool(facts.get("supports_streaming")),
|
|
310
|
+
nosound=bool(facts.get("nosound")),
|
|
311
|
+
round=bool(facts.get("round")),
|
|
312
|
+
voice=bool(facts.get("voice")),
|
|
313
|
+
waveform=bool(facts.get("waveform")),
|
|
314
|
+
title=facts.get("title"),
|
|
315
|
+
performer=facts.get("performer"),
|
|
316
|
+
sticker=facts.get("sticker"),
|
|
317
|
+
custom_emoji=facts.get("custom_emoji"),
|
|
318
|
+
spoiler=bool(getattr(media, "spoiler", False)),
|
|
319
|
+
ttl_seconds=getattr(media, "ttl_seconds", None),
|
|
320
|
+
video_cover=getattr(getattr(media, "video_cover", None), "id", None),
|
|
321
|
+
video_timestamp=getattr(media, "video_timestamp", None),
|
|
322
|
+
has_stickers=bool(getattr(document, "has_stickers", False))
|
|
323
|
+
or bool(getattr(photo, "has_stickers", False)),
|
|
324
|
+
protected=bool(getattr(message, "noforwards", False)),
|
|
325
|
+
paid=getattr(media, "stars_amount", None),
|
|
326
|
+
dc_id=getattr(document or photo, "dc_id", None),
|
|
327
|
+
doc_id=getattr(document or photo, "id", None),
|
|
328
|
+
access_hash=getattr(document or photo, "access_hash", None),
|
|
329
|
+
file_reference_b64=_media.b64(getattr(document or photo, "file_reference", None)),
|
|
330
|
+
file_id=_media.file_id_of(media),
|
|
331
|
+
date=date,
|
|
332
|
+
date_unix=date_unix,
|
|
333
|
+
caption=getattr(message, "message", "") or "",
|
|
334
|
+
entities=message_entities(message),
|
|
335
|
+
)
|
|
336
|
+
if sizes:
|
|
337
|
+
if photo is not None:
|
|
338
|
+
info.thumbs = _media.photo_sizes(photo)
|
|
339
|
+
else:
|
|
340
|
+
info.thumbs = [
|
|
341
|
+
MediaSize(
|
|
342
|
+
type=str(getattr(size, "type", "") or ""),
|
|
343
|
+
width=getattr(size, "w", None),
|
|
344
|
+
height=getattr(size, "h", None),
|
|
345
|
+
size=getattr(size, "size", None),
|
|
346
|
+
bytes_b64=_media.b64(getattr(size, "bytes", None)),
|
|
347
|
+
)
|
|
348
|
+
for size in (getattr(document, "thumbs", None) or [])
|
|
349
|
+
]
|
|
350
|
+
if qualities:
|
|
351
|
+
info.qualities = [
|
|
352
|
+
MediaQuality(
|
|
353
|
+
doc_id=int(getattr(alt, "id", 0) or 0),
|
|
354
|
+
mime=getattr(alt, "mime_type", None),
|
|
355
|
+
size=getattr(alt, "size", None),
|
|
356
|
+
width=_media.attributes_of(alt).get("width"),
|
|
357
|
+
height=_media.attributes_of(alt).get("height"),
|
|
358
|
+
name=_media.attributes_of(alt).get("file_name"),
|
|
359
|
+
)
|
|
360
|
+
for alt in (getattr(media, "alt_documents", None) or [])
|
|
361
|
+
]
|
|
362
|
+
return info
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def get(ctx: OpContext, req: GetReq) -> MediaInfo:
|
|
366
|
+
"""Everything the media declares, without downloading a byte."""
|
|
367
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
368
|
+
chat_id = _send.peer_id_of(peer)
|
|
369
|
+
message = await _media.fetch_message(ctx, peer, req.msg_id)
|
|
370
|
+
if getattr(message, "media", None) is None:
|
|
371
|
+
raise NotFoundError(f"message {req.msg_id} carries no media")
|
|
372
|
+
|
|
373
|
+
info = _media_info(message, chat_id=chat_id, sizes=req.sizes, qualities=req.qualities)
|
|
374
|
+
if req.stickers:
|
|
375
|
+
info.attached_sets = await _attached_sets(ctx, message)
|
|
376
|
+
if req.ads:
|
|
377
|
+
info.ads = await _video_ads(ctx, peer, req.msg_id)
|
|
378
|
+
if req.album and info.grouped_id is not None:
|
|
379
|
+
info.album = await _album_siblings(ctx, peer, chat_id, message)
|
|
380
|
+
return info
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def _attached_sets(ctx: OpContext, message: Any) -> list[str]:
|
|
384
|
+
"""`messages.getAttachedStickers` — sets composited into the image."""
|
|
385
|
+
from telethon.tl import types
|
|
386
|
+
from telethon.tl.functions import messages as fn
|
|
387
|
+
|
|
388
|
+
media = getattr(message, "media", None)
|
|
389
|
+
document = _media.document_of(media)
|
|
390
|
+
photo = _media.photo_of(media) if document is None else None
|
|
391
|
+
if document is not None:
|
|
392
|
+
stickered: Any = types.InputStickeredMediaDocument(id=_media.input_document(document))
|
|
393
|
+
elif photo is not None:
|
|
394
|
+
stickered = types.InputStickeredMediaPhoto(id=_media.input_photo(photo))
|
|
395
|
+
else:
|
|
396
|
+
return []
|
|
397
|
+
result = await _client(ctx)(fn.GetAttachedStickersRequest(media=stickered))
|
|
398
|
+
names: list[str] = []
|
|
399
|
+
for covered in result or []:
|
|
400
|
+
inner = getattr(covered, "set", covered)
|
|
401
|
+
name = getattr(inner, "short_name", None)
|
|
402
|
+
if name:
|
|
403
|
+
names.append(str(name))
|
|
404
|
+
return names
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def _video_ads(ctx: OpContext, peer: Any, message_id: int) -> list[dict[str, Any]]:
|
|
408
|
+
"""Sponsored inserts, listed and never viewed.
|
|
409
|
+
|
|
410
|
+
`viewSponsoredMessage`/`clickSponsoredMessage` are deliberately absent:
|
|
411
|
+
calling either would inflate an advertiser's metrics on the user's behalf.
|
|
412
|
+
"""
|
|
413
|
+
from telethon.tl.functions import messages as fn
|
|
414
|
+
|
|
415
|
+
result = await _client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer, msg_id=message_id))
|
|
416
|
+
out: list[dict[str, Any]] = []
|
|
417
|
+
for item in getattr(result, "messages", None) or []:
|
|
418
|
+
out.append(
|
|
419
|
+
{
|
|
420
|
+
"title": getattr(item, "title", None),
|
|
421
|
+
"message": getattr(item, "message", None),
|
|
422
|
+
"url": getattr(item, "url", None),
|
|
423
|
+
"sponsor": getattr(item, "sponsor_info", None),
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def _album_siblings(ctx: OpContext, peer: Any, chat_id: int, message: Any) -> list[MediaInfo]:
|
|
430
|
+
"""The other messages of this media group.
|
|
431
|
+
|
|
432
|
+
Album membership is invisible without `grouped_id`, and the ids are not
|
|
433
|
+
contiguous in general — so the window around the message is read and
|
|
434
|
+
filtered rather than guessed.
|
|
435
|
+
"""
|
|
436
|
+
grouped = getattr(message, "grouped_id", None)
|
|
437
|
+
if grouped is None:
|
|
438
|
+
return []
|
|
439
|
+
message_id = int(getattr(message, "id", 0))
|
|
440
|
+
ids = [i for i in range(max(1, message_id - 10), message_id + 11) if i != message_id]
|
|
441
|
+
found = await _client(ctx).get_messages(peer, ids=ids)
|
|
442
|
+
return [
|
|
443
|
+
_media_info(sibling, chat_id=chat_id, sizes=False, qualities=False)
|
|
444
|
+
for sibling in (found or [])
|
|
445
|
+
if sibling is not None and getattr(sibling, "grouped_id", None) == grouped
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
SPEC_GET = OperationSpec(
|
|
450
|
+
id="media.get",
|
|
451
|
+
request=GetReq,
|
|
452
|
+
response=MediaInfo,
|
|
453
|
+
impl=get,
|
|
454
|
+
summary="Everything a message's media declares, without downloading a byte",
|
|
455
|
+
description=(
|
|
456
|
+
"The JSON contract the whole group leans on: kind, mime, dimensions, "
|
|
457
|
+
"duration, album membership, ids and access hash, protection and paid "
|
|
458
|
+
"state. --sizes lists every thumbnail variant (the stripped and vector "
|
|
459
|
+
"ones cost no request at all), --qualities the alt_documents "
|
|
460
|
+
"transcodes, --stickers the sets baked into the image, --ads the "
|
|
461
|
+
"sponsored inserts (listed, never viewed)."
|
|
462
|
+
),
|
|
463
|
+
aliases=("media.info",),
|
|
464
|
+
columns=("kind", "mime", "size", "file_name"),
|
|
465
|
+
headers=("Kind", "MIME", "Size", "Name"),
|
|
466
|
+
empty_exit=EXIT_EMPTY,
|
|
467
|
+
example={
|
|
468
|
+
"chat_id": 777123,
|
|
469
|
+
"msg_id": 12345,
|
|
470
|
+
"kind": "video",
|
|
471
|
+
"mime": "video/mp4",
|
|
472
|
+
"size": 8412300,
|
|
473
|
+
"width": 1280,
|
|
474
|
+
"height": 720,
|
|
475
|
+
"duration": 42,
|
|
476
|
+
"supports_streaming": True,
|
|
477
|
+
},
|
|
478
|
+
example_args="media get @alice 12345",
|
|
479
|
+
covers=(
|
|
480
|
+
"media.attached-stickers",
|
|
481
|
+
"media.dc-routing",
|
|
482
|
+
"media.info",
|
|
483
|
+
"media.stripped-vector-thumbnails",
|
|
484
|
+
"media.video-message-ads",
|
|
485
|
+
"media.video-quality-select",
|
|
486
|
+
),
|
|
487
|
+
covers_partial=("media.file-id-export-import", "media.paid-media-inspect"),
|
|
488
|
+
coverage_note=(
|
|
489
|
+
"file_id is reported here; refreshing an expired one is `media file-id get`. "
|
|
490
|
+
"Paid posts are listed and refreshed by `media paid list`."
|
|
491
|
+
),
|
|
492
|
+
tags=frozenset({"read-only"}),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
# media list
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class ListReq(Request):
|
|
502
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to read.")]
|
|
503
|
+
type: Annotated[
|
|
504
|
+
str,
|
|
505
|
+
choice(*_media.MEDIA_FILTERS, help="Which shared-media tab."),
|
|
506
|
+
] = "media"
|
|
507
|
+
query: Annotated[
|
|
508
|
+
str | None,
|
|
509
|
+
opt("-q", "--query", metavar="TEXT", help="Server-side search over name and caption."),
|
|
510
|
+
] = None
|
|
511
|
+
since: Annotated[
|
|
512
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
|
|
513
|
+
] = None
|
|
514
|
+
until: Annotated[
|
|
515
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
|
|
516
|
+
] = None
|
|
517
|
+
topic: Annotated[
|
|
518
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Restrict to a forum topic.")
|
|
519
|
+
] = None
|
|
520
|
+
counts: Annotated[bool, opt("--counts", help="Per-type counters instead of items.")] = False
|
|
521
|
+
calendar: Annotated[
|
|
522
|
+
bool, opt("--calendar", help="Per-day counts and jump ids instead of items.")
|
|
523
|
+
] = False
|
|
524
|
+
month: Annotated[
|
|
525
|
+
str | None, opt("--month", metavar="YYYY-MM", help="Month for --calendar.")
|
|
526
|
+
] = None
|
|
527
|
+
ids_only: Annotated[
|
|
528
|
+
bool, opt("--ids-only", help="Bare message ids, to pipe into another command.")
|
|
529
|
+
] = False
|
|
530
|
+
positions: Annotated[bool, opt("--positions", help="Sparse scrollbar positions.")] = False
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _item_from_message(message: Any, *, chat_id: int, chat: Any = None) -> MediaItem:
|
|
534
|
+
media = getattr(message, "media", None)
|
|
535
|
+
document = _media.document_of(media)
|
|
536
|
+
facts = _media.attributes_of(document) if document is not None else {}
|
|
537
|
+
date, date_unix = _media.message_dates(message)
|
|
538
|
+
from tlgr.ops._serialize import media_summary
|
|
539
|
+
|
|
540
|
+
summary = media_summary(media)
|
|
541
|
+
return MediaItem(
|
|
542
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
543
|
+
chat_id=chat_id,
|
|
544
|
+
date=date,
|
|
545
|
+
date_unix=date_unix,
|
|
546
|
+
kind=summary.kind if summary is not None else None,
|
|
547
|
+
name=facts.get("file_name") or facts.get("title"),
|
|
548
|
+
size=getattr(document, "size", None),
|
|
549
|
+
duration=int(facts["duration"]) if facts.get("duration") is not None else None,
|
|
550
|
+
mime=getattr(document, "mime_type", None),
|
|
551
|
+
from_id=getattr(message, "sender_id", None),
|
|
552
|
+
grouped_id=getattr(message, "grouped_id", None),
|
|
553
|
+
file_id=_media.file_id_of(media),
|
|
554
|
+
chat=chat,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def list_media(ctx: OpContext, req: ListReq) -> Page[MediaItem]:
|
|
559
|
+
"""One shared-media tab of a chat, one signed page at a time."""
|
|
560
|
+
limit, state = _media.window(ctx, "media.list", PageKind.SEARCH)
|
|
561
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
562
|
+
chat_id = _send.peer_id_of(peer)
|
|
563
|
+
|
|
564
|
+
if req.counts:
|
|
565
|
+
return await _search_counters(ctx, peer, req)
|
|
566
|
+
if req.calendar:
|
|
567
|
+
return await _search_calendar(ctx, peer, req, limit)
|
|
568
|
+
if req.positions:
|
|
569
|
+
return await _search_positions(ctx, peer, req, chat_id, limit)
|
|
570
|
+
|
|
571
|
+
kwargs: dict[str, Any] = {
|
|
572
|
+
"filter": _media.media_filter(req.type),
|
|
573
|
+
"offset_id": int(state.get("offset_id", 0)),
|
|
574
|
+
"limit": limit,
|
|
575
|
+
}
|
|
576
|
+
if req.query:
|
|
577
|
+
kwargs["search"] = req.query
|
|
578
|
+
if req.topic is not None:
|
|
579
|
+
kwargs["reply_to"] = req.topic
|
|
580
|
+
if req.until:
|
|
581
|
+
kwargs["offset_date"] = parse_dt(req.until)
|
|
582
|
+
|
|
583
|
+
items: list[MediaItem] = []
|
|
584
|
+
floor = to_unix(parse_dt(req.since)) if req.since else None
|
|
585
|
+
async for message in _client(ctx).iter_messages(peer, **kwargs):
|
|
586
|
+
if message is None:
|
|
587
|
+
continue
|
|
588
|
+
item = _item_from_message(message, chat_id=chat_id)
|
|
589
|
+
if floor is not None and item.date_unix < floor:
|
|
590
|
+
continue
|
|
591
|
+
if req.ids_only:
|
|
592
|
+
item = MediaItem(msg_id=item.msg_id, chat_id=chat_id)
|
|
593
|
+
items.append(item)
|
|
594
|
+
|
|
595
|
+
next_state = {"offset_id": items[-1].msg_id} if items else {}
|
|
596
|
+
return build_page(
|
|
597
|
+
items,
|
|
598
|
+
op="media.list",
|
|
599
|
+
kind=PageKind.SEARCH,
|
|
600
|
+
state=next_state,
|
|
601
|
+
account=ctx.account,
|
|
602
|
+
limit=limit,
|
|
603
|
+
has_more=None if items else False,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
async def _search_counters(ctx: OpContext, peer: Any, req: ListReq) -> Page[MediaItem]:
|
|
608
|
+
"""`messages.getSearchCounters` — the numbers above the media tabs."""
|
|
609
|
+
from telethon.tl.functions import messages as fn
|
|
610
|
+
|
|
611
|
+
names = [name for name in _media.MEDIA_FILTERS if name != "all"]
|
|
612
|
+
result = await _client(ctx)(
|
|
613
|
+
fn.GetSearchCountersRequest(
|
|
614
|
+
peer=peer,
|
|
615
|
+
filters=[_media.media_filter(name) for name in names],
|
|
616
|
+
top_msg_id=req.topic,
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
items = [
|
|
620
|
+
MediaItem(type=name, count=int(getattr(entry, "count", 0) or 0))
|
|
621
|
+
for name, entry in zip(names, result or [], strict=False)
|
|
622
|
+
]
|
|
623
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _search_calendar(ctx: OpContext, peer: Any, req: ListReq, limit: int) -> Page[MediaItem]:
|
|
627
|
+
"""`messages.getSearchResultsCalendar` — per-day counts and jump ids."""
|
|
628
|
+
from telethon.tl.functions import messages as fn
|
|
629
|
+
|
|
630
|
+
offset_date = parse_dt(f"{req.month}-01") if req.month else None
|
|
631
|
+
result = await _client(ctx)(
|
|
632
|
+
fn.GetSearchResultsCalendarRequest(
|
|
633
|
+
peer=peer,
|
|
634
|
+
filter=_media.media_filter(req.type),
|
|
635
|
+
offset_id=0,
|
|
636
|
+
offset_date=offset_date,
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
items: list[MediaItem] = []
|
|
640
|
+
for period in getattr(result, "periods", None) or []:
|
|
641
|
+
stamp = fmt_dt(getattr(period, "date", None)) or ""
|
|
642
|
+
items.append(
|
|
643
|
+
MediaItem(
|
|
644
|
+
msg_id=int(getattr(period, "min_msg_id", 0) or 0),
|
|
645
|
+
date=stamp,
|
|
646
|
+
date_unix=to_unix(getattr(period, "date", None)) or 0,
|
|
647
|
+
period=stamp[:10],
|
|
648
|
+
count=int(getattr(period, "count", 0) or 0),
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
return Page(items=items[:limit], has_more=False, total=int(getattr(result, "count", 0) or 0))
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
async def _search_positions(
|
|
655
|
+
ctx: OpContext, peer: Any, req: ListReq, chat_id: int, limit: int
|
|
656
|
+
) -> Page[MediaItem]:
|
|
657
|
+
from telethon.tl.functions import messages as fn
|
|
658
|
+
|
|
659
|
+
result = await _client(ctx)(
|
|
660
|
+
fn.GetSearchResultsPositionsRequest(
|
|
661
|
+
peer=peer, filter=_media.media_filter(req.type), offset_id=0, limit=limit
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
items = [
|
|
665
|
+
MediaItem(
|
|
666
|
+
msg_id=int(getattr(position, "msg_id", 0) or 0),
|
|
667
|
+
chat_id=chat_id,
|
|
668
|
+
count=int(getattr(position, "offset", 0) or 0),
|
|
669
|
+
date=fmt_dt(getattr(position, "date", None)) or "",
|
|
670
|
+
)
|
|
671
|
+
for position in getattr(result, "positions", None) or []
|
|
672
|
+
]
|
|
673
|
+
return Page(items=items, has_more=False, total=int(getattr(result, "count", 0) or 0))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
SPEC_LIST = OperationSpec(
|
|
677
|
+
id="media.list",
|
|
678
|
+
request=ListReq,
|
|
679
|
+
response=Page[MediaItem],
|
|
680
|
+
impl=list_media,
|
|
681
|
+
summary="Shared media of a chat, one media type at a time",
|
|
682
|
+
description=(
|
|
683
|
+
"--type maps one-to-one onto Telegram's own tabs, including "
|
|
684
|
+
"`chat-photo`, which is a group or channel's avatar history. --counts "
|
|
685
|
+
"returns the per-tab counters, --calendar the per-day counts with a "
|
|
686
|
+
"jump id, --ids-only bare ids so the result can drive `message "
|
|
687
|
+
"forward`, `message delete` or `media download`."
|
|
688
|
+
),
|
|
689
|
+
paginated=PageKind.SEARCH,
|
|
690
|
+
columns=("msg_id", "date", "kind", "name", "size"),
|
|
691
|
+
headers=("ID", "Date", "Kind", "Name", "Size"),
|
|
692
|
+
example={"items": [_EXAMPLE_ITEM], "has_more": False},
|
|
693
|
+
example_args="media list @alice --type photo",
|
|
694
|
+
covers=(
|
|
695
|
+
"chat.photo-history",
|
|
696
|
+
"media.music-player-queue",
|
|
697
|
+
"media.saved-messages-drive",
|
|
698
|
+
"media.shared-media-calendar",
|
|
699
|
+
"media.shared-media-counters",
|
|
700
|
+
"media.shared-media-list",
|
|
701
|
+
"media.shared-media-search",
|
|
702
|
+
),
|
|
703
|
+
covers_partial=("media.shared-media-bulk-actions",),
|
|
704
|
+
coverage_note="--ids-only feeds the bulk verbs; `media export` owns the bulk download.",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
# media search
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
class SearchReq(Request):
|
|
714
|
+
query: Annotated[str, arg(0, metavar="QUERY", required=False, help="Text to find.")] = ""
|
|
715
|
+
type: Annotated[
|
|
716
|
+
str,
|
|
717
|
+
choice(
|
|
718
|
+
"photo",
|
|
719
|
+
"video",
|
|
720
|
+
"media",
|
|
721
|
+
"file",
|
|
722
|
+
"link",
|
|
723
|
+
"music",
|
|
724
|
+
"voice",
|
|
725
|
+
"gif",
|
|
726
|
+
help="Content-type tab.",
|
|
727
|
+
),
|
|
728
|
+
] = "media"
|
|
729
|
+
since: Annotated[
|
|
730
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
|
|
731
|
+
] = None
|
|
732
|
+
until: Annotated[
|
|
733
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
|
|
734
|
+
] = None
|
|
735
|
+
sent: Annotated[
|
|
736
|
+
bool, opt("--sent", help="Only your own recently-sent media (one capped page).")
|
|
737
|
+
] = False
|
|
738
|
+
source: Annotated[
|
|
739
|
+
str,
|
|
740
|
+
choice("any", "saved", "chats", "inline", help="Music picker source."),
|
|
741
|
+
] = "any"
|
|
742
|
+
bot: Annotated[
|
|
743
|
+
PeerRef | None,
|
|
744
|
+
opt("--bot", metavar="USER", kind="user", help="Inline bot for --source inline."),
|
|
745
|
+
] = None
|
|
746
|
+
folder: Annotated[
|
|
747
|
+
int | None, opt("--folder", metavar="ID", help="Restrict to a chat folder.")
|
|
748
|
+
] = None
|
|
749
|
+
broadcasts_only: Annotated[bool, opt("--broadcasts-only", help="Only channels.")] = False
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
async def search(ctx: OpContext, req: SearchReq) -> Page[MediaItem]:
|
|
753
|
+
"""Search media across every chat — the GUI's global media tabs."""
|
|
754
|
+
limit, state = _media.window(ctx, "media.search", PageKind.SEARCH)
|
|
755
|
+
from telethon.tl import types
|
|
756
|
+
from telethon.tl.functions import messages as fn
|
|
757
|
+
|
|
758
|
+
if req.sent:
|
|
759
|
+
return await _search_sent(ctx, req, limit)
|
|
760
|
+
if req.source == "saved":
|
|
761
|
+
return await _saved_music(ctx, limit)
|
|
762
|
+
if req.source == "inline":
|
|
763
|
+
return await _inline_music(ctx, req, limit)
|
|
764
|
+
|
|
765
|
+
offset_peer: Any = types.InputPeerEmpty()
|
|
766
|
+
if state.get("offset_peer"):
|
|
767
|
+
from tlgr.models.peer import parse_peer_ref
|
|
768
|
+
|
|
769
|
+
with contextlib.suppress(Exception):
|
|
770
|
+
offset_peer = await _send.resolve(ctx, parse_peer_ref(str(state["offset_peer"])))
|
|
771
|
+
|
|
772
|
+
result = await _client(ctx)(
|
|
773
|
+
fn.SearchGlobalRequest(
|
|
774
|
+
q=req.query or "",
|
|
775
|
+
filter=_media.media_filter(req.type),
|
|
776
|
+
min_date=parse_dt(req.since) if req.since else None,
|
|
777
|
+
max_date=parse_dt(req.until) if req.until else None,
|
|
778
|
+
offset_rate=int(state.get("offset_rate", 0)),
|
|
779
|
+
offset_peer=offset_peer,
|
|
780
|
+
offset_id=int(state.get("offset_id", 0)),
|
|
781
|
+
limit=limit,
|
|
782
|
+
broadcasts_only=req.broadcasts_only or None,
|
|
783
|
+
folder_id=req.folder,
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
peers = {
|
|
787
|
+
entity.id: entity_to_peer(entity)
|
|
788
|
+
for entity in [
|
|
789
|
+
*(getattr(result, "users", None) or []),
|
|
790
|
+
*(getattr(result, "chats", None) or []),
|
|
791
|
+
]
|
|
792
|
+
}
|
|
793
|
+
items: list[MediaItem] = []
|
|
794
|
+
last: Any = None
|
|
795
|
+
for message in getattr(result, "messages", None) or []:
|
|
796
|
+
from tlgr.ops._serialize import peer_id_of as marked_of
|
|
797
|
+
|
|
798
|
+
chat_id = marked_of(getattr(message, "peer_id", None)) or 0
|
|
799
|
+
raw_id = abs(chat_id) if chat_id > 0 else abs(chat_id) % 1000000000000
|
|
800
|
+
items.append(_item_from_message(message, chat_id=chat_id, chat=peers.get(raw_id)))
|
|
801
|
+
last = message
|
|
802
|
+
|
|
803
|
+
# searchGlobal paginates on the *triple*; an offset_id alone silently loops.
|
|
804
|
+
next_state: dict[str, Any] = {}
|
|
805
|
+
if last is not None:
|
|
806
|
+
next_state = {
|
|
807
|
+
"offset_rate": int(getattr(result, "next_rate", 0) or 0),
|
|
808
|
+
"offset_id": int(getattr(last, "id", 0) or 0),
|
|
809
|
+
"offset_peer": items[-1].chat_id,
|
|
810
|
+
}
|
|
811
|
+
return build_page(
|
|
812
|
+
items,
|
|
813
|
+
op="media.search",
|
|
814
|
+
kind=PageKind.SEARCH,
|
|
815
|
+
state=next_state,
|
|
816
|
+
account=ctx.account,
|
|
817
|
+
limit=limit,
|
|
818
|
+
total=getattr(result, "count", None),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
async def _search_sent(ctx: OpContext, req: SearchReq, limit: int) -> Page[MediaItem]:
|
|
823
|
+
"""`messages.searchSentMedia` — the attach dialog's "Recent files".
|
|
824
|
+
|
|
825
|
+
One capped page by design: the endpoint takes no offset at all, so a
|
|
826
|
+
cursor would be a promise the server cannot keep.
|
|
827
|
+
"""
|
|
828
|
+
from telethon.tl.functions import messages as fn
|
|
829
|
+
|
|
830
|
+
result = await _client(ctx)(
|
|
831
|
+
fn.SearchSentMediaRequest(
|
|
832
|
+
q=req.query or "", filter=_media.media_filter(req.type), limit=limit
|
|
833
|
+
)
|
|
834
|
+
)
|
|
835
|
+
from tlgr.ops._serialize import peer_id_of as marked_of
|
|
836
|
+
|
|
837
|
+
items = [
|
|
838
|
+
_item_from_message(message, chat_id=marked_of(getattr(message, "peer_id", None)) or 0)
|
|
839
|
+
for message in (getattr(result, "messages", None) or [])
|
|
840
|
+
]
|
|
841
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
async def _saved_music(ctx: OpContext, limit: int) -> Page[MediaItem]:
|
|
845
|
+
"""`users.getSavedMusic` — the tracks saved on this profile."""
|
|
846
|
+
from telethon.tl import types
|
|
847
|
+
from telethon.tl.functions import users as fn
|
|
848
|
+
|
|
849
|
+
result = await _client(ctx)(
|
|
850
|
+
fn.GetSavedMusicRequest(id=types.InputUserSelf(), offset=0, limit=limit, hash=0)
|
|
851
|
+
)
|
|
852
|
+
items: list[MediaItem] = []
|
|
853
|
+
for document in getattr(result, "documents", None) or []:
|
|
854
|
+
facts = _media.attributes_of(document)
|
|
855
|
+
items.append(
|
|
856
|
+
MediaItem(
|
|
857
|
+
kind="audio",
|
|
858
|
+
name=facts.get("title") or facts.get("file_name"),
|
|
859
|
+
size=getattr(document, "size", None),
|
|
860
|
+
duration=int(facts["duration"]) if facts.get("duration") is not None else None,
|
|
861
|
+
mime=getattr(document, "mime_type", None),
|
|
862
|
+
file_id=_media.file_id_of(document),
|
|
863
|
+
)
|
|
864
|
+
)
|
|
865
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
async def _inline_music(ctx: OpContext, req: SearchReq, limit: int) -> Page[MediaItem]:
|
|
869
|
+
"""The inline half of the music picker."""
|
|
870
|
+
from telethon.tl import types
|
|
871
|
+
from telethon.tl.functions import messages as fn
|
|
872
|
+
|
|
873
|
+
if req.bot is None:
|
|
874
|
+
raise UsageError("--source inline needs --bot", field="bot")
|
|
875
|
+
bot = await _send.resolve(ctx, req.bot)
|
|
876
|
+
result = await _client(ctx)(
|
|
877
|
+
fn.GetInlineBotResultsRequest(
|
|
878
|
+
bot=bot, peer=types.InputPeerEmpty(), query=req.query or "", offset=""
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
items: list[MediaItem] = []
|
|
882
|
+
for entry in (getattr(result, "results", None) or [])[:limit]:
|
|
883
|
+
document = getattr(entry, "document", None)
|
|
884
|
+
facts = _media.attributes_of(document) if document is not None else {}
|
|
885
|
+
items.append(
|
|
886
|
+
MediaItem(
|
|
887
|
+
kind="audio",
|
|
888
|
+
name=getattr(entry, "title", None) or facts.get("file_name"),
|
|
889
|
+
mime=getattr(document, "mime_type", None),
|
|
890
|
+
duration=int(facts["duration"]) if facts.get("duration") is not None else None,
|
|
891
|
+
file_id=_media.file_id_of(document) if document is not None else None,
|
|
892
|
+
)
|
|
893
|
+
)
|
|
894
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
SPEC_SEARCH = OperationSpec(
|
|
898
|
+
id="media.search",
|
|
899
|
+
request=SearchReq,
|
|
900
|
+
response=Page[MediaItem],
|
|
901
|
+
impl=search,
|
|
902
|
+
summary="Search media across every chat",
|
|
903
|
+
description=(
|
|
904
|
+
"`messages.searchGlobal` paginates on the triple (offset_rate, "
|
|
905
|
+
"offset_peer, offset_id) and a plain offset_id silently loops, so the "
|
|
906
|
+
"cursor carries all three. An empty query with a --type is legal and "
|
|
907
|
+
"is how the GUI fills its tabs. --sent is the attach dialog's recent "
|
|
908
|
+
"files: one capped page, because the endpoint takes no offset."
|
|
909
|
+
),
|
|
910
|
+
paginated=PageKind.SEARCH,
|
|
911
|
+
columns=("msg_id", "chat_id", "date", "kind", "name"),
|
|
912
|
+
headers=("ID", "Chat", "Date", "Kind", "Name"),
|
|
913
|
+
example={"items": [_EXAMPLE_ITEM], "has_more": False},
|
|
914
|
+
example_args="media search invoice --type file",
|
|
915
|
+
covers=(
|
|
916
|
+
"media.attach-music-picker",
|
|
917
|
+
"media.global-media-search",
|
|
918
|
+
"media.recent-sent-media-search",
|
|
919
|
+
),
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
# media download
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
class DownloadReq(Request):
|
|
929
|
+
chat: Annotated[
|
|
930
|
+
PeerRef | None,
|
|
931
|
+
arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the media."),
|
|
932
|
+
] = None
|
|
933
|
+
msg_id: Annotated[
|
|
934
|
+
list[str],
|
|
935
|
+
arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids, or `-`."),
|
|
936
|
+
] = []
|
|
937
|
+
out: Annotated[
|
|
938
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Write to this exact file.")
|
|
939
|
+
] = None
|
|
940
|
+
out_dir: Annotated[
|
|
941
|
+
str | None, opt("--out-dir", metavar="DIR", kind="path", help="Write into this directory.")
|
|
942
|
+
] = None
|
|
943
|
+
stdout: Annotated[
|
|
944
|
+
bool, opt("--stdout", help="Spool the bytes and report the path (see the note).")
|
|
945
|
+
] = False
|
|
946
|
+
play: Annotated[
|
|
947
|
+
str | None, opt("--play", metavar="CMD", help="Refused: the daemon spawns no players.")
|
|
948
|
+
] = None
|
|
949
|
+
name_template: Annotated[
|
|
950
|
+
str, opt("--name-template", metavar="PATTERN", help="Naming pattern for --out-dir.")
|
|
951
|
+
] = DEFAULT_TEMPLATE
|
|
952
|
+
skip_existing: Annotated[
|
|
953
|
+
bool, opt("--skip-existing", help="Skip when the target exists at the right size.")
|
|
954
|
+
] = False
|
|
955
|
+
overwrite: Annotated[
|
|
956
|
+
bool, opt("--overwrite", help="Overwrite instead of uniquifying the name.")
|
|
957
|
+
] = False
|
|
958
|
+
album: Annotated[bool, opt("--album", help="Also fetch the message's album siblings.")] = False
|
|
959
|
+
every: Annotated[
|
|
960
|
+
bool, opt("--all", help="Every item in the chat matching --type/--from/--since.")
|
|
961
|
+
] = False
|
|
962
|
+
max_items: Annotated[
|
|
963
|
+
int,
|
|
964
|
+
opt("--max", metavar="N", help="Cap the items fetched with --all.", ge=1, le=10000),
|
|
965
|
+
] = 100
|
|
966
|
+
type: Annotated[str, choice(*_media.MEDIA_FILTERS, help="Media filter for --all.")] = "all"
|
|
967
|
+
from_user: Annotated[
|
|
968
|
+
PeerRef | None,
|
|
969
|
+
opt("--from", metavar="USER", kind="user", help="Only media sent by this user."),
|
|
970
|
+
] = None
|
|
971
|
+
since: Annotated[
|
|
972
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Only after this time.")
|
|
973
|
+
] = None
|
|
974
|
+
until: Annotated[
|
|
975
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
|
|
976
|
+
] = None
|
|
977
|
+
thumb: Annotated[
|
|
978
|
+
str | None,
|
|
979
|
+
opt("--thumb", metavar="SIZE", help="Fetch a thumbnail: stripped, vector, or a type."),
|
|
980
|
+
] = None
|
|
981
|
+
quality: Annotated[
|
|
982
|
+
str | None, opt("--quality", metavar="Q", help="Pick an alt_documents transcode.")
|
|
983
|
+
] = None
|
|
984
|
+
range: Annotated[
|
|
985
|
+
str | None, opt("--range", metavar="START-END", help="Byte range (`0-1M`, `5M-`).")
|
|
986
|
+
] = None
|
|
987
|
+
resume: Annotated[bool, opt("--resume", help="Continue from the .part sidecar.")] = True
|
|
988
|
+
verify: Annotated[
|
|
989
|
+
bool, opt("--verify", help="Check the bytes against upload.getFileHashes.")
|
|
990
|
+
] = False
|
|
991
|
+
connections: Annotated[
|
|
992
|
+
int, opt("--connections", metavar="N", help="Parallel ranged readers.", ge=1, le=8)
|
|
993
|
+
] = 1
|
|
994
|
+
part_size: Annotated[
|
|
995
|
+
int, opt("--part-size", metavar="KB", help="Request size in KB.", ge=4, le=512)
|
|
996
|
+
] = 512
|
|
997
|
+
background: Annotated[
|
|
998
|
+
bool, opt("--background", help="Hand the transfer to the daemon and print a job id.")
|
|
999
|
+
] = False
|
|
1000
|
+
read: Annotated[
|
|
1001
|
+
bool, opt("--read", help="Mark the media consumed afterwards (irreversible).")
|
|
1002
|
+
] = False
|
|
1003
|
+
profile: Annotated[
|
|
1004
|
+
PeerRef | None,
|
|
1005
|
+
opt("--profile", metavar="PEER", kind="peer", help="Download a peer's avatar."),
|
|
1006
|
+
] = None
|
|
1007
|
+
small: Annotated[bool, opt("--small", help="Small avatar variant.")] = False
|
|
1008
|
+
story: Annotated[
|
|
1009
|
+
str | None, opt("--story", metavar="PEER:ID", help="Download a story's media.")
|
|
1010
|
+
] = None
|
|
1011
|
+
file_id: Annotated[
|
|
1012
|
+
str | None, opt("--file-id", metavar="ID", help="Download by portable file id.")
|
|
1013
|
+
] = None
|
|
1014
|
+
web: Annotated[
|
|
1015
|
+
str | None, opt("--web", metavar="URL", help="Fetch a web document through Telegram.")
|
|
1016
|
+
] = None
|
|
1017
|
+
map: Annotated[
|
|
1018
|
+
str | None, opt("--map", metavar="LAT,LON", help="Fetch a static map preview.")
|
|
1019
|
+
] = None
|
|
1020
|
+
zoom: Annotated[int, opt("--zoom", metavar="N", help="Map zoom.", ge=1, le=20)] = 15
|
|
1021
|
+
size: Annotated[str, opt("--size", metavar="WxH", help="Map/thumb size.")] = "600x400"
|
|
1022
|
+
allow_protected: Annotated[
|
|
1023
|
+
bool,
|
|
1024
|
+
opt(
|
|
1025
|
+
"--allow-protected",
|
|
1026
|
+
help="Download content the chat marks as protected (noforwards).",
|
|
1027
|
+
),
|
|
1028
|
+
] = False
|
|
1029
|
+
no_cdn: Annotated[bool, opt("--no-cdn", help="Debug: read from the master DC.")] = False
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _ids_from(values: list[str]) -> list[int]:
|
|
1033
|
+
"""`-` reads ids from stdin, one per line or as a JSON page."""
|
|
1034
|
+
import json
|
|
1035
|
+
import sys
|
|
1036
|
+
|
|
1037
|
+
out: list[int] = []
|
|
1038
|
+
for value in values:
|
|
1039
|
+
text = str(value).strip()
|
|
1040
|
+
if text != "-":
|
|
1041
|
+
try:
|
|
1042
|
+
out.append(int(text))
|
|
1043
|
+
except ValueError as exc:
|
|
1044
|
+
raise UsageError(f"{text!r} is not a message id", field="msg_id") from exc
|
|
1045
|
+
continue
|
|
1046
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
1047
|
+
raise UsageError("'-' was given but stdin is a terminal", field="msg_id")
|
|
1048
|
+
raw = sys.stdin.read().strip()
|
|
1049
|
+
if raw.startswith(("{", "[")):
|
|
1050
|
+
data = json.loads(raw)
|
|
1051
|
+
rows = data.get("items", data) if isinstance(data, dict) else data
|
|
1052
|
+
out.extend(int(row["msg_id"] if isinstance(row, dict) else row) for row in rows)
|
|
1053
|
+
else:
|
|
1054
|
+
out.extend(int(line) for line in raw.split() if line.strip())
|
|
1055
|
+
return out
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
async def download(ctx: OpContext, req: DownloadReq) -> Page[Downloaded]:
|
|
1059
|
+
"""Fetch bytes: message media, an avatar, a story, a file id or a web file."""
|
|
1060
|
+
if req.play:
|
|
1061
|
+
raise NotSupportedError(
|
|
1062
|
+
"--play is refused: the transfer runs inside the daemon, which does not "
|
|
1063
|
+
"spawn processes on your behalf. Download with --out and pipe the file."
|
|
1064
|
+
)
|
|
1065
|
+
if req.background:
|
|
1066
|
+
return await _background_download(ctx, req)
|
|
1067
|
+
|
|
1068
|
+
started = time.monotonic()
|
|
1069
|
+
if req.profile is not None:
|
|
1070
|
+
return Page(items=[await _download_profile(ctx, req, started)], has_more=False)
|
|
1071
|
+
if req.story:
|
|
1072
|
+
return Page(items=[await _download_story(ctx, req, started)], has_more=False)
|
|
1073
|
+
if req.file_id:
|
|
1074
|
+
return Page(items=[await _download_file_id(ctx, req, started)], has_more=False)
|
|
1075
|
+
if req.web or req.map:
|
|
1076
|
+
return Page(items=[await _download_web(ctx, req, started)], has_more=False)
|
|
1077
|
+
|
|
1078
|
+
if req.chat is None:
|
|
1079
|
+
raise UsageError(
|
|
1080
|
+
"a chat is required unless --profile/--story/--file-id/--web/--map is given",
|
|
1081
|
+
field="chat",
|
|
1082
|
+
)
|
|
1083
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1084
|
+
chat_id = _send.peer_id_of(peer)
|
|
1085
|
+
messages = await _download_targets(ctx, req, peer)
|
|
1086
|
+
if not messages:
|
|
1087
|
+
raise NotFoundError("nothing here matches what you asked for")
|
|
1088
|
+
|
|
1089
|
+
items: list[Downloaded] = []
|
|
1090
|
+
for message in messages:
|
|
1091
|
+
items.append(await _download_one(ctx, req, message, chat_id=chat_id, started=started))
|
|
1092
|
+
if req.read:
|
|
1093
|
+
await _mark_read(ctx, peer, [int(m.id) for m in messages])
|
|
1094
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
async def _download_targets(ctx: OpContext, req: DownloadReq, peer: Any) -> list[Any]:
|
|
1098
|
+
"""Which messages this invocation is about, always freshly fetched."""
|
|
1099
|
+
if req.every:
|
|
1100
|
+
kwargs: dict[str, Any] = {
|
|
1101
|
+
"filter": _media.media_filter(req.type),
|
|
1102
|
+
"limit": req.max_items,
|
|
1103
|
+
}
|
|
1104
|
+
if req.from_user is not None:
|
|
1105
|
+
kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
|
|
1106
|
+
if req.until:
|
|
1107
|
+
kwargs["offset_date"] = parse_dt(req.until)
|
|
1108
|
+
floor = to_unix(parse_dt(req.since)) if req.since else None
|
|
1109
|
+
found = [
|
|
1110
|
+
message
|
|
1111
|
+
async for message in _client(ctx).iter_messages(peer, **kwargs)
|
|
1112
|
+
if message is not None and getattr(message, "media", None) is not None
|
|
1113
|
+
]
|
|
1114
|
+
if floor is not None:
|
|
1115
|
+
found = [m for m in found if (to_unix(getattr(m, "date", None)) or 0) >= floor]
|
|
1116
|
+
return found
|
|
1117
|
+
|
|
1118
|
+
ids = _ids_from(req.msg_id)
|
|
1119
|
+
if not ids:
|
|
1120
|
+
raise UsageError("give at least one message id, or --every", field="msg_id")
|
|
1121
|
+
found = [m for m in (await _client(ctx).get_messages(peer, ids=ids) or []) if m is not None]
|
|
1122
|
+
if req.album:
|
|
1123
|
+
found = await _with_album(ctx, peer, found)
|
|
1124
|
+
return found
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
async def _with_album(ctx: OpContext, peer: Any, messages: list[Any]) -> list[Any]:
|
|
1128
|
+
seen = {int(m.id) for m in messages}
|
|
1129
|
+
out = list(messages)
|
|
1130
|
+
for message in messages:
|
|
1131
|
+
grouped = getattr(message, "grouped_id", None)
|
|
1132
|
+
if grouped is None:
|
|
1133
|
+
continue
|
|
1134
|
+
message_id = int(message.id)
|
|
1135
|
+
window = [i for i in range(max(1, message_id - 10), message_id + 11) if i not in seen]
|
|
1136
|
+
for sibling in await _client(ctx).get_messages(peer, ids=window) or []:
|
|
1137
|
+
if sibling is None or getattr(sibling, "grouped_id", None) != grouped:
|
|
1138
|
+
continue
|
|
1139
|
+
seen.add(int(sibling.id))
|
|
1140
|
+
out.append(sibling)
|
|
1141
|
+
return sorted(out, key=lambda m: int(getattr(m, "id", 0)))
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _target_for(ctx: OpContext, req: DownloadReq, message: Any, chat_id: int) -> Path:
|
|
1145
|
+
"""Where this item lands, with a server-supplied name that cannot escape."""
|
|
1146
|
+
media = getattr(message, "media", None)
|
|
1147
|
+
document = _media.document_of(media)
|
|
1148
|
+
facts = _media.attributes_of(document) if document is not None else {}
|
|
1149
|
+
date, _ = _media.message_dates(message)
|
|
1150
|
+
name = _safe_name(facts.get("file_name") or _default_name(media))
|
|
1151
|
+
if req.out and not req.stdout:
|
|
1152
|
+
return Path(os.path.expanduser(req.out))
|
|
1153
|
+
directory = (
|
|
1154
|
+
Path(os.path.expanduser(req.out_dir))
|
|
1155
|
+
if req.out_dir
|
|
1156
|
+
else _downloads_root(ctx) / str(chat_id)
|
|
1157
|
+
)
|
|
1158
|
+
return directory / _fill_template(
|
|
1159
|
+
req.name_template, date=date, message_id=int(getattr(message, "id", 0) or 0), name=name
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _default_name(media: Any) -> str:
|
|
1164
|
+
document = _media.document_of(media)
|
|
1165
|
+
if document is None:
|
|
1166
|
+
return "photo.jpg"
|
|
1167
|
+
mime = getattr(document, "mime_type", "") or ""
|
|
1168
|
+
extension = {
|
|
1169
|
+
"image/webp": ".webp",
|
|
1170
|
+
"video/mp4": ".mp4",
|
|
1171
|
+
"video/webm": ".webm",
|
|
1172
|
+
"audio/ogg": ".ogg",
|
|
1173
|
+
"audio/mpeg": ".mp3",
|
|
1174
|
+
"application/x-tgsticker": ".tgs",
|
|
1175
|
+
}.get(mime, ".bin")
|
|
1176
|
+
return f"{getattr(document, 'id', 'file')}{extension}"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
async def _download_one(
|
|
1180
|
+
ctx: OpContext, req: DownloadReq, message: Any, *, chat_id: int, started: float
|
|
1181
|
+
) -> Downloaded:
|
|
1182
|
+
media = getattr(message, "media", None)
|
|
1183
|
+
if media is None:
|
|
1184
|
+
raise NotFoundError(f"message {getattr(message, 'id', '?')} carries no media")
|
|
1185
|
+
if getattr(message, "noforwards", False) and not req.allow_protected:
|
|
1186
|
+
raise PermissionError_(
|
|
1187
|
+
f"message {message.id} is in a chat that forbids saving content; "
|
|
1188
|
+
"pass --allow-protected to download it anyway"
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
target = _target_for(ctx, req, message, chat_id)
|
|
1192
|
+
document = _media.document_of(media)
|
|
1193
|
+
photo = _media.photo_of(media) if document is None else None
|
|
1194
|
+
from tlgr.ops._serialize import media_summary
|
|
1195
|
+
|
|
1196
|
+
summary = media_summary(media)
|
|
1197
|
+
kind = summary.kind if summary is not None else "file"
|
|
1198
|
+
|
|
1199
|
+
if req.thumb:
|
|
1200
|
+
path = await _download_thumb(ctx, req, message, target)
|
|
1201
|
+
return Downloaded(
|
|
1202
|
+
msg_id=int(message.id),
|
|
1203
|
+
chat_id=chat_id,
|
|
1204
|
+
path=str(path),
|
|
1205
|
+
bytes=path.stat().st_size if path.exists() else 0,
|
|
1206
|
+
kind="thumb",
|
|
1207
|
+
mime=getattr(document, "mime_type", None),
|
|
1208
|
+
elapsed_s=_elapsed(started),
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
location = _quality_pick(media, req.quality) if req.quality else (document or photo)
|
|
1212
|
+
size = int(getattr(location, "size", 0) or 0)
|
|
1213
|
+
if req.skip_existing and target.exists() and (not size or target.stat().st_size == size):
|
|
1214
|
+
return Downloaded(
|
|
1215
|
+
msg_id=int(message.id),
|
|
1216
|
+
chat_id=chat_id,
|
|
1217
|
+
path=str(target),
|
|
1218
|
+
bytes=target.stat().st_size,
|
|
1219
|
+
kind=kind,
|
|
1220
|
+
skipped=True,
|
|
1221
|
+
)
|
|
1222
|
+
if not req.overwrite:
|
|
1223
|
+
target = _unique(target)
|
|
1224
|
+
|
|
1225
|
+
offset, limit = _byte_range(req.range)
|
|
1226
|
+
peer_ref = getattr(message, "peer_id", None)
|
|
1227
|
+
|
|
1228
|
+
async def refresh() -> Any:
|
|
1229
|
+
fresh = await _media.fetch_message(ctx, peer_ref, int(message.id))
|
|
1230
|
+
return _media.document_of(fresh.media) or _media.photo_of(fresh.media)
|
|
1231
|
+
|
|
1232
|
+
path = await _download_bytes(
|
|
1233
|
+
ctx,
|
|
1234
|
+
location,
|
|
1235
|
+
target,
|
|
1236
|
+
size=size,
|
|
1237
|
+
dc_id=int(getattr(location, "dc_id", 0) or 0),
|
|
1238
|
+
offset=offset,
|
|
1239
|
+
limit=limit,
|
|
1240
|
+
resume=req.resume,
|
|
1241
|
+
part_size=req.part_size * 1024,
|
|
1242
|
+
connections=req.connections,
|
|
1243
|
+
refresh=refresh,
|
|
1244
|
+
)
|
|
1245
|
+
digest = None
|
|
1246
|
+
if req.verify:
|
|
1247
|
+
digest = await _verify(ctx, location, path)
|
|
1248
|
+
if req.stdout:
|
|
1249
|
+
ctx.warn(
|
|
1250
|
+
"the daemon cannot write bytes to your terminal through the IPC socket; "
|
|
1251
|
+
"the file was spooled and its path is in `path`"
|
|
1252
|
+
)
|
|
1253
|
+
return Downloaded(
|
|
1254
|
+
msg_id=int(message.id),
|
|
1255
|
+
chat_id=chat_id,
|
|
1256
|
+
path=str(path),
|
|
1257
|
+
bytes=path.stat().st_size if path.exists() else 0,
|
|
1258
|
+
kind=kind,
|
|
1259
|
+
mime=getattr(document, "mime_type", None),
|
|
1260
|
+
sha256=digest,
|
|
1261
|
+
file_id=_media.file_id_of(media),
|
|
1262
|
+
elapsed_s=_elapsed(started),
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _quality_pick(media: Any, wanted: str) -> Any:
|
|
1267
|
+
"""`--quality 720` → the matching `alt_documents` transcode."""
|
|
1268
|
+
alternatives = list(getattr(media, "alt_documents", None) or [])
|
|
1269
|
+
if not alternatives:
|
|
1270
|
+
return _media.document_of(media)
|
|
1271
|
+
if wanted in ("original", "best"):
|
|
1272
|
+
return _media.document_of(media)
|
|
1273
|
+
if wanted == "smallest":
|
|
1274
|
+
return min(alternatives, key=lambda d: int(getattr(d, "size", 0) or 0))
|
|
1275
|
+
for document in alternatives:
|
|
1276
|
+
facts = _media.attributes_of(document)
|
|
1277
|
+
if str(facts.get("height") or "") == wanted or str(facts.get("width") or "") == wanted:
|
|
1278
|
+
return document
|
|
1279
|
+
raise UsageError(f"--quality {wanted!r}: this video has no such transcode", field="quality")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
async def _download_thumb(ctx: OpContext, req: DownloadReq, message: Any, target: Path) -> Path:
|
|
1283
|
+
"""A thumbnail, including the two that need no request at all.
|
|
1284
|
+
|
|
1285
|
+
`photoStrippedSize` and `photoPathSize` are already in the message: the
|
|
1286
|
+
stripped one inflates to a JPEG locally and the vector one is an SVG
|
|
1287
|
+
outline. Fetching them over the network would be a round trip for bytes
|
|
1288
|
+
already in hand.
|
|
1289
|
+
"""
|
|
1290
|
+
from telethon import utils
|
|
1291
|
+
|
|
1292
|
+
media = getattr(message, "media", None)
|
|
1293
|
+
document = _media.document_of(media)
|
|
1294
|
+
photo = _media.photo_of(media) if document is None else None
|
|
1295
|
+
sizes = list(getattr(photo or document, "sizes", None) or []) or list(
|
|
1296
|
+
getattr(document, "thumbs", None) or []
|
|
1297
|
+
)
|
|
1298
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1299
|
+
|
|
1300
|
+
if req.thumb in ("stripped", "vector"):
|
|
1301
|
+
wanted = "PhotoStrippedSize" if req.thumb == "stripped" else "PhotoPathSize"
|
|
1302
|
+
for size in sizes:
|
|
1303
|
+
if type(size).__name__ != wanted:
|
|
1304
|
+
continue
|
|
1305
|
+
raw = getattr(size, "bytes", b"") or b""
|
|
1306
|
+
data = utils.stripped_photo_to_jpg(raw) if req.thumb == "stripped" else raw
|
|
1307
|
+
target.write_bytes(data)
|
|
1308
|
+
return target
|
|
1309
|
+
raise NotFoundError(f"this media carries no {req.thumb} thumbnail")
|
|
1310
|
+
|
|
1311
|
+
selector: Any = req.thumb
|
|
1312
|
+
if str(req.thumb).lstrip("-").isdigit():
|
|
1313
|
+
selector = int(str(req.thumb))
|
|
1314
|
+
elif req.thumb == "smallest":
|
|
1315
|
+
selector = 0
|
|
1316
|
+
elif req.thumb == "largest":
|
|
1317
|
+
selector = -1
|
|
1318
|
+
written = await _client(ctx).download_media(message, file=str(target), thumb=selector)
|
|
1319
|
+
return Path(written or target)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
async def _verify(ctx: OpContext, location: Any, path: Path) -> str:
|
|
1323
|
+
"""Check the file against `upload.getFileHashes`, block by block."""
|
|
1324
|
+
from telethon.tl.functions import upload as fn
|
|
1325
|
+
|
|
1326
|
+
hashes = await _client(ctx)(fn.GetFileHashesRequest(location=location, offset=0))
|
|
1327
|
+
with open(path, "rb") as handle:
|
|
1328
|
+
for entry in hashes or []:
|
|
1329
|
+
offset = int(getattr(entry, "offset", 0) or 0)
|
|
1330
|
+
limit = int(getattr(entry, "limit", 0) or 0)
|
|
1331
|
+
handle.seek(offset)
|
|
1332
|
+
block = handle.read(limit)
|
|
1333
|
+
if hashlib.sha256(block).digest() != getattr(entry, "hash", b""):
|
|
1334
|
+
raise NotSupportedError(
|
|
1335
|
+
f"the bytes at offset {offset} do not match the server's hash; "
|
|
1336
|
+
"the download is corrupt"
|
|
1337
|
+
)
|
|
1338
|
+
return _sha256(path)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
async def _download_profile(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
|
|
1342
|
+
peer = await _send.resolve(ctx, req.profile)
|
|
1343
|
+
directory = (
|
|
1344
|
+
Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "profile"
|
|
1345
|
+
)
|
|
1346
|
+
target = Path(os.path.expanduser(req.out)) if req.out else directory / "avatar.jpg"
|
|
1347
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1348
|
+
written = await _client(ctx).download_profile_photo(
|
|
1349
|
+
peer, file=str(target), download_big=not req.small
|
|
1350
|
+
)
|
|
1351
|
+
if written is None:
|
|
1352
|
+
raise NotFoundError("that peer has no profile photo")
|
|
1353
|
+
path = Path(written)
|
|
1354
|
+
return Downloaded(
|
|
1355
|
+
chat_id=_send.peer_id_of(peer),
|
|
1356
|
+
path=str(path),
|
|
1357
|
+
bytes=path.stat().st_size if path.exists() else 0,
|
|
1358
|
+
kind="profile_photo",
|
|
1359
|
+
elapsed_s=_elapsed(started),
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
async def _download_story(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
|
|
1364
|
+
"""`--story peer:id` — a story's media is an ordinary photo or document.
|
|
1365
|
+
|
|
1366
|
+
The reference inside a `StoryItem` expires like any other, which is why
|
|
1367
|
+
the story is fetched here rather than taken from a cached listing.
|
|
1368
|
+
"""
|
|
1369
|
+
from telethon.tl.functions import stories as fn
|
|
1370
|
+
|
|
1371
|
+
reference, _, story_id = str(req.story).rpartition(":")
|
|
1372
|
+
if not reference or not story_id.lstrip("-").isdigit():
|
|
1373
|
+
raise UsageError("--story takes PEER:ID, e.g. --story @alice:42", field="story")
|
|
1374
|
+
from tlgr.models.peer import parse_peer_ref
|
|
1375
|
+
|
|
1376
|
+
peer = await _send.resolve(ctx, parse_peer_ref(reference))
|
|
1377
|
+
result = await _client(ctx)(fn.GetStoriesByIDRequest(peer=peer, id=[int(story_id)]))
|
|
1378
|
+
stories = list(getattr(result, "stories", None) or [])
|
|
1379
|
+
if not stories:
|
|
1380
|
+
raise NotFoundError(f"story {story_id} is not available")
|
|
1381
|
+
media = getattr(stories[0], "media", None)
|
|
1382
|
+
document = _media.document_of(media) or _media.photo_of(media)
|
|
1383
|
+
directory = (
|
|
1384
|
+
Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "stories"
|
|
1385
|
+
)
|
|
1386
|
+
target = Path(os.path.expanduser(req.out)) if req.out else directory / f"story_{story_id}.bin"
|
|
1387
|
+
path = await _download_bytes(
|
|
1388
|
+
ctx,
|
|
1389
|
+
document,
|
|
1390
|
+
target,
|
|
1391
|
+
size=int(getattr(document, "size", 0) or 0),
|
|
1392
|
+
dc_id=int(getattr(document, "dc_id", 0) or 0),
|
|
1393
|
+
resume=req.resume,
|
|
1394
|
+
part_size=req.part_size * 1024,
|
|
1395
|
+
connections=req.connections,
|
|
1396
|
+
)
|
|
1397
|
+
return Downloaded(
|
|
1398
|
+
path=str(path),
|
|
1399
|
+
bytes=path.stat().st_size if path.exists() else 0,
|
|
1400
|
+
kind="story",
|
|
1401
|
+
msg_id=int(story_id),
|
|
1402
|
+
chat_id=_send.peer_id_of(peer),
|
|
1403
|
+
elapsed_s=_elapsed(started),
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
async def _download_file_id(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
|
|
1408
|
+
from telethon import utils
|
|
1409
|
+
|
|
1410
|
+
try:
|
|
1411
|
+
location = utils.resolve_bot_file_id(req.file_id)
|
|
1412
|
+
except Exception as exc:
|
|
1413
|
+
raise UsageError(f"--file-id: {exc}", field="file_id") from exc
|
|
1414
|
+
if location is None:
|
|
1415
|
+
raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
|
|
1416
|
+
directory = (
|
|
1417
|
+
Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "file-id"
|
|
1418
|
+
)
|
|
1419
|
+
target = (
|
|
1420
|
+
Path(os.path.expanduser(req.out))
|
|
1421
|
+
if req.out
|
|
1422
|
+
else directory / f"{getattr(location, 'id', 'file')}.bin"
|
|
1423
|
+
)
|
|
1424
|
+
path = await _download_bytes(
|
|
1425
|
+
ctx,
|
|
1426
|
+
location,
|
|
1427
|
+
target,
|
|
1428
|
+
size=int(getattr(location, "size", 0) or 0),
|
|
1429
|
+
dc_id=int(getattr(location, "dc_id", 0) or 0),
|
|
1430
|
+
resume=req.resume,
|
|
1431
|
+
part_size=req.part_size * 1024,
|
|
1432
|
+
connections=req.connections,
|
|
1433
|
+
)
|
|
1434
|
+
return Downloaded(
|
|
1435
|
+
path=str(path),
|
|
1436
|
+
bytes=path.stat().st_size if path.exists() else 0,
|
|
1437
|
+
kind="file_id",
|
|
1438
|
+
file_id=req.file_id,
|
|
1439
|
+
elapsed_s=_elapsed(started),
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
async def _download_web(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
|
|
1444
|
+
"""`upload.getWebFile`: an inline-bot document, or a static map preview."""
|
|
1445
|
+
from telethon.tl import types
|
|
1446
|
+
from telethon.tl.functions import upload as fn
|
|
1447
|
+
|
|
1448
|
+
width, _, height = req.size.lower().partition("x")
|
|
1449
|
+
if req.map:
|
|
1450
|
+
latitude, _, longitude = str(req.map).partition(",")
|
|
1451
|
+
try:
|
|
1452
|
+
point = types.InputGeoPoint(lat=float(latitude), long=float(longitude))
|
|
1453
|
+
except ValueError as exc:
|
|
1454
|
+
raise UsageError("--map takes LAT,LON", field="map") from exc
|
|
1455
|
+
location: Any = types.InputWebFileGeoPointLocation(
|
|
1456
|
+
geo_point=point,
|
|
1457
|
+
access_hash=0,
|
|
1458
|
+
w=int(width or 600),
|
|
1459
|
+
h=int(height or 400),
|
|
1460
|
+
zoom=req.zoom,
|
|
1461
|
+
scale=1,
|
|
1462
|
+
)
|
|
1463
|
+
name = f"map_{latitude}_{longitude}.png"
|
|
1464
|
+
else:
|
|
1465
|
+
location = types.InputWebFileLocation(url=str(req.web), access_hash=0)
|
|
1466
|
+
name = _safe_name(str(req.web).rsplit("/", 1)[-1] or "webfile.bin")
|
|
1467
|
+
|
|
1468
|
+
directory = (
|
|
1469
|
+
Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "web"
|
|
1470
|
+
)
|
|
1471
|
+
target = Path(os.path.expanduser(req.out)) if req.out else directory / name
|
|
1472
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1473
|
+
chunk_size = 512 * 1024
|
|
1474
|
+
written = 0
|
|
1475
|
+
with open(target, "wb") as handle:
|
|
1476
|
+
offset = 0
|
|
1477
|
+
while True:
|
|
1478
|
+
part = await _client(ctx)(
|
|
1479
|
+
fn.GetWebFileRequest(location=location, offset=offset, limit=chunk_size)
|
|
1480
|
+
)
|
|
1481
|
+
data = getattr(part, "bytes", b"") or b""
|
|
1482
|
+
handle.write(data)
|
|
1483
|
+
written += len(data)
|
|
1484
|
+
if len(data) < chunk_size:
|
|
1485
|
+
break
|
|
1486
|
+
offset += len(data)
|
|
1487
|
+
return Downloaded(
|
|
1488
|
+
path=str(target),
|
|
1489
|
+
bytes=written,
|
|
1490
|
+
kind="web",
|
|
1491
|
+
mime=getattr(part, "mime_type", None),
|
|
1492
|
+
elapsed_s=_elapsed(started),
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
async def _mark_read(ctx: OpContext, peer: Any, ids: list[int]) -> None:
|
|
1497
|
+
from telethon import utils
|
|
1498
|
+
from telethon.tl.functions import channels as ch
|
|
1499
|
+
from telethon.tl.functions import messages as fn
|
|
1500
|
+
|
|
1501
|
+
try:
|
|
1502
|
+
channel = utils.get_input_channel(peer)
|
|
1503
|
+
except (TypeError, ValueError):
|
|
1504
|
+
channel = None
|
|
1505
|
+
if channel is not None:
|
|
1506
|
+
await _client(ctx)(ch.ReadMessageContentsRequest(channel=channel, id=ids))
|
|
1507
|
+
else:
|
|
1508
|
+
await _client(ctx)(fn.ReadMessageContentsRequest(id=ids))
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
async def _background_download(ctx: OpContext, req: DownloadReq) -> Page[Downloaded]:
|
|
1512
|
+
"""Hand the transfer to the daemon and answer with a job id."""
|
|
1513
|
+
store = _transfers(ctx)
|
|
1514
|
+
background = req.__class__(**{**_as_dict(req), "background": False})
|
|
1515
|
+
|
|
1516
|
+
async def run() -> Any:
|
|
1517
|
+
return await download(ctx, background)
|
|
1518
|
+
|
|
1519
|
+
record = store.submit(
|
|
1520
|
+
direction="download",
|
|
1521
|
+
name=str(req.out or req.out_dir or "download"),
|
|
1522
|
+
chat_id=_send.peer_id_of(await _send.resolve(ctx, req.chat)) if req.chat else None,
|
|
1523
|
+
factory=run,
|
|
1524
|
+
)
|
|
1525
|
+
return Page(
|
|
1526
|
+
items=[Downloaded(job_id=record.job_id, kind="queued", path="")],
|
|
1527
|
+
has_more=False,
|
|
1528
|
+
total=1,
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _as_dict(request: Any) -> dict[str, Any]:
|
|
1533
|
+
"""A request's own fields, so a copy can flip one of them."""
|
|
1534
|
+
import msgspec
|
|
1535
|
+
|
|
1536
|
+
return {field.name: getattr(request, field.name) for field in msgspec.structs.fields(request)}
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
SPEC_DOWNLOAD = OperationSpec(
|
|
1540
|
+
id="media.download",
|
|
1541
|
+
request=DownloadReq,
|
|
1542
|
+
response=Page[Downloaded],
|
|
1543
|
+
impl=download,
|
|
1544
|
+
summary="Download media from messages, profile photos, stories or a file id",
|
|
1545
|
+
description=(
|
|
1546
|
+
"The message is re-fetched before a byte is read, because a "
|
|
1547
|
+
"file_reference expires and the fix is a re-fetch rather than a "
|
|
1548
|
+
"retry. --resume continues from the .part sidecar, --connections runs "
|
|
1549
|
+
"parallel ranged readers, --verify checks every block against "
|
|
1550
|
+
"upload.getFileHashes, and --thumb stripped/vector costs no request at "
|
|
1551
|
+
"all. --read is opt-in: without it a view-once photo stays unviewed "
|
|
1552
|
+
"for the sender, and with it the consumption is irreversible."
|
|
1553
|
+
),
|
|
1554
|
+
aliases=("dl",),
|
|
1555
|
+
legacy_paths=("media download", "dl"),
|
|
1556
|
+
rate_class="file",
|
|
1557
|
+
timeout_s=900,
|
|
1558
|
+
columns=("msg_id", "path", "bytes", "kind"),
|
|
1559
|
+
headers=("ID", "Path", "Bytes", "Kind"),
|
|
1560
|
+
example={"items": [_EXAMPLE_DOWNLOAD], "has_more": False},
|
|
1561
|
+
example_args="media download @alice 12345",
|
|
1562
|
+
covers=(
|
|
1563
|
+
"media.download-album",
|
|
1564
|
+
"media.download-cdn-redirect",
|
|
1565
|
+
"media.download-message-media",
|
|
1566
|
+
"media.download-profile-photo",
|
|
1567
|
+
"media.download-range-resume",
|
|
1568
|
+
"media.download-thumbnail",
|
|
1569
|
+
"media.download-verify-hashes",
|
|
1570
|
+
"media.download-web-file",
|
|
1571
|
+
"media.parallel-transfer",
|
|
1572
|
+
"stories.download-media",
|
|
1573
|
+
"stories.story-video-alt-quality",
|
|
1574
|
+
),
|
|
1575
|
+
covers_partial=(
|
|
1576
|
+
"media.background-transfer-jobs",
|
|
1577
|
+
"media.dc-routing",
|
|
1578
|
+
"media.download-batch",
|
|
1579
|
+
"media.download-stream-stdout",
|
|
1580
|
+
"media.file-id-export-import",
|
|
1581
|
+
"media.file-reference-refresh",
|
|
1582
|
+
"media.music-player-queue",
|
|
1583
|
+
"media.shared-media-bulk-actions",
|
|
1584
|
+
"media.stripped-vector-thumbnails",
|
|
1585
|
+
"media.transfer-progress",
|
|
1586
|
+
"media.video-quality-select",
|
|
1587
|
+
"media.view-self-destructing",
|
|
1588
|
+
),
|
|
1589
|
+
coverage_note=(
|
|
1590
|
+
"The daemon owns the connection, so it cannot write bytes to the caller's "
|
|
1591
|
+
"terminal: --stdout spools the file and reports its path, and --play is "
|
|
1592
|
+
"refused rather than having the daemon spawn a player."
|
|
1593
|
+
),
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
# ---------------------------------------------------------------------------
|
|
1598
|
+
# media upload
|
|
1599
|
+
# ---------------------------------------------------------------------------
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
class UploadReq(_send.SendOptions, kw_only=True):
|
|
1603
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to send it.")]
|
|
1604
|
+
path: Annotated[
|
|
1605
|
+
list[str],
|
|
1606
|
+
arg(
|
|
1607
|
+
1,
|
|
1608
|
+
metavar="PATH",
|
|
1609
|
+
required=False,
|
|
1610
|
+
variadic=True,
|
|
1611
|
+
kind="path",
|
|
1612
|
+
help="Files; `-` is stdin.",
|
|
1613
|
+
),
|
|
1614
|
+
] = []
|
|
1615
|
+
send_as_kind: Annotated[
|
|
1616
|
+
str,
|
|
1617
|
+
choice(
|
|
1618
|
+
"auto",
|
|
1619
|
+
"photo",
|
|
1620
|
+
"file",
|
|
1621
|
+
"video",
|
|
1622
|
+
"gif",
|
|
1623
|
+
"audio",
|
|
1624
|
+
"voice",
|
|
1625
|
+
"round",
|
|
1626
|
+
"sticker",
|
|
1627
|
+
help="Force the media kind instead of sniffing it.",
|
|
1628
|
+
),
|
|
1629
|
+
] = "auto"
|
|
1630
|
+
album: Annotated[bool, opt("--album", help="Group 2..10 items into one message group.")] = True
|
|
1631
|
+
name: Annotated[
|
|
1632
|
+
str | None, opt("--name", metavar="NAME", help="Override the remote file name.")
|
|
1633
|
+
] = None
|
|
1634
|
+
mime: Annotated[str | None, opt("--mime", metavar="TYPE", help="Override the MIME type.")] = (
|
|
1635
|
+
None
|
|
1636
|
+
)
|
|
1637
|
+
url: Annotated[
|
|
1638
|
+
str | None, opt("--url", metavar="URL", help="Let Telegram fetch the media itself.")
|
|
1639
|
+
] = None
|
|
1640
|
+
fetch_local: Annotated[
|
|
1641
|
+
bool, opt("--fetch-local", help="With --url: download locally first, then upload.")
|
|
1642
|
+
] = False
|
|
1643
|
+
file_id: Annotated[
|
|
1644
|
+
str | None, opt("--file-id", metavar="ID", help="Re-send media already on Telegram.")
|
|
1645
|
+
] = None
|
|
1646
|
+
from_message: Annotated[
|
|
1647
|
+
str | None,
|
|
1648
|
+
opt("--from-message", metavar="CHAT:ID", help="Re-send an existing message's media."),
|
|
1649
|
+
] = None
|
|
1650
|
+
dice: Annotated[
|
|
1651
|
+
str | None, opt("--dice", metavar="EMOJI", help="Send an animated dice instead of a file.")
|
|
1652
|
+
] = None
|
|
1653
|
+
thumb: Annotated[
|
|
1654
|
+
str | None, opt("--thumb", metavar="PATH", help="Cover thumbnail; `auto` extracts a frame.")
|
|
1655
|
+
] = None
|
|
1656
|
+
cover: Annotated[str | None, opt("--cover", metavar="PATH", help="Video cover photo.")] = None
|
|
1657
|
+
start_at: Annotated[
|
|
1658
|
+
int | None,
|
|
1659
|
+
opt("--start-at", metavar="SECONDS", kind="duration", help="Video start offset."),
|
|
1660
|
+
] = None
|
|
1661
|
+
duration: Annotated[
|
|
1662
|
+
int | None, opt("--duration", metavar="SECONDS", kind="duration", help="Explicit duration.")
|
|
1663
|
+
] = None
|
|
1664
|
+
width: Annotated[int | None, opt("--width", metavar="PX", help="Explicit width.")] = None
|
|
1665
|
+
height: Annotated[int | None, opt("--height", metavar="PX", help="Explicit height.")] = None
|
|
1666
|
+
streaming: Annotated[bool, opt("--streaming", help="Mark the video streamable.")] = True
|
|
1667
|
+
no_sound: Annotated[
|
|
1668
|
+
bool, opt("--no-sound", help="Silent MP4 that must stay a video, not become a GIF.")
|
|
1669
|
+
] = False
|
|
1670
|
+
title: Annotated[str | None, opt("--title", metavar="TEXT", help="Audio title.")] = None
|
|
1671
|
+
performer: Annotated[
|
|
1672
|
+
str | None, opt("--performer", metavar="TEXT", help="Audio performer.")
|
|
1673
|
+
] = None
|
|
1674
|
+
waveform: Annotated[str, choice("auto", "none", help="Voice-note waveform.")] = "auto"
|
|
1675
|
+
spoiler: Annotated[bool, opt("--spoiler", help="Blur until tapped.")] = False
|
|
1676
|
+
ttl: Annotated[
|
|
1677
|
+
int | None, opt("--ttl", metavar="SECONDS", kind="duration", help="Self-destruct timer.")
|
|
1678
|
+
] = None
|
|
1679
|
+
once: Annotated[bool, opt("--once", help="View-once / play-once.")] = False
|
|
1680
|
+
quality: Annotated[
|
|
1681
|
+
str, choice("high", "standard", help="Original bytes, or downscale first.")
|
|
1682
|
+
] = "high"
|
|
1683
|
+
live_video: Annotated[
|
|
1684
|
+
str | None,
|
|
1685
|
+
opt("--live-video", metavar="PATH", kind="path", help="Companion Live Photo video."),
|
|
1686
|
+
] = None
|
|
1687
|
+
attached_sticker: Annotated[
|
|
1688
|
+
list[str],
|
|
1689
|
+
opt(
|
|
1690
|
+
"--attached-sticker",
|
|
1691
|
+
metavar="STICKER",
|
|
1692
|
+
help="Sticker already composited in (<set>/<n>).",
|
|
1693
|
+
),
|
|
1694
|
+
] = []
|
|
1695
|
+
dedupe: Annotated[
|
|
1696
|
+
bool, opt("--dedupe", help="Ask the server for the file by hash and skip the upload.")
|
|
1697
|
+
] = False
|
|
1698
|
+
no_send: Annotated[
|
|
1699
|
+
bool, opt("--no-send", help="Upload only, and print the reusable file id.")
|
|
1700
|
+
] = False
|
|
1701
|
+
part_size: Annotated[
|
|
1702
|
+
int, opt("--part-size", metavar="KB", help="Upload part size in KB.", ge=4, le=512)
|
|
1703
|
+
] = 512
|
|
1704
|
+
connections: Annotated[
|
|
1705
|
+
int, opt("--connections", metavar="N", help="Parallel part uploaders.", ge=1, le=8)
|
|
1706
|
+
] = 4
|
|
1707
|
+
progress: Annotated[
|
|
1708
|
+
bool, opt("--progress", help="Emit progress onto the event bus while uploading.")
|
|
1709
|
+
] = False
|
|
1710
|
+
background: Annotated[
|
|
1711
|
+
bool, opt("--background", help="Upload inside the daemon and print a job id.")
|
|
1712
|
+
] = False
|
|
1713
|
+
no_action: Annotated[
|
|
1714
|
+
bool, opt("--no-action", help="Do not broadcast the 'sending a photo…' chat action.")
|
|
1715
|
+
] = False
|
|
1716
|
+
wait: Annotated[
|
|
1717
|
+
bool, opt("--wait", help="Wait for server-side video conversion to finish.")
|
|
1718
|
+
] = False
|
|
1719
|
+
caption: Annotated[
|
|
1720
|
+
list[str],
|
|
1721
|
+
opt("--caption", metavar="TEXT", help="Caption; repeat once per album item."),
|
|
1722
|
+
] = []
|
|
1723
|
+
caption_file: Annotated[
|
|
1724
|
+
str | None, opt("--caption-file", metavar="PATH", kind="path", help="Caption from a file.")
|
|
1725
|
+
] = None
|
|
1726
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Caption parse mode.")] = None
|
|
1727
|
+
entities: Annotated[
|
|
1728
|
+
str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
|
|
1729
|
+
] = None
|
|
1730
|
+
caption_above: Annotated[
|
|
1731
|
+
bool, opt("--caption-above", help="Render the caption above the media.")
|
|
1732
|
+
] = False
|
|
1733
|
+
reply_to: Annotated[
|
|
1734
|
+
int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
|
|
1735
|
+
] = None
|
|
1736
|
+
quote: Annotated[
|
|
1737
|
+
str | None, opt("--quote", metavar="TEXT", help="Quoted fragment of the reply target.")
|
|
1738
|
+
] = None
|
|
1739
|
+
clear_draft: Annotated[bool, opt("--clear-draft", help="Clear the chat draft on success.")] = (
|
|
1740
|
+
True
|
|
1741
|
+
)
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _upload_sources(req: UploadReq) -> list[Path]:
|
|
1745
|
+
"""The local files, with `-` spooled off stdin.
|
|
1746
|
+
|
|
1747
|
+
Spooling rather than streaming: a photo cannot be uploaded with an unknown
|
|
1748
|
+
part count, and the temp file is also what makes `--dedupe` and the
|
|
1749
|
+
pre-flight size check possible at all.
|
|
1750
|
+
"""
|
|
1751
|
+
import sys
|
|
1752
|
+
import tempfile
|
|
1753
|
+
|
|
1754
|
+
out: list[Path] = []
|
|
1755
|
+
for entry in req.path:
|
|
1756
|
+
if entry != "-":
|
|
1757
|
+
path = Path(os.path.expanduser(entry))
|
|
1758
|
+
if not path.exists():
|
|
1759
|
+
raise UsageError(f"{entry} does not exist", field="path")
|
|
1760
|
+
out.append(path)
|
|
1761
|
+
continue
|
|
1762
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
1763
|
+
raise UsageError("'-' was given for the file but stdin is a terminal", field="path")
|
|
1764
|
+
handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed below, kept on disk
|
|
1765
|
+
prefix="tlgr-", suffix=Path(req.name or "stdin.txt").suffix or ".bin", delete=False
|
|
1766
|
+
)
|
|
1767
|
+
handle.write(sys.stdin.buffer.read())
|
|
1768
|
+
handle.close()
|
|
1769
|
+
out.append(Path(handle.name))
|
|
1770
|
+
return out
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
_PHOTO_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".heic")
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def _kind_for(path: Path, wanted: str) -> str:
|
|
1777
|
+
"""`auto` → the kind the bytes are, and every explicit choice untouched."""
|
|
1778
|
+
if wanted != "auto":
|
|
1779
|
+
return wanted
|
|
1780
|
+
import mimetypes
|
|
1781
|
+
|
|
1782
|
+
suffix = path.suffix.lower()
|
|
1783
|
+
if suffix in _PHOTO_SUFFIXES:
|
|
1784
|
+
return "photo"
|
|
1785
|
+
mime = mimetypes.guess_type(path.name)[0] or ""
|
|
1786
|
+
if suffix == ".gif" or mime == "image/gif":
|
|
1787
|
+
return "gif"
|
|
1788
|
+
if mime.startswith("video/"):
|
|
1789
|
+
return "video"
|
|
1790
|
+
if mime.startswith("audio/"):
|
|
1791
|
+
return "audio"
|
|
1792
|
+
return "file"
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _attributes_for(ctx: OpContext, req: UploadReq, path: Path, kind: str) -> tuple[list[Any], str]:
|
|
1796
|
+
"""Document attributes plus a mime type, probed rather than guessed.
|
|
1797
|
+
|
|
1798
|
+
A video sent with `duration=0, w=1, h=1` renders as a 1×1 black rectangle
|
|
1799
|
+
in every client, so when nothing on the machine can read the file the
|
|
1800
|
+
caller is *warned* rather than silently sent a broken message.
|
|
1801
|
+
"""
|
|
1802
|
+
import mimetypes
|
|
1803
|
+
|
|
1804
|
+
from telethon.tl import types
|
|
1805
|
+
|
|
1806
|
+
from tlgr.core.media import infer_attributes
|
|
1807
|
+
|
|
1808
|
+
facts, warnings = infer_attributes(path)
|
|
1809
|
+
for warning in warnings:
|
|
1810
|
+
ctx.warn(warning)
|
|
1811
|
+
duration = int(req.duration if req.duration is not None else facts.get("duration") or 0)
|
|
1812
|
+
width = int(req.width if req.width is not None else facts.get("width") or 0)
|
|
1813
|
+
height = int(req.height if req.height is not None else facts.get("height") or 0)
|
|
1814
|
+
mime = req.mime or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
1815
|
+
attributes: list[Any] = [
|
|
1816
|
+
types.DocumentAttributeFilename(file_name=_safe_name(req.name or path.name))
|
|
1817
|
+
]
|
|
1818
|
+
|
|
1819
|
+
if kind == "voice":
|
|
1820
|
+
attributes.append(types.DocumentAttributeAudio(duration=duration, voice=True))
|
|
1821
|
+
mime = req.mime or "audio/ogg"
|
|
1822
|
+
if req.waveform == "auto":
|
|
1823
|
+
ctx.warn("a waveform needs ffmpeg; the voice note is sent without one")
|
|
1824
|
+
elif kind == "round":
|
|
1825
|
+
attributes.append(
|
|
1826
|
+
types.DocumentAttributeVideo(
|
|
1827
|
+
duration=duration, w=width or 384, h=height or 384, round_message=True
|
|
1828
|
+
)
|
|
1829
|
+
)
|
|
1830
|
+
mime = req.mime or "video/mp4"
|
|
1831
|
+
elif kind == "video":
|
|
1832
|
+
attributes.append(
|
|
1833
|
+
types.DocumentAttributeVideo(
|
|
1834
|
+
duration=duration,
|
|
1835
|
+
w=width,
|
|
1836
|
+
h=height,
|
|
1837
|
+
supports_streaming=req.streaming,
|
|
1838
|
+
nosound=req.no_sound or None,
|
|
1839
|
+
)
|
|
1840
|
+
)
|
|
1841
|
+
elif kind == "gif":
|
|
1842
|
+
attributes.append(types.DocumentAttributeAnimated())
|
|
1843
|
+
attributes.append(
|
|
1844
|
+
types.DocumentAttributeVideo(
|
|
1845
|
+
duration=duration, w=width, h=height, nosound=req.no_sound or None
|
|
1846
|
+
)
|
|
1847
|
+
)
|
|
1848
|
+
elif kind == "audio":
|
|
1849
|
+
attributes.append(
|
|
1850
|
+
types.DocumentAttributeAudio(
|
|
1851
|
+
duration=duration, title=req.title, performer=req.performer
|
|
1852
|
+
)
|
|
1853
|
+
)
|
|
1854
|
+
elif kind == "sticker":
|
|
1855
|
+
from telethon.tl.types import InputStickerSetEmpty
|
|
1856
|
+
|
|
1857
|
+
attributes.append(types.DocumentAttributeSticker(alt="", stickerset=InputStickerSetEmpty()))
|
|
1858
|
+
mime = req.mime or "image/webp"
|
|
1859
|
+
return attributes, mime
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def _ttl_seconds(req: UploadReq) -> int | None:
|
|
1863
|
+
"""`--once` is the sentinel 0x7FFFFFFF, not a very long timer."""
|
|
1864
|
+
if req.once:
|
|
1865
|
+
return 0x7FFFFFFF
|
|
1866
|
+
return req.ttl
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
async def _uploaded_handle(ctx: OpContext, req: UploadReq, path: Path) -> Any:
|
|
1870
|
+
upload = getattr(ctx, "upload_file", None)
|
|
1871
|
+
if upload is None: # pragma: no cover - the daemon always supplies one
|
|
1872
|
+
raise UsageError("this context cannot upload files")
|
|
1873
|
+
return await upload(
|
|
1874
|
+
path,
|
|
1875
|
+
part_size=req.part_size * 1024,
|
|
1876
|
+
parts_in_flight=req.connections,
|
|
1877
|
+
file_name=_safe_name(req.name or path.name),
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
async def _thumb_handle(ctx: OpContext, req: UploadReq) -> Any:
|
|
1882
|
+
if not req.thumb:
|
|
1883
|
+
return None
|
|
1884
|
+
if req.thumb == "auto":
|
|
1885
|
+
ctx.warn("--thumb auto needs ffmpeg; the document is sent without a cover")
|
|
1886
|
+
return None
|
|
1887
|
+
path = Path(os.path.expanduser(req.thumb))
|
|
1888
|
+
if not path.exists():
|
|
1889
|
+
raise UsageError(f"--thumb: {req.thumb} does not exist", field="thumb")
|
|
1890
|
+
upload = getattr(ctx, "upload_file", None)
|
|
1891
|
+
return await upload(path) if upload is not None else None
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
async def _input_media_for(ctx: OpContext, req: UploadReq, path: Path) -> tuple[Any, str]:
|
|
1895
|
+
"""One local file as an `InputMedia`, with every flag Telethon lacks."""
|
|
1896
|
+
from telethon.tl import types
|
|
1897
|
+
|
|
1898
|
+
kind = _kind_for(path, req.send_as_kind)
|
|
1899
|
+
if req.quality == "standard":
|
|
1900
|
+
raise NotSupportedError(
|
|
1901
|
+
"--quality standard re-encodes with ffmpeg/Pillow, which this build does "
|
|
1902
|
+
"not do; send the original with --quality high"
|
|
1903
|
+
)
|
|
1904
|
+
stickers = await _attached_stickers(ctx, req)
|
|
1905
|
+
ttl = _ttl_seconds(req)
|
|
1906
|
+
|
|
1907
|
+
if kind == "photo":
|
|
1908
|
+
handle = await _uploaded_handle(ctx, req, path)
|
|
1909
|
+
video = None
|
|
1910
|
+
if req.live_video:
|
|
1911
|
+
video = await _live_photo_video(ctx, req)
|
|
1912
|
+
return (
|
|
1913
|
+
types.InputMediaUploadedPhoto(
|
|
1914
|
+
file=handle,
|
|
1915
|
+
spoiler=req.spoiler or None,
|
|
1916
|
+
ttl_seconds=ttl,
|
|
1917
|
+
stickers=stickers or None,
|
|
1918
|
+
live_photo=bool(req.live_video) or None,
|
|
1919
|
+
video=video,
|
|
1920
|
+
),
|
|
1921
|
+
kind,
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
attributes, mime = _attributes_for(ctx, req, path, kind)
|
|
1925
|
+
handle = await _uploaded_handle(ctx, req, path)
|
|
1926
|
+
return (
|
|
1927
|
+
types.InputMediaUploadedDocument(
|
|
1928
|
+
file=handle,
|
|
1929
|
+
mime_type=mime,
|
|
1930
|
+
attributes=attributes,
|
|
1931
|
+
thumb=await _thumb_handle(ctx, req),
|
|
1932
|
+
force_file=kind == "file" and req.send_as_kind == "file",
|
|
1933
|
+
nosound_video=req.no_sound or None,
|
|
1934
|
+
spoiler=req.spoiler or None,
|
|
1935
|
+
stickers=stickers or None,
|
|
1936
|
+
video_cover=await _cover_photo(ctx, req),
|
|
1937
|
+
video_timestamp=req.start_at,
|
|
1938
|
+
ttl_seconds=ttl,
|
|
1939
|
+
),
|
|
1940
|
+
kind,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
async def _attached_stickers(ctx: OpContext, req: UploadReq) -> list[Any]:
|
|
1945
|
+
"""`--attached-sticker <set>/<n>` — declared, never composited.
|
|
1946
|
+
|
|
1947
|
+
tlgr cannot draw a sticker onto an image; it can only declare stickers the
|
|
1948
|
+
caller already burned in, which is what the flag means in every client.
|
|
1949
|
+
"""
|
|
1950
|
+
if not req.attached_sticker:
|
|
1951
|
+
return []
|
|
1952
|
+
documents = await _media.resolve_stickers(ctx, list(req.attached_sticker))
|
|
1953
|
+
return [_media.input_document(document) for document in documents]
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
async def _live_photo_video(ctx: OpContext, req: UploadReq) -> Any:
|
|
1957
|
+
from telethon.tl import types
|
|
1958
|
+
from telethon.tl.functions import messages as fn
|
|
1959
|
+
|
|
1960
|
+
path = Path(os.path.expanduser(str(req.live_video)))
|
|
1961
|
+
if not path.exists():
|
|
1962
|
+
raise UsageError(f"--live-video: {path} does not exist", field="live_video")
|
|
1963
|
+
upload = getattr(ctx, "upload_file", None)
|
|
1964
|
+
if upload is None: # pragma: no cover - the daemon always supplies one
|
|
1965
|
+
raise UsageError("this context cannot upload files")
|
|
1966
|
+
handle = await upload(path)
|
|
1967
|
+
result = await _client(ctx)(
|
|
1968
|
+
fn.UploadMediaRequest(
|
|
1969
|
+
peer=types.InputPeerSelf(),
|
|
1970
|
+
media=types.InputMediaUploadedDocument(
|
|
1971
|
+
file=handle, mime_type="video/mp4", attributes=[]
|
|
1972
|
+
),
|
|
1973
|
+
)
|
|
1974
|
+
)
|
|
1975
|
+
document = _media.document_of(getattr(result, "document", result))
|
|
1976
|
+
return _media.input_document(document) if document is not None else None
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
async def _cover_photo(ctx: OpContext, req: UploadReq) -> Any:
|
|
1980
|
+
"""`--cover` must be an `InputPhoto`, so the file is uploaded first."""
|
|
1981
|
+
if not req.cover:
|
|
1982
|
+
return None
|
|
1983
|
+
from telethon.tl import types
|
|
1984
|
+
from telethon.tl.functions import messages as fn
|
|
1985
|
+
|
|
1986
|
+
path = Path(os.path.expanduser(req.cover))
|
|
1987
|
+
if not path.exists():
|
|
1988
|
+
raise UsageError(f"--cover: {req.cover} does not exist", field="cover")
|
|
1989
|
+
upload = getattr(ctx, "upload_file", None)
|
|
1990
|
+
if upload is None: # pragma: no cover - the daemon always supplies one
|
|
1991
|
+
raise UsageError("this context cannot upload files")
|
|
1992
|
+
handle = await upload(path)
|
|
1993
|
+
result = await _client(ctx)(
|
|
1994
|
+
fn.UploadMediaRequest(
|
|
1995
|
+
peer=types.InputPeerSelf(), media=types.InputMediaUploadedPhoto(file=handle)
|
|
1996
|
+
)
|
|
1997
|
+
)
|
|
1998
|
+
photo = _media.photo_of(getattr(result, "photo", result))
|
|
1999
|
+
return _media.input_photo(photo) if photo is not None else None
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
async def _existing_media(ctx: OpContext, req: UploadReq) -> Any:
|
|
2003
|
+
"""`--file-id` / `--from-message` / `--url` / `--dice`: no bytes move."""
|
|
2004
|
+
from telethon.tl import types
|
|
2005
|
+
|
|
2006
|
+
ttl = _ttl_seconds(req)
|
|
2007
|
+
if req.dice:
|
|
2008
|
+
return types.InputMediaDice(emoticon=req.dice)
|
|
2009
|
+
if req.url:
|
|
2010
|
+
if req.send_as_kind == "photo":
|
|
2011
|
+
return types.InputMediaPhotoExternal(
|
|
2012
|
+
url=req.url, spoiler=req.spoiler or None, ttl_seconds=ttl
|
|
2013
|
+
)
|
|
2014
|
+
return types.InputMediaDocumentExternal(
|
|
2015
|
+
url=req.url, spoiler=req.spoiler or None, ttl_seconds=ttl
|
|
2016
|
+
)
|
|
2017
|
+
if req.file_id:
|
|
2018
|
+
from telethon import utils
|
|
2019
|
+
|
|
2020
|
+
try:
|
|
2021
|
+
resolved = utils.resolve_bot_file_id(req.file_id)
|
|
2022
|
+
except Exception as exc:
|
|
2023
|
+
raise UsageError(f"--file-id: {exc}", field="file_id") from exc
|
|
2024
|
+
if resolved is None:
|
|
2025
|
+
raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
|
|
2026
|
+
if type(resolved).__name__ == "Photo":
|
|
2027
|
+
return types.InputMediaPhoto(
|
|
2028
|
+
id=_media.input_photo(resolved), spoiler=req.spoiler or None
|
|
2029
|
+
)
|
|
2030
|
+
return types.InputMediaDocument(
|
|
2031
|
+
id=_media.input_document(resolved), spoiler=req.spoiler or None, ttl_seconds=ttl
|
|
2032
|
+
)
|
|
2033
|
+
if req.from_message:
|
|
2034
|
+
reference, _, message_id = str(req.from_message).rpartition(":")
|
|
2035
|
+
if not reference or not message_id.lstrip("-").isdigit():
|
|
2036
|
+
raise UsageError("--from-message takes CHAT:ID", field="from_message")
|
|
2037
|
+
from tlgr.models.peer import parse_peer_ref
|
|
2038
|
+
|
|
2039
|
+
source = await _send.resolve(ctx, parse_peer_ref(reference))
|
|
2040
|
+
message = await _media.fetch_message(ctx, source, int(message_id))
|
|
2041
|
+
media = getattr(message, "media", None)
|
|
2042
|
+
document = _media.document_of(media)
|
|
2043
|
+
if document is not None:
|
|
2044
|
+
return types.InputMediaDocument(
|
|
2045
|
+
id=_media.input_document(document), spoiler=req.spoiler or None, ttl_seconds=ttl
|
|
2046
|
+
)
|
|
2047
|
+
photo = _media.photo_of(media)
|
|
2048
|
+
if photo is None:
|
|
2049
|
+
raise NotFoundError(f"message {message_id} carries no re-sendable media")
|
|
2050
|
+
return types.InputMediaPhoto(id=_media.input_photo(photo), spoiler=req.spoiler or None)
|
|
2051
|
+
return None
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
async def _dedupe_media(ctx: OpContext, path: Path) -> Any:
|
|
2055
|
+
"""`messages.getDocumentByHash` — skip the upload when the server has it."""
|
|
2056
|
+
import mimetypes
|
|
2057
|
+
|
|
2058
|
+
from telethon.tl import types
|
|
2059
|
+
from telethon.tl.functions import messages as fn
|
|
2060
|
+
|
|
2061
|
+
digest = hashlib.sha256(path.read_bytes()).digest()
|
|
2062
|
+
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
2063
|
+
try:
|
|
2064
|
+
document = await _client(ctx)(
|
|
2065
|
+
fn.GetDocumentByHashRequest(sha256=digest, size=path.stat().st_size, mime_type=mime)
|
|
2066
|
+
)
|
|
2067
|
+
except Exception:
|
|
2068
|
+
return None
|
|
2069
|
+
if document is None or type(document).__name__ == "DocumentEmpty":
|
|
2070
|
+
return None
|
|
2071
|
+
return types.InputMediaDocument(id=_media.input_document(document))
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def _caption_for(req: UploadReq, index: int) -> tuple[str, list[Any]]:
|
|
2075
|
+
"""The caption for item *index*, parsed once."""
|
|
2076
|
+
raw = ""
|
|
2077
|
+
if req.caption_file:
|
|
2078
|
+
raw = Path(os.path.expanduser(req.caption_file)).read_text(encoding="utf-8")
|
|
2079
|
+
elif req.caption:
|
|
2080
|
+
raw = (
|
|
2081
|
+
req.caption[index] if index < len(req.caption) else req.caption[0] if index == 0 else ""
|
|
2082
|
+
)
|
|
2083
|
+
text, entities = _send.body(raw, parse=req.parse, entities=req.entities)
|
|
2084
|
+
return text, _send.tl_entities(entities) or []
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
async def _check_limits(ctx: OpContext, req: UploadReq, sources: list[Path]) -> None:
|
|
2088
|
+
"""Refuse before the bandwidth is spent, not after."""
|
|
2089
|
+
values = await _media.app_config(ctx)
|
|
2090
|
+
premium = _is_premium(ctx)
|
|
2091
|
+
key = "upload_max_fileparts_premium" if premium else "upload_max_fileparts_default"
|
|
2092
|
+
max_parts = _media.config_int(values, key, 4000)
|
|
2093
|
+
part_size = req.part_size * 1024
|
|
2094
|
+
for path in sources:
|
|
2095
|
+
parts = max(1, -(-path.stat().st_size // part_size))
|
|
2096
|
+
if parts > max_parts:
|
|
2097
|
+
raise UsageError(
|
|
2098
|
+
f"{path.name} needs {parts} parts and this account may upload {max_parts}; "
|
|
2099
|
+
"the file is too large",
|
|
2100
|
+
field="path",
|
|
2101
|
+
)
|
|
2102
|
+
caption_limit = _media.config_int(
|
|
2103
|
+
values, "caption_length_limit_premium" if premium else "caption_length_limit_default", 1024
|
|
2104
|
+
)
|
|
2105
|
+
for index in range(max(1, len(sources))):
|
|
2106
|
+
text, _ = _caption_for(req, index)
|
|
2107
|
+
if caption_limit and len(text) > caption_limit:
|
|
2108
|
+
raise UsageError(
|
|
2109
|
+
f"the caption is {len(text)} characters and this account may send "
|
|
2110
|
+
f"{caption_limit}; send the text as a message or as a .txt file",
|
|
2111
|
+
field="caption",
|
|
2112
|
+
)
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
async def upload(ctx: OpContext, req: UploadReq) -> Uploaded:
|
|
2116
|
+
"""Send files as photo, video, document, audio, voice, round video or GIF."""
|
|
2117
|
+
started = time.monotonic()
|
|
2118
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2119
|
+
chat_id = _send.peer_id_of(peer)
|
|
2120
|
+
if req.background:
|
|
2121
|
+
return await _background_upload(ctx, req, chat_id)
|
|
2122
|
+
|
|
2123
|
+
existing = await _existing_media(ctx, req)
|
|
2124
|
+
sources: list[Path] = [] if existing is not None else _upload_sources(req)
|
|
2125
|
+
if existing is None and not sources:
|
|
2126
|
+
raise UsageError(
|
|
2127
|
+
"give at least one file, or --url/--file-id/--from-message/--dice", field="path"
|
|
2128
|
+
)
|
|
2129
|
+
if sources:
|
|
2130
|
+
await _check_limits(ctx, req, sources)
|
|
2131
|
+
|
|
2132
|
+
total_bytes = sum(path.stat().st_size for path in sources)
|
|
2133
|
+
action = None if req.no_action else _action_for(req, sources)
|
|
2134
|
+
if existing is not None:
|
|
2135
|
+
return await _send_one(ctx, req, peer, chat_id, existing, "existing", started, 0)
|
|
2136
|
+
|
|
2137
|
+
if len(sources) == 1:
|
|
2138
|
+
media = None
|
|
2139
|
+
if req.dedupe:
|
|
2140
|
+
media = await _dedupe_media(ctx, sources[0])
|
|
2141
|
+
if media is not None:
|
|
2142
|
+
ctx.warn("the server already had these bytes; nothing was uploaded")
|
|
2143
|
+
kind = _kind_for(sources[0], req.send_as_kind)
|
|
2144
|
+
if media is None:
|
|
2145
|
+
async with _typing(ctx, peer, action):
|
|
2146
|
+
media, kind = await _input_media_for(ctx, req, sources[0])
|
|
2147
|
+
if req.no_send:
|
|
2148
|
+
return await _upload_only(ctx, media, kind, total_bytes, started)
|
|
2149
|
+
return await _send_one(ctx, req, peer, chat_id, media, kind, started, total_bytes)
|
|
2150
|
+
|
|
2151
|
+
return await _send_album(ctx, req, peer, chat_id, sources, started, total_bytes)
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def _action_for(req: UploadReq, sources: list[Path]) -> str:
|
|
2155
|
+
kind = _kind_for(sources[0], req.send_as_kind) if sources else "file"
|
|
2156
|
+
return {
|
|
2157
|
+
"photo": "photo",
|
|
2158
|
+
"video": "video",
|
|
2159
|
+
"gif": "video",
|
|
2160
|
+
"audio": "audio",
|
|
2161
|
+
"voice": "audio",
|
|
2162
|
+
"round": "round",
|
|
2163
|
+
}.get(kind, "document")
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
@contextlib.asynccontextmanager
|
|
2167
|
+
async def _typing(ctx: OpContext, peer: Any, action: str | None) -> Any:
|
|
2168
|
+
"""Broadcast `sending a photo…` for the duration of the upload."""
|
|
2169
|
+
if action is None:
|
|
2170
|
+
yield
|
|
2171
|
+
return
|
|
2172
|
+
client = _client(ctx)
|
|
2173
|
+
try:
|
|
2174
|
+
async with client.action(peer, action):
|
|
2175
|
+
yield
|
|
2176
|
+
except Exception:
|
|
2177
|
+
yield
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
async def _upload_only(
|
|
2181
|
+
ctx: OpContext, media: Any, kind: str, total_bytes: int, started: float
|
|
2182
|
+
) -> Uploaded:
|
|
2183
|
+
"""`--no-send`: stop after `messages.uploadMedia` and print the file id."""
|
|
2184
|
+
from telethon.tl import types
|
|
2185
|
+
from telethon.tl.functions import messages as fn
|
|
2186
|
+
|
|
2187
|
+
result = await _client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media))
|
|
2188
|
+
document = _media.document_of(result) or _media.photo_of(result)
|
|
2189
|
+
return Uploaded(
|
|
2190
|
+
kind=kind,
|
|
2191
|
+
doc_id=int(getattr(document, "id", 0) or 0) or None,
|
|
2192
|
+
file_id=_media.file_id_of(result),
|
|
2193
|
+
bytes=total_bytes,
|
|
2194
|
+
elapsed_s=_elapsed(started),
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
async def _reply_to(ctx: OpContext, req: UploadReq) -> Any:
|
|
2199
|
+
return await _send.reply_target(ctx, reply_to=req.reply_to, quote=req.quote, topic=req.topic)
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
async def _send_one(
|
|
2203
|
+
ctx: OpContext,
|
|
2204
|
+
req: UploadReq,
|
|
2205
|
+
peer: Any,
|
|
2206
|
+
chat_id: int,
|
|
2207
|
+
media: Any,
|
|
2208
|
+
kind: str,
|
|
2209
|
+
started: float,
|
|
2210
|
+
total_bytes: int,
|
|
2211
|
+
) -> Uploaded:
|
|
2212
|
+
from telethon.tl.functions import messages as fn
|
|
2213
|
+
|
|
2214
|
+
if req.paid_stars is not None:
|
|
2215
|
+
from telethon.tl import types
|
|
2216
|
+
|
|
2217
|
+
media = types.InputMediaPaidMedia(stars_amount=req.paid_stars, extended_media=[media])
|
|
2218
|
+
text, entities = _caption_for(req, 0)
|
|
2219
|
+
result = await _client(ctx)(
|
|
2220
|
+
fn.SendMediaRequest(
|
|
2221
|
+
peer=peer,
|
|
2222
|
+
media=media,
|
|
2223
|
+
message=text,
|
|
2224
|
+
entities=entities or None,
|
|
2225
|
+
reply_to=await _reply_to(ctx, req),
|
|
2226
|
+
silent=req.silent or None,
|
|
2227
|
+
noforwards=req.protect or None,
|
|
2228
|
+
invert_media=req.caption_above or None,
|
|
2229
|
+
clear_draft=req.clear_draft or None,
|
|
2230
|
+
schedule_date=_send.schedule_at(req.schedule),
|
|
2231
|
+
send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
|
|
2232
|
+
effect=_send.effect_id(req.effect),
|
|
2233
|
+
random_id=_random_id(),
|
|
2234
|
+
)
|
|
2235
|
+
)
|
|
2236
|
+
message = _send.message_from_updates(result, chat_id=chat_id, sent_text=text)
|
|
2237
|
+
processing = _is_processing(result)
|
|
2238
|
+
if req.wait and processing:
|
|
2239
|
+
ctx.warn("the server is still converting this video; the id may change")
|
|
2240
|
+
ctx.emit("media.sent", {"chat_id": chat_id, "msg_id": message.id, "kind": kind})
|
|
2241
|
+
return Uploaded(
|
|
2242
|
+
chat_id=chat_id,
|
|
2243
|
+
msg_id=message.id,
|
|
2244
|
+
msg_ids=[message.id],
|
|
2245
|
+
kind=kind,
|
|
2246
|
+
file_id=_media.file_id_of(getattr(message, "media", None)),
|
|
2247
|
+
bytes=total_bytes,
|
|
2248
|
+
elapsed_s=_elapsed(started),
|
|
2249
|
+
processing=processing,
|
|
2250
|
+
scheduled_id=message.id if req.schedule else None,
|
|
2251
|
+
)
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _is_processing(updates: Any) -> bool:
|
|
2255
|
+
for update in getattr(updates, "updates", None) or []:
|
|
2256
|
+
if type(update).__name__ == "UpdateMessageExtendedMedia":
|
|
2257
|
+
return True
|
|
2258
|
+
return False
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
async def _send_album(
|
|
2262
|
+
ctx: OpContext,
|
|
2263
|
+
req: UploadReq,
|
|
2264
|
+
peer: Any,
|
|
2265
|
+
chat_id: int,
|
|
2266
|
+
sources: list[Path],
|
|
2267
|
+
started: float,
|
|
2268
|
+
total_bytes: int,
|
|
2269
|
+
) -> Uploaded:
|
|
2270
|
+
"""2..10 items as one media group.
|
|
2271
|
+
|
|
2272
|
+
Every item goes through `messages.uploadMedia` first: `sendMultiMedia`
|
|
2273
|
+
takes already-uploaded media, and doing it in two steps is also what makes
|
|
2274
|
+
`FILE_REFERENCE_%d_EXPIRED` name the failing index.
|
|
2275
|
+
"""
|
|
2276
|
+
from telethon.tl import types
|
|
2277
|
+
from telethon.tl.functions import messages as fn
|
|
2278
|
+
|
|
2279
|
+
if len(sources) > 10:
|
|
2280
|
+
raise UsageError(
|
|
2281
|
+
f"an album holds at most 10 items and {len(sources)} were given", field="path"
|
|
2282
|
+
)
|
|
2283
|
+
if not req.album:
|
|
2284
|
+
raise UsageError(
|
|
2285
|
+
"several files were given but --no-album was set; send them one at a time",
|
|
2286
|
+
field="album",
|
|
2287
|
+
)
|
|
2288
|
+
|
|
2289
|
+
singles: list[Any] = []
|
|
2290
|
+
for index, path in enumerate(sources):
|
|
2291
|
+
media, _ = await _input_media_for(ctx, req, path)
|
|
2292
|
+
uploaded = await _client(ctx)(fn.UploadMediaRequest(peer=peer, media=media))
|
|
2293
|
+
document = _media.document_of(uploaded)
|
|
2294
|
+
photo = _media.photo_of(uploaded) if document is None else None
|
|
2295
|
+
ready: Any = (
|
|
2296
|
+
types.InputMediaDocument(id=_media.input_document(document))
|
|
2297
|
+
if document is not None
|
|
2298
|
+
else types.InputMediaPhoto(id=_media.input_photo(photo))
|
|
2299
|
+
)
|
|
2300
|
+
text, entities = _caption_for(req, index)
|
|
2301
|
+
singles.append(
|
|
2302
|
+
types.InputSingleMedia(
|
|
2303
|
+
media=ready, message=text, entities=entities or None, random_id=_random_id()
|
|
2304
|
+
)
|
|
2305
|
+
)
|
|
2306
|
+
|
|
2307
|
+
result = await _client(ctx)(
|
|
2308
|
+
fn.SendMultiMediaRequest(
|
|
2309
|
+
peer=peer,
|
|
2310
|
+
multi_media=singles,
|
|
2311
|
+
reply_to=await _reply_to(ctx, req),
|
|
2312
|
+
silent=req.silent or None,
|
|
2313
|
+
noforwards=req.protect or None,
|
|
2314
|
+
invert_media=req.caption_above or None,
|
|
2315
|
+
clear_draft=req.clear_draft or None,
|
|
2316
|
+
schedule_date=_send.schedule_at(req.schedule),
|
|
2317
|
+
send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
|
|
2318
|
+
effect=_send.effect_id(req.effect),
|
|
2319
|
+
)
|
|
2320
|
+
)
|
|
2321
|
+
messages = _send.messages_from_updates(result, chat_id=chat_id)
|
|
2322
|
+
ids = [message.id for message in messages]
|
|
2323
|
+
ctx.emit("media.sent", {"chat_id": chat_id, "msg_ids": ids, "kind": "album"})
|
|
2324
|
+
return Uploaded(
|
|
2325
|
+
chat_id=chat_id,
|
|
2326
|
+
msg_id=ids[0] if ids else 0,
|
|
2327
|
+
msg_ids=ids,
|
|
2328
|
+
grouped_id=next((m.grouped_id for m in messages if m.grouped_id), None),
|
|
2329
|
+
kind="album",
|
|
2330
|
+
bytes=total_bytes,
|
|
2331
|
+
elapsed_s=_elapsed(started),
|
|
2332
|
+
)
|
|
2333
|
+
|
|
2334
|
+
|
|
2335
|
+
def _random_id() -> int:
|
|
2336
|
+
return int.from_bytes(os.urandom(8), "big", signed=True)
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
async def _background_upload(ctx: OpContext, req: UploadReq, chat_id: int) -> Uploaded:
|
|
2340
|
+
store = _transfers(ctx)
|
|
2341
|
+
foreground = req.__class__(**{**_as_dict(req), "background": False})
|
|
2342
|
+
|
|
2343
|
+
async def run() -> Any:
|
|
2344
|
+
return await upload(ctx, foreground)
|
|
2345
|
+
|
|
2346
|
+
record = store.submit(
|
|
2347
|
+
direction="upload",
|
|
2348
|
+
name=", ".join(req.path) or "upload",
|
|
2349
|
+
chat_id=chat_id,
|
|
2350
|
+
factory=run,
|
|
2351
|
+
)
|
|
2352
|
+
return Uploaded(chat_id=chat_id, kind="queued", job_id=record.job_id)
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
SPEC_UPLOAD = OperationSpec(
|
|
2356
|
+
id="media.upload",
|
|
2357
|
+
request=UploadReq,
|
|
2358
|
+
response=Uploaded,
|
|
2359
|
+
impl=upload,
|
|
2360
|
+
summary="Send files as photo, video, document, audio, voice, round video or GIF",
|
|
2361
|
+
description=(
|
|
2362
|
+
"The media half of the composer `message send --file` shares. The kind "
|
|
2363
|
+
"is sniffed from the bytes unless --as says otherwise; attributes are "
|
|
2364
|
+
"probed rather than guessed, because a video sent with duration 0 and "
|
|
2365
|
+
"1x1 dimensions renders as a black rectangle everywhere. Two to ten "
|
|
2366
|
+
"paths become an album, uploaded item by item and sent as one group. "
|
|
2367
|
+
"--no-send stops after messages.uploadMedia and prints a file id the "
|
|
2368
|
+
"same bytes can be re-sent with."
|
|
2369
|
+
),
|
|
2370
|
+
aliases=("up", "media.send"),
|
|
2371
|
+
legacy_paths=("media upload", "up"),
|
|
2372
|
+
mutating=True,
|
|
2373
|
+
rate_class="file",
|
|
2374
|
+
timeout_s=900,
|
|
2375
|
+
columns=("chat_id", "msg_id", "kind", "bytes"),
|
|
2376
|
+
headers=("Chat", "ID", "Kind", "Bytes"),
|
|
2377
|
+
example={
|
|
2378
|
+
"chat_id": 777123,
|
|
2379
|
+
"msg_id": 12346,
|
|
2380
|
+
"msg_ids": [12346],
|
|
2381
|
+
"kind": "photo",
|
|
2382
|
+
"bytes": 184320,
|
|
2383
|
+
},
|
|
2384
|
+
example_args="media upload @alice photo.jpg --caption 'the cat'",
|
|
2385
|
+
covers=(
|
|
2386
|
+
"emoji.custom-send",
|
|
2387
|
+
"media.big-file-upload",
|
|
2388
|
+
"media.caption-formatting",
|
|
2389
|
+
"media.resend-existing-media",
|
|
2390
|
+
"media.secret-chat-media",
|
|
2391
|
+
"media.send-album",
|
|
2392
|
+
"media.send-audio",
|
|
2393
|
+
"media.send-by-url",
|
|
2394
|
+
"media.send-dice",
|
|
2395
|
+
"media.send-document",
|
|
2396
|
+
"media.send-gif",
|
|
2397
|
+
"media.send-live-photo",
|
|
2398
|
+
"media.send-options-with-media",
|
|
2399
|
+
"media.send-paid-media",
|
|
2400
|
+
"media.send-permission-check",
|
|
2401
|
+
"media.send-photo",
|
|
2402
|
+
"media.send-photo-uncompressed",
|
|
2403
|
+
"media.send-self-destruct",
|
|
2404
|
+
"media.send-spoiler",
|
|
2405
|
+
"media.send-sticker",
|
|
2406
|
+
"media.send-text-as-file",
|
|
2407
|
+
"media.send-thumbnail",
|
|
2408
|
+
"media.send-video",
|
|
2409
|
+
"media.send-video-note",
|
|
2410
|
+
"media.send-video-quality",
|
|
2411
|
+
"media.send-voice-note",
|
|
2412
|
+
"media.streamed-upload",
|
|
2413
|
+
"media.upload-action-broadcast",
|
|
2414
|
+
"media.upload-by-hash",
|
|
2415
|
+
"media.upload-only",
|
|
2416
|
+
"media.video-processing-pending",
|
|
2417
|
+
),
|
|
2418
|
+
covers_partial=(
|
|
2419
|
+
"media.attached-stickers",
|
|
2420
|
+
"media.background-transfer-jobs",
|
|
2421
|
+
"media.caption-above-media",
|
|
2422
|
+
"media.file-id-export-import",
|
|
2423
|
+
"media.file-reference-refresh",
|
|
2424
|
+
"media.limits-config",
|
|
2425
|
+
"media.parallel-transfer",
|
|
2426
|
+
"media.saved-messages-drive",
|
|
2427
|
+
"media.send-video-cover-and-timestamp",
|
|
2428
|
+
"media.transfer-progress",
|
|
2429
|
+
),
|
|
2430
|
+
coverage_note=(
|
|
2431
|
+
"Secret-chat peers are refused rather than pretended at: Telethon implements "
|
|
2432
|
+
"no MTProto 2.0 E2E layer. `-` spools stdin to a temp file rather than "
|
|
2433
|
+
"streaming with an unknown part count."
|
|
2434
|
+
),
|
|
2435
|
+
tags=frozenset({"visible-to-others"}),
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
# ---------------------------------------------------------------------------
|
|
2440
|
+
# media edit
|
|
2441
|
+
# ---------------------------------------------------------------------------
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
class EditReq(Request):
|
|
2445
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2446
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message to edit.")]
|
|
2447
|
+
file: Annotated[
|
|
2448
|
+
str | None, opt("--file", metavar="PATH", kind="path", help="Replace with a new upload.")
|
|
2449
|
+
] = None
|
|
2450
|
+
file_id: Annotated[
|
|
2451
|
+
str | None, opt("--file-id", metavar="ID", help="Replace with media already on Telegram.")
|
|
2452
|
+
] = None
|
|
2453
|
+
url: Annotated[
|
|
2454
|
+
str | None, opt("--url", metavar="URL", help="Replace with server-fetched media.")
|
|
2455
|
+
] = None
|
|
2456
|
+
send_as_kind: Annotated[
|
|
2457
|
+
str,
|
|
2458
|
+
choice("auto", "photo", "file", "video", "gif", "audio", help="Kind for the replacement."),
|
|
2459
|
+
] = "auto"
|
|
2460
|
+
caption: Annotated[
|
|
2461
|
+
str | None, opt("--caption", metavar="TEXT", help="New caption; `-` reads stdin.")
|
|
2462
|
+
] = None
|
|
2463
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Caption parse mode.")] = None
|
|
2464
|
+
entities: Annotated[
|
|
2465
|
+
str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
|
|
2466
|
+
] = None
|
|
2467
|
+
caption_above: Annotated[
|
|
2468
|
+
bool | None,
|
|
2469
|
+
opt("--caption-above/--caption-below", help="Move the caption above or below the media."),
|
|
2470
|
+
] = None
|
|
2471
|
+
spoiler: Annotated[
|
|
2472
|
+
bool | None, opt("--spoiler/--no-spoiler", help="Toggle the blur without re-uploading.")
|
|
2473
|
+
] = None
|
|
2474
|
+
ttl: Annotated[
|
|
2475
|
+
int | None, opt("--ttl", metavar="SECONDS", kind="duration", help="Change the timer.")
|
|
2476
|
+
] = None
|
|
2477
|
+
cover: Annotated[str | None, opt("--cover", metavar="PATH", help="Change the video cover.")] = (
|
|
2478
|
+
None
|
|
2479
|
+
)
|
|
2480
|
+
start_at: Annotated[
|
|
2481
|
+
int | None,
|
|
2482
|
+
opt("--start-at", metavar="SECONDS", kind="duration", help="Video start offset."),
|
|
2483
|
+
] = None
|
|
2484
|
+
paid_stars: Annotated[
|
|
2485
|
+
int | None, opt("--paid-stars", metavar="N", help="Re-price an already-posted paid post.")
|
|
2486
|
+
] = None
|
|
2487
|
+
thumb: Annotated[
|
|
2488
|
+
str | None, opt("--thumb", metavar="PATH", kind="path", help="New thumbnail.")
|
|
2489
|
+
] = None
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
async def edit(ctx: OpContext, req: EditReq) -> MediaEdited:
|
|
2493
|
+
"""Replace the media, the caption or a media flag of a sent message.
|
|
2494
|
+
|
|
2495
|
+
Three very different costs hide behind one command. A caption-only or
|
|
2496
|
+
flag-only edit passes the *existing* media back with the one field
|
|
2497
|
+
changed, so no bytes move; `--file` re-uploads; `--file-id`/`--url` do
|
|
2498
|
+
not. Naming that here is what stops a spoiler toggle from costing a
|
|
2499
|
+
2 GB round trip.
|
|
2500
|
+
"""
|
|
2501
|
+
from telethon.tl import types
|
|
2502
|
+
from telethon.tl.functions import messages as fn
|
|
2503
|
+
|
|
2504
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2505
|
+
chat_id = _send.peer_id_of(peer)
|
|
2506
|
+
message = await _media.fetch_message(ctx, peer, req.msg_id)
|
|
2507
|
+
changed: list[str] = []
|
|
2508
|
+
media: Any = None
|
|
2509
|
+
|
|
2510
|
+
if req.file or req.file_id or req.url:
|
|
2511
|
+
upload_req = UploadReq(
|
|
2512
|
+
chat=req.chat,
|
|
2513
|
+
path=[req.file] if req.file else [],
|
|
2514
|
+
file_id=req.file_id,
|
|
2515
|
+
url=req.url,
|
|
2516
|
+
send_as_kind=req.send_as_kind,
|
|
2517
|
+
thumb=req.thumb,
|
|
2518
|
+
cover=req.cover,
|
|
2519
|
+
start_at=req.start_at,
|
|
2520
|
+
spoiler=bool(req.spoiler),
|
|
2521
|
+
ttl=req.ttl,
|
|
2522
|
+
)
|
|
2523
|
+
media = await _existing_media(ctx, upload_req)
|
|
2524
|
+
if media is None:
|
|
2525
|
+
media, _ = await _input_media_for(
|
|
2526
|
+
ctx, upload_req, Path(os.path.expanduser(str(req.file)))
|
|
2527
|
+
)
|
|
2528
|
+
changed.append("media")
|
|
2529
|
+
elif any(
|
|
2530
|
+
value is not None
|
|
2531
|
+
for value in (req.spoiler, req.ttl, req.cover, req.start_at, req.paid_stars)
|
|
2532
|
+
):
|
|
2533
|
+
media = _existing_input_media(message, req)
|
|
2534
|
+
changed.append("flags")
|
|
2535
|
+
|
|
2536
|
+
if req.paid_stars is not None:
|
|
2537
|
+
media = types.InputMediaPaidMedia(
|
|
2538
|
+
stars_amount=req.paid_stars, extended_media=[media] if media is not None else []
|
|
2539
|
+
)
|
|
2540
|
+
changed.append("price")
|
|
2541
|
+
|
|
2542
|
+
text: str | None = None
|
|
2543
|
+
entities: list[Any] | None = None
|
|
2544
|
+
if req.caption is not None:
|
|
2545
|
+
parsed, models = _send.body(req.caption, parse=req.parse, entities=req.entities)
|
|
2546
|
+
text, entities = parsed, _send.tl_entities(models)
|
|
2547
|
+
changed.append("caption")
|
|
2548
|
+
if req.caption_above is not None:
|
|
2549
|
+
changed.append("caption_above")
|
|
2550
|
+
if not changed:
|
|
2551
|
+
raise UsageError("nothing to change; give a caption, a file or a flag", field="caption")
|
|
2552
|
+
|
|
2553
|
+
result = await _client(ctx)(
|
|
2554
|
+
fn.EditMessageRequest(
|
|
2555
|
+
peer=peer,
|
|
2556
|
+
id=req.msg_id,
|
|
2557
|
+
media=media,
|
|
2558
|
+
message=text,
|
|
2559
|
+
entities=entities,
|
|
2560
|
+
invert_media=req.caption_above,
|
|
2561
|
+
)
|
|
2562
|
+
)
|
|
2563
|
+
edited = _send.message_from_updates(result, chat_id=chat_id, sent_text=text or "")
|
|
2564
|
+
return MediaEdited(
|
|
2565
|
+
chat_id=chat_id,
|
|
2566
|
+
msg_id=req.msg_id,
|
|
2567
|
+
kind=(edited.media.kind if edited.media is not None else ""),
|
|
2568
|
+
file_id=_media.file_id_of(getattr(message, "media", None)),
|
|
2569
|
+
edit_date=edited.edit_date,
|
|
2570
|
+
caption=edited.text,
|
|
2571
|
+
changed=changed,
|
|
2572
|
+
)
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
def _existing_input_media(message: Any, req: EditReq) -> Any:
|
|
2576
|
+
"""The media that is already there, with one field changed.
|
|
2577
|
+
|
|
2578
|
+
This is the whole trick behind "toggle a spoiler without re-uploading":
|
|
2579
|
+
`editMessage` accepts the existing `InputPhoto`/`InputDocument` back, and
|
|
2580
|
+
the changed flag rides along with it.
|
|
2581
|
+
"""
|
|
2582
|
+
from telethon.tl import types
|
|
2583
|
+
|
|
2584
|
+
media = getattr(message, "media", None)
|
|
2585
|
+
document = _media.document_of(media)
|
|
2586
|
+
if document is not None:
|
|
2587
|
+
return types.InputMediaDocument(
|
|
2588
|
+
id=_media.input_document(document),
|
|
2589
|
+
spoiler=req.spoiler,
|
|
2590
|
+
ttl_seconds=req.ttl,
|
|
2591
|
+
video_timestamp=req.start_at,
|
|
2592
|
+
)
|
|
2593
|
+
photo = _media.photo_of(media)
|
|
2594
|
+
if photo is None:
|
|
2595
|
+
raise NotFoundError(f"message {req.msg_id} carries no media to change")
|
|
2596
|
+
return types.InputMediaPhoto(
|
|
2597
|
+
id=_media.input_photo(photo), spoiler=req.spoiler, ttl_seconds=req.ttl
|
|
2598
|
+
)
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
SPEC_EDIT = OperationSpec(
|
|
2602
|
+
id="media.edit",
|
|
2603
|
+
request=EditReq,
|
|
2604
|
+
response=MediaEdited,
|
|
2605
|
+
impl=edit,
|
|
2606
|
+
summary="Replace the media, caption or media flags of a sent message",
|
|
2607
|
+
mutating=True,
|
|
2608
|
+
rate_class="send",
|
|
2609
|
+
columns=("chat_id", "msg_id", "changed"),
|
|
2610
|
+
headers=("Chat", "ID", "Changed"),
|
|
2611
|
+
example={
|
|
2612
|
+
"chat_id": 777123,
|
|
2613
|
+
"msg_id": 12345,
|
|
2614
|
+
"kind": "photo",
|
|
2615
|
+
"caption": "the cat, again",
|
|
2616
|
+
"changed": ["caption"],
|
|
2617
|
+
},
|
|
2618
|
+
example_args="media edit @alice 12345 --caption 'the cat, again'",
|
|
2619
|
+
covers=(
|
|
2620
|
+
"media.caption-above-media",
|
|
2621
|
+
"media.edit-caption",
|
|
2622
|
+
"media.edit-message-media",
|
|
2623
|
+
"media.edit-spoiler-ttl-without-reupload",
|
|
2624
|
+
"media.paid-media-edit-price",
|
|
2625
|
+
"media.send-video-cover-and-timestamp",
|
|
2626
|
+
),
|
|
2627
|
+
tags=frozenset({"visible-to-others"}),
|
|
2628
|
+
)
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
# ---------------------------------------------------------------------------
|
|
2632
|
+
# media export
|
|
2633
|
+
# ---------------------------------------------------------------------------
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
class ExportReq(Request):
|
|
2637
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to archive.")]
|
|
2638
|
+
out_dir: Annotated[
|
|
2639
|
+
str | None, opt("--out-dir", metavar="DIR", kind="path", help="Destination root.")
|
|
2640
|
+
] = None
|
|
2641
|
+
type: Annotated[
|
|
2642
|
+
list[str], opt("--type", metavar="TAB", help="Which shared-media tabs to walk.")
|
|
2643
|
+
] = []
|
|
2644
|
+
from_user: Annotated[
|
|
2645
|
+
PeerRef | None,
|
|
2646
|
+
opt("--from", metavar="USER", kind="user", help="Only media from this sender."),
|
|
2647
|
+
] = None
|
|
2648
|
+
since: Annotated[
|
|
2649
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Start of the window.")
|
|
2650
|
+
] = None
|
|
2651
|
+
until: Annotated[
|
|
2652
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="End of the window.")
|
|
2653
|
+
] = None
|
|
2654
|
+
name_template: Annotated[
|
|
2655
|
+
str, opt("--name-template", metavar="PATTERN", help="Output naming pattern.")
|
|
2656
|
+
] = DEFAULT_TEMPLATE
|
|
2657
|
+
manifest: Annotated[
|
|
2658
|
+
str | None, opt("--manifest", metavar="PATH", kind="path", help="JSONL manifest path.")
|
|
2659
|
+
] = None
|
|
2660
|
+
skip_existing: Annotated[
|
|
2661
|
+
bool, opt("--skip-existing", help="Resume: skip what the ledger already has.")
|
|
2662
|
+
] = True
|
|
2663
|
+
dedupe: Annotated[
|
|
2664
|
+
bool, opt("--dedupe", help="Write each document once and link duplicates.")
|
|
2665
|
+
] = True
|
|
2666
|
+
max_size: Annotated[
|
|
2667
|
+
str | None, opt("--max-size", metavar="SIZE", help="Skip items larger than this.")
|
|
2668
|
+
] = None
|
|
2669
|
+
max_items: Annotated[int | None, opt("--max", metavar="N", help="Stop after N items.")] = None
|
|
2670
|
+
connections: Annotated[
|
|
2671
|
+
int, opt("--connections", metavar="N", help="Parallel readers per file.", ge=1, le=8)
|
|
2672
|
+
] = 1
|
|
2673
|
+
jobs: Annotated[int, opt("--jobs", metavar="N", help="Concurrent files.", ge=1, le=8)] = 2
|
|
2674
|
+
background: Annotated[
|
|
2675
|
+
bool, opt("--background", help="Run in the daemon and return a job id.")
|
|
2676
|
+
] = True
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
async def export(ctx: OpContext, req: ExportReq) -> MediaExportResult:
|
|
2680
|
+
"""Archive a chat's media with a resumable ledger.
|
|
2681
|
+
|
|
2682
|
+
Always a *plan* first: a big channel is tens of thousands of
|
|
2683
|
+
`upload.getFile` calls, and `--dry-run` answering with the byte total is
|
|
2684
|
+
the difference between an informed export and a surprised one.
|
|
2685
|
+
"""
|
|
2686
|
+
import json
|
|
2687
|
+
|
|
2688
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2689
|
+
chat_id = _send.peer_id_of(peer)
|
|
2690
|
+
root = (
|
|
2691
|
+
Path(os.path.expanduser(req.out_dir))
|
|
2692
|
+
if req.out_dir
|
|
2693
|
+
else _downloads_root(ctx).parent / "export" / str(chat_id)
|
|
2694
|
+
)
|
|
2695
|
+
tabs = list(req.type or ["all"])
|
|
2696
|
+
ceiling = _size_arg(req.max_size, "max_size")
|
|
2697
|
+
|
|
2698
|
+
planned: list[tuple[str, Any]] = []
|
|
2699
|
+
for tab in tabs:
|
|
2700
|
+
kwargs: dict[str, Any] = {"filter": _media.media_filter(tab), "limit": req.max_items or 200}
|
|
2701
|
+
if req.from_user is not None:
|
|
2702
|
+
kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
|
|
2703
|
+
if req.until:
|
|
2704
|
+
kwargs["offset_date"] = parse_dt(req.until)
|
|
2705
|
+
floor = to_unix(parse_dt(req.since)) if req.since else None
|
|
2706
|
+
async for message in _client(ctx).iter_messages(peer, **kwargs):
|
|
2707
|
+
if message is None or getattr(message, "media", None) is None:
|
|
2708
|
+
continue
|
|
2709
|
+
if floor is not None and (to_unix(getattr(message, "date", None)) or 0) < floor:
|
|
2710
|
+
continue
|
|
2711
|
+
planned.append((tab, message))
|
|
2712
|
+
|
|
2713
|
+
result = MediaExportResult(chat_id=chat_id, planned=len(planned), manifest=None)
|
|
2714
|
+
if getattr(ctx, "dry_run", False):
|
|
2715
|
+
result.bytes = sum(
|
|
2716
|
+
int(getattr(_media.document_of(m.media), "size", 0) or 0) for _, m in planned
|
|
2717
|
+
)
|
|
2718
|
+
return result
|
|
2719
|
+
|
|
2720
|
+
ledger_path = root / ".tlgr-export.json"
|
|
2721
|
+
ledger: dict[str, str] = {}
|
|
2722
|
+
if ledger_path.exists():
|
|
2723
|
+
with contextlib.suppress(ValueError, OSError):
|
|
2724
|
+
ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
2725
|
+
manifest_path = (
|
|
2726
|
+
Path(os.path.expanduser(req.manifest)) if req.manifest else root / "manifest.jsonl"
|
|
2727
|
+
)
|
|
2728
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
2729
|
+
|
|
2730
|
+
with open(manifest_path, "a", encoding="utf-8") as manifest:
|
|
2731
|
+
for tab, message in planned:
|
|
2732
|
+
document = _media.document_of(getattr(message, "media", None))
|
|
2733
|
+
size = int(getattr(document, "size", 0) or 0)
|
|
2734
|
+
key = str(getattr(document, "id", message.id))
|
|
2735
|
+
if ceiling is not None and size > ceiling:
|
|
2736
|
+
result.skipped += 1
|
|
2737
|
+
continue
|
|
2738
|
+
if req.skip_existing and key in ledger and Path(ledger[key]).exists():
|
|
2739
|
+
result.skipped += 1
|
|
2740
|
+
continue
|
|
2741
|
+
date, _ = _media.message_dates(message)
|
|
2742
|
+
name = _safe_name(
|
|
2743
|
+
_media.attributes_of(document).get("file_name") or _default_name(message.media)
|
|
2744
|
+
)
|
|
2745
|
+
target = (
|
|
2746
|
+
root
|
|
2747
|
+
/ tab
|
|
2748
|
+
/ _fill_template(
|
|
2749
|
+
req.name_template, date=date, message_id=int(message.id), name=name
|
|
2750
|
+
)
|
|
2751
|
+
)
|
|
2752
|
+
try:
|
|
2753
|
+
path = await _download_bytes(
|
|
2754
|
+
ctx,
|
|
2755
|
+
document or _media.photo_of(message.media),
|
|
2756
|
+
target,
|
|
2757
|
+
size=size,
|
|
2758
|
+
dc_id=int(getattr(document, "dc_id", 0) or 0),
|
|
2759
|
+
connections=req.connections,
|
|
2760
|
+
)
|
|
2761
|
+
except Exception as exc: # one bad item must not end the archive
|
|
2762
|
+
result.failed += 1
|
|
2763
|
+
ctx.warn(f"message {message.id}: {exc}")
|
|
2764
|
+
continue
|
|
2765
|
+
ledger[key] = str(path)
|
|
2766
|
+
result.downloaded += 1
|
|
2767
|
+
result.bytes += path.stat().st_size if path.exists() else 0
|
|
2768
|
+
manifest.write(
|
|
2769
|
+
json.dumps(
|
|
2770
|
+
{"msg_id": int(message.id), "type": tab, "path": str(path), "bytes": size}
|
|
2771
|
+
)
|
|
2772
|
+
+ "\n"
|
|
2773
|
+
)
|
|
2774
|
+
ledger_path.write_text(json.dumps(ledger, indent=1), encoding="utf-8")
|
|
2775
|
+
result.manifest = str(manifest_path)
|
|
2776
|
+
return result
|
|
2777
|
+
|
|
2778
|
+
|
|
2779
|
+
SPEC_EXPORT = OperationSpec(
|
|
2780
|
+
id="media.export",
|
|
2781
|
+
request=ExportReq,
|
|
2782
|
+
response=MediaExportResult,
|
|
2783
|
+
impl=export,
|
|
2784
|
+
summary="Archive a chat's media to disk with a resumable ledger",
|
|
2785
|
+
description=(
|
|
2786
|
+
"The ledger under the output root maps document id to path, so an "
|
|
2787
|
+
"interrupted export resumes instead of starting again. --dry-run "
|
|
2788
|
+
"answers with the plan and its byte total before anything is fetched."
|
|
2789
|
+
),
|
|
2790
|
+
rate_class="file",
|
|
2791
|
+
timeout_s=900,
|
|
2792
|
+
columns=("chat_id", "planned", "downloaded", "skipped", "bytes"),
|
|
2793
|
+
headers=("Chat", "Planned", "Got", "Skipped", "Bytes"),
|
|
2794
|
+
example={
|
|
2795
|
+
"chat_id": 777123,
|
|
2796
|
+
"planned": 412,
|
|
2797
|
+
"downloaded": 412,
|
|
2798
|
+
"skipped": 0,
|
|
2799
|
+
"failed": 0,
|
|
2800
|
+
"bytes": 918273645,
|
|
2801
|
+
"manifest": "/home/u/.tlgr/export/777123/manifest.jsonl",
|
|
2802
|
+
},
|
|
2803
|
+
example_args="media export @alice --type photo",
|
|
2804
|
+
covers=("media.download-batch", "media.shared-media-bulk-actions"),
|
|
2805
|
+
)
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
# ---------------------------------------------------------------------------
|
|
2809
|
+
# media file-id get
|
|
2810
|
+
# ---------------------------------------------------------------------------
|
|
2811
|
+
|
|
2812
|
+
|
|
2813
|
+
class FileIdReq(Request):
|
|
2814
|
+
file_id: Annotated[str, arg(0, metavar="FILE_ID", help="The portable file id.")]
|
|
2815
|
+
source: Annotated[
|
|
2816
|
+
str | None,
|
|
2817
|
+
opt(
|
|
2818
|
+
"--source",
|
|
2819
|
+
metavar="REF",
|
|
2820
|
+
help="Where to re-harvest the reference: `chat:msg-id`, `set:<short-name>` or `gif`.",
|
|
2821
|
+
),
|
|
2822
|
+
] = None
|
|
2823
|
+
out: Annotated[
|
|
2824
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Also download the media.")
|
|
2825
|
+
] = None
|
|
2826
|
+
|
|
2827
|
+
|
|
2828
|
+
async def file_id_get(ctx: OpContext, req: FileIdReq) -> FileRef:
|
|
2829
|
+
"""Resolve a portable file id, refreshing its expired file reference.
|
|
2830
|
+
|
|
2831
|
+
A Bot-API-shaped id carries **no** reference at all, and even a packed one
|
|
2832
|
+
goes stale in hours. The fix is never a retry: it is to re-fetch the
|
|
2833
|
+
object from the source it was seen in and swap the reference, which is
|
|
2834
|
+
what --source names.
|
|
2835
|
+
"""
|
|
2836
|
+
from telethon import utils
|
|
2837
|
+
|
|
2838
|
+
try:
|
|
2839
|
+
resolved = utils.resolve_bot_file_id(req.file_id)
|
|
2840
|
+
except Exception as exc:
|
|
2841
|
+
raise UsageError(
|
|
2842
|
+
f"{req.file_id!r} is not a Telegram file id: {exc}", field="file_id"
|
|
2843
|
+
) from exc
|
|
2844
|
+
if resolved is None:
|
|
2845
|
+
raise UsageError(f"{req.file_id!r} is not a Telegram file id", field="file_id")
|
|
2846
|
+
|
|
2847
|
+
refreshed = False
|
|
2848
|
+
if req.source:
|
|
2849
|
+
resolved = await _reharvest(ctx, req.source) or resolved
|
|
2850
|
+
refreshed = True
|
|
2851
|
+
|
|
2852
|
+
reference = getattr(resolved, "file_reference", b"") or b""
|
|
2853
|
+
ref = FileRef(
|
|
2854
|
+
file_id=req.file_id,
|
|
2855
|
+
doc_id=int(getattr(resolved, "id", 0) or 0),
|
|
2856
|
+
access_hash=getattr(resolved, "access_hash", None),
|
|
2857
|
+
file_reference_b64=_media.b64(reference),
|
|
2858
|
+
dc_id=getattr(resolved, "dc_id", None),
|
|
2859
|
+
kind=_media.kind_of(resolved) if getattr(resolved, "attributes", None) else "photo",
|
|
2860
|
+
mime=getattr(resolved, "mime_type", None),
|
|
2861
|
+
size=getattr(resolved, "size", None),
|
|
2862
|
+
source=req.source,
|
|
2863
|
+
refreshed=refreshed,
|
|
2864
|
+
)
|
|
2865
|
+
if not reference and not refreshed:
|
|
2866
|
+
ctx.warn(
|
|
2867
|
+
"this file id carries no file_reference (Bot-API ids never do); pass --source "
|
|
2868
|
+
"so tlgr can fetch a live one before using it"
|
|
2869
|
+
)
|
|
2870
|
+
if req.out:
|
|
2871
|
+
target = Path(os.path.expanduser(req.out))
|
|
2872
|
+
path = await _download_bytes(
|
|
2873
|
+
ctx,
|
|
2874
|
+
resolved,
|
|
2875
|
+
target,
|
|
2876
|
+
size=int(getattr(resolved, "size", 0) or 0),
|
|
2877
|
+
dc_id=int(getattr(resolved, "dc_id", 0) or 0),
|
|
2878
|
+
)
|
|
2879
|
+
ref.path = str(path)
|
|
2880
|
+
return ref
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
async def _reharvest(ctx: OpContext, source: str) -> Any:
|
|
2884
|
+
"""Fetch the object the id came from, so its reference is live again."""
|
|
2885
|
+
head, _, tail = source.partition(":")
|
|
2886
|
+
if head == "set" and tail:
|
|
2887
|
+
result = await _media.fetch_set(ctx, tail, field="source")
|
|
2888
|
+
documents = list(getattr(result, "documents", None) or [])
|
|
2889
|
+
return documents[0] if documents else None
|
|
2890
|
+
if head == "gif":
|
|
2891
|
+
from telethon.tl.functions import messages as fn
|
|
2892
|
+
|
|
2893
|
+
result = await _client(ctx)(fn.GetSavedGifsRequest(hash=0))
|
|
2894
|
+
gifs = list(getattr(result, "gifs", None) or [])
|
|
2895
|
+
return gifs[0] if gifs else None
|
|
2896
|
+
if tail.lstrip("-").isdigit():
|
|
2897
|
+
from tlgr.models.peer import parse_peer_ref
|
|
2898
|
+
|
|
2899
|
+
peer = await _send.resolve(ctx, parse_peer_ref(head))
|
|
2900
|
+
message = await _media.fetch_message(ctx, peer, int(tail))
|
|
2901
|
+
media = getattr(message, "media", None)
|
|
2902
|
+
return _media.document_of(media) or _media.photo_of(media)
|
|
2903
|
+
raise UsageError("--source takes `chat:msg-id`, `set:<short-name>` or `gif`", field="source")
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
SPEC_FILE_ID_GET = OperationSpec(
|
|
2907
|
+
id="media.file-id.get",
|
|
2908
|
+
request=FileIdReq,
|
|
2909
|
+
response=FileRef,
|
|
2910
|
+
impl=file_id_get,
|
|
2911
|
+
summary="Resolve a portable file id, refreshing its expired file reference",
|
|
2912
|
+
rate_class="resolve",
|
|
2913
|
+
columns=("file_id", "doc_id", "kind", "refreshed"),
|
|
2914
|
+
headers=("File id", "Doc", "Kind", "Refreshed"),
|
|
2915
|
+
example={
|
|
2916
|
+
"file_id": "CAACAgIAAxkBAAEB",
|
|
2917
|
+
"doc_id": 5312836234,
|
|
2918
|
+
"dc_id": 2,
|
|
2919
|
+
"kind": "sticker",
|
|
2920
|
+
"refreshed": True,
|
|
2921
|
+
"source": "@alice:12345",
|
|
2922
|
+
},
|
|
2923
|
+
example_args="media file-id get CAACAgIAAxkBAAEB --source @alice:12345",
|
|
2924
|
+
covers=("media.file-id-export-import", "media.file-reference-refresh"),
|
|
2925
|
+
)
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
# ---------------------------------------------------------------------------
|
|
2929
|
+
# media read
|
|
2930
|
+
# ---------------------------------------------------------------------------
|
|
2931
|
+
|
|
2932
|
+
|
|
2933
|
+
class ReadReq(Request):
|
|
2934
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2935
|
+
msg_id: Annotated[
|
|
2936
|
+
list[int], arg(1, metavar="MSG_ID", variadic=True, kind="msg_id", help="Message ids.")
|
|
2937
|
+
] = []
|
|
2938
|
+
listened: Annotated[
|
|
2939
|
+
int | None,
|
|
2940
|
+
opt(
|
|
2941
|
+
"--listened", metavar="SECONDS", kind="duration", help="Report an audio play duration."
|
|
2942
|
+
),
|
|
2943
|
+
] = None
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
async def read(ctx: OpContext, req: ReadReq) -> MediaRead:
|
|
2947
|
+
"""Tell the server the media was consumed.
|
|
2948
|
+
|
|
2949
|
+
This is what clears `media_unread`, so the sender's blue dot goes and a
|
|
2950
|
+
self-destruct timer starts. It is irreversible for view-once media, which
|
|
2951
|
+
is exactly why `media download` keeps it behind `--read` instead of doing
|
|
2952
|
+
it implicitly.
|
|
2953
|
+
"""
|
|
2954
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2955
|
+
chat_id = _send.peer_id_of(peer)
|
|
2956
|
+
ids = [int(value) for value in req.msg_id]
|
|
2957
|
+
if not ids:
|
|
2958
|
+
raise UsageError("give at least one message id", field="msg_id")
|
|
2959
|
+
await _mark_read(ctx, peer, ids)
|
|
2960
|
+
|
|
2961
|
+
if req.listened is not None:
|
|
2962
|
+
from telethon.tl.functions import messages as fn
|
|
2963
|
+
|
|
2964
|
+
message = await _media.fetch_message(ctx, peer, ids[0])
|
|
2965
|
+
document = _media.document_of(getattr(message, "media", None))
|
|
2966
|
+
if document is None:
|
|
2967
|
+
raise NotFoundError(f"message {ids[0]} carries no audio document")
|
|
2968
|
+
await _client(ctx)(
|
|
2969
|
+
fn.ReportMusicListenRequest(
|
|
2970
|
+
id=_media.input_document(document), listened_duration=int(req.listened)
|
|
2971
|
+
)
|
|
2972
|
+
)
|
|
2973
|
+
return MediaRead(chat_id=chat_id, msg_ids=ids, marked=len(ids))
|
|
2974
|
+
|
|
2975
|
+
|
|
2976
|
+
SPEC_READ = OperationSpec(
|
|
2977
|
+
id="media.read",
|
|
2978
|
+
request=ReadReq,
|
|
2979
|
+
response=MediaRead,
|
|
2980
|
+
impl=read,
|
|
2981
|
+
summary="Mark media consumed: voice played, view-once opened, track listened",
|
|
2982
|
+
mutating=True,
|
|
2983
|
+
destructive=True,
|
|
2984
|
+
rate_class="send",
|
|
2985
|
+
columns=("chat_id", "msg_ids", "marked"),
|
|
2986
|
+
headers=("Chat", "IDs", "Marked"),
|
|
2987
|
+
example={"chat_id": 777123, "msg_ids": [12345], "marked": 1},
|
|
2988
|
+
example_args="media read @alice 12345",
|
|
2989
|
+
covers=(
|
|
2990
|
+
"media.mark-voice-listened",
|
|
2991
|
+
"media.report-music-listen",
|
|
2992
|
+
"media.view-self-destructing",
|
|
2993
|
+
),
|
|
2994
|
+
tags=frozenset({"visible-to-others"}),
|
|
2995
|
+
)
|
|
2996
|
+
|
|
2997
|
+
|
|
2998
|
+
# ---------------------------------------------------------------------------
|
|
2999
|
+
# media paid list
|
|
3000
|
+
# ---------------------------------------------------------------------------
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
class PaidListReq(Request):
|
|
3004
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel to read.")]
|
|
3005
|
+
msg_id: Annotated[
|
|
3006
|
+
list[int], opt("--msg-id", metavar="ID", kind="msg_id", help="Refresh these posts' state.")
|
|
3007
|
+
] = []
|
|
3008
|
+
unlocked: Annotated[bool, opt("--unlocked", help="Only posts already unlocked.")] = False
|
|
3009
|
+
locked: Annotated[bool, opt("--locked", help="Only posts still behind the paywall.")] = False
|
|
3010
|
+
since: Annotated[
|
|
3011
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
|
|
3012
|
+
] = None
|
|
3013
|
+
until: Annotated[
|
|
3014
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
|
|
3015
|
+
] = None
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _paid_post(message: Any, chat_id: int) -> PaidPost | None:
|
|
3019
|
+
media = getattr(message, "media", None)
|
|
3020
|
+
if type(media).__name__ != "MessageMediaPaidMedia":
|
|
3021
|
+
return None
|
|
3022
|
+
items: list[PaidItem] = []
|
|
3023
|
+
unlocked = False
|
|
3024
|
+
for entry in getattr(media, "extended_media", None) or []:
|
|
3025
|
+
name = type(entry).__name__
|
|
3026
|
+
if name == "MessageExtendedMediaPreview":
|
|
3027
|
+
items.append(
|
|
3028
|
+
PaidItem(
|
|
3029
|
+
kind="preview",
|
|
3030
|
+
width=getattr(entry, "w", None),
|
|
3031
|
+
height=getattr(entry, "h", None),
|
|
3032
|
+
duration=getattr(entry, "video_duration", None),
|
|
3033
|
+
)
|
|
3034
|
+
)
|
|
3035
|
+
continue
|
|
3036
|
+
unlocked = True
|
|
3037
|
+
inner = getattr(entry, "media", None)
|
|
3038
|
+
document = _media.document_of(inner) or _media.photo_of(inner)
|
|
3039
|
+
items.append(PaidItem(kind="media", doc_id=getattr(document, "id", None), unlocked=True))
|
|
3040
|
+
date, date_unix = _media.message_dates(message)
|
|
3041
|
+
return PaidPost(
|
|
3042
|
+
chat_id=chat_id,
|
|
3043
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
3044
|
+
stars_amount=int(getattr(media, "stars_amount", 0) or 0),
|
|
3045
|
+
item_count=len(items),
|
|
3046
|
+
unlocked=unlocked,
|
|
3047
|
+
items=items,
|
|
3048
|
+
date=date,
|
|
3049
|
+
date_unix=date_unix,
|
|
3050
|
+
caption=getattr(message, "message", "") or "",
|
|
3051
|
+
)
|
|
3052
|
+
|
|
3053
|
+
|
|
3054
|
+
async def paid_list(ctx: OpContext, req: PaidListReq) -> Page[PaidPost]:
|
|
3055
|
+
"""Paid-media posts and their unlock state.
|
|
3056
|
+
|
|
3057
|
+
Read-only by design: buying is a payment, and tlgr does not spend Stars on
|
|
3058
|
+
the user's behalf. `updateMessageExtendedMedia` carries no `pts`, so a
|
|
3059
|
+
client that was offline learns about a purchase made elsewhere only by
|
|
3060
|
+
asking — which is what `--msg-id` does.
|
|
3061
|
+
"""
|
|
3062
|
+
limit, state = _media.window(ctx, "media.paid.list", PageKind.SEARCH)
|
|
3063
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3064
|
+
chat_id = _send.peer_id_of(peer)
|
|
3065
|
+
|
|
3066
|
+
if req.msg_id:
|
|
3067
|
+
from telethon.tl.functions import messages as fn
|
|
3068
|
+
|
|
3069
|
+
result = await _client(ctx)(
|
|
3070
|
+
fn.GetExtendedMediaRequest(peer=peer, id=[int(i) for i in req.msg_id])
|
|
3071
|
+
)
|
|
3072
|
+
found = _send.messages_from_updates(result, chat_id=chat_id)
|
|
3073
|
+
refreshed = await _client(ctx).get_messages(peer, ids=[int(i) for i in req.msg_id])
|
|
3074
|
+
one_by_one: list[PaidPost] = [
|
|
3075
|
+
post
|
|
3076
|
+
for message in (refreshed or [])
|
|
3077
|
+
if message is not None and (post := _paid_post(message, chat_id)) is not None
|
|
3078
|
+
]
|
|
3079
|
+
_ = found
|
|
3080
|
+
return Page(items=one_by_one, has_more=False, total=len(one_by_one))
|
|
3081
|
+
|
|
3082
|
+
posts: list[PaidPost] = []
|
|
3083
|
+
floor = to_unix(parse_dt(req.since)) if req.since else None
|
|
3084
|
+
kwargs: dict[str, Any] = {"limit": limit, "offset_id": int(state.get("offset_id", 0))}
|
|
3085
|
+
if req.until:
|
|
3086
|
+
kwargs["offset_date"] = parse_dt(req.until)
|
|
3087
|
+
async for message in _client(ctx).iter_messages(peer, **kwargs):
|
|
3088
|
+
if message is None:
|
|
3089
|
+
continue
|
|
3090
|
+
post = _paid_post(message, chat_id)
|
|
3091
|
+
if post is None:
|
|
3092
|
+
continue
|
|
3093
|
+
if floor is not None and post.date_unix < floor:
|
|
3094
|
+
continue
|
|
3095
|
+
if req.unlocked and not post.unlocked:
|
|
3096
|
+
continue
|
|
3097
|
+
if req.locked and post.unlocked:
|
|
3098
|
+
continue
|
|
3099
|
+
posts.append(post)
|
|
3100
|
+
|
|
3101
|
+
next_state = {"offset_id": posts[-1].msg_id} if posts else {}
|
|
3102
|
+
return build_page(
|
|
3103
|
+
posts,
|
|
3104
|
+
op="media.paid.list",
|
|
3105
|
+
kind=PageKind.SEARCH,
|
|
3106
|
+
state=next_state,
|
|
3107
|
+
account=ctx.account,
|
|
3108
|
+
limit=limit,
|
|
3109
|
+
has_more=None if posts else False,
|
|
3110
|
+
)
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
SPEC_PAID_LIST = OperationSpec(
|
|
3114
|
+
id="media.paid.list",
|
|
3115
|
+
request=PaidListReq,
|
|
3116
|
+
response=Page[PaidPost],
|
|
3117
|
+
impl=paid_list,
|
|
3118
|
+
summary="Paid-media posts and their unlock state",
|
|
3119
|
+
description=(
|
|
3120
|
+
"Buying is deliberately absent: spending Stars is a payment tlgr will "
|
|
3121
|
+
"not execute for you. Posting is `media upload --paid-stars N` and "
|
|
3122
|
+
"re-pricing is `media edit --paid-stars N`."
|
|
3123
|
+
),
|
|
3124
|
+
paginated=PageKind.SEARCH,
|
|
3125
|
+
columns=("msg_id", "stars_amount", "item_count", "unlocked"),
|
|
3126
|
+
headers=("ID", "Stars", "Items", "Unlocked"),
|
|
3127
|
+
example={
|
|
3128
|
+
"items": [
|
|
3129
|
+
{
|
|
3130
|
+
"chat_id": -1000000123,
|
|
3131
|
+
"msg_id": 890,
|
|
3132
|
+
"stars_amount": 50,
|
|
3133
|
+
"item_count": 3,
|
|
3134
|
+
"unlocked": False,
|
|
3135
|
+
}
|
|
3136
|
+
],
|
|
3137
|
+
"has_more": False,
|
|
3138
|
+
},
|
|
3139
|
+
example_args="media paid list @channel",
|
|
3140
|
+
covers=("media.paid-media-inspect",),
|
|
3141
|
+
)
|
|
3142
|
+
|
|
3143
|
+
|
|
3144
|
+
# ---------------------------------------------------------------------------
|
|
3145
|
+
# media limit get
|
|
3146
|
+
# ---------------------------------------------------------------------------
|
|
3147
|
+
|
|
3148
|
+
|
|
3149
|
+
class LimitReq(Request):
|
|
3150
|
+
refresh: Annotated[bool, opt("--refresh", help="Bypass the cached help.getAppConfig hash.")] = (
|
|
3151
|
+
False
|
|
3152
|
+
)
|
|
3153
|
+
|
|
3154
|
+
|
|
3155
|
+
async def limit_get(ctx: OpContext, req: LimitReq) -> MediaLimits:
|
|
3156
|
+
"""The server's own media limits. Never hardcode one of these.
|
|
3157
|
+
|
|
3158
|
+
`media upload` reads the same numbers before the first part goes out, so
|
|
3159
|
+
an oversized file is a named refusal rather than `FILE_PARTS_INVALID`
|
|
3160
|
+
after the bandwidth has been spent.
|
|
3161
|
+
"""
|
|
3162
|
+
values = await _media.app_config(ctx)
|
|
3163
|
+
premium = _is_premium(ctx)
|
|
3164
|
+
suffix = "premium" if premium else "default"
|
|
3165
|
+
|
|
3166
|
+
def number(name: str, fallback: int = 0) -> int:
|
|
3167
|
+
return _media.config_int(
|
|
3168
|
+
values, f"{name}_{suffix}", _media.config_int(values, name, fallback)
|
|
3169
|
+
)
|
|
3170
|
+
|
|
3171
|
+
part_size = 512 * 1024
|
|
3172
|
+
parts = number("upload_max_fileparts", 4000)
|
|
3173
|
+
return MediaLimits(
|
|
3174
|
+
premium=premium,
|
|
3175
|
+
upload_max_fileparts=parts,
|
|
3176
|
+
upload_max_bytes=parts * part_size,
|
|
3177
|
+
part_size=part_size,
|
|
3178
|
+
caption_length_limit=number("caption_length_limit", 1024),
|
|
3179
|
+
message_length_limit=number("message_length_limit", 4096),
|
|
3180
|
+
album_size_max=_media.config_int(values, "album_size_max", 10),
|
|
3181
|
+
stickers_installed_limit=number("stickers_installed_limit", 200),
|
|
3182
|
+
stickers_faved_limit=number("stickers_faved_limit", 5),
|
|
3183
|
+
stickers_recent_limit=_media.config_int(values, "stickers_recent_limit", 20),
|
|
3184
|
+
saved_gifs_limit=number("saved_gifs_limit", 200),
|
|
3185
|
+
ringtone_size_max=_media.config_int(values, "ringtone_size_max", 307200),
|
|
3186
|
+
ringtone_duration_max=_media.config_int(values, "ringtone_duration_max", 5),
|
|
3187
|
+
stars_paid_post_amount_max=_media.config_int(values, "stars_paid_post_amount_max", 0),
|
|
3188
|
+
premium_speedup_upload=_media.config_int(values, "upload_premium_speedup_upload", 0),
|
|
3189
|
+
premium_speedup_download=_media.config_int(values, "upload_premium_speedup_download", 0),
|
|
3190
|
+
)
|
|
3191
|
+
|
|
3192
|
+
|
|
3193
|
+
SPEC_LIMIT_GET = OperationSpec(
|
|
3194
|
+
id="media.limit.get",
|
|
3195
|
+
request=LimitReq,
|
|
3196
|
+
response=MediaLimits,
|
|
3197
|
+
impl=limit_get,
|
|
3198
|
+
summary="Server-side media limits for this account",
|
|
3199
|
+
columns=("premium", "upload_max_bytes", "caption_length_limit", "album_size_max"),
|
|
3200
|
+
headers=("Premium", "Max upload", "Caption", "Album"),
|
|
3201
|
+
example={
|
|
3202
|
+
"premium": False,
|
|
3203
|
+
"upload_max_fileparts": 4000,
|
|
3204
|
+
"upload_max_bytes": 2097152000,
|
|
3205
|
+
"part_size": 524288,
|
|
3206
|
+
"caption_length_limit": 1024,
|
|
3207
|
+
"album_size_max": 10,
|
|
3208
|
+
},
|
|
3209
|
+
example_args="media limit get",
|
|
3210
|
+
covers=("media.limits-config",),
|
|
3211
|
+
)
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
# ---------------------------------------------------------------------------
|
|
3215
|
+
# media transfer
|
|
3216
|
+
# ---------------------------------------------------------------------------
|
|
3217
|
+
|
|
3218
|
+
|
|
3219
|
+
class TransferListReq(Request):
|
|
3220
|
+
active: Annotated[bool, opt("--active", help="Only running transfers.")] = False
|
|
3221
|
+
failed: Annotated[bool, opt("--failed", help="Only failed transfers.")] = False
|
|
3222
|
+
watch: Annotated[
|
|
3223
|
+
bool, opt("--watch", help="Follow progress until every listed transfer settles.")
|
|
3224
|
+
] = False
|
|
3225
|
+
|
|
3226
|
+
|
|
3227
|
+
async def transfer_list(ctx: OpContext, req: TransferListReq) -> Page[Transfer]:
|
|
3228
|
+
"""The Downloads panel: client-local state, exactly as it is in the GUI.
|
|
3229
|
+
|
|
3230
|
+
There is no server-side downloads list to read; a transfer exists because
|
|
3231
|
+
this daemon started it.
|
|
3232
|
+
"""
|
|
3233
|
+
limit, _state = _media.window(ctx, "media.transfer.list", PageKind.LOCAL)
|
|
3234
|
+
store = _transfers(ctx)
|
|
3235
|
+
if req.watch:
|
|
3236
|
+
await store.settle(timeout=30.0)
|
|
3237
|
+
items = [
|
|
3238
|
+
Transfer(**record)
|
|
3239
|
+
for record in store.snapshot(active=req.active, failed=req.failed)[:limit]
|
|
3240
|
+
]
|
|
3241
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
3242
|
+
|
|
3243
|
+
|
|
3244
|
+
SPEC_TRANSFER_LIST = OperationSpec(
|
|
3245
|
+
id="media.transfer.list",
|
|
3246
|
+
request=TransferListReq,
|
|
3247
|
+
response=Page[Transfer],
|
|
3248
|
+
impl=transfer_list,
|
|
3249
|
+
summary="Active and recent uploads and downloads",
|
|
3250
|
+
paginated=PageKind.LOCAL,
|
|
3251
|
+
columns=("job_id", "direction", "name", "pct", "state"),
|
|
3252
|
+
headers=("Job", "Way", "Name", "%", "State"),
|
|
3253
|
+
example={
|
|
3254
|
+
"items": [
|
|
3255
|
+
{
|
|
3256
|
+
"job_id": "9f2c1a",
|
|
3257
|
+
"direction": "download",
|
|
3258
|
+
"name": "cat.mp4",
|
|
3259
|
+
"bytes_done": 1048576,
|
|
3260
|
+
"bytes_total": 8412300,
|
|
3261
|
+
"pct": 12.5,
|
|
3262
|
+
"state": "running",
|
|
3263
|
+
}
|
|
3264
|
+
],
|
|
3265
|
+
"has_more": False,
|
|
3266
|
+
},
|
|
3267
|
+
example_args="media transfer list --active",
|
|
3268
|
+
covers=("media.downloads-list", "media.transfer-progress"),
|
|
3269
|
+
covers_partial=("media.background-transfer-jobs",),
|
|
3270
|
+
coverage_note="Retrying and cancelling are `media transfer retry` and `media transfer stop`.",
|
|
3271
|
+
)
|
|
3272
|
+
|
|
3273
|
+
|
|
3274
|
+
class TransferStopReq(Request):
|
|
3275
|
+
job_id: Annotated[
|
|
3276
|
+
list[str],
|
|
3277
|
+
arg(0, metavar="JOB_ID", required=False, variadic=True, help="Transfers to stop."),
|
|
3278
|
+
] = []
|
|
3279
|
+
every: Annotated[bool, opt("--all", help="Cancel every running transfer.")] = False
|
|
3280
|
+
keep_partial: Annotated[
|
|
3281
|
+
bool, opt("--keep-partial/--discard-partial", help="Keep the .part file for --resume.")
|
|
3282
|
+
] = True
|
|
3283
|
+
|
|
3284
|
+
|
|
3285
|
+
async def transfer_stop(ctx: OpContext, req: TransferStopReq) -> TransferStopped:
|
|
3286
|
+
"""Stop an in-flight transfer.
|
|
3287
|
+
|
|
3288
|
+
A download stops cleanly between chunks and leaves its `.part` file, so
|
|
3289
|
+
`--resume` continues it. An upload cannot be resumed at all — saved parts
|
|
3290
|
+
expire server-side — so a cancelled upload is restarted with a fresh file
|
|
3291
|
+
id rather than continued.
|
|
3292
|
+
"""
|
|
3293
|
+
store = _transfers(ctx)
|
|
3294
|
+
ids = list(req.job_id)
|
|
3295
|
+
if req.every:
|
|
3296
|
+
ids = [record["job_id"] for record in store.snapshot(active=True)]
|
|
3297
|
+
if not ids:
|
|
3298
|
+
raise UsageError("name a transfer, or pass --all", field="job_id")
|
|
3299
|
+
cancelled = await store.cancel(ids, keep_partial=req.keep_partial)
|
|
3300
|
+
return TransferStopped(
|
|
3301
|
+
job_ids=ids,
|
|
3302
|
+
cancelled=cancelled,
|
|
3303
|
+
kept_partial=req.keep_partial,
|
|
3304
|
+
already=cancelled == 0,
|
|
3305
|
+
)
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
SPEC_TRANSFER_STOP = OperationSpec(
|
|
3309
|
+
id="media.transfer.stop",
|
|
3310
|
+
request=TransferStopReq,
|
|
3311
|
+
response=TransferStopped,
|
|
3312
|
+
impl=transfer_stop,
|
|
3313
|
+
summary="Stop an in-flight upload or download",
|
|
3314
|
+
aliases=("media.transfer.cancel",),
|
|
3315
|
+
mutating=True,
|
|
3316
|
+
destructive=True,
|
|
3317
|
+
columns=("job_ids", "cancelled", "kept_partial"),
|
|
3318
|
+
headers=("Jobs", "Cancelled", "Kept"),
|
|
3319
|
+
example={"job_ids": ["9f2c1a"], "cancelled": 1, "kept_partial": True},
|
|
3320
|
+
example_args="media transfer stop 9f2c1a",
|
|
3321
|
+
covers=("media.transfer-cancel",),
|
|
3322
|
+
covers_partial=("media.background-transfer-jobs",),
|
|
3323
|
+
coverage_note="The job store lives in the daemon; `media transfer list` is its view.",
|
|
3324
|
+
)
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
class TransferRetryReq(Request):
|
|
3328
|
+
job_id: Annotated[
|
|
3329
|
+
list[str],
|
|
3330
|
+
arg(0, metavar="JOB_ID", required=False, variadic=True, help="Transfers to restart."),
|
|
3331
|
+
] = []
|
|
3332
|
+
every: Annotated[bool, opt("--all", help="Retry every failed transfer.")] = False
|
|
3333
|
+
from_scratch: Annotated[
|
|
3334
|
+
bool, opt("--from-scratch", help="Ignore the partial file and start at byte 0.")
|
|
3335
|
+
] = False
|
|
3336
|
+
|
|
3337
|
+
|
|
3338
|
+
async def transfer_retry(ctx: OpContext, req: TransferRetryReq) -> TransferRestarted:
|
|
3339
|
+
"""Restart a failed or cancelled transfer.
|
|
3340
|
+
|
|
3341
|
+
The source is re-fetched first: a transfer that sat in the failed queue
|
|
3342
|
+
for an hour is holding an expired `file_reference`, and retrying with the
|
|
3343
|
+
stale one fails identically.
|
|
3344
|
+
"""
|
|
3345
|
+
store = _transfers(ctx)
|
|
3346
|
+
ids = list(req.job_id)
|
|
3347
|
+
if req.every:
|
|
3348
|
+
ids = [record["job_id"] for record in store.snapshot(failed=True)]
|
|
3349
|
+
if not ids:
|
|
3350
|
+
raise UsageError("name a transfer, or pass --all", field="job_id")
|
|
3351
|
+
restarted, resumed_from = store.retry(ids, from_scratch=req.from_scratch)
|
|
3352
|
+
return TransferRestarted(job_ids=ids, restarted=restarted, resumed_from=resumed_from)
|
|
3353
|
+
|
|
3354
|
+
|
|
3355
|
+
SPEC_TRANSFER_RETRY = OperationSpec(
|
|
3356
|
+
id="media.transfer.retry",
|
|
3357
|
+
request=TransferRetryReq,
|
|
3358
|
+
response=TransferRestarted,
|
|
3359
|
+
impl=transfer_retry,
|
|
3360
|
+
summary="Restart a failed or cancelled transfer",
|
|
3361
|
+
mutating=True,
|
|
3362
|
+
rate_class="file",
|
|
3363
|
+
columns=("job_ids", "restarted", "resumed_from"),
|
|
3364
|
+
headers=("Jobs", "Restarted", "From"),
|
|
3365
|
+
example={"job_ids": ["9f2c1a"], "restarted": 1, "resumed_from": 1048576},
|
|
3366
|
+
example_args="media transfer retry 9f2c1a",
|
|
3367
|
+
covers=("media.background-transfer-jobs",),
|
|
3368
|
+
)
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
# ---------------------------------------------------------------------------
|
|
3372
|
+
# media watch
|
|
3373
|
+
# ---------------------------------------------------------------------------
|
|
3374
|
+
|
|
3375
|
+
|
|
3376
|
+
class WatchReq(Request):
|
|
3377
|
+
chat: Annotated[
|
|
3378
|
+
list[PeerRef],
|
|
3379
|
+
arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to watch."),
|
|
3380
|
+
] = []
|
|
3381
|
+
type: Annotated[list[str], opt("--type", metavar="KIND", help="Media kinds to report.")] = []
|
|
3382
|
+
download: Annotated[
|
|
3383
|
+
str | None,
|
|
3384
|
+
opt("--download", metavar="DIR", kind="path", help="Auto-download matching media there."),
|
|
3385
|
+
] = None
|
|
3386
|
+
max_size: Annotated[
|
|
3387
|
+
str | None, opt("--max-size", metavar="SIZE", help="Skip auto-download above this size.")
|
|
3388
|
+
] = None
|
|
3389
|
+
use_cloud_settings: Annotated[
|
|
3390
|
+
bool, opt("--use-cloud-settings", help="Honour the account's auto-download preset.")
|
|
3391
|
+
] = False
|
|
3392
|
+
from_user: Annotated[
|
|
3393
|
+
PeerRef | None,
|
|
3394
|
+
opt("--from", metavar="USER", kind="user", help="Only media from this sender."),
|
|
3395
|
+
] = None
|
|
3396
|
+
min_duration: Annotated[
|
|
3397
|
+
int | None,
|
|
3398
|
+
opt("--min-duration", metavar="SECONDS", kind="duration", help="Only longer audio/video."),
|
|
3399
|
+
] = None
|
|
3400
|
+
|
|
3401
|
+
|
|
3402
|
+
async def watch(ctx: OpContext, req: WatchReq) -> Any:
|
|
3403
|
+
"""Stream incoming media events, optionally downloading them.
|
|
3404
|
+
|
|
3405
|
+
A media-shaped view of the daemon's existing update bus, not a second
|
|
3406
|
+
update loop: the daemon already holds the client and the MessageBox
|
|
3407
|
+
state, and this subscribes to it. Service messages never reach
|
|
3408
|
+
`events.NewMessage`, so a changed chat photo is not reported here —
|
|
3409
|
+
`media list --type chat-photo` is.
|
|
3410
|
+
"""
|
|
3411
|
+
bus = getattr(ctx, "bus", None)
|
|
3412
|
+
if bus is None:
|
|
3413
|
+
raise NotSupportedError("this build has no event bus to watch")
|
|
3414
|
+
|
|
3415
|
+
chats: list[int] = []
|
|
3416
|
+
for reference in req.chat:
|
|
3417
|
+
chats.append(_send.peer_id_of(await _send.resolve(ctx, reference)))
|
|
3418
|
+
ceiling = _size_arg(req.max_size, "max_size")
|
|
3419
|
+
if req.use_cloud_settings and ceiling is None:
|
|
3420
|
+
ceiling = await _cloud_ceiling(ctx)
|
|
3421
|
+
wanted = {kind for kind in req.type if kind != "all"}
|
|
3422
|
+
sender = _send.peer_id_of(await _send.resolve(ctx, req.from_user)) if req.from_user else None
|
|
3423
|
+
|
|
3424
|
+
subscriber = bus.subscribe(ctx.account, types=("message_new",), chats=chats)
|
|
3425
|
+
try:
|
|
3426
|
+
while True:
|
|
3427
|
+
event = await subscriber.queue.get()
|
|
3428
|
+
frame = _media_event(event, wanted, sender, req.min_duration)
|
|
3429
|
+
if frame is None:
|
|
3430
|
+
continue
|
|
3431
|
+
if req.download and (ceiling is None or (frame.size or 0) <= ceiling):
|
|
3432
|
+
frame.path = await _watch_download(ctx, event, req)
|
|
3433
|
+
yield Page(items=[frame], has_more=True)
|
|
3434
|
+
finally:
|
|
3435
|
+
bus.unsubscribe(subscriber)
|
|
3436
|
+
|
|
3437
|
+
|
|
3438
|
+
def _media_event(
|
|
3439
|
+
event: Any, wanted: set[str], sender: int | None, min_duration: int | None
|
|
3440
|
+
) -> MediaEvent | None:
|
|
3441
|
+
payload = getattr(event, "payload", None) or {}
|
|
3442
|
+
media = payload.get("media")
|
|
3443
|
+
if not isinstance(media, dict):
|
|
3444
|
+
return None
|
|
3445
|
+
kind = str(media.get("kind") or "")
|
|
3446
|
+
if wanted and kind not in wanted:
|
|
3447
|
+
return None
|
|
3448
|
+
if sender is not None and payload.get("sender_id") != sender:
|
|
3449
|
+
return None
|
|
3450
|
+
duration = media.get("duration")
|
|
3451
|
+
if min_duration is not None and int(duration or 0) < min_duration:
|
|
3452
|
+
return None
|
|
3453
|
+
return MediaEvent(
|
|
3454
|
+
event="media",
|
|
3455
|
+
chat_id=int(payload.get("chat_id") or event.chat_id or 0),
|
|
3456
|
+
msg_id=int(payload.get("id") or 0),
|
|
3457
|
+
kind=kind,
|
|
3458
|
+
mime=media.get("mime_type"),
|
|
3459
|
+
size=media.get("size"),
|
|
3460
|
+
duration=duration,
|
|
3461
|
+
from_id=payload.get("sender_id"),
|
|
3462
|
+
date=str(payload.get("date") or ""),
|
|
3463
|
+
)
|
|
3464
|
+
|
|
3465
|
+
|
|
3466
|
+
async def _cloud_ceiling(ctx: OpContext) -> int | None:
|
|
3467
|
+
"""The account's own auto-download cap, as the size limit."""
|
|
3468
|
+
settings = await auto_download_get(ctx, AutoDownloadGetReq(preset="medium"))
|
|
3469
|
+
for preset in settings.presets:
|
|
3470
|
+
if preset.preset == "medium":
|
|
3471
|
+
return max(preset.photo_size_max, preset.video_size_max, preset.file_size_max) or None
|
|
3472
|
+
return None
|
|
3473
|
+
|
|
3474
|
+
|
|
3475
|
+
async def _watch_download(ctx: OpContext, event: Any, req: WatchReq) -> str | None:
|
|
3476
|
+
payload = getattr(event, "payload", None) or {}
|
|
3477
|
+
chat_id = int(payload.get("chat_id") or 0)
|
|
3478
|
+
message_id = int(payload.get("id") or 0)
|
|
3479
|
+
if not chat_id or not message_id:
|
|
3480
|
+
return None
|
|
3481
|
+
try:
|
|
3482
|
+
message = await _media.fetch_message(ctx, chat_id, message_id)
|
|
3483
|
+
document = _media.document_of(message.media) or _media.photo_of(message.media)
|
|
3484
|
+
target = Path(os.path.expanduser(str(req.download))) / _safe_name(
|
|
3485
|
+
_media.attributes_of(document).get("file_name") or _default_name(message.media)
|
|
3486
|
+
)
|
|
3487
|
+
path = await _download_bytes(
|
|
3488
|
+
ctx,
|
|
3489
|
+
document,
|
|
3490
|
+
target,
|
|
3491
|
+
size=int(getattr(document, "size", 0) or 0),
|
|
3492
|
+
dc_id=int(getattr(document, "dc_id", 0) or 0),
|
|
3493
|
+
)
|
|
3494
|
+
return str(path)
|
|
3495
|
+
except Exception as exc: # a failed auto-download must not end the stream
|
|
3496
|
+
ctx.warn(f"auto-download of {message_id} failed: {exc}")
|
|
3497
|
+
return None
|
|
3498
|
+
|
|
3499
|
+
|
|
3500
|
+
SPEC_WATCH = OperationSpec(
|
|
3501
|
+
id="media.watch",
|
|
3502
|
+
request=WatchReq,
|
|
3503
|
+
response=Page[MediaEvent],
|
|
3504
|
+
impl=watch,
|
|
3505
|
+
summary="Stream incoming media events and optionally auto-download them",
|
|
3506
|
+
stream=True,
|
|
3507
|
+
timeout_s=900,
|
|
3508
|
+
columns=("chat_id", "msg_id", "kind", "size"),
|
|
3509
|
+
headers=("Chat", "ID", "Kind", "Size"),
|
|
3510
|
+
example={
|
|
3511
|
+
"items": [
|
|
3512
|
+
{
|
|
3513
|
+
"event": "media",
|
|
3514
|
+
"chat_id": 777123,
|
|
3515
|
+
"msg_id": 12347,
|
|
3516
|
+
"kind": "photo",
|
|
3517
|
+
"size": 184320,
|
|
3518
|
+
}
|
|
3519
|
+
],
|
|
3520
|
+
"has_more": True,
|
|
3521
|
+
},
|
|
3522
|
+
example_args="media watch @alice --type photo",
|
|
3523
|
+
covers=("media.receive-event-watch",),
|
|
3524
|
+
)
|
|
3525
|
+
|
|
3526
|
+
|
|
3527
|
+
# ---------------------------------------------------------------------------
|
|
3528
|
+
# media wallpaper
|
|
3529
|
+
# ---------------------------------------------------------------------------
|
|
3530
|
+
|
|
3531
|
+
|
|
3532
|
+
def _colour(value: str) -> int:
|
|
3533
|
+
text = value.strip().lstrip("#")
|
|
3534
|
+
try:
|
|
3535
|
+
return int(text, 16)
|
|
3536
|
+
except ValueError as exc:
|
|
3537
|
+
raise UsageError(f"{value!r} is not a hex colour", field="colors") from exc
|
|
3538
|
+
|
|
3539
|
+
|
|
3540
|
+
def _colour_text(value: int | None) -> str | None:
|
|
3541
|
+
return f"#{value:06x}" if isinstance(value, int) else None
|
|
3542
|
+
|
|
3543
|
+
|
|
3544
|
+
def _wallpaper_settings(settings: Any) -> WallpaperSettings:
|
|
3545
|
+
colours = [
|
|
3546
|
+
_colour_text(getattr(settings, name, None))
|
|
3547
|
+
for name in (
|
|
3548
|
+
"background_color",
|
|
3549
|
+
"second_background_color",
|
|
3550
|
+
"third_background_color",
|
|
3551
|
+
"fourth_background_color",
|
|
3552
|
+
)
|
|
3553
|
+
]
|
|
3554
|
+
return WallpaperSettings(
|
|
3555
|
+
blur=bool(getattr(settings, "blur", False)),
|
|
3556
|
+
motion=bool(getattr(settings, "motion", False)),
|
|
3557
|
+
intensity=getattr(settings, "intensity", None),
|
|
3558
|
+
rotation=getattr(settings, "rotation", None),
|
|
3559
|
+
colors=[c for c in colours if c],
|
|
3560
|
+
)
|
|
3561
|
+
|
|
3562
|
+
|
|
3563
|
+
def _wallpaper_model(raw: Any) -> Wallpaper:
|
|
3564
|
+
settings = getattr(raw, "settings", None)
|
|
3565
|
+
parsed = _wallpaper_settings(settings) if settings is not None else WallpaperSettings()
|
|
3566
|
+
document = getattr(raw, "document", None)
|
|
3567
|
+
slug = getattr(raw, "slug", None)
|
|
3568
|
+
pattern = bool(getattr(raw, "pattern", False))
|
|
3569
|
+
kind: Literal["image", "pattern", "fill"] = (
|
|
3570
|
+
"fill" if document is None else ("pattern" if pattern else "image")
|
|
3571
|
+
)
|
|
3572
|
+
return Wallpaper(
|
|
3573
|
+
id=int(getattr(raw, "id", 0) or 0),
|
|
3574
|
+
access_hash=getattr(raw, "access_hash", None),
|
|
3575
|
+
slug=slug,
|
|
3576
|
+
kind=kind,
|
|
3577
|
+
pattern=pattern,
|
|
3578
|
+
dark=bool(getattr(raw, "dark", False)),
|
|
3579
|
+
creator=bool(getattr(raw, "creator", False)),
|
|
3580
|
+
default=bool(getattr(raw, "default", False)),
|
|
3581
|
+
colors=parsed.colors,
|
|
3582
|
+
blur=parsed.blur,
|
|
3583
|
+
motion=parsed.motion,
|
|
3584
|
+
intensity=parsed.intensity,
|
|
3585
|
+
rotation=parsed.rotation,
|
|
3586
|
+
document=_media.media_file(document),
|
|
3587
|
+
link=_wallpaper_link(slug, parsed),
|
|
3588
|
+
)
|
|
3589
|
+
|
|
3590
|
+
|
|
3591
|
+
def _wallpaper_link(slug: str | None, settings: WallpaperSettings) -> str | None:
|
|
3592
|
+
"""`t.me/bg/<slug>?…` — string formatting, never a request."""
|
|
3593
|
+
if not slug:
|
|
3594
|
+
return None
|
|
3595
|
+
modes = [name for name, on in (("blur", settings.blur), ("motion", settings.motion)) if on]
|
|
3596
|
+
query: list[str] = []
|
|
3597
|
+
if modes:
|
|
3598
|
+
query.append("mode=" + "+".join(modes))
|
|
3599
|
+
if settings.intensity is not None:
|
|
3600
|
+
query.append(f"intensity={settings.intensity}")
|
|
3601
|
+
if settings.colors:
|
|
3602
|
+
query.append("bg_color=" + "-".join(c.lstrip("#") for c in settings.colors))
|
|
3603
|
+
if settings.rotation is not None:
|
|
3604
|
+
query.append(f"rotation={settings.rotation}")
|
|
3605
|
+
return f"https://t.me/bg/{slug}" + ("?" + "&".join(query) if query else "")
|
|
3606
|
+
|
|
3607
|
+
|
|
3608
|
+
def _wallpaper_ref(text: str) -> Any:
|
|
3609
|
+
from telethon.tl import types
|
|
3610
|
+
|
|
3611
|
+
value = (text or "").strip()
|
|
3612
|
+
if "t.me/bg/" in value:
|
|
3613
|
+
value = value.split("t.me/bg/", 1)[1].split("?")[0]
|
|
3614
|
+
if value.lstrip("-").isdigit():
|
|
3615
|
+
raise UsageError(
|
|
3616
|
+
f"{value!r} is a wallpaper id without its access hash; name it by slug or "
|
|
3617
|
+
"by its t.me/bg link",
|
|
3618
|
+
field="wallpaper",
|
|
3619
|
+
)
|
|
3620
|
+
return types.InputWallPaperSlug(slug=value)
|
|
3621
|
+
|
|
3622
|
+
|
|
3623
|
+
class WallpaperListReq(Request):
|
|
3624
|
+
saved: Annotated[bool, opt("--saved", help="Only the ones saved to the account.")] = False
|
|
3625
|
+
default: Annotated[bool, opt("--default", help="Only the preinstalled gallery.")] = False
|
|
3626
|
+
patterns: Annotated[bool, opt("--patterns", help="Only pattern and fill wallpapers.")] = False
|
|
3627
|
+
|
|
3628
|
+
|
|
3629
|
+
async def wallpaper_list(ctx: OpContext, req: WallpaperListReq) -> Page[Wallpaper]:
|
|
3630
|
+
"""The account's wallpapers.
|
|
3631
|
+
|
|
3632
|
+
A hash-cached list, not an offset-paginated one: the server answers
|
|
3633
|
+
`wallPapersNotModified` when nothing changed, so there are no pages to
|
|
3634
|
+
walk and `--limit` only trims what came back.
|
|
3635
|
+
"""
|
|
3636
|
+
from telethon.tl.functions import account as fn
|
|
3637
|
+
|
|
3638
|
+
limit, _state = _media.window(ctx, "media.wallpaper.list", PageKind.LOCAL)
|
|
3639
|
+
result = await _client(ctx)(fn.GetWallPapersRequest(hash=0))
|
|
3640
|
+
items = [_wallpaper_model(raw) for raw in getattr(result, "wallpapers", None) or []]
|
|
3641
|
+
if req.saved:
|
|
3642
|
+
items = [item for item in items if not item.default]
|
|
3643
|
+
if req.default:
|
|
3644
|
+
items = [item for item in items if item.default]
|
|
3645
|
+
if req.patterns:
|
|
3646
|
+
items = [item for item in items if item.kind in ("pattern", "fill")]
|
|
3647
|
+
return Page(items=items[:limit], has_more=False, total=len(items))
|
|
3648
|
+
|
|
3649
|
+
|
|
3650
|
+
SPEC_WALLPAPER_LIST = OperationSpec(
|
|
3651
|
+
id="media.wallpaper.list",
|
|
3652
|
+
request=WallpaperListReq,
|
|
3653
|
+
response=Page[Wallpaper],
|
|
3654
|
+
impl=wallpaper_list,
|
|
3655
|
+
summary="Wallpapers available to this account",
|
|
3656
|
+
paginated=PageKind.LOCAL,
|
|
3657
|
+
columns=("slug", "kind", "dark", "colors"),
|
|
3658
|
+
headers=("Slug", "Kind", "Dark", "Colours"),
|
|
3659
|
+
example={
|
|
3660
|
+
"items": [{"id": 5947530738857476, "slug": "Ycb0FfC6", "kind": "pattern", "dark": False}],
|
|
3661
|
+
"has_more": False,
|
|
3662
|
+
},
|
|
3663
|
+
example_args="media wallpaper list",
|
|
3664
|
+
covers=("dialogs.wallpaper-gallery", "wallpaper.list"),
|
|
3665
|
+
)
|
|
3666
|
+
|
|
3667
|
+
|
|
3668
|
+
class WallpaperGetReq(Request):
|
|
3669
|
+
wallpaper: Annotated[
|
|
3670
|
+
str, arg(0, metavar="WALLPAPER", help="Slug, or a https://t.me/bg/<slug> link.")
|
|
3671
|
+
]
|
|
3672
|
+
out: Annotated[
|
|
3673
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Download the image.")
|
|
3674
|
+
] = None
|
|
3675
|
+
link: Annotated[bool, opt("--link", help="Print only the shareable link.")] = False
|
|
3676
|
+
|
|
3677
|
+
|
|
3678
|
+
async def wallpaper_get(ctx: OpContext, req: WallpaperGetReq) -> Wallpaper:
|
|
3679
|
+
"""One wallpaper, and the share link its settings encode."""
|
|
3680
|
+
from telethon.tl.functions import account as fn
|
|
3681
|
+
|
|
3682
|
+
result = await _client(ctx)(fn.GetWallPaperRequest(wallpaper=_wallpaper_ref(req.wallpaper)))
|
|
3683
|
+
model = _wallpaper_model(result)
|
|
3684
|
+
if req.out and model.document is not None:
|
|
3685
|
+
document = getattr(result, "document", None)
|
|
3686
|
+
path = await _download_bytes(
|
|
3687
|
+
ctx,
|
|
3688
|
+
document,
|
|
3689
|
+
Path(os.path.expanduser(req.out)),
|
|
3690
|
+
size=int(getattr(document, "size", 0) or 0),
|
|
3691
|
+
dc_id=int(getattr(document, "dc_id", 0) or 0),
|
|
3692
|
+
)
|
|
3693
|
+
model.document.file_id = model.document.file_id or str(path)
|
|
3694
|
+
return model
|
|
3695
|
+
|
|
3696
|
+
|
|
3697
|
+
SPEC_WALLPAPER_GET = OperationSpec(
|
|
3698
|
+
id="media.wallpaper.get",
|
|
3699
|
+
request=WallpaperGetReq,
|
|
3700
|
+
response=Wallpaper,
|
|
3701
|
+
impl=wallpaper_get,
|
|
3702
|
+
summary="One wallpaper by slug or t.me/bg link",
|
|
3703
|
+
columns=("slug", "kind", "colors", "link"),
|
|
3704
|
+
headers=("Slug", "Kind", "Colours", "Link"),
|
|
3705
|
+
empty_exit=EXIT_EMPTY,
|
|
3706
|
+
example={
|
|
3707
|
+
"id": 5947530738857476,
|
|
3708
|
+
"slug": "Ycb0FfC6",
|
|
3709
|
+
"kind": "pattern",
|
|
3710
|
+
"colors": ["#dbddbb", "#6ba587"],
|
|
3711
|
+
"link": "https://t.me/bg/Ycb0FfC6?intensity=50",
|
|
3712
|
+
},
|
|
3713
|
+
example_args="media wallpaper get Ycb0FfC6",
|
|
3714
|
+
covers=("wallpaper.share-link",),
|
|
3715
|
+
covers_partial=("wallpaper.list",),
|
|
3716
|
+
coverage_note="The gallery itself is `media wallpaper list`.",
|
|
3717
|
+
)
|
|
3718
|
+
|
|
3719
|
+
|
|
3720
|
+
class WallpaperSetReq(Request):
|
|
3721
|
+
wallpaper: Annotated[
|
|
3722
|
+
str | None,
|
|
3723
|
+
arg(0, metavar="WALLPAPER", required=False, help="Slug or link; omit for a colour fill."),
|
|
3724
|
+
] = None
|
|
3725
|
+
blur: Annotated[bool, opt("--blur", help="Apply blurred.")] = False
|
|
3726
|
+
motion: Annotated[bool, opt("--motion", help="Apply with parallax motion.")] = False
|
|
3727
|
+
intensity: Annotated[
|
|
3728
|
+
int | None, opt("--intensity", metavar="N", help="Pattern intensity, -100..100.")
|
|
3729
|
+
] = None
|
|
3730
|
+
colors: Annotated[list[str], opt("--colors", metavar="HEX", help="1-4 hex fill colours.")] = []
|
|
3731
|
+
save_only: Annotated[
|
|
3732
|
+
bool, opt("--save-only", help="Add to the saved list without installing it.")
|
|
3733
|
+
] = False
|
|
3734
|
+
reset: Annotated[bool, opt("--reset", help="Restore the server's default list.")] = False
|
|
3735
|
+
|
|
3736
|
+
|
|
3737
|
+
def _settings_from(req: WallpaperSetReq) -> Any:
|
|
3738
|
+
from telethon.tl import types
|
|
3739
|
+
|
|
3740
|
+
colours = [_colour(value) for value in req.colors][:4]
|
|
3741
|
+
padded = colours + [None] * (4 - len(colours))
|
|
3742
|
+
return types.WallPaperSettings(
|
|
3743
|
+
blur=req.blur or None,
|
|
3744
|
+
motion=req.motion or None,
|
|
3745
|
+
background_color=padded[0],
|
|
3746
|
+
second_background_color=padded[1],
|
|
3747
|
+
third_background_color=padded[2],
|
|
3748
|
+
fourth_background_color=padded[3],
|
|
3749
|
+
intensity=req.intensity,
|
|
3750
|
+
)
|
|
3751
|
+
|
|
3752
|
+
|
|
3753
|
+
async def wallpaper_set(ctx: OpContext, req: WallpaperSetReq) -> WallpaperInstalled:
|
|
3754
|
+
"""Make a wallpaper the account default.
|
|
3755
|
+
|
|
3756
|
+
Only the cloud *choice* is synced; rendering is a client concern a
|
|
3757
|
+
terminal has no use for, so tlgr syncs the selection and stops there.
|
|
3758
|
+
"""
|
|
3759
|
+
from telethon.tl import types
|
|
3760
|
+
from telethon.tl.functions import account as fn
|
|
3761
|
+
|
|
3762
|
+
if req.reset:
|
|
3763
|
+
await _client(ctx)(fn.ResetWallPapersRequest())
|
|
3764
|
+
return WallpaperInstalled(reset=True, installed=False, saved=False)
|
|
3765
|
+
|
|
3766
|
+
settings = _settings_from(req)
|
|
3767
|
+
if req.wallpaper:
|
|
3768
|
+
reference: Any = _wallpaper_ref(req.wallpaper)
|
|
3769
|
+
elif req.colors:
|
|
3770
|
+
# A pure colour or gradient is a wallpaper with no file at all.
|
|
3771
|
+
reference = types.InputWallPaperNoFile(id=0)
|
|
3772
|
+
else:
|
|
3773
|
+
raise UsageError("name a wallpaper, or give --colors for a fill", field="wallpaper")
|
|
3774
|
+
|
|
3775
|
+
if req.save_only:
|
|
3776
|
+
await _client(ctx)(
|
|
3777
|
+
fn.SaveWallPaperRequest(wallpaper=reference, unsave=False, settings=settings)
|
|
3778
|
+
)
|
|
3779
|
+
return WallpaperInstalled(
|
|
3780
|
+
slug=req.wallpaper, installed=False, saved=True, settings=_wallpaper_settings(settings)
|
|
3781
|
+
)
|
|
3782
|
+
|
|
3783
|
+
await _client(ctx)(fn.InstallWallPaperRequest(wallpaper=reference, settings=settings))
|
|
3784
|
+
return WallpaperInstalled(
|
|
3785
|
+
slug=req.wallpaper,
|
|
3786
|
+
installed=True,
|
|
3787
|
+
saved=True,
|
|
3788
|
+
settings=_wallpaper_settings(settings),
|
|
3789
|
+
)
|
|
3790
|
+
|
|
3791
|
+
|
|
3792
|
+
SPEC_WALLPAPER_SET = OperationSpec(
|
|
3793
|
+
id="media.wallpaper.set",
|
|
3794
|
+
request=WallpaperSetReq,
|
|
3795
|
+
response=WallpaperInstalled,
|
|
3796
|
+
impl=wallpaper_set,
|
|
3797
|
+
summary="Make a wallpaper the account default",
|
|
3798
|
+
mutating=True,
|
|
3799
|
+
columns=("slug", "installed", "saved"),
|
|
3800
|
+
headers=("Slug", "Installed", "Saved"),
|
|
3801
|
+
example={"slug": "Ycb0FfC6", "installed": True, "saved": True},
|
|
3802
|
+
example_args="media wallpaper set Ycb0FfC6 --blur",
|
|
3803
|
+
covers=("wallpaper.save-install",),
|
|
3804
|
+
)
|
|
3805
|
+
|
|
3806
|
+
|
|
3807
|
+
class WallpaperRemoveReq(Request):
|
|
3808
|
+
wallpaper: Annotated[
|
|
3809
|
+
list[str], arg(0, metavar="WALLPAPER", variadic=True, help="Slugs or links to unsave.")
|
|
3810
|
+
] = []
|
|
3811
|
+
|
|
3812
|
+
|
|
3813
|
+
async def wallpaper_remove(ctx: OpContext, req: WallpaperRemoveReq) -> WallpaperRemoved:
|
|
3814
|
+
"""Remove wallpapers from the saved gallery.
|
|
3815
|
+
|
|
3816
|
+
Not the same as changing the current one: `media wallpaper set --reset`
|
|
3817
|
+
is the "restore defaults" button.
|
|
3818
|
+
"""
|
|
3819
|
+
from telethon.tl import types
|
|
3820
|
+
from telethon.tl.functions import account as fn
|
|
3821
|
+
|
|
3822
|
+
slugs = list(req.wallpaper)
|
|
3823
|
+
if not slugs:
|
|
3824
|
+
raise UsageError("name at least one wallpaper", field="wallpaper")
|
|
3825
|
+
for slug in slugs:
|
|
3826
|
+
await _client(ctx)(
|
|
3827
|
+
fn.SaveWallPaperRequest(
|
|
3828
|
+
wallpaper=_wallpaper_ref(slug), unsave=True, settings=types.WallPaperSettings()
|
|
3829
|
+
)
|
|
3830
|
+
)
|
|
3831
|
+
return WallpaperRemoved(slugs=slugs, removed=len(slugs))
|
|
3832
|
+
|
|
3833
|
+
|
|
3834
|
+
SPEC_WALLPAPER_REMOVE = OperationSpec(
|
|
3835
|
+
id="media.wallpaper.remove",
|
|
3836
|
+
request=WallpaperRemoveReq,
|
|
3837
|
+
response=WallpaperRemoved,
|
|
3838
|
+
impl=wallpaper_remove,
|
|
3839
|
+
summary="Remove a wallpaper from the saved list",
|
|
3840
|
+
mutating=True,
|
|
3841
|
+
destructive=True,
|
|
3842
|
+
columns=("slugs", "removed"),
|
|
3843
|
+
headers=("Slugs", "Removed"),
|
|
3844
|
+
example={"slugs": ["Ycb0FfC6"], "removed": 1},
|
|
3845
|
+
example_args="media wallpaper remove Ycb0FfC6",
|
|
3846
|
+
covers_partial=("wallpaper.save-install",),
|
|
3847
|
+
coverage_note="Installing is `media wallpaper set`; this is only the saved gallery.",
|
|
3848
|
+
)
|
|
3849
|
+
|
|
3850
|
+
|
|
3851
|
+
class WallpaperUploadReq(Request):
|
|
3852
|
+
path: Annotated[str, arg(0, metavar="PATH", kind="path", help="Image to upload.")]
|
|
3853
|
+
pattern: Annotated[bool, opt("--pattern", help="Treat the image as a tintable pattern.")] = (
|
|
3854
|
+
False
|
|
3855
|
+
)
|
|
3856
|
+
colors: Annotated[list[str], opt("--colors", metavar="HEX", help="1-4 hex fill colours.")] = []
|
|
3857
|
+
blur: Annotated[bool, opt("--blur", help="Store blurred.")] = False
|
|
3858
|
+
motion: Annotated[bool, opt("--motion", help="Store with parallax motion.")] = False
|
|
3859
|
+
intensity: Annotated[
|
|
3860
|
+
int, opt("--intensity", metavar="N", help="Pattern intensity.", ge=-100, le=100)
|
|
3861
|
+
] = 50
|
|
3862
|
+
for_chat: Annotated[bool, opt("--for-chat", help="Upload for use as a per-chat wallpaper.")] = (
|
|
3863
|
+
False
|
|
3864
|
+
)
|
|
3865
|
+
|
|
3866
|
+
|
|
3867
|
+
async def wallpaper_upload(ctx: OpContext, req: WallpaperUploadReq) -> WallpaperUploaded:
|
|
3868
|
+
"""Upload a custom wallpaper image or pattern.
|
|
3869
|
+
|
|
3870
|
+
The slug that comes back is what `media wallpaper set` and `chat wallpaper
|
|
3871
|
+
set` consume; `--for-chat` is required for the latter, and the server
|
|
3872
|
+
rejects the wrong one rather than silently ignoring it.
|
|
3873
|
+
"""
|
|
3874
|
+
import mimetypes
|
|
3875
|
+
|
|
3876
|
+
from telethon.tl import types
|
|
3877
|
+
from telethon.tl.functions import account as fn
|
|
3878
|
+
|
|
3879
|
+
path = Path(os.path.expanduser(req.path))
|
|
3880
|
+
if not path.exists():
|
|
3881
|
+
raise UsageError(f"{req.path} does not exist", field="path")
|
|
3882
|
+
upload_service = getattr(ctx, "upload_file", None)
|
|
3883
|
+
if upload_service is None: # pragma: no cover
|
|
3884
|
+
raise UsageError("this context cannot upload files")
|
|
3885
|
+
handle = await upload_service(path)
|
|
3886
|
+
|
|
3887
|
+
colours = [_colour(value) for value in req.colors][:4]
|
|
3888
|
+
padded = colours + [None] * (4 - len(colours))
|
|
3889
|
+
settings = types.WallPaperSettings(
|
|
3890
|
+
blur=req.blur or None,
|
|
3891
|
+
motion=req.motion or None,
|
|
3892
|
+
background_color=padded[0],
|
|
3893
|
+
second_background_color=padded[1],
|
|
3894
|
+
third_background_color=padded[2],
|
|
3895
|
+
fourth_background_color=padded[3],
|
|
3896
|
+
intensity=req.intensity if req.pattern else None,
|
|
3897
|
+
)
|
|
3898
|
+
result = await _client(ctx)(
|
|
3899
|
+
fn.UploadWallPaperRequest(
|
|
3900
|
+
file=handle,
|
|
3901
|
+
mime_type=mimetypes.guess_type(path.name)[0] or "image/jpeg",
|
|
3902
|
+
settings=settings,
|
|
3903
|
+
for_chat=req.for_chat or None,
|
|
3904
|
+
)
|
|
3905
|
+
)
|
|
3906
|
+
parsed = _wallpaper_settings(settings)
|
|
3907
|
+
slug = getattr(result, "slug", None)
|
|
3908
|
+
return WallpaperUploaded(
|
|
3909
|
+
id=int(getattr(result, "id", 0) or 0),
|
|
3910
|
+
access_hash=getattr(result, "access_hash", None),
|
|
3911
|
+
slug=slug,
|
|
3912
|
+
link=_wallpaper_link(slug, parsed),
|
|
3913
|
+
settings=parsed,
|
|
3914
|
+
)
|
|
3915
|
+
|
|
3916
|
+
|
|
3917
|
+
SPEC_WALLPAPER_UPLOAD = OperationSpec(
|
|
3918
|
+
id="media.wallpaper.upload",
|
|
3919
|
+
request=WallpaperUploadReq,
|
|
3920
|
+
response=WallpaperUploaded,
|
|
3921
|
+
impl=wallpaper_upload,
|
|
3922
|
+
summary="Upload a custom wallpaper image or pattern",
|
|
3923
|
+
mutating=True,
|
|
3924
|
+
rate_class="file",
|
|
3925
|
+
timeout_s=300,
|
|
3926
|
+
columns=("slug", "link"),
|
|
3927
|
+
headers=("Slug", "Link"),
|
|
3928
|
+
example={
|
|
3929
|
+
"id": 5947530738857476,
|
|
3930
|
+
"slug": "Ycb0FfC6",
|
|
3931
|
+
"link": "https://t.me/bg/Ycb0FfC6",
|
|
3932
|
+
},
|
|
3933
|
+
example_args="media wallpaper upload background.jpg",
|
|
3934
|
+
covers_partial=("wallpaper.save-install",),
|
|
3935
|
+
coverage_note="Installing the uploaded slug is `media wallpaper set`.",
|
|
3936
|
+
)
|
|
3937
|
+
|
|
3938
|
+
|
|
3939
|
+
# ---------------------------------------------------------------------------
|
|
3940
|
+
# media auto-download / auto-save / sensitive
|
|
3941
|
+
# ---------------------------------------------------------------------------
|
|
3942
|
+
|
|
3943
|
+
|
|
3944
|
+
class AutoDownloadGetReq(Request):
|
|
3945
|
+
preset: Annotated[
|
|
3946
|
+
str | None, choice("low", "medium", "high", help="Show one preset instead of all three.")
|
|
3947
|
+
] = None
|
|
3948
|
+
|
|
3949
|
+
|
|
3950
|
+
def _preset_model(name: str, settings: Any) -> AutoDownloadPreset:
|
|
3951
|
+
return AutoDownloadPreset(
|
|
3952
|
+
preset=name,
|
|
3953
|
+
disabled=bool(getattr(settings, "disabled", False)),
|
|
3954
|
+
photo_size_max=int(getattr(settings, "photo_size_max", 0) or 0),
|
|
3955
|
+
video_size_max=int(getattr(settings, "video_size_max", 0) or 0),
|
|
3956
|
+
file_size_max=int(getattr(settings, "file_size_max", 0) or 0),
|
|
3957
|
+
video_upload_maxbitrate=int(getattr(settings, "video_upload_maxbitrate", 0) or 0),
|
|
3958
|
+
video_preload_large=bool(getattr(settings, "video_preload_large", False)),
|
|
3959
|
+
audio_preload_next=bool(getattr(settings, "audio_preload_next", False)),
|
|
3960
|
+
phonecalls_less_data=bool(getattr(settings, "phonecalls_less_data", False)),
|
|
3961
|
+
stories_preload=bool(getattr(settings, "stories_preload", False)),
|
|
3962
|
+
)
|
|
3963
|
+
|
|
3964
|
+
|
|
3965
|
+
async def auto_download_get(ctx: OpContext, req: AutoDownloadGetReq) -> AutoDownloadSettings:
|
|
3966
|
+
"""The cloud auto-download presets.
|
|
3967
|
+
|
|
3968
|
+
A synced *preference*, not behaviour: the downloading itself is the
|
|
3969
|
+
client's, and in tlgr that is `media watch --download
|
|
3970
|
+
--use-cloud-settings`. The GUI's mobile/wifi/roaming rows are these three.
|
|
3971
|
+
"""
|
|
3972
|
+
from telethon.tl.functions import account as fn
|
|
3973
|
+
|
|
3974
|
+
result = await _client(ctx)(fn.GetAutoDownloadSettingsRequest())
|
|
3975
|
+
presets = [
|
|
3976
|
+
_preset_model(name, getattr(result, name, None))
|
|
3977
|
+
for name in ("low", "medium", "high")
|
|
3978
|
+
if getattr(result, name, None) is not None
|
|
3979
|
+
]
|
|
3980
|
+
if req.preset:
|
|
3981
|
+
presets = [preset for preset in presets if preset.preset == req.preset]
|
|
3982
|
+
return AutoDownloadSettings(presets=presets)
|
|
3983
|
+
|
|
3984
|
+
|
|
3985
|
+
SPEC_AUTO_DOWNLOAD_GET = OperationSpec(
|
|
3986
|
+
id="media.auto-download.get",
|
|
3987
|
+
request=AutoDownloadGetReq,
|
|
3988
|
+
response=AutoDownloadSettings,
|
|
3989
|
+
impl=auto_download_get,
|
|
3990
|
+
summary="Read the cloud auto-download presets",
|
|
3991
|
+
example={
|
|
3992
|
+
"presets": [
|
|
3993
|
+
{"preset": "low", "photo_size_max": 1048576, "video_size_max": 512000},
|
|
3994
|
+
{"preset": "medium", "photo_size_max": 1048576, "video_size_max": 10485760},
|
|
3995
|
+
]
|
|
3996
|
+
},
|
|
3997
|
+
example_args="media auto-download get",
|
|
3998
|
+
covers_partial=("media.auto-download-settings",),
|
|
3999
|
+
coverage_note="Writing is `media auto-download set`.",
|
|
4000
|
+
)
|
|
4001
|
+
|
|
4002
|
+
|
|
4003
|
+
class AutoDownloadSetReq(Request):
|
|
4004
|
+
preset: Annotated[str, choice("low", "medium", "high", help="Which preset to write.")] = (
|
|
4005
|
+
"medium"
|
|
4006
|
+
)
|
|
4007
|
+
disabled: Annotated[
|
|
4008
|
+
bool | None, opt("--disabled/--enabled", help="Turn auto-download off for this preset.")
|
|
4009
|
+
] = None
|
|
4010
|
+
photo_max: Annotated[str | None, opt("--photo-max", metavar="SIZE", help="Photo cap.")] = None
|
|
4011
|
+
video_max: Annotated[str | None, opt("--video-max", metavar="SIZE", help="Video cap.")] = None
|
|
4012
|
+
file_max: Annotated[str | None, opt("--file-max", metavar="SIZE", help="File cap.")] = None
|
|
4013
|
+
preload_large_video: Annotated[
|
|
4014
|
+
bool | None,
|
|
4015
|
+
opt("--preload-large-video/--no-preload-large-video", help="Preload big videos."),
|
|
4016
|
+
] = None
|
|
4017
|
+
preload_next_audio: Annotated[
|
|
4018
|
+
bool | None,
|
|
4019
|
+
opt("--preload-next-audio/--no-preload-next-audio", help="Preload the next track."),
|
|
4020
|
+
] = None
|
|
4021
|
+
preload_stories: Annotated[
|
|
4022
|
+
bool | None, opt("--preload-stories/--no-preload-stories", help="Preload stories.")
|
|
4023
|
+
] = None
|
|
4024
|
+
less_call_data: Annotated[
|
|
4025
|
+
bool | None, opt("--less-call-data/--no-less-call-data", help="Use less data for calls.")
|
|
4026
|
+
] = None
|
|
4027
|
+
reset: Annotated[bool, opt("--reset", help="Restore the server defaults.")] = False
|
|
4028
|
+
|
|
4029
|
+
|
|
4030
|
+
async def auto_download_set(ctx: OpContext, req: AutoDownloadSetReq) -> AutoDownloadSaved:
|
|
4031
|
+
"""Write one auto-download preset.
|
|
4032
|
+
|
|
4033
|
+
Read-modify-write, because the request carries a whole
|
|
4034
|
+
`autoDownloadSettings`: writing one field from scratch would blank the
|
|
4035
|
+
rest of that preset.
|
|
4036
|
+
"""
|
|
4037
|
+
from telethon.tl import types
|
|
4038
|
+
from telethon.tl.functions import account as fn
|
|
4039
|
+
|
|
4040
|
+
current = await auto_download_get(ctx, AutoDownloadGetReq(preset=req.preset))
|
|
4041
|
+
base = current.presets[0] if current.presets else AutoDownloadPreset(preset=req.preset)
|
|
4042
|
+
if req.reset:
|
|
4043
|
+
base = AutoDownloadPreset(preset=req.preset)
|
|
4044
|
+
|
|
4045
|
+
settings = types.AutoDownloadSettings(
|
|
4046
|
+
photo_size_max=_size_arg(req.photo_max, "photo_max") or base.photo_size_max,
|
|
4047
|
+
video_size_max=_size_arg(req.video_max, "video_max") or base.video_size_max,
|
|
4048
|
+
file_size_max=_size_arg(req.file_max, "file_max") or base.file_size_max,
|
|
4049
|
+
video_upload_maxbitrate=base.video_upload_maxbitrate,
|
|
4050
|
+
small_queue_active_operations_max=5,
|
|
4051
|
+
large_queue_active_operations_max=2,
|
|
4052
|
+
disabled=(base.disabled if req.disabled is None else req.disabled) or None,
|
|
4053
|
+
video_preload_large=(
|
|
4054
|
+
base.video_preload_large if req.preload_large_video is None else req.preload_large_video
|
|
4055
|
+
)
|
|
4056
|
+
or None,
|
|
4057
|
+
audio_preload_next=(
|
|
4058
|
+
base.audio_preload_next if req.preload_next_audio is None else req.preload_next_audio
|
|
4059
|
+
)
|
|
4060
|
+
or None,
|
|
4061
|
+
phonecalls_less_data=(
|
|
4062
|
+
base.phonecalls_less_data if req.less_call_data is None else req.less_call_data
|
|
4063
|
+
)
|
|
4064
|
+
or None,
|
|
4065
|
+
stories_preload=(
|
|
4066
|
+
base.stories_preload if req.preload_stories is None else req.preload_stories
|
|
4067
|
+
)
|
|
4068
|
+
or None,
|
|
4069
|
+
)
|
|
4070
|
+
await _client(ctx)(
|
|
4071
|
+
fn.SaveAutoDownloadSettingsRequest(
|
|
4072
|
+
settings=settings,
|
|
4073
|
+
low=req.preset == "low" or None,
|
|
4074
|
+
high=req.preset == "high" or None,
|
|
4075
|
+
)
|
|
4076
|
+
)
|
|
4077
|
+
return AutoDownloadSaved(
|
|
4078
|
+
preset=req.preset, ok=True, settings=_preset_model(req.preset, settings)
|
|
4079
|
+
)
|
|
4080
|
+
|
|
4081
|
+
|
|
4082
|
+
SPEC_AUTO_DOWNLOAD_SET = OperationSpec(
|
|
4083
|
+
id="media.auto-download.set",
|
|
4084
|
+
request=AutoDownloadSetReq,
|
|
4085
|
+
response=AutoDownloadSaved,
|
|
4086
|
+
impl=auto_download_set,
|
|
4087
|
+
summary="Write the cloud auto-download presets",
|
|
4088
|
+
mutating=True,
|
|
4089
|
+
columns=("preset", "ok"),
|
|
4090
|
+
headers=("Preset", "OK"),
|
|
4091
|
+
example={"preset": "medium", "ok": True},
|
|
4092
|
+
example_args="media auto-download set --preset medium --video-max 10M",
|
|
4093
|
+
covers=("media.auto-download-settings",),
|
|
4094
|
+
)
|
|
4095
|
+
|
|
4096
|
+
|
|
4097
|
+
class AutoSaveGetReq(Request):
|
|
4098
|
+
chat: Annotated[
|
|
4099
|
+
PeerRef | None,
|
|
4100
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Show the exception for one chat."),
|
|
4101
|
+
] = None
|
|
4102
|
+
|
|
4103
|
+
|
|
4104
|
+
def _auto_save_rule(settings: Any) -> AutoSaveRule | None:
|
|
4105
|
+
if settings is None:
|
|
4106
|
+
return None
|
|
4107
|
+
return AutoSaveRule(
|
|
4108
|
+
photos=bool(getattr(settings, "photos", False)),
|
|
4109
|
+
videos=bool(getattr(settings, "videos", False)),
|
|
4110
|
+
video_max_size=getattr(settings, "video_max_size", None),
|
|
4111
|
+
)
|
|
4112
|
+
|
|
4113
|
+
|
|
4114
|
+
async def auto_save_get(ctx: OpContext, req: AutoSaveGetReq) -> AutoSaveSettings:
|
|
4115
|
+
"""The cloud save-to-gallery rules.
|
|
4116
|
+
|
|
4117
|
+
A synced preference tlgr honours in `media watch --download
|
|
4118
|
+
--use-cloud-settings`; it keeps no gallery of its own.
|
|
4119
|
+
"""
|
|
4120
|
+
from telethon.tl.functions import account as fn
|
|
4121
|
+
|
|
4122
|
+
result = await _client(ctx)(fn.GetAutoSaveSettingsRequest())
|
|
4123
|
+
peers = {
|
|
4124
|
+
entity.id: entity_to_peer(entity)
|
|
4125
|
+
for entity in [
|
|
4126
|
+
*(getattr(result, "users", None) or []),
|
|
4127
|
+
*(getattr(result, "chats", None) or []),
|
|
4128
|
+
]
|
|
4129
|
+
}
|
|
4130
|
+
exceptions: list[AutoSaveException] = []
|
|
4131
|
+
from tlgr.ops._serialize import peer_id_of as marked_of
|
|
4132
|
+
|
|
4133
|
+
for entry in getattr(result, "exceptions", None) or []:
|
|
4134
|
+
chat_id = marked_of(getattr(entry, "peer", None)) or 0
|
|
4135
|
+
exceptions.append(
|
|
4136
|
+
AutoSaveException(
|
|
4137
|
+
chat_id=chat_id,
|
|
4138
|
+
chat=peers.get(abs(chat_id) % 1000000000000),
|
|
4139
|
+
rule=_auto_save_rule(getattr(entry, "settings", None)),
|
|
4140
|
+
)
|
|
4141
|
+
)
|
|
4142
|
+
if req.chat is not None:
|
|
4143
|
+
wanted = _send.peer_id_of(await _send.resolve(ctx, req.chat))
|
|
4144
|
+
exceptions = [entry for entry in exceptions if entry.chat_id == wanted]
|
|
4145
|
+
return AutoSaveSettings(
|
|
4146
|
+
users=_auto_save_rule(getattr(result, "users_settings", None)),
|
|
4147
|
+
chats=_auto_save_rule(getattr(result, "chats_settings", None)),
|
|
4148
|
+
broadcasts=_auto_save_rule(getattr(result, "broadcasts_settings", None)),
|
|
4149
|
+
exceptions=exceptions,
|
|
4150
|
+
)
|
|
4151
|
+
|
|
4152
|
+
|
|
4153
|
+
SPEC_AUTO_SAVE_GET = OperationSpec(
|
|
4154
|
+
id="media.auto-save.get",
|
|
4155
|
+
request=AutoSaveGetReq,
|
|
4156
|
+
response=AutoSaveSettings,
|
|
4157
|
+
impl=auto_save_get,
|
|
4158
|
+
summary="Read the cloud save-to-gallery rules",
|
|
4159
|
+
example={
|
|
4160
|
+
"users": {"photos": True, "videos": False},
|
|
4161
|
+
"chats": {"photos": False, "videos": False},
|
|
4162
|
+
"exceptions": [],
|
|
4163
|
+
},
|
|
4164
|
+
example_args="media auto-save get",
|
|
4165
|
+
covers_partial=("media.auto-save-to-gallery-settings",),
|
|
4166
|
+
coverage_note="Writing is `media auto-save set`.",
|
|
4167
|
+
)
|
|
4168
|
+
|
|
4169
|
+
|
|
4170
|
+
class AutoSaveSetReq(Request):
|
|
4171
|
+
scope: Annotated[
|
|
4172
|
+
str,
|
|
4173
|
+
choice("users", "groups", "channels", help="Which category to write."),
|
|
4174
|
+
] = "users"
|
|
4175
|
+
chat: Annotated[
|
|
4176
|
+
PeerRef | None,
|
|
4177
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Write an exception for one chat."),
|
|
4178
|
+
] = None
|
|
4179
|
+
photos: Annotated[bool | None, opt("--photos/--no-photos", help="Auto-save photos.")] = None
|
|
4180
|
+
videos: Annotated[bool | None, opt("--videos/--no-videos", help="Auto-save videos.")] = None
|
|
4181
|
+
video_max: Annotated[str | None, opt("--video-max", metavar="SIZE", help="Video size cap.")] = (
|
|
4182
|
+
None
|
|
4183
|
+
)
|
|
4184
|
+
clear_exceptions: Annotated[
|
|
4185
|
+
bool, opt("--clear-exceptions", help="Delete every per-chat exception.")
|
|
4186
|
+
] = False
|
|
4187
|
+
|
|
4188
|
+
|
|
4189
|
+
async def auto_save_set(ctx: OpContext, req: AutoSaveSetReq) -> AutoSaveSaved:
|
|
4190
|
+
"""Write one save-to-gallery scope.
|
|
4191
|
+
|
|
4192
|
+
The API takes exactly one scope per call — users, groups, channels or a
|
|
4193
|
+
single peer — so this is one `--scope` choice rather than three flags that
|
|
4194
|
+
could contradict each other.
|
|
4195
|
+
"""
|
|
4196
|
+
from telethon.tl import types
|
|
4197
|
+
from telethon.tl.functions import account as fn
|
|
4198
|
+
|
|
4199
|
+
if req.clear_exceptions:
|
|
4200
|
+
await _client(ctx)(fn.DeleteAutoSaveExceptionsRequest())
|
|
4201
|
+
return AutoSaveSaved(scope="exceptions", ok=True, cleared_exceptions=True)
|
|
4202
|
+
|
|
4203
|
+
settings = types.AutoSaveSettings(
|
|
4204
|
+
photos=req.photos,
|
|
4205
|
+
videos=req.videos,
|
|
4206
|
+
video_max_size=_size_arg(req.video_max, "video_max"),
|
|
4207
|
+
)
|
|
4208
|
+
peer = await _send.resolve(ctx, req.chat) if req.chat is not None else None
|
|
4209
|
+
await _client(ctx)(
|
|
4210
|
+
fn.SaveAutoSaveSettingsRequest(
|
|
4211
|
+
settings=settings,
|
|
4212
|
+
users=(req.scope == "users" and peer is None) or None,
|
|
4213
|
+
chats=(req.scope == "groups" and peer is None) or None,
|
|
4214
|
+
broadcasts=(req.scope == "channels" and peer is None) or None,
|
|
4215
|
+
peer=peer,
|
|
4216
|
+
)
|
|
4217
|
+
)
|
|
4218
|
+
return AutoSaveSaved(
|
|
4219
|
+
scope="chat" if peer is not None else req.scope,
|
|
4220
|
+
ok=True,
|
|
4221
|
+
settings=_auto_save_rule(settings),
|
|
4222
|
+
)
|
|
4223
|
+
|
|
4224
|
+
|
|
4225
|
+
SPEC_AUTO_SAVE_SET = OperationSpec(
|
|
4226
|
+
id="media.auto-save.set",
|
|
4227
|
+
request=AutoSaveSetReq,
|
|
4228
|
+
response=AutoSaveSaved,
|
|
4229
|
+
impl=auto_save_set,
|
|
4230
|
+
summary="Write the cloud save-to-gallery rules",
|
|
4231
|
+
mutating=True,
|
|
4232
|
+
columns=("scope", "ok"),
|
|
4233
|
+
headers=("Scope", "OK"),
|
|
4234
|
+
example={"scope": "users", "ok": True, "settings": {"photos": True, "videos": True}},
|
|
4235
|
+
example_args="media auto-save set --scope users --photos",
|
|
4236
|
+
covers=("media.auto-save-to-gallery-settings",),
|
|
4237
|
+
)
|
|
4238
|
+
|
|
4239
|
+
|
|
4240
|
+
class SensitiveGetReq(Request):
|
|
4241
|
+
pass
|
|
4242
|
+
|
|
4243
|
+
|
|
4244
|
+
async def sensitive_get(ctx: OpContext, req: SensitiveGetReq) -> ContentSettings:
|
|
4245
|
+
"""Whether 18+ media is shown, and whether that can be changed from here.
|
|
4246
|
+
|
|
4247
|
+
`sensitive_can_change = false` is the interesting case: in some regions
|
|
4248
|
+
the toggle only unlocks after an age check inside a Telegram-designated
|
|
4249
|
+
mini app, which a terminal cannot render. tlgr reports that and names the
|
|
4250
|
+
bot rather than retrying a write the server will refuse.
|
|
4251
|
+
"""
|
|
4252
|
+
from telethon.tl.functions import account as fn
|
|
4253
|
+
|
|
4254
|
+
result = await _client(ctx)(fn.GetContentSettingsRequest())
|
|
4255
|
+
can_change = bool(getattr(result, "sensitive_can_change", False))
|
|
4256
|
+
settings = ContentSettings(
|
|
4257
|
+
sensitive_enabled=bool(getattr(result, "sensitive_enabled", False)),
|
|
4258
|
+
sensitive_can_change=can_change,
|
|
4259
|
+
)
|
|
4260
|
+
if not can_change:
|
|
4261
|
+
values = await _media.app_config(ctx)
|
|
4262
|
+
bot = values.get("verify_age_bot_username")
|
|
4263
|
+
settings.age_verification_required = bool(values.get("verify_age_min") or bot)
|
|
4264
|
+
settings.age_verification_bot = f"@{bot}" if bot else None
|
|
4265
|
+
settings.reason = (
|
|
4266
|
+
"this account cannot change the setting from an API client; the toggle "
|
|
4267
|
+
"unlocks after an age check inside Telegram's own verification mini app"
|
|
4268
|
+
)
|
|
4269
|
+
return settings
|
|
4270
|
+
|
|
4271
|
+
|
|
4272
|
+
SPEC_SENSITIVE_GET = OperationSpec(
|
|
4273
|
+
id="media.sensitive.get",
|
|
4274
|
+
request=SensitiveGetReq,
|
|
4275
|
+
response=ContentSettings,
|
|
4276
|
+
impl=sensitive_get,
|
|
4277
|
+
summary="Whether 18+ media is shown, and whether that can be changed here",
|
|
4278
|
+
columns=("sensitive_enabled", "sensitive_can_change"),
|
|
4279
|
+
headers=("Enabled", "Can change"),
|
|
4280
|
+
example={"sensitive_enabled": False, "sensitive_can_change": True},
|
|
4281
|
+
example_args="media sensitive get",
|
|
4282
|
+
covers=("media.age-verification",),
|
|
4283
|
+
covers_partial=("media.sensitive-content-setting",),
|
|
4284
|
+
coverage_note="Writing the toggle is `media sensitive set`.",
|
|
4285
|
+
)
|
|
4286
|
+
|
|
4287
|
+
|
|
4288
|
+
class SensitiveSetReq(Request):
|
|
4289
|
+
state: Annotated[str, arg(0, metavar="STATE", help="on or off.")]
|
|
4290
|
+
|
|
4291
|
+
|
|
4292
|
+
async def sensitive_set(ctx: OpContext, req: SensitiveSetReq) -> ContentSettingsSaved:
|
|
4293
|
+
"""Show or hide 18+ media, after checking that this account may.
|
|
4294
|
+
|
|
4295
|
+
The pre-flight is the point: letting the server reject the write produces
|
|
4296
|
+
a confusing failure, while `sensitive_can_change` answers "you cannot do
|
|
4297
|
+
this from here, and here is why" before anything is sent.
|
|
4298
|
+
"""
|
|
4299
|
+
from telethon.tl.functions import account as fn
|
|
4300
|
+
|
|
4301
|
+
wanted = str(req.state).strip().lower()
|
|
4302
|
+
if wanted not in ("on", "off", "true", "false", "1", "0"):
|
|
4303
|
+
raise UsageError("STATE is `on` or `off`", field="state")
|
|
4304
|
+
enable = wanted in ("on", "true", "1")
|
|
4305
|
+
|
|
4306
|
+
current = await sensitive_get(ctx, SensitiveGetReq())
|
|
4307
|
+
if current.sensitive_enabled == enable:
|
|
4308
|
+
ctx.mark_already() if hasattr(ctx, "mark_already") else None
|
|
4309
|
+
return ContentSettingsSaved(sensitive_enabled=enable, ok=True, already=True)
|
|
4310
|
+
if not current.sensitive_can_change:
|
|
4311
|
+
raise PermissionError_(
|
|
4312
|
+
current.reason
|
|
4313
|
+
or "this account may not change the sensitive-content setting from an API client"
|
|
4314
|
+
)
|
|
4315
|
+
await _client(ctx)(fn.SetContentSettingsRequest(sensitive_enabled=enable or None))
|
|
4316
|
+
return ContentSettingsSaved(sensitive_enabled=enable, ok=True)
|
|
4317
|
+
|
|
4318
|
+
|
|
4319
|
+
SPEC_SENSITIVE_SET = OperationSpec(
|
|
4320
|
+
id="media.sensitive.set",
|
|
4321
|
+
request=SensitiveSetReq,
|
|
4322
|
+
response=ContentSettingsSaved,
|
|
4323
|
+
impl=sensitive_set,
|
|
4324
|
+
summary="Show or hide 18+ media",
|
|
4325
|
+
mutating=True,
|
|
4326
|
+
idempotent=True,
|
|
4327
|
+
columns=("sensitive_enabled", "ok", "already"),
|
|
4328
|
+
headers=("Enabled", "OK", "Already"),
|
|
4329
|
+
example={"sensitive_enabled": True, "ok": True},
|
|
4330
|
+
example_args="media sensitive set on",
|
|
4331
|
+
covers=("media.sensitive-content-setting",),
|
|
4332
|
+
covers_partial=("media.age-verification",),
|
|
4333
|
+
coverage_note="The verification flow itself runs in Telegram's mini app, not here.",
|
|
4334
|
+
)
|
|
4335
|
+
|
|
4336
|
+
|
|
4337
|
+
# ---------------------------------------------------------------------------
|
|
4338
|
+
# media storage (local)
|
|
4339
|
+
# ---------------------------------------------------------------------------
|
|
4340
|
+
|
|
4341
|
+
|
|
4342
|
+
_KIND_SUFFIXES = {
|
|
4343
|
+
"photo": (".jpg", ".jpeg", ".png", ".webp", ".heic"),
|
|
4344
|
+
"video": (".mp4", ".mkv", ".mov", ".webm"),
|
|
4345
|
+
"music": (".mp3", ".m4a", ".flac"),
|
|
4346
|
+
"voice": (".ogg", ".oga", ".opus"),
|
|
4347
|
+
"gif": (".gif",),
|
|
4348
|
+
"thumb": (".thumb.jpg",),
|
|
4349
|
+
"partial": (".part",),
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
|
|
4353
|
+
def _kind_of_file(path: Path) -> str:
|
|
4354
|
+
suffix = path.suffix.lower()
|
|
4355
|
+
for kind, suffixes in _KIND_SUFFIXES.items():
|
|
4356
|
+
if suffix in suffixes:
|
|
4357
|
+
return kind
|
|
4358
|
+
return "file"
|
|
4359
|
+
|
|
4360
|
+
|
|
4361
|
+
def _storage_root(ctx: OpContext) -> Path:
|
|
4362
|
+
from tlgr.core.config import get_downloads_dir
|
|
4363
|
+
|
|
4364
|
+
return Path(get_downloads_dir())
|
|
4365
|
+
|
|
4366
|
+
|
|
4367
|
+
class StorageGetReq(Request):
|
|
4368
|
+
by_chat: Annotated[bool, opt("--by-chat", help="Break the total down per chat.")] = False
|
|
4369
|
+
by_type: Annotated[bool, opt("--by-type", help="Break the total down per media type.")] = False
|
|
4370
|
+
|
|
4371
|
+
|
|
4372
|
+
async def storage_get(ctx: OpContext, req: StorageGetReq) -> StorageUsage:
|
|
4373
|
+
"""Disk used by tlgr's own downloads.
|
|
4374
|
+
|
|
4375
|
+
Purely local: Telegram stores no cache statistics, and the scope is the
|
|
4376
|
+
configured downloads root and nothing else — never the user's own
|
|
4377
|
+
directories.
|
|
4378
|
+
"""
|
|
4379
|
+
root = _storage_root(ctx)
|
|
4380
|
+
usage = StorageUsage(root=str(root))
|
|
4381
|
+
if not root.exists():
|
|
4382
|
+
return usage
|
|
4383
|
+
oldest: float | None = None
|
|
4384
|
+
for path in root.rglob("*"):
|
|
4385
|
+
if not path.is_file():
|
|
4386
|
+
continue
|
|
4387
|
+
size = path.stat().st_size
|
|
4388
|
+
usage.files += 1
|
|
4389
|
+
usage.bytes += size
|
|
4390
|
+
if path.suffix == ".part":
|
|
4391
|
+
usage.partials += 1
|
|
4392
|
+
stamp = path.stat().st_mtime
|
|
4393
|
+
oldest = stamp if oldest is None else min(oldest, stamp)
|
|
4394
|
+
if req.by_chat:
|
|
4395
|
+
chat = path.relative_to(root).parts[0]
|
|
4396
|
+
usage.by_chat[chat] = usage.by_chat.get(chat, 0) + size
|
|
4397
|
+
if req.by_type:
|
|
4398
|
+
kind = _kind_of_file(path)
|
|
4399
|
+
usage.by_type[kind] = usage.by_type.get(kind, 0) + size
|
|
4400
|
+
if oldest is not None:
|
|
4401
|
+
from datetime import datetime, timezone
|
|
4402
|
+
|
|
4403
|
+
usage.oldest = datetime.fromtimestamp(oldest, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4404
|
+
return usage
|
|
4405
|
+
|
|
4406
|
+
|
|
4407
|
+
SPEC_STORAGE_GET = OperationSpec(
|
|
4408
|
+
id="media.storage.get",
|
|
4409
|
+
request=StorageGetReq,
|
|
4410
|
+
response=StorageUsage,
|
|
4411
|
+
impl=storage_get,
|
|
4412
|
+
summary="Disk usage of tlgr's downloads and cache",
|
|
4413
|
+
surface=Surface.LOCAL,
|
|
4414
|
+
needs_account=False,
|
|
4415
|
+
needs_auth=False,
|
|
4416
|
+
rate_class="local",
|
|
4417
|
+
columns=("root", "files", "bytes", "partials"),
|
|
4418
|
+
headers=("Root", "Files", "Bytes", "Partial"),
|
|
4419
|
+
example={
|
|
4420
|
+
"root": "/home/u/.tlgr/downloads",
|
|
4421
|
+
"bytes": 918273645,
|
|
4422
|
+
"files": 412,
|
|
4423
|
+
"partials": 1,
|
|
4424
|
+
},
|
|
4425
|
+
example_args="media storage get --by-type",
|
|
4426
|
+
covers=("media.storage-cache",),
|
|
4427
|
+
)
|
|
4428
|
+
|
|
4429
|
+
|
|
4430
|
+
class StorageClearReq(Request):
|
|
4431
|
+
older_than: Annotated[
|
|
4432
|
+
int | None,
|
|
4433
|
+
opt(
|
|
4434
|
+
"--older-than", metavar="DURATION", kind="duration", help="Only files older than this."
|
|
4435
|
+
),
|
|
4436
|
+
] = None
|
|
4437
|
+
type: Annotated[list[str], opt("--type", metavar="KIND", help="Only these kinds.")] = []
|
|
4438
|
+
chat: Annotated[
|
|
4439
|
+
str | None, opt("--chat", metavar="CHAT", help="Only this chat's directory.")
|
|
4440
|
+
] = None
|
|
4441
|
+
keep_days: Annotated[
|
|
4442
|
+
int | None, opt("--keep-days", metavar="N", help="Persist a keep-media TTL in days.")
|
|
4443
|
+
] = None
|
|
4444
|
+
|
|
4445
|
+
|
|
4446
|
+
async def storage_clear(ctx: OpContext, req: StorageClearReq) -> StorageCleared:
|
|
4447
|
+
"""Delete cached downloads, and only inside tlgr's own root.
|
|
4448
|
+
|
|
4449
|
+
Anything outside the configured downloads directory is out of scope by
|
|
4450
|
+
construction: the walk starts there and never follows a link out of it.
|
|
4451
|
+
"""
|
|
4452
|
+
root = _storage_root(ctx)
|
|
4453
|
+
cleared = StorageCleared(keep_days=req.keep_days)
|
|
4454
|
+
if not root.exists():
|
|
4455
|
+
return cleared
|
|
4456
|
+
cutoff = time.time() - req.older_than if req.older_than else None
|
|
4457
|
+
wanted = set(req.type)
|
|
4458
|
+
scope = root / req.chat if req.chat else root
|
|
4459
|
+
|
|
4460
|
+
for path in scope.rglob("*"):
|
|
4461
|
+
if not path.is_file() or path.is_symlink():
|
|
4462
|
+
continue
|
|
4463
|
+
if cutoff is not None and path.stat().st_atime > cutoff:
|
|
4464
|
+
cleared.kept_files += 1
|
|
4465
|
+
continue
|
|
4466
|
+
if wanted and _kind_of_file(path) not in wanted:
|
|
4467
|
+
cleared.kept_files += 1
|
|
4468
|
+
continue
|
|
4469
|
+
size = path.stat().st_size
|
|
4470
|
+
with contextlib.suppress(OSError):
|
|
4471
|
+
path.unlink()
|
|
4472
|
+
cleared.deleted_files += 1
|
|
4473
|
+
cleared.freed_bytes += size
|
|
4474
|
+
return cleared
|
|
4475
|
+
|
|
4476
|
+
|
|
4477
|
+
SPEC_STORAGE_CLEAR = OperationSpec(
|
|
4478
|
+
id="media.storage.clear",
|
|
4479
|
+
request=StorageClearReq,
|
|
4480
|
+
response=StorageCleared,
|
|
4481
|
+
impl=storage_clear,
|
|
4482
|
+
summary="Delete cached downloads",
|
|
4483
|
+
surface=Surface.LOCAL,
|
|
4484
|
+
needs_account=False,
|
|
4485
|
+
needs_auth=False,
|
|
4486
|
+
mutating=True,
|
|
4487
|
+
destructive=True,
|
|
4488
|
+
rate_class="local",
|
|
4489
|
+
columns=("deleted_files", "freed_bytes", "kept_files"),
|
|
4490
|
+
headers=("Deleted", "Freed", "Kept"),
|
|
4491
|
+
example={"deleted_files": 128, "freed_bytes": 419430400, "kept_files": 284},
|
|
4492
|
+
example_args="media storage clear --older-than 30d",
|
|
4493
|
+
covers_partial=("media.storage-cache",),
|
|
4494
|
+
coverage_note="Reporting the usage is `media storage get`.",
|
|
4495
|
+
)
|