mkv2cast 1.2.7.post4__py3-none-any.whl
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.
- mkv2cast/__init__.py +77 -0
- mkv2cast/__main__.py +14 -0
- mkv2cast/cli.py +1886 -0
- mkv2cast/config.py +638 -0
- mkv2cast/converter.py +1454 -0
- mkv2cast/history.py +389 -0
- mkv2cast/i18n.py +179 -0
- mkv2cast/integrity.py +176 -0
- mkv2cast/json_progress.py +311 -0
- mkv2cast/locales/de/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/de/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/en/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/en/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/es/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/es/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/fr/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/fr/LC_MESSAGES/mkv2cast.po +430 -0
- mkv2cast/locales/it/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/it/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/notifications.py +196 -0
- mkv2cast/pipeline.py +641 -0
- mkv2cast/ui/__init__.py +26 -0
- mkv2cast/ui/legacy_ui.py +136 -0
- mkv2cast/ui/rich_ui.py +462 -0
- mkv2cast/ui/simple_rich.py +243 -0
- mkv2cast/watcher.py +293 -0
- mkv2cast-1.2.7.post4.dist-info/METADATA +1411 -0
- mkv2cast-1.2.7.post4.dist-info/RECORD +31 -0
- mkv2cast-1.2.7.post4.dist-info/WHEEL +4 -0
- mkv2cast-1.2.7.post4.dist-info/entry_points.txt +2 -0
- mkv2cast-1.2.7.post4.dist-info/licenses/LICENSE +50 -0
mkv2cast/config.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for mkv2cast.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- XDG Base Directory compliance
|
|
6
|
+
- TOML/INI configuration file loading
|
|
7
|
+
- Config dataclass with all options
|
|
8
|
+
- Configuration merging (system -> user -> CLI)
|
|
9
|
+
- Automatic script mode detection
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import configparser
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
# -------------------- SCRIPT MODE DETECTION --------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_script_mode() -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Detect if running as a library (not CLI).
|
|
25
|
+
|
|
26
|
+
Returns True if:
|
|
27
|
+
- stdout is not a TTY (piped or redirected)
|
|
28
|
+
- NO_COLOR environment variable is set
|
|
29
|
+
- MKV2CAST_SCRIPT_MODE environment variable is set
|
|
30
|
+
- Being imported as a library (not running as __main__)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True if running in script mode, False otherwise.
|
|
34
|
+
"""
|
|
35
|
+
# Check if stdout is a TTY
|
|
36
|
+
try:
|
|
37
|
+
if not sys.stdout.isatty():
|
|
38
|
+
return True
|
|
39
|
+
except Exception:
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
# Check environment variables
|
|
43
|
+
if os.getenv("NO_COLOR") or os.getenv("MKV2CAST_SCRIPT_MODE"):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
# Check if being imported (not __main__)
|
|
47
|
+
try:
|
|
48
|
+
import __main__
|
|
49
|
+
|
|
50
|
+
# If __main__ has no __file__, we're likely in an interactive session or import
|
|
51
|
+
if not hasattr(__main__, "__file__"):
|
|
52
|
+
return True
|
|
53
|
+
# Check if the main module is mkv2cast CLI
|
|
54
|
+
main_file = getattr(__main__, "__file__", "") or ""
|
|
55
|
+
if "mkv2cast" not in main_file.lower():
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Try TOML support (Python 3.11+ or tomli package)
|
|
64
|
+
try:
|
|
65
|
+
import tomllib # Python 3.11+
|
|
66
|
+
|
|
67
|
+
TOML_AVAILABLE = True
|
|
68
|
+
except ImportError:
|
|
69
|
+
try:
|
|
70
|
+
import tomli as tomllib # pip install tomli
|
|
71
|
+
|
|
72
|
+
TOML_AVAILABLE = True
|
|
73
|
+
except ImportError:
|
|
74
|
+
TOML_AVAILABLE = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -------------------- XDG DIRECTORIES --------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_xdg_config_home() -> Path:
|
|
81
|
+
"""Get XDG config home directory."""
|
|
82
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_xdg_state_home() -> Path:
|
|
86
|
+
"""Get XDG state home directory."""
|
|
87
|
+
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_xdg_cache_home() -> Path:
|
|
91
|
+
"""Get XDG cache home directory."""
|
|
92
|
+
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_app_dirs() -> Dict[str, Path]:
|
|
96
|
+
"""Return all application directories, creating them if needed."""
|
|
97
|
+
dirs = {
|
|
98
|
+
"config": get_xdg_config_home() / "mkv2cast",
|
|
99
|
+
"state": get_xdg_state_home() / "mkv2cast",
|
|
100
|
+
"logs": get_xdg_state_home() / "mkv2cast" / "logs",
|
|
101
|
+
"cache": get_xdg_cache_home() / "mkv2cast",
|
|
102
|
+
"tmp": get_xdg_cache_home() / "mkv2cast" / "tmp",
|
|
103
|
+
}
|
|
104
|
+
for d in dirs.values():
|
|
105
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
return dirs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# -------------------- CONFIGURATION DATACLASS --------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Config:
|
|
114
|
+
"""All configuration options for mkv2cast."""
|
|
115
|
+
|
|
116
|
+
# Output settings
|
|
117
|
+
suffix: str = ".cast"
|
|
118
|
+
container: str = "mkv"
|
|
119
|
+
|
|
120
|
+
# Scan settings
|
|
121
|
+
recursive: bool = True
|
|
122
|
+
ignore_patterns: List[str] = field(default_factory=list)
|
|
123
|
+
ignore_paths: List[str] = field(default_factory=list)
|
|
124
|
+
include_patterns: List[str] = field(default_factory=list)
|
|
125
|
+
include_paths: List[str] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
# Debug/test
|
|
128
|
+
debug: bool = False
|
|
129
|
+
dryrun: bool = False
|
|
130
|
+
|
|
131
|
+
# Codec decisions
|
|
132
|
+
skip_when_ok: bool = True
|
|
133
|
+
force_h264: bool = False
|
|
134
|
+
allow_hevc: bool = False
|
|
135
|
+
force_aac: bool = False
|
|
136
|
+
keep_surround: bool = False
|
|
137
|
+
add_silence_if_no_audio: bool = True
|
|
138
|
+
|
|
139
|
+
# Encoding quality
|
|
140
|
+
abr: str = "192k"
|
|
141
|
+
crf: int = 20
|
|
142
|
+
preset: str = "slow"
|
|
143
|
+
profile: Optional[str] = None # fast, balanced, quality
|
|
144
|
+
|
|
145
|
+
# Hardware acceleration
|
|
146
|
+
vaapi_device: str = "/dev/dri/renderD128"
|
|
147
|
+
vaapi_qp: int = 23
|
|
148
|
+
qsv_quality: int = 23
|
|
149
|
+
nvenc_cq: int = 23 # NVIDIA NVENC constant quality (0-51, lower=better)
|
|
150
|
+
amf_quality: int = 23 # AMD AMF quality (0-51, lower=better)
|
|
151
|
+
hw: str = "auto" # auto, nvenc, amf, qsv, vaapi, cpu
|
|
152
|
+
|
|
153
|
+
# Audio track selection
|
|
154
|
+
audio_lang: Optional[str] = None # Comma-separated language codes (e.g., "fre,fra,fr,eng")
|
|
155
|
+
audio_track: Optional[int] = None # Explicit audio track index
|
|
156
|
+
|
|
157
|
+
# Subtitle selection
|
|
158
|
+
subtitle_lang: Optional[str] = None # Comma-separated language codes
|
|
159
|
+
subtitle_track: Optional[int] = None # Explicit subtitle track index
|
|
160
|
+
prefer_forced_subs: bool = True # Prefer forced subtitles in audio language
|
|
161
|
+
no_subtitles: bool = False # Disable all subtitles
|
|
162
|
+
|
|
163
|
+
# Preservation
|
|
164
|
+
preserve_metadata: bool = True
|
|
165
|
+
preserve_chapters: bool = True
|
|
166
|
+
preserve_attachments: bool = True
|
|
167
|
+
|
|
168
|
+
# Integrity checks
|
|
169
|
+
integrity_check: bool = True
|
|
170
|
+
stable_wait: int = 3
|
|
171
|
+
deep_check: bool = False
|
|
172
|
+
|
|
173
|
+
# Disk guards / quotas
|
|
174
|
+
disk_min_free_mb: int = 1024
|
|
175
|
+
disk_min_free_tmp_mb: int = 512
|
|
176
|
+
max_output_mb: int = 0
|
|
177
|
+
max_output_ratio: float = 0.0
|
|
178
|
+
|
|
179
|
+
# UI settings
|
|
180
|
+
progress: bool = True
|
|
181
|
+
bar_width: int = 26
|
|
182
|
+
ui_refresh_ms: int = 120
|
|
183
|
+
stats_period: float = 0.2
|
|
184
|
+
|
|
185
|
+
# Pipeline mode
|
|
186
|
+
pipeline: bool = True
|
|
187
|
+
|
|
188
|
+
# Parallelism (0 = auto)
|
|
189
|
+
encode_workers: int = 0
|
|
190
|
+
integrity_workers: int = 0
|
|
191
|
+
|
|
192
|
+
# Notifications (new)
|
|
193
|
+
notify: bool = True
|
|
194
|
+
notify_on_success: bool = True
|
|
195
|
+
notify_on_failure: bool = True
|
|
196
|
+
|
|
197
|
+
# Internationalization (new)
|
|
198
|
+
lang: Optional[str] = None
|
|
199
|
+
|
|
200
|
+
# JSON progress output (new)
|
|
201
|
+
json_progress: bool = False
|
|
202
|
+
|
|
203
|
+
# Retry / robustness
|
|
204
|
+
retry_attempts: int = 1
|
|
205
|
+
retry_delay_sec: float = 2.0
|
|
206
|
+
retry_fallback_cpu: bool = True
|
|
207
|
+
|
|
208
|
+
def __post_init__(self):
|
|
209
|
+
"""Apply automatic script mode detection after initialization."""
|
|
210
|
+
# Don't auto-disable if explicitly running in CLI mode
|
|
211
|
+
# (CLI will set these values explicitly)
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
def apply_script_mode(self) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Automatically disable UI features when running in script mode.
|
|
217
|
+
|
|
218
|
+
Call this method when using mkv2cast as a library to ensure
|
|
219
|
+
no unwanted output is generated.
|
|
220
|
+
|
|
221
|
+
Disables:
|
|
222
|
+
- progress: No progress bars
|
|
223
|
+
- notify: No desktop notifications
|
|
224
|
+
- pipeline: No Rich UI (use simple sequential mode)
|
|
225
|
+
"""
|
|
226
|
+
if is_script_mode():
|
|
227
|
+
self.progress = False
|
|
228
|
+
self.notify = False
|
|
229
|
+
self.pipeline = False
|
|
230
|
+
|
|
231
|
+
def apply_profile(self, name: str, only_if_default: bool = False) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Apply a preset profile to common encoding settings.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
name: Profile name ("fast", "balanced", "quality").
|
|
237
|
+
only_if_default: If True, only apply fields still at default values.
|
|
238
|
+
"""
|
|
239
|
+
profile = (name or "").strip().lower()
|
|
240
|
+
if not profile:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
defaults = Config()
|
|
244
|
+
|
|
245
|
+
def _set(attr: str, value: Any) -> None:
|
|
246
|
+
if not only_if_default or getattr(self, attr) == getattr(defaults, attr):
|
|
247
|
+
setattr(self, attr, value)
|
|
248
|
+
|
|
249
|
+
if profile == "fast":
|
|
250
|
+
_set("preset", "fast")
|
|
251
|
+
_set("crf", 23)
|
|
252
|
+
_set("abr", "160k")
|
|
253
|
+
_set("vaapi_qp", 28)
|
|
254
|
+
_set("qsv_quality", 28)
|
|
255
|
+
_set("nvenc_cq", 28)
|
|
256
|
+
_set("amf_quality", 28)
|
|
257
|
+
elif profile == "balanced":
|
|
258
|
+
_set("preset", "medium")
|
|
259
|
+
_set("crf", 21)
|
|
260
|
+
_set("abr", "192k")
|
|
261
|
+
_set("vaapi_qp", 23)
|
|
262
|
+
_set("qsv_quality", 23)
|
|
263
|
+
_set("nvenc_cq", 23)
|
|
264
|
+
_set("amf_quality", 23)
|
|
265
|
+
elif profile == "quality":
|
|
266
|
+
_set("preset", "slow")
|
|
267
|
+
_set("crf", 18)
|
|
268
|
+
_set("abr", "256k")
|
|
269
|
+
_set("vaapi_qp", 20)
|
|
270
|
+
_set("qsv_quality", 20)
|
|
271
|
+
_set("nvenc_cq", 20)
|
|
272
|
+
_set("amf_quality", 20)
|
|
273
|
+
else:
|
|
274
|
+
raise ValueError(f"Unknown profile: {profile}")
|
|
275
|
+
|
|
276
|
+
self.profile = profile
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def for_library(cls, **kwargs) -> "Config":
|
|
280
|
+
"""
|
|
281
|
+
Create a Config instance optimized for library usage.
|
|
282
|
+
|
|
283
|
+
Automatically disables UI features (progress bars, notifications,
|
|
284
|
+
Rich UI) that are not suitable for programmatic use.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
**kwargs: Configuration options to override defaults.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Config instance with script mode settings applied.
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
>>> config = Config.for_library(hw="vaapi", crf=20)
|
|
294
|
+
>>> success, output, msg = convert_file(path, cfg=config)
|
|
295
|
+
"""
|
|
296
|
+
# Set sensible defaults for library usage
|
|
297
|
+
defaults: Dict[str, Any] = {
|
|
298
|
+
"progress": False,
|
|
299
|
+
"notify": False,
|
|
300
|
+
"pipeline": False,
|
|
301
|
+
}
|
|
302
|
+
# User overrides take precedence
|
|
303
|
+
defaults.update(kwargs)
|
|
304
|
+
cfg = cls(**defaults)
|
|
305
|
+
if cfg.profile:
|
|
306
|
+
cfg.apply_profile(cfg.profile, only_if_default=False)
|
|
307
|
+
return cfg
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Global config instance (set by parse_args in cli.py)
|
|
311
|
+
CFG = Config()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# -------------------- CONFIG FILE LOADING --------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _parse_ini_value(value: str):
|
|
318
|
+
"""Parse INI value: bool, int, float, list (comma-sep), or string."""
|
|
319
|
+
v = value.strip()
|
|
320
|
+
if not v:
|
|
321
|
+
return ""
|
|
322
|
+
if v.lower() in ("true", "yes", "on"):
|
|
323
|
+
return True
|
|
324
|
+
if v.lower() in ("false", "no", "off"):
|
|
325
|
+
return False
|
|
326
|
+
# Try int
|
|
327
|
+
try:
|
|
328
|
+
return int(v)
|
|
329
|
+
except ValueError:
|
|
330
|
+
pass
|
|
331
|
+
# Try float
|
|
332
|
+
try:
|
|
333
|
+
return float(v)
|
|
334
|
+
except ValueError:
|
|
335
|
+
pass
|
|
336
|
+
# Check for comma-separated list
|
|
337
|
+
if "," in v:
|
|
338
|
+
return [x.strip() for x in v.split(",") if x.strip()]
|
|
339
|
+
return v
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _load_ini_config(path: Path) -> Dict[str, Any]:
|
|
343
|
+
"""Load INI file and convert to nested dict."""
|
|
344
|
+
cp = configparser.ConfigParser()
|
|
345
|
+
cp.read(path)
|
|
346
|
+
result: Dict[str, Any] = {}
|
|
347
|
+
for section in cp.sections():
|
|
348
|
+
result[section] = {}
|
|
349
|
+
for key, value in cp.items(section):
|
|
350
|
+
result[section][key] = _parse_ini_value(value)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _load_single_config(config_dir: Path) -> Dict[str, Any]:
|
|
355
|
+
"""Load config from a single directory (TOML or INI file)."""
|
|
356
|
+
toml_path = config_dir / "config.toml"
|
|
357
|
+
ini_path = config_dir / "config.ini"
|
|
358
|
+
|
|
359
|
+
if TOML_AVAILABLE and toml_path.exists():
|
|
360
|
+
try:
|
|
361
|
+
with toml_path.open("rb") as f:
|
|
362
|
+
return dict(tomllib.load(f))
|
|
363
|
+
except Exception as e:
|
|
364
|
+
import sys
|
|
365
|
+
|
|
366
|
+
print(f"Warning: Failed to load {toml_path}: {e}", file=sys.stderr)
|
|
367
|
+
return {}
|
|
368
|
+
elif ini_path.exists():
|
|
369
|
+
try:
|
|
370
|
+
return _load_ini_config(ini_path)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
import sys
|
|
373
|
+
|
|
374
|
+
print(f"Warning: Failed to load {ini_path}: {e}", file=sys.stderr)
|
|
375
|
+
return {}
|
|
376
|
+
return {}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _deep_merge_dicts(base: dict, override: dict) -> dict:
|
|
380
|
+
"""Deep merge two dicts, with override taking precedence."""
|
|
381
|
+
result = base.copy()
|
|
382
|
+
for key, value in override.items():
|
|
383
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
384
|
+
result[key] = _deep_merge_dicts(result[key], value)
|
|
385
|
+
else:
|
|
386
|
+
result[key] = value
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def load_config_file(config_dir: Path) -> dict:
|
|
391
|
+
"""
|
|
392
|
+
Load config with priority:
|
|
393
|
+
1. User config: ~/.config/mkv2cast/config.toml (highest priority)
|
|
394
|
+
2. System config: /etc/mkv2cast/config.toml (lowest priority, optional)
|
|
395
|
+
|
|
396
|
+
User config values override system config values.
|
|
397
|
+
"""
|
|
398
|
+
# System-wide config (optional, only if exists)
|
|
399
|
+
system_config_dir = Path("/etc/mkv2cast")
|
|
400
|
+
system_config = {}
|
|
401
|
+
if system_config_dir.exists():
|
|
402
|
+
system_config = _load_single_config(system_config_dir)
|
|
403
|
+
|
|
404
|
+
# User config (takes precedence)
|
|
405
|
+
user_config = _load_single_config(config_dir)
|
|
406
|
+
|
|
407
|
+
# Merge: system as base, user overrides
|
|
408
|
+
if system_config and user_config:
|
|
409
|
+
return _deep_merge_dicts(system_config, user_config)
|
|
410
|
+
elif user_config:
|
|
411
|
+
return user_config
|
|
412
|
+
elif system_config:
|
|
413
|
+
return system_config
|
|
414
|
+
return {}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _get_default_config_toml() -> str:
|
|
418
|
+
"""Return default config as TOML string."""
|
|
419
|
+
return """# mkv2cast configuration file
|
|
420
|
+
# This file is auto-generated on first run
|
|
421
|
+
|
|
422
|
+
[output]
|
|
423
|
+
suffix = ".cast"
|
|
424
|
+
container = "mkv"
|
|
425
|
+
|
|
426
|
+
[scan]
|
|
427
|
+
recursive = true
|
|
428
|
+
# Patterns to ignore (glob format, comma-separated in INI)
|
|
429
|
+
ignore_patterns = []
|
|
430
|
+
ignore_paths = []
|
|
431
|
+
# Patterns to include (only process matching files)
|
|
432
|
+
include_patterns = []
|
|
433
|
+
include_paths = []
|
|
434
|
+
|
|
435
|
+
[encoding]
|
|
436
|
+
backend = "auto" # auto, vaapi, qsv, cpu
|
|
437
|
+
# profile = "balanced" # fast, balanced, quality
|
|
438
|
+
crf = 20
|
|
439
|
+
preset = "slow"
|
|
440
|
+
abr = "192k"
|
|
441
|
+
|
|
442
|
+
[preserve]
|
|
443
|
+
metadata = true
|
|
444
|
+
chapters = true
|
|
445
|
+
attachments = true
|
|
446
|
+
|
|
447
|
+
[workers]
|
|
448
|
+
# 0 = auto-detect based on system
|
|
449
|
+
encode = 0
|
|
450
|
+
integrity = 0
|
|
451
|
+
|
|
452
|
+
[integrity]
|
|
453
|
+
enabled = true
|
|
454
|
+
stable_wait = 3
|
|
455
|
+
deep_check = false
|
|
456
|
+
|
|
457
|
+
[disk]
|
|
458
|
+
# Minimum free space to keep (MB)
|
|
459
|
+
min_free_mb = 1024
|
|
460
|
+
min_free_tmp_mb = 512
|
|
461
|
+
# Output size quotas (0 disables)
|
|
462
|
+
max_output_mb = 0
|
|
463
|
+
max_output_ratio = 0.0
|
|
464
|
+
|
|
465
|
+
[retry]
|
|
466
|
+
# Number of retries after a failure (0 disables)
|
|
467
|
+
attempts = 1
|
|
468
|
+
# Delay between retries (seconds)
|
|
469
|
+
delay_sec = 2.0
|
|
470
|
+
# Fallback to CPU encoder on last retry
|
|
471
|
+
fallback_cpu = true
|
|
472
|
+
|
|
473
|
+
[notifications]
|
|
474
|
+
# Desktop notifications when processing completes
|
|
475
|
+
enabled = true
|
|
476
|
+
on_success = true
|
|
477
|
+
on_failure = true
|
|
478
|
+
|
|
479
|
+
[i18n]
|
|
480
|
+
# Language for messages (auto-detected from system if not set)
|
|
481
|
+
# Supported: en, fr, es, it, de
|
|
482
|
+
# lang = "fr"
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _get_default_config_ini() -> str:
|
|
487
|
+
"""Return default config as INI string."""
|
|
488
|
+
return """# mkv2cast configuration file
|
|
489
|
+
# This file is auto-generated on first run
|
|
490
|
+
|
|
491
|
+
[output]
|
|
492
|
+
suffix = .cast
|
|
493
|
+
container = mkv
|
|
494
|
+
|
|
495
|
+
[scan]
|
|
496
|
+
recursive = true
|
|
497
|
+
# Lists as comma-separated values
|
|
498
|
+
ignore_patterns =
|
|
499
|
+
ignore_paths =
|
|
500
|
+
include_patterns =
|
|
501
|
+
include_paths =
|
|
502
|
+
|
|
503
|
+
[encoding]
|
|
504
|
+
backend = auto
|
|
505
|
+
; profile = balanced ; fast, balanced, quality
|
|
506
|
+
crf = 20
|
|
507
|
+
preset = slow
|
|
508
|
+
abr = 192k
|
|
509
|
+
|
|
510
|
+
[preserve]
|
|
511
|
+
metadata = true
|
|
512
|
+
chapters = true
|
|
513
|
+
attachments = true
|
|
514
|
+
|
|
515
|
+
[workers]
|
|
516
|
+
# 0 = auto-detect based on system
|
|
517
|
+
encode = 0
|
|
518
|
+
integrity = 0
|
|
519
|
+
|
|
520
|
+
[integrity]
|
|
521
|
+
enabled = true
|
|
522
|
+
stable_wait = 3
|
|
523
|
+
deep_check = false
|
|
524
|
+
|
|
525
|
+
[disk]
|
|
526
|
+
; Minimum free space to keep (MB)
|
|
527
|
+
min_free_mb = 1024
|
|
528
|
+
min_free_tmp_mb = 512
|
|
529
|
+
; Output size quotas (0 disables)
|
|
530
|
+
max_output_mb = 0
|
|
531
|
+
max_output_ratio = 0.0
|
|
532
|
+
|
|
533
|
+
[retry]
|
|
534
|
+
; Number of retries after a failure (0 disables)
|
|
535
|
+
attempts = 1
|
|
536
|
+
; Delay between retries (seconds)
|
|
537
|
+
delay_sec = 2.0
|
|
538
|
+
; Fallback to CPU encoder on last retry
|
|
539
|
+
fallback_cpu = true
|
|
540
|
+
|
|
541
|
+
[notifications]
|
|
542
|
+
# Desktop notifications when processing completes
|
|
543
|
+
enabled = true
|
|
544
|
+
on_success = true
|
|
545
|
+
on_failure = true
|
|
546
|
+
|
|
547
|
+
[i18n]
|
|
548
|
+
# Language for messages (auto-detected from system if not set)
|
|
549
|
+
# lang = fr
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def save_default_config(config_dir: Path) -> Path:
|
|
554
|
+
"""Create default config file (TOML if available, else INI). Returns path."""
|
|
555
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
|
|
557
|
+
if TOML_AVAILABLE:
|
|
558
|
+
path = config_dir / "config.toml"
|
|
559
|
+
if not path.exists():
|
|
560
|
+
path.write_text(_get_default_config_toml())
|
|
561
|
+
return path
|
|
562
|
+
else:
|
|
563
|
+
path = config_dir / "config.ini"
|
|
564
|
+
if not path.exists():
|
|
565
|
+
path.write_text(_get_default_config_ini())
|
|
566
|
+
return path
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def apply_config_to_args(file_config: dict, cfg: Config, cli_explicit: Optional[set] = None) -> None:
|
|
570
|
+
"""
|
|
571
|
+
Apply file config values to Config instance.
|
|
572
|
+
|
|
573
|
+
Only applies values from file config if they weren't explicitly set on CLI.
|
|
574
|
+
This ensures CLI arguments have priority over config file values.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
file_config: Dict from config file (TOML or INI)
|
|
578
|
+
cfg: Config instance with CLI-parsed values
|
|
579
|
+
cli_explicit: Optional set of attribute names explicitly set on CLI
|
|
580
|
+
"""
|
|
581
|
+
# Get default values for comparison
|
|
582
|
+
default_cfg = Config()
|
|
583
|
+
|
|
584
|
+
# Map config file keys to Config attribute names
|
|
585
|
+
mappings = {
|
|
586
|
+
("output", "suffix"): "suffix",
|
|
587
|
+
("output", "container"): "container",
|
|
588
|
+
("scan", "recursive"): "recursive",
|
|
589
|
+
("scan", "ignore_patterns"): "ignore_patterns",
|
|
590
|
+
("scan", "ignore_paths"): "ignore_paths",
|
|
591
|
+
("scan", "include_patterns"): "include_patterns",
|
|
592
|
+
("scan", "include_paths"): "include_paths",
|
|
593
|
+
("encoding", "backend"): "hw",
|
|
594
|
+
("encoding", "profile"): "profile",
|
|
595
|
+
("encoding", "crf"): "crf",
|
|
596
|
+
("encoding", "preset"): "preset",
|
|
597
|
+
("encoding", "abr"): "abr",
|
|
598
|
+
("preserve", "metadata"): "preserve_metadata",
|
|
599
|
+
("preserve", "chapters"): "preserve_chapters",
|
|
600
|
+
("preserve", "attachments"): "preserve_attachments",
|
|
601
|
+
("workers", "encode"): "encode_workers",
|
|
602
|
+
("workers", "integrity"): "integrity_workers",
|
|
603
|
+
("integrity", "enabled"): "integrity_check",
|
|
604
|
+
("integrity", "stable_wait"): "stable_wait",
|
|
605
|
+
("integrity", "deep_check"): "deep_check",
|
|
606
|
+
("disk", "min_free_mb"): "disk_min_free_mb",
|
|
607
|
+
("disk", "min_free_tmp_mb"): "disk_min_free_tmp_mb",
|
|
608
|
+
("disk", "max_output_mb"): "max_output_mb",
|
|
609
|
+
("disk", "max_output_ratio"): "max_output_ratio",
|
|
610
|
+
("retry", "attempts"): "retry_attempts",
|
|
611
|
+
("retry", "delay_sec"): "retry_delay_sec",
|
|
612
|
+
("retry", "fallback_cpu"): "retry_fallback_cpu",
|
|
613
|
+
("notifications", "enabled"): "notify",
|
|
614
|
+
("notifications", "on_success"): "notify_on_success",
|
|
615
|
+
("notifications", "on_failure"): "notify_on_failure",
|
|
616
|
+
("i18n", "lang"): "lang",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for (section, key), attr_name in mappings.items():
|
|
620
|
+
if section in file_config and key in file_config[section]:
|
|
621
|
+
file_val = file_config[section][key]
|
|
622
|
+
current_val = getattr(cfg, attr_name)
|
|
623
|
+
default_val = getattr(default_cfg, attr_name)
|
|
624
|
+
|
|
625
|
+
# Skip if CLI explicitly set this value (different from default)
|
|
626
|
+
# This ensures CLI args have priority over config file
|
|
627
|
+
if current_val != default_val:
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
# For lists that might be empty
|
|
631
|
+
if attr_name in ("ignore_patterns", "ignore_paths", "include_patterns", "include_paths"):
|
|
632
|
+
if not current_val and file_val:
|
|
633
|
+
if isinstance(file_val, list):
|
|
634
|
+
setattr(cfg, attr_name, file_val)
|
|
635
|
+
elif isinstance(file_val, str) and file_val:
|
|
636
|
+
setattr(cfg, attr_name, [file_val])
|
|
637
|
+
else:
|
|
638
|
+
setattr(cfg, attr_name, file_val)
|