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":