mirror of
https://github.com/NohamR/OqeeRewind.git
synced 2026-08-26 10:12:08 +00:00
Compare commits
18 Commits
586a45aeda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12c6f47a3 | ||
|
|
f78bf1d6c0 | ||
|
|
b7efe34ed0 | ||
|
|
ae5035c6a8 | ||
|
|
cada374158 | ||
|
|
daebae4411 | ||
|
|
86e370b86d | ||
|
|
ee5ba75a50 | ||
|
|
94e7b71bf6 | ||
|
|
03549172f0 | ||
|
|
cbb6275aff | ||
|
|
7f465affbb | ||
|
|
4524d474eb | ||
|
|
03512e4430 | ||
|
|
619c91b71d | ||
|
|
9f97f7ff33 | ||
|
|
5634871a35 | ||
|
|
0063753c47 |
79
main.py
79
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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.0",
|
"tqdm>=4.70.0",
|
||||||
"tzdata>=2026.3",
|
"tzdata>=2026.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
aiohttp==3.14.1
|
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.0
|
tqdm==4.70.0
|
||||||
tzdata==2026.3
|
tzdata==2026.3
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -55,45 +55,66 @@ 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)
|
||||||
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 = []
|
async def probe(t):
|
||||||
for coro in tqdm(
|
async with semaphore:
|
||||||
asyncio.as_completed(tasks),
|
return await fetch_segment(session, t + date, track_id)
|
||||||
total=len(tasks),
|
|
||||||
desc="Bruteforce",
|
|
||||||
unit="req",
|
|
||||||
):
|
|
||||||
result = await coro
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
valid_ticks.extend(
|
for batch_start in range(0, total_requests, batch_size):
|
||||||
[r for r in results if r and not isinstance(r, Exception)]
|
batch_end = min(batch_start + batch_size, total_requests)
|
||||||
)
|
tasks = [probe(t) for t in range(batch_start, batch_end)]
|
||||||
|
|
||||||
# Stop if we found valid ticks
|
results = []
|
||||||
if valid_ticks:
|
for coro in tqdm(
|
||||||
logger.debug("Found valid ticks: %s, stopping bruteforce.", valid_ticks)
|
asyncio.as_completed(tasks),
|
||||||
break
|
total=len(tasks),
|
||||||
|
desc="Bruteforce",
|
||||||
|
unit="req",
|
||||||
|
):
|
||||||
|
result = await coro
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
valid_ticks.extend(
|
||||||
|
[r for r in results if r and not isinstance(r, Exception)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop if we found valid ticks
|
||||||
|
if valid_ticks:
|
||||||
|
logger.debug("Found valid ticks: %s, stopping bruteforce.", valid_ticks)
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
8
uv.lock
generated
@@ -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.0" },
|
{ 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.0"
|
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/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" }
|
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/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" },
|
{ 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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user