[GH-ISSUE #93] Bug Report: OSError [Errno 36] File name too long with long Cyrillic titles (overlaps with issue #88) #19

Closed
opened 2026-10-13 19:45:51 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @avrohop5691-del on GitHub (Mar 2, 2026).
Original GitHub issue: https://github.com/Glitchy-Sheep/boosty-downloader/issues/93

Originally assigned to: @Glitchy-Sheep on GitHub.

When downloading posts with very long titles (particularly those containing Cyrillic characters), the downloader fails with OSError: [Errno 36] File name too long on Linux systems. This happens because the post title is used as a directory name, exceeding the 255-byte limit of filesystems like ext4. Since UTF-8 characters like Cyrillic are multi-byte, the character count might look safe while the byte count is not.

Proposed fix:

The directory name needs to be truncated based on byte length rather than character length to ensure it stays within OS limits while remaining valid UTF-8.

Location: boosty_downloader/src/infrastructure/html_generator/renderer.py (and potentially download_single_post.py).

Applied the following patch that seems to solve the problem (at least in my case):

--- /usr/local/lib/python3.14/site-packages/boosty_downloader/src/application/use_cases/download_single_post.py	2026-03-01 22:05:06.553090026 +0200
+++ /usr/local/lib/python3.14/site-packages/boosty_downloader/src/application/use_cases/download_single_post.py-bak	2026-03-01 20:13:12.355441074 +0200
@@ -162,18 +162,6 @@
             )
             return
 
-        # --- TRUNCATION FIX START ---
-        # 250 bytes is a safe limit for most Linux filesystems (MAX is 255)
-        _max_bytes = 250
-        _parent = self.destination.parent
-        _name_bytes = self.destination.name.encode('utf-8')
-
-        if len(_name_bytes) > _max_bytes:
-            # Truncate bytes, decode back, and ignore partial characters
-            _truncated_name = _name_bytes[:_max_bytes].decode('utf-8', 'ignore').rstrip()
-            self.destination = _parent / _truncated_name
-        # --- TRUNCATION FIX END ---
-
         self.destination.mkdir(parents=True, exist_ok=True)
         post_task_id = self._start_post_task(post)
         try:
@@ -340,44 +328,13 @@
     async def _download_with_progress(
         self,
         url: str,
-        destination: Path,
         filename: str,
+        destination: Path,
         task_label: str,
+        *,
         guess_extension: bool = True,
     ) -> Path:
-        """Download a file with progress tracking and return path relative to post direc
-        """
-        # --- TRUNCATION FIX v2 ---
-        _max_bytes = 250
-        
-        # 1. Fix the directory path
-        _new_parts = []
-        for _p in destination.parts:
-            _p_bytes = _p.encode('utf-8')
-            if len(_p_bytes) > _max_bytes:
-                # Truncate and decode safely
-                _p = _p_bytes[:_max_bytes].decode('utf-8', 'ignore').rstrip()
-            _new_parts.append(_p)
-        
-        # Reconstruct path: handle absolute paths (starts with /)
-        if destination.is_absolute():
-            # On Linux, _parts[0] is '/', so we join the rest
-            destination = Path(_new_parts[0]).joinpath(*_new_parts[1:])
-        else:
-            destination = Path(*_new_parts)
-
-        # 2. Fix the filename itself (to prevent the next crash)
-        _f_bytes = filename.encode('utf-8')
-        if len(_f_bytes) > _max_bytes:
-            # Try to preserve extension if possible
-            import os
-            _f_name, _f_ext = os.path.splitext(filename)
-            _ext_bytes = _f_ext.encode('utf-8')
-            _name_max = _max_bytes - len(_ext_bytes)
-            _f_name_truncated = _f_name.encode('utf-8')[:_name_max].decode('utf-8', 'ignore').rstrip()
-            filename = _f_name_truncated + _f_ext
-        # --- END FIX ---
-
+        """Download a file with progress tracking and return path relative to post directory."""
         destination.mkdir(parents=True, exist_ok=True)
         task_id = self.context.progress_reporter.create_task(task_label, indent_level=2)
 
