Retry transient video download failures, friendlier fallback link text, enable previews

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:36:22 +05:00
parent 3a086d9e54
commit 4ad047b5c3
6 changed files with 64 additions and 7 deletions
+3
View File
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
# stall, not just a big/slow file. This is the timeout that actually matters # 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. # day to day; yt_dlp_timeout_sec above is just the outer safety net.
yt_dlp_stall_timeout_sec: int = 120 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 # Text Styling & Decoration
header_text: str = "" header_text: str = ""
+13 -1
View File
@@ -304,12 +304,24 @@ class MAXPoster:
vk_url=vk_url, 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") video_note = build_oversized_video_note(oversized_videos or [], parse_mode="html")
for extra in (note, video_note): for extra in (note, video_note):
if extra: if extra:
formatted_text = f"{formatted_text}\n\n{extra}" if formatted_text else 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) chunks = split_message_chunks(formatted_text, self.message_limit)
# Chunk into groups of MAX_MEDIA_ITEMS instead of silently dropping the excess: # 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. # the first group rides with the text message, extra groups go out as follow-ups.
+23
View File
@@ -49,6 +49,7 @@ class ProcessedMedia:
height: Optional[int] = None height: Optional[int] = None
error: Optional[str] = None error: Optional[str] = None
is_link_only: bool = False is_link_only: bool = False
thumbnail_url: Optional[str] = None
class MediaProcessor: class MediaProcessor:
@@ -100,6 +101,26 @@ class MediaProcessor:
async def download_video_ytdlp( async def download_video_ytdlp(
self, url: str, prefix: str = "video_" 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]: ) -> tuple[Optional[Path], Optional[str], bool]:
"""Returns (path, error_message, is_permanent_error)""" """Returns (path, error_message, is_permanent_error)"""
output_path = self.cache_dir / f"{prefix}{uuid.uuid4().hex[:12]}.mp4" output_path = self.cache_dir / f"{prefix}{uuid.uuid4().hex[:12]}.mp4"
@@ -249,6 +270,7 @@ class MediaProcessor:
duration_sec=item.duration_sec, duration_sec=item.duration_sec,
error="video duration exceeds maximum", error="video duration exceeds maximum",
is_link_only=True, is_link_only=True,
thumbnail_url=item.thumbnail_url,
) )
) )
continue continue
@@ -277,6 +299,7 @@ class MediaProcessor:
duration_sec=item.duration_sec, duration_sec=item.duration_sec,
error=err or "failed to download video", error=err or "failed to download video",
is_link_only=True, is_link_only=True,
thumbnail_url=item.thumbnail_url,
) )
) )
return results return results
+12 -4
View File
@@ -140,7 +140,9 @@ def normalize_wrapper_text(text: str, parse_mode: str = "html") -> str:
return parser.normalized() 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, """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, too long, private, download failed, etc.) with a link to the original,
so readers aren't left with no idea that media was omitted.""" 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 "медиа") media_type = str(getattr(item, "media_type", "медиа") or "медиа")
if not url: if not url:
continue continue
label = "Смотреть видео" if media_type == "video" else media_type
if parse_mode == "html": if parse_mode == "html":
lines.append(f'- {html.escape(media_type)}: <a href="{html.escape(url, quote=True)}">ссылка</a>') lines.append(f'- <a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>')
else: else:
lines.append(f"- {media_type}: {url}") lines.append(f"- {label}: {url}")
if not lines: if not lines:
return "" return ""
header = "Не удалось прикрепить медиа, оригинал:" if vk_url:
if parse_mode == "html":
lines.append(f'- <a href="{html.escape(vk_url, quote=True)}">Открыть пост в VK</a>')
else:
lines.append(f"- пост в VK: {vk_url}")
header = "Не поместилось в пост:"
if parse_mode == "html": if parse_mode == "html":
header = f"<i>{html.escape(header)}</i>" header = f"<i>{html.escape(header)}</i>"
return header + "\n" + "\n".join(lines) return header + "\n" + "\n".join(lines)
+5 -2
View File
@@ -393,11 +393,14 @@ class TelegramPoster:
chunks = split_message_chunks(text, self.message_limit) chunks = split_message_chunks(text, self.message_limit)
mids: list[int] = [] mids: list[int] = []
for chunk in chunks: 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( msg = await self.tg_retry(
lambda c=chunk: self.bot.send_message( lambda c=chunk: self.bot.send_message(
text=c, text=c,
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=False,
**self.chat_kwargs(chat_id, thread_id), **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] 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] 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: if note:
formatted_text = f"{formatted_text}\n\n{note}" if formatted_text else note formatted_text = f"{formatted_text}\n\n{note}" if formatted_text else note
+8
View File
@@ -45,6 +45,7 @@ class VKMediaItem:
height: Optional[int] = None height: Optional[int] = None
duration_sec: Optional[int] = None duration_sec: Optional[int] = None
title: Optional[str] = None title: Optional[str] = None
thumbnail_url: Optional[str] = None
@dataclass @dataclass
@@ -189,6 +190,12 @@ class VKClient:
video_url = f"https://vk.com/video{owner_id}_{video_id}" video_url = f"https://vk.com/video{owner_id}_{video_id}"
if access_key: if access_key:
video_url += f"_{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( items.append(
VKMediaItem( VKMediaItem(
media_type="video", media_type="video",
@@ -198,6 +205,7 @@ class VKClient:
height=video.get("height"), height=video.get("height"),
duration_sec=video.get("duration"), duration_sec=video.get("duration"),
title=video.get("title"), title=video.get("title"),
thumbnail_url=thumbnail_url,
) )
) )
elif att_type == "link": elif att_type == "link":