From 4ad047b5c387b6548ed421df0ff9921a2d1b3b61 Mon Sep 17 00:00:00 2001 From: exostring Date: Fri, 21 Aug 2026 20:36:22 +0500 Subject: [PATCH] Retry transient video download failures, friendlier fallback link text, enable previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yt-dlp: retry transient failures (stall/timeout) up to 2x before falling back to a link-only post; permanent errors (private/removed) still fail fast - Fallback note: friendlier wording, "Смотреть видео" as link text instead of a bare "ссылка", plus a link to the original VK wall post as a backup - TG: enable link preview for text-only fallback posts (was unconditionally disabled, leaving fallback posts with no visual at all) - MAX: attach the VK video's own thumbnail as an image so failed/oversized video fallbacks still show a preview picture (MAX has no OG-preview for arbitrary URLs, but does accept a remote image url without upload) Co-Authored-By: Claude Sonnet 5 --- src/config.py | 3 +++ src/max_poster.py | 14 +++++++++++++- src/media_processor.py | 23 +++++++++++++++++++++++ src/text_formatter.py | 16 ++++++++++++---- src/tg_poster.py | 7 +++++-- src/vk_client.py | 8 ++++++++ 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/config.py b/src/config.py index 9b01676..7dcd68c 100644 --- a/src/config.py +++ b/src/config.py @@ -80,6 +80,9 @@ class Settings(BaseSettings): # stall, not just a big/slow file. This is the timeout that actually matters # day to day; yt_dlp_timeout_sec above is just the outer safety net. yt_dlp_stall_timeout_sec: int = 120 + # Extra attempts after a transient failure (stall/timeout/network hiccup) before + # falling back to a link-only post. Permanent failures (private/removed/etc.) never retry. + yt_dlp_retry_attempts: int = 2 # Text Styling & Decoration header_text: str = "" diff --git a/src/max_poster.py b/src/max_poster.py index 2f05296..77d0baf 100644 --- a/src/max_poster.py +++ b/src/max_poster.py @@ -304,12 +304,24 @@ class MAXPoster: vk_url=vk_url, ) - note = build_media_unavailable_note(link_only_media or [], parse_mode="html") + note = build_media_unavailable_note(link_only_media or [], parse_mode="html", vk_url=vk_url) video_note = build_oversized_video_note(oversized_videos or [], parse_mode="html") for extra in (note, video_note): if extra: formatted_text = f"{formatted_text}\n\n{extra}" if formatted_text else extra + # MAX has no OG-style link preview for arbitrary URLs, so a video we couldn't + # attach (failed download or over the 250MB cap) would otherwise post as bare + # text with no picture. MAX does let `image` attachments reference a remote + # `url` directly (no upload needed) - reuse the VK video's own thumbnail as a + # stand-in preview so the fallback link post still looks like a real post. + preview_attachments = [ + {"type": "image", "payload": {"url": item.thumbnail_url}} + for item in (list(link_only_media or []) + list(oversized_videos or [])) + if item.media_type == "video" and getattr(item, "thumbnail_url", None) + ] + attachments = list(attachments) + preview_attachments + chunks = split_message_chunks(formatted_text, self.message_limit) # Chunk into groups of MAX_MEDIA_ITEMS instead of silently dropping the excess: # the first group rides with the text message, extra groups go out as follow-ups. diff --git a/src/media_processor.py b/src/media_processor.py index cde62cb..3a903e7 100644 --- a/src/media_processor.py +++ b/src/media_processor.py @@ -49,6 +49,7 @@ class ProcessedMedia: height: Optional[int] = None error: Optional[str] = None is_link_only: bool = False + thumbnail_url: Optional[str] = None class MediaProcessor: @@ -100,6 +101,26 @@ class MediaProcessor: async def download_video_ytdlp( self, url: str, prefix: str = "video_" + ) -> tuple[Optional[Path], Optional[str], bool]: + """Retries transient failures (stalls, timeouts, one-off yt-dlp/network hiccups) + a couple of times before giving up - a single flaky attempt used to fall straight + through to the link-only fallback even though the video was perfectly downloadable.""" + last_result: tuple[Optional[Path], Optional[str], bool] = (None, "video download failed", False) + for attempt in range(1, settings.yt_dlp_retry_attempts + 2): + path, err, is_permanent = await self._download_video_ytdlp_attempt(url, prefix) + if path or is_permanent: + return path, err, is_permanent + last_result = (path, err, is_permanent) + if attempt <= settings.yt_dlp_retry_attempts: + logger.warning( + "yt-dlp attempt {}/{} failed transiently for {}: {} - retrying", + attempt, settings.yt_dlp_retry_attempts + 1, url, err, + ) + await asyncio.sleep(3.0) + return last_result + + async def _download_video_ytdlp_attempt( + self, url: str, prefix: str = "video_" ) -> tuple[Optional[Path], Optional[str], bool]: """Returns (path, error_message, is_permanent_error)""" output_path = self.cache_dir / f"{prefix}{uuid.uuid4().hex[:12]}.mp4" @@ -249,6 +270,7 @@ class MediaProcessor: duration_sec=item.duration_sec, error="video duration exceeds maximum", is_link_only=True, + thumbnail_url=item.thumbnail_url, ) ) continue @@ -277,6 +299,7 @@ class MediaProcessor: duration_sec=item.duration_sec, error=err or "failed to download video", is_link_only=True, + thumbnail_url=item.thumbnail_url, ) ) return results diff --git a/src/text_formatter.py b/src/text_formatter.py index 25af74b..8e05e45 100644 --- a/src/text_formatter.py +++ b/src/text_formatter.py @@ -140,7 +140,9 @@ def normalize_wrapper_text(text: str, parse_mode: str = "html") -> str: return parser.normalized() -def build_media_unavailable_note(link_only_items: list, parse_mode: str = "html") -> str: +def build_media_unavailable_note( + link_only_items: list, parse_mode: str = "html", vk_url: Optional[str] = None +) -> str: """Builds a short note listing media that couldn't be attached (too big, too long, private, download failed, etc.) with a link to the original, so readers aren't left with no idea that media was omitted.""" @@ -152,13 +154,19 @@ def build_media_unavailable_note(link_only_items: list, parse_mode: str = "html" media_type = str(getattr(item, "media_type", "медиа") or "медиа") if not url: continue + label = "Смотреть видео" if media_type == "video" else media_type if parse_mode == "html": - lines.append(f'- {html.escape(media_type)}: ссылка') + lines.append(f'- {html.escape(label)}') else: - lines.append(f"- {media_type}: {url}") + lines.append(f"- {label}: {url}") if not lines: return "" - header = "Не удалось прикрепить медиа, оригинал:" + if vk_url: + if parse_mode == "html": + lines.append(f'- Открыть пост в VK') + else: + lines.append(f"- пост в VK: {vk_url}") + header = "Не поместилось в пост:" if parse_mode == "html": header = f"{html.escape(header)}" return header + "\n" + "\n".join(lines) diff --git a/src/tg_poster.py b/src/tg_poster.py index 3074ff2..961d13d 100644 --- a/src/tg_poster.py +++ b/src/tg_poster.py @@ -393,11 +393,14 @@ class TelegramPoster: chunks = split_message_chunks(text, self.message_limit) mids: list[int] = [] for chunk in chunks: + # Preview enabled here (unlike the other send sites): this path only fires + # for text-only posts, most commonly a media-unavailable fallback link - a + # link preview card is the closest thing to the "picture" the post is missing. msg = await self.tg_retry( lambda c=chunk: self.bot.send_message( text=c, parse_mode="HTML", - disable_web_page_preview=True, + disable_web_page_preview=False, **self.chat_kwargs(chat_id, thread_id), ) ) @@ -448,7 +451,7 @@ class TelegramPoster: valid_media = [m for m in media_items if not m.is_link_only and m.local_path] link_only = [m for m in media_items if m.is_link_only] - note = build_media_unavailable_note(link_only, parse_mode="html") + note = build_media_unavailable_note(link_only, parse_mode="html", vk_url=vk_url) if note: formatted_text = f"{formatted_text}\n\n{note}" if formatted_text else note diff --git a/src/vk_client.py b/src/vk_client.py index 882fac4..e7637aa 100644 --- a/src/vk_client.py +++ b/src/vk_client.py @@ -45,6 +45,7 @@ class VKMediaItem: height: Optional[int] = None duration_sec: Optional[int] = None title: Optional[str] = None + thumbnail_url: Optional[str] = None @dataclass @@ -189,6 +190,12 @@ class VKClient: video_url = f"https://vk.com/video{owner_id}_{video_id}" if access_key: video_url += f"_{access_key}" + image_sizes = sorted( + video.get("image", []) or [], + key=lambda s: int(s.get("width") or 0) * int(s.get("height") or 0), + reverse=True, + ) + thumbnail_url = str(image_sizes[0].get("url") or "") if image_sizes else None items.append( VKMediaItem( media_type="video", @@ -198,6 +205,7 @@ class VKClient: height=video.get("height"), duration_sec=video.get("duration"), title=video.get("title"), + thumbnail_url=thumbnail_url, ) ) elif att_type == "link":