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) :158967438326280d(duration) : Durée de chaque segment en ticks :288000r(repeat) : Nombre de fois que ce motif se répète :4501timescale: 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
20000requêtes à la fois pour l’efficacité du threading/mémoire - S’arrête dès qu’un tick valide est trouvé
- Utilise
asynciopour 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ésolution | FPS | Codec | Débit |
|---|---|---|---|
384×216 | 25 | avc1.64000d | 400 kbps |
640×360 | 25 | avc1.64001e | 800 kbps |
896×504 | 25 | avc1.64001f | 1,6 Mbps |
1280×720 | 25 | avc1.64001f | 3,0 Mbps |
896×504 | 50 | hvc1.1.2.L93.90 | 1,6 Mbps |
1920×1080 | 50 | hvc1.1.2.L123.90 | 4,8 Mbps |
1920×1080 | 50 | hvc1.1.2.L123.90 | 14,8 Mbps |
Pistes audio :
| Langue | Rôle | Codec | Débit |
|---|---|---|---|
fra | main | mp4a.40.2 (AAC-LC) | 64 kbps |
und | main | mp4a.40.2 (AAC-LC) | 64 kbps |
fra | description | mp4a.40.2 (AAC-LC) | 64 kbps |
Pistes de sous-titres :
| Langue | Rôle | Codec | Format |
|---|---|---|---|
fra | caption | stpp | TTML dans MP4 |
fra | subtitle | stpp | TTML 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 Rep | Résolution | FPS/Rôle | Codec | Débit | Date la plus ancienne | Tick le plus ancien |
|---|---|---|---|---|---|---|
376 | fra | main | mp4a.40.2 (AAC-LC) | 64 kbps | 2020-09-18 | 144038412002096 |
377 | und | main | mp4a.40.2 (AAC-LC) | 64 kbps | 2020-09-18 | 144038412002096 |
379 | 384×216 | 25 | avc1.64000d | 400 kbps | 2020-09-18 | 144038412021420 |
380 | 640×360 | 25 | avc1.64001e | 800 kbps | 2020-09-18 | 144038412021420 |
381 | 896×504 | 25 | avc1.64001f | 1,6 Mbps | 2020-09-18 | 144038412021420 |
382 | 1280×720 | 25 | avc1.64001f | 3,0 Mbps | 2020-09-18 | 144038412021420 |
463 | fra | description | mp4a.40.2 (AAC-LC) | 64 kbps | 2020-09-18 | 144038412002096 |
6658 | 896×504 | 50 | hvc1.1.2.L93.90 | 1,6 Mbps | 2025-12-02 | 158820588030870 |
6659 | 1920×1080 | 50 | hvc1.1.2.L123.90 | 4,8 Mbps | 2025-12-02 | 158820588030870 |
6661 | 1920×1080 | 50 | hvc1.1.2.L123.90 | 14,8 Mbps | 2025-12-02 | 158820588030870 |
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 :
- Finale de Koh-Lanta (4 décembre 2020)

- Finale de Koh-Lanta (4 juin 2021)

- Finale de The Voice (21 mai 2022)

- Complément d’enquête (December 14, 2022)

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 :)