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
|
# 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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user