import os import posixpath from typing import List from yt_dlp.utils import sanitize_filename from ytdl_sub.downloaders.ytdlp import YTDLP from ytdl_sub.script.functions import Functions from ytdl_sub.script.types.map import Map from ytdl_sub.script.types.resolvable import AnyArgument from ytdl_sub.script.types.resolvable import Boolean from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import ReturnableArgument from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.utils.file_path import FilePathTruncater def _pad(num: int, width: int): return str(num).zfill(width) _days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] class CustomFunctions: @staticmethod def is_playlist_ordered_by_newest(url: String) -> Boolean: """ Queries a playlist-based URL using yt-dlp to see if it is ordered from newest (lower playlist number) to oldest. """ # Top-level allow-list of notorious playlists that can be # ordered in either direction if not any(["youtube.com/playlist" in url.value]): return Boolean("True") info_only_kwargs = { "skip_download": True, "writethumbnail": False, "writeinfojson": False, "ignoreerrors": True, "extract_flat": "discard", "playlist_items": "0:5", } try: url_info = YTDLP.extract_info( ytdl_options_overrides=info_only_kwargs, url=url.value, ) if ( not isinstance(url_info, dict) or not isinstance(url_info.get("entries"), list) or len(url_info["entries"]) == 0 ): return Boolean(False) upload_dates: List[str] = [] for entry in url_info["entries"]: if entry.get("uploader_id") and entry.get("url"): full_entry = YTDLP.extract_info( ytdl_options_overrides=info_only_kwargs, url=entry.get("url") ) if isinstance(full_entry.get("upload_date"), str): upload_dates.append(full_entry["upload_date"]) for idx in range(1, len(upload_dates)): if upload_dates[idx - 1] < upload_dates[idx]: return Boolean(False) return Boolean(True) except Exception: # pylint: disable=broad-except return Boolean(False) @staticmethod def legacy_bracket_safety(value: ReturnableArgument) -> ReturnableArgument: """ ytdl-sub used to replace brackets ('{', '}') with unicode brackets ('{', '}') to not interfere with its legacy variable scripting system. This function replicates that behavior. """ if isinstance(value, String): value = String(value.value.replace("{", "{").replace("}", "}")) return value @staticmethod def to_native_filepath(filepath: String) -> String: """ Convert any unix-based path separators ('/') with the OS's native separator. """ return String(filepath.value.replace(posixpath.sep, os.sep)) @staticmethod def truncate_filepath_if_too_long(filepath: String) -> String: """ If a file-path is too long for the OS, this function will truncate it while preserving the extension. """ return String(FilePathTruncater.maybe_truncate_file_path(filepath.value)) @staticmethod def sanitize(value: AnyArgument) -> String: """ Sanitize a string using yt-dlp's ``sanitize_filename`` method to ensure it's safe to use for file/directory names on any OS. """ return String(sanitize_filename(str(value))) @staticmethod def sanitize_plex_episode(string: String) -> String: """ Sanitize a string using ``sanitize`` and replace numerics with their respective fixed-width numbers. This is used to have Plex avoid scraping numbers like ``4x4`` as the season and/or episode. """ sanitized_string = CustomFunctions.sanitize(string).value out = "" for char in sanitized_string: match char: case "0": out += "0" case "1": out += "1" case "2": out += "2" case "3": out += "3" case "4": out += "4" case "5": out += "5" case "6": out += "6" case "7": out += "7" case "8": out += "8" case "9": out += "9" case _: out += char return String(out) @staticmethod def to_date_metadata(yyyymmdd: String) -> Map: """ Takes a date in the form of YYYYMMDD and returns a Map containing: - date (String, YYYYMMDD) - date_standardized (String, YYYY-MM-DD) - year (Integer) - month (Integer) - day (Integer) - year_truncated (Integer, YY from YY[YY]) - month_padded (String) - day_padded (String) - year_truncated_reversed (Integer, 100 - year_truncated) - month_reversed (Integer, 13 - month) - month_reversed_padded (String) - day_reversed (Integer, total_days_in_month + 1 - day) - day_reversed_padded (String) - day_of_year (Integer) - day_of_year_padded (String, padded 3) - day_of_year_reversed (Integer, total_days_in_year + 1 - day_of_year) - day_of_year_reversed_padded (String, padded 3) """ date_str = yyyymmdd.value if not (date_str.isnumeric() and len(date_str) == 8): raise RuntimeException( f"Expected input of date_metadata to be YYYYMMDD, but received {date_str}" ) year: int = int(date_str[:4]) month_padded: str = date_str[4:6] day_padded: str = date_str[6:8] month: int = int(month_padded) day: int = int(day_padded) year_truncated: int = int(str(year)[-2:]) day_of_year: int = sum(_days_in_month[:month]) + day total_days_in_month: int = _days_in_month[month] total_days_in_year: int = 365 if year % 4 == 0: total_days_in_year += 1 if month == 2: total_days_in_month += 1 if month > 2: day_of_year += 1 day_of_year_reversed: int = total_days_in_year + 1 - day_of_year month_reversed: int = 13 - month day_reversed: int = total_days_in_month + 1 - day return Map( { String("date"): yyyymmdd, String("date_standardized"): String(f"{year}-{month_padded}-{day_padded}"), String("year"): Integer(year), String("month"): Integer(month), String("day"): Integer(day), String("year_truncated"): Integer(year_truncated), String("month_padded"): String(month_padded), String("day_padded"): String(day_padded), String("year_truncated_reversed"): Integer(100 - year_truncated), String("month_reversed"): Integer(month_reversed), String("month_reversed_padded"): String(_pad(month_reversed, width=2)), String("day_reversed"): Integer(day_reversed), String("day_reversed_padded"): String(_pad(day_reversed, width=2)), String("day_of_year"): Integer(day_of_year), String("day_of_year_padded"): String(_pad(day_of_year, width=3)), String("day_of_year_reversed"): Integer(day_of_year_reversed), String("day_of_year_reversed_padded"): String(_pad(day_of_year_reversed, width=3)), } ) @staticmethod def register(): """ Register Custom functions once and only once """ if not Functions.is_built_in("sanitize"): Functions.register_function(CustomFunctions.is_playlist_ordered_by_newest) Functions.register_function(CustomFunctions.legacy_bracket_safety) Functions.register_function(CustomFunctions.truncate_filepath_if_too_long) Functions.register_function(CustomFunctions.to_native_filepath) Functions.register_function(CustomFunctions.sanitize) Functions.register_function(CustomFunctions.sanitize_plex_episode) Functions.register_function(CustomFunctions.to_date_metadata)