diff --git a/src/ytdl_sub/utils/file_path.py b/src/ytdl_sub/utils/file_path.py index ff50c991..6f321653 100644 --- a/src/ytdl_sub/utils/file_path.py +++ b/src/ytdl_sub/utils/file_path.py @@ -51,6 +51,13 @@ class FilePathTruncater: return f"{file_sub_name}{delimiter}{file_ext}" + @classmethod + def _truncate_directory_name(cls, directory_name: str) -> str: + while len(directory_name.encode("utf-8")) > cls._MAX_BASE_FILE_NAME_BYTES: + directory_name = directory_name[:-1] + + return directory_name + @classmethod def maybe_truncate_file_path(cls, file_path: str) -> str: """Turn into a Path, then a string, to get correct directory separators""" @@ -61,6 +68,30 @@ class FilePathTruncater: return str(file_path) + @classmethod + def maybe_truncate_file_name_path(cls, file_name_path: str) -> str: + """ + Truncates each component of a relative output file name path. Both the + subdirectories (which can be derived from metadata such as the title) and the + final file name are truncated if they exceed the OS limit. The file name's + extension is always preserved. + """ + parts = Path(file_name_path).parts + if not parts: + return file_name_path + + *directory_parts, file_name = parts + + truncated_parts = [ + cls._truncate_directory_name(part) if cls._is_file_name_too_long(part) else part + for part in directory_parts + ] + + if cls._is_file_name_too_long(file_name): + file_name = cls._truncate_file_name(file_name) + + return str(Path(*truncated_parts, file_name)) + @classmethod def to_native_filepath(cls, file_path: str) -> str: """Ensures file paths use the correct separator""" diff --git a/src/ytdl_sub/validators/file_path_validators.py b/src/ytdl_sub/validators/file_path_validators.py index f0076d5a..520a6216 100644 --- a/src/ytdl_sub/validators/file_path_validators.py +++ b/src/ytdl_sub/validators/file_path_validators.py @@ -48,7 +48,7 @@ class StringFormatterFileNameValidator(StringFormatterValidator): def post_process(self, resolved: str) -> str: return FilePathTruncater.to_native_filepath( - FilePathTruncater.maybe_truncate_file_path(resolved) + FilePathTruncater.maybe_truncate_file_name_path(resolved) ) diff --git a/tests/unit/validators/test_file_path_validators.py b/tests/unit/validators/test_file_path_validators.py index e66a11c2..02305ac9 100644 --- a/tests/unit/validators/test_file_path_validators.py +++ b/tests/unit/validators/test_file_path_validators.py @@ -77,6 +77,46 @@ class TestStringFormatterFilePathValidator: 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", [