[BUGFIX] Fix "File name too long" error when a title is used as a folder name (#1479)
Some checks failed
ytld-sub CI (Linux) / test-lint (push) Has been cancelled
ytld-sub CI (Linux) / test-unit (push) Has been cancelled
ytld-sub CI (Windows) / test-unit (push) Has been cancelled
ytld-sub CI (Windows) / test-integration (push) Has been cancelled
ytld-sub CI (Windows) / test-integration-prebuilt-presets (push) Has been cancelled
ytld-sub CI (Windows) / test-e2e (push) Has been cancelled
ytld-sub CI (Linux) / test-integration (push) Has been cancelled
ytld-sub CI (Linux) / test-integration-prebuilt-presets (push) Has been cancelled
ytld-sub CI (Linux) / test-e2e (push) Has been cancelled
ytld-sub Docker GUI Build / version (push) Has been cancelled
ytld-sub Docker GUI Build / build (3.12) (push) Has been cancelled
ytld-sub Docker Ubuntu Build / version (push) Has been cancelled
ytld-sub Docker Ubuntu Build / build (3.12) (push) Has been cancelled
ytld-sub Docker Build / version (push) Has been cancelled
ytld-sub Docker Build / build (3.12) (push) Has been cancelled
ytld-sub Release / version (push) Has been cancelled
ytld-sub CI (Linux) / codecov-upload (push) Has been cancelled
ytld-sub Docker GUI Build / package-arm64 (push) Has been cancelled
ytld-sub Docker GUI Build / package-amd64 (push) Has been cancelled
ytld-sub Docker GUI Build / deploy (push) Has been cancelled
ytld-sub Docker Ubuntu Build / package-arm64 (push) Has been cancelled
ytld-sub Docker Ubuntu Build / package-amd64 (push) Has been cancelled
ytld-sub Docker Ubuntu Build / deploy (push) Has been cancelled
ytld-sub Docker Build / package-arm64 (push) Has been cancelled
ytld-sub Docker Build / package-amd64 (push) Has been cancelled
ytld-sub Docker Build / deploy (push) Has been cancelled
ytld-sub Release / build-linux (push) Has been cancelled
ytld-sub Release / build-windows (push) Has been cancelled
ytld-sub Release / github-release (push) Has been cancelled
ytld-sub Release / pypi-publish (push) Has been cancelled

Some presets put the video title into a folder name (not just the file name). The code only shortened the file name, never the folder name. So a title with multi-byte characters (like bold/fancy Unicode, which take 4 bytes each) could make the folder name go over the OS 255-byte limit and crash with OSError: [Errno 36] File name too long.

This fix shortens every folder in the path too, not just the file name, while keeping the file extension. output_directory is left alone so real folders aren't touched.

Added a test with a title made of 4-byte bold Unicode characters.

Relates to #1092, #1189

-- Thanks @Nojyto for the contribution!
This commit is contained in:
Nojyto 2026-06-23 19:05:10 +03:00 committed by GitHub
parent 1c4633b815
commit 658f5ef3e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 72 additions and 1 deletions

View file

@ -51,6 +51,13 @@ class FilePathTruncater:
return f"{file_sub_name}{delimiter}{file_ext}" 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 @classmethod
def maybe_truncate_file_path(cls, file_path: str) -> str: def maybe_truncate_file_path(cls, file_path: str) -> str:
"""Turn into a Path, then a string, to get correct directory separators""" """Turn into a Path, then a string, to get correct directory separators"""
@ -61,6 +68,30 @@ class FilePathTruncater:
return str(file_path) 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 @classmethod
def to_native_filepath(cls, file_path: str) -> str: def to_native_filepath(cls, file_path: str) -> str:
"""Ensures file paths use the correct separator""" """Ensures file paths use the correct separator"""

View file

@ -48,7 +48,7 @@ class StringFormatterFileNameValidator(StringFormatterValidator):
def post_process(self, resolved: str) -> str: def post_process(self, resolved: str) -> str:
return FilePathTruncater.to_native_filepath( return FilePathTruncater.to_native_filepath(
FilePathTruncater.maybe_truncate_file_path(resolved) FilePathTruncater.maybe_truncate_file_name_path(resolved)
) )

View file

@ -77,6 +77,46 @@ class TestStringFormatterFilePathValidator:
assert len(dir_paths) == 1 assert len(dir_paths) == 1
assert Path(truncated_file_path) == dir_paths[0] 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( @pytest.mark.parametrize(
"file_name_max_bytes, expected_max", "file_name_max_bytes, expected_max",
[ [