Files
pmomusic/tools/generate_radiofrance_stations.py
Eric Coissac 898db9a354 Refactor station data management: externalize to generated file + improve metadata handling
- Replace hardcoded station constants with generated data from tools/generate_radiofrance_stations.py
- Update discover_local_radios to use static STATION_IDS instead of scraping
- Support both direct URLs and Pikapi UUIDs in visual_background handling
- Improve cover caching logic to handle mixed URL types (S3 vs Pikapi)
- Only notify subscribers on actual track changes in metadata cache
- Fix playlist browse to return items for single-station playlists
- Add 73 stations (including all France Bleu locals and webradios) via GraphQL API
- Graceful fallback in playlist DIDL generation when metadata is unavailable
2026-03-25 20:18:20 +01:00

317 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Génère les constantes Rust pour pmoradiofrance depuis l'API GraphQL Radio France.
Usage:
python3 generate_radiofrance_stations.py --token <votre-token>
python3 generate_radiofrance_stations.py # utilise RADIOFRANCE_TOKEN env var
Token de secours (token public trouvé dans AlexandrePradeilles/ECI, actif depuis 2022) :
36bee04f-68a9-4bf8-8f2c-0662b454192c
Le script interroge l'API GraphQL officielle et génère le code Rust pour :
- KNOWN_MAIN_STATIONS : stations principales (slug, nom officiel)
- STATION_IDS : slug → ID numérique livemeta/pull
- STATION_STREAMS : slug → URL HiFi AAC Icecast
"""
import argparse
import os
import sys
import json
import urllib.request
import urllib.parse
import re
GRAPHQL_URL = "https://openapi.radiofrance.fr/v1/graphql"
# Token de secours — trouvé dans le dépôt public AlexandrePradeilles/ECI (oct. 2022),
# toujours actif. À remplacer par ton propre token si révoqué.
FALLBACK_TOKEN = "36bee04f-68a9-4bf8-8f2c-0662b454192c"
QUERY = """
{
brands {
id
title
playerUrl
liveStream
webRadios {
id
title
playerUrl
liveStream
}
localRadios {
id
title
playerUrl
liveStream
}
}
}
"""
# Slugs manuels pour les cas où la dérivation automatique ne correspond pas
# à la convention déjà établie dans le code Rust.
# Format : GraphQL_ID → slug_rust
SLUG_OVERRIDES = {
# LocalRadios dont l'ID GraphQL manque le préfixe francebleu_
"ELSASS": "francebleu_elsass",
# Stations dont le slug GraphQL diffère du slug Rust historique
"FRANCEBLEU_SUR_LORRAINE": "francebleu_lorraine",
"FRANCEBLEU_NORMANDIE_CAEN": "francebleu_normandie",
"FRANCEBLEU_NORMANDIE_ROUEN": "francebleu_normandie_seine_maritime",
"FRANCEBLEU_TOULOUSE": "francebleu_occitanie",
}
# Slugs qui ne correspondent pas à la conversion lowercase de l'ID GraphQL
SLUG_OVERRIDES.update({
"FIP_HIP_HOP": "fip_hiphop",
})
# Mouv' webradios : pas de liveStream donc inutilisables, on les exclut
EXCLUDED_WEBRADIO_IDS = {
"MOUV_100MIX",
"MOUV_CLASSICS",
"MOUV_DANCEHALL",
"MOUV_RNB",
"MOUV_RAPUS",
"MOUV_RAPFR",
}
# Brands à exclure en tant que station principale (pas de stream au niveau brand)
# mais dont on garde les webRadios et localRadios
EXCLUDED_BRAND_STREAM_IDS = {"FRANCEBLEU"}
def graphql_id_to_slug(graphql_id: str, is_local_radio: bool = False) -> str:
"""Convertit un ID GraphQL en slug Rust."""
if graphql_id in SLUG_OVERRIDES:
return SLUG_OVERRIDES[graphql_id]
slug = graphql_id.lower()
# LocalRadios sous FRANCEBLEU qui n'ont pas le préfixe
if is_local_radio and not slug.startswith("francebleu_"):
slug = "francebleu_" + slug
return slug
def midfi_to_hifi(stream_url: str) -> str | None:
"""Convertit une URL midfi MP3 en URL HiFi AAC."""
if not stream_url:
return None
# https://icecast.radiofrance.fr/fiprock-midfi.mp3?id=openapi
# → https://icecast.radiofrance.fr/fiprock-hifi.aac
url = re.sub(r"-midfi\.mp3\?.*$", "-hifi.aac", stream_url)
if url == stream_url:
return None # pattern non reconnu
return url
def extract_livemeta_id(player_url: str | None) -> int | None:
"""Extrait l'ID numérique depuis l'URL du player embed."""
if not player_url:
return None
m = re.search(r"id_station=(\d+)", player_url)
return int(m.group(1)) if m else None
def query_api(token: str) -> dict:
url = f"{GRAPHQL_URL}?x-token={token}"
payload = json.dumps({"query": QUERY}).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def collect_stations(data: dict) -> list[dict]:
"""
Retourne une liste plate de stations :
{ slug, name, livemeta_id, hifi_url, kind }
kind ∈ { "brand", "webradio", "local" }
"""
stations = []
for brand in data["data"]["brands"]:
brand_id = brand["id"]
# Station principale — seulement si elle a un stream au niveau brand
if brand_id not in EXCLUDED_BRAND_STREAM_IDS:
livemeta_id = extract_livemeta_id(brand.get("playerUrl"))
hifi_url = midfi_to_hifi(brand.get("liveStream") or "")
if not hifi_url:
slug = graphql_id_to_slug(brand_id)
hifi_url = f"https://icecast.radiofrance.fr/{slug}-hifi.aac"
stations.append({
"slug": graphql_id_to_slug(brand_id),
"name": brand["title"],
"livemeta_id": livemeta_id,
"hifi_url": hifi_url,
"kind": "brand",
})
# Webradios
for wr in brand.get("webRadios") or []:
if wr["id"] in EXCLUDED_WEBRADIO_IDS:
continue
live = wr.get("liveStream")
hifi = midfi_to_hifi(live or "")
if not hifi:
continue # pas de stream utilisable
stations.append({
"slug": graphql_id_to_slug(wr["id"]),
"name": wr["title"],
"livemeta_id": extract_livemeta_id(wr.get("playerUrl")),
"hifi_url": hifi,
"kind": "webradio",
})
# Radios locales (ICI / France Bleu)
for lr in brand.get("localRadios") or []:
live = lr.get("liveStream")
hifi = midfi_to_hifi(live or "")
if not hifi:
continue
stations.append({
"slug": graphql_id_to_slug(lr["id"], is_local_radio=True),
"name": lr["title"],
"livemeta_id": extract_livemeta_id(lr.get("playerUrl")),
"hifi_url": hifi,
"kind": "local",
})
return stations
def rust_escape(s: str) -> str:
return s.replace('"', '\\"').replace("'", "\\'")
def generate_rust(stations: list[dict], token_origin: str = "inconnu") -> str:
brands = [s for s in stations if s["kind"] == "brand"]
webradios = [s for s in stations if s["kind"] == "webradio"]
locals_ = [s for s in stations if s["kind"] == "local"]
from datetime import date
lines = []
lines.append("// ============================================================")
lines.append("// FICHIER GÉNÉRÉ AUTOMATIQUEMENT — NE PAS MODIFIER À LA MAIN")
lines.append("//")
lines.append(f"// Généré par : tools/generate_radiofrance_stations.py")
lines.append(f"// Date : {date.today().isoformat()}")
lines.append(f"// Source : API GraphQL Radio France (openapi.radiofrance.fr)")
lines.append(f"// Token : {token_origin}")
lines.append(f"// Stations : {len(stations)}")
lines.append("//")
lines.append("// Pour mettre à jour :")
lines.append("// python3 tools/generate_radiofrance_stations.py")
lines.append("// ============================================================")
lines.append("")
# KNOWN_MAIN_STATIONS
lines.append("pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[")
for s in brands:
lines.append(f' ("{s["slug"]}", "{rust_escape(s["name"])}"),')
lines.append("];")
lines.append("")
# STATION_IDS
lines.append("/// Mapping slug → ID numérique livemeta/pull API")
lines.append("pub const STATION_IDS: &[(&str, u32)] = &[")
lines.append(" // Stations principales")
for s in brands:
if s["livemeta_id"] is not None:
lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}),')
lines.append(" // Webradios")
for s in webradios:
if s["livemeta_id"] is not None:
lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}), // {rust_escape(s["name"])}')
lines.append(" // Radios ICI (France Bleu locales)")
for s in sorted(locals_, key=lambda x: x["livemeta_id"] or 0):
if s["livemeta_id"] is not None:
lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}), // {rust_escape(s["name"])}')
lines.append("];")
lines.append("")
# STATION_STREAMS
lines.append("/// URLs HiFi AAC Icecast (dérivées des URLs midfi de l'API GraphQL)")
lines.append("pub const STATION_STREAMS: &[(&str, &str)] = &[")
lines.append(" // Stations principales")
for s in brands:
lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"),')
lines.append(" // Webradios")
for s in webradios:
lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"), // {rust_escape(s["name"])}')
lines.append(" // Radios ICI (France Bleu locales)")
for s in sorted(locals_, key=lambda x: x["livemeta_id"] or 0):
lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"), // {rust_escape(s["name"])}')
lines.append("];")
return "\n".join(lines)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_OUTPUT = os.path.join(SCRIPT_DIR, "..", "pmoradiofrance", "src", "stations_data.rs")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--token", help="Token API Radio France (ou variable RADIOFRANCE_TOKEN)")
parser.add_argument(
"--output", "-o",
default=DEFAULT_OUTPUT,
help=f"Fichier Rust de sortie (défaut : {DEFAULT_OUTPUT})",
)
args = parser.parse_args()
token = args.token or os.environ.get("RADIOFRANCE_TOKEN") or FALLBACK_TOKEN
if token == FALLBACK_TOKEN:
token_origin = "token de secours (AlexandrePradeilles/ECI, 2022)"
print("Info : utilisation du token de secours (AlexandrePradeilles/ECI).", file=sys.stderr)
elif os.environ.get("RADIOFRANCE_TOKEN"):
token_origin = "variable d'environnement RADIOFRANCE_TOKEN"
else:
token_origin = "argument --token"
print("Interrogation de l'API GraphQL Radio France...", file=sys.stderr)
try:
data = query_api(token)
except Exception as e:
print(f"Erreur API : {e}", file=sys.stderr)
sys.exit(1)
if "errors" in data:
print(f"Erreurs GraphQL : {data['errors']}", file=sys.stderr)
sys.exit(1)
stations = collect_stations(data)
print(f"{len(stations)} stations collectées.", file=sys.stderr)
rust_code = generate_rust(stations, token_origin=token_origin)
output_path = os.path.realpath(args.output)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rust_code + "\n")
print(f"Fichier écrit : {output_path}", file=sys.stderr)
if __name__ == "__main__":
main()