ytdl-sub/src/ytdl_sub/utils/file_path.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

98 lines
3.6 KiB
Python

import os
import posixpath
from pathlib import Path
from typing import Tuple
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
from ytdl_sub.utils.file_handler import get_file_extension
class FilePathTruncater:
_EXTENSION_BYTES = len("-thumb.jpg".encode("utf-8")) + 8
_DEFAULT_MAX_BASE_FILE_NAME_BYTES: int = MAX_FILE_NAME_BYTES - _EXTENSION_BYTES
_MAX_BASE_FILE_NAME_BYTES: int = _DEFAULT_MAX_BASE_FILE_NAME_BYTES
@classmethod
def set_max_file_name_bytes(cls, max_file_name_bytes: int) -> None:
"""Actually sets the max _base_ file name in bytes (excludes extension)"""
max_base_file_name_bytes = max_file_name_bytes - cls._EXTENSION_BYTES
# bound between (extension_bytes + 20, MAX_FILE_NAME_BYTES)
max_base_file_name_bytes = max(max_base_file_name_bytes, 16)
max_base_file_name_bytes = min(
max_base_file_name_bytes, MAX_FILE_NAME_BYTES - cls._EXTENSION_BYTES
)
cls._MAX_BASE_FILE_NAME_BYTES = max_base_file_name_bytes
@classmethod
def _is_file_name_too_long(cls, file_name: str) -> bool:
return len(file_name.encode("utf-8")) > cls._MAX_BASE_FILE_NAME_BYTES
@classmethod
def _get_extension_split(cls, file_name: str) -> Tuple[str, str, str]:
if file_name.endswith("-thumb.jpg"):
ext = "-thumb.jpg"
delimiter = ""
else:
ext = get_file_extension(file_name)
delimiter = "."
return file_name[: -len(ext)], ext, delimiter
@classmethod
def _truncate_file_name(cls, file_name: str) -> str:
file_sub_name, file_ext, delimiter = cls._get_extension_split(file_name)
desired_size = cls._MAX_BASE_FILE_NAME_BYTES - len(file_ext.encode("utf-8")) - 1
while len(file_sub_name.encode("utf-8")) > desired_size:
file_sub_name = file_sub_name[:-1]
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"""
file_directory, file_name = os.path.split(Path(file_path))
if cls._is_file_name_too_long(file_name):
return str(Path(file_directory) / cls._truncate_file_name(file_name))
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"""
return os.path.expanduser(file_path.replace(posixpath.sep, os.sep))