Compare commits

...

12 Commits

Author SHA1 Message Date
√(noham)²
e12c6f47a3 Merge pull request #36 from NohamR/dependabot/pip/python-dotenv-1.2.3
Bump python-dotenv from 1.2.2 to 1.2.3
2026-08-23 14:08:49 +02:00
dependabot[bot]
f78bf1d6c0 Bump python-dotenv from 1.2.2 to 1.2.3
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.2 to 1.2.3.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-23 06:52:30 +00:00
√(noham)²
b7efe34ed0 Merge branch 'main' of https://github.com/NohamR/OqeeRewind 2026-08-13 17:40:09 +02:00
√(noham)²
ae5035c6a8 optimize DASH probing and segment download paths
- Switch bruteforce probes from GET to HEAD to avoid downloading
  full segment bodies (only the status code is needed)
- Cap bruteforce concurrency with a semaphore (default 100) instead of issuing one task per tick, and cancel leftover tasks on early exit
- Reuse a single aiohttp session / event loop for the manifest mode's AVC/HEVC/audio bruteforces to keep TCP connections alive
- Hoist the per-request segment headers into a module-level constant
- Use a set for the already-downloaded resume check in save_segments
- Replace read()-into-memory copies with shutil.copyfileobj in   merge_segments and decrypt
2026-08-13 17:40:06 +02:00
√(noham)²
cada374158 Merge pull request #32 from NohamR/dependabot/pip/tqdm-4.70.0
Bump tqdm from 4.69.1 to 4.70.0
2026-08-02 13:52:10 +02:00
√(noham)²
daebae4411 Merge pull request #33 from NohamR/dependabot/pip/prompt-toolkit-3.0.53
Bump prompt-toolkit from 3.0.52 to 3.0.53
2026-08-02 13:52:01 +02:00
√(noham)²
86e370b86d Merge pull request #34 from NohamR/dependabot/uv/tqdm-4.70.0
Bump tqdm from 4.69.1 to 4.70.0
2026-08-02 13:51:52 +02:00
√(noham)²
ee5ba75a50 Merge pull request #35 from NohamR/dependabot/uv/prompt-toolkit-3.0.53
Bump prompt-toolkit from 3.0.52 to 3.0.53
2026-08-02 13:51:40 +02:00
dependabot[bot]
94e7b71bf6 Bump prompt-toolkit from 3.0.52 to 3.0.53
Bumps [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) from 3.0.52 to 3.0.53.
- [Release notes](https://github.com/prompt-toolkit/python-prompt-toolkit/releases)
- [Changelog](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/CHANGELOG)
- [Commits](https://github.com/prompt-toolkit/python-prompt-toolkit/compare/3.0.52...3.0.53)

---
updated-dependencies:
- dependency-name: prompt-toolkit
  dependency-version: 3.0.53
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 06:53:06 +00:00
dependabot[bot]
03549172f0 Bump tqdm from 4.69.1 to 4.70.0
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.69.1 to 4.70.0.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.69.1...v4.70.0)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 06:52:58 +00:00
dependabot[bot]
cbb6275aff Bump prompt-toolkit from 3.0.52 to 3.0.53
Bumps [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) from 3.0.52 to 3.0.53.
- [Release notes](https://github.com/prompt-toolkit/python-prompt-toolkit/releases)
- [Changelog](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/CHANGELOG)
- [Commits](https://github.com/prompt-toolkit/python-prompt-toolkit/compare/3.0.52...3.0.53)

---
updated-dependencies:
- dependency-name: prompt-toolkit
  dependency-version: 3.0.53
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 06:52:44 +00:00
dependabot[bot]
7f465affbb Bump tqdm from 4.69.1 to 4.70.0
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.69.1 to 4.70.0.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.69.1...v4.70.0)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 06:52:39 +00:00
7 changed files with 122 additions and 99 deletions

77
main.py
View File

@@ -9,6 +9,7 @@ import shutil
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
import aiohttp
from utils.input import ( from utils.input import (
stream_selection, stream_selection,
get_date_input, get_date_input,
@@ -190,45 +191,21 @@ if __name__ == "__main__":
mpd_content = get_manifest(dash_id) mpd_content = get_manifest(dash_id)
manifest_info = parse_mpd_manifest(mpd_content) manifest_info = parse_mpd_manifest(mpd_content)
avc_tick = None avc_track_id = None
if "video" in selections_avc: if "video" in selections_avc:
avc_sel = selections_avc["video"] avc_sel = selections_avc["video"]
avc_init_segment = avc_sel["segments"]["initialization"] avc_init_segment = avc_sel["segments"]["initialization"]
avc_track_id = avc_init_segment.split("/")[-1].split("_init")[0] avc_track_id = avc_init_segment.split("/")[-1].split("_init")[0]
logger.info("Bruteforcing AVC video track %s...", avc_track_id)
avc_valid_ticks = asyncio.run(
bruteforce(avc_track_id, start_tick_user, batch_size)
)
if len(avc_valid_ticks) == 0:
logger.warning(
"No valid ticks found for AVC video. AVC tracks will be removed from manifest."
)
else:
avc_tick = avc_valid_ticks[0]
logger.info("AVC video tick found: %s", avc_tick)
else: else:
logger.warning( logger.warning(
"No AVC video track available. AVC tracks will be removed from manifest." "No AVC video track available. AVC tracks will be removed from manifest."
) )
hevc_tick = None hevc_track_id = None
if "video" in selections_hevc: if "video" in selections_hevc:
hevc_sel = selections_hevc["video"] hevc_sel = selections_hevc["video"]
hevc_init_segment = hevc_sel["segments"]["initialization"] hevc_init_segment = hevc_sel["segments"]["initialization"]
hevc_track_id = hevc_init_segment.split("/")[-1].split("_init")[0] hevc_track_id = hevc_init_segment.split("/")[-1].split("_init")[0]
logger.info("Bruteforcing HEVC video track %s...", hevc_track_id)
hevc_valid_ticks = asyncio.run(
bruteforce(hevc_track_id, start_tick_user, batch_size)
)
if len(hevc_valid_ticks) == 0:
logger.warning(
"No valid ticks found for HEVC video. HEVC tracks will be removed from manifest."
)
else:
hevc_tick = hevc_valid_ticks[0]
logger.info("HEVC video tick found: %s", hevc_tick)
else: else:
logger.warning( logger.warning(
"No HEVC video track available. HEVC tracks will be removed from manifest." "No HEVC video track available. HEVC tracks will be removed from manifest."
@@ -239,10 +216,52 @@ if __name__ == "__main__":
audio_init_segment = audio_sel["segments"]["initialization"] audio_init_segment = audio_sel["segments"]["initialization"]
audio_track_id = audio_init_segment.split("/")[-1].split("_init")[0] audio_track_id = audio_init_segment.split("/")[-1].split("_init")[0]
logger.info("Bruteforcing audio track %s...", audio_track_id) async def bruteforce_all():
audio_valid_ticks = asyncio.run( async with aiohttp.ClientSession() as session:
bruteforce(audio_track_id, start_tick_user, batch_size) avc_ticks = None
hevc_ticks = None
audio_ticks = []
if avc_track_id:
logger.info(
"Bruteforcing AVC video track %s...", avc_track_id
) )
avc_valid = await bruteforce(
avc_track_id, start_tick_user, batch_size, session=session
)
if len(avc_valid) == 0:
logger.warning(
"No valid ticks found for AVC video. AVC tracks will be removed from manifest."
)
else:
avc_ticks = avc_valid[0]
logger.info("AVC video tick found: %s", avc_ticks)
if hevc_track_id:
logger.info(
"Bruteforcing HEVC video track %s...", hevc_track_id
)
hevc_valid = await bruteforce(
hevc_track_id, start_tick_user, batch_size, session=session
)
if len(hevc_valid) == 0:
logger.warning(
"No valid ticks found for HEVC video. HEVC tracks will be removed from manifest."
)
else:
hevc_ticks = hevc_valid[0]
logger.info("HEVC video tick found: %s", hevc_ticks)
logger.info("Bruteforcing audio track %s...", audio_track_id)
audio_valid = await bruteforce(
audio_track_id, start_tick_user, batch_size, session=session
)
if len(audio_valid) > 0:
audio_ticks = audio_valid
return avc_ticks, hevc_ticks, audio_ticks
avc_tick, hevc_tick, audio_valid_ticks = asyncio.run(bruteforce_all())
if len(audio_valid_ticks) == 0: if len(audio_valid_ticks) == 0:
logger.error("No valid ticks found for audio.") logger.error("No valid ticks found for audio.")
sys.exit(1) sys.exit(1)

View File

@@ -17,7 +17,7 @@ dependencies = [
"pywidevine>=1.9.0", "pywidevine>=1.9.0",
"requests>=2.32.5", "requests>=2.32.5",
"tabulate>=0.9.0", "tabulate>=0.9.0",
"tqdm>=4.69.1", "tqdm>=4.70.0",
"tzdata>=2026.3", "tzdata>=2026.3",
] ]

View File

@@ -1,10 +1,10 @@
aiohttp==3.14.3 aiohttp==3.14.3
beautifulsoup4==4.15.0 beautifulsoup4==4.15.0
InquirerPy==0.3.4 InquirerPy==0.3.4
prompt_toolkit==3.0.52 prompt_toolkit==3.0.53
python-dotenv==1.2.2 python-dotenv==1.2.3
pywidevine==1.9.0 pywidevine==1.9.0
Requests==2.34.2 Requests==2.34.2
tabulate==0.10.0 tabulate==0.10.0
tqdm==4.69.1 tqdm==4.70.0
tzdata==2026.3 tzdata==2026.3

View File

@@ -18,6 +18,16 @@ ET.register_namespace("", "urn:mpeg:dash:schema:mpd:2011")
ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
ET.register_namespace("cenc", "urn:mpeg:cenc:2013") ET.register_namespace("cenc", "urn:mpeg:cenc:2013")
SEGMENT_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"
),
}
def parse_mpd_manifest(mpd_content: str) -> Dict[str, Any]: def parse_mpd_manifest(mpd_content: str) -> Dict[str, Any]:
"""Parse an MPD manifest and extract metadata. """Parse an MPD manifest and extract metadata.
@@ -340,7 +350,7 @@ def get_manifest(manifest_id):
async def fetch_segment(session, ticks, track_id): async def fetch_segment(session, ticks, track_id):
"""Fetch a media segment asynchronously. """Probe whether a media segment exists using a lightweight HEAD request.
Args: Args:
session: The aiohttp ClientSession. session: The aiohttp ClientSession.
@@ -348,20 +358,11 @@ async def fetch_segment(session, ticks, track_id):
track_id: The track identifier. track_id: The track identifier.
Returns: Returns:
The tick value if successful, None otherwise. The tick value if the segment exists, None otherwise.
""" """
url = f"https://media4.stream.proxad.net/media/{track_id}_{ticks}" url = f"https://media4.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: try:
async with session.get(url, headers=headers) as resp: async with session.head(url, headers=SEGMENT_HEADERS) as resp:
if resp.status == 200: if resp.status == 200:
return ticks return ticks
return None return None
@@ -377,16 +378,7 @@ def get_init(output_folder, track_id):
track_id: The track identifier. track_id: The track identifier.
""" """
url = f"https://media4.stream.proxad.net/media/{track_id}_init" url = f"https://media4.stream.proxad.net/media/{track_id}_init"
headers = { response = requests.get(url, headers=SEGMENT_HEADERS, timeout=10)
"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"
),
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200: if response.status_code == 200:
os.makedirs(f"{output_folder}/segments_{track_id}", exist_ok=True) os.makedirs(f"{output_folder}/segments_{track_id}", exist_ok=True)
init_path = f"{output_folder}/segments_{track_id}/init.mp4" init_path = f"{output_folder}/segments_{track_id}/init.mp4"
@@ -418,17 +410,8 @@ async def save_segments(
Tuple of (success: bool, tick: int, rep: int) Tuple of (success: bool, tick: int, rep: int)
""" """
url = f"https://media4.stream.proxad.net/media/{track_id}_{tick}" url = f"https://media4.stream.proxad.net/media/{track_id}_{tick}"
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: try:
async with session.get(url, headers=headers) as resp: async with session.get(url, headers=SEGMENT_HEADERS) as resp:
if resp.status == 200: if resp.status == 200:
content = await resp.read() content = await resp.read()
filename = f"{output_folder}/segments_{track_id}/{tick}.m4s" filename = f"{output_folder}/segments_{track_id}/{tick}.m4s"
@@ -458,11 +441,11 @@ async def save_segments(
segments_to_download = [(start_tick + i * duration, i) for i in range(rep_nb)] segments_to_download = [(start_tick + i * duration, i) for i in range(rep_nb)]
# In case of resuming, check which segments are already downloaded # In case of resuming, check which segments are already downloaded
already_downloaded = [ already_downloaded = {
int(f.split(".")[0]) int(f.split(".")[0])
for f in os.listdir(f"{output_folder}/segments_{track_id}") for f in os.listdir(f"{output_folder}/segments_{track_id}")
if f.endswith(".m4s") and f.split(".")[0].isdigit() if f.endswith(".m4s") and f.split(".")[0].isdigit()
] }
segments_to_download = [ segments_to_download = [
(tick, rep) for tick, rep in segments_to_download if tick not in already_downloaded (tick, rep) for tick, rep in segments_to_download if tick not in already_downloaded
] ]

View File

@@ -55,23 +55,39 @@ def future(rep, base, duration):
return base + rep * duration return base + rep * duration
async def bruteforce(track_id, date, batch_size=20000): async def bruteforce(track_id, date, batch_size=20000, concurrency=100, session=None):
"""Bruteforce segments to find valid ticks.""" """Bruteforce segments to find valid ticks.
Args:
track_id: The track identifier.
date: The base tick to start probing from.
batch_size: Number of ticks to probe per batch.
concurrency: Maximum number of in-flight requests at once.
session: Optional shared aiohttp session. A new one is created if omitted.
Returns:
List of valid ticks found (empty if none).
"""
valid_ticks = [] valid_ticks = []
total_requests = 288000 total_requests = 288000
logger.debug("Starting bruteforce for %s near (%s) %s", track_id, date, convert_sec_to_date(convert_ticks_to_sec(date, TIMESCALE))) logger.debug("Starting bruteforce for %s near (%s) %s", track_id, date, convert_sec_to_date(convert_ticks_to_sec(date, TIMESCALE)))
start_time = time.time() start_time = time.time()
owns_session = session is None
if owns_session:
session = aiohttp.ClientSession()
try: try:
async with aiohttp.ClientSession() as session: semaphore = asyncio.Semaphore(concurrency)
async def probe(t):
async with semaphore:
return await fetch_segment(session, t + date, track_id)
for batch_start in range(0, total_requests, batch_size): for batch_start in range(0, total_requests, batch_size):
batch_end = min(batch_start + batch_size, total_requests) batch_end = min(batch_start + batch_size, total_requests)
tasks = [ tasks = [probe(t) for t in range(batch_start, batch_end)]
fetch_segment(session, t + date, track_id)
for t in range(batch_start, batch_end)
]
results = [] results = []
for coro in tqdm( for coro in tqdm(
@@ -90,10 +106,15 @@ async def bruteforce(track_id, date, batch_size=20000):
# Stop if we found valid ticks # Stop if we found valid ticks
if valid_ticks: if valid_ticks:
logger.debug("Found valid ticks: %s, stopping bruteforce.", valid_ticks) logger.debug("Found valid ticks: %s, stopping bruteforce.", valid_ticks)
for task in tasks:
task.cancel()
break break
except KeyboardInterrupt: except KeyboardInterrupt:
logger.error("Interrupted by user (Ctrl+C)") logger.error("Interrupted by user (Ctrl+C)")
finally:
if owns_session:
await session.close()
elapsed = time.time() - start_time elapsed = time.time() - start_time
logger.debug("Completed in %.2fs", elapsed) logger.debug("Completed in %.2fs", elapsed)

View File

@@ -39,7 +39,7 @@ def merge_segments(input_folder: str, track_id: str, output_file: str):
with open(output_file, "wb") as outfile: with open(output_file, "wb") as outfile:
for fname in segment_files: for fname in segment_files:
with open(f"{segment_folder}/{fname}", "rb") as infile: with open(f"{segment_folder}/{fname}", "rb") as infile:
outfile.write(infile.read()) shutil.copyfileobj(infile, outfile)
logger.info("Merged segments into %s", output_file) logger.info("Merged segments into %s", output_file)
@@ -104,7 +104,7 @@ def decrypt(segment_dir, init_path, output_file, key):
with open(chunk_merged, "wb") as outfile: with open(chunk_merged, "wb") as outfile:
for fname in chunk_files: for fname in chunk_files:
with open(os.path.join(segment_dir, fname), "rb") as infile: with open(os.path.join(segment_dir, fname), "rb") as infile:
outfile.write(infile.read()) shutil.copyfileobj(infile, outfile)
logger.debug("Decrypting chunk %d", chunk_num) logger.debug("Decrypting chunk %d", chunk_num)
result = subprocess.run( result = subprocess.run(

8
uv.lock generated
View File

@@ -808,7 +808,7 @@ requires-dist = [
{ name = "pywidevine", specifier = ">=1.9.0" }, { name = "pywidevine", specifier = ">=1.9.0" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "tabulate", specifier = ">=0.9.0" }, { name = "tabulate", specifier = ">=0.9.0" },
{ name = "tqdm", specifier = ">=4.69.1" }, { name = "tqdm", specifier = ">=4.70.0" },
{ name = "tzdata", specifier = ">=2026.3" }, { name = "tzdata", specifier = ">=2026.3" },
] ]
@@ -1301,14 +1301,14 @@ wheels = [
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.69.1" version = "4.70.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046, upload-time = "2026-07-24T14:22:02.08Z" } sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
] ]
[[package]] [[package]]