ytdl-sub/tests/unit/validators/test_file_path_validators.py
Nojyto 658f5ef3e1
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
[BUGFIX] Fix "File name too long" error when a title is used as a folder name (#1479)
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!
2026-06-23 09:05:10 -07:00

151 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)