import os import tempfile from pathlib import Path import pytest from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES from ytdl_sub.script.script import Script from ytdl_sub.utils.file_path import FilePathTruncater from ytdl_sub.utils.subtitles import SUBTITLE_EXTENSIONS from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator @pytest.mark.usefixtures("register_custom_functions") class TestStringFormatterFilePathValidator: @pytest.mark.parametrize( "ext", ["mp4", "info.json", "-thumb.jpg"] + [f"en-US.{ext}" for ext in SUBTITLE_EXTENSIONS], ) @pytest.mark.parametrize("file_name_char", ["a", "๐’ƒ€"]) @pytest.mark.parametrize("file_name_len", [10, 10000]) def test_truncates_file_name_successfully( self, ext: str, file_name_char: str, file_name_len: int ): if "thumb" not in ext: # do not put . in front of -thumb ext = f".{ext}" # pytest args with . in the beginning act weird with tempfile.TemporaryDirectory() as temp_dir: file_name = (file_name_char * file_name_len) + ext file_path = str(Path(temp_dir) / file_name) formatter = StringFormatterFileNameValidator(name="test", value=str(file_path)) truncated_file_path = formatter.post_process( Script({"file_name": formatter.format_string}).resolve().get_str("file_name") ) _, truncated_file_name = os.path.split(truncated_file_path) assert truncated_file_name.count(".") == ext.count(".") assert str(Path(temp_dir)) in truncated_file_path assert ext in truncated_file_path # Ensure it can actually open the file with open(truncated_file_path, "w", encoding="utf-8"): # Make sure the file is actually in the directory dir_paths = list(Path(temp_dir).rglob("*")) assert len(dir_paths) == 1 assert Path(truncated_file_path) == dir_paths[0] @pytest.mark.parametrize( "ext", ["mp4", "info.json", "-thumb.jpg"] + [f"en-US.{ext}" for ext in SUBTITLE_EXTENSIONS], ) def test_truncates_file_names_successfully(self, ext: str): if "thumb" not in ext: # do not put . in front of -thumb ext = f".{ext}" # pytest args with . in the beginning act weird base_file_name = "s2023.e031701 - ๐—ช๐—”๐—ฅ๐—ก๐—œ๐—ก๐—š๏ผš {LG} Secretly Overhaul This OLED Feature on C๏ผ“ & G๏ผ“โ€ฆ Should You Buy C๏ผ’ Instead๏ผŸ" with tempfile.TemporaryDirectory() as temp_dir: file_path = str(Path(temp_dir) / f"{base_file_name}{ext}") formatter = StringFormatterFileNameValidator(name="test", value="") truncated_file_path = formatter.post_process(file_path) assert truncated_file_path == str( Path(temp_dir) / f"s2023.e031701 - ๐—ช๐—”๐—ฅ๐—ก๐—œ๐—ก๐—š๏ผš {{LG}} Secretly Overhaul This OLED Feature on C๏ผ“ & G๏ผ“โ€ฆ Should You Buy C๏ผ’ Instead๏ผŸ{ext}" ) # Ensure it can actually open the file with open(truncated_file_path, "w", encoding="utf-8"): # Make sure the file is actually in the directory dir_paths = list(Path(temp_dir).rglob("*")) assert len(dir_paths) == 1 assert Path(truncated_file_path) == dir_paths[0] @pytest.mark.parametrize("ext", ["mp4", "-thumb.jpg"]) def test_truncates_subdirectory_in_file_name_path(self, ext: str): if "thumb" not in ext: # do not put . in front of -thumb ext = f".{ext}" # Mathematical bold unicode characters are 4 bytes each in UTF-8, so a title used # as a subdirectory can easily exceed the OS file name limit (typically 255 bytes). long_directory = ( "[2026] ๐—ฟ๐˜๐—ถ๐˜€๐˜ ๐—ข๐—ป๐—ฒ - ๐—” ๐—ฉ๐—ฒ๐—ฟ๐˜† ๐—Ÿ๐—ผ๐—ป๐—ด ๐—ฆ๐—ผ๐—ป๐—ด ๐—ง๐—ถ๐˜๐—น๐—ฒ " "๐—ง๐—ต๐—ฎ๐˜ ๐—˜๐˜…๐—ฐ๐—ฒ๐—ฒ๐—ฑ๐˜€ ๐—ง๐—ต๐—ฒ ๐—Ÿ๐—ถ๐—บ๐—ถ๐˜ (๐—˜๐˜…๐˜๐—ฒ๐—ป๐—ฑ๐—ฒ๐—ฑ ๐— ๐—ถ๐˜… ๐˜… " "๐—ฆ๐—ฒ๐—ฐ๐—ผ๐—ป๐—ฑ ๐—”๐—ฟ๐˜๐—ถ๐˜€๐˜ & ๐—ง๐—ต๐—ถ๐—ฟ๐—ฑ ๐—”๐—ฟ๐˜๐—ถ๐˜€๐˜)" ) # Sanity check that the directory really does exceed the limit assert len(long_directory.encode("utf-8")) > FilePathTruncater._MAX_BASE_FILE_NAME_BYTES with tempfile.TemporaryDirectory() as temp_dir: file_path = str(Path(temp_dir) / long_directory / f"video{ext}") formatter = StringFormatterFileNameValidator(name="test", value="") truncated_file_path = formatter.post_process(file_path) truncated_directory, truncated_file_name = os.path.split(truncated_file_path) _, truncated_directory_name = os.path.split(truncated_directory) # Both the subdirectory and the file name must fit within the OS limit assert ( len(truncated_directory_name.encode("utf-8")) <= FilePathTruncater._MAX_BASE_FILE_NAME_BYTES ) assert ( len(truncated_file_name.encode("utf-8")) <= FilePathTruncater._MAX_BASE_FILE_NAME_BYTES ) assert truncated_file_name.endswith(ext) # The original bug raised "OSError: [Errno 36] Filename too long" here because # the subdirectory component was never truncated. os.makedirs(truncated_directory, exist_ok=True) assert os.path.isdir(truncated_directory) @pytest.mark.parametrize( "file_name_max_bytes, expected_max", [ (50, 50 - FilePathTruncater._EXTENSION_BYTES), (0, 16), (10000, MAX_FILE_NAME_BYTES - FilePathTruncater._EXTENSION_BYTES), ], ) def test_config_changes_max_file_name_bytes(self, file_name_max_bytes: int, expected_max: int): # Ensure the default is set assert ( FilePathTruncater._MAX_BASE_FILE_NAME_BYTES == FilePathTruncater._DEFAULT_MAX_BASE_FILE_NAME_BYTES ) try: # Initializes the config values, setting the max to 10 bytes _ = ConfigFile.from_dict( { "configuration": { "working_directory": ".", "file_name_max_bytes": file_name_max_bytes, }, "presets": {}, } ) assert FilePathTruncater._MAX_BASE_FILE_NAME_BYTES == expected_max finally: FilePathTruncater._MAX_BASE_FILE_NAME_BYTES = ( FilePathTruncater._DEFAULT_MAX_BASE_FILE_NAME_BYTES )