aisrt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aisrt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arvarik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
aisrt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: aisrt
3
+ Version: 0.1.0
4
+ Summary: Hardware-aware, concurrent pipeline for subtitle generation.
5
+ License-File: LICENSE
6
+ Author: Arvind
7
+ Author-email: arvind@example.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: aiosqlite (>=0.19.0,<0.20.0)
15
+ Requires-Dist: faster-whisper (>=1.0.0,<2.0.0)
16
+ Requires-Dist: loguru (>=0.7.2,<0.8.0)
17
+ Requires-Dist: psutil (>=5.9.8,<6.0.0)
18
+ Requires-Dist: pydantic (>=2.5.3,<3.0.0)
19
+ Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
20
+ Requires-Dist: pynvml (>=11.5.0,<12.0.0)
21
+ Requires-Dist: rich (>=13.7.0,<14.0.0)
22
+ Requires-Dist: typer[all] (>=0.9.0,<0.10.0)
23
+ Description-Content-Type: text/markdown
24
+
25
+ <div align="center">
26
+ <h1>🎬 Ultimate SRT Generator</h1>
27
+ <p><strong>Hardware-aware, zero-disk, highly concurrent AI pipeline for mass-generating broadcast-quality subtitles.</strong></p>
28
+
29
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
30
+ [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
31
+ [![Checked with mypy](https://img.shields.io/badge/mypy-strict-blue)](https://mypy-lang.org/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
33
+ </div>
34
+
35
+ ---
36
+
37
+ ## 🌟 Overview
38
+
39
+ The **Ultimate SRT Generator** is a production-grade daemon built for power users, NAS hoarders, and sysadmins. It autonomously crawls massive network-attached media libraries, detects videos missing English subtitles, performs **zero-disk** audio extraction directly into RAM, and infers broadcast-quality `.srt` files using state-of-the-art [faster-whisper](https://github.com/SYSTRAN/faster-whisper) AI models.
40
+
41
+ ### 🛠️ Key Architectural Features
42
+
43
+ * **Zero-Disk Audio Extraction:** Stops SSD wear-and-tear by bypassing `/tmp/` files completely. Audio streams are asynchronously ripped via FFmpeg and piped directly into RAM (NumPy arrays) for AI ingestion.
44
+ * **Bounded Asynchronous Concurrency:** Eliminates Python GIL starvation via an `asyncio.TaskGroup` producer-consumer pipeline. It extracts audio streams exactly as fast as the GPU can transcribe them, strictly capping memory usage.
45
+ * **Intelligent Hardware Routing Matrix:** Auto-detects NVIDIA GPUs (VRAM), Apple Silicon, or pure CPU environments to intelligently route to the most optimal `large-v3-turbo`, `small`, or quantized `int8` model.
46
+ * **NAS-Safe & Deduplicating:** Backed by an asynchronous local SQLite database (WAL mode) tracking `inode` and `size`. It avoids parsing active downloads, seamlessly skips duplicate hardlinks, and performs strict POSIX Atomic `os.replace` operations with original MKV metadata inheritance.
47
+ * **Broadcast Formatting:** Implements a strict chunking algorithm on top of Whisper's word-level timestamps. No more "walls of text"—subtitles are limited to ~42 chars and 2 lines, naturally breaking on terminal punctuation.
48
+
49
+ ---
50
+
51
+ ## 🚀 Installation & Deployment
52
+
53
+ ### 🐳 Docker (Recommended for TrueNAS / Unraid)
54
+
55
+ For maximum stability and ease-of-use with NVIDIA hardware, use the provided Docker stack.
56
+
57
+ 1. Clone the repository:
58
+ ```bash
59
+ git clone https://github.com/arvarik/srt-generator.git
60
+ cd srt-generator
61
+ ```
62
+ 2. Review and modify the `docker-compose.yml` to point to your media directory:
63
+ ```yaml
64
+ volumes:
65
+ - /mnt/user/media/movies:/media:rw
66
+ - ./aisrt_data:/root/.config/aisrt:rw
67
+ ```
68
+ 3. Deploy:
69
+ ```bash
70
+ docker compose up --build -d
71
+ ```
72
+
73
+ ### 💻 Native Python (Ubuntu Desktop / Server / macOS)
74
+
75
+ **Prerequisites:** Python 3.11+ and `ffmpeg` must be installed on your system.
76
+
77
+ ```bash
78
+ # 1. Clone repository
79
+ git clone https://github.com/arvarik/srt-generator.git
80
+ cd srt-generator
81
+
82
+ # 2. Create virtual environment
83
+ python3 -m venv .venv
84
+ source .venv/bin/activate
85
+
86
+ # 3. Install the application
87
+ pip install -e .
88
+
89
+ # 4. (Optional) Install development dependencies
90
+ pip install -e ".[dev]"
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 🎮 Usage
96
+
97
+ The application features a beautifully formatted CLI built on `Typer` and `Rich`.
98
+
99
+ ### Dry-Run (Scan)
100
+ Safely scan a directory to see exactly what hardware will be loaded and what files will be processed, without actually running the AI model.
101
+
102
+ ```bash
103
+ aisrt scan /path/to/movies --min-age-mins 60 --verbose
104
+ ```
105
+
106
+ ### Live Run
107
+ Execute the extraction and inference pipeline.
108
+
109
+ ```bash
110
+ aisrt run /path/to/movies
111
+ ```
112
+
113
+ ### CLI Overrides & Environment Variables
114
+ You can manually override the hardware auto-detector and execution options.
115
+
116
+ **Via CLI:**
117
+ ```bash
118
+ aisrt run /path/to/movies --force-device cuda --force-model large-v3-turbo --translate --watch --watch-interval 60
119
+ ```
120
+
121
+ **Via Docker Environment Variables:**
122
+ Since `AppConfig` utilizes `pydantic-settings`, you can configure the daemon entirely through your `docker-compose.yml`:
123
+ * `AISRT_TRANSLATE=True` (Auto-dub foreign audio into English)
124
+ * `AISRT_WATCH=True` (Run 24/7 as a daemon)
125
+ * `AISRT_WATCH_INTERVAL_MINS=60` (Time between library scans)
126
+ * `AISRT_FILTERS__MIN_AGE_MINS=30` (Skip active torrent/usenet downloads)
127
+ * `AISRT_HARDWARE__FORCE_MODEL=large-v3-turbo`
128
+
129
+ ---
130
+
131
+ ## 🏗️ Open Source Development
132
+
133
+ We welcome contributions! The codebase strictly adheres to enterprise-level typing and styling.
134
+
135
+ **Development Setup:**
136
+ ```bash
137
+ poetry install # Or pip install -e ".[dev]"
138
+ ```
139
+
140
+ **Running Tests & Linters:**
141
+ ```bash
142
+ ruff check . # Linter
143
+ ruff format . # Formatter
144
+ mypy src/aisrt tests # Strict Type Checking
145
+ pytest tests # Asynchronous Unit Tests
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 📜 License
151
+ Distributed under the MIT License. See `LICENSE` for more information.
152
+
aisrt-0.1.0/README.md ADDED
@@ -0,0 +1,127 @@
1
+ <div align="center">
2
+ <h1>🎬 Ultimate SRT Generator</h1>
3
+ <p><strong>Hardware-aware, zero-disk, highly concurrent AI pipeline for mass-generating broadcast-quality subtitles.</strong></p>
4
+
5
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
6
+ [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
7
+ [![Checked with mypy](https://img.shields.io/badge/mypy-strict-blue)](https://mypy-lang.org/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+ </div>
10
+
11
+ ---
12
+
13
+ ## 🌟 Overview
14
+
15
+ The **Ultimate SRT Generator** is a production-grade daemon built for power users, NAS hoarders, and sysadmins. It autonomously crawls massive network-attached media libraries, detects videos missing English subtitles, performs **zero-disk** audio extraction directly into RAM, and infers broadcast-quality `.srt` files using state-of-the-art [faster-whisper](https://github.com/SYSTRAN/faster-whisper) AI models.
16
+
17
+ ### 🛠️ Key Architectural Features
18
+
19
+ * **Zero-Disk Audio Extraction:** Stops SSD wear-and-tear by bypassing `/tmp/` files completely. Audio streams are asynchronously ripped via FFmpeg and piped directly into RAM (NumPy arrays) for AI ingestion.
20
+ * **Bounded Asynchronous Concurrency:** Eliminates Python GIL starvation via an `asyncio.TaskGroup` producer-consumer pipeline. It extracts audio streams exactly as fast as the GPU can transcribe them, strictly capping memory usage.
21
+ * **Intelligent Hardware Routing Matrix:** Auto-detects NVIDIA GPUs (VRAM), Apple Silicon, or pure CPU environments to intelligently route to the most optimal `large-v3-turbo`, `small`, or quantized `int8` model.
22
+ * **NAS-Safe & Deduplicating:** Backed by an asynchronous local SQLite database (WAL mode) tracking `inode` and `size`. It avoids parsing active downloads, seamlessly skips duplicate hardlinks, and performs strict POSIX Atomic `os.replace` operations with original MKV metadata inheritance.
23
+ * **Broadcast Formatting:** Implements a strict chunking algorithm on top of Whisper's word-level timestamps. No more "walls of text"—subtitles are limited to ~42 chars and 2 lines, naturally breaking on terminal punctuation.
24
+
25
+ ---
26
+
27
+ ## 🚀 Installation & Deployment
28
+
29
+ ### 🐳 Docker (Recommended for TrueNAS / Unraid)
30
+
31
+ For maximum stability and ease-of-use with NVIDIA hardware, use the provided Docker stack.
32
+
33
+ 1. Clone the repository:
34
+ ```bash
35
+ git clone https://github.com/arvarik/srt-generator.git
36
+ cd srt-generator
37
+ ```
38
+ 2. Review and modify the `docker-compose.yml` to point to your media directory:
39
+ ```yaml
40
+ volumes:
41
+ - /mnt/user/media/movies:/media:rw
42
+ - ./aisrt_data:/root/.config/aisrt:rw
43
+ ```
44
+ 3. Deploy:
45
+ ```bash
46
+ docker compose up --build -d
47
+ ```
48
+
49
+ ### 💻 Native Python (Ubuntu Desktop / Server / macOS)
50
+
51
+ **Prerequisites:** Python 3.11+ and `ffmpeg` must be installed on your system.
52
+
53
+ ```bash
54
+ # 1. Clone repository
55
+ git clone https://github.com/arvarik/srt-generator.git
56
+ cd srt-generator
57
+
58
+ # 2. Create virtual environment
59
+ python3 -m venv .venv
60
+ source .venv/bin/activate
61
+
62
+ # 3. Install the application
63
+ pip install -e .
64
+
65
+ # 4. (Optional) Install development dependencies
66
+ pip install -e ".[dev]"
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 🎮 Usage
72
+
73
+ The application features a beautifully formatted CLI built on `Typer` and `Rich`.
74
+
75
+ ### Dry-Run (Scan)
76
+ Safely scan a directory to see exactly what hardware will be loaded and what files will be processed, without actually running the AI model.
77
+
78
+ ```bash
79
+ aisrt scan /path/to/movies --min-age-mins 60 --verbose
80
+ ```
81
+
82
+ ### Live Run
83
+ Execute the extraction and inference pipeline.
84
+
85
+ ```bash
86
+ aisrt run /path/to/movies
87
+ ```
88
+
89
+ ### CLI Overrides & Environment Variables
90
+ You can manually override the hardware auto-detector and execution options.
91
+
92
+ **Via CLI:**
93
+ ```bash
94
+ aisrt run /path/to/movies --force-device cuda --force-model large-v3-turbo --translate --watch --watch-interval 60
95
+ ```
96
+
97
+ **Via Docker Environment Variables:**
98
+ Since `AppConfig` utilizes `pydantic-settings`, you can configure the daemon entirely through your `docker-compose.yml`:
99
+ * `AISRT_TRANSLATE=True` (Auto-dub foreign audio into English)
100
+ * `AISRT_WATCH=True` (Run 24/7 as a daemon)
101
+ * `AISRT_WATCH_INTERVAL_MINS=60` (Time between library scans)
102
+ * `AISRT_FILTERS__MIN_AGE_MINS=30` (Skip active torrent/usenet downloads)
103
+ * `AISRT_HARDWARE__FORCE_MODEL=large-v3-turbo`
104
+
105
+ ---
106
+
107
+ ## 🏗️ Open Source Development
108
+
109
+ We welcome contributions! The codebase strictly adheres to enterprise-level typing and styling.
110
+
111
+ **Development Setup:**
112
+ ```bash
113
+ poetry install # Or pip install -e ".[dev]"
114
+ ```
115
+
116
+ **Running Tests & Linters:**
117
+ ```bash
118
+ ruff check . # Linter
119
+ ruff format . # Formatter
120
+ mypy src/aisrt tests # Strict Type Checking
121
+ pytest tests # Asynchronous Unit Tests
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 📜 License
127
+ Distributed under the MIT License. See `LICENSE` for more information.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.0.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [tool.poetry]
6
+ name = "aisrt"
7
+ version = "0.1.0"
8
+ description = "Hardware-aware, concurrent pipeline for subtitle generation."
9
+ authors = ["Arvind <arvind@example.com>"]
10
+ readme = "README.md"
11
+ packages = [{include = "aisrt", from = "src"}]
12
+
13
+ [tool.poetry.scripts]
14
+ aisrt = "aisrt.cli:app"
15
+
16
+ [tool.poetry.dependencies]
17
+ python = "^3.11"
18
+ typer = {extras = ["all"], version = "^0.9.0"}
19
+ rich = "^13.7.0"
20
+ loguru = "^0.7.2"
21
+ pydantic = "^2.5.3"
22
+ pydantic-settings = "^2.1.0"
23
+ aiosqlite = "^0.19.0"
24
+ psutil = "^5.9.8"
25
+ pynvml = "^11.5.0"
26
+ faster-whisper = "^1.0.0"
27
+
28
+ [tool.poetry.group.dev.dependencies]
29
+ pytest = "^8.0.0"
30
+ pytest-asyncio = "^0.23.5"
31
+ ruff = "^0.2.0"
32
+ mypy = "^1.8.0"
33
+
34
+ [tool.ruff]
35
+ line-length = 100
36
+ target-version = "py311"
37
+
38
+ [tool.ruff.lint]
39
+ select = [
40
+ "E", # pycodestyle errors
41
+ "W", # pycodestyle warnings
42
+ "F", # pyflakes
43
+ "I", # isort
44
+ "C", # flake8-comprehensions
45
+ "B", # flake8-bugbear
46
+ "UP", # pyupgrade
47
+ ]
48
+ ignore = []
49
+
50
+ [tool.mypy]
51
+ python_version = "3.11"
52
+ strict = true
53
+ ignore_missing_imports = true
@@ -0,0 +1,3 @@
1
+ """Ultimate SRT Generator."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,192 @@
1
+ """Broadcast-quality SubRip (SRT) formatting and Atomic File I/O."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+
10
+ def _format_timestamp(seconds: float) -> str:
11
+ """Format a timestamp (in float seconds) to SRT standard: HH:MM:SS,mmm."""
12
+ hours = int(seconds // 3600)
13
+ minutes = int((seconds % 3600) // 60)
14
+ secs = int(seconds % 60)
15
+ millis = int(round((seconds - int(seconds)) * 1000))
16
+
17
+ if millis == 1000:
18
+ secs += 1
19
+ millis = 0
20
+ if secs == 60:
21
+ secs = 0
22
+ minutes += 1
23
+ if minutes == 60:
24
+ minutes = 0
25
+ hours += 1
26
+
27
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
28
+
29
+
30
+ class SRTFormatter:
31
+ """Chunks Whisper words into broadcast-standard SRT format."""
32
+
33
+ def __init__(self, max_chars_per_line: int = 42, max_lines: int = 2) -> None:
34
+ """Initialize the SRT chunker.
35
+
36
+ Args:
37
+ max_chars_per_line: Maximum characters before wrapping a line.
38
+ max_lines: Maximum lines per subtitle block.
39
+ """
40
+ self.max_chars_per_line = max_chars_per_line
41
+ self.max_lines = max_lines
42
+ self.terminal_punctuation = {".", "?", "!", "。", "?", "!"}
43
+
44
+ def format_segments(self, segments: Any) -> str:
45
+ """Iterate over faster-whisper Segment/Word objects and yield SRT blocks.
46
+
47
+ Requires word_timestamps=True in the Whisper model transcribe() call.
48
+
49
+ Args:
50
+ segments: A generator of faster-whisper Segment objects.
51
+
52
+ Returns:
53
+ The complete SRT file content as a string.
54
+ """
55
+ self._srt_blocks: list[str] = []
56
+ self._block_idx = 1
57
+
58
+ for segment in segments:
59
+ if not getattr(segment, "words", None):
60
+ self._format_raw_segment(segment)
61
+ else:
62
+ self._format_word_segment(segment)
63
+
64
+ return "\n".join(self._srt_blocks)
65
+
66
+ def _format_raw_segment(self, segment: Any) -> None:
67
+ """Fallback formatter for segments without word timestamps."""
68
+ text = segment.text.strip()
69
+ if text:
70
+ start = _format_timestamp(segment.start)
71
+ end = _format_timestamp(segment.end)
72
+ self._srt_blocks.append(f"{self._block_idx}\n{start} --> {end}\n{text}\n")
73
+ self._block_idx += 1
74
+
75
+ def _format_word_segment(self, segment: Any) -> None:
76
+ """Advanced formatter that chunks based on character count and punctuation."""
77
+ current_words: list[str] = []
78
+ current_start: float | None = None
79
+ current_end: float = 0.0
80
+ char_count = 0
81
+ line_count = 1
82
+
83
+ for word_obj in segment.words:
84
+ word = word_obj.word.strip()
85
+ if not word:
86
+ continue
87
+
88
+ # Temporal gap check: flush if silence > 1.5s
89
+ if current_end > 0.0 and (word_obj.start - current_end) > 1.5:
90
+ if current_words and current_start is not None:
91
+ self._flush_words(current_words, current_start, current_end)
92
+ current_words = []
93
+ current_start = None
94
+ char_count = 0
95
+ line_count = 1
96
+
97
+ if current_start is None:
98
+ current_start = word_obj.start
99
+
100
+ current_words.append(word_obj.word)
101
+ current_end = word_obj.end
102
+ char_count += len(word)
103
+
104
+ is_terminal = any(word.endswith(p) for p in self.terminal_punctuation)
105
+
106
+ # If appending this word exceeds the line length, wrap BEFORE adding it
107
+ if char_count > self.max_chars_per_line and line_count < self.max_lines:
108
+ # Insert newline before the current word
109
+ current_words.pop() # Remove the word we just added
110
+ current_words.append("\n")
111
+ current_words.append(word_obj.word.lstrip())
112
+ char_count = len(word)
113
+ line_count += 1
114
+
115
+ is_too_long = char_count >= self.max_chars_per_line
116
+
117
+ if is_terminal or (is_too_long and line_count >= self.max_lines):
118
+ self._flush_words(current_words, current_start, current_end)
119
+ current_words = []
120
+ current_start = None
121
+ char_count = 0
122
+ line_count = 1
123
+
124
+ if current_words and current_start is not None:
125
+ self._flush_words(current_words, current_start, current_end)
126
+
127
+ def _flush_words(self, words: list[str], start_time: float, end_time: float) -> None:
128
+ """Write the aggregated words to the block list."""
129
+ text = "".join(words).strip()
130
+ if text:
131
+ start_str = _format_timestamp(start_time)
132
+ end_str = _format_timestamp(end_time)
133
+ self._srt_blocks.append(f"{self._block_idx}\n{start_str} --> {end_str}\n{text}\n")
134
+ self._block_idx += 1
135
+
136
+
137
+ class AtomicWriter:
138
+ """Handles cross-device POSIX atomic file writing and metadata inheritance."""
139
+
140
+ @staticmethod
141
+ def write_srt(source_video: Path, srt_content: str, language_code: str = "en") -> Path:
142
+ """Write the SRT securely, inheriting the permissions of the source video.
143
+
144
+ Args:
145
+ source_video: The original MKV/MP4 file.
146
+ srt_content: The fully formatted SRT text block.
147
+ language_code: The locale suffix for the subtitle (e.g., 'en', 'eng').
148
+
149
+ Returns:
150
+ The Path to the finalized, atomically committed SRT file.
151
+ """
152
+ final_srt_path = source_video.with_suffix(f".{language_code}.srt")
153
+ temp_srt_path = source_video.with_name(f".{source_video.stem}.srt.tmp")
154
+
155
+ logger.debug(f"Assembling atomic SRT chunks in {temp_srt_path}")
156
+
157
+ try:
158
+ # 1. Write to hidden temp file in the same directory
159
+ temp_srt_path.write_text(srt_content, encoding="utf-8")
160
+
161
+ # 2. Inherit metadata from the source video
162
+ stat = source_video.stat()
163
+
164
+ try:
165
+ os.chown(temp_srt_path, stat.st_uid, stat.st_gid)
166
+ except PermissionError:
167
+ # Running as non-root over SMB/NFS might restrict chown
168
+ logger.debug(
169
+ f"Insufficient permissions to chown {temp_srt_path} to "
170
+ f"UID:{stat.st_uid}/GID:{stat.st_gid}. Proceeding anyway."
171
+ )
172
+
173
+ try:
174
+ os.chmod(temp_srt_path, stat.st_mode)
175
+ except PermissionError:
176
+ logger.debug(f"Insufficient permissions to chmod {temp_srt_path}")
177
+
178
+ # 3. Cross-device safe Atomic Rename
179
+ # os.replace is atomic on POSIX if both files are on the same filesystem.
180
+ # We write the temp file in the same folder to guarantee this and prevent EXDEV errors.
181
+ os.replace(temp_srt_path, final_srt_path)
182
+ logger.info(f"Successfully generated and committed {final_srt_path.name}")
183
+ return final_srt_path
184
+
185
+ except Exception as e:
186
+ # Clean up the temp file if the atomic commit fails
187
+ if temp_srt_path.exists():
188
+ try:
189
+ temp_srt_path.unlink()
190
+ except OSError:
191
+ pass
192
+ raise RuntimeError(f"Atomic subtitle write failed for {source_video.name}: {e}") from e