Compare commits

..

24 Commits

Author SHA1 Message Date
√(noham)²
cbb4a2ad63 Merge pull request #20 from NohamR/dependabot/pip/aiohttp-3.14.1
Bump aiohttp from 3.13.5 to 3.14.1
2026-06-28 14:48:42 +02:00
√(noham)²
a8f7f91305 Merge pull request #21 from NohamR/dependabot/pip/python-dotenv-1.2.2
Bump python-dotenv from 1.2.1 to 1.2.2
2026-06-28 14:48:33 +02:00
√(noham)²
6027190194 Merge pull request #22 from NohamR/dependabot/pip/requests-2.34.2
Bump requests from 2.32.5 to 2.34.2
2026-06-28 14:48:22 +02:00
√(noham)²
f7729dcd2f Merge branch 'main' into dependabot/pip/requests-2.34.2 2026-06-28 14:48:13 +02:00
√(noham)²
0054e70257 Merge pull request #23 from NohamR/dependabot/pip/tabulate-0.10.0
Bump tabulate from 0.9.0 to 0.10.0
2026-06-28 14:47:40 +02:00
dependabot[bot]
b566360abf Bump tabulate from 0.9.0 to 0.10.0
Bumps [tabulate](https://github.com/astanin/python-tabulate) from 0.9.0 to 0.10.0.
- [Changelog](https://github.com/astanin/python-tabulate/blob/master/CHANGELOG)
- [Commits](https://github.com/astanin/python-tabulate/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: tabulate
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-28 06:52:53 +00:00
dependabot[bot]
c534ba9357 Bump requests from 2.32.5 to 2.34.2
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.34.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.34.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-28 06:52:50 +00:00
dependabot[bot]
3336477824 Bump python-dotenv from 1.2.1 to 1.2.2
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.1 to 1.2.2.
- [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.1...v1.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-28 06:52:43 +00:00
dependabot[bot]
67986279f1 Bump aiohttp from 3.13.5 to 3.14.1
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-28 06:52:39 +00:00
√(noham)²
ff8462d3df Times + logging changes 2026-06-23 18:31:50 +02:00
√(noham)²
51a58d3694 Add tzdata 2026-06-22 11:17:16 +02:00
√(noham)²
4d613a1b3f Merge pull request #18 from NohamR/dependabot/pip/tqdm-4.68.3
Bump tqdm from 4.68.2 to 4.68.3
2026-06-21 14:49:42 +02:00
√(noham)²
cd5c19793f Merge pull request #19 from NohamR/dependabot/uv/tqdm-4.68.3
Bump tqdm from 4.68.2 to 4.68.3
2026-06-21 14:49:30 +02:00
dependabot[bot]
297e8d7f59 Bump tqdm from 4.68.2 to 4.68.3
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.68.2 to 4.68.3.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.68.2...v4.68.3)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.68.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 06:52:30 +00:00
dependabot[bot]
9d0da79cf0 Bump tqdm from 4.68.2 to 4.68.3
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.68.2 to 4.68.3.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.68.2...v4.68.3)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.68.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 06:52:27 +00:00
√(noham)²
e51a108d28 Chunked decryption, resume & selection fixes
Implement chunked decryption of .m4s segments (avoid loading entire stream into memory) by changing decrypt() to accept a segment directory, process ~1GB chunks with mp4ff-decrypt, and concatenate results. Add resume support in segment downloader by skipping already downloaded .m4s files and initializing successful counter accordingly. Improve manifest handling: gracefully handle missing AVC/HEVC tracks, bruteforce ticks conditionally, and mark representations for removal when no valid tick is found. Add fallback unfiltered selection in select_track when filtered lookup yields no candidates. Replace naive UTC offset logic with explicit Europe/Paris timezone utilities and adjust related time conversion signatures. Bump project version to 1.0.
2026-06-17 16:49:49 +02:00
√(noham)²
4b524b2ead Merge pull request #16 from NohamR/dependabot/pip/tqdm-4.68.2
Bump tqdm from 4.68.1 to 4.68.2
2026-06-14 13:53:55 +02:00
√(noham)²
5e4083cc68 Merge pull request #17 from NohamR/dependabot/uv/tqdm-4.68.2
Bump tqdm from 4.68.1 to 4.68.2
2026-06-14 13:53:45 +02:00
dependabot[bot]
327c928f4a Bump tqdm from 4.68.1 to 4.68.2
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.68.1 to 4.68.2.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.68.1...v4.68.2)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.68.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-14 06:52:40 +00:00
dependabot[bot]
7628b8b215 Bump tqdm from 4.68.1 to 4.68.2
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.68.1 to 4.68.2.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.68.1...v4.68.2)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-version: 4.68.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-14 06:52:35 +00:00
√(noham)²
5143b4c2cf Merge pull request #14 from NohamR/dependabot/pip/tqdm-4.68.1
Bump tqdm from 4.67.3 to 4.68.1
2026-06-07 12:15:52 +02:00
√(noham)²
667f9ebba7 Merge pull request #15 from NohamR/dependabot/uv/tqdm-4.68.1
Bump tqdm from 4.67.3 to 4.68.1
2026-06-07 12:15:41 +02:00
dependabot[bot]
ead73a2deb Bump tqdm from 4.67.3 to 4.68.1
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.67.3 to 4.68.1.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.67.3...v4.68.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 06:52:46 +00:00
dependabot[bot]
2904a53bac Bump tqdm from 4.67.3 to 4.68.1
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.67.3 to 4.68.1.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.67.3...v4.68.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 06:52:45 +00:00
8 changed files with 221 additions and 102 deletions

