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/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)