I'm very new to all this. I'd like to download episodes from ITVX.
I struggled getting devine to work and so I found Angela's ITVX script:
https://github.com/vinefeeder/Sundry/blob/main/ITVX.py
However, it requires:
I have absolutely no idea how to generate this. I've tried asking ChatGPT but for obvious reasons it's refusing to help.Code:WVD_PATH = "/home/angela/Programming/gits/device.wvd" # location of widevine wvd file
I understand genuine devices have a wvd but I don't understand the easiest way for me to obtain one.
Could someone please help?
I am on Ubuntu (if answer depends on that).
+ Reply to Thread
Results 1 to 5 of 5
-
-
There are ready made WVD's in this thread you can use.
https://forum.videohelp.com/threads/417425-Real-Device-L3-Cdms -
Suggest you take a look at TwinVine. It comes with a wvd file already setup and will download ITVX and all the UK free to Air stuff. https://github.com/vinefeeder/TwinVine
For Ubuntu (Linux) users there is an install script that needs no editing that finds the necessary binaries and installs to your system's path. Install-media-tools.sh. The Windows version is Install-media-tools.ps1
An install of TwinVine takes me less than 3 minutes from bare machine to downloading. Just be sure to read and followNovice/bare machine instructions.
The latest stand-alone ITVX downloader from me is fastITVX.py
But that still needs a wvd filePHP Code:# A_n_g_e_l_a 20:09:2023
# Reworked June 2025 to 1080p
# AI refactor and cleanup March 2026
# Script to download videos from direct URL entry.
# Called as the downloader from itv_loader.py
#
# Uses pywidevine
# Folder structures are created of the form:
# ./output/ITV/'series-title'/'videos name'
from __future__ import annotations
import json
import os
import re
import subprocess
from base64 import b64encode
from pathlib import Path
import httpx
import jmespath
import pyfiglet as PF
from httpx import Client
from rich.console import Console
from scrapy import Selector
from selectolax.lexbor import LexborHTMLParser
from termcolor import colored
######## BEFORE USE #############
######## configure paths ############
#######################################
WVD_PATH = "/home/angela/Programming/gits/device.wvd" # location of widevine wvd file
SAVE_PATH = Path("./Downloads/") # current folder
#######################################
#######################################
SAVE_PATH.mkdir(exist_ok=True, parents=True)
console = Console()
def pad_number(match: re.Match[str]) -> str:
number = int(match.group(1))
return format(number, "02d")
class ITV:
def __init__(self) -> None:
self.client = Client(
headers={
"User-Agent": "okhttp/4.9.3",
"Accept-Language": "en-US,en;q=0.8",
"Origin": "https://www.itv.com",
"Connection": "keep-alive",
}
)
self.cookie_header_value = ""
@staticmethod
def rinse(text: str) -> str:
illegals = "*'%$!(),.;"
text = "".join(c for c in text if c.isprintable() and c not in illegals)
replacements = {
'"': "",
" ": "_",
"_-_": "_",
"&": "and",
":": "",
"_Content": "",
}
for old, new in replacements.items():
text = text.replace(old, new)
# Selectively remove underscore in S01_E02 -> S01E02
text = re.sub(r"(S\d{1,2})(_)(E\d{1,2})", r"\1\3", text)
return text
def get_pssh(self, mpd_url: str) -> str:
response = self.client.get(mpd_url)
response.raise_for_status()
content_protection = LexborHTMLParser(response.text).css_first("ContentProtection")
if not content_protection:
raise ValueError("Could not find ContentProtection element in MPD.")
kid = content_protection.attributes.get("cenc:default_kid")
if not kid:
raise ValueError("Could not find cenc:default_kid in MPD.")
kid = kid.replace("-", "")
pssh_hex = (
"000000387073736800000000edef8ba979d64acea3c827dcd51d21ed"
f"000000181210{kid}48e3dc959b06"
)
return b64encode(bytes.fromhex(pssh_hex)).decode()
def get_key(self, pssh: str, license_url: str) -> str:
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
device = Device.load(WVD_PATH)
cdm = Cdm.from_device(device)
session_id = cdm.open()
try:
challenge = cdm.get_license_challenge(session_id, PSSH(pssh))
response = httpx.post(license_url, data=challenge)
response.raise_for_status()
cdm.parse_license(session_id, response.content)
keys = [
f"{k.kid.hex}:{k.key.hex()}"
for k in cdm.get_keys(session_id)
if k.type == "CONTENT"
]
return ":".join(keys)
finally:
cdm.close(session_id)
def fetch_subtitles(self, video: dict, output_name: str) -> bool:
try:
subs_url = video["Subtitles"][0]["Href"]
except (KeyError, IndexError, TypeError):
return False
response = self.client.get(subs_url)
if response.status_code != 200:
return False
vtt_path = Path(f"{output_name}.subs.vtt")
srt_path = Path(f"{output_name}.subs.srt")
vtt_path.write_bytes(response.content)
subprocess.run(
[
"ffmpeg",
"-loglevel",
"quiet",
"-hide_banner",
"-i",
str(vtt_path),
str(srt_path),
],
check=False,
)
return srt_path.exists()
def get_data(self, url: str) -> tuple[str, str, dict]:
headers = {"Referer": "https://www.itv.com/"}
if url.count("/") != 6:
init_response = self.client.get(url, headers=headers, follow_redirects=True)
selector = Selector(text=init_response.text)
next_data = selector.xpath('//*[@id="__NEXT_DATA__"]').get()
if not next_data:
raise ValueError("Could not find __NEXT_DATA__ in ITV page.")
myjson = json.loads(re.search(r"\s*({.*})\s*", next_data).group(1))
episode_id = myjson["props"]["pageProps"]["seriesList"][0]["titles"][0]["encodedEpisodeId"]["letterA"]
res = jmespath.search(
"{programmeSlug: programmeSlug, programmeId: programmeId}",
myjson["query"],
)
url = f"https://www.itv.com/watch/{res['programmeSlug']}/{res['programmeId']}/{episode_id}"
response = self.client.get(url, follow_redirects=True)
self.cookie_header_value = "; ".join(
f"{c.name}={c.value}" for c in self.client.cookies.jar
)
selector = Selector(text=response.text)
next_data = selector.xpath('//*[@id="__NEXT_DATA__"]').get()
if not next_data:
raise ValueError("Could not find __NEXT_DATA__ in ITV watch page.")
myjson = json.loads(re.search(r"\s*({.*})\s*", next_data).group(1))
myjson = myjson["props"]["pageProps"]
res = jmespath.search(
"""
episode.{
episode: episode,
eptitle: episodeTitle,
series: series,
description: description,
channel: channel,
content: contentInfo
}
""",
myjson,
)
title = jmespath.search("programme.title", myjson)
episode = res["episode"]
episode_title = res["eptitle"] or ""
series = res["series"]
channel = res["channel"]
extendtitle = f"{episode_title}_{channel}_S{series}E{episode}"
magni_url_candidates = jmespath.search(
"[ seriesList.[*].titles.[*].playlistUrl , episode.playlistUrl]",
myjson,
)
magni_url = next(item for item in magni_url_candidates if item is not None)
payload = json.dumps(
{
"client": {"id": "lg"},
"device": {"deviceGroup": "ctv"},
"variantAvailability": {
"player": "dash",
"featureset": [
"mpeg-dash",
"widevine",
"outband-webvtt",
"hd",
"single-track",
],
"platformTag": "ctv",
"drm": {"system": "widevine", "maxSupported": "L3"},
},
}
)
headers = {
"Accept": "application/vnd.itv.vod.playlist.v4+json",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,da;q=0.8",
"Connection": "keep-alive",
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
"Cookie": self.cookie_header_value,
"Host": "magni.itv.com",
"User-Agent": "okhttp/4.9.3",
}
playlist_response = self.client.post(magni_url, headers=headers, content=payload)
playlist_response.raise_for_status()
return title, extendtitle, playlist_response.json()
def download(self, url: str, index: str) -> None:
del index # retained only for compatibility with older call signatures
title, extendtitle, data = self.get_data(url)
composite_title = f"{title} {extendtitle}"
video = data["Playlist"]["Video"]
media = video["MediaFiles"]
illegals = "*'%$!(),.:;"
replacements = {
"ITV01": "ITV1",
"ITV02": "ITV2",
"ITV03": "ITV3",
"ITV04": "ITV4",
"SNone": "S00",
"Sothers": "S00",
"ENone": "_Special",
" Episode ": "E",
" Series ": "_S",
"otherepisodes": "_extra",
"ITVX": "",
" ": "_",
"&": "and",
"?": "",
}
videoname = "".join(
c for c in composite_title if c.isprintable() and c not in illegals
)
videoname = re.sub(r"(\d+)", pad_number, videoname).strip("_")
for old, new in replacements.items():
videoname = videoname.replace(old, new)
try:
folder = self.rinse(title)
except Exception:
folder = "specials"
myvideoname = self.rinse(videoname)
has_subs = self.fetch_subtitles(video, myvideoname)
mpd_url = media[0]["Href"]
lic_url = media[0]["KeyServiceUrl"]
pssh = self.get_pssh(mpd_url)
key = self.get_key(pssh, lic_url)
subs_arg = (
f"--mux-import:path=./{myvideoname}.subs.srt:lang=eng:name='English'"
if has_subs
else "--no-log"
)
out_path = SAVE_PATH / "ITV" / folder
out_path.mkdir(exist_ok=True, parents=True)
command = [
"N_m3u8DL-RE",
mpd_url,
"--append-url-params",
"--auto-select",
"--save-name",
myvideoname,
"--save-dir",
str(out_path),
"--tmp-dir",
"./",
"-mt",
"--decryption-engine",
"SHAKA_PACKAGER",
"--key",
key,
"-M",
"format=mkv:muxer=ffmpeg",
subs_arg,
]
subprocess.run(command)
print(f"File saved to {out_path / title}")
print(f"[info] {myvideoname}.mkv is in {out_path}")
if has_subs:
for path in (
Path(f"{myvideoname}.subs.vtt"),
Path(f"{myvideoname}.subs.srt"),
):
if path.exists():
path.unlink()
def run(self) -> int:
while True:
url = input("Enter video url for download.\n").strip()
if "watch" in url.lower():
break
print("A correct download url has 'watch/<video-title>/<alpha-numeric>' in the line.")
self.download(url, "No")
return 0
if __name__ == "__main__":
title = PF.figlet_format(" I T V X ", font="smslant")
print(colored(title, "green"))
print(colored("A Single ITVX Downloader:\n", "red"))
print()
my_itv = ITV()
raise SystemExit(my_itv.run())
And while I am at it; here is a repost of fastAll4.py
PHP Code:''''
An alternative Channel 4 downloader using web-browser web endpoints.
Whilst it recovers 1080p resolution video, the bitrate is not quite
as high as Android endpoints provide.
The script is fast!
After the first run, when python creates runtime files and an AES key
is fetched and stored, the time to start downloading appears almost instantaneous.
A_n_g_e_l_a October 2025
USE: Just enter the C4 program_id found at the end of video urls eg 77297-003
Either on command-line enter
python fastALL4.py 74035-001
python fastALL4.py https://www.channel4.com/programmes/astrid-murder-in-paris/on-demand/74035-001
or just python fastALL4.py and enter program_id at prompt.
'''
from httpx import Client, HTTPError
import re
import json
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
import base64
from pywidevine.pssh import PSSH
from pywidevine.device import Device
from pywidevine.cdm import Cdm
import os
import subprocess
import struct, uuid
from concurrent.futures import ThreadPoolExecutor
import sys
import pyfiglet as PF
SERVICE_CERT_HINT = "CAQ="
KEYCACHE = "./.keycache/C4keydata.json"
n_m3u8dl = "N_m3u8DL-RE"
# -- set this for your preferences
DOWNLOAD_DIR = "/home/angela/Downloads/devine/"
WVD_PATH = "./device.wvd"
# -- end set
# --- create tmp folder to hold subtitles
if not os.path.exists("./tmp"):
os.makedirs("./tmp")
# --- ./tmp deleted by N_m3u8dl_re on completion
# --- create keycache folder to store AES KEY/IV
if not os.path.exists("./.keycache"):
os.makedirs("./.keycache")
# ---
class KeyRefreshNeeded(Exception):
pass
# data silo
class Title():
def __init__(self, title, episode,subtitle, key, episode_id = None):
self.title = title
self.episode = episode # any text for episode
self.episode_id = episode_id # in form S01E02
self.subtitle = subtitle
self.key = key # media decryption
# -- OUTLINE
# -- 1. look for cached AES KEY, IV previously obtained
# -- 2. If not found, download app.bundle.js and search for KEY,IV and store
# -- 3. Get VOD Stream data for the program_id
# -- 4. From data returned find enc_token - and decrypt with AES KEY,IV to find license url and ANOTHER token.
# -- 5. Use new token and license url to obtain a certificate. (1st license call)
# -- 6. Create DRM Challenge and include certificate in POST request to license url (2nd license call)
# -- 7. Parse license response for media decryption KEYS
# -- decryption related --
def load_cached_keys():
try:
with open(KEYCACHE, "r") as f:
data = json.load(f)
f.close()
KEY = data.get("KEY", "").encode()
IV = data.get("IV", "").encode()
if len(KEY) != 16 or len(IV) != 16:
raise ValueError("Bad key/iv lengths")
return KEY, IV
except FileNotFoundError:
return None
except Exception:
# Corrupt cache: remove and refetch
try: os.remove(KEYCACHE)
except FileNotFoundError: pass
return None
def save_cached_keys(KEY: bytes, IV: bytes):
tmp = KEYCACHE + ".tmp"
with open(tmp, "w") as f:
json.dump({"KEY": KEY.decode(), "IV": IV.decode()}, f)
f.close()
os.replace(tmp, KEYCACHE)
def fetch_fresh_keys(client):
url = "https://static.c4assets.com/all4-player/latest/bundle.app.js"
try:
bundle = client.get(url, timeout=8).text
except HTTPError as e:
raise RuntimeError(f"fetch bundle.app.js failed: {e}")
k_hits = re.findall(r'"bytes1":"([^"]+)"', bundle)
v_hits = re.findall(r'"bytes2":"([^"]+)"', bundle)
if not k_hits or not v_hits:
raise RuntimeError("bytes1/bytes2 not found in bundle.app.js")
KEY_s, IV_s = k_hits[0], v_hits[0]
KEY, IV = KEY_s.encode("latin-1"), IV_s.encode("latin-1")
if len(KEY) != 16 or len(IV) != 16:
raise RuntimeError(f"KEY/IV wrong size ({len(KEY)}, {len(IV)}), expected 16/16")
save_cached_keys(KEY, IV)
return KEY, IV
def get_keys_with_cache(client):
kv = load_cached_keys()
if kv:
return kv
return fetch_fresh_keys(client)
def decrypt_message(message_b64: str, KEY: bytes, IV: bytes) -> str:
try:
decryptor = Cipher(algorithms.AES(KEY), modes.CBC(IV), backend=default_backend()).decryptor()
unpadder = PKCS7(128).unpadder()
cbytes = b64u_decode(message_b64)
padded = decryptor.update(cbytes) + decryptor.finalize()
pt = unpadder.update(padded) + unpadder.finalize()
return pt.decode("utf-8")
except Exception as e:
# Signal caller to refresh keys
raise KeyRefreshNeeded from e
def b64u_decode(s: str) -> bytes:
"""
Decode a URL-safe Base64 string (with '-' and '_') and
auto-fix missing padding.
"""
s = s.strip().replace("\n", "")
s += "=" * (-len(s) % 4) # pad to multiple of 4
return base64.urlsafe_b64decode(s)
# --- end decryption ops ---
# -- create pssh box
def make_widevine_pssh(kid_hex: str,
system_id: str = "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
version: int = 0) -> str:
"""
Build a Widevine PSSH (v0) containing one KID.
Returns base64-encoded PSSH box.
"""
# SystemID as 16 raw bytes (no dashes)
sysid_bytes = uuid.UUID(system_id).bytes # big-endian, correct for MP4/pssh
# KID as 16 bytes
kid_bytes = bytes.fromhex(kid_hex.replace("-", ""))
if len(kid_bytes) != 16:
raise ValueError("KID must be 16 bytes (32 hex chars).")
# Widevine PSSH data: field 0x12 (repeated key_id), length 0x10 (16), then the KID
# This is the minimal valid Widevine protobuf payload.
data = b"\x12\x10" + kid_bytes
# MP4 'pssh' box: size(4) + type(4) + version/flags(4) + systemID(16) + data_size(4) + data
size = 4 + 4 + 4 + 16 + 4 + len(data)
box = struct.pack(">I4sI16sI", size, b"pssh", version, sysid_bytes, len(data)) + data
return base64.b64encode(box).decode("ascii")
def get_kid(transcode_id: str) -> str:
''' C4, uniquely, allows default_kid to be constructed,
transcodeId is normally 7 or 8 binary digits.
code here deals with length differences'''
n = int(transcode_id, 10) # decimal → int
if not (0 <= n <= 0xFFFFFFFF):
raise ValueError("transcode_id out of 32-bit range")
tail = f"{n:08x}" # 8 hex chars, zero-padded
return "0" * 24 + tail
def generate_pssh(transcodeId: str = None):
default_kid = get_kid(transcodeId)
return make_widevine_pssh(default_kid)
# --- Widevine license helper ---
DEFAULT_HEADERS = {
"Content-type": "application/json",
"Accept": "*/*",
"Referer": "https://www.channel4.com/",
"user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0"
}
def build_drm_today_payload(request_id: str, token: str, mpd_url: str, message_b64: str = "") -> dict:
# build payload to match C4 pattern
payload = {}
if token:
payload["token"] = token
if request_id:
payload["request_id"] = request_id
if mpd_url:
payload["video"] = {"type": "ondemand", "url": mpd_url}
if message_b64:
payload["message"] = message_b64
else:
payload['message'] = SERVICE_CERT_HINT
return payload
def post_json(client, url: str, data: dict, headers: dict) -> dict:
r = client.post(url, json=data, headers=headers)
try:
j = r.json()
except Exception:
raise RuntimeError(f"License server returned non-JSON (HTTP {r.status_code}): {r.text[:500]}")
if r.status_code >= 400:
raise RuntimeError(f"HTTP {r.status_code} from license server: {j}")
return j
def fetch_service_certificate(client, lic_url: str, request_id: str, lic_token: str, mpd_url: str) -> str:
# First POST: get signed DRM service certificate
payload = build_drm_today_payload(request_id, lic_token, mpd_url)
j = post_json(client, lic_url, payload, DEFAULT_HEADERS)
if not j.get("license"):
raise RuntimeError(f"Missing 'license' field in service certificate response: {j}")
return j["license"]
def request_license(client, lic_url: str, request_id: str, lic_token: str, mpd_url: str, challenge_bytes: bytes) -> str:
# Second POST: base64 of the CDM challenge inside 'message'
challenge_b64 = base64.b64encode(challenge_bytes).decode("ascii")
payload = build_drm_today_payload(request_id, lic_token, mpd_url, message_b64=challenge_b64)
j = post_json(client, lic_url, payload, DEFAULT_HEADERS)
if not j.get("license"):
raise RuntimeError(f"Missing 'license' field in license response: {j}")
return j["license"]
def pad_number(number):
return format(int(number), "02d")
def prompt_prog_id(default="74015-001"):
try:
s = input(f"Program ID ({default})?: ").strip()
return s or default
except (KeyboardInterrupt, EOFError):
print("\nAborted.", file=sys.stderr)
raise
def main(url):
client = Client()
with ThreadPoolExecutor(max_workers=1) as ex:
key_future = ex.submit(get_keys_with_cache, client) # disk/network in background
if url == "":
progId = prompt_prog_id()
elif len(url)>10:
progId = url.split('/')[-1] # https://www.channel4.com/programmes/astrid-murder-in-paris/on-demand/74035-001
else:
progId = url # 74035-001
try:
my_data = client.get(f"https://www.channel4.com/vod/stream/{progId}").json()
except HTTPError as e:
raise RuntimeError(f"fetch vod stream data failed: {e}")
vps = my_data["videoProfiles"]
if len(vps) <= 1:
raise RuntimeError(f"Expected at least 2 profiles; got {len(vps)}: {vps!r}")
stream = vps[1]["streams"][0]
# sanity: make sure it's still the one we expect
expected = "dashwv-dyn-stream-1"
actual = vps[1].get("name", "?")
if actual != expected:
raise RuntimeError(f"Profile[1] changed: expected '{expected}', got '{actual}'. Full entry: {vps[1]!r}")
uri = stream["uri"]
enc_token = stream["token"]
asset_id = my_data["transcodeId"] # both names 'asset_id' and 'transcodeId' used in code
# wait for keys from threadPoolExecutor
KEY, IV = key_future.result()
# the stream token hides -> lic_url | lic_token so decrypt with KEY, IV found in bundles.js
try:
decoded = decrypt_message(enc_token, KEY, IV)
except KeyRefreshNeeded:
# Refresh keys ONCE and retry decrypt
try:
os.remove(KEYCACHE)
except FileNotFoundError:
pass
KEY, IV = fetch_fresh_keys(client)
# --- license url and token hidden; use KEY and IV to decrypt
decoded = decrypt_message(enc_token, KEY, IV)
lic_url, lic_token = decoded.split("|", 1)
# PSSH
pssh_b64 = generate_pssh(transcodeId=asset_id)
# --- Widevine essentials start ---
# --- uses a certificate to authorise cdm key exchange ---
# --- first negotialte certificate then get the license ---
device = Device.load(WVD_PATH)
cdm = Cdm.from_device(device)
session_id = cdm.open()
try:
# 1) Service certificate (first POST, message = 'CAQ=' (found using httptoolkit and watching json exchanges))
service_cert_b64 = fetch_service_certificate(client, lic_url, asset_id, lic_token, uri)
# 2) Set the service certificate
cdm.set_service_certificate(session_id, service_cert_b64)
# 3) Build challenge
challenge = cdm.get_license_challenge(session_id, PSSH(pssh_b64), privacy_mode=True)
# 4) Request license (second POST with message=base64(challenge))
license_blob = request_license(client, lic_url, asset_id, lic_token, uri, challenge)
# 5) Parse license and dump keys
cdm.parse_license(session_id, license_blob)
key = None
for k in cdm.get_keys(session_id):
if k.type == "CONTENT":
key = f"{k.kid.hex}:{k.key.hex()}"
if not key:
raise RuntimeError("No CONTENT keys in license response")
finally:
cdm.close(session_id)
# --- Widevine essentials end ---
# --- Have Key, mpd now need
# --- collect data for video download ---
title = my_data["brandTitle"].replace(':','').rstrip() # ; upsets N_m3u8
websafe_title = my_data['webSafeBrandTitle'] # for url constuction
episode = my_data["episodeTitle"]
subtitle = my_data["subtitlesAssets"][1]["url"]
# -- get series and episode numbers
vid_url = f"https://www.channel4.com/programmes/{websafe_title}/on-demand/{progId}"
try:
vid_cont = client.get(vid_url).text
except HTTPError as e:
raise RuntimeError(f"fetch episode/series data failed: {e}")
regex = re.findall(r"name=\"title\" content=\"(.*?)\W{1,3}On\WDemand", vid_cont)
# --- If we have episode data
try:
_, sernum, _, epnum = regex[0].split(' ')
epnum = pad_number(epnum)
sernum = pad_number(sernum)
episode_id = (f"S{sernum}E{epnum}")
except:
epnum = ''
sernum = ''
episode_id = ''
if episode in title:
episode = '' # avoid repetitive strings in programme title build
# --- store subtitle file 'w' in file open will mean
# over-write of existng contents - easy re-use
my_title = Title(title, episode, subtitle, key, episode_id)
subs = client.get(my_title.subtitle).text
try:
with open("./tmp/subtitles.srt", 'w') as f:
f.write(subs)
f.close()
except FileNotFoundError:
subs = None
except Exception:
# Corrupt cache: remove and refetch
try: os.remove(KEYCACHE)
except FileNotFoundError: pass
subs = None
if my_title.episode_id == "" and my_title.episode == "":
save_name = f"{my_title.title}"
else:
save_name = f"{my_title.title} {my_title.episode} {my_title.episode_id}"
save_name = save_name.strip() # remove trailing spaces if episode or episode_id missing
# N_m3u8DL-RE to do download
command = ([
n_m3u8dl,
uri,
#'--auto-select',
'-sa',
'all',
'-sv',
'best',
'-ds',
'id="0"',
'--save-name',
save_name,
#f"{my_title.title} {my_title.episode} {my_title.episode_id}",
'--save-dir',
f'{DOWNLOAD_DIR}/C4/{my_title.title}/',
'--tmp-dir',
"./tmp",
'--no-log',
'--key',
my_title.key,
'-mt',
'-M',
'format=mkv',
])
if subs:
f = open("./tmp/subtitles.srt", 'r')
mytext = f.read()
if "#EXTM3U" in mytext: # N_m3u8DL-RE will sometimes fail to mux. This hack allows the script to finalize the video if the subtitle file is not in the expected format.
pass
else:
command.append("--mux-import:path=./tmp/subtitles.srt:lang=en:name='English':format=vtt")
# bastard nilaoda!! Command nothing like --help tells it!
f.close()
subprocess.run(command)
if __name__=="__main__":
title = PF.figlet_format(' A L L 4 ', font='smslant')
print(title)
try:
main(sys.argv[1])
except:
main("")
Last edited by A_n_g_e_l_a; 26th Mar 2026 at 03:54.
Noob Starter Pack. Just download any Widevine media! Over 24,000 downloads for V6!.
https://files.videohelp.com/u/301890/hellyes6.zip -
Hi Angela, thank you so much for that!
Once the download finished it seemed to output some Python errors. Then these disappeared and it said "Ready". I found the episode in the Temp folder. I'm not sure if this is because the post-processing was interrupted?
At the moment I cannot seem to navigate backwards to download another, so I close the terminal tab and open another. If I'm just being stupid here, would love to know how to return to the main menu? I did see mention of the terminal reset but my config already had it set to true.
This was a very easy process compared with the other routes I tried. -
To set your download folder make sure you've copied the example config file using
as per the instructions I pointed you to.Code:cp .\packages\envied\src\envied\envied-working-example.yaml .\packages\envied\src\envied\envied.yaml
Once you are sure that is done change directory to the TwinVine folder and run vinefeeder
and click in the pink 'envied' button and select configCode:uv run vinefeeder
[Attachment 91701 - Click to enlarge]
Config will open gedit on Linux with the \packages\envied\src\envied\envied.yaml open for editing; search for directories:
And edit your save location to replace the existing downloads: value
[Attachment 91702 - Click to enlarge] then save the file and close.
Navigation on Linux sometimes gets lost so it is usual to set the file ./packages/vinefeeder/src/vinefeeder/config.yaml with TERMINAL_RESET: true as in the example.
[Attachment 91703 - Click to enlarge] - You do say you tried this but it is easy to get confused and mix-up the vinefeeder config and the envied config.
I set up an alias line in .bashrc to change directory to my TwinVine folder and then run 'uv run vinefeeder'.
[Attachment 91704 - Click to enlarge]
so shift ctrl T will open a terminal window (or however you do it on Ubuntu) and v [return] will start vinefeeder. You will need to log out and back in again to make this operative should you choose to use it.
There are no python errors that I know of. More probably you saw a python warning. It is safe to ignore. Once it has been shown you won't see it again until you need to rsync.
If ever you need to update Twinvine in the future 'git pull' will load all new files on to your system.Last edited by A_n_g_e_l_a; 26th Mar 2026 at 14:57.
Noob Starter Pack. Just download any Widevine media! Over 24,000 downloads for V6!.
https://files.videohelp.com/u/301890/hellyes6.zip
Similar Threads
-
Someone help download this tv show for me itvx in 1080p with HQ audio
By Starviper in forum Video Streaming DownloadingReplies: 2Last Post: 11th Mar 2026, 23:37 -
Download video from itvx
By lollo in forum Video Streaming DownloadingReplies: 1Last Post: 1st Nov 2025, 18:11 -
Possible to download from ITVX?
By Bexinthecity05 in forum Video Streaming DownloadingReplies: 1Last Post: 20th Oct 2024, 05:37 -
How to download mpd file from itvx
By ımtryingdownload in forum Video Streaming DownloadingReplies: 12Last Post: 15th Jun 2024, 12:19 -
Will pay for scripts/Package (ITVX/All4) to download TV programmes
By PolkaDotBikini in forum LinuxReplies: 0Last Post: 26th Jul 2023, 22:11



Quote