Restore official geo-blocked RAI streams alongside the CloudFront mirror

Fixes #1179.

PR #1166 moved Rai 1, Rai 2, Rai 3, Rai Premium, Rai Storia and Rai
Scuola from the official mediapolis.rai.it relinker to a third-party
CloudFront mirror, because the relinker had become Italy-IP-gated and
was failing for everyone else (including the CI checker). That mirror
works for casual viewing, but as #1179 reports, it goes black during
broadcasts where RAI only holds domestic rights (e.g. EuroVolley) -
the official stream keeps working correctly for Italian-IP viewers
through exactly those broadcasts, because it's RAI's own domestic
feed, not a re-mirror of it.

Rather than trade one gap for the other, add the official mediapolis
URL back as a second, Ⓖ-marked entry for each of the six channels -
the same "CDN mirror as the general-access entry, official relinker
as the geo-gated alternative" split this file already uses for every
other Rai channel (Rai 4, Rai 5, Rai Movie, Rai Sport, etc., all still
on mediapolis.rai.it + Ⓖ). Viewers with an Italian IP get the more
correct source; everyone else keeps the mirror that already works for
them.

Also fixes a real gap in check_channels.py found while verifying this:
mediapolis.rai.it's relinker doesn't refuse an out-of-territory request
with an HTTP error - it answers 200 with a real MP4, but the file is
always the same "video_no_available.mp4" decoy regardless of which
channel was asked for. The checker had no way to tell that apart from
a working stream failing its playlist-header check, so every
mediapolis.rai.it entry (the pre-existing Rai 4/5/Movie/Sport ones
included) was scored `dead` instead of `blocked`, right down to the
newly-fixed pr_check.yml workflow that now hard-fails a PR over a
`dead`-scored touched channel. Added a check for this specific decoy
redirect target, classified as `blocked` like any other geo-refusal.

Regenerated playlist.m3u8 and playlists/playlist_italy.m3u8 via
make_playlist.py. pylint check_channels.py: 10.00/10.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Enus8N247jHM5r5Som5JuU
This commit is contained in:
Kálmán „KAMI” Szalai
2026-09-06 21:09:12 +02:00
co-authored by Claude Sonnet 5
parent d9bb826b0b
commit 72b7c7e076
4 changed files with 55 additions and 3 deletions
+25 -3
View File
@@ -135,6 +135,29 @@ def looks_like_a_raw_stream(content_type):
return content_type.lower().startswith(STREAM_CONTENT_TYPES)
# some geo-gated broadcasters (RAI's relinker is the known case) answer an
# out-of-territory request with a plain HTTP 200 and a real video file - not an
# error, a decoy: a short "this content is not available in your country" clip.
# A player never notices anything is wrong; our probe would call it alive. The
# redirect chain gives it away: it always lands on the same well-known asset,
# regardless of which channel was asked for.
GEOBLOCK_PLACEHOLDER_MARKERS = ("video_no_available.mp4",)
def is_geoblock_placeholder(final_url):
"""Return True if `final_url` is a known geo-block decoy, not a real stream."""
return any(marker in final_url for marker in GEOBLOCK_PLACEHOLDER_MARKERS)
def classify_response(final_url, content_type, head):
"""Turn a completed HTTP response into an outcome: OK, REFUSED, or GONE."""
if is_geoblock_placeholder(final_url):
return REFUSED
if looks_like_a_raw_stream(content_type):
return OK
return OK if looks_like_a_playlist(head) else GONE
def probe(url, timeout):
"""Open `url` once and report what the server did."""
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
@@ -144,6 +167,7 @@ def probe(url, timeout):
context.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
final_url = response.geturl()
content_type = response.headers.get("Content-Type", "")
head = response.read(3000).decode("utf-8", "ignore")
except urllib.error.HTTPError as error:
@@ -157,9 +181,7 @@ def probe(url, timeout):
# response and then hung up mid-chunk (IncompleteRead) and similar
# low-level protocol violations - a broken connection, not a bad URL
return UNREACHABLE
if looks_like_a_raw_stream(content_type):
return OK
return OK if looks_like_a_playlist(head) else GONE
return classify_response(final_url, content_type, head)
def ffprobe_finds_a_stream(url):