ProcessedMedia.width/height for downloaded videos were always VK's original
source dimensions (e.g. 3840x2160 for a 4K upload), passed straight through
to Telegram's sendVideo width/height params - even though the actual file
sent is capped to video_max_height (e.g. 720p) by _select_format. A client
declaring 4K but receiving a 720p stream is a known cause of black-screen
playback on strict mobile decoders, which can allocate the render surface
from the declared size before the real stream is probed - matches the
reported symptom exactly (fine on desktop and in PiP, black in Telegram
mobile's fullscreen decoder path).
Neither the faststart remux nor the negative-DTS fix addressed this, since
both were about the file's own internal structure, not the metadata
describing it externally.
Adds _probe_video_dims (ffprobe on the actual downloaded/remuxed file) and
uses its real width/height/duration instead of VK's pre-download values.
Verified on the actual failing video: now reports the real 1280x720 instead
of VK's original 3840x2160.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The faststart fix (f8842f8) handled the moov-atom position but missed a
second issue in the same merged video+audio stream: the video track's first
packet has a slightly negative DTS (from B-frame reordering across the
merge), confirmed via ffprobe on the actual failing video (-0.083322s).
Reported symptom after the faststart fix was already live: preview thumbnail
fine, black screen on opening fullscreen on Telegram's mobile app, but
picture visible in PiP mode - consistent with the hardware-accelerated
fullscreen decoder path choking on it while PiP's path (apparently software)
tolerates it. Desktop played it fine regardless.
Adds -avoid_negative_ts make_zero to the same lossless -c copy remux pass
(no extra cost - it's the same command). Verified on the actual video:
first packet DTS goes from -0.083322 to -0.000322 (down to B-frame rounding
noise, not a real negative start anymore).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause of a real report (Russia Airsoft Awards post): none of the five
send_video/InputMediaVideo call sites in tg_poster.py set
supports_streaming=True. Without it, Telegram doesn't prepare the upload
for progressive/inline playback - showing "00:00" duration with a plain
download prompt on desktop, and, when it does play on mobile, audio with a
black video (same underlying cause as the faststart fix, but that only
handles the local moov-atom position; Telegram's own server-side handling
of the upload needs the flag regardless).
The real post went through the rich-message/file_id path
(upload_media_for_file_ids -> sendRichMessage): a file_id bakes in whatever
attributes the video was uploaded with, so fixing that call site fixes
every future post reusing the id. Also pass width/height/duration
explicitly everywhere instead of relying on Telegram's own probing, and
carry those through the plain-caption/media-group fallback paths for
consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build_oversized_video_note linked to the video's own VK page (with an
access_key suffix), which 404s for viewers without a VK session - reported
as "такой страницы нет" on a real post. Point at the wall post instead
(vk_url), same as build_media_unavailable_note already does, since that
always resolves.
Also dropped "Видео слишком большое для платформы" from the visible text -
subscribers don't need to know MAX has a 250MB cap, that's an internal
implementation detail, not something worth surfacing in a public post.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause of a real report (post #343474, video-36860851_456244054):
_select_format's video-only+bestaudio path merges VK's separate DASH video
and audio streams via ffmpeg, which leaves the moov atom at the end of the
file by default (confirmed: mdat at offset 44, moov near EOF). Streaming
players - Telegram's mobile clients in particular - can play the audio
track immediately but can't render video without seeking to the index
first, showing black video with working audio until the whole file has
downloaded.
Adds a fast, lossless -c copy -movflags +faststart remux after every video
download (cheap - just repositions the atom, no re-encode) so the moov atom
is always at the front regardless of which yt-dlp code path produced the
file. Falls back to the original file if the remux fails for any reason,
rather than dropping the video. Verified on the actual failing video:
moov now at offset 36 instead of past 2MB in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces both prior fixes for the oversized-video bug (a blind long retry
wait, then an ffmpeg re-encode pass) with the actual root-cause fix: the old
"-f" selector's [filesize<X] filter only checks the literal filesize field,
which VK's progressive "urlNNN" links never report - so the filter silently
never matched and yt-dlp fell back to picking a format with no known size at
all, occasionally a not-yet-transcoded master far above the height cap.
Now format metadata is fetched up front (yt-dlp's own extract_info, no
download) and the best-quality format at/under the height cap whose size is
actually known (filesize or tbr * duration) and fits the limit is selected
explicitly before a single byte is downloaded. No guessing at VK's
transcoding timing, no re-encoding, no quality loss, no wasted bandwidth on
oversized pulls. Verified against a real 33-minute post that previously
failed: now resolves straight to a 720p/183MB format and downloads exactly
that.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts the guessed long-retry-budget approach (no documented VK transcoding
SLA exists to tune it against, and it would've blocked other routes in the
same poll cycle for minutes). Instead, when a download comes out over the
platform size limit - whether genuinely oversized or VK having briefly
served an unfinished-transcode master on a height-capped URL - re-encode it
locally with ffmpeg to a bitrate computed from its own real duration
(ffprobe), so it fits deterministically rather than by retrying and hoping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed on a real post: VK can still be transcoding lower renditions 8+
minutes after publish, so a few seconds of backoff wasn't enough margin.
Size-exceeded failures now get their own retry budget (5 attempts, 90s
apart - up to 7.5 extra minutes) instead of sharing the short 3s backoff
meant for stalls/timeouts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VK can briefly serve a not-yet-transcoded master stream on a capped-height
URL right after a video is published, tripping the size check on the very
first attempt. Treat that as transient (retry with a longer backoff) instead
of failing permanently, and merge the fallback note into a single "Смотреть
видео в VK" link instead of two separate lines.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
New STALE_DONOR_ALERT_DAYS (.env, global threshold, 0=off) + per-route
stale_alert_enabled/stale_alert_ids (routes.json). Checked once per route
per cycle against MAX(posted_at) in the DB - self-resetting via a single
route_alerts.stale_alert_sent_at timestamp compared against the last post
time, so it fires once per quiet spell and re-arms automatically once the
donor posts again, no separate ack/clear step needed.
If a configured stale_alert_ids recipient can't be reached (never started
a chat with the bot), the global TG_ADMIN_IDS get a separate notice about
that delivery failure instead of the alert silently vanishing.
Error text and VK group names were inserted into admin Telegram reports
unescaped under parse_mode=HTML - any '<'/'>'/'&' in an exception message
(common) made Telegram reject the whole message with "can't parse
entities", silently swallowed as a warning log. Admins got nothing.
Escape everything user/exception-controlled before embedding.
Also adds a minimal /id command (long-polling, shares the existing bot
session) so people can DM the bot and get their Telegram ID in a
copy-paste-ready format, for configuring per-route notification recipients.
Two call sites in send_media_post() (the >1-media-item send_media_group
branches) were missed when chat_kwargs() was parameterized for multi-route
support - broke TG posting for any post with more than one photo/video.
Posts can now be sourced from multiple VK groups, each routed to its own
Telegram/MAX destination(s) with independent on/off switches, configured
via data/routes.json (supports // line comments). Falls back to a single
route auto-generated from the legacy VK_SOURCE/TG_CHAT_ID/MAX_CHAT_ID env
vars if routes.json doesn't exist yet, so existing deployments keep working.
Routes are processed strictly sequentially within a cycle (no concurrency)
to keep flood control on VK/TG/MAX correct, since bot tokens are shared
across routes. DB schema gains route_id in the posts uniqueness key so the
same VK donor can safely feed multiple routes without status collisions.
Also removes the trailing-hashtag-stripping logic in text_formatter, which
was silently deleting VK posts' own hashtags whenever COMMON_TAGS wasn't
configured (it always wasn't) - posts are now forwarded unchanged.
- Stop blindly retrying send_photo/send_video/send_media_group and MAX
send_message on ambiguous network timeouts - a timeout doesn't prove
the message wasn't delivered, and retrying risked posting duplicates
(observed live: a video posted 3-5x after repeated timeout retries).
- Raise/rework timeouts that were too short for real large-file transfer
speeds: TG media upload timeout, and yt-dlp download now uses stall
detection (killed only on true silence) instead of a flat ceiling that
was cutting off legitimately slow-but-successful video downloads.
- Fix sendRichMessage: ok=true with an unparseable message_id no longer
triggers a fallback send (Telegram already created the message).
- MAX: videos over the documented 250MB cap are now sent as a "watch via
link" note instead of silently failing the upload.
- Add PRAGMA busy_timeout to all DB connections.
- Remove unused MAX_MEDIA_CHANNEL_ID (MAX's /uploads returns a portable
token directly, no staging channel needed, unlike Telegram).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MAX Bot API has no reaction endpoint (confirmed 404 in production logs),
so drop the dead code path entirely instead of eating a warning every post.
Telegram's setMessageReaction is a real Bot API method (verified against
aiogram 3.21.0, the pinned version) - wire it up as a non-fatal
best-effort call after a successful send, mirroring the same
try/except-and-log pattern MAX already used.
- Stop re-sending to a platform that already succeeded when the other
platform failed (was causing duplicate posts on partial failure)
- Escape VK post text before sending with parse_mode=HTML instead of
only escaping matched bracket-link substrings (bare & < > broke sends)
- Restore Telegram sendRichMessage (Bot API 10.1) ported faithfully from
new_vk_parser's proven implementation, with fallback to standard
send_photo/send_video/send_media_group
- Restore "media unavailable" note when a video/photo fails to download
instead of silently dropping it
- Handle YouTube link attachments from VK posts (route into yt-dlp)
- MAX: chunk media beyond 10 items into follow-up messages instead of
dropping them
- yt-dlp format selector no longer falls back to unconstrained height
- Wire up VK_RATE_LIMIT_RPS, drop unused Pillow dep and dead config fields