606 lines
16 KiB
Python
606 lines
16 KiB
Python
import copy
|
|
import glob
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import uuid
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
import yt_dlp
|
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
|
|
|
from .LogWrapper import LogWrapper
|
|
|
|
LOG = logging.getLogger("Utils")
|
|
|
|
IGNORED_KEYS: tuple[str] = (
|
|
"paths",
|
|
"outtmpl",
|
|
"progress_hooks",
|
|
"postprocessor_hooks",
|
|
"download_archive",
|
|
)
|
|
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
|
|
|
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
|
|
|
|
|
|
class StreamingError(Exception):
|
|
"""Raised when an error occurs during streaming."""
|
|
|
|
|
|
def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
|
"""
|
|
Returns ytdlp options download options
|
|
|
|
Args:
|
|
preset (str): the name of the preset selected.
|
|
ytdl_opts (dict): current options selected
|
|
|
|
Returns:
|
|
ytdl extra options
|
|
|
|
"""
|
|
if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2:
|
|
format = ytdl_opts["format"]
|
|
LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.")
|
|
return ytdl_opts
|
|
|
|
opts = copy.deepcopy(ytdl_opts)
|
|
|
|
if "default" == preset:
|
|
LOG.debug("Using default preset.")
|
|
return opts
|
|
|
|
from .config import Config
|
|
|
|
presets = Config.get_instance().presets
|
|
|
|
found = False
|
|
for _preset in presets:
|
|
if _preset["name"] == preset:
|
|
found = True
|
|
preset_opts = _preset
|
|
break
|
|
|
|
if not found:
|
|
LOG.error(f"Preset '{preset}' is not defined in the presets.")
|
|
return opts
|
|
|
|
opts["format"] = preset_opts.get("format")
|
|
|
|
if "postprocessors" in preset_opts:
|
|
opts["postprocessors"] = preset_opts["postprocessors"]
|
|
|
|
if "args" in preset_opts:
|
|
for key, value in preset_opts["args"].items():
|
|
opts[key] = value
|
|
|
|
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
|
return opts
|
|
|
|
|
|
def get_video_info(url: str, ytdlp_opts: dict = None, no_archive: bool = True) -> Any | dict[str, Any] | None:
|
|
"""
|
|
Extracts video information from the given URL.
|
|
|
|
Args:
|
|
url (str): URL to extract information from.
|
|
ytdlp_opts (dict): Additional options to pass to yt-dlp.
|
|
no_archive (bool): Do not use download archive.
|
|
|
|
Returns:
|
|
dict: Video information.
|
|
|
|
"""
|
|
params: dict = {
|
|
"quiet": True,
|
|
"color": "no_color",
|
|
"extract_flat": True,
|
|
"ignoreerrors": True,
|
|
"skip_download": True,
|
|
"ignore_no_formats_error": True,
|
|
}
|
|
|
|
if ytdlp_opts:
|
|
params = {**params, **ytdlp_opts}
|
|
|
|
if no_archive and "download_archive" in params:
|
|
del params["download_archive"]
|
|
|
|
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
|
|
|
|
|
|
def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str:
|
|
"""
|
|
Calculates download path and prevents folder traversal.
|
|
|
|
Returns:
|
|
Download path with base folder factored in.
|
|
|
|
"""
|
|
if not folder:
|
|
return base_path
|
|
|
|
if folder.startswith("/"):
|
|
folder = folder[1:]
|
|
|
|
realBasePath = os.path.realpath(base_path)
|
|
download_path = os.path.realpath(os.path.join(base_path, folder))
|
|
|
|
if not download_path.startswith(realBasePath):
|
|
msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".'
|
|
raise Exception(msg)
|
|
|
|
if not os.path.isdir(download_path) and create_path:
|
|
os.makedirs(download_path, exist_ok=True)
|
|
|
|
return download_path
|
|
|
|
|
|
def extract_info(config: dict, url: str, debug: bool = False) -> dict:
|
|
log_wrapper = LogWrapper()
|
|
|
|
params: dict = {
|
|
"color": "no_color",
|
|
"extract_flat": True,
|
|
"skip_download": True,
|
|
"ignoreerrors": True,
|
|
"ignore_no_formats_error": True,
|
|
**config,
|
|
}
|
|
|
|
# Remove keys that are not needed for info extraction.
|
|
keys: list = [
|
|
"writeinfojson",
|
|
"writethumbnail",
|
|
"writedescription",
|
|
"writeautomaticsub",
|
|
"postprocessors",
|
|
]
|
|
|
|
for key in keys:
|
|
if key in params:
|
|
params.pop(key)
|
|
|
|
log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING)
|
|
if debug:
|
|
params["verbose"] = True
|
|
else:
|
|
params["quiet"] = True
|
|
|
|
if "callback" in params:
|
|
# callback can be a function or dict with {level: level, func: target}
|
|
if isinstance(params["callback"], dict):
|
|
log_wrapper.add_target(
|
|
target=params["callback"]["func"],
|
|
level=params["callback"]["level"] or logging.ERROR,
|
|
)
|
|
else:
|
|
log_wrapper.add_target(target=params["callback"], level=logging.ERROR)
|
|
del params["callback"]
|
|
|
|
if log_wrapper.has_targets():
|
|
if "logger" in params:
|
|
log_wrapper.add_target(target=params["logger"], level=logging.DEBUG)
|
|
|
|
params["logger"] = log_wrapper
|
|
|
|
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
|
|
|
|
|
|
def merge_dict(source: dict, destination: dict) -> dict:
|
|
"""Merge data from source into destination"""
|
|
destination_copy = copy.deepcopy(destination)
|
|
|
|
for key, value in source.items():
|
|
destination_key_value = destination_copy.get(key)
|
|
if isinstance(value, dict) and isinstance(destination_key_value, dict):
|
|
destination_copy[key] = merge_dict(source=value, destination=destination_copy.setdefault(key, {}))
|
|
elif isinstance(value, list) and isinstance(destination_key_value, list):
|
|
destination_copy[key] = destination_key_value + value
|
|
else:
|
|
destination_copy[key] = value
|
|
|
|
return destination_copy
|
|
|
|
|
|
def merge_config(config: dict, new_config: dict) -> dict:
|
|
"""
|
|
Merge user provided config into default config
|
|
|
|
Args:
|
|
config (dict): Default config
|
|
new_config (dict): User provided config
|
|
|
|
Returns:
|
|
dict: Merged config
|
|
|
|
"""
|
|
for key in IGNORED_KEYS:
|
|
if key in new_config:
|
|
LOG.error(f"Key '{key}' is not allowed to be manually set via config.")
|
|
del new_config[key]
|
|
|
|
conf = merge_dict(new_config, config)
|
|
|
|
if "impersonate" in conf:
|
|
conf["impersonate"] = ImpersonateTarget.from_str(conf["impersonate"])
|
|
|
|
return conf
|
|
|
|
|
|
def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
|
|
global YTDLP_INFO_CLS # noqa: PLW0603
|
|
|
|
idDict = {
|
|
"id": None,
|
|
"ie_key": None,
|
|
"archive_id": None,
|
|
}
|
|
|
|
if not url or not archive_file or not os.path.exists(archive_file):
|
|
return (
|
|
False,
|
|
idDict,
|
|
)
|
|
|
|
if YTDLP_INFO_CLS is None:
|
|
YTDLP_INFO_CLS = yt_dlp.YoutubeDL(
|
|
params={
|
|
"color": "no_color",
|
|
"extract_flat": True,
|
|
"skip_download": True,
|
|
"ignoreerrors": True,
|
|
"ignore_no_formats_error": True,
|
|
"quiet": True,
|
|
}
|
|
)
|
|
|
|
for key, ie in YTDLP_INFO_CLS._ies.items():
|
|
if not ie.suitable(url):
|
|
continue
|
|
|
|
if not ie.working():
|
|
break
|
|
|
|
temp_id = ie.get_temp_id(url)
|
|
if not temp_id:
|
|
break
|
|
|
|
idDict["id"] = temp_id
|
|
idDict["ie_key"] = key
|
|
idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict)
|
|
break
|
|
|
|
if not idDict["archive_id"]:
|
|
return (False, idDict)
|
|
|
|
with open(archive_file) as f:
|
|
for line in f:
|
|
if idDict["archive_id"] in line:
|
|
return (True, idDict)
|
|
|
|
return (False, idDict)
|
|
|
|
|
|
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
|
|
"""
|
|
Load a JSON or JSON5 file and return the contents as a dictionary
|
|
|
|
Args:
|
|
file (str): File path
|
|
check_type (type): Type to check the loaded file against.
|
|
|
|
Returns tuple:
|
|
dict|list: Dictionary or list of the file contents. Empty dict if the file could not be loaded.
|
|
bool: True if the file was loaded successfully.
|
|
str: Error message if the file could not be loaded.
|
|
|
|
"""
|
|
try:
|
|
with open(file) as json_data:
|
|
opts = json.load(json_data)
|
|
|
|
if check_type:
|
|
assert isinstance(opts, check_type) # noqa: S101
|
|
|
|
return (
|
|
opts,
|
|
True,
|
|
"",
|
|
)
|
|
except Exception:
|
|
with open(file) as json_data:
|
|
from pyjson5 import load as json5_load
|
|
|
|
try:
|
|
opts = json5_load(json_data)
|
|
|
|
if check_type:
|
|
assert isinstance(opts, check_type) # noqa: S101
|
|
|
|
return (
|
|
opts,
|
|
True,
|
|
"",
|
|
)
|
|
except AssertionError:
|
|
return (
|
|
{},
|
|
False,
|
|
f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.",
|
|
)
|
|
except Exception as e:
|
|
return (
|
|
{},
|
|
False,
|
|
f"{e}",
|
|
)
|
|
|
|
|
|
def check_id(file: pathlib.Path) -> bool | str:
|
|
"""
|
|
Check if we are able to get an id from the file name.
|
|
if so check if any video file with the same id exists.
|
|
|
|
:param basePath: Base path to strip.
|
|
:param file: File to check.
|
|
|
|
:return: False if no id found, otherwise the id.
|
|
"""
|
|
match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE)
|
|
if not match:
|
|
return False
|
|
|
|
id = match.groupdict().get("id")
|
|
|
|
for f in file.parent.iterdir():
|
|
if id not in f.stem:
|
|
continue
|
|
|
|
if f.suffix not in (".mp4", ".mkv", ".webm", ".m4v", ".m4a", ".mp3", ".aac", ".ogg"):
|
|
continue
|
|
|
|
return f.absolute()
|
|
|
|
return False
|
|
|
|
|
|
def get_value(value):
|
|
return value() if callable(value) else value
|
|
|
|
|
|
def ag(array: dict | list, path: list[str | int] | str | int, default: Any = None, separator: str = ".") -> Any:
|
|
"""
|
|
dict/array getter: Retrieve a value from a nested dict or object using a path.
|
|
|
|
:param array_or_object: dict-like or object from which to retrieve values
|
|
:param path: string, list, or None. Represents the path to retrieve:
|
|
- If None or empty string, returns the entire structure.
|
|
- If list, tries each path and returns the first found.
|
|
- If string, navigates through nested dict keys separated by `separator`.
|
|
:param default: Value (or callable) returned if nothing is found.
|
|
:param separator: Separator for nested paths in strings.
|
|
:return: The found value or the default if not found.
|
|
"""
|
|
if path is None or path == "":
|
|
return array
|
|
|
|
if not isinstance(array, dict):
|
|
try:
|
|
array = vars(array)
|
|
except TypeError:
|
|
array = dict(array)
|
|
|
|
if isinstance(path, list):
|
|
randomValue = str(uuid.uuid4())
|
|
for key in path:
|
|
val = ag(array, key, randomValue, separator)
|
|
if val != randomValue:
|
|
return val
|
|
|
|
return get_value(default)
|
|
|
|
if path in array and array[path] is not None:
|
|
return array[path]
|
|
|
|
if separator not in path:
|
|
return array.get(path, get_value(default))
|
|
|
|
keys = path.split(separator)
|
|
current = array
|
|
|
|
for segment in keys:
|
|
if isinstance(current, dict) and segment in current:
|
|
current = current[segment]
|
|
else:
|
|
return get_value(default)
|
|
|
|
return current
|
|
|
|
|
|
@lru_cache(maxsize=512)
|
|
def is_private_address(hostname: str) -> bool:
|
|
try:
|
|
ip = socket.gethostbyname(hostname)
|
|
ip_obj = ipaddress.ip_address(ip)
|
|
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
|
|
except socket.gaierror:
|
|
# Could not resolve - treat as invalid or restricted
|
|
return True
|
|
|
|
|
|
def validate_url(url: str) -> bool:
|
|
"""
|
|
Validate if the url is valid and allowed.
|
|
|
|
Args:
|
|
url (str): URL to validate.
|
|
|
|
Raises:
|
|
ValueError: If the URL is invalid or not allowed.
|
|
|
|
Returns:
|
|
bool: True if the URL is valid and allowed.
|
|
|
|
"""
|
|
if not url:
|
|
msg = "URL is required."
|
|
raise ValueError(msg)
|
|
|
|
try:
|
|
from yarl import URL
|
|
|
|
parsed_url = URL(url)
|
|
except ValueError:
|
|
msg = "Invalid URL."
|
|
raise ValueError(msg) # noqa: B904
|
|
|
|
# Check allowed schemes
|
|
if parsed_url.scheme not in ["http", "https"]:
|
|
msg = "Invalid scheme usage. Only HTTP or HTTPS allowed."
|
|
raise ValueError(msg)
|
|
|
|
hostname = parsed_url.host
|
|
if not hostname or is_private_address(hostname):
|
|
msg = "Access to internal urls or private networks is not allowed."
|
|
raise ValueError(msg)
|
|
|
|
return True
|
|
|
|
|
|
def arg_converter(args: str) -> dict:
|
|
"""
|
|
Convert yt-dlp options to a dictionary.
|
|
|
|
Args:
|
|
args (str): yt-dlp options string.
|
|
|
|
Returns:
|
|
dict: yt-dlp options dictionary.
|
|
|
|
"""
|
|
import yt_dlp.options
|
|
|
|
create_parser = yt_dlp.options.create_parser
|
|
|
|
def _default_opts(args: str):
|
|
patched_parser = create_parser()
|
|
try:
|
|
yt_dlp.options.create_parser = lambda: patched_parser
|
|
return yt_dlp.parse_options(args)
|
|
finally:
|
|
yt_dlp.options.create_parser = create_parser
|
|
|
|
default_opts = _default_opts([]).ydl_opts
|
|
|
|
opts = yt_dlp.parse_options(shlex.split(args)).ydl_opts
|
|
|
|
diff = {k: v for k, v in opts.items() if default_opts[k] != v}
|
|
if "postprocessors" in diff:
|
|
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
|
|
|
|
return json.loads(json.dumps(diff))
|
|
|
|
|
|
def validate_uuid(uuid_str: str, version: int = 4) -> bool:
|
|
"""
|
|
Validate if the UUID is valid.
|
|
|
|
Args:
|
|
uuid_str (str): UUID to validate.
|
|
version (int): The UUID version
|
|
|
|
Returns:
|
|
bool: True if the UUID is valid.
|
|
|
|
"""
|
|
try:
|
|
uuid.UUID(uuid_str, version=version)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
|
|
"""
|
|
Get sidecar files for the given file.
|
|
|
|
:param file: File to get sidecar files for.
|
|
:return: List of sidecar files.
|
|
"""
|
|
files = []
|
|
|
|
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
|
|
if f == file or f.is_file() is False or f.stem.startswith("."):
|
|
continue
|
|
|
|
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
|
continue
|
|
|
|
if f.stat().st_size < 1:
|
|
continue
|
|
|
|
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
|
|
lang = lg.groupdict().get("lang") if lg else "und"
|
|
|
|
files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"})
|
|
|
|
return files
|
|
|
|
|
|
def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str:
|
|
"""
|
|
Determine the correct MIME type for a video file based on ffprobe metadata.
|
|
|
|
Args:
|
|
metadata (dict): Parsed JSON output from ffprobe.
|
|
file_path (str): The path to the video file for fallback detection.
|
|
|
|
Returns:
|
|
str: MIME type compatible with HTML5 <video> tag.
|
|
|
|
"""
|
|
# Extract format name from ffprobe
|
|
format_name = metadata.get("format_name", "")
|
|
|
|
# Define mappings for HTML5-compatible video types
|
|
format_to_mime = {
|
|
"matroska": "video/x-matroska", # Default for MKV
|
|
"webm": "video/webm", # MKV can also be WebM
|
|
"mp4": "video/mp4",
|
|
"mov": "video/quicktime",
|
|
"avi": "video/x-msvideo",
|
|
"flv": "video/x-flv",
|
|
"ogg": "video/ogg",
|
|
"mpegts": "video/mp2t",
|
|
"3gp": "video/3gpp",
|
|
}
|
|
|
|
# Check format_name against known formats
|
|
if format_name:
|
|
selected = None
|
|
for fmt in format_name.split(","):
|
|
fmt = fmt.strip().lower()
|
|
if fmt in format_to_mime:
|
|
selected = format_to_mime[fmt]
|
|
|
|
if selected:
|
|
return selected
|
|
|
|
# Fallback: Use Python's mimetypes module
|
|
import mimetypes
|
|
|
|
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
if mime_type:
|
|
return mime_type
|
|
|
|
# Final fallback: Return generic binary type
|
|
return "application/octet-stream"
|