Spaces:
Running
Running
File size: 20,037 Bytes
529b973 58f00c9 529b973 58f00c9 a670808 58f00c9 84e63a1 58f00c9 5f400f8 58f00c9 529b973 58f00c9 84e63a1 58f00c9 a670808 58f00c9 6c3a31d 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 529b973 6c3a31d 529b973 a670808 529b973 a670808 529b973 a670808 529b973 58f00c9 529b973 58f00c9 a00abaf 58f00c9 a00abaf 58f00c9 c3c6394 58f00c9 529b973 49b8170 58f00c9 529b973 58f00c9 a00abaf 58f00c9 a00abaf 58f00c9 a00abaf 529b973 58f00c9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 | """The rename pipeline: download -> (ffmpeg) -> upload, or for a plain rename of a big file a copy straight from the download into the upload."""
from __future__ import annotations
import logging
import mimetypes
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from telethon import errors, types
from . import errorlog
from . import fasttransfer as transfer
from . import media
from .config import Config
from .db import Database
from .errors import Cancelled, MediaError
from .formats import apply_format
from .parser import build_values, parse_info, render_caption, split_ext
from .progress import ProgressReporter
from .queue import Job
from .utils import clip, esc, human_duration, human_size
log = logging.getLogger(__name__)
MIME_OVERRIDES = {
".mkv": "video/x-matroska", ".mka": "audio/x-matroska", ".webm": "video/webm", ".mp4": "video/mp4",
".m4v": "video/x-m4v", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".ts": "video/mp2t",
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".flac": "audio/flac", ".ogg": "audio/ogg", ".opus": "audio/ogg",
".wav": "audio/x-wav", ".srt": "application/x-subrip", ".ass": "text/x-ssa", ".zip": "application/zip",
}
# containers worth running ffprobe on
PROBE_EXTS = media.VIDEO_CONTAINERS | media.AUDIO_TAG_EXTS | {".avi", ".ts", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".wav", ".aac", ".wma"}
def guess_mime(name: str, fallback: Optional[str]) -> str:
ext = split_ext(name)[1].lower()
return MIME_OVERRIDES.get(ext) or mimetypes.guess_type(name)[0] or fallback or "application/octet-stream"
def decide_send_type(setting: str, message: Any) -> str:
"""How the renamed file is sent: ``document`` | ``video`` | ``audio``."""
if setting in ("document", "video", "audio"):
return setting
if getattr(message, "video", None) is not None:
return "video"
if getattr(message, "audio", None) is not None:
return "audio"
return "document"
def source_ext(message: Any, original_name: str) -> str:
ext = split_ext(original_name)[1]
if not ext:
ext = getattr(message.file, "ext", None) or ""
return ext
def original_name_of(message: Any) -> str:
f = message.file
if f is not None and f.name:
return f.name
kind = "video" if getattr(message, "video", None) is not None else "audio" if getattr(message, "audio", None) is not None else "file"
return f"{kind}_{message.id}{(f.ext if f is not None else '') or ''}"
def make_caption(template: str, *, new_name: str, orig_name: str, ext: str, size: int, duration: float) -> Optional[str]:
"""The caption of a renamed file: the user's (or the format's) template with its variables filled in."""
parsed = parse_info(split_ext(orig_name)[0])
values = build_values(parsed, split_ext(orig_name)[0], ext)
values.update(
filename=split_ext(new_name)[0], original=orig_name, ext=ext.lstrip("."),
size=human_size(size), duration=human_duration(duration) if duration else "",
)
return render_caption(template, values)[:1000] or None
def source_attributes(message: Any) -> Tuple[Optional[Any], Optional[Any]]:
"""The video and the audio details Telegram keeps with a file: ``(DocumentAttributeVideo, DocumentAttributeAudio)``, ``None`` for a missing one."""
attributes = getattr(getattr(message, "document", None), "attributes", None) or []
video = next((a for a in attributes if isinstance(a, types.DocumentAttributeVideo) and not a.round_message), None)
audio = next((a for a in attributes if isinstance(a, types.DocumentAttributeAudio) and not a.voice), None)
return video, audio
async def embedded_thumb(client: Any, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
"""The preview Telegram keeps with a file. The tiny blurred one (a "stripped" size) only counts when ``allow_stripped``."""
thumbs = getattr(getattr(msg, "document", None), "thumbs", None) or []
if not any(not isinstance(t, types.PhotoStrippedSize) for t in thumbs) and not (allow_stripped and thumbs):
return None
try:
return await client.download_media(msg, file=bytes, thumb=-1) or None
except Exception as exc: # noqa: BLE001
log.debug("could not download the embedded thumbnail: %s", exc)
return None
@dataclass
class StreamPlan:
"""What a streamed rename sends along with the file, worked out without ever reading the file."""
attrs: List[Any]
as_document: bool
thumb: Optional[bytes]
duration: float
class Renamer:
def __init__(self, client: Any, cfg: Config, db: Database):
self.client = client
self.cfg = cfg
self.db = db
# called as ``await on_delivered(job, chat, sent)`` once a file renamed with a manual format is in its chat (the offer
# to upload it to the Database Channel, see dbflow); ``None``: nothing to do
self.on_delivered: Optional[Callable[[Job, int, Any], Awaitable[None]]] = None
# ------------------------------------------------------------------
async def _fresh_message(self, job: Job) -> Any:
"""Re-fetch the message so its file reference is valid even after a long queue wait."""
msg = await self.client.get_messages(job.chat_id, ids=job.msg_id)
if msg is None or msg.file is None:
raise RuntimeError("the original message was deleted")
return msg
def _check_space(self, work: Path, size: int, will_remux: bool) -> None:
need = int(size * (2.2 if will_remux else 1.1)) + 64 * 1024 * 1024
free = shutil.disk_usage(work).free
if free < need:
raise RuntimeError(f"not enough free disk space ({human_size(free)} free, about {human_size(need)} needed)")
# ------------------------------------------------------------------
async def process(self, job: Job) -> None:
s = apply_format(await self.db.get_settings(job.user_id), job.fmt) # no format: the built-in template and caption
work = Path(self.cfg.temp_dir) / f"job{job.id}"
work.mkdir(parents=True, exist_ok=True)
reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
started = time.monotonic()
context = errorlog.error_context.set( # the log channel report says which file this was
f"user {job.user_id} · job #{job.id}\n{job.file_name} → {job.new_name} ({human_size(job.size)})"
)
try:
await self._run(job, s, work, reporter, started)
except Cancelled:
job.state = "cancelled"
await reporter.finish("❌ <b>Cancelled</b>\n<code>" + esc(clip(job.new_name, 120)) + "</code>")
except Exception as exc: # noqa: BLE001
job.state = "failed"
log.exception("Error processing file:")
reason = clip(f"{type(exc).__name__}: {exc}", 300)
await reporter.finish(f"❌ <b>Failed</b>\n<code>{esc(clip(job.new_name, 120))}</code>\n\n{esc(reason)}")
finally:
errorlog.error_context.reset(context)
shutil.rmtree(work, ignore_errors=True)
async def _custom_thumb(self, job: Job) -> Optional[bytes]:
"""The thumbnail the user chose: the one of the format being used (``None``: a frame of the video is used)."""
return await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
async def _stream_plan(
self, job: Job, s: Dict[str, Any], msg: Any, ext: str, send_type: str, size: int
) -> Optional[StreamPlan]:
"""What a streamed rename would send, or ``None`` when the file has to be looked at first (it is then saved and uploaded as usual).
Streaming never saves the file, so nothing can read it: the video and audio details come from the ones Telegram keeps
with the original, the thumbnail from the user's setting or the original's own preview. A rename that changes the
file itself (metadata and tracks) cannot be streamed either.
"""
if not (self.cfg.fast_transfer and self.cfg.stream_transfer):
return None
video, audio = source_attributes(msg)
source = video if send_type == "video" else audio if send_type == "audio" else (video or audio)
duration = float(getattr(source, "duration", 0) or 0)
reason = None
if getattr(msg, "document", None) is None:
reason = "it is not a document"
elif size <= transfer.BIG_FILE_THRESHOLD:
reason = "it is small, so there is little to gain"
elif s["metadata_on"]:
reason = "metadata and tracks change the file"
elif send_type == "video" and video is None:
reason = "Telegram has no video details to reuse"
elif send_type == "audio" and audio is None:
reason = "Telegram has no audio details to reuse"
elif "{duration}" in (s["caption"] or "") and not duration:
reason = "the caption wants the duration and only the file can tell"
thumb = None
if reason is None:
thumb = await self._custom_thumb(job)
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS: # the usual way makes one from a frame of the file
thumb = await embedded_thumb(self.client, msg)
if thumb is None:
reason = "there is no thumbnail to reuse"
if reason is not None:
log.info("%s is not streamed: %s", job.new_name, reason)
return None
attrs: List[Any] = [types.DocumentAttributeFilename(job.new_name)]
if send_type == "video":
attrs.append(types.DocumentAttributeVideo(duration=video.duration or 0, w=video.w or 1, h=video.h or 1, supports_streaming=True))
elif send_type == "audio":
attrs.append(
types.DocumentAttributeAudio(
duration=int(audio.duration or 0), title=audio.title or None, performer=audio.performer or None
)
)
return StreamPlan(attrs=attrs, as_document=(send_type == "document"), thumb=thumb, duration=duration)
async def _stream(
self, job: Job, s: Dict[str, Any], msg: Any, orig_name: str, ext: str, send_type: str, size: int,
reporter: ProgressReporter, started: float,
) -> bool:
"""Rename by copying the file from the download straight into the upload: nothing is saved, so the whole takes about as long as
the slower of the two. ``True`` when the file was sent; ``False`` when it cannot be streamed or streaming failed (the usual way follows)."""
plan = await self._stream_plan(job, s, msg, ext, send_type, size)
if plan is None:
return False
reporter.set_stage("⬇️⬆️ Streaming", size)
began = time.monotonic()
try:
input_file = await transfer.relay(
self.client, msg.document, job.new_name, connections=self.cfg.connections, progress=reporter, cancel=job.cancel
)
except Cancelled:
raise
except Exception as exc: # noqa: BLE001
self._stream_failed(job, exc)
return False
seconds = time.monotonic() - began
if job.cancel.is_set():
raise Cancelled()
rates = f" · ⬇⬆ {human_size(size / seconds)}/s streamed" if seconds >= 1 else ""
caption = make_caption(s["caption"], new_name=job.new_name, orig_name=orig_name, ext=ext, size=size, duration=plan.duration)
try:
await self._deliver(
job, s, msg, input_file, caption=caption, attrs=plan.attrs, as_document=plan.as_document, thumb=plan.thumb,
final_size=size, warnings=list(job.notes), rates=rates, started=started, reporter=reporter,
)
except Exception as exc: # noqa: BLE001
if not transfer.is_file_part_error(exc): # only Telegram not accepting the parts is a streaming problem
raise
self._stream_failed(job, exc)
return False
transfer.stats.streamed += 1
return True
def _stream_failed(self, job: Job, exc: BaseException) -> None:
log.error("streaming %s failed (%s: %s); doing it the usual way", job.new_name, type(exc).__name__, exc, exc_info=exc)
transfer.stats.stream_failed(exc)
job.notes.append("Streaming didn't work for this file, so it was saved first and then uploaded.")
async def _deliver(
self, job: Job, s: Dict[str, Any], msg: Any, input_file: Any, *, caption: Optional[str], attrs: List[Any],
as_document: bool, thumb: Optional[bytes], final_size: int, warnings: List[str], rates: str, started: float,
reporter: ProgressReporter,
) -> None:
"""Send the uploaded file to its chat, then count it and say it is done."""
target = s.get("target_channel") or job.chat_id
kwargs: Dict[str, Any] = dict(
caption=caption, attributes=attrs, mime_type=guess_mime(job.new_name, getattr(msg.file, "mime_type", None)),
force_document=as_document, thumb=thumb, parse_mode="html",
)
if target == job.chat_id:
kwargs["reply_to"] = job.msg_id
try:
sent = await self.client.send_file(target, input_file, **kwargs)
except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
# the caption template produced something Telegram rejects: send the file without a caption
kwargs.update(caption=None)
sent = await self.client.send_file(target, input_file, **kwargs)
warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
await self.db.record_job(job.user_id, final_size)
job.state = "done"
took = time.monotonic() - started
note = "".join(f"\n⚠️ {esc(w)}" for w in warnings)
where = "" if target == job.chat_id else "\n📣 Sent to your channel."
await reporter.finish(
f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{rates}{where}{note}"
)
await self._after_delivery(job, target, sent)
async def _after_delivery(self, job: Job, target: int, sent: Any) -> None:
"""A file renamed with a manual format may go on to the Database Channel: the offer comes after the file itself."""
if self.on_delivered is None or job.fmt is None or job.fmt.get("kind") != "manual":
return
try:
await self.on_delivered(job, target, sent)
except Exception: # noqa: BLE001 - the rename is done whatever happens here
log.exception("could not offer the upload to the Database Channel")
async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
client, cfg = self.client, self.cfg
msg = await self._fresh_message(job)
orig_name = original_name_of(msg)
ext = source_ext(msg, orig_name)
send_type = decide_send_type(s["file_type"], msg)
size = int(msg.file.size or job.size or 0)
src = work / f"src{ext}"
if await self._stream(job, s, msg, orig_name, ext, send_type, size, reporter, started):
return
self._check_space(work, size, will_remux=bool(s["metadata_on"]))
# ---- download ------------------------------------------------------------
reporter.set_stage("⬇️ Downloading", size)
began = time.monotonic()
await transfer.download(
client, msg, src, fast=cfg.fast_transfer, connections=cfg.connections, progress=reporter, cancel=job.cancel
)
down_seconds = time.monotonic() - began
if job.cancel.is_set():
raise Cancelled()
# ---- probe / ffmpeg -----------------------------------------------------------
info: Optional[media.MediaInfo] = None
current = src
if ext.lower() in PROBE_EXTS or send_type in ("video", "audio"):
try:
info = await media.probe(src, job.cancel)
except MediaError as exc:
log.info("ffprobe could not read %s: %s", orig_name, exc)
warnings = list(job.notes)
plan = media.build_plan(info, s, ext) if (info is not None and s["metadata_on"]) else None
if s["metadata_on"] and plan is None and info is not None and ext.lower() not in (media.VIDEO_CONTAINERS | media.AUDIO_TAG_EXTS):
warnings.append(f"Metadata is not supported for {ext or 'this file type'}, so it was skipped.")
if plan is not None:
warnings.extend(plan.warnings)
reporter.set_stage("⚙️ Applying metadata & tracks", 0)
out = work / f"out{ext}"
await media.apply_plan(src, out, plan, job.cancel)
src.unlink(missing_ok=True)
current = out
info = await media.probe(current, job.cancel)
final_size = current.stat().st_size
# ---- how it will be sent -------------------------------------------------------
if send_type == "video" and (info is None or not info.of("video")):
send_type = "document"
if send_type == "audio" and (info is None or not info.of("audio")):
send_type = "document"
meta = s["meta"] if s["metadata_on"] else {}
attrs = [types.DocumentAttributeFilename(job.new_name)]
if send_type == "video":
attrs.append(
types.DocumentAttributeVideo(
duration=info.duration or 0, w=info.width or 1, h=info.height or 1, supports_streaming=True
)
)
elif send_type == "audio":
attrs.append(
types.DocumentAttributeAudio(
duration=int(info.duration or 0),
title=(meta.get("title") or info.tags.get("title") or None),
performer=(meta.get("artist") or meta.get("author") or info.tags.get("artist") or None),
)
)
thumb = await self._custom_thumb(job)
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
thumb_path = work / "thumb.jpg"
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
thumb = thumb_path.read_bytes()
# ---- caption ---------------------------------------------------------------------
caption = make_caption(
s["caption"], new_name=job.new_name, orig_name=orig_name, ext=ext, size=final_size,
duration=(info.duration if info else 0) or 0,
)
# ---- upload ---------------------------------------------------------------------------
reporter.set_stage("⬆️ Uploading", final_size)
began = time.monotonic()
input_file = await transfer.upload(
client, current, job.new_name, fast=cfg.fast_transfer, connections=cfg.connections,
progress=reporter, cancel=job.cancel,
)
up_seconds = time.monotonic() - began
if job.cancel.is_set():
raise Cancelled()
rates = "".join( # how fast each way went; a transfer under a second says nothing
f" · {arrow} {human_size(amount / seconds)}/s"
for arrow, amount, seconds in (("⬇", size, down_seconds), ("⬆", final_size, up_seconds))
if seconds >= 1 and amount > 0
)
await self._deliver(
job, s, msg, input_file, caption=caption, attrs=attrs, as_document=(send_type == "document"), thumb=thumb,
final_size=final_size, warnings=warnings, rates=rates, started=started, reporter=reporter,
)
|