@@ -405,12 +362,7 @@
         finally:
             self.context.progress_reporter.complete_task(task_id)
 
-        # We use a try/except because if truncation happened, 
-        # the path is no longer a subpath of the original parent.
-        try:
-            return path.relative_to(self.post_file_path.parent)
-        except ValueError:
-            return path
+        return path.relative_to(self.post_file_path.parent)
 
     async def download_boosty_video(self, video: PostDataChunkBoostyVideo) -> Path:
         """Download a Boosty video and return the path to the saved file."""
--- /usr/local/lib/python3.14/site-packages/boosty_downloader/src/infrastructure/html_generator/renderer.py-bak	2026-03-02 03:26:52.628143752 +0200
+++ /usr/local/lib/python3.14/site-packages/boosty_downloader/src/infrastructure/html_generator/renderer.py	2026-03-02 02:45:32.159967084 +0200
@@ -61,5 +61,14 @@
 def render_html_to_file(chunks: list[HtmlGenChunk], out_path: Path) -> None:
     """Render HTML chunks to HTML file."""
     html = render_html(chunks)
+
+    # --- HOTFIX START ---
+    parent = out_path.parent
+    if len(parent.name.encode('utf-8')) > 250:
+        # Truncate to 250 bytes to be safe, then rebuild the path
+        truncated_name = parent.name.encode('utf-8')[:250].decode('utf-8', 'ignore')
+        out_path = parent.parent / truncated_name / out_path.name
+    # --- HOTFIX END ---
+
     out_path.parent.mkdir(parents=True, exist_ok=True)
     out_path.write_text(html, encoding='utf-8')

Potentially a more elegant solution would be to add some option (--max-title-length maybe) to configure the maximum title length. Consider this for a future release.

Originally created by @avrohop5691-del on GitHub (Mar 2, 2026). Original GitHub issue: https://github.com/Glitchy-Sheep/boosty-downloader/issues/93 Originally assigned to: @Glitchy-Sheep on GitHub. When downloading posts with very long titles (particularly those containing Cyrillic characters), the downloader fails with OSError: [Errno 36] File name too long on Linux systems. This happens because the post title is used as a directory name, exceeding the 255-byte limit of filesystems like ext4. Since UTF-8 characters like Cyrillic are multi-byte, the character count might look safe while the byte count is not. Proposed fix: The directory name needs to be truncated based on byte length rather than character length to ensure it stays within OS limits while remaining valid UTF-8. Location: boosty_downloader/src/infrastructure/html_generator/renderer.py (and potentially download_single_post.py). Applied the following patch that seems to solve the problem (at least in my case): ``` --- /usr/local/lib/python3.14/site-packages/boosty_downloader/src/application/use_cases/download_single_post.py 2026-03-01 22:05:06.553090026 +0200 +++ /usr/local/lib/python3.14/site-packages/boosty_downloader/src/application/use_cases/download_single_post.py-bak 2026-03-01 20:13:12.355441074 +0200 @@ -162,18 +162,6 @@ ) return - # --- TRUNCATION FIX START --- - # 250 bytes is a safe limit for most Linux filesystems (MAX is 255) - _max_bytes = 250 - _parent = self.destination.parent - _name_bytes = self.destination.name.encode('utf-8') - - if len(_name_bytes) > _max_bytes: - # Truncate bytes, decode back, and ignore partial characters - _truncated_name = _name_bytes[:_max_bytes].decode('utf-8', 'ignore').rstrip() - self.destination = _parent / _truncated_name - # --- TRUNCATION FIX END --- - self.destination.mkdir(parents=True, exist_ok=True) post_task_id = self._start_post_task(post) try: @@ -340,44 +328,13 @@ async def _download_with_progress( self, url: str, - destination: Path, filename: str, + destination: Path, task_label: str, + *, guess_extension: bool = True, ) -> Path: - """Download a file with progress tracking and return path relative to post direc - """ - # --- TRUNCATION FIX v2 --- - _max_bytes = 250 - - # 1. Fix the directory path - _new_parts = [] - for _p in destination.parts: - _p_bytes = _p.encode('utf-8') - if len(_p_bytes) > _max_bytes: - # Truncate and decode safely - _p = _p_bytes[:_max_bytes].decode('utf-8', 'ignore').rstrip() - _new_parts.append(_p) - - # Reconstruct path: handle absolute paths (starts with /) - if destination.is_absolute(): - # On Linux, _parts[0] is '/', so we join the rest - destination = Path(_new_parts[0]).joinpath(*_new_parts[1:]) - else: - destination = Path(*_new_parts) - - # 2. Fix the filename itself (to prevent the next crash) - _f_bytes = filename.encode('utf-8') - if len(_f_bytes) > _max_bytes: - # Try to preserve extension if possible - import os - _f_name, _f_ext = os.path.splitext(filename) - _ext_bytes = _f_ext.encode('utf-8') - _name_max = _max_bytes - len(_ext_bytes) - _f_name_truncated = _f_name.encode('utf-8')[:_name_max].decode('utf-8', 'ignore').rstrip() - filename = _f_name_truncated + _f_ext - # --- END FIX --- - + """Download a file with progress tracking and return path relative to post directory.""" destination.mkdir(parents=True, exist_ok=True) task_id = self.context.progress_reporter.create_task(task_label, indent_level=2) @@ -405,12 +362,7 @@ finally: self.context.progress_reporter.complete_task(task_id) - # We use a try/except because if truncation happened, - # the path is no longer a subpath of the original parent. - try: - return path.relative_to(self.post_file_path.parent) - except ValueError: - return path + return path.relative_to(self.post_file_path.parent) async def download_boosty_video(self, video: PostDataChunkBoostyVideo) -> Path: """Download a Boosty video and return the path to the saved file.""" --- /usr/local/lib/python3.14/site-packages/boosty_downloader/src/infrastructure/html_generator/renderer.py-bak 2026-03-02 03:26:52.628143752 +0200 +++ /usr/local/lib/python3.14/site-packages/boosty_downloader/src/infrastructure/html_generator/renderer.py 2026-03-02 02:45:32.159967084 +0200 @@ -61,5 +61,14 @@ def render_html_to_file(chunks: list[HtmlGenChunk], out_path: Path) -> None: """Render HTML chunks to HTML file.""" html = render_html(chunks) + + # --- HOTFIX START --- + parent = out_path.parent + if len(parent.name.encode('utf-8')) > 250: + # Truncate to 250 bytes to be safe, then rebuild the path + truncated_name = parent.name.encode('utf-8')[:250].decode('utf-8', 'ignore') + out_path = parent.parent / truncated_name / out_path.name + # --- HOTFIX END --- + out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(html, encoding='utf-8') ``` Potentially a more elegant solution would be to add some option (--max-title-length maybe) to configure the maximum title length. Consider this for a future release.
Author
Owner

@Glitchy-Sheep commented on GitHub (Mar 6, 2026):

Hey! Thank you for the report.

Yeah, I haven't encountered this personally.

But it's definitely important to check, because it's not rare for Boosty titles to exceed 255 bytes (it's even worse with UTF-8, which uses multiple bytes per character).

I'll research the problem across different OSes and prepare a patch.

<!-- gh-comment-id:4012220455 --> @Glitchy-Sheep commented on GitHub (Mar 6, 2026): Hey! Thank you for the report. Yeah, I haven't encountered this personally. But it's definitely important to check, because it's not rare for Boosty titles to exceed 255 bytes (it's even worse with UTF-8, which uses multiple bytes per character). I'll research the problem across different OSes and prepare a patch.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Glitchy-Sheep/boosty-downloader#19
No description provided.