mirror of
https://github.com/NohamR/OqeeRewind.git
synced 2026-07-12 06:50:12 +00:00
Compare commits
24 Commits
8f115881e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb4a2ad63 | ||
|
|
a8f7f91305 | ||
|
|
6027190194 | ||
|
|
f7729dcd2f | ||
|
|
0054e70257 | ||
|
|
b566360abf | ||
|
|
c534ba9357 | ||
|
|
3336477824 | ||
|
|
67986279f1 | ||
|
|
ff8462d3df | ||
|
|
51a58d3694 | ||
|
|
4d613a1b3f | ||
|
|
cd5c19793f | ||
|
|
297e8d7f59 | ||
|
|
9d0da79cf0 | ||
|
|
e51a108d28 | ||
|
|
4b524b2ead | ||
|
|
5e4083cc68 | ||
|
|
327c928f4a | ||
|
|
7628b8b215 | ||
|
|
5143b4c2cf | ||
|
|
667f9ebba7 | ||
|
|
ead73a2deb | ||
|
|
2904a53bac |
113
main.py
113
main.py
@@ -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,36 +190,49 @@ 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_sel = selections_avc["video"]
|
avc_tick = None
|
||||||
avc_init_segment = avc_sel["segments"]["initialization"]
|
if "video" in selections_avc:
|
||||||
avc_track_id = avc_init_segment.split("/")[-1].split("_init")[0]
|
avc_sel = selections_avc["video"]
|
||||||
|
avc_init_segment = avc_sel["segments"]["initialization"]
|
||||||
|
avc_track_id = avc_init_segment.split("/")[-1].split("_init")[0]
|
||||||
|
|
||||||
logger.info("Bruteforcing AVC video track %s...", avc_track_id)
|
logger.info("Bruteforcing AVC video track %s...", avc_track_id)
|
||||||
avc_valid_ticks = asyncio.run(
|
avc_valid_ticks = asyncio.run(
|
||||||
bruteforce(avc_track_id, start_tick_user, batch_size)
|
bruteforce(avc_track_id, start_tick_user, batch_size)
|
||||||
)
|
|
||||||
if len(avc_valid_ticks) == 0:
|
|
||||||
logger.error("No valid ticks found for AVC video.")
|
|
||||||
sys.exit(1)
|
|
||||||
avc_tick = avc_valid_ticks[0]
|
|
||||||
logger.info("AVC video tick found: %s", avc_tick)
|
|
||||||
|
|
||||||
hevc_sel = selections_hevc["video"]
|
|
||||||
hevc_init_segment = hevc_sel["segments"]["initialization"]
|
|
||||||
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."
|
|
||||||
)
|
)
|
||||||
hevc_tick = None
|
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:
|
||||||
hevc_tick = hevc_valid_ticks[0]
|
logger.warning(
|
||||||
logger.info("HEVC video tick found: %s", hevc_tick)
|
"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_init_segment = hevc_sel["segments"]["initialization"]
|
||||||
|
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:
|
||||||
|
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,13 +261,20 @@ 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"):
|
||||||
for seg in rep["segments"]["timeline"]:
|
if avc_tick is not None:
|
||||||
seg["t"] = avc_tick
|
for seg in rep["segments"]["timeline"]:
|
||||||
logger.debug(
|
seg["t"] = avc_tick
|
||||||
"Updated AVC tick for video rep %s (codec: %s)",
|
logger.debug(
|
||||||
rep.get("id"),
|
"Updated AVC tick for video rep %s (codec: %s)",
|
||||||
rep_codec,
|
rep.get("id"),
|
||||||
)
|
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']}")
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -439,8 +440,16 @@ def select_track(content_dict, quality_spec, content_type):
|
|||||||
candidates.extend(tracks)
|
candidates.extend(tracks)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
logger.warning("No %s track found for '%s'.", content_type, quality_spec)
|
if filter_part:
|
||||||
return None
|
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:
|
||||||
|
logger.warning("No %s track found for '%s'.", content_type, quality_spec)
|
||||||
|
return None
|
||||||
|
|
||||||
if pref == "best":
|
if pref == "best":
|
||||||
selected = max(candidates, key=lambda x: x["bandwidth"])
|
selected = max(candidates, key=lambda x: x["bandwidth"])
|
||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
result = subprocess.run(
|
|
||||||
["mp4ff-decrypt", "-init", init_path, "-key", key, input_file, output_file],
|
segment_files = sorted(
|
||||||
capture_output=True,
|
[f for f in os.listdir(segment_dir) if f.endswith(".m4s")],
|
||||||
text=True,
|
key=lambda x: int(x.split(".")[0]),
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
|
||||||
logger.info("Decrypted %s to %s", input_file, output_file)
|
if not segment_files:
|
||||||
return True
|
logger.error("No segment files found in %s", segment_dir)
|
||||||
logger.error("Decryption failed: %s", result.stderr)
|
return False
|
||||||
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(
|
||||||
|
[
|
||||||
|
"mp4ff-decrypt",
|
||||||
|
"-init",
|
||||||
|
init_path,
|
||||||
|
"-key",
|
||||||
|
key,
|
||||||
|
chunk_merged,
|
||||||
|
chunk_dec,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Decryption failed for chunk %d: %s", chunk_num, result.stderr)
|
||||||
|
for tf in temp_files:
|
||||||
|
if os.path.exists(tf):
|
||||||
|
os.remove(tf)
|
||||||
|
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
21
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user