2025-10-25 22:39:09 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2025-10-25 23:14:07 +02:00
|
|
|
|
Multi-Country Radio M3U Playlist Generator (resilient + dynamic mirrors)
|
|
|
|
|
|
-----------------------------------------------------------------------
|
2025-10-25 23:01:41 +02:00
|
|
|
|
Generuje M3U playlist s radii z více zemí pomocí radio-browser.info API.
|
2025-10-25 23:14:07 +02:00
|
|
|
|
Automaticky detekuje dostupné servery přes DNS a přepíná mezi nimi.
|
2025-10-27 21:26:36 +01:00
|
|
|
|
Podporuje --tvh-compatible formát pro Tvheadend.
|
2025-10-25 22:39:09 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import os
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import time
|
|
|
|
|
|
import random
|
|
|
|
|
|
import requests
|
2025-10-25 23:14:07 +02:00
|
|
|
|
import socket
|
2025-10-25 22:39:09 +02:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pyradios import RadioBrowser
|
|
|
|
|
|
|
|
|
|
|
|
class RateLimitError(Exception):
|
|
|
|
|
|
"""Exception raised when rate limit is hit"""
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2025-10-25 23:14:07 +02:00
|
|
|
|
# === Funkce pro detekci aktuálních mirrorů ===
|
|
|
|
|
|
def get_dynamic_mirrors():
|
|
|
|
|
|
"""Získá aktuální dostupné RadioBrowser servery z DNS"""
|
|
|
|
|
|
hosts = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
infos = socket.getaddrinfo('all.api.radio-browser.info', 80, 0, 0, socket.IPPROTO_TCP)
|
|
|
|
|
|
for info in infos:
|
|
|
|
|
|
ip = info[4][0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
host = socket.gethostbyaddr(ip)[0]
|
|
|
|
|
|
if host not in hosts:
|
|
|
|
|
|
hosts.append(host)
|
|
|
|
|
|
except socket.herror:
|
|
|
|
|
|
continue
|
|
|
|
|
|
hosts.sort()
|
|
|
|
|
|
mirrors = [f"https://{h}" for h in hosts]
|
|
|
|
|
|
if mirrors:
|
|
|
|
|
|
print(f"🌍 Načteno {len(mirrors)} mirrorů z DNS:")
|
|
|
|
|
|
for m in mirrors:
|
|
|
|
|
|
print(" •", m)
|
|
|
|
|
|
return mirrors
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print("⚠️ Nepodařilo se zjistit servery přes DNS:", e)
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
# === Inicializace RadioBrowseru s fallbacky ===
|
2025-10-25 23:01:41 +02:00
|
|
|
|
def init_radio_browser():
|
2025-10-25 23:14:07 +02:00
|
|
|
|
mirrors = get_dynamic_mirrors()
|
|
|
|
|
|
|
|
|
|
|
|
if not mirrors:
|
|
|
|
|
|
mirrors = [
|
|
|
|
|
|
"https://de1.api.radio-browser.info",
|
|
|
|
|
|
"https://de2.api.radio-browser.info",
|
|
|
|
|
|
"https://fi1.api.radio-browser.info",
|
|
|
|
|
|
"https://nl1.api.radio-browser.info",
|
|
|
|
|
|
"https://at1.api.radio-browser.info",
|
|
|
|
|
|
]
|
|
|
|
|
|
print("⚠️ Používám fallback seznam mirrorů.")
|
|
|
|
|
|
|
|
|
|
|
|
random.shuffle(mirrors)
|
|
|
|
|
|
|
2025-10-25 23:01:41 +02:00
|
|
|
|
for mirror in mirrors:
|
|
|
|
|
|
try:
|
|
|
|
|
|
rb = RadioBrowser(base_url=mirror)
|
|
|
|
|
|
rb.countries() # test funkčnosti
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"✅ Používám RadioBrowser mirror: {mirror}")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
return rb
|
|
|
|
|
|
except Exception as e:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⚠️ Mirror {mirror} selhal: {e}")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
2025-10-25 23:14:07 +02:00
|
|
|
|
raise RuntimeError("❌ Žádný funkční RadioBrowser mirror nebyl nalezen.")
|
|
|
|
|
|
|
|
|
|
|
|
# === Pomocné funkce ===
|
2025-10-25 22:39:09 +02:00
|
|
|
|
def get_workspace_safe_path(output_file):
|
|
|
|
|
|
if not output_file:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if os.path.isabs(output_file):
|
|
|
|
|
|
return output_file
|
|
|
|
|
|
workspace = os.environ.get('GITHUB_WORKSPACE')
|
|
|
|
|
|
if workspace and os.path.exists(workspace):
|
|
|
|
|
|
return os.path.join(workspace, output_file)
|
|
|
|
|
|
return os.path.abspath(output_file)
|
|
|
|
|
|
|
|
|
|
|
|
def generate_safe_output_path(output_file, country_codes):
|
|
|
|
|
|
if not output_file:
|
|
|
|
|
|
country_string = "_".join(country_codes[:5]).upper()
|
|
|
|
|
|
if len(country_codes) > 5:
|
|
|
|
|
|
country_string += "_etc"
|
|
|
|
|
|
output_file = f"radio_playlist_{country_string}_{datetime.now().strftime('%Y%m%d')}.m3u"
|
|
|
|
|
|
safe_path = get_workspace_safe_path(output_file)
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"📄 Výstupní soubor: {safe_path}")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
return safe_path
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_stations_with_retry(rb, country_code, max_retries=5, initial_backoff=10):
|
|
|
|
|
|
retry_count = 0
|
|
|
|
|
|
backoff_time = initial_backoff
|
|
|
|
|
|
while retry_count < max_retries:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return rb.stations_by_countrycode(country_code)
|
|
|
|
|
|
except requests.exceptions.HTTPError as e:
|
2025-10-25 23:01:41 +02:00
|
|
|
|
if e.response.status_code == 429:
|
2025-10-25 22:39:09 +02:00
|
|
|
|
retry_count += 1
|
2025-10-25 23:01:41 +02:00
|
|
|
|
jitter = random.uniform(0.8, 1.2)
|
2025-10-25 22:39:09 +02:00
|
|
|
|
wait_time = backoff_time * jitter
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⏳ Rate limit – čekám {wait_time:.1f}s (pokus {retry_count}/{max_retries})...")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
time.sleep(wait_time)
|
|
|
|
|
|
backoff_time *= 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise
|
2025-10-25 23:01:41 +02:00
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
|
|
|
|
retry_count += 1
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⚠️ Síťová chyba ({e}). Opakuji za {backoff_time:.1f}s...")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
time.sleep(backoff_time)
|
|
|
|
|
|
backoff_time *= 1.5
|
2025-10-25 23:14:07 +02:00
|
|
|
|
raise RateLimitError(f"❌ Nezdařilo se načíst stanice pro {country_code} po {max_retries} pokusech")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
|
2025-10-25 23:14:07 +02:00
|
|
|
|
# === Hlavní logika ===
|
2025-10-25 23:01:41 +02:00
|
|
|
|
def create_multi_country_playlist(country_codes, output_file=None, group_title="Radio Stations",
|
2025-10-27 21:26:36 +01:00
|
|
|
|
default_logo_url="https://amz.odjezdy.online/xbackbone/VUne9/HilOMeka75.png/raw",
|
|
|
|
|
|
use_country_as_group=False,
|
2025-12-04 13:00:07 +01:00
|
|
|
|
tvh_compatible=False):
|
2025-10-25 23:14:07 +02:00
|
|
|
|
rb = init_radio_browser() # použije autodetekovaný mirror
|
2025-10-25 22:39:09 +02:00
|
|
|
|
output_file = generate_safe_output_path(output_file, country_codes)
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
try:
|
|
|
|
|
|
countries_info = rb.countries()
|
2025-10-25 23:01:41 +02:00
|
|
|
|
country_dict = {c['iso_3166_1']: c['name'] for c in countries_info}
|
|
|
|
|
|
except Exception as e:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"❌ Nepodařilo se načíst seznam zemí: {e}")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
return
|
|
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
invalid_codes = [code.upper() for code in country_codes if code.upper() not in country_dict]
|
|
|
|
|
|
if invalid_codes:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⚠️ Neplatné kódy zemí: {', '.join(invalid_codes)}")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
country_codes = [c for c in country_codes if c.upper() in country_dict]
|
2025-10-25 22:39:09 +02:00
|
|
|
|
if not country_codes:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print("❌ Žádné platné kódy zemí. Ukončuji.")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
sys.exit(1)
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
unique_stations = {}
|
|
|
|
|
|
total_found = 0
|
|
|
|
|
|
failed_countries = []
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
for i, country_code in enumerate(country_codes):
|
|
|
|
|
|
country_code = country_code.upper()
|
2025-10-25 23:01:41 +02:00
|
|
|
|
country_name = country_dict.get(country_code, "Unknown")
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"[{i+1}/{len(country_codes)}] 📻 {country_name} ({country_code})")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
try:
|
|
|
|
|
|
stations = fetch_stations_with_retry(rb, country_code)
|
|
|
|
|
|
valid_stations = [s for s in stations if s.get('url')]
|
|
|
|
|
|
if not valid_stations:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⚠️ Žádné stanice pro {country_name}.")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
continue
|
|
|
|
|
|
total_found += len(valid_stations)
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"✅ {len(valid_stations)} stanic načteno pro {country_name}.")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
for s in valid_stations:
|
|
|
|
|
|
key = f"{s['name'].lower()}_{s['url']}"
|
|
|
|
|
|
if key not in unique_stations:
|
|
|
|
|
|
s['country_code'] = country_code
|
|
|
|
|
|
s['country_name'] = country_name
|
|
|
|
|
|
unique_stations[key] = s
|
2025-10-25 22:39:09 +02:00
|
|
|
|
except RateLimitError as e:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"❌ Rate limit pro {country_name}: {e}")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
failed_countries.append(country_code)
|
|
|
|
|
|
except Exception as e:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"❌ Chyba při načítání {country_name}: {e}")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
failed_countries.append(country_code)
|
|
|
|
|
|
if i < len(country_codes) - 1:
|
|
|
|
|
|
time.sleep(random.uniform(0.5, 1.5))
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"\n📊 Celkem nalezeno {total_found} (unikátních {len(unique_stations)}).")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
if failed_countries:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"⚠️ Neúspěšné země: {', '.join(failed_countries)}")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
f.write("#EXTM3U\n")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
for s in unique_stations.values():
|
|
|
|
|
|
name = s['name'].replace(',', ' ').strip()
|
|
|
|
|
|
logo = s.get('favicon') or default_logo_url
|
|
|
|
|
|
if not logo or logo == "null":
|
|
|
|
|
|
logo = default_logo_url
|
|
|
|
|
|
group = s['country_name'] if use_country_as_group else group_title
|
2025-10-27 21:26:36 +01:00
|
|
|
|
stream_url = s['url']
|
|
|
|
|
|
|
2025-12-04 13:00:07 +01:00
|
|
|
|
# Blok pro formátování URL pro Tvheadend
|
2025-10-27 21:26:36 +01:00
|
|
|
|
if tvh_compatible:
|
|
|
|
|
|
# Připravíme metadata - odstraníme mezery a problematické znaky
|
|
|
|
|
|
service_name = name.replace(" ", "_").replace('"', "'").replace("=", "-").replace(":", "-")
|
2025-12-04 13:00:07 +01:00
|
|
|
|
provider_name = "RadioBrowser" # Jednoduchý string místo URL
|
|
|
|
|
|
|
|
|
|
|
|
# Escapujeme URL pro bezpečné použití v shellu
|
|
|
|
|
|
escaped_url = stream_url.replace('"', '\\"').replace("'", "\\'")
|
2025-10-27 21:26:36 +01:00
|
|
|
|
|
|
|
|
|
|
url_to_write = (
|
2025-12-04 13:00:07 +01:00
|
|
|
|
f'pipe://ffmpeg -loglevel fatal -i "{escaped_url}" -vn -acodec copy -flags +global_header '
|
|
|
|
|
|
f'-strict -2 -metadata service_provider="{provider_name}" '
|
|
|
|
|
|
f'-metadata service_name="{service_name}" -f mpegts -mpegts_service_type digital_radio pipe:1'
|
2025-10-27 21:26:36 +01:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
url_to_write = stream_url
|
|
|
|
|
|
|
2025-10-25 23:01:41 +02:00
|
|
|
|
f.write(f'#EXTINF:-1 group-title="{group}" tvg-logo="{logo}",{s["country_code"]} | {name}\n')
|
2025-12-04 13:00:07 +01:00
|
|
|
|
f.write(f"{url_to_write}\n")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print(f"🎧 Playlist vytvořen: {output_file} ({len(unique_stations)} stanic)")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
return output_file
|
|
|
|
|
|
|
|
|
|
|
|
def parse_country_codes(countries_str):
|
2025-10-25 23:01:41 +02:00
|
|
|
|
return [c.strip() for c in countries_str.split(',') if c.strip()]
|
2025-10-25 22:39:09 +02:00
|
|
|
|
|
|
|
|
|
|
def main():
|
2025-10-25 23:01:41 +02:00
|
|
|
|
parser = argparse.ArgumentParser(description="Generate an M3U playlist for multiple countries")
|
|
|
|
|
|
parser.add_argument("--countries", type=str, help="Comma-separated list (e.g. 'CZ,SK,DE')")
|
|
|
|
|
|
parser.add_argument("country_codes", nargs="*", help="Two-letter country codes")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
parser.add_argument("-o", "--output", help="Output file path")
|
2025-10-25 23:01:41 +02:00
|
|
|
|
parser.add_argument("-g", "--group", default="Radio Stations", help="Group title")
|
2025-12-04 13:00:07 +01:00
|
|
|
|
parser.add_argument("--logo", default="https://amz.odjezdy.online/xbackend/VUne9/HilOMeka75.png/raw",
|
2025-10-25 23:01:41 +02:00
|
|
|
|
help="Default logo URL")
|
|
|
|
|
|
parser.add_argument("--use-country-as-group", "-ucag", action="store_true",
|
|
|
|
|
|
help="Use full country name as group-title")
|
2025-10-27 21:26:36 +01:00
|
|
|
|
parser.add_argument("--tvh-compatible", "-tvh", action="store_true",
|
|
|
|
|
|
help="Generovat M3U kompatibilní s Tvheadend (pipe://ffmpeg)")
|
|
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
args = parser.parse_args()
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
if args.countries:
|
2025-10-25 23:01:41 +02:00
|
|
|
|
codes = parse_country_codes(args.countries)
|
2025-10-25 22:39:09 +02:00
|
|
|
|
elif args.country_codes:
|
2025-10-25 23:01:41 +02:00
|
|
|
|
codes = args.country_codes
|
2025-10-25 22:39:09 +02:00
|
|
|
|
else:
|
|
|
|
|
|
parser.print_help()
|
|
|
|
|
|
sys.exit(1)
|
2025-10-25 23:01:41 +02:00
|
|
|
|
|
2025-10-25 22:39:09 +02:00
|
|
|
|
try:
|
2025-10-27 21:26:36 +01:00
|
|
|
|
create_multi_country_playlist(codes, args.output, args.group, args.logo,
|
|
|
|
|
|
args.use_country_as_group, args.tvh_compatible)
|
2025-10-25 22:39:09 +02:00
|
|
|
|
except KeyboardInterrupt:
|
2025-10-25 23:14:07 +02:00
|
|
|
|
print("\n🛑 Přerušeno uživatelem.")
|
2025-10-25 22:39:09 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2025-10-27 21:26:36 +01:00
|
|
|
|
main()
|