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:
@@ -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 = ""
|
||||
|
||||
+13
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-4
@@ -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)}: <a href="{html.escape(url, quote=True)}">ссылка</a>')
|
||||
lines.append(f'- <a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>')
|
||||
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'- <a href="{html.escape(vk_url, quote=True)}">Открыть пост в VK</a>')
|
||||
else:
|
||||
lines.append(f"- пост в VK: {vk_url}")
|
||||
header = "Не поместилось в пост:"
|
||||
if parse_mode == "html":
|
||||
header = f"<i>{html.escape(header)}</i>"
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user