push
This commit is contained in:
commit
fee546d5e1
|
@ -0,0 +1,7 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""Top-level package for telegram-upload."""
|
||||||
|
|
||||||
|
__author__ = """Nekmo"""
|
||||||
|
__email__ = 'contacto@nekmo.com'
|
||||||
|
__version__ = '0.7.1'
|
|
@ -0,0 +1,67 @@
|
||||||
|
import collections
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
try:
|
||||||
|
from os import scandir
|
||||||
|
except ImportError:
|
||||||
|
from scandir import scandir
|
||||||
|
|
||||||
|
|
||||||
|
# https://pypi.org/project/asyncio_utils/
|
||||||
|
|
||||||
|
async def anext(iterator: typing.AsyncIterator[typing.Any], *args, **kwargs
|
||||||
|
) -> typing.Any:
|
||||||
|
"""Mimics the builtin ``next`` for an ``AsyncIterator``.
|
||||||
|
|
||||||
|
:param iterator: An ``AsyncIterator`` to get the next value from.
|
||||||
|
:param default: Can be supplied as second arg or as a kwarg. If a value is
|
||||||
|
supplied in either of those positions then a
|
||||||
|
``StopAsyncIteration`` will not be raised and the
|
||||||
|
``default`` will be returned.
|
||||||
|
|
||||||
|
:raises TypeError: If the input is not a :class:`collections.AsyncIterator`
|
||||||
|
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
>>> async def main():
|
||||||
|
myrange = await arange(1, 5)
|
||||||
|
for n in range(1, 5):
|
||||||
|
print(n, n == await anext(myrange))
|
||||||
|
try:
|
||||||
|
n = await anext(myrange)
|
||||||
|
print("This should not be shown")
|
||||||
|
except StopAsyncIteration:
|
||||||
|
print('Sorry no more values!')
|
||||||
|
|
||||||
|
>>> loop.run_until_complete(main())
|
||||||
|
1 True
|
||||||
|
2 True
|
||||||
|
3 True
|
||||||
|
4 True
|
||||||
|
Sorry no more values!
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not isinstance(iterator, collections.AsyncIterator):
|
||||||
|
raise TypeError(f'Not an AsyncIterator: {iterator}')
|
||||||
|
|
||||||
|
use_default = False
|
||||||
|
default = None
|
||||||
|
|
||||||
|
if len(args) > 0:
|
||||||
|
default = args[0]
|
||||||
|
use_default = True
|
||||||
|
else:
|
||||||
|
if 'default' in kwargs:
|
||||||
|
default = kwargs['default']
|
||||||
|
use_default = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await iterator.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
if use_default:
|
||||||
|
return default
|
||||||
|
raise StopAsyncIteration
|
|
@ -0,0 +1,358 @@
|
||||||
|
import _string
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path, PosixPath, WindowsPath
|
||||||
|
from string import Formatter
|
||||||
|
from typing import Any, Sequence, Mapping, Tuple, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from telegram_upload.video import video_metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing import LiteralString
|
||||||
|
except ImportError:
|
||||||
|
LiteralString = str
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
cached_property = property
|
||||||
|
else:
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_SIZE = 4096
|
||||||
|
VALID_TYPES: Tuple[Any, ...] = (str, int, float, complex, bool, datetime.datetime, datetime.date, datetime.time)
|
||||||
|
AUTHORIZED_METHODS = (Path.home,)
|
||||||
|
AUTHORIZED_STRING_METHODS = ("title", "capitalize", "lower", "upper", "swapcase", "strip", "lstrip", "rstrip")
|
||||||
|
AUTHORIZED_DT_METHODS = (
|
||||||
|
"astimezone", "ctime", "date", "dst", "isoformat", "isoweekday", "now", "time",
|
||||||
|
"timestamp", "today", "toordinal", "tzname", "utcnow", "utcoffset", "weekday"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Duration:
|
||||||
|
def __init__(self, seconds: int):
|
||||||
|
self.seconds = seconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_minutes(self) -> int:
|
||||||
|
return self.seconds // 60
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_hours(self) -> int:
|
||||||
|
return self.as_minutes // 60
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_days(self) -> int:
|
||||||
|
return self.as_hours // 24
|
||||||
|
|
||||||
|
@property
|
||||||
|
def for_humans(self) -> str:
|
||||||
|
words = ["year", "day", "hour", "minute", "second"]
|
||||||
|
|
||||||
|
if not self.seconds:
|
||||||
|
return "now"
|
||||||
|
else:
|
||||||
|
m, s = divmod(self.seconds, 60)
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
d, h = divmod(h, 24)
|
||||||
|
y, d = divmod(d, 365)
|
||||||
|
|
||||||
|
time = [y, d, h, m, s]
|
||||||
|
|
||||||
|
duration = []
|
||||||
|
|
||||||
|
for x, i in enumerate(time):
|
||||||
|
if i == 1:
|
||||||
|
duration.append(f"{i} {words[x]}")
|
||||||
|
elif i > 1:
|
||||||
|
duration.append(f"{i} {words[x]}s")
|
||||||
|
|
||||||
|
if len(duration) == 1:
|
||||||
|
return duration[0]
|
||||||
|
elif len(duration) == 2:
|
||||||
|
return f"{duration[0]} and {duration[1]}"
|
||||||
|
else:
|
||||||
|
return ", ".join(duration[:-1]) + " and " + duration[-1]
|
||||||
|
|
||||||
|
def __int__(self) -> int:
|
||||||
|
return self.seconds
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class FileSize:
|
||||||
|
def __init__(self, size: int):
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_kilobytes(self) -> int:
|
||||||
|
return self.size // 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_megabytes(self) -> int:
|
||||||
|
return self.as_kilobytes // 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_gigabytes(self) -> int:
|
||||||
|
return self.as_megabytes // 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_kibibytes(self) -> int:
|
||||||
|
return self.size // 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_mebibytes(self) -> int:
|
||||||
|
return self.as_kibibytes // 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_gibibytes(self) -> int:
|
||||||
|
return self.as_mebibytes // 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def for_humans(self, suffix="B") -> str:
|
||||||
|
num = self.size
|
||||||
|
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
|
||||||
|
if abs(num) < 1024.0:
|
||||||
|
return f"{num:3.1f} {unit}{suffix}"
|
||||||
|
num /= 1024.0
|
||||||
|
return f"{num:.1f} Yi{suffix}"
|
||||||
|
|
||||||
|
def __int__(self) -> int:
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.size)
|
||||||
|
|
||||||
|
|
||||||
|
class FileMedia:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.metadata = video_metadata(path)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def video_metadata(self) -> Any:
|
||||||
|
metadata = self.metadata
|
||||||
|
meta_groups = None
|
||||||
|
if hasattr(metadata, '_MultipleMetadata__groups'):
|
||||||
|
# Is mkv
|
||||||
|
meta_groups = metadata._MultipleMetadata__groups
|
||||||
|
if metadata is not None and not metadata.has('width') and meta_groups:
|
||||||
|
return meta_groups[next(filter(lambda x: x.startswith('video'), meta_groups._key_list))]
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> Optional[Duration]:
|
||||||
|
if self.metadata and self.metadata.has('duration'):
|
||||||
|
return Duration(self.metadata.get('duration').seconds)
|
||||||
|
|
||||||
|
def _get_video_metadata(self, key: str) -> Optional[Any]:
|
||||||
|
if self.video_metadata and self.video_metadata.has(key):
|
||||||
|
return self.video_metadata.get(key)
|
||||||
|
|
||||||
|
def _get_metadata(self, key: str) -> Optional[Any]:
|
||||||
|
if self.metadata and self.metadata.has(key):
|
||||||
|
return self.metadata.get(key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> Optional[int]:
|
||||||
|
return self._get_video_metadata('width')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> Optional[int]:
|
||||||
|
return self._get_video_metadata('height')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> Optional[str]:
|
||||||
|
return self._get_metadata('title')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def artist(self) -> Optional[str]:
|
||||||
|
return self._get_metadata('artist')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def album(self) -> Optional[str]:
|
||||||
|
return self._get_metadata('album')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def producer(self) -> Optional[str]:
|
||||||
|
return self._get_metadata('producer')
|
||||||
|
|
||||||
|
|
||||||
|
class FileMixin:
|
||||||
|
|
||||||
|
def _calculate_hash(self, hash_calculator: Any) -> str:
|
||||||
|
with open(str(self), "rb") as f:
|
||||||
|
# Read and update hash string value in blocks
|
||||||
|
for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""):
|
||||||
|
hash_calculator.update(byte_block)
|
||||||
|
return hash_calculator.hexdigest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def md5(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.md5())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha1(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha1())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha224(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha224())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha256(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha256())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha384(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha384())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha512(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha512())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha3_224(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha3_224())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha3_256(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha3_256())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha3_384(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha3_384())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha3_512(self) -> str:
|
||||||
|
return self._calculate_hash(hashlib.sha3_512())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crc32(self) -> str:
|
||||||
|
with open(str(self), "rb") as f:
|
||||||
|
calculated_hash = 0
|
||||||
|
# Read and update hash string value in blocks
|
||||||
|
for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""):
|
||||||
|
calculated_hash = zlib.crc32(byte_block, calculated_hash)
|
||||||
|
return "%08X" % (calculated_hash & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adler32(self) -> str:
|
||||||
|
with open(str(self), "rb") as f:
|
||||||
|
calculated_hash = 1
|
||||||
|
# Read and update hash string value in blocks
|
||||||
|
for byte_block in iter(lambda: f.read(CHUNK_SIZE), b""):
|
||||||
|
calculated_hash = zlib.adler32(byte_block, calculated_hash)
|
||||||
|
if calculated_hash < 0:
|
||||||
|
calculated_hash += 2 ** 32
|
||||||
|
return hex(calculated_hash)[2:10].zfill(8)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def _file_stat(self) -> os.stat_result:
|
||||||
|
return os.stat(str(self))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def ctime(self) -> datetime.datetime:
|
||||||
|
return datetime.datetime.fromtimestamp(self._file_stat.st_ctime)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def mtime(self) -> datetime.datetime:
|
||||||
|
return datetime.datetime.fromtimestamp(self._file_stat.st_mtime)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def atime(self) -> datetime.datetime:
|
||||||
|
return datetime.datetime.fromtimestamp(self._file_stat.st_atime)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def size(self) -> FileSize:
|
||||||
|
return FileSize(self._file_stat.st_size)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def media(self) -> FileMedia:
|
||||||
|
return FileMedia(str(self))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def mimetype(self) -> Optional[str]:
|
||||||
|
mimetypes.init()
|
||||||
|
return mimetypes.guess_type(str(self))[0]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def suffixes(self) -> str:
|
||||||
|
return "".join(super().suffixes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def absolute(self) -> "FilePath":
|
||||||
|
return super().absolute()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def relative(self) -> "FilePath":
|
||||||
|
return self.relative_to(Path.cwd())
|
||||||
|
|
||||||
|
|
||||||
|
class FilePath(FileMixin, Path):
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
if cls is FilePath:
|
||||||
|
cls = WindowsFilePath if os.name == 'nt' else PosixFilePath
|
||||||
|
self = cls._from_parts(args)
|
||||||
|
if not self._flavour.is_supported:
|
||||||
|
raise NotImplementedError("cannot instantiate %r on your system"
|
||||||
|
% (cls.__name__,))
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsFilePath(FileMixin, WindowsPath):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PosixFilePath(FileMixin, PosixPath):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CaptionFormatter(Formatter):
|
||||||
|
|
||||||
|
def get_field(self, field_name: str, args: Sequence[Any], kwargs: Mapping[str, Any]) -> Any:
|
||||||
|
try:
|
||||||
|
if "._" in field_name:
|
||||||
|
raise TypeError(f'Access to private property in {field_name}')
|
||||||
|
obj, first = super().get_field(field_name, args, kwargs)
|
||||||
|
has_func = hasattr(obj, "__func__")
|
||||||
|
has_self = hasattr(obj, "__self__")
|
||||||
|
if (has_func and obj.__func__ in AUTHORIZED_METHODS) or \
|
||||||
|
(has_self and isinstance(obj.__self__, str) and obj.__name__ in AUTHORIZED_STRING_METHODS) or \
|
||||||
|
(has_self and isinstance(obj.__self__, datetime.datetime)
|
||||||
|
and obj.__name__ in AUTHORIZED_DT_METHODS):
|
||||||
|
obj = obj()
|
||||||
|
if not isinstance(obj, VALID_TYPES + (WindowsFilePath, PosixFilePath, FilePath, FileSize, Duration)):
|
||||||
|
raise TypeError(f'Invalid type for {field_name}: {type(obj)}')
|
||||||
|
return obj, first
|
||||||
|
except Exception:
|
||||||
|
first, rest = _string.formatter_field_name_split(field_name)
|
||||||
|
return '{' + field_name + '}', first
|
||||||
|
|
||||||
|
def format(self, __format_string: LiteralString, *args: LiteralString, **kwargs: LiteralString) -> LiteralString:
|
||||||
|
try:
|
||||||
|
return super().format(__format_string, *args, **kwargs)
|
||||||
|
except ValueError:
|
||||||
|
return __format_string
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument('file', type=click.Path(exists=True))
|
||||||
|
@click.argument('caption_format', type=str)
|
||||||
|
def test_caption_format(file: str, caption_format: str) -> None:
|
||||||
|
"""Test the caption format on a given file"""
|
||||||
|
file_path = FilePath(file)
|
||||||
|
formatter = CaptionFormatter()
|
||||||
|
print(formatter.format(caption_format, file=file_path, now=datetime.datetime.now()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Testing mode
|
||||||
|
test_caption_format()
|
|
@ -0,0 +1,147 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import Sequence, Tuple, List, TypeVar
|
||||||
|
|
||||||
|
import click
|
||||||
|
from prompt_toolkit.filters import Condition
|
||||||
|
from prompt_toolkit.formatted_text import AnyFormattedText
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.layout import FormattedTextControl, Window, ConditionalMargin, ScrollbarMargin
|
||||||
|
from prompt_toolkit.widgets import CheckboxList, RadioList
|
||||||
|
from prompt_toolkit.widgets.base import E, _DialogList
|
||||||
|
|
||||||
|
from telegram_upload.utils import aislice
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def async_handler(handler, event):
|
||||||
|
if handler:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
# Tell the application to redraw. We need to do this,
|
||||||
|
# because the below event handler won't be able to
|
||||||
|
# wait for the task to finish.
|
||||||
|
event.app.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
class IterableDialogList(_DialogList):
|
||||||
|
many = False
|
||||||
|
|
||||||
|
def __init__(self, values: Sequence[Tuple[_T, AnyFormattedText]]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _init(self, values: Sequence[Tuple[_T, AnyFormattedText]]) -> None:
|
||||||
|
started_values = await aislice(values, PAGE_SIZE)
|
||||||
|
|
||||||
|
# started_values = await aislice(values, PAGE_SIZE)
|
||||||
|
if not started_values:
|
||||||
|
raise IndexError('Values is empty.')
|
||||||
|
self.values = started_values
|
||||||
|
# current_values will be used in multiple_selection,
|
||||||
|
# current_value will be used otherwise.
|
||||||
|
self.current_values: List[_T] = []
|
||||||
|
self.current_value: _T = started_values[0][0]
|
||||||
|
self._selected_index = 0
|
||||||
|
|
||||||
|
# Key bindings.
|
||||||
|
kb = KeyBindings()
|
||||||
|
|
||||||
|
@kb.add("up")
|
||||||
|
def _up(event: E) -> None:
|
||||||
|
self._selected_index = max(0, self._selected_index - 1)
|
||||||
|
|
||||||
|
@kb.add("down")
|
||||||
|
def _down(event: E) -> None:
|
||||||
|
async def handler(event):
|
||||||
|
if self._selected_index + 1 >= len(self.values):
|
||||||
|
self.values.extend(await aislice(values, PAGE_SIZE))
|
||||||
|
self._selected_index = min(len(self.values) - 1, self._selected_index + 1)
|
||||||
|
asyncio.get_event_loop().create_task(async_handler(handler, event))
|
||||||
|
|
||||||
|
@kb.add("pageup")
|
||||||
|
def _pageup(event: E) -> None:
|
||||||
|
w = event.app.layout.current_window
|
||||||
|
if w.render_info:
|
||||||
|
self._selected_index = max(
|
||||||
|
0, self._selected_index - len(w.render_info.displayed_lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
@kb.add("pagedown")
|
||||||
|
def _pagedown(event: E) -> None:
|
||||||
|
async def handler(event):
|
||||||
|
w = event.app.layout.current_window
|
||||||
|
if self._selected_index + len(w.render_info.displayed_lines) >= len(self.values):
|
||||||
|
self.values.extend(await aislice(values, PAGE_SIZE))
|
||||||
|
if w.render_info:
|
||||||
|
self._selected_index = min(
|
||||||
|
len(self.values) - 1,
|
||||||
|
self._selected_index + len(w.render_info.displayed_lines),
|
||||||
|
)
|
||||||
|
asyncio.get_event_loop().create_task(async_handler(handler, event))
|
||||||
|
|
||||||
|
@kb.add("enter")
|
||||||
|
def _enter(event: E) -> None:
|
||||||
|
if self.many:
|
||||||
|
event.app.exit(result=self.current_values)
|
||||||
|
else:
|
||||||
|
event.app.exit(result=self.current_value)
|
||||||
|
|
||||||
|
@kb.add(" ")
|
||||||
|
def _enter(event: E) -> None:
|
||||||
|
self._handle_enter()
|
||||||
|
|
||||||
|
# Control and window.
|
||||||
|
self.control = FormattedTextControl(
|
||||||
|
self._get_text_fragments, key_bindings=kb, focusable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.window = Window(
|
||||||
|
content=self.control,
|
||||||
|
style=self.container_style,
|
||||||
|
right_margins=[
|
||||||
|
ConditionalMargin(
|
||||||
|
margin=ScrollbarMargin(display_arrows=True),
|
||||||
|
filter=Condition(lambda: self.show_scrollbar),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dont_extend_height=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IterableCheckboxList(IterableDialogList, CheckboxList):
|
||||||
|
many = True
|
||||||
|
|
||||||
|
|
||||||
|
class IterableRadioList(IterableDialogList, RadioList):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def show_cli_widget(widget):
|
||||||
|
from prompt_toolkit import Application
|
||||||
|
from prompt_toolkit.layout import Layout
|
||||||
|
app = Application(full_screen=False, layout=Layout(widget), mouse_support=True)
|
||||||
|
return await app.run_async()
|
||||||
|
|
||||||
|
|
||||||
|
async def show_checkboxlist(iterator, not_items_error='No items were found. Exiting...'):
|
||||||
|
# iterator = map(lambda x: (x, f'{x.text} by {x.chat.first_name}'), iterator)
|
||||||
|
try:
|
||||||
|
checkbox_list = IterableCheckboxList(iterator)
|
||||||
|
await checkbox_list._init(iterator)
|
||||||
|
except IndexError:
|
||||||
|
click.echo(not_items_error, err=True)
|
||||||
|
return []
|
||||||
|
return await show_cli_widget(checkbox_list)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_radiolist(iterator, not_items_error='No items were found. Exiting...'):
|
||||||
|
try:
|
||||||
|
radio_list = IterableRadioList(iterator)
|
||||||
|
await radio_list._init(iterator)
|
||||||
|
except IndexError:
|
||||||
|
click.echo(not_items_error, err=True)
|
||||||
|
return None
|
||||||
|
return await show_cli_widget(radio_list)
|
|
@ -0,0 +1,4 @@
|
||||||
|
from telegram_upload.client.telegram_manager_client import TelegramManagerClient, get_message_file_attribute
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TelegramManagerClient", "get_message_file_attribute"]
|
|
@ -0,0 +1,16 @@
|
||||||
|
from ctypes import c_int64
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_bar(action, file, length):
|
||||||
|
bar = click.progressbar(label='{} "{}"'.format(action, file), length=length)
|
||||||
|
last_current = c_int64(0)
|
||||||
|
|
||||||
|
def progress(current, total):
|
||||||
|
if current < last_current.value:
|
||||||
|
return
|
||||||
|
bar.pos = 0
|
||||||
|
bar.update(current)
|
||||||
|
last_current.value = current
|
||||||
|
return progress, bar
|
|
@ -0,0 +1,133 @@
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from more_itertools import grouper
|
||||||
|
from telethon import TelegramClient, utils, helpers
|
||||||
|
from telethon.client.downloads import MIN_CHUNK_SIZE
|
||||||
|
from telethon.crypto import AES
|
||||||
|
|
||||||
|
from telegram_upload.client.progress_bar import get_progress_bar
|
||||||
|
from telegram_upload.download_files import DownloadFile
|
||||||
|
from telegram_upload.exceptions import TelegramUploadNoSpaceError
|
||||||
|
from telegram_upload.utils import free_disk_usage, sizeof_fmt, get_environment_integer
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 10):
|
||||||
|
from telegram_upload._compat import anext
|
||||||
|
|
||||||
|
|
||||||
|
PARALLEL_DOWNLOAD_BLOCKS = get_environment_integer('TELEGRAM_UPLOAD_PARALLEL_DOWNLOAD_BLOCKS', 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramDownloadClient(TelegramClient):
|
||||||
|
def find_files(self, entity):
|
||||||
|
for message in self.iter_messages(entity):
|
||||||
|
if message.document:
|
||||||
|
yield message
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def iter_files(self, entity):
|
||||||
|
async for message in self.iter_messages(entity=entity):
|
||||||
|
if message.document:
|
||||||
|
yield message
|
||||||
|
|
||||||
|
def download_files(self, entity, download_files: Iterable[DownloadFile], delete_on_success: bool = False):
|
||||||
|
for download_file in download_files:
|
||||||
|
if download_file.size > free_disk_usage():
|
||||||
|
raise TelegramUploadNoSpaceError(
|
||||||
|
'There is no disk space to download "{}". Space required: {}'.format(
|
||||||
|
download_file.file_name, sizeof_fmt(download_file.size - free_disk_usage())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
progress, bar = get_progress_bar('Downloading', download_file.file_name, download_file.size)
|
||||||
|
file_name = download_file.file_name
|
||||||
|
try:
|
||||||
|
file_name = self.download_media(download_file.message, progress_callback=progress)
|
||||||
|
download_file.set_download_file_name(file_name)
|
||||||
|
finally:
|
||||||
|
bar.label = f'Downloaded "{file_name}"'
|
||||||
|
bar.update(1, 1)
|
||||||
|
bar.render_finish()
|
||||||
|
if delete_on_success:
|
||||||
|
self.delete_messages(entity, [download_file.message])
|
||||||
|
|
||||||
|
async def _download_file(
|
||||||
|
self: 'TelegramClient',
|
||||||
|
input_location: 'hints.FileLike',
|
||||||
|
file: 'hints.OutFileLike' = None,
|
||||||
|
*,
|
||||||
|
part_size_kb: float = None,
|
||||||
|
file_size: int = None,
|
||||||
|
progress_callback: 'hints.ProgressCallback' = None,
|
||||||
|
dc_id: int = None,
|
||||||
|
key: bytes = None,
|
||||||
|
iv: bytes = None,
|
||||||
|
msg_data: tuple = None) -> typing.Optional[bytes]:
|
||||||
|
if not part_size_kb:
|
||||||
|
if not file_size:
|
||||||
|
part_size_kb = 64 # Reasonable default
|
||||||
|
else:
|
||||||
|
part_size_kb = utils.get_appropriated_part_size(file_size)
|
||||||
|
|
||||||
|
part_size = int(part_size_kb * 1024)
|
||||||
|
if part_size % MIN_CHUNK_SIZE != 0:
|
||||||
|
raise ValueError(
|
||||||
|
'The part size must be evenly divisible by 4096.')
|
||||||
|
|
||||||
|
if isinstance(file, pathlib.Path):
|
||||||
|
file = str(file.absolute())
|
||||||
|
|
||||||
|
in_memory = file is None or file is bytes
|
||||||
|
if in_memory:
|
||||||
|
f = io.BytesIO()
|
||||||
|
elif isinstance(file, str):
|
||||||
|
# Ensure that we'll be able to download the media
|
||||||
|
helpers.ensure_parent_dir_exists(file)
|
||||||
|
f = open(file, 'wb')
|
||||||
|
else:
|
||||||
|
f = file
|
||||||
|
|
||||||
|
try:
|
||||||
|
# The speed of this code can be improved. 10 requests are made in parallel, but it waits for all 10 to
|
||||||
|
# finish before launching another 10.
|
||||||
|
for tasks in grouper(self._iter_download_chunk_tasks(input_location, part_size, dc_id, msg_data, file_size),
|
||||||
|
PARALLEL_DOWNLOAD_BLOCKS):
|
||||||
|
tasks = list(filter(bool, tasks))
|
||||||
|
await asyncio.wait(tasks)
|
||||||
|
chunk = b''.join(filter(bool, [task.result() for task in tasks]))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if iv and key:
|
||||||
|
chunk = AES.decrypt_ige(chunk, key, iv)
|
||||||
|
r = f.write(chunk)
|
||||||
|
if inspect.isawaitable(r):
|
||||||
|
await r
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
r = progress_callback(f.tell(), file_size)
|
||||||
|
if inspect.isawaitable(r):
|
||||||
|
await r
|
||||||
|
|
||||||
|
# Not all IO objects have flush (see #1227)
|
||||||
|
if callable(getattr(f, 'flush', None)):
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
if in_memory:
|
||||||
|
return f.getvalue()
|
||||||
|
finally:
|
||||||
|
if isinstance(file, str) or in_memory:
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def _iter_download_chunk_tasks(self, input_location, part_size, dc_id, msg_data, file_size):
|
||||||
|
for i in range(0, file_size, part_size):
|
||||||
|
yield self.loop.create_task(
|
||||||
|
anext(self._iter_download(input_location, offset=i, request_size=part_size, dc_id=dc_id,
|
||||||
|
msg_data=msg_data))
|
||||||
|
)
|
|
@ -0,0 +1,129 @@
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
from typing import Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import click
|
||||||
|
from telethon.errors import ApiIdInvalidError
|
||||||
|
from telethon.network import ConnectionTcpMTProxyRandomizedIntermediate
|
||||||
|
from telethon.tl.types import DocumentAttributeFilename, User, InputPeerUser
|
||||||
|
from telethon.version import __version__ as telethon_version
|
||||||
|
|
||||||
|
from telegram_upload.client.telegram_download_client import TelegramDownloadClient
|
||||||
|
from telegram_upload.client.telegram_upload_client import TelegramUploadClient
|
||||||
|
from telegram_upload.config import SESSION_FILE
|
||||||
|
from telegram_upload.exceptions import TelegramProxyError, InvalidApiFileError
|
||||||
|
|
||||||
|
if StrictVersion(telethon_version) >= StrictVersion('1.0'):
|
||||||
|
import telethon.sync # noqa
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
cached_property = property
|
||||||
|
else:
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
|
|
||||||
|
BOT_USER_MAX_FILE_SIZE = 52428800 # 50MB
|
||||||
|
USER_MAX_FILE_SIZE = 2097152000 # 2GB
|
||||||
|
PREMIUM_USER_MAX_FILE_SIZE = 4194304000 # 4GB
|
||||||
|
USER_MAX_CAPTION_LENGTH = 1024
|
||||||
|
PREMIUM_USER_MAX_CAPTION_LENGTH = 2048
|
||||||
|
PROXY_ENVIRONMENT_VARIABLE_NAMES = [
|
||||||
|
'TELEGRAM_UPLOAD_PROXY',
|
||||||
|
'HTTPS_PROXY',
|
||||||
|
'HTTP_PROXY',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_file_attribute(message):
|
||||||
|
return next(filter(lambda x: isinstance(x, DocumentAttributeFilename),
|
||||||
|
message.document.attributes), None)
|
||||||
|
|
||||||
|
|
||||||
|
def phone_match(value):
|
||||||
|
match = re.match(r'\+?[0-9.()\[\] \-]+', value)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError('{} is not a valid phone'.format(value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxy_environment_variable():
|
||||||
|
for env_name in PROXY_ENVIRONMENT_VARIABLE_NAMES:
|
||||||
|
if env_name in os.environ:
|
||||||
|
return os.environ[env_name]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_proxy_string(proxy: Union[str, None]):
|
||||||
|
if not proxy:
|
||||||
|
return None
|
||||||
|
proxy_parsed = urlparse(proxy)
|
||||||
|
if not proxy_parsed.scheme or not proxy_parsed.hostname or not proxy_parsed.port:
|
||||||
|
raise TelegramProxyError('Malformed proxy address: {}'.format(proxy))
|
||||||
|
if proxy_parsed.scheme == 'mtproxy':
|
||||||
|
return ('mtproxy', proxy_parsed.hostname, proxy_parsed.port, proxy_parsed.username)
|
||||||
|
try:
|
||||||
|
import socks
|
||||||
|
except ImportError:
|
||||||
|
raise TelegramProxyError('pysocks module is required for use HTTP/socks proxies. '
|
||||||
|
'Install it using: pip install pysocks')
|
||||||
|
proxy_type = {
|
||||||
|
'http': socks.HTTP,
|
||||||
|
'socks4': socks.SOCKS4,
|
||||||
|
'socks5': socks.SOCKS5,
|
||||||
|
}.get(proxy_parsed.scheme)
|
||||||
|
if proxy_type is None:
|
||||||
|
raise TelegramProxyError('Unsupported proxy type: {}'.format(proxy_parsed.scheme))
|
||||||
|
return (proxy_type, proxy_parsed.hostname, proxy_parsed.port, True,
|
||||||
|
proxy_parsed.username, proxy_parsed.password)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramManagerClient(TelegramUploadClient, TelegramDownloadClient):
|
||||||
|
def __init__(self, config_file, proxy=None, **kwargs):
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
self.config_file = config_file
|
||||||
|
proxy = proxy if proxy is not None else get_proxy_environment_variable()
|
||||||
|
proxy = parse_proxy_string(proxy)
|
||||||
|
if proxy and proxy[0] == 'mtproxy':
|
||||||
|
proxy = proxy[1:]
|
||||||
|
kwargs['connection'] = ConnectionTcpMTProxyRandomizedIntermediate
|
||||||
|
super().__init__(config.get('session', SESSION_FILE), config['api_id'], config['api_hash'],
|
||||||
|
proxy=proxy, **kwargs)
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
phone=lambda: click.prompt('Please enter your phone', type=phone_match),
|
||||||
|
password=lambda: getpass.getpass('Please enter your password: '),
|
||||||
|
*,
|
||||||
|
bot_token=None, force_sms=False, code_callback=None,
|
||||||
|
first_name='New User', last_name='', max_attempts=3):
|
||||||
|
try:
|
||||||
|
return super().start(phone=phone, password=password, bot_token=bot_token, force_sms=force_sms,
|
||||||
|
first_name=first_name, last_name=last_name, max_attempts=max_attempts)
|
||||||
|
except ApiIdInvalidError:
|
||||||
|
raise InvalidApiFileError(self.config_file)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def me(self) -> Union[User, InputPeerUser]:
|
||||||
|
return self.get_me()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_file_size(self):
|
||||||
|
if hasattr(self.me, 'premium') and self.me.premium:
|
||||||
|
return PREMIUM_USER_MAX_FILE_SIZE
|
||||||
|
elif self.me.bot:
|
||||||
|
return BOT_USER_MAX_FILE_SIZE
|
||||||
|
else:
|
||||||
|
return USER_MAX_FILE_SIZE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_caption_length(self):
|
||||||
|
if hasattr(self.me, 'premium') and self.me.premium:
|
||||||
|
return PREMIUM_USER_MAX_CAPTION_LENGTH
|
||||||
|
else:
|
||||||
|
return USER_MAX_CAPTION_LENGTH
|
|
@ -0,0 +1,406 @@
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from telethon import TelegramClient, utils, helpers, custom
|
||||||
|
from telethon.crypto import AES
|
||||||
|
from telethon.errors import RPCError, FloodWaitError, InvalidBufferError, FloodError
|
||||||
|
from telethon.tl import types, functions, TLRequest
|
||||||
|
from telethon.utils import pack_bot_file_id
|
||||||
|
|
||||||
|
from telegram_upload.client.progress_bar import get_progress_bar
|
||||||
|
from telegram_upload.exceptions import TelegramUploadDataLoss, MissingFileError
|
||||||
|
from telegram_upload.upload_files import File
|
||||||
|
from telegram_upload.utils import grouper, async_to_sync, get_environment_integer
|
||||||
|
|
||||||
|
PARALLEL_UPLOAD_BLOCKS = get_environment_integer('TELEGRAM_UPLOAD_PARALLEL_UPLOAD_BLOCKS', 8)
|
||||||
|
ALBUM_FILES = 10
|
||||||
|
RETRIES = 10
|
||||||
|
MAX_RECONNECT_RETRIES = get_environment_integer('TELEGRAM_UPLOAD_MAX_RECONNECT_RETRIES', 10)
|
||||||
|
RECONNECT_TIMEOUT = get_environment_integer('TELEGRAM_UPLOAD_RECONNECT_TIMEOUT', 10)
|
||||||
|
MIN_RECONNECT_WAIT = get_environment_integer('TELEGRAM_UPLOAD_MIN_RECONNECT_WAIT', 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUploadClient(TelegramClient):
|
||||||
|
parallel_upload_blocks = PARALLEL_UPLOAD_BLOCKS
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.reconnecting_lock = asyncio.Lock()
|
||||||
|
self.upload_semaphore = asyncio.Semaphore(self.parallel_upload_blocks)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def forward_to(self, message, destinations):
|
||||||
|
for destination in destinations:
|
||||||
|
self.forward_messages(destination, [message])
|
||||||
|
|
||||||
|
async def _send_album_media(self, entity, media):
|
||||||
|
entity = await self.get_input_entity(entity)
|
||||||
|
request = functions.messages.SendMultiMediaRequest(
|
||||||
|
entity, multi_media=media, silent=None, schedule_date=None, clear_draft=None
|
||||||
|
)
|
||||||
|
result = await self(request)
|
||||||
|
|
||||||
|
random_ids = [m.random_id for m in media]
|
||||||
|
return self._get_response_message(random_ids, result, entity)
|
||||||
|
|
||||||
|
def send_files_as_album(self, entity, files, delete_on_success=False, print_file_id=False,
|
||||||
|
forward=()):
|
||||||
|
for files_group in grouper(ALBUM_FILES, files):
|
||||||
|
media = self.send_files(entity, files_group, delete_on_success, print_file_id, forward, send_as_media=True)
|
||||||
|
async_to_sync(self._send_album_media(entity, media))
|
||||||
|
|
||||||
|
def _send_file_message(self, entity, file, thumb, progress):
|
||||||
|
message = self.send_file(entity, file, thumb=thumb,
|
||||||
|
file_size=file.file_size if isinstance(file, File) else None,
|
||||||
|
caption=file.file_caption, force_document=file.force_file,
|
||||||
|
progress_callback=progress, attributes=file.file_attributes)
|
||||||
|
if hasattr(message.media, 'document') and file.file_size != message.media.document.size:
|
||||||
|
raise TelegramUploadDataLoss(
|
||||||
|
'Remote document size: {} bytes (local file size: {} bytes)'.format(
|
||||||
|
message.media.document.size, file.file_size))
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def _send_media(self, entity, file: File, progress):
|
||||||
|
entity = await self.get_input_entity(entity)
|
||||||
|
supports_streaming = False # TODO
|
||||||
|
fh, fm, _ = await self._file_to_media(
|
||||||
|
file, supports_streaming=file, progress_callback=progress)
|
||||||
|
if isinstance(fm, types.InputMediaUploadedPhoto):
|
||||||
|
r = await self(functions.messages.UploadMediaRequest(
|
||||||
|
entity, media=fm
|
||||||
|
))
|
||||||
|
|
||||||
|
fm = utils.get_input_media(r.photo)
|
||||||
|
elif isinstance(fm, types.InputMediaUploadedDocument):
|
||||||
|
r = await self(functions.messages.UploadMediaRequest(
|
||||||
|
entity, media=fm
|
||||||
|
))
|
||||||
|
|
||||||
|
fm = utils.get_input_media(
|
||||||
|
r.document, supports_streaming=supports_streaming)
|
||||||
|
|
||||||
|
return types.InputSingleMedia(
|
||||||
|
fm,
|
||||||
|
message=file.short_name,
|
||||||
|
entities=None,
|
||||||
|
# random_id is autogenerated
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_one_file(self, entity, file: File, send_as_media: bool = False, thumb: Optional[str] = None,
|
||||||
|
retries=RETRIES):
|
||||||
|
message = None
|
||||||
|
progress, bar = get_progress_bar('Uploading', file.file_name, file.file_size)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
# TODO: remove distinction?
|
||||||
|
if send_as_media:
|
||||||
|
message = async_to_sync(self._send_media(entity, file, progress))
|
||||||
|
else:
|
||||||
|
message = self._send_file_message(entity, file, thumb, progress)
|
||||||
|
finally:
|
||||||
|
bar.render_finish()
|
||||||
|
except (FloodWaitError,FloodError) as e:
|
||||||
|
click.echo(f'{e}. Waiting for {e.seconds} seconds.', err=True)
|
||||||
|
time.sleep(e.seconds)
|
||||||
|
message = self.send_one_file(entity, file, send_as_media, thumb, retries)
|
||||||
|
except RPCError as e:
|
||||||
|
if retries > 0:
|
||||||
|
click.echo(f'The file "{file.file_name}" could not be uploaded: {e}. Retrying...', err=True)
|
||||||
|
message = self.send_one_file(entity, file, send_as_media, thumb, retries - 1)
|
||||||
|
else:
|
||||||
|
click.echo(f'The file "{file.file_name}" could not be uploaded: {e}. It will not be retried.', err=True)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def send_files(self, entity, files: Iterable[File], delete_on_success=False, print_file_id=False,
|
||||||
|
forward=(), send_as_media: bool = False):
|
||||||
|
has_files = False
|
||||||
|
messages = []
|
||||||
|
for file in files:
|
||||||
|
has_files = True
|
||||||
|
thumb = file.get_thumbnail()
|
||||||
|
try:
|
||||||
|
message = self.send_one_file(entity, file, send_as_media, thumb=thumb)
|
||||||
|
finally:
|
||||||
|
if thumb and not file.is_custom_thumbnail and os.path.lexists(thumb):
|
||||||
|
os.remove(thumb)
|
||||||
|
if message is None:
|
||||||
|
click.echo('Failed to upload file "{}"'.format(file.file_name), err=True)
|
||||||
|
if message and print_file_id:
|
||||||
|
click.echo('Uploaded successfully "{}" (file_id {})'.format(file.file_name,
|
||||||
|
pack_bot_file_id(message.media)))
|
||||||
|
if message and delete_on_success:
|
||||||
|
click.echo('Deleting "{}"'.format(file))
|
||||||
|
os.remove(file.path)
|
||||||
|
if message:
|
||||||
|
self.forward_to(message, forward)
|
||||||
|
messages.append(message)
|
||||||
|
if not has_files:
|
||||||
|
raise MissingFileError('Files do not exist.')
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self: 'TelegramClient',
|
||||||
|
file: 'hints.FileLike',
|
||||||
|
*,
|
||||||
|
part_size_kb: float = None,
|
||||||
|
file_size: int = None,
|
||||||
|
file_name: str = None,
|
||||||
|
use_cache: type = None,
|
||||||
|
key: bytes = None,
|
||||||
|
iv: bytes = None,
|
||||||
|
progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile':
|
||||||
|
"""
|
||||||
|
Uploads a file to Telegram's servers, without sending it.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Generally, you want to use `send_file` instead.
|
||||||
|
|
||||||
|
This method returns a handle (an instance of :tl:`InputFile` or
|
||||||
|
:tl:`InputFileBig`, as required) which can be later used before
|
||||||
|
it expires (they are usable during less than a day).
|
||||||
|
|
||||||
|
Uploading a file will simply return a "handle" to the file stored
|
||||||
|
remotely in the Telegram servers, which can be later used on. This
|
||||||
|
will **not** upload the file to your own chat or any chat at all.
|
||||||
|
|
||||||
|
Arguments
|
||||||
|
file (`str` | `bytes` | `file`):
|
||||||
|
The path of the file, byte array, or stream that will be sent.
|
||||||
|
Note that if a byte array or a stream is given, a filename
|
||||||
|
or its type won't be inferred, and it will be sent as an
|
||||||
|
"unnamed application/octet-stream".
|
||||||
|
|
||||||
|
part_size_kb (`int`, optional):
|
||||||
|
Chunk size when uploading files. The larger, the less
|
||||||
|
requests will be made (up to 512KB maximum).
|
||||||
|
|
||||||
|
file_size (`int`, optional):
|
||||||
|
The size of the file to be uploaded, which will be determined
|
||||||
|
automatically if not specified.
|
||||||
|
|
||||||
|
If the file size can't be determined beforehand, the entire
|
||||||
|
file will be read in-memory to find out how large it is.
|
||||||
|
|
||||||
|
file_name (`str`, optional):
|
||||||
|
The file name which will be used on the resulting InputFile.
|
||||||
|
If not specified, the name will be taken from the ``file``
|
||||||
|
and if this is not a `str`, it will be ``"unnamed"``.
|
||||||
|
|
||||||
|
use_cache (`type`, optional):
|
||||||
|
This parameter currently does nothing, but is kept for
|
||||||
|
backward-compatibility (and it may get its use back in
|
||||||
|
the future).
|
||||||
|
|
||||||
|
key ('bytes', optional):
|
||||||
|
In case of an encrypted upload (secret chats) a key is supplied
|
||||||
|
|
||||||
|
iv ('bytes', optional):
|
||||||
|
In case of an encrypted upload (secret chats) an iv is supplied
|
||||||
|
|
||||||
|
progress_callback (`callable`, optional):
|
||||||
|
A callback function accepting two parameters:
|
||||||
|
``(sent bytes, total)``.
|
||||||
|
|
||||||
|
When sending an album, the callback will receive a number
|
||||||
|
between 0 and the amount of files as the "sent" parameter,
|
||||||
|
and the amount of files as the "total". Note that the first
|
||||||
|
parameter will be a floating point number to indicate progress
|
||||||
|
within a file (e.g. ``2.5`` means it has sent 50% of the third
|
||||||
|
file, because it's between 2 and 3).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
:tl:`InputFileBig` if the file size is larger than 10MB,
|
||||||
|
`InputSizedFile <telethon.tl.custom.inputsizedfile.InputSizedFile>`
|
||||||
|
(subclass of :tl:`InputFile`) otherwise.
|
||||||
|
|
||||||
|
Example
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Photos as photo and document
|
||||||
|
file = await client.upload_file('photo.jpg')
|
||||||
|
await client.send_file(chat, file) # sends as photo
|
||||||
|
await client.send_file(chat, file, force_document=True) # sends as document
|
||||||
|
|
||||||
|
file.name = 'not a photo.jpg'
|
||||||
|
await client.send_file(chat, file, force_document=True) # document, new name
|
||||||
|
|
||||||
|
# As song or as voice note
|
||||||
|
file = await client.upload_file('song.ogg')
|
||||||
|
await client.send_file(chat, file) # sends as song
|
||||||
|
await client.send_file(chat, file, voice_note=True) # sends as voice note
|
||||||
|
"""
|
||||||
|
if isinstance(file, (types.InputFile, types.InputFileBig)):
|
||||||
|
return file # Already uploaded
|
||||||
|
|
||||||
|
async with helpers._FileStream(file, file_size=file_size) as stream:
|
||||||
|
# Opening the stream will determine the correct file size
|
||||||
|
file_size = stream.file_size
|
||||||
|
|
||||||
|
if not part_size_kb:
|
||||||
|
part_size_kb = utils.get_appropriated_part_size(file_size)
|
||||||
|
|
||||||
|
if part_size_kb > 512:
|
||||||
|
raise ValueError('The part size must be less or equal to 512KB')
|
||||||
|
|
||||||
|
part_size = int(part_size_kb * 1024)
|
||||||
|
if part_size % 1024 != 0:
|
||||||
|
raise ValueError(
|
||||||
|
'The part size must be evenly divisible by 1024')
|
||||||
|
|
||||||
|
# Set a default file name if None was specified
|
||||||
|
file_id = helpers.generate_random_long()
|
||||||
|
if not file_name:
|
||||||
|
file_name = stream.name or str(file_id)
|
||||||
|
|
||||||
|
# If the file name lacks extension, add it if possible.
|
||||||
|
# Else Telegram complains with `PHOTO_EXT_INVALID_ERROR`
|
||||||
|
# even if the uploaded image is indeed a photo.
|
||||||
|
if not os.path.splitext(file_name)[-1]:
|
||||||
|
file_name += utils._get_extension(stream)
|
||||||
|
|
||||||
|
# Determine whether the file is too big (over 10MB) or not
|
||||||
|
# Telegram does make a distinction between smaller or larger files
|
||||||
|
is_big = file_size > 10 * 1024 * 1024
|
||||||
|
hash_md5 = hashlib.md5()
|
||||||
|
|
||||||
|
part_count = (file_size + part_size - 1) // part_size
|
||||||
|
self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d',
|
||||||
|
file_size, part_count, part_size)
|
||||||
|
|
||||||
|
pos = 0
|
||||||
|
for part_index in range(part_count):
|
||||||
|
# Read the file by in chunks of size part_size
|
||||||
|
part = await helpers._maybe_await(stream.read(part_size))
|
||||||
|
|
||||||
|
if not isinstance(part, bytes):
|
||||||
|
raise TypeError(
|
||||||
|
'file descriptor returned {}, not bytes (you must '
|
||||||
|
'open the file in bytes mode)'.format(type(part)))
|
||||||
|
|
||||||
|
# `file_size` could be wrong in which case `part` may not be
|
||||||
|
# `part_size` before reaching the end.
|
||||||
|
if len(part) != part_size and part_index < part_count - 1:
|
||||||
|
raise ValueError(
|
||||||
|
'read less than {} before reaching the end; either '
|
||||||
|
'`file_size` or `read` are wrong'.format(part_size))
|
||||||
|
|
||||||
|
pos += len(part)
|
||||||
|
|
||||||
|
# Encryption part if needed
|
||||||
|
if key and iv:
|
||||||
|
part = AES.encrypt_ige(part, key, iv)
|
||||||
|
|
||||||
|
if not is_big:
|
||||||
|
# Bit odd that MD5 is only needed for small files and not
|
||||||
|
# big ones with more chance for corruption, but that's
|
||||||
|
# what Telegram wants.
|
||||||
|
hash_md5.update(part)
|
||||||
|
|
||||||
|
# The SavePartRequest is different depending on whether
|
||||||
|
# the file is too large or not (over or less than 10MB)
|
||||||
|
if is_big:
|
||||||
|
request = functions.upload.SaveBigFilePartRequest(
|
||||||
|
file_id, part_index, part_count, part)
|
||||||
|
else:
|
||||||
|
request = functions.upload.SaveFilePartRequest(
|
||||||
|
file_id, part_index, part)
|
||||||
|
await self.upload_semaphore.acquire()
|
||||||
|
self.loop.create_task(
|
||||||
|
self._send_file_part(request, part_index, part_count, pos, file_size, progress_callback),
|
||||||
|
name=f"telegram-upload-file-{part_index}"
|
||||||
|
)
|
||||||
|
# Wait for all tasks to finish
|
||||||
|
await asyncio.wait([
|
||||||
|
task for task in asyncio.all_tasks() if task.get_name().startswith(f"telegram-upload-file-")
|
||||||
|
])
|
||||||
|
if is_big:
|
||||||
|
return types.InputFileBig(file_id, part_count, file_name)
|
||||||
|
else:
|
||||||
|
return custom.InputSizedFile(
|
||||||
|
file_id, part_count, file_name, md5=hash_md5, size=file_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
async def _send_file_part(self, request: TLRequest, part_index: int, part_count: int, pos: int, file_size: int,
|
||||||
|
progress_callback: Optional['hints.ProgressCallback'] = None, retry: int = 0) -> None:
|
||||||
|
"""
|
||||||
|
Submit the file request part to Telegram. This method waits for the request to be executed, logs the upload,
|
||||||
|
and releases the semaphore to allow further uploading.
|
||||||
|
|
||||||
|
:param request: SaveBigFilePartRequest or SaveFilePartRequest. This request will be awaited.
|
||||||
|
:param part_index: Part index as integer. Used in logging.
|
||||||
|
:param part_count: Total parts count as integer. Used in logging.
|
||||||
|
:param pos: Number of part as integer. Used for progress bar.
|
||||||
|
:param file_size: Total file size. Used for progress bar.
|
||||||
|
:param progress_callback: Callback to use after submit the request. Optional.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
result = await self(request)
|
||||||
|
except InvalidBufferError as e:
|
||||||
|
if e.code == 429:
|
||||||
|
# Too many connections
|
||||||
|
click.echo(f'Too many connections to Telegram servers.', err=True)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
except ConnectionError:
|
||||||
|
# Retry to send the file part
|
||||||
|
click.echo(f'Detected connection error. Retrying...', err=True)
|
||||||
|
except FloodError as e:
|
||||||
|
print(e)
|
||||||
|
else:
|
||||||
|
self.upload_semaphore.release()
|
||||||
|
if result is None and retry < MAX_RECONNECT_RETRIES:
|
||||||
|
# An error occurred, retry
|
||||||
|
await asyncio.sleep(max(MIN_RECONNECT_WAIT, retry * MIN_RECONNECT_WAIT))
|
||||||
|
await self.reconnect()
|
||||||
|
await self._send_file_part(
|
||||||
|
request, part_index, part_count, pos, file_size, progress_callback, retry + 1
|
||||||
|
)
|
||||||
|
elif result:
|
||||||
|
self._log[__name__].debug('Uploaded %d/%d',
|
||||||
|
part_index + 1, part_count)
|
||||||
|
if progress_callback:
|
||||||
|
await helpers._maybe_await(progress_callback(pos, file_size))
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Failed to upload file part {}.'.format(part_index))
|
||||||
|
|
||||||
|
def decrease_upload_semaphore(self):
|
||||||
|
"""
|
||||||
|
Decreases the upload semaphore by one. This method is used to reduce the number of parallel uploads.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if self.parallel_upload_blocks > 1:
|
||||||
|
self.parallel_upload_blocks -= 1
|
||||||
|
self.loop.create_task(self.upload_semaphore.acquire())
|
||||||
|
|
||||||
|
async def reconnect(self):
|
||||||
|
"""
|
||||||
|
Reconnects to Telegram servers.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
await self.reconnecting_lock.acquire()
|
||||||
|
if self.is_connected():
|
||||||
|
# Reconnected in another task
|
||||||
|
self.reconnecting_lock.release()
|
||||||
|
return
|
||||||
|
self.decrease_upload_semaphore()
|
||||||
|
try:
|
||||||
|
click.echo(f'Reconnecting to Telegram servers...')
|
||||||
|
await asyncio.wait_for(self.connect(), RECONNECT_TIMEOUT)
|
||||||
|
click.echo(f'Reconnected to Telegram servers.')
|
||||||
|
except InvalidBufferError:
|
||||||
|
click.echo(f'InvalidBufferError connecting to Telegram servers.', err=True)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
click.echo(f'Timeout connecting to Telegram servers.', err=True)
|
||||||
|
finally:
|
||||||
|
self.reconnecting_lock.release()
|
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
CONFIG_DIRECTORY = os.environ.get('TELEGRAM_UPLOAD_CONFIG_DIRECTORY', '~/.config')
|
||||||
|
CONFIG_FILE = os.path.expanduser('{}/telegram-upload.json'.format(CONFIG_DIRECTORY))
|
||||||
|
SESSION_FILE = os.path.expanduser('{}/telegram-upload'.format(CONFIG_DIRECTORY))
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_config(config_file):
|
||||||
|
os.makedirs(os.path.dirname(config_file), exist_ok=True)
|
||||||
|
click.echo('Go to https://my.telegram.org and create a App in API development tools')
|
||||||
|
api_id = click.prompt('Please Enter api_id', type=int)
|
||||||
|
api_hash = click.prompt('Now enter api_hash')
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump({'api_id': api_id, 'api_hash': api_hash}, f)
|
||||||
|
return config_file
|
||||||
|
|
||||||
|
|
||||||
|
def default_config():
|
||||||
|
if os.path.lexists(CONFIG_FILE):
|
||||||
|
return CONFIG_FILE
|
||||||
|
return prompt_config(CONFIG_FILE)
|
|
@ -0,0 +1,210 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Iterable, Iterator, Optional, BinaryIO
|
||||||
|
|
||||||
|
from telethon.tl.types import Message, DocumentAttributeFilename
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
cached_property = property
|
||||||
|
else:
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_FILE_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def pipe_file(read_file_name: str, write_file: BinaryIO):
|
||||||
|
"""Read a file by its file name and write in another file already open."""
|
||||||
|
with open(read_file_name, "rb") as read_file:
|
||||||
|
while True:
|
||||||
|
data = read_file.read(CHUNK_FILE_SIZE)
|
||||||
|
if data:
|
||||||
|
write_file.write(data)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
class JoinStrategyBase:
|
||||||
|
"""Base class to inherit join strategies. The strategies depend on the file type.
|
||||||
|
For example, zip files and rar files do not merge in the same way.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.download_files = []
|
||||||
|
|
||||||
|
def is_part(self, download_file: 'DownloadFile') -> bool:
|
||||||
|
"""Returns if the download file is part of this bundle."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add_download_file(self, download_file: 'DownloadFile') -> None:
|
||||||
|
"""Add a download file to this bundle."""
|
||||||
|
if download_file in self.download_files:
|
||||||
|
return
|
||||||
|
self.download_files.append(download_file)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_applicable(cls, download_file: 'DownloadFile') -> bool:
|
||||||
|
"""Returns if this strategy is applicable to the download file."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def join_download_files(self):
|
||||||
|
"""Join the downloaded files in the bundle."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class UnionJoinStrategy(JoinStrategyBase):
|
||||||
|
"""Join separate files without any application. These files have extension
|
||||||
|
01, 02, 03...
|
||||||
|
"""
|
||||||
|
base_name: Optional[str] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_base_name(download_file: 'DownloadFile'):
|
||||||
|
"""Returns the file name without extension."""
|
||||||
|
return download_file.file_name.rsplit(".", 1)[0]
|
||||||
|
|
||||||
|
def add_download_file(self, download_file: 'DownloadFile') -> None:
|
||||||
|
"""Add a download file to this bundle."""
|
||||||
|
if self.base_name is None:
|
||||||
|
self.base_name = self.get_base_name(download_file)
|
||||||
|
super().add_download_file(download_file)
|
||||||
|
|
||||||
|
def is_part(self, download_file: 'DownloadFile') -> bool:
|
||||||
|
"""Returns if the download file is part of this bundle."""
|
||||||
|
return self.base_name == self.get_base_name(download_file)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_applicable(cls, download_file: 'DownloadFile') -> bool:
|
||||||
|
"""Returns if this strategy is applicable to the download file."""
|
||||||
|
return download_file.file_name_extension.isdigit()
|
||||||
|
|
||||||
|
def join_download_files(self):
|
||||||
|
"""Join the downloaded files in the bundle."""
|
||||||
|
download_files = self.download_files
|
||||||
|
sorted_files = sorted(download_files, key=lambda x: x.file_name_extension)
|
||||||
|
sorted_files = [file for file in sorted_files if os.path.lexists(file.downloaded_file_name or "")]
|
||||||
|
if not sorted_files or len(sorted_files) - 1 != int(sorted_files[-1].file_name_extension):
|
||||||
|
# There are parts of the file missing. Stopping...
|
||||||
|
return
|
||||||
|
with open(self.get_base_name(sorted_files[0]), "wb") as new_file:
|
||||||
|
for download_file in sorted_files:
|
||||||
|
pipe_file(download_file.downloaded_file_name, new_file)
|
||||||
|
for download_file in sorted_files:
|
||||||
|
os.remove(download_file.downloaded_file_name)
|
||||||
|
|
||||||
|
|
||||||
|
JOIN_STRATEGIES = [
|
||||||
|
UnionJoinStrategy,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_join_strategy(download_file: 'DownloadFile') -> Optional[JoinStrategyBase]:
|
||||||
|
"""Get join strategy for the download file. An instance is returned if a strategy
|
||||||
|
is available. Otherwise, None is returned.
|
||||||
|
"""
|
||||||
|
for strategy_cls in JOIN_STRATEGIES:
|
||||||
|
if strategy_cls.is_applicable(download_file):
|
||||||
|
strategy = strategy_cls()
|
||||||
|
strategy.add_download_file(download_file)
|
||||||
|
return strategy
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadFile:
|
||||||
|
"""File to download. This includes the Telethon message with the file."""
|
||||||
|
downloaded_file_name: Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(self, message: Message):
|
||||||
|
"""Creates the download file instance from the message."""
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def set_download_file_name(self, file_name):
|
||||||
|
"""After download the file, set the final download file name."""
|
||||||
|
self.downloaded_file_name = file_name
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filename_attr(self) -> Optional[DocumentAttributeFilename]:
|
||||||
|
"""Get the document attribute file name attribute in the document."""
|
||||||
|
return next(filter(lambda x: isinstance(x, DocumentAttributeFilename),
|
||||||
|
self.document.attributes), None)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def file_name(self) -> str:
|
||||||
|
"""Get the file name."""
|
||||||
|
return self.filename_attr.file_name if self.filename_attr else 'Unknown'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_name_extension(self) -> str:
|
||||||
|
"""Get the file name extension."""
|
||||||
|
parts = self.file_name.rsplit(".", 1)
|
||||||
|
return parts[-1] if len(parts) >= 2 else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def document(self):
|
||||||
|
"""Get the message document."""
|
||||||
|
return self.message.document
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
"""Get the file size."""
|
||||||
|
return self.document.size
|
||||||
|
|
||||||
|
def __eq__(self, other: 'DownloadFile'):
|
||||||
|
"""Compare download files by their file name."""
|
||||||
|
return self.file_name == other.file_name
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadSplitFilesBase:
|
||||||
|
"""Iterate over complete and split files. Base class to inherit."""
|
||||||
|
def __init__(self, messages: Iterable[Message]):
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
def get_iterator(self) -> Iterator[DownloadFile]:
|
||||||
|
"""Get an iterator with the download files."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __iter__(self) -> 'DownloadSplitFilesBase':
|
||||||
|
"""Set the iterator from the get_iterator method."""
|
||||||
|
self._iterator = self.get_iterator()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self) -> 'DownloadFile':
|
||||||
|
"""Get the next download file in the iterator."""
|
||||||
|
if self._iterator is None:
|
||||||
|
self._iterator = self.get_iterator()
|
||||||
|
return next(self._iterator)
|
||||||
|
|
||||||
|
|
||||||
|
class KeepDownloadSplitFiles(DownloadSplitFilesBase):
|
||||||
|
"""Download split files without join it."""
|
||||||
|
def get_iterator(self) -> Iterator[DownloadFile]:
|
||||||
|
"""Get an iterator with the download files."""
|
||||||
|
return map(lambda message: DownloadFile(message), self.messages)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinDownloadSplitFiles(DownloadSplitFilesBase):
|
||||||
|
"""Download split files and join it."""
|
||||||
|
def get_iterator(self) -> Iterator[DownloadFile]:
|
||||||
|
"""Get an iterator with the download files. This method applies the join strategy and
|
||||||
|
joins the files after download it.
|
||||||
|
"""
|
||||||
|
current_join_strategy: Optional[JoinStrategyBase] = None
|
||||||
|
for message in self.messages:
|
||||||
|
download_file = DownloadFile(message)
|
||||||
|
yield download_file
|
||||||
|
if current_join_strategy and current_join_strategy.is_part(download_file):
|
||||||
|
# There is a bundle in process and the download file is part of it. Add the download
|
||||||
|
# file to the bundle.
|
||||||
|
current_join_strategy.add_download_file(download_file)
|
||||||
|
elif current_join_strategy and not current_join_strategy.is_part(download_file):
|
||||||
|
# There is a bundle in process and the download file is not part of it. Join the files
|
||||||
|
# in the bundle and finish it.
|
||||||
|
current_join_strategy.join_download_files()
|
||||||
|
current_join_strategy = None
|
||||||
|
if current_join_strategy is None:
|
||||||
|
# There is no bundle in process. Get the current bundle if the file has a strategy
|
||||||
|
# available.
|
||||||
|
current_join_strategy = get_join_strategy(download_file)
|
||||||
|
else:
|
||||||
|
# After finish all the files, join the latest bundle.
|
||||||
|
if current_join_strategy:
|
||||||
|
current_join_strategy.join_download_files()
|
|
@ -0,0 +1,76 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""Exceptions for telegram-upload."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from telegram_upload.config import prompt_config
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbVideoError(ThumbError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUploadError(Exception):
|
||||||
|
body = ''
|
||||||
|
error_code = 1
|
||||||
|
|
||||||
|
def __init__(self, extra_body=''):
|
||||||
|
self.extra_body = extra_body
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
msg = self.__class__.__name__
|
||||||
|
if self.body:
|
||||||
|
msg += ': {}'.format(self.body)
|
||||||
|
if self.extra_body:
|
||||||
|
msg += ('. {}' if self.body else ': {}').format(self.extra_body)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class MissingFileError(TelegramUploadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidApiFileError(TelegramUploadError):
|
||||||
|
def __init__(self, config_file, extra_body=''):
|
||||||
|
self.config_file = config_file
|
||||||
|
super().__init__(extra_body)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramInvalidFile(TelegramUploadError):
|
||||||
|
error_code = 3
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUploadNoSpaceError(TelegramUploadError):
|
||||||
|
error_code = 28
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUploadDataLoss(TelegramUploadError):
|
||||||
|
error_code = 29
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramProxyError(TelegramUploadError):
|
||||||
|
error_code = 30
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramEnvironmentError(TelegramUploadError):
|
||||||
|
error_code = 31
|
||||||
|
|
||||||
|
|
||||||
|
def catch(fn):
|
||||||
|
def wrap(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
except InvalidApiFileError as e:
|
||||||
|
click.echo('The api_id/api_hash combination is invalid. Re-enter both values.')
|
||||||
|
prompt_config(e.config_file)
|
||||||
|
return catch(fn)(*args, **kwargs)
|
||||||
|
except TelegramUploadError as e:
|
||||||
|
sys.stderr.write('[Error] telegram-upload Exception:\n{}\n'.format(e))
|
||||||
|
exit(e.error_code)
|
||||||
|
return wrap
|
|
@ -0,0 +1,255 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""Console script for telegram-upload."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
from telethon.tl.types import User
|
||||||
|
|
||||||
|
from telegram_upload.cli import show_checkboxlist, show_radiolist
|
||||||
|
from telegram_upload.client import TelegramManagerClient, get_message_file_attribute
|
||||||
|
from telegram_upload.config import default_config, CONFIG_FILE
|
||||||
|
from telegram_upload.download_files import KeepDownloadSplitFiles, JoinDownloadSplitFiles
|
||||||
|
from telegram_upload.exceptions import catch
|
||||||
|
from telegram_upload.upload_files import NoDirectoriesFiles, RecursiveFiles, NoLargeFiles, SplitFiles, is_valid_file
|
||||||
|
from telegram_upload.utils import async_to_sync, amap, sync_to_async_iterator
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from natsort import natsorted
|
||||||
|
except ImportError:
|
||||||
|
natsorted = None
|
||||||
|
|
||||||
|
|
||||||
|
DIRECTORY_MODES = {
|
||||||
|
'fail': NoDirectoriesFiles,
|
||||||
|
'recursive': RecursiveFiles,
|
||||||
|
}
|
||||||
|
LARGE_FILE_MODES = {
|
||||||
|
'fail': NoLargeFiles,
|
||||||
|
'split': SplitFiles,
|
||||||
|
}
|
||||||
|
DOWNLOAD_SPLIT_FILE_MODES = {
|
||||||
|
'keep': KeepDownloadSplitFiles,
|
||||||
|
'join': JoinDownloadSplitFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_display_name(message):
|
||||||
|
display_name_parts = []
|
||||||
|
is_document = message.document
|
||||||
|
if is_document and message.document.mime_type:
|
||||||
|
display_name_parts.append(message.document.mime_type.split('/')[0])
|
||||||
|
if is_document and get_message_file_attribute(message):
|
||||||
|
display_name_parts.append(get_message_file_attribute(message).file_name)
|
||||||
|
if message.text:
|
||||||
|
display_name_parts.append(f'[{message.text}]' if display_name_parts else message.text)
|
||||||
|
from_user = message.sender and isinstance(message.sender, User)
|
||||||
|
if from_user:
|
||||||
|
display_name_parts.append('by')
|
||||||
|
if from_user and message.sender.first_name:
|
||||||
|
display_name_parts.append(message.sender.first_name)
|
||||||
|
if from_user and message.sender.last_name:
|
||||||
|
display_name_parts.append(message.sender.last_name)
|
||||||
|
if from_user and message.sender.username:
|
||||||
|
display_name_parts.append(f'@{message.sender.username}')
|
||||||
|
display_name_parts.append(f'{message.date}')
|
||||||
|
return ' '.join(display_name_parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def interactive_select_files(client, entity: str):
|
||||||
|
iterator = client.iter_files(entity)
|
||||||
|
iterator = amap(lambda x: (x, get_file_display_name(x)), iterator,)
|
||||||
|
return await show_checkboxlist(iterator)
|
||||||
|
|
||||||
|
|
||||||
|
async def interactive_select_local_files():
|
||||||
|
iterator = filter(lambda x: os.path.isfile(x) and os.path.lexists(x), os.listdir('.'))
|
||||||
|
iterator = sync_to_async_iterator(map(lambda x: (x, x), iterator))
|
||||||
|
return await show_checkboxlist(iterator, 'Not files were found in the current directory '
|
||||||
|
'(subdirectories are not supported). Exiting...')
|
||||||
|
|
||||||
|
|
||||||
|
async def interactive_select_dialog(client):
|
||||||
|
iterator = client.iter_dialogs()
|
||||||
|
iterator = amap(lambda x: (x, x.name), iterator,)
|
||||||
|
value = await show_radiolist(iterator, 'Not dialogs were found in your Telegram session. '
|
||||||
|
'Have you started any conversations?')
|
||||||
|
return value.id if value else None
|
||||||
|
|
||||||
|
|
||||||
|
class MutuallyExclusiveOption(click.Option):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', []))
|
||||||
|
help = kwargs.get('help', '')
|
||||||
|
if self.mutually_exclusive:
|
||||||
|
kwargs['help'] = help + (
|
||||||
|
' NOTE: This argument is mutually exclusive with'
|
||||||
|
' arguments: [{}].'.format(self.mutually_exclusive_text)
|
||||||
|
)
|
||||||
|
super(MutuallyExclusiveOption, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def handle_parse_result(self, ctx, opts, args):
|
||||||
|
if self.mutually_exclusive.intersection(opts) and self.name in opts:
|
||||||
|
raise click.UsageError(
|
||||||
|
"Illegal usage: `{}` is mutually exclusive with "
|
||||||
|
"arguments `{}`.".format(
|
||||||
|
self.name,
|
||||||
|
self.mutually_exclusive_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return super(MutuallyExclusiveOption, self).handle_parse_result(
|
||||||
|
ctx,
|
||||||
|
opts,
|
||||||
|
args
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mutually_exclusive_text(self):
|
||||||
|
return ', '.join([x.replace('_', '-') for x in self.mutually_exclusive])
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument('files', nargs=-1)
|
||||||
|
@click.option('--to', default=None, help='Phone number, username, invite link or "me" (saved messages). '
|
||||||
|
'By default "me".')
|
||||||
|
@click.option('--config', default=None, help='Configuration file to use. By default "{}".'.format(CONFIG_FILE))
|
||||||
|
@click.option('-d', '--delete-on-success', is_flag=True, help='Delete local file after successful upload.')
|
||||||
|
@click.option('--print-file-id', is_flag=True, help='Print the id of the uploaded file after the upload.')
|
||||||
|
@click.option('--force-file', is_flag=True, help='Force send as a file. The filename will be preserved '
|
||||||
|
'but the preview will not be available.')
|
||||||
|
@click.option('-f', '--forward', multiple=True, help='Forward the file to a chat (alias or id) or user (username, '
|
||||||
|
'mobile or id). This option can be used multiple times.')
|
||||||
|
@click.option('--directories', default='fail', type=click.Choice(list(DIRECTORY_MODES.keys())),
|
||||||
|
help='Defines how to process directories. By default directories are not accepted and will raise an '
|
||||||
|
'error.')
|
||||||
|
@click.option('--large-files', default='fail', type=click.Choice(list(LARGE_FILE_MODES.keys())),
|
||||||
|
help='Defines how to process large files unsupported for Telegram. By default large files are not '
|
||||||
|
'accepted and will raise an error.')
|
||||||
|
@click.option('--caption', type=str, help='Change file description. By default the file name.')
|
||||||
|
@click.option('--no-thumbnail', is_flag=True, cls=MutuallyExclusiveOption, mutually_exclusive=["thumbnail_file"],
|
||||||
|
help='Disable thumbnail generation. For some known file formats, Telegram may still generate a '
|
||||||
|
'thumbnail or show a preview.')
|
||||||
|
@click.option('--thumbnail-file', default=None, cls=MutuallyExclusiveOption, mutually_exclusive=["no_thumbnail"],
|
||||||
|
help='Path to the preview file to use for the uploaded file.')
|
||||||
|
@click.option('-p', '--proxy', default=None,
|
||||||
|
help='Use an http proxy, socks4, socks5 or mtproxy. For example socks5://user:pass@1.2.3.4:8080 '
|
||||||
|
'for socks5 and mtproxy://secret@1.2.3.4:443 for mtproxy.')
|
||||||
|
@click.option('-a', '--album', is_flag=True,
|
||||||
|
help='Send video or photos as an album.')
|
||||||
|
@click.option('-i', '--interactive', is_flag=True,
|
||||||
|
help='Use interactive mode.')
|
||||||
|
@click.option('--sort', is_flag=True,
|
||||||
|
help='Sort files by name before upload it. Install the natsort Python package for natural sorting.')
|
||||||
|
def upload(files, to, config, delete_on_success, print_file_id, force_file, forward, directories, large_files, caption,
|
||||||
|
no_thumbnail, thumbnail_file, proxy, album, interactive, sort):
|
||||||
|
"""Upload one or more files to Telegram using your personal account.
|
||||||
|
The maximum file size is 2 GiB for free users and 4 GiB for premium accounts.
|
||||||
|
By default, they will be saved in your saved messages.
|
||||||
|
"""
|
||||||
|
client = TelegramManagerClient(config or default_config(), proxy=proxy)
|
||||||
|
client.start()
|
||||||
|
if interactive and not files:
|
||||||
|
click.echo('Select the local files to upload:')
|
||||||
|
click.echo('[SPACE] Select file [ENTER] Next step')
|
||||||
|
files = async_to_sync(interactive_select_local_files())
|
||||||
|
if interactive and not files:
|
||||||
|
# No files selected. Exiting.
|
||||||
|
return
|
||||||
|
if interactive and to is None:
|
||||||
|
click.echo('Select the recipient dialog of the files:')
|
||||||
|
click.echo('[SPACE] Select dialog [ENTER] Next step')
|
||||||
|
to = async_to_sync(interactive_select_dialog(client))
|
||||||
|
elif to is None:
|
||||||
|
to = 'me'
|
||||||
|
files = filter(lambda file: is_valid_file(file, lambda message: click.echo(message, err=True)), files)
|
||||||
|
files = DIRECTORY_MODES[directories](client, files)
|
||||||
|
if directories == 'fail':
|
||||||
|
# Validate now
|
||||||
|
files = list(files)
|
||||||
|
if no_thumbnail:
|
||||||
|
thumbnail = False
|
||||||
|
elif thumbnail_file:
|
||||||
|
thumbnail = thumbnail_file
|
||||||
|
else:
|
||||||
|
thumbnail = None
|
||||||
|
files_cls = LARGE_FILE_MODES[large_files]
|
||||||
|
files = files_cls(client, files, caption=caption, thumbnail=thumbnail, force_file=force_file)
|
||||||
|
if large_files == 'fail':
|
||||||
|
# Validate now
|
||||||
|
files = list(files)
|
||||||
|
if isinstance(to, str) and to.lstrip("-+").isdigit():
|
||||||
|
to = int(to)
|
||||||
|
if sort and natsorted:
|
||||||
|
files = natsorted(files, key=lambda x: x.name)
|
||||||
|
elif sort:
|
||||||
|
files = sorted(files, key=lambda x: x.name)
|
||||||
|
if album:
|
||||||
|
client.send_files_as_album(to, files, delete_on_success, print_file_id, forward)
|
||||||
|
else:
|
||||||
|
client.send_files(to, files, delete_on_success, print_file_id, forward)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option('--from', '-f', 'from_', default='',
|
||||||
|
help='Phone number, username, chat id or "me" (saved messages). By default "me".')
|
||||||
|
@click.option('--config', default=None, help='Configuration file to use. By default "{}".'.format(CONFIG_FILE))
|
||||||
|
@click.option('-d', '--delete-on-success', is_flag=True,
|
||||||
|
help='Delete telegram message after successful download. Useful for creating a download queue.')
|
||||||
|
@click.option('-p', '--proxy', default=None,
|
||||||
|
help='Use an http proxy, socks4, socks5 or mtproxy. For example socks5://user:pass@1.2.3.4:8080 '
|
||||||
|
'for socks5 and mtproxy://secret@1.2.3.4:443 for mtproxy.')
|
||||||
|
@click.option('-m', '--split-files', default='keep', type=click.Choice(list(DOWNLOAD_SPLIT_FILE_MODES.keys())),
|
||||||
|
help='Defines how to download large files split in Telegram. By default the files are not merged.')
|
||||||
|
@click.option('-i', '--interactive', is_flag=True,
|
||||||
|
help='Use interactive mode.')
|
||||||
|
def download(from_, config, delete_on_success, proxy, split_files, interactive):
|
||||||
|
"""Download all the latest messages that are files in a chat, by default download
|
||||||
|
from "saved messages". It is recommended to forward the files to download to
|
||||||
|
"saved messages" and use parameter ``--delete-on-success``. Forwarded messages will
|
||||||
|
be removed from the chat after downloading, such as a download queue.
|
||||||
|
"""
|
||||||
|
client = TelegramManagerClient(config or default_config(), proxy=proxy)
|
||||||
|
client.start()
|
||||||
|
if not interactive and not from_:
|
||||||
|
from_ = 'me'
|
||||||
|
elif isinstance(from_, str) and from_.lstrip("-+").isdigit():
|
||||||
|
from_ = int(from_)
|
||||||
|
elif interactive and not from_:
|
||||||
|
click.echo('Select the dialog of the files to download:')
|
||||||
|
click.echo('[SPACE] Select dialog [ENTER] Next step')
|
||||||
|
from_ = async_to_sync(interactive_select_dialog(client))
|
||||||
|
if interactive:
|
||||||
|
click.echo('Select all files to download:')
|
||||||
|
click.echo('[SPACE] Select files [ENTER] Download selected files')
|
||||||
|
messages = async_to_sync(interactive_select_files(client, from_))
|
||||||
|
else:
|
||||||
|
messages = client.find_files(from_)
|
||||||
|
messages_cls = DOWNLOAD_SPLIT_FILE_MODES[split_files]
|
||||||
|
download_files = messages_cls(reversed(list(messages)))
|
||||||
|
client.download_files(from_, download_files, delete_on_success)
|
||||||
|
|
||||||
|
|
||||||
|
upload_cli = catch(upload)
|
||||||
|
download_cli = catch(download)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
commands = {'upload': upload_cli, 'download': download_cli}
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.stderr.write('A command is required. Available commands: {}\n'.format(
|
||||||
|
', '.join(commands)
|
||||||
|
))
|
||||||
|
sys.exit(1)
|
||||||
|
if sys.argv[1] not in commands:
|
||||||
|
sys.stderr.write('{} is an invalid command. Valid commands: {}\n'.format(
|
||||||
|
sys.argv[1], ', '.join(commands)
|
||||||
|
))
|
||||||
|
sys.exit(1)
|
||||||
|
fn = commands[sys.argv[1]]
|
||||||
|
sys.argv = [sys.argv[0]] + sys.argv[2:]
|
||||||
|
sys.exit(fn())
|
|
@ -0,0 +1,254 @@
|
||||||
|
import datetime
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from io import FileIO, SEEK_SET
|
||||||
|
from typing import Union, TYPE_CHECKING
|
||||||
|
|
||||||
|
import click
|
||||||
|
from hachoir.metadata.metadata import RootMetadata
|
||||||
|
from hachoir.metadata.video import MP4Metadata
|
||||||
|
from telethon.tl.types import DocumentAttributeVideo, DocumentAttributeFilename
|
||||||
|
|
||||||
|
from telegram_upload.caption_formatter import CaptionFormatter, FilePath
|
||||||
|
from telegram_upload.exceptions import TelegramInvalidFile, ThumbError
|
||||||
|
from telegram_upload.utils import scantree, truncate
|
||||||
|
from telegram_upload.video import get_video_thumb, video_metadata
|
||||||
|
|
||||||
|
mimetypes.init()
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from telegram_upload.client import TelegramManagerClient
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_file(file, error_logger=None):
|
||||||
|
error_message = None
|
||||||
|
if not os.path.lexists(file):
|
||||||
|
error_message = 'File "{}" does not exist.'.format(file)
|
||||||
|
elif not os.path.getsize(file):
|
||||||
|
error_message = 'File "{}" is empty.'.format(file)
|
||||||
|
if error_message and error_logger is not None:
|
||||||
|
error_logger(error_message)
|
||||||
|
return error_message is None
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_mime(file):
|
||||||
|
return (mimetypes.guess_type(file)[0] or ('')).split('/')[0]
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_has(metadata: RootMetadata, key: str):
|
||||||
|
try:
|
||||||
|
return metadata.has(key)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_attributes(file):
|
||||||
|
attrs = []
|
||||||
|
mime = get_file_mime(file)
|
||||||
|
if mime == 'video':
|
||||||
|
metadata = video_metadata(file)
|
||||||
|
video_meta = metadata
|
||||||
|
meta_groups = None
|
||||||
|
if hasattr(metadata, '_MultipleMetadata__groups'):
|
||||||
|
# Is mkv
|
||||||
|
meta_groups = metadata._MultipleMetadata__groups
|
||||||
|
if metadata is not None and not metadata.has('width') and meta_groups:
|
||||||
|
video_meta = meta_groups[next(filter(lambda x: x.startswith('video'), meta_groups._key_list))]
|
||||||
|
if metadata is not None:
|
||||||
|
supports_streaming = isinstance(video_meta, MP4Metadata)
|
||||||
|
attrs.append(DocumentAttributeVideo(
|
||||||
|
(0, metadata.get('duration').seconds)[metadata_has(metadata, 'duration')],
|
||||||
|
(0, video_meta.get('width'))[metadata_has(video_meta, 'width')],
|
||||||
|
(0, video_meta.get('height'))[metadata_has(video_meta, 'height')],
|
||||||
|
False,
|
||||||
|
supports_streaming,
|
||||||
|
))
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_thumb(file):
|
||||||
|
if get_file_mime(file) == 'video':
|
||||||
|
return get_video_thumb(file)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadFilesBase:
|
||||||
|
def __init__(self, client: 'TelegramManagerClient', files, thumbnail: Union[str, bool, None] = None,
|
||||||
|
force_file: bool = False, caption: Union[str, None] = None):
|
||||||
|
self._iterator = None
|
||||||
|
self.client = client
|
||||||
|
self.files = files
|
||||||
|
self.thumbnail = thumbnail
|
||||||
|
self.force_file = force_file
|
||||||
|
self.caption = caption
|
||||||
|
|
||||||
|
def get_iterator(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
self._iterator = self.get_iterator()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
if self._iterator is None:
|
||||||
|
self._iterator = self.get_iterator()
|
||||||
|
return next(self._iterator)
|
||||||
|
|
||||||
|
|
||||||
|
class RecursiveFiles(UploadFilesBase):
|
||||||
|
|
||||||
|
def get_iterator(self):
|
||||||
|
for file in self.files:
|
||||||
|
if os.path.isdir(file):
|
||||||
|
yield from map(lambda file: file.path,
|
||||||
|
filter(lambda x: not x.is_dir(), scantree(file, True)))
|
||||||
|
else:
|
||||||
|
yield file
|
||||||
|
|
||||||
|
|
||||||
|
class NoDirectoriesFiles(UploadFilesBase):
|
||||||
|
def get_iterator(self):
|
||||||
|
for file in self.files:
|
||||||
|
if os.path.isdir(file):
|
||||||
|
raise TelegramInvalidFile('"{}" is a directory.'.format(file))
|
||||||
|
else:
|
||||||
|
yield file
|
||||||
|
|
||||||
|
|
||||||
|
class LargeFilesBase(UploadFilesBase):
|
||||||
|
def get_iterator(self):
|
||||||
|
for file in self.files:
|
||||||
|
if os.path.getsize(file) > self.client.max_file_size:
|
||||||
|
yield from self.process_large_file(file)
|
||||||
|
else:
|
||||||
|
yield self.process_normal_file(file)
|
||||||
|
|
||||||
|
def process_normal_file(self, file: str) -> 'File':
|
||||||
|
return File(self.client, file, force_file=self.force_file, thumbnail=self.thumbnail, caption=self.caption)
|
||||||
|
|
||||||
|
def process_large_file(self, file):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class NoLargeFiles(LargeFilesBase):
|
||||||
|
def process_large_file(self, file):
|
||||||
|
raise TelegramInvalidFile('"{}" file is too large for Telegram.'.format(file))
|
||||||
|
|
||||||
|
|
||||||
|
class File(FileIO):
|
||||||
|
force_file = False
|
||||||
|
|
||||||
|
def __init__(self, client: 'TelegramManagerClient', path: str, force_file: Union[bool, None] = None,
|
||||||
|
thumbnail: Union[str, bool, None] = None, caption: Union[str, None] = None):
|
||||||
|
super().__init__(path)
|
||||||
|
self.client = client
|
||||||
|
self.path = path
|
||||||
|
self.force_file = self.force_file if force_file is None else force_file
|
||||||
|
self._thumbnail = thumbnail
|
||||||
|
self._caption = caption
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_name(self):
|
||||||
|
return os.path.basename(self.path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size(self):
|
||||||
|
return os.path.getsize(self.path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_name(self):
|
||||||
|
return '.'.join(self.file_name.split('.')[:-1])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_custom_thumbnail(self):
|
||||||
|
return self._thumbnail is not False and self._thumbnail is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_caption(self) -> str:
|
||||||
|
"""Get file caption. If caption parameter is not set, return file name.
|
||||||
|
If caption is set, format it with CaptionFormatter.
|
||||||
|
Anyways, truncate caption to max_caption_length.
|
||||||
|
"""
|
||||||
|
if self._caption is not None:
|
||||||
|
formatter = CaptionFormatter()
|
||||||
|
caption = formatter.format(self._caption, file=FilePath(self.path), now=datetime.datetime.now())
|
||||||
|
else:
|
||||||
|
caption = self.short_name
|
||||||
|
return truncate(caption, self.client.max_caption_length)
|
||||||
|
|
||||||
|
def get_thumbnail(self):
|
||||||
|
thumb = None
|
||||||
|
if self._thumbnail is None and not self.force_file:
|
||||||
|
try:
|
||||||
|
thumb = get_file_thumb(self.path)
|
||||||
|
except ThumbError as e:
|
||||||
|
click.echo('{}'.format(e), err=True)
|
||||||
|
elif self.is_custom_thumbnail:
|
||||||
|
if not isinstance(self._thumbnail, str):
|
||||||
|
raise TypeError('Invalid type for thumbnail: {}'.format(type(self._thumbnail)))
|
||||||
|
elif not os.path.lexists(self._thumbnail):
|
||||||
|
raise TelegramInvalidFile('{} thumbnail file does not exists.'.format(self._thumbnail))
|
||||||
|
thumb = self._thumbnail
|
||||||
|
return thumb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_attributes(self):
|
||||||
|
if self.force_file:
|
||||||
|
return [DocumentAttributeFilename(self.file_name)]
|
||||||
|
else:
|
||||||
|
return get_file_attributes(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class SplitFile(File, FileIO):
|
||||||
|
force_file = True
|
||||||
|
|
||||||
|
def __init__(self, client: 'TelegramManagerClient', file: Union[str, bytes, int], max_read_size: int, name: str):
|
||||||
|
super().__init__(client, file)
|
||||||
|
self.max_read_size = max_read_size
|
||||||
|
self.remaining_size = max_read_size
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def read(self, size: int = -1) -> bytes:
|
||||||
|
if size == -1:
|
||||||
|
size = self.remaining_size
|
||||||
|
if not self.remaining_size:
|
||||||
|
return b''
|
||||||
|
size = min(self.remaining_size, size)
|
||||||
|
self.remaining_size -= size
|
||||||
|
return super().read(size)
|
||||||
|
|
||||||
|
def readall(self) -> bytes:
|
||||||
|
return self.read()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_name(self):
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size(self):
|
||||||
|
return self.max_read_size
|
||||||
|
|
||||||
|
def seek(self, offset: int, whence: int = SEEK_SET, split_seek: bool = False) -> int:
|
||||||
|
if not split_seek:
|
||||||
|
self.remaining_size += self.tell() - offset
|
||||||
|
return super().seek(offset, whence)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_name(self):
|
||||||
|
return self.file_name.split('/')[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class SplitFiles(LargeFilesBase):
|
||||||
|
def process_large_file(self, file):
|
||||||
|
file_name = os.path.basename(file)
|
||||||
|
total_size = os.path.getsize(file)
|
||||||
|
parts = math.ceil(total_size / self.client.max_file_size)
|
||||||
|
zfill = int(math.log10(10)) + 1
|
||||||
|
for part in range(parts):
|
||||||
|
size = total_size - (part * self.client.max_file_size) if part >= parts - 1 else self.client.max_file_size
|
||||||
|
splitted_file = SplitFile(self.client, file, size, '{}.{}'.format(file_name, str(part).zfill(zfill)))
|
||||||
|
splitted_file.seek(self.client.max_file_size * part, split_seek=True)
|
||||||
|
yield splitted_file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from telegram_upload._compat import scandir
|
||||||
|
from telegram_upload.exceptions import TelegramEnvironmentError
|
||||||
|
|
||||||
|
|
||||||
|
def free_disk_usage(directory='.'):
|
||||||
|
return shutil.disk_usage(directory)[2]
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(text, max_length):
|
||||||
|
return (text[:max_length - 3] + '...') if len(text) > max_length else text
|
||||||
|
|
||||||
|
|
||||||
|
def grouper(n, iterable):
|
||||||
|
it = iter(iterable)
|
||||||
|
while True:
|
||||||
|
chunk = tuple(itertools.islice(it, n))
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
def sizeof_fmt(num, suffix='B'):
|
||||||
|
for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
|
||||||
|
if abs(num) < 1024.0:
|
||||||
|
return "%3.1f%s%s" % (num, unit, suffix)
|
||||||
|
num /= 1024.0
|
||||||
|
return "%.1f%s%s" % (num, 'Yi', suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def scantree(path, follow_symlinks=False):
|
||||||
|
"""Recursively yield DirEntry objects for given directory."""
|
||||||
|
for entry in scandir(path):
|
||||||
|
if entry.is_dir(follow_symlinks=follow_symlinks):
|
||||||
|
yield from scantree(entry.path, follow_symlinks) # see below for Python 2.x
|
||||||
|
else:
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
|
||||||
|
def async_to_sync(coro):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
return coro
|
||||||
|
else:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
async def aislice(iterator, limit):
|
||||||
|
items = []
|
||||||
|
i = 0
|
||||||
|
async for value in iterator:
|
||||||
|
if i > limit:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
items.append(value)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def amap(fn, iterator):
|
||||||
|
async for value in iterator:
|
||||||
|
yield fn(value)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_to_async_iterator(iterator):
|
||||||
|
for value in iterator:
|
||||||
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
def get_environment_integer(environment_name: str, default_value: int):
|
||||||
|
"""Get an integer from an environment variable."""
|
||||||
|
value = os.environ.get(environment_name, default_value)
|
||||||
|
if isinstance(value, int) or value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
raise TelegramEnvironmentError(f"Environment variable {environment_name} must be an integer")
|
|
@ -0,0 +1,69 @@
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
from hachoir.metadata import extractMetadata
|
||||||
|
from hachoir.parser import createParser
|
||||||
|
from hachoir.core import config as hachoir_config
|
||||||
|
|
||||||
|
from telegram_upload.exceptions import ThumbVideoError
|
||||||
|
|
||||||
|
|
||||||
|
hachoir_config.quiet = True
|
||||||
|
|
||||||
|
|
||||||
|
def video_metadata(file):
|
||||||
|
return extractMetadata(createParser(file))
|
||||||
|
|
||||||
|
|
||||||
|
def call_ffmpeg(args):
|
||||||
|
try:
|
||||||
|
return subprocess.Popen([get_ffmpeg_command()] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise ThumbVideoError('ffmpeg command is not available. Thumbnails for videos are not available!')
|
||||||
|
|
||||||
|
|
||||||
|
def get_ffmpeg_command():
|
||||||
|
return os.environ.get('FFMPEG_COMMAND',
|
||||||
|
'ffmpeg.exe' if platform.system() == 'Windows' else 'ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_size(file):
|
||||||
|
p = call_ffmpeg([
|
||||||
|
'-i', file,
|
||||||
|
])
|
||||||
|
stdout, stderr = p.communicate()
|
||||||
|
video_lines = re.findall(': Video: ([^\n]+)', stderr.decode('utf-8', errors='ignore'))
|
||||||
|
if not video_lines:
|
||||||
|
return
|
||||||
|
matchs = re.findall("(\d{2,6})x(\d{2,6})", video_lines[0])
|
||||||
|
if matchs:
|
||||||
|
return [int(x) for x in matchs[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_thumb(file, output=None, size=200):
|
||||||
|
output = output or tempfile.NamedTemporaryFile(suffix='.jpg').name
|
||||||
|
metadata = video_metadata(file)
|
||||||
|
if metadata is None:
|
||||||
|
return
|
||||||
|
duration = metadata.get('duration').seconds if metadata.has('duration') else 0
|
||||||
|
ratio = get_video_size(file)
|
||||||
|
if ratio is None:
|
||||||
|
raise ThumbVideoError('Video ratio is not available.')
|
||||||
|
if ratio[0] / ratio[1] > 1:
|
||||||
|
width, height = size, -1
|
||||||
|
else:
|
||||||
|
width, height = -1, size
|
||||||
|
p = call_ffmpeg([
|
||||||
|
'-ss', str(int(duration / 2)),
|
||||||
|
'-i', file,
|
||||||
|
'-filter:v',
|
||||||
|
'scale={}:{}'.format(width, height),
|
||||||
|
'-vframes:v', '1',
|
||||||
|
output,
|
||||||
|
])
|
||||||
|
p.communicate()
|
||||||
|
if not p.returncode and os.path.lexists(file):
|
||||||
|
return output
|
Loading…
Reference in New Issue