51
main.py
View File

@@ -18,7 +18,7 @@ from utils.input import (
) )
from utils.oqee import OqeeClient from utils.oqee import OqeeClient
from utils.downloader import get_keys from utils.downloader import get_keys
from utils.utilities import verify_cmd, merge_segments, decrypt, verify_mp4ff from utils.utilities import verify_cmd, decrypt, verify_mp4ff
from utils.times import ( from utils.times import (
convert_date_to_sec, convert_date_to_sec,
convert_sec_to_ticks, convert_sec_to_ticks,
@@ -26,6 +26,7 @@ from utils.times import (
convert_sec_to_date, convert_sec_to_date,
find_nearest_tick_by_hour, find_nearest_tick_by_hour,
bruteforce, bruteforce,
TIMESCALE,
) )
from utils.stream import ( from utils.stream import (
save_segments, save_segments,
@@ -38,7 +39,6 @@ from utils.stream import (
from utils.logging_config import setup_logging, logger from utils.logging_config import setup_logging, logger
load_dotenv() load_dotenv()
TIMESCALE = 90000
DURATION = 288000 DURATION = 288000
@@ -71,7 +71,7 @@ def parse_arguments():
parser.add_argument( parser.add_argument(
"--title", "--title",
type=str, type=str,
default="title", default=None,
help="Title for the download (default: channel_id_start_date)", help="Title for the download (default: channel_id_start_date)",
) )
parser.add_argument("--username", type=str, help="Oqee username for authentication") parser.add_argument("--username", type=str, help="Oqee username for authentication")
@@ -190,6 +190,8 @@ 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
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]
@@ -199,11 +201,19 @@ if __name__ == "__main__":
bruteforce(avc_track_id, start_tick_user, batch_size) bruteforce(avc_track_id, start_tick_user, batch_size)
) )
if len(avc_valid_ticks) == 0: if len(avc_valid_ticks) == 0:
logger.error("No valid ticks found for AVC video.") logger.warning(
sys.exit(1) "No valid ticks found for AVC video. AVC tracks will be removed from manifest."
)
else:
avc_tick = avc_valid_ticks[0] avc_tick = avc_valid_ticks[0]
logger.info("AVC video tick found: %s", avc_tick) logger.info("AVC video tick found: %s", avc_tick)
else:
logger.warning(
"No AVC video track available. AVC tracks will be removed from manifest."
)
hevc_tick = None
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]
@@ -216,10 +226,13 @@ if __name__ == "__main__":
logger.warning( logger.warning(
"No valid ticks found for HEVC video. HEVC tracks will be removed from manifest." "No valid ticks found for HEVC video. HEVC tracks will be removed from manifest."
) )
hevc_tick = None
else: else:
hevc_tick = hevc_valid_ticks[0] hevc_tick = hevc_valid_ticks[0]
logger.info("HEVC video tick found: %s", hevc_tick) logger.info("HEVC video tick found: %s", hevc_tick)
else:
logger.warning(
"No HEVC video track available. HEVC tracks will be removed from manifest."
)
# Bruteforce for audio (same for all audio tracks) # Bruteforce for audio (same for all audio tracks)
audio_sel = selections_avc["audio"] audio_sel = selections_avc["audio"]
@@ -248,6 +261,7 @@ if __name__ == "__main__":
rep_codec = rep.get("codecs", "") or rep.get("codec", "") rep_codec = rep.get("codecs", "") or rep.get("codec", "")
if rep.get("segments") and rep["segments"].get("timeline"): if rep.get("segments") and rep["segments"].get("timeline"):
if rep_codec.startswith("avc"): if rep_codec.startswith("avc"):
if avc_tick is not None:
for seg in rep["segments"]["timeline"]: for seg in rep["segments"]["timeline"]:
seg["t"] = avc_tick seg["t"] = avc_tick
logger.debug( logger.debug(
@@ -255,6 +269,12 @@ if __name__ == "__main__":
rep.get("id"), rep.get("id"),
rep_codec, rep_codec,
) )
else:
reps_to_remove.append(rep)
logger.debug(
"Marking AVC rep %s for removal (no valid tick)",
rep.get("id"),
)
elif rep_codec.startswith( elif rep_codec.startswith(
"hvc" "hvc"
) or rep_codec.startswith("hev"): ) or rep_codec.startswith("hev"):
@@ -273,7 +293,7 @@ if __name__ == "__main__":
rep.get("id"), rep.get("id"),
) )
# Remove HEVC representations # Remove representations with no valid tick
for rep in reps_to_remove: for rep in reps_to_remove:
adaptation_info["representations"].remove(rep) adaptation_info["representations"].remove(rep)
@@ -494,17 +514,6 @@ if __name__ == "__main__":
) )
) )
# Merge video and audio
video_file = f"{output_dir}/temp_video.mp4"
audio_file = f"{output_dir}/temp_audio.mp4"
data["file"] = video_file if content_type == "video" else audio_file
merge_segments(
output_dir,
track_id,
video_file if content_type == "video" else audio_file,
)
kid = get_kid(output_dir, track_id) kid = get_kid(output_dir, track_id)
data["kid"] = kid data["kid"] = kid
key = None key = None
@@ -539,7 +548,6 @@ if __name__ == "__main__":
for content_type, data in [("video", video_data), ("audio", audio_data)]: for content_type, data in [("video", video_data), ("audio", audio_data)]:
track_id = data["track_id"] track_id = data["track_id"]
file = data["file"]
kid = data["kid"] kid = data["kid"]
key = None key = None
@@ -550,7 +558,7 @@ if __name__ == "__main__":
init_path = get_init(output_dir, track_id) init_path = get_init(output_dir, track_id)
dec_file = f"{output_dir}/dec_{content_type}.mp4" dec_file = f"{output_dir}/dec_{content_type}.mp4"
decrypt(file, init_path, dec_file, key) decrypt(f"{output_dir}/segments_{track_id}", init_path, dec_file, key)
track_id_video = video_data["track_id"] track_id_video = video_data["track_id"]
track_id_audio = audio_data["track_id"] track_id_audio = audio_data["track_id"]
@@ -607,8 +615,7 @@ if __name__ == "__main__":
os.remove(f"{output_dir}/dec_audio.mp4") os.remove(f"{output_dir}/dec_audio.mp4")
os.remove(f"{output_dir}/video.mp4") os.remove(f"{output_dir}/video.mp4")
os.remove(f"{output_dir}/audio.mp4") os.remove(f"{output_dir}/audio.mp4")
os.remove(f"{output_dir}/temp_video.mp4")
os.remove(f"{output_dir}/temp_audio.mp4")
shutil.rmtree(f"{output_dir}/segments_{video_data['track_id']}") shutil.rmtree(f"{output_dir}/segments_{video_data['track_id']}")
shutil.rmtree(f"{output_dir}/segments_{audio_data['track_id']}") shutil.rmtree(f"{output_dir}/segments_{audio_data['track_id']}")

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "oqee-rewind" name = "oqee-rewind"
version = "0.1.0" version = "1.0"
description = "Oqee TV Live Downloader" description = "Oqee TV Live Downloader"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -17,7 +17,8 @@ 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.67.1", "tqdm>=4.68.3",
"tzdata>=2026.2",
] ]
[project.scripts] [project.scripts]

