Free TV, previously known as Oqee TV by Free, is now available to everyone at no cost. The platform offers over 170 live TV channels and an extensive VOD library. However, recordings and catch-up TV (replay) remain exclusive to Freebox subscribers, with recordings limited to 100 hours and requiring advance scheduling.

The interesting part, though, is that with some technical exploration, it’s possible to retrieve past content by exploiting how Free serves video using MPEG-DASH manifests. In this post, I’ll demonstrate how to brute-force timestamps to access historical content and explain why this works.

The API dead end

My first instinct was to explore the records API endpoint, perhaps the restrictions were merely UI limitations that could be bypassed with direct API calls.

Let’s test the boundaries with some Python:

import requests
import time

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

# Test 1: Attempting to record content that already aired
start = int(time.time()) - 60  # 1 minute ago
end = start + 60 * 60  # 1 hour duration

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

Response:

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

The API explicitly rejects any recording with a start time in the past..

What about the 4-hour maximum recording duration?

# Test 2: Exceeding maximum recording duration
start = int(time.time())
end = start + 60 * 60 * 5  # 5 hours duration

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

Response:

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

Hard limit confirmed at 4 hours..

The recording margins (the buffer time before and after recording) are also strictly controlled. Interestingly, these margin options no longer appear in the UI, yet the API still validates them:

# Test 3: Exceeding margin limits
start = int(time.time())
end = start + 60 * 60 * 2  # 2 hours duration

margin_before = int(60 * 60 * 1.01)  # 1h 36s (just over 1 hour max)
margin_after = 60 * 60 * 1  # 1 hour

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

Response:

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

Exceeding the margin by even a few seconds triggers validation errors. The margins are capped at one hour each.

API testing revealed several hard constraints:

  • Recordings cannot be scheduled in the past.
  • The maximum recording duration. 4 hours
  • The maximum margin before/after: 1 hour each
  • Server-side validation cannot be bypassed.

As the recording API is a dead end, a different approach is needed. Understanding the underlying streaming protocol is therefore crucial.

Understanding MPEG-DASH manifests

When you request a video stream, the player fetches an MPEG-DASH manifest, an XML file that describes how to access the video segments. Here’s a simplified example:

<?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>

Let’s decode the critical line: <S t="158967438326280" d="288000" r="4501"/>

  • t (time): Starting timestamp in ticks (timescale units): 158967438326280
  • d (duration): Duration of each segment in ticks: 288000
  • r (repeat): Number of times to repeat this pattern: 4501
  • timescale: Ticks per second: 90000

Converting ticks to real time

With a timescale of 90000 ticks per second:

  • Each segment duration: 288000 / 90000 = 3.2 seconds
  • Each tick represents: 1 / 90000 ≈ 0.000011 seconds

The URL template https://media.example.com/video_$Time$ generates actual segment URLs by replacing $Time$ with the tick value:

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

The vulnerability: predictable timing

The critical insight here is that if you know one valid timestamp, you can calculate ANY other timestamp because segments are spaced at fixed, predictable intervals.

Given a known segment URL like https://media.example.com/video_158967438326280:

To access past content:

1 hour ago = base_tick - (3600 seconds × 90000 ticks/sec ÷ 288000 per segment) × 288000
           = base_tick - (1125 × 288000)

To access future content:

3 hours ahead = base_tick + (3 × 1125 × 288000)

The segment spacing is completely deterministic; there is no randomisation or authentication tokens, just arithmetic.

The main obstacle is obtaining the initial valid timestamp. Manifests rotate daily, so accessing content from earlier in the current day is straightforward (you can use the manifest’s current timestamp), but going further back requires knowledge of a historical tick value.

Let’s solve this with a brute-force approach.

Building the time conversion toolkit

First, I created helper functions to convert between ticks and human-readable time:

import datetime

def convert_ticks_to_sec(ticks, timescale):
    """Convert ticks to seconds."""
    return ticks / timescale

def convert_sec_to_ticks(seconds, timescale):
    """Convert seconds to ticks."""
    return seconds * timescale

def convert_sec_to_date(seconds, offset_hours=1):
    """Convert seconds to datetime with UTC offset."""
    dt = datetime.datetime.utcfromtimestamp(seconds) + datetime.timedelta(
        hours=offset_hours
    )
    return dt

def convert_date_to_sec(dt, offset_hours=1):
    """Convert datetime to seconds with UTC offset."""
    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):
    """Convert datetime to ticks with UTC offset."""
    return int(round(convert_date_to_sec(dt, offset_hours) * timescale))

Calculating the nearest segment

Once you have a valid base tick, this function calculates the closest segment to any target time:

def find_nearest_tick_by_hour(base_tick, dt, timescale, duration, offset_hours=1):
    """Find the nearest tick for a given datetime."""
    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:
        # Target is in the future from base
        rep = int(round(abs(rep_estimate)))
        nearest_tick = base_tick + rep * duration
    else:
        # Target is in the past from base
        rep = int(round(rep_estimate))
        nearest_tick = base_tick - rep * duration
    
    return nearest_tick, rep
# Find content from 2 hours ago
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
)
# Result: tick = 158968350998280, rep = 3169
# URL: https://media.example.com/video_158968350998280

The bruteforce approach

But what if you don’t have a valid tick to start with? Here’s where the mathematical guarantee comes in: since segments occur every 288000 ticks, at least one valid segment MUST exist within any 288000-tick window.

This means we can bruteforce our way to a valid timestamp by testing up to 288000 sequential tick values.

Implementing efficient bruteforce

Given the scale of requests needed (potentially 288000, 144000 on average), I used asyncio and aiohttp for concurrent requests instead of synchronous calls:

import aiohttp
import asyncio
from tqdm import tqdm

async def fetch_segment(session, ticks, track_id):
    """Fetch a media segment asynchronously."""
    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):
    """Bruteforce segments to find valid ticks."""
    valid_ticks = []
    total_requests = 288000  # Guaranteed to contain at least one segment
    batch_size = 20000  # Process in batches for progress tracking
    
    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"Found {len(valid_ticks)} valid tick(s), stopping search.")
                break
    
    return valid_ticks

I made few optimizations to the bruteforce process:

  • Processes 20000 requests at a time for threading/memory efficiency
  • It stops as soon as a valid tick is found
  • Uses asyncio to send thousands of requests in parallel
  • Displays real-time progress using tqdm

Running the bruteforce

import asyncio

# Target: December 19, 2025 at 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
))

# Run bruteforce
valid_ticks = asyncio.run(bruteforce("0_1_382", approximate_ticks))
print(f"Valid ticks found: {valid_ticks}")

The output is the following:

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]
Found 1 valid tick(s), stopping search.
Valid ticks found: [158952780040500]

In this example, the bruteforce found a valid tick after checking approximately 60000 values in about 15 seconds, a rate of ~3600 requests per second.

Putting it all together

Once you have a valid tick from bruteforce, you can now access any content in the timeline:

# Get the base tick from bruteforce
base_tick = valid_ticks[0]  # 158952780040500

# Want content from 6 hours earlier?
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"Segment URL: {url}")

Segment URL: https://media.stream.proxad.net/media/0_1_382_158967224104500

Next steps: automation

The next logical step is to automate the entire workflow:

  • Brute-forcing to find a valid tick for the target date
  • Balculating all segment ticks for the desired time range.
  • Download all video and audio segments.
  • Decrypt the segments (as they are DRM-protected).
  • Concatenate the segments to create a playable video file.

To simplify this process, I created OqeeRewind, a tool that automates the entire pipeline. It handles brute forcing, downloading segments, decryption and video assembly, making retrieval straightforward.

Random notes

Available tracks across all channels

Every channel on Free TV offers the same set of tracks with varying quality options for different bandwidth and device capabilities.

Video tracks:

ResolutionFPSCodecBitrate
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

Audio tracks:

LanguageRoleCodecBitrate
framainmp4a.40.2 (AAC-LC)64 kbps
undmainmp4a.40.2 (AAC-LC)64 kbps
fradescriptionmp4a.40.2 (AAC-LC)64 kbps

Subtitle Tracks:

LanguageRoleCodecFormat
fracaptionstppTTML in MP4
frasubtitlestppTTML in MP4

Those doesn’t seem to be filled with any content currently..

How far back does the content go?

To understand the temporal depth of available content, I examined TF1’s manifest (https://api-proxad.oqee.net/playlist/v1/live/612/1/live.mpd) and bruteforced the oldest accessible segments for each track:

Tracks:

Rep IDResolutionFPS/RoleCodecBitrateOldest dateOldest tick
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

It seems that if you want to access content older than a few weeks, you must use the AVC tracks. HEVC tracks won’t work for content before December 2025, and may be erased sooner due to storage constraints.

I was able to retrieve content from TF1 as far back as September 2020 using the AVC tracks.

After some testing I was able to download an HEVC segment from December 14, 2023..

Proof of concept: retrieving years-old content

To validate this approach, I successfully retrieved several high-profile French TV broadcasts from years past:

Go try OqeeRewind and see what hidden gems you can uncover from Free TV’s archives! Feel free to star the repo if you find it useful :)