View File

@@ -1,7 +1,7 @@
aiohttp==3.13.5 aiohttp==3.14.1
InquirerPy==0.3.4 InquirerPy==0.3.4
python-dotenv==1.2.1 python-dotenv==1.2.2
pywidevine==1.9.0 pywidevine==1.9.0
Requests==2.32.5 Requests==2.34.2
tabulate==0.9.0 tabulate==0.10.0
tqdm==4.67.3 tqdm==4.68.3

View File

@@ -9,6 +9,7 @@ from InquirerPy.base.control import Choice
from utils.stream import get_manifest, parse_mpd_manifest, organize_by_content_type from utils.stream import get_manifest, parse_mpd_manifest, organize_by_content_type
from utils.logging_config import logger from utils.logging_config import logger
from utils.times import FRANCE_TZ
SERVICE_PLAN_API_URL = "https://api.oqee.net/api/v6/service_plan" SERVICE_PLAN_API_URL = "https://api.oqee.net/api/v6/service_plan"
EPG_API_URL = "https://api.oqee.net/api/v1/epg/all/{unix}" EPG_API_URL = "https://api.oqee.net/api/v1/epg/all/{unix}"
@@ -438,6 +439,14 @@ def select_track(content_dict, quality_spec, content_type):
continue continue
candidates.extend(tracks) candidates.extend(tracks)
if not candidates:
if filter_part:
logger.warning(
"No %s track found for '%s'. Falling back to unfiltered selection.",
content_type, quality_spec
)
for key, tracks in content_dict.items():
candidates.extend(tracks)
if not candidates: if not candidates:
logger.warning("No %s track found for '%s'.", content_type, quality_spec) logger.warning("No %s track found for '%s'.", content_type, quality_spec)
return None return None
@@ -476,7 +485,7 @@ def get_epg_data_at(dt: datetime.datetime):
else: else:
dt_aligned = dt.replace(minute=0, second=0, microsecond=0) dt_aligned = dt.replace(minute=0, second=0, microsecond=0)
unix_time = int(dt_aligned.timestamp()) unix_time = int(dt_aligned.replace(tzinfo=FRANCE_TZ).timestamp())
logger.info("Fetching EPG for aligned time: %s (unix=%d)", dt_aligned, unix_time) logger.info("Fetching EPG for aligned time: %s (unix=%d)", dt_aligned, unix_time)
try: try:
@@ -525,8 +534,8 @@ def select_program_from_epg(programs, original_start_date, original_end_date):
# Extract the live data from the program # Extract the live data from the program
live_data = program.get("live", program) live_data = program.get("live", program)
title = live_data.get("title", "Untitled") title = live_data.get("title", "Untitled")
start_time = datetime.datetime.fromtimestamp(live_data.get("start", 0)) start_time = datetime.datetime.fromtimestamp(live_data.get("start", 0), tz=FRANCE_TZ).replace(tzinfo=None)
end_time = datetime.datetime.fromtimestamp(live_data.get("end", 0)) end_time = datetime.datetime.fromtimestamp(live_data.get("end", 0), tz=FRANCE_TZ).replace(tzinfo=None)
duration_min = (end_time - start_time).total_seconds() / 60 duration_min = (end_time - start_time).total_seconds() / 60
choice_name = ( choice_name = (
@@ -578,8 +587,8 @@ def select_program_from_epg(programs, original_start_date, original_end_date):
# Extract live data and convert program timestamps to datetime objects # Extract live data and convert program timestamps to datetime objects
live_data = selected_program.get("live", selected_program) live_data = selected_program.get("live", selected_program)
program_start = datetime.datetime.fromtimestamp(live_data.get("start", 0)) program_start = datetime.datetime.fromtimestamp(live_data.get("start", 0), tz=FRANCE_TZ).replace(tzinfo=None)
program_end = datetime.datetime.fromtimestamp(live_data.get("end", 0)) program_end = datetime.datetime.fromtimestamp(live_data.get("end", 0), tz=FRANCE_TZ).replace(tzinfo=None)
program_title = live_data.get("title", "Untitled") program_title = live_data.get("title", "Untitled")
logger.info("Selected program:") logger.info("Selected program:")

View File

@@ -456,8 +456,19 @@ async def save_segments(
# Build list of all segments to download # Build list of all segments to download
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
already_downloaded = [
int(f.split(".")[0])
for f in os.listdir(f"{output_folder}/segments_{track_id}")
if f.endswith(".m4s") and f.split(".")[0].isdigit()
]
segments_to_download = [
(tick, rep) for tick, rep in segments_to_download if tick not in already_downloaded
]
retry_list = [] retry_list = []
successful = 0 successful = len(already_downloaded)
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
# Process segments in batches # Process segments in batches

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import datetime import datetime
import time import time
from zoneinfo import ZoneInfo
import aiohttp import aiohttp
from tqdm import tqdm from tqdm import tqdm
@@ -10,6 +11,9 @@ from tqdm import tqdm
from utils.stream import fetch_segment from utils.stream import fetch_segment
from utils.logging_config import logger from utils.logging_config import logger
FRANCE_TZ = ZoneInfo("Europe/Paris")
TIMESCALE = 90000
def convert_ticks_to_sec(ticks, timescale): def convert_ticks_to_sec(ticks, timescale):
"""Convert ticks to seconds.""" """Convert ticks to seconds."""
@@ -21,24 +25,24 @@ def convert_sec_to_ticks(seconds, timescale):
return seconds * timescale return seconds * timescale
def convert_sec_to_date(seconds, offset_hours=1): def convert_sec_to_date(seconds):
"""Convert seconds to datetime with offset.""" """Convert UTC seconds to France local datetime."""
dt = datetime.datetime.utcfromtimestamp(seconds) + datetime.timedelta( dt = datetime.datetime.fromtimestamp(seconds, tz=datetime.UTC).astimezone(
hours=offset_hours FRANCE_TZ
) )
return dt return dt.replace(tzinfo=None)
def convert_date_to_sec(dt, offset_hours=1): def convert_date_to_sec(dt):
"""Convert datetime to seconds with offset.""" """Convert France local datetime to UTC seconds."""
epoch = datetime.datetime(1970, 1, 1) aware = dt.replace(tzinfo=FRANCE_TZ)
utc_dt = dt - datetime.timedelta(hours=offset_hours) epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)
return (utc_dt - epoch).total_seconds() return (aware.astimezone(datetime.UTC) - epoch).total_seconds()
def convert_date_to_ticks(dt, timescale, offset_hours=1): def convert_date_to_ticks(dt, timescale):
"""Convert datetime to ticks with offset.""" """Convert France local datetime to ticks."""
return int(round(convert_date_to_sec(dt, offset_hours) * timescale)) return int(round(convert_date_to_sec(dt) * timescale))
def past(rep, base, duration): def past(rep, base, duration):
@@ -56,7 +60,7 @@ async def bruteforce(track_id, date, batch_size=20000):
valid_ticks = [] valid_ticks = []
total_requests = 288000 total_requests = 288000
logger.debug("Starting bruteforce for %s near %s", track_id, date) 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()
@@ -99,9 +103,9 @@ async def bruteforce(track_id, date, batch_size=20000):
return valid_ticks return valid_ticks
def find_nearest_tick_by_hour(base_tick, dt, timescale, duration, offset_hours=1): def find_nearest_tick_by_hour(base_tick, dt, timescale, duration):
"""Find the nearest tick for a given datetime.""" """Find the nearest tick for a given datetime."""
target_ticks = convert_date_to_ticks(dt, timescale, offset_hours) target_ticks = convert_date_to_ticks(dt, timescale)
diff_ticks = base_tick - target_ticks diff_ticks = base_tick - target_ticks
rep_estimate = diff_ticks / duration rep_estimate = diff_ticks / duration

View File

@@ -43,11 +43,11 @@ def merge_segments(input_folder: str, track_id: str, output_file: str):
logger.info("Merged segments into %s", output_file) logger.info("Merged segments into %s", output_file)
def decrypt(input_file, init_path, output_file, key): def decrypt(segment_dir, init_path, output_file, key):
"""Decrypt a media file using mp4ff-decrypt. """Decrypt segments in chunks of ~1GB to avoid loading entire file in memory.
Args: Args:
input_file: Path to the input encrypted file. segment_dir: Path to the directory containing .m4s segment files.
init_path: Path to the initialization file. init_path: Path to the initialization file.
output_file: Path to the output decrypted file. output_file: Path to the output decrypted file.
key: The decryption key in KID:KEY format. key: The decryption key in KID:KEY format.
@@ -56,14 +56,90 @@ def decrypt(input_file, init_path, output_file, key):
True if decryption succeeded, False otherwise. True if decryption succeeded, False otherwise.
""" """
key = key.split(":")[1] key = key.split(":")[1]
segment_files = sorted(
[f for f in os.listdir(segment_dir) if f.endswith(".m4s")],
key=lambda x: int(x.split(".")[0]),
)
if not segment_files:
logger.error("No segment files found in %s", segment_dir)
return False
logger.info(
"Decrypting %d segments from %s to %s",
len(segment_files),
segment_dir,
output_file,
)
chunk_num = 0
temp_files = []
TARGET_SIZE = 1 * 1024 * 1024 * 1024 # 1GB
i = 0
while i < len(segment_files):
chunk_files = []
chunk_size = 0
while i < len(segment_files) and chunk_size < TARGET_SIZE:
fname = segment_files[i]
fpath = os.path.join(segment_dir, fname)
fsize = os.path.getsize(fpath)
chunk_files.append(fname)
chunk_size += fsize
i += 1
logger.debug(
"Processing chunk %d: %d segments, %.2f MB",
chunk_num,
len(chunk_files),
chunk_size / (1024 * 1024),
)
chunk_merged = os.path.join(segment_dir, f"chunk_{chunk_num}_merged")
chunk_dec = os.path.join(segment_dir, f"chunk_{chunk_num}_dec")
temp_files.extend([chunk_merged, chunk_dec])
with open(chunk_merged, "wb") as outfile:
for fname in chunk_files:
with open(os.path.join(segment_dir, fname), "rb") as infile:
outfile.write(infile.read())
logger.debug("Decrypting chunk %d", chunk_num)
result = subprocess.run( result = subprocess.run(
["mp4ff-decrypt", "-init", init_path, "-key", key, input_file, output_file], [
"mp4ff-decrypt",
"-init",
init_path,
"-key",
key,
chunk_merged,
chunk_dec,
],
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
) )
if result.returncode == 0: if result.returncode != 0:
logger.info("Decrypted %s to %s", input_file, output_file) logger.error("Decryption failed for chunk %d: %s", chunk_num, result.stderr)
return True for tf in temp_files:
logger.error("Decryption failed: %s", result.stderr) if os.path.exists(tf):
os.remove(tf)
return False return False
chunk_num += 1
logger.debug("Concatenating %d decrypted chunks into %s", chunk_num, output_file)
with open(output_file, "wb") as outfile:
for c in range(chunk_num):
chunk_dec = os.path.join(segment_dir, f"chunk_{c}_dec")
with open(chunk_dec, "rb") as infile:
outfile.write(infile.read())
for tf in temp_files:
if os.path.exists(tf):
os.remove(tf)
logger.info("Successfully decrypted to %s", output_file)
return True

21
uv.lock generated
View File

@@ -779,7 +779,7 @@ wheels = [
[[package]] [[package]]
name = "oqee-rewind" name = "oqee-rewind"
version = "0.1.0" version = "1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -790,6 +790,7 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
{ name = "tabulate" }, { name = "tabulate" },
{ name = "tqdm" }, { name = "tqdm" },
{ name = "tzdata" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -807,7 +808,8 @@ 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.67.1" }, { name = "tqdm", specifier = ">=4.68.3" },
{ name = "tzdata", specifier = ">=2026.2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1299,14 +1301,14 @@ wheels = [
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.3" version = "4.68.3"
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/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" },
] ]
[[package]] [[package]]
@@ -1318,6 +1320,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]] [[package]]
name = "unidecode" name = "unidecode"
version = "1.4.0" version = "1.4.0"