deezy 1.2.6__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.
Files changed (51) hide show
  1. cli/__init__.py +1075 -0
  2. cli/payload_factory.py +228 -0
  3. cli/utils.py +170 -0
  4. deezy/__init__.py +0 -0
  5. deezy/audio_encoders/base.py +120 -0
  6. deezy/audio_encoders/dee/ac4.py +274 -0
  7. deezy/audio_encoders/dee/atmos.py +184 -0
  8. deezy/audio_encoders/dee/base.py +240 -0
  9. deezy/audio_encoders/dee/dd.py +318 -0
  10. deezy/audio_encoders/dee/ddp.py +324 -0
  11. deezy/audio_encoders/dee/json/ac4_base.py +74 -0
  12. deezy/audio_encoders/dee/json/atmos_base.py +73 -0
  13. deezy/audio_encoders/dee/json/dd_base.py +86 -0
  14. deezy/audio_encoders/dee/json/dee_json_generator.py +240 -0
  15. deezy/audio_encoders/delay/__init__.py +143 -0
  16. deezy/audio_processors/dee.py +214 -0
  17. deezy/audio_processors/ffmpeg.py +176 -0
  18. deezy/audio_processors/truehdd.py +267 -0
  19. deezy/config/__init__.py +8 -0
  20. deezy/config/defaults.py +90 -0
  21. deezy/config/manager.py +223 -0
  22. deezy/enums/__init__.py +86 -0
  23. deezy/enums/ac4.py +36 -0
  24. deezy/enums/atmos.py +50 -0
  25. deezy/enums/dd.py +82 -0
  26. deezy/enums/ddp.py +198 -0
  27. deezy/enums/ddp_bluray.py +26 -0
  28. deezy/enums/shared.py +150 -0
  29. deezy/exceptions/__init__.py +42 -0
  30. deezy/info/__init__.py +206 -0
  31. deezy/payloads/ac4.py +19 -0
  32. deezy/payloads/atmos.py +11 -0
  33. deezy/payloads/dd.py +9 -0
  34. deezy/payloads/ddp.py +10 -0
  35. deezy/payloads/shared.py +88 -0
  36. deezy/track_info/audio_track_info.py +19 -0
  37. deezy/track_info/mediainfo.py +248 -0
  38. deezy/track_info/track_index.py +104 -0
  39. deezy/utils/__init__.py +0 -0
  40. deezy/utils/_version.py +33 -0
  41. deezy/utils/dependencies.py +82 -0
  42. deezy/utils/exit.py +24 -0
  43. deezy/utils/file_parser.py +42 -0
  44. deezy/utils/logger.py +49 -0
  45. deezy/utils/progress.py +233 -0
  46. deezy/utils/utils.py +39 -0
  47. deezy-1.2.6.dist-info/METADATA +900 -0
  48. deezy-1.2.6.dist-info/RECORD +51 -0
  49. deezy-1.2.6.dist-info/WHEEL +5 -0
  50. deezy-1.2.6.dist-info/licenses/LICENSE +21 -0
  51. deezy-1.2.6.dist-info/top_level.txt +2 -0
cli/__init__.py ADDED
@@ -0,0 +1,1075 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ import sys
4
+ import traceback
5
+
6
+ from cli.payload_factory import PayloadBuilder
7
+ from cli.utils import (
8
+ CustomHelpFormatter,
9
+ clean_deezy_temp_folders,
10
+ dialnorm_options,
11
+ get_deezy_temp_info,
12
+ int_0_100,
13
+ validate_track_index,
14
+ )
15
+ from deezy.audio_encoders.dee.ac4 import Ac4Encoder
16
+ from deezy.audio_encoders.dee.atmos import AtmosEncoder
17
+ from deezy.audio_encoders.dee.dd import DDEncoderDEE
18
+ from deezy.audio_encoders.dee.ddp import DDPEncoderDEE
19
+ from deezy.config.defaults import get_default_config_path
20
+ from deezy.config.manager import ConfigManager, get_config_manager
21
+ from deezy.enums import case_insensitive_enum, enum_choices
22
+ from deezy.enums.ac4 import Ac4EncodingProfile
23
+ from deezy.enums.atmos import AtmosMode, WarpMode
24
+ from deezy.enums.dd import DolbyDigitalChannels
25
+ from deezy.enums.ddp import DolbyDigitalPlusChannels
26
+ from deezy.enums.ddp_bluray import DolbyDigitalPlusBlurayChannels
27
+ from deezy.enums.shared import TrackType
28
+ from deezy.enums.shared import DeeDRC, LogLevel, MeteringMode, StereoDownmix
29
+ from deezy.info import parse_audio_streams
30
+ from deezy.track_info.track_index import TrackIndex
31
+ from deezy.utils._version import __version__, program_name
32
+ from deezy.utils.dependencies import DependencyNotFoundError, FindDependencies
33
+ from deezy.utils.exit import EXIT_FAIL, EXIT_SUCCESS, exit_application
34
+ from deezy.utils.file_parser import parse_input_s
35
+ from deezy.utils.logger import logger, logger_manager
36
+
37
+
38
+ def create_main_parser() -> argparse.ArgumentParser:
39
+ """Create the main argument parser with global options."""
40
+ parser = argparse.ArgumentParser(prog=program_name)
41
+ parser.add_argument(
42
+ "--version", action="version", version=f"%(prog)s {__version__}"
43
+ )
44
+ parser.add_argument(
45
+ "--config",
46
+ type=str,
47
+ help="Path to configuration file (default: deezy-conf.toml beside executable)",
48
+ metavar="CONFIG_FILE",
49
+ )
50
+ parser.add_argument(
51
+ "--log-level",
52
+ type=case_insensitive_enum(LogLevel),
53
+ default=LogLevel.INFO,
54
+ choices=LogLevel,
55
+ metavar=enum_choices(LogLevel),
56
+ help="Sets the log level (defaults to INFO).",
57
+ )
58
+ parser.add_argument(
59
+ "--log-to-file",
60
+ action="store_true",
61
+ help="Write log to file (defaults to input path with suffix of .log).",
62
+ )
63
+ parser.add_argument(
64
+ "--no-progress-bars",
65
+ action="store_true",
66
+ help="Disables progress bars on level INFO (disabled for DEBUG or higher).",
67
+ )
68
+ return parser
69
+
70
+
71
+ def create_common_argument_groups() -> dict[str, argparse.ArgumentParser]:
72
+ """Create reusable argument groups."""
73
+ # input files argument group
74
+ input_group = argparse.ArgumentParser(add_help=False)
75
+ input_group.add_argument(
76
+ "input", nargs="+", help="Input file paths or directories", metavar="INPUT"
77
+ )
78
+
79
+ # common Encode Args
80
+ encode_group = argparse.ArgumentParser(add_help=False)
81
+ encode_group.add_argument(
82
+ "--ffmpeg",
83
+ type=str,
84
+ help="Path to FFMPEG executable.",
85
+ )
86
+ encode_group.add_argument(
87
+ "--truehdd",
88
+ type=str,
89
+ help="Path to Truehdd executable.",
90
+ )
91
+ encode_group.add_argument(
92
+ "--dee",
93
+ type=str,
94
+ help="Path to Dolby Encoding Engine executable.",
95
+ )
96
+ encode_group.add_argument(
97
+ "--track-index",
98
+ type=validate_track_index,
99
+ default=TrackIndex(TrackType.AUDIO, 0),
100
+ help="Track to use for encoding. Supports: 'N' (audio track N), 'a:N' (audio track N), 's:N' (stream index N).",
101
+ )
102
+ encode_group.add_argument(
103
+ "--delay",
104
+ type=str,
105
+ help="The delay in milliseconds or seconds. Note '--delay=' is required! (--delay=-10ms / --delay=10s).",
106
+ )
107
+ encode_group.add_argument(
108
+ "--keep-temp",
109
+ action="store_true",
110
+ help="Keeps the temp files after finishing.",
111
+ )
112
+ encode_group.add_argument(
113
+ "--temp-dir",
114
+ type=str,
115
+ help=(
116
+ "Path to store temporary files to. If not specified this will "
117
+ "automatically happen in the temp dir of the os."
118
+ ),
119
+ )
120
+ encode_group.add_argument(
121
+ "--output",
122
+ type=str,
123
+ help=(
124
+ "The output file path. If not specified we will attempt to automatically add "
125
+ "Delay/Language string to output file name."
126
+ ),
127
+ )
128
+
129
+ # bitrate group
130
+ bitrate_group = argparse.ArgumentParser(add_help=False)
131
+ bitrate_group.add_argument(
132
+ "--bitrate",
133
+ type=int,
134
+ default=None,
135
+ help=(
136
+ "The bitrate in Kbps (If too high or low for you desired layout, "
137
+ "the bitrate will automatically be adjusted to the closest allowed bitrate)."
138
+ ),
139
+ )
140
+
141
+ # DD/DDP/Atmos DRC group (limited choices)
142
+ dd_ddp_atmos_drc_group = argparse.ArgumentParser(add_help=False)
143
+ dd_ddp_atmos_drc_choices = (
144
+ DeeDRC.FILM_STANDARD,
145
+ DeeDRC.FILM_LIGHT,
146
+ DeeDRC.MUSIC_STANDARD,
147
+ DeeDRC.MUSIC_LIGHT,
148
+ DeeDRC.SPEECH,
149
+ )
150
+ dd_ddp_atmos_drc_group.add_argument(
151
+ "--drc-line-mode",
152
+ type=case_insensitive_enum(DeeDRC),
153
+ choices=dd_ddp_atmos_drc_choices,
154
+ metavar=enum_choices(dd_ddp_atmos_drc_choices),
155
+ default=DeeDRC.FILM_LIGHT,
156
+ help="Dynamic range compression settings.",
157
+ )
158
+ dd_ddp_atmos_drc_group.add_argument(
159
+ "--drc-rf-mode",
160
+ type=case_insensitive_enum(DeeDRC),
161
+ choices=dd_ddp_atmos_drc_choices,
162
+ metavar=enum_choices(dd_ddp_atmos_drc_choices),
163
+ default=DeeDRC.FILM_LIGHT,
164
+ help="Dynamic range compression settings.",
165
+ )
166
+ dd_ddp_atmos_drc_group.add_argument(
167
+ "--custom-dialnorm",
168
+ type=dialnorm_options,
169
+ default=0,
170
+ help="Custom dialnorm (0 disables custom dialnorm).",
171
+ )
172
+
173
+ # shared loudness arguments (common to all formats)
174
+ loudness_group = argparse.ArgumentParser(add_help=False)
175
+ loudness_group.add_argument(
176
+ "--no-dialogue-intelligence",
177
+ action="store_false",
178
+ help="Dialogue Intelligence enabled. Option ignored for 1770-1 or LeqA metering mode.",
179
+ )
180
+ loudness_group.add_argument(
181
+ "--speech-threshold",
182
+ type=int_0_100,
183
+ default=15,
184
+ help=(
185
+ "[0-100] If the percentage of speech is higher than the threshold, the encoder uses speech "
186
+ "gating to set the dialnorm value. (Otherwise, the encoder uses level gating)."
187
+ ),
188
+ )
189
+
190
+ # DD/DDP limited metering mode (excludes MODE_1770_4)
191
+ dd_ddp_metering_group = argparse.ArgumentParser(add_help=False)
192
+ dd_ddp_metering_choices = (
193
+ MeteringMode.MODE_1770_1,
194
+ MeteringMode.MODE_1770_2,
195
+ MeteringMode.MODE_1770_3,
196
+ MeteringMode.MODE_LEQA,
197
+ )
198
+ dd_ddp_metering_group.add_argument(
199
+ "--metering-mode",
200
+ type=case_insensitive_enum(MeteringMode),
201
+ choices=dd_ddp_metering_choices,
202
+ metavar=enum_choices(dd_ddp_metering_choices),
203
+ default=MeteringMode.MODE_1770_3,
204
+ help="Loudness measuring mode according to one of the broadcast standards.",
205
+ )
206
+
207
+ # Atmos/AC4 full metering mode (includes MODE_1770_4)
208
+ atmos_ac4_metering_group = argparse.ArgumentParser(add_help=False)
209
+ atmos_ac4_metering_group.add_argument(
210
+ "--metering-mode",
211
+ type=case_insensitive_enum(MeteringMode),
212
+ choices=MeteringMode,
213
+ metavar=enum_choices(MeteringMode),
214
+ default=MeteringMode.MODE_1770_4,
215
+ help="Loudness measuring mode according to one of the broadcast standards.",
216
+ )
217
+
218
+ # DD/DDP specific processing options
219
+ dd_ddp_processing_group = argparse.ArgumentParser(add_help=False)
220
+ dd_ddp_processing_group.add_argument(
221
+ "--no-low-pass-filter",
222
+ action="store_false",
223
+ help="Disables low pass filter.",
224
+ )
225
+ dd_ddp_processing_group.add_argument(
226
+ "--no-surround-3db",
227
+ action="store_false",
228
+ help="Disables surround 3db attenuation.",
229
+ )
230
+ dd_ddp_processing_group.add_argument(
231
+ "--no-surround-90-deg-phase-shift",
232
+ action="store_false",
233
+ help="Disables surround 90 degree phase shift.",
234
+ )
235
+
236
+ # stereo downmix mode
237
+ stereo_downmix_group = argparse.ArgumentParser(add_help=False)
238
+ stereo_downmix_group.add_argument(
239
+ "--stereo-down-mix",
240
+ type=case_insensitive_enum(StereoDownmix),
241
+ choices=StereoDownmix,
242
+ default=StereoDownmix.LORO,
243
+ metavar=enum_choices(StereoDownmix),
244
+ help="Down mix method for stereo.",
245
+ )
246
+
247
+ # downmix metadata levels
248
+ downmix_metadata_group = argparse.ArgumentParser(add_help=False)
249
+ downmix_metadata_group.add_argument(
250
+ "--lt-rt-center",
251
+ type=str,
252
+ choices=("+3", "+1.5", "0", "-1.5", "-3", "-4.5", "-6", "-inf"),
253
+ default="-3",
254
+ help="Lt/Rt center downmix level.",
255
+ )
256
+ downmix_metadata_group.add_argument(
257
+ "--lt-rt-surround",
258
+ type=str,
259
+ choices=("-1.5", "-3", "-4.5", "-6", "-inf"),
260
+ default="-3",
261
+ help="Lt/Rt surround downmix level.",
262
+ )
263
+ downmix_metadata_group.add_argument(
264
+ "--lo-ro-center",
265
+ type=str,
266
+ choices=("+3", "+1.5", "0", "-1.5", "-3", "-4.5", "-6", "-inf"),
267
+ default="-3",
268
+ help="Lo/Ro center downmix level.",
269
+ )
270
+ downmix_metadata_group.add_argument(
271
+ "--lo-ro-surround",
272
+ type=str,
273
+ choices=("-1.5", "-3", "-4.5", "-6", "-inf"),
274
+ default="-3",
275
+ help="Lo/Ro surround downmix level.",
276
+ )
277
+
278
+ # DD channels group
279
+ dd_channels_group = argparse.ArgumentParser(add_help=False)
280
+ dd_channels_group.add_argument(
281
+ "--channels",
282
+ type=case_insensitive_enum(DolbyDigitalChannels),
283
+ choices=DolbyDigitalChannels,
284
+ default=DolbyDigitalChannels.AUTO,
285
+ metavar=enum_choices(DolbyDigitalChannels),
286
+ help="The number of channels.",
287
+ )
288
+
289
+ # DDP channels group
290
+ ddp_channels_group = argparse.ArgumentParser(add_help=False)
291
+ ddp_channels_group.add_argument(
292
+ "--channels",
293
+ type=case_insensitive_enum(DolbyDigitalPlusChannels),
294
+ choices=DolbyDigitalPlusChannels,
295
+ default=DolbyDigitalPlusChannels.AUTO,
296
+ metavar=enum_choices(DolbyDigitalPlusChannels),
297
+ help="The number of channels.",
298
+ )
299
+
300
+ # DDP BluRay channels group
301
+ ddp_bluray_channels_group = argparse.ArgumentParser(add_help=False)
302
+ ddp_bluray_channels_group.add_argument(
303
+ "--channels",
304
+ type=case_insensitive_enum(DolbyDigitalPlusBlurayChannels),
305
+ choices=DolbyDigitalPlusBlurayChannels,
306
+ default=DolbyDigitalPlusBlurayChannels.SURROUNDEX,
307
+ metavar=enum_choices(DolbyDigitalPlusBlurayChannels),
308
+ help="The number of channels.",
309
+ )
310
+
311
+ # atmos specific options
312
+ atmos_options_group = argparse.ArgumentParser(add_help=False)
313
+ atmos_options_group.add_argument(
314
+ "--atmos-mode",
315
+ type=case_insensitive_enum(AtmosMode),
316
+ choices=AtmosMode,
317
+ default=AtmosMode.STREAMING,
318
+ metavar=enum_choices(AtmosMode),
319
+ help="Atmos encoding mode.",
320
+ )
321
+
322
+ # trueHDD options (shared by Atmos and AC4)
323
+ truehdd_options_group = argparse.ArgumentParser(add_help=False)
324
+ truehdd_options_group.add_argument(
325
+ "--thd-warp-mode",
326
+ type=case_insensitive_enum(WarpMode),
327
+ choices=WarpMode,
328
+ default=WarpMode.NORMAL,
329
+ metavar=enum_choices(WarpMode),
330
+ help="Specify warp mode when not present in metadata (truehdd).",
331
+ )
332
+ truehdd_options_group.add_argument(
333
+ "--bed-conform",
334
+ action="store_true",
335
+ help="Enables bed conformance for Atmos content (truehd).",
336
+ )
337
+
338
+ # AC4 basic options
339
+ ac4_basic_options_group = argparse.ArgumentParser(add_help=False)
340
+ ac4_basic_options_group.add_argument(
341
+ "--ims-legacy-presentation",
342
+ action="store_true",
343
+ help=(
344
+ "Determines whether the Dolby AC-4 encoder inserts an additional "
345
+ "presentation for backward compatibility."
346
+ ),
347
+ )
348
+ ac4_basic_options_group.add_argument(
349
+ "--encoding-profile",
350
+ type=case_insensitive_enum(Ac4EncodingProfile),
351
+ choices=Ac4EncodingProfile,
352
+ metavar=enum_choices(Ac4EncodingProfile),
353
+ default=Ac4EncodingProfile.IMS,
354
+ help="Encoding profile. For encoding music content, select ims_music.",
355
+ )
356
+
357
+ # AC4 DRC options (full DeeDRC enum choices)
358
+ ac4_drc_group = argparse.ArgumentParser(add_help=False)
359
+ ac4_drc_args = (
360
+ "--ddp-drc",
361
+ "--flat-panel-drc",
362
+ "--home-theatre-drc",
363
+ "--portable-headphones-drc",
364
+ "--portable-speakers-drc",
365
+ )
366
+ for ac4_drc in ac4_drc_args:
367
+ ac4_drc_group.add_argument(
368
+ ac4_drc,
369
+ type=case_insensitive_enum(DeeDRC),
370
+ choices=DeeDRC,
371
+ metavar=enum_choices(DeeDRC),
372
+ default=DeeDRC.FILM_LIGHT,
373
+ help="Dynamic range compression settings for AC4.",
374
+ )
375
+
376
+ # preset metering mode (full choices for flexibility)
377
+ preset_metering_group = argparse.ArgumentParser(add_help=False)
378
+ preset_metering_group.add_argument(
379
+ "--metering-mode",
380
+ type=case_insensitive_enum(MeteringMode),
381
+ choices=MeteringMode,
382
+ metavar=enum_choices(MeteringMode),
383
+ help="Loudness measuring mode according to one of the broadcast standards.",
384
+ )
385
+
386
+ # preset override channels (flexible string type)
387
+ preset_channels_group = argparse.ArgumentParser(add_help=False)
388
+ preset_channels_group.add_argument(
389
+ "--channels",
390
+ type=str,
391
+ help="Override channels setting from preset (format depends on preset's format).",
392
+ )
393
+
394
+ return {
395
+ "input_group": input_group,
396
+ "encode_group": encode_group,
397
+ "bitrate_group": bitrate_group,
398
+ "dd_ddp_atmos_drc_group": dd_ddp_atmos_drc_group,
399
+ "loudness_group": loudness_group,
400
+ "dd_ddp_metering_group": dd_ddp_metering_group,
401
+ "atmos_ac4_metering_group": atmos_ac4_metering_group,
402
+ "preset_metering_group": preset_metering_group,
403
+ "dd_ddp_processing_group": dd_ddp_processing_group,
404
+ "stereo_downmix_group": stereo_downmix_group,
405
+ "downmix_metadata_group": downmix_metadata_group,
406
+ "dd_channels_group": dd_channels_group,
407
+ "ddp_channels_group": ddp_channels_group,
408
+ "ddp_bluray_channels_group": ddp_bluray_channels_group,
409
+ "atmos_options_group": atmos_options_group,
410
+ "truehdd_options_group": truehdd_options_group,
411
+ "ac4_basic_options_group": ac4_basic_options_group,
412
+ "ac4_drc_group": ac4_drc_group,
413
+ "preset_channels_group": preset_channels_group,
414
+ }
415
+
416
+
417
+ def create_encode_parsers(
418
+ subparsers: argparse._SubParsersAction,
419
+ argument_groups: dict[str, argparse.ArgumentParser],
420
+ ) -> None:
421
+ """Create encode command parsers."""
422
+ # encode command parser
423
+ encode_parser = subparsers.add_parser("encode", help="Encode management")
424
+ encode_subparsers = encode_parser.add_subparsers(
425
+ dest="format_command", required=True
426
+ )
427
+
428
+ ### Dolby Digital Command ###
429
+ _encode_dd_parser = encode_subparsers.add_parser(
430
+ "dd",
431
+ parents=(
432
+ argument_groups["input_group"],
433
+ argument_groups["encode_group"],
434
+ argument_groups["bitrate_group"],
435
+ argument_groups["dd_ddp_atmos_drc_group"],
436
+ argument_groups["loudness_group"],
437
+ argument_groups["dd_ddp_metering_group"],
438
+ argument_groups["dd_ddp_processing_group"],
439
+ argument_groups["stereo_downmix_group"],
440
+ argument_groups["downmix_metadata_group"],
441
+ argument_groups["dd_channels_group"],
442
+ ),
443
+ formatter_class=lambda prog: CustomHelpFormatter(
444
+ prog,
445
+ width=78,
446
+ max_help_position=7,
447
+ ),
448
+ )
449
+
450
+ ### Dolby Digital Plus Command ###
451
+ _encode_ddp_parser = encode_subparsers.add_parser(
452
+ "ddp",
453
+ parents=(
454
+ argument_groups["input_group"],
455
+ argument_groups["encode_group"],
456
+ argument_groups["bitrate_group"],
457
+ argument_groups["dd_ddp_atmos_drc_group"],
458
+ argument_groups["loudness_group"],
459
+ argument_groups["dd_ddp_metering_group"],
460
+ argument_groups["dd_ddp_processing_group"],
461
+ argument_groups["stereo_downmix_group"],
462
+ argument_groups["downmix_metadata_group"],
463
+ argument_groups["ddp_channels_group"],
464
+ ),
465
+ formatter_class=lambda prog: CustomHelpFormatter(
466
+ prog,
467
+ width=78,
468
+ max_help_position=7,
469
+ ),
470
+ )
471
+
472
+ ### Dolby Digital Plus BluRay Command ###
473
+ _encode_ddp_bluray_parser = encode_subparsers.add_parser(
474
+ "ddp-bluray",
475
+ parents=(
476
+ argument_groups["input_group"],
477
+ argument_groups["encode_group"],
478
+ argument_groups["bitrate_group"],
479
+ argument_groups["dd_ddp_atmos_drc_group"],
480
+ argument_groups["loudness_group"],
481
+ argument_groups["dd_ddp_metering_group"],
482
+ argument_groups["dd_ddp_processing_group"],
483
+ argument_groups["stereo_downmix_group"],
484
+ argument_groups["downmix_metadata_group"],
485
+ argument_groups["ddp_bluray_channels_group"],
486
+ ),
487
+ formatter_class=lambda prog: CustomHelpFormatter(
488
+ prog,
489
+ width=78,
490
+ max_help_position=7,
491
+ ),
492
+ )
493
+
494
+ ### Atmos Command ###
495
+ _encode_atmos_parser = encode_subparsers.add_parser(
496
+ "atmos",
497
+ parents=(
498
+ argument_groups["input_group"],
499
+ argument_groups["encode_group"],
500
+ argument_groups["bitrate_group"],
501
+ argument_groups["dd_ddp_atmos_drc_group"],
502
+ argument_groups["loudness_group"],
503
+ argument_groups["atmos_ac4_metering_group"],
504
+ argument_groups["downmix_metadata_group"],
505
+ argument_groups["stereo_downmix_group"],
506
+ argument_groups["atmos_options_group"],
507
+ argument_groups["truehdd_options_group"],
508
+ ),
509
+ formatter_class=lambda prog: CustomHelpFormatter(
510
+ prog,
511
+ width=78,
512
+ max_help_position=7,
513
+ ),
514
+ )
515
+
516
+ ### AC4 Command ###
517
+ _encode_ac4_parser = encode_subparsers.add_parser(
518
+ "ac4",
519
+ parents=(
520
+ argument_groups["input_group"],
521
+ argument_groups["encode_group"],
522
+ argument_groups["bitrate_group"],
523
+ argument_groups["loudness_group"],
524
+ argument_groups["atmos_ac4_metering_group"],
525
+ argument_groups["ac4_basic_options_group"],
526
+ argument_groups["ac4_drc_group"],
527
+ argument_groups["truehdd_options_group"],
528
+ ),
529
+ formatter_class=lambda prog: CustomHelpFormatter(
530
+ prog,
531
+ width=78,
532
+ max_help_position=7,
533
+ ),
534
+ )
535
+
536
+ ### Preset Command ###
537
+ encode_preset_parser = encode_subparsers.add_parser(
538
+ "preset",
539
+ parents=(
540
+ argument_groups["input_group"],
541
+ argument_groups["encode_group"],
542
+ argument_groups["bitrate_group"],
543
+ argument_groups["dd_ddp_atmos_drc_group"],
544
+ argument_groups["loudness_group"],
545
+ argument_groups["preset_metering_group"],
546
+ argument_groups["dd_ddp_processing_group"],
547
+ argument_groups["stereo_downmix_group"],
548
+ argument_groups["downmix_metadata_group"],
549
+ argument_groups["preset_channels_group"],
550
+ argument_groups["atmos_options_group"],
551
+ argument_groups["truehdd_options_group"],
552
+ argument_groups["ac4_basic_options_group"],
553
+ argument_groups["ac4_drc_group"],
554
+ ),
555
+ formatter_class=lambda prog: CustomHelpFormatter(
556
+ prog,
557
+ width=78,
558
+ max_help_position=7,
559
+ ),
560
+ help="Encode using a preset configuration (format determined from preset).",
561
+ )
562
+ encode_preset_parser.add_argument(
563
+ "--name",
564
+ dest="preset_name",
565
+ required=True,
566
+ help="Name of the preset to use for encoding.",
567
+ metavar="PRESET_NAME",
568
+ )
569
+
570
+
571
+ def create_other_parsers(
572
+ subparsers: argparse._SubParsersAction,
573
+ argument_groups: dict[str, argparse.ArgumentParser],
574
+ ) -> None:
575
+ """Create find, info, and config command parsers."""
576
+ # find command parser
577
+ find_parser = subparsers.add_parser(
578
+ "find", parents=[argument_groups["input_group"]], help="Find management"
579
+ )
580
+ find_parser.add_argument(
581
+ "-n",
582
+ "--name",
583
+ action="store_true",
584
+ help="Only display names instead of full paths.",
585
+ )
586
+
587
+ # info command parser
588
+ _info_parser = subparsers.add_parser(
589
+ "info", parents=[argument_groups["input_group"]], help="Info management"
590
+ )
591
+
592
+ # config command parser
593
+ config_parser = subparsers.add_parser("config", help="Configuration management")
594
+ config_subparsers = config_parser.add_subparsers(
595
+ dest="config_command", required=True
596
+ )
597
+
598
+ # generate config subcommand
599
+ generate_parser = config_subparsers.add_parser(
600
+ "generate", help="Generate configuration file"
601
+ )
602
+ generate_parser.add_argument(
603
+ "-o",
604
+ "--output",
605
+ type=str,
606
+ help="Output path for config file (default: auto-detect)",
607
+ )
608
+ generate_parser.add_argument(
609
+ "--overwrite", action="store_true", help="Overwrite existing config file"
610
+ )
611
+ generate_parser.add_argument(
612
+ "--from-args",
613
+ action="store_true",
614
+ help="Generate config from current CLI arguments (use with encode command)",
615
+ )
616
+
617
+ # info config subcommand
618
+ info_parser = config_subparsers.add_parser(
619
+ "info", help="Show configuration information"
620
+ )
621
+ info_parser.add_argument("--path", type=str, help="Show specific config file path")
622
+
623
+ # validate config subcommand
624
+ validate_parser = config_subparsers.add_parser(
625
+ "validate", help="Validate configuration file"
626
+ )
627
+ validate_parser.add_argument("--preset", type=str, help="Validate specific preset")
628
+
629
+ # list presets subcommand
630
+ list_parser = config_subparsers.add_parser(
631
+ "list-presets", help="List available presets"
632
+ )
633
+ list_parser.add_argument(
634
+ "--detailed", action="store_true", help="Show detailed preset information"
635
+ )
636
+
637
+ # temp management command parser
638
+ temp_parser = subparsers.add_parser("temp", help="Temporary folder management")
639
+ temp_subparsers = temp_parser.add_subparsers(dest="temp_command", required=True)
640
+
641
+ # clean temp subcommand
642
+ clean_parser = temp_subparsers.add_parser("clean", help="Clean DeeZy temp folders")
643
+ clean_parser.add_argument(
644
+ "--max-age",
645
+ type=int,
646
+ default=24,
647
+ help="Remove folders older than N hours (default: 24)",
648
+ metavar="HOURS",
649
+ )
650
+ clean_parser.add_argument(
651
+ "--dry-run",
652
+ action="store_true",
653
+ help="Show what would be deleted without actually deleting",
654
+ )
655
+ # info temp subcommand
656
+ temp_subparsers.add_parser("info", help="Show temp folder information")
657
+
658
+
659
+ def apply_default_bitrate(
660
+ args: argparse.Namespace, config_manager: ConfigManager | None
661
+ ) -> None:
662
+ """Apply default bitrate based on format and channels/mode if none specified."""
663
+ if not hasattr(args, "bitrate") or args.bitrate is None:
664
+ # determine the channel/mode key based on format
665
+ channels_or_mode = None
666
+ if args.format_command == "atmos":
667
+ # for Atmos, use the mode (streaming/bluray)
668
+ if hasattr(args, "atmos_mode") and args.atmos_mode:
669
+ channels_or_mode = (
670
+ args.atmos_mode.value
671
+ if hasattr(args.atmos_mode, "value")
672
+ else str(args.atmos_mode)
673
+ )
674
+ else:
675
+ # for DD/DDP, use channels
676
+ if hasattr(args, "channels") and args.channels:
677
+ channels_or_mode = args.channels
678
+
679
+ # get default bitrate from config
680
+ if config_manager is not None:
681
+ default_bitrate = config_manager.get_default_bitrate(
682
+ args.format_command, channels_or_mode
683
+ )
684
+
685
+ if default_bitrate:
686
+ args.bitrate = default_bitrate
687
+ logger.debug(
688
+ f"No bitrate specified, using default {default_bitrate}k for {args.format_command}"
689
+ )
690
+
691
+
692
+ def handle_preset_injection() -> None:
693
+ """Handle preset injection into sys.argv before argparse runs."""
694
+
695
+ # check if this is a preset command
696
+ if len(sys.argv) >= 4 and "preset" in sys.argv and "--name" in sys.argv:
697
+ try:
698
+ name_idx = sys.argv.index("--name")
699
+
700
+ # get preset name (should be right after --name)
701
+ if name_idx + 1 < len(sys.argv):
702
+ preset_name = sys.argv[name_idx + 1]
703
+
704
+ # load config and get preset
705
+ config_manager = get_config_manager()
706
+ config_manager.load_config()
707
+ config_manager.inject_preset_args(preset_name)
708
+
709
+ except (ValueError, IndexError):
710
+ # preset parsing failed, let argparse handle the error
711
+ pass
712
+
713
+
714
+ def cli_parser(base_wd: Path) -> None:
715
+ """Main CLI parser entry point."""
716
+ # handle presets by injecting args before parsing
717
+ handle_preset_injection()
718
+
719
+ # create parser and subcommands
720
+ parser = create_main_parser()
721
+ subparsers = parser.add_subparsers(dest="sub_command")
722
+
723
+ # create argument groups and parsers
724
+ argument_groups = create_common_argument_groups()
725
+ create_encode_parsers(subparsers, argument_groups)
726
+ create_other_parsers(subparsers, argument_groups)
727
+
728
+ # parse arguments and initialize
729
+ args = parser.parse_args()
730
+ setup_logging(args)
731
+
732
+ if not args.sub_command:
733
+ if not hasattr(args, "version"):
734
+ parser.print_usage()
735
+ exit_application("", EXIT_FAIL)
736
+
737
+ # handle configuration and dependencies
738
+ config_manager = handle_configuration(args)
739
+
740
+ # apply default bitrates for encoding commands
741
+ if args.sub_command == "encode" and hasattr(args, "format_command"):
742
+ apply_default_bitrate(args, config_manager)
743
+ dependencies = handle_dependencies(args, base_wd, config_manager)
744
+ file_inputs = handle_file_inputs(args)
745
+
746
+ # execute the appropriate command
747
+ execute_command(args, file_inputs, dependencies, config_manager)
748
+
749
+
750
+ def setup_logging(args: argparse.Namespace) -> None:
751
+ """Initialize logging based on arguments."""
752
+ logger_manager.set_level(args.log_level.to_logging_level())
753
+
754
+
755
+ def handle_configuration(args: argparse.Namespace) -> ConfigManager | None:
756
+ """Load configuration manager."""
757
+ config_manager = None
758
+ if args.sub_command != "config":
759
+ config_manager = get_config_manager()
760
+ return config_manager
761
+
762
+
763
+ def handle_dependencies(
764
+ args: argparse.Namespace, base_wd: Path, config_manager: ConfigManager | None
765
+ ) -> dict[str, Path | None] | None:
766
+ """
767
+ Handle tool dependencies detection.
768
+ CLI > Config.
769
+ """
770
+ if args.sub_command in ("config", "temp"):
771
+ return None
772
+
773
+ deps = {}
774
+ config_deps = (
775
+ config_manager.config.get("dependencies", {}) if config_manager else {}
776
+ )
777
+
778
+ for key in ("ffmpeg", "truehdd", "dee"):
779
+ cli_value = getattr(args, key, None)
780
+ config_value = config_deps.get(key)
781
+ deps[key] = cli_value or config_value
782
+
783
+ atmos_required = getattr(args, "format_command", None) in ("atmos", "ac4")
784
+
785
+ try:
786
+ tools = FindDependencies().get_dependencies(
787
+ base_wd,
788
+ deps["ffmpeg"],
789
+ deps["truehdd"],
790
+ deps["dee"],
791
+ require_truehdd=atmos_required,
792
+ )
793
+ except DependencyNotFoundError as e:
794
+ exit_application(str(e), EXIT_FAIL)
795
+
796
+ return {
797
+ "ffmpeg_path": Path(tools.ffmpeg),
798
+ "truehdd_path": Path(tools.truehdd) if tools.truehdd else None,
799
+ "dee_path": Path(tools.dee),
800
+ }
801
+
802
+
803
+ def handle_file_inputs(args: argparse.Namespace) -> list[Path]:
804
+ """Parse and validate file inputs."""
805
+ if not hasattr(args, "input") or not args.input:
806
+ if args.sub_command not in {"config", "temp"}:
807
+ exit_application("", EXIT_FAIL)
808
+ return []
809
+
810
+ if args.sub_command not in ("find", "info", "encode"):
811
+ return []
812
+
813
+ # parse all possible file inputs
814
+ file_inputs = parse_input_s(args.input)
815
+ if not file_inputs and args.sub_command in ("find", "info", "encode"):
816
+ exit_application("No input files were found.", EXIT_FAIL)
817
+
818
+ return file_inputs
819
+
820
+
821
+ def execute_command(
822
+ args: argparse.Namespace,
823
+ file_inputs: list[Path],
824
+ dependencies: dict[str, Path | None] | None,
825
+ config_manager: ConfigManager | None,
826
+ ) -> None:
827
+ """Execute the appropriate command based on parsed arguments."""
828
+ if args.sub_command == "encode":
829
+ if dependencies is None:
830
+ exit_application("Dependencies not found for encoding.", EXIT_FAIL)
831
+ execute_encode_command(args, file_inputs, dependencies)
832
+ elif args.sub_command == "find":
833
+ execute_find_command(args, file_inputs)
834
+ elif args.sub_command == "info":
835
+ execute_info_command(args, file_inputs)
836
+ elif args.sub_command == "config":
837
+ execute_config_command(args, config_manager)
838
+ elif args.sub_command == "temp":
839
+ execute_temp_command(args)
840
+
841
+
842
+ def execute_encode_command(
843
+ args: argparse.Namespace,
844
+ file_inputs: list[Path],
845
+ dependencies: dict[str, Path | None],
846
+ ) -> None:
847
+ """Execute encoding commands."""
848
+ ffmpeg_path = dependencies["ffmpeg_path"]
849
+ truehdd_path = dependencies["truehdd_path"]
850
+ dee_path = dependencies["dee_path"]
851
+
852
+ # assert required paths are not None
853
+ assert ffmpeg_path is not None, "ffmpeg_path is required for encoding"
854
+ assert dee_path is not None, "dee_path is required for encoding"
855
+
856
+ try:
857
+ for input_file in file_inputs:
858
+ # update logger to write to file if needed
859
+ if getattr(args, "log_to_file", False):
860
+ logger_manager.set_file(input_file.with_suffix(".log"))
861
+
862
+ # build payload based on format
863
+ if args.format_command == "dd":
864
+ payload = PayloadBuilder.build_dd_payload(
865
+ args, input_file, ffmpeg_path, truehdd_path, dee_path
866
+ )
867
+ result = DDEncoderDEE(payload).encode()
868
+ elif args.format_command in ("ddp", "ddp-bluray"):
869
+ payload = PayloadBuilder.build_ddp_payload(
870
+ args, input_file, ffmpeg_path, truehdd_path, dee_path
871
+ )
872
+ result = DDPEncoderDEE(payload).encode()
873
+ elif args.format_command == "atmos":
874
+ payload = PayloadBuilder.build_atmos_payload(
875
+ args, input_file, ffmpeg_path, truehdd_path, dee_path
876
+ )
877
+ result = AtmosEncoder(payload).encode()
878
+ elif args.format_command == "ac4":
879
+ payload = PayloadBuilder.build_ac4_payload(
880
+ args, input_file, ffmpeg_path, truehdd_path, dee_path
881
+ )
882
+ result = Ac4Encoder(payload).encode()
883
+ elif args.format_command == "preset":
884
+ # this should not happen since config manager converts "preset" to actual format
885
+ exit_application(
886
+ "Preset format conversion failed. This is a bug in the configuration system.",
887
+ EXIT_FAIL,
888
+ )
889
+ else:
890
+ exit_application(f"Unknown format: {args.format_command}", EXIT_FAIL)
891
+
892
+ logger.info(f"Job successful! Output file path:\n{result}")
893
+
894
+ except Exception as e:
895
+ logger.debug(traceback.format_exc())
896
+ exit_application(str(e), EXIT_FAIL)
897
+
898
+
899
+ def execute_find_command(args: argparse.Namespace, file_inputs: list[Path]) -> None:
900
+ """Execute find command."""
901
+ file_names = []
902
+ for input_file in file_inputs:
903
+ # if name only is used, print only the name of the file.
904
+ if args.name:
905
+ input_file = input_file.name
906
+ file_names.append(str(input_file))
907
+
908
+ exit_application("\n".join(file_names), EXIT_SUCCESS)
909
+
910
+
911
+ def execute_info_command(_args: argparse.Namespace, file_inputs: list[Path]) -> None:
912
+ """Execute info command."""
913
+ track_s_info = ""
914
+ for input_file in file_inputs:
915
+ info = parse_audio_streams(input_file)
916
+ if info.media_info:
917
+ track_s_info = (
918
+ track_s_info
919
+ + f"File: {input_file.name}\nAudio tracks: {info.track_list}\n"
920
+ + info.media_info
921
+ + "\n\n"
922
+ )
923
+ exit_application(track_s_info, EXIT_SUCCESS)
924
+
925
+
926
+ def execute_config_command(
927
+ args: argparse.Namespace, config_manager: ConfigManager | None
928
+ ) -> None:
929
+ """Execute config command."""
930
+ # config commands need their own manager instance
931
+ if config_manager is None:
932
+ config_manager = get_config_manager()
933
+
934
+ if args.config_command == "generate":
935
+ try:
936
+ output_path = Path(args.output) if args.output else None
937
+
938
+ # generate config (simplified - no from_args support yet)
939
+ config_path = config_manager.generate_config(
940
+ output_path=output_path, overwrite=args.overwrite
941
+ )
942
+
943
+ exit_application(
944
+ f"Configuration file generated: {config_path}", EXIT_SUCCESS
945
+ )
946
+
947
+ except FileExistsError:
948
+ exit_application(
949
+ "Configuration file already exists. Use --overwrite to replace it.",
950
+ EXIT_FAIL,
951
+ )
952
+ except Exception as e:
953
+ exit_application(f"Failed to generate config: {e}", EXIT_FAIL)
954
+
955
+ elif args.config_command == "info":
956
+ try:
957
+ if args.path:
958
+ config_path = Path(args.path)
959
+ if config_path.exists():
960
+ exit_application(f"Config file: {config_path}", EXIT_SUCCESS)
961
+ else:
962
+ exit_application(f"Config file not found: {config_path}", EXIT_FAIL)
963
+ else:
964
+ if config_manager.config_path:
965
+ info_text = f"Active config file: {config_manager.config_path}\n"
966
+ info_text += f"Presets available: {', '.join(config_manager.list_presets()) or 'None'}"
967
+ else:
968
+ info_text = (
969
+ "No configuration file found. Using built-in defaults.\n"
970
+ )
971
+ info_text += f"Default config location: {get_default_config_path()}"
972
+
973
+ exit_application(info_text, EXIT_SUCCESS)
974
+ except Exception as e:
975
+ exit_application(f"Failed to load config info: {e}", EXIT_FAIL)
976
+
977
+ elif args.config_command == "validate":
978
+ try:
979
+ if not config_manager.has_valid_config():
980
+ exit_application("No configuration file found to validate.", EXIT_FAIL)
981
+
982
+ if args.preset:
983
+ # validate specific preset
984
+ if args.preset not in config_manager.list_presets():
985
+ available = ", ".join(config_manager.list_presets()) or "None"
986
+ exit_application(
987
+ f"Preset '{args.preset}' not found. Available: {available}",
988
+ EXIT_FAIL,
989
+ )
990
+
991
+ if config_manager.validate_preset(args.preset):
992
+ exit_application(f"Preset '{args.preset}' is valid.", EXIT_SUCCESS)
993
+ else:
994
+ exit_application(f"Preset '{args.preset}' is invalid.", EXIT_FAIL)
995
+ else:
996
+ # validate all presets
997
+ presets = config_manager.list_presets()
998
+ if not presets:
999
+ exit_application("No presets found in configuration.", EXIT_SUCCESS)
1000
+
1001
+ invalid_presets = []
1002
+ for preset in presets:
1003
+ if not config_manager.validate_preset(preset):
1004
+ invalid_presets.append(preset)
1005
+
1006
+ if invalid_presets:
1007
+ exit_application(
1008
+ f"Invalid presets found: {', '.join(invalid_presets)}",
1009
+ EXIT_FAIL,
1010
+ )
1011
+ else:
1012
+ exit_application(
1013
+ f"All {len(presets)} presets are valid.", EXIT_SUCCESS
1014
+ )
1015
+
1016
+ except Exception as e:
1017
+ exit_application(f"Failed to validate config: {e}", EXIT_FAIL)
1018
+
1019
+ elif args.config_command == "list-presets":
1020
+ try:
1021
+ presets = config_manager.list_presets()
1022
+ if not presets:
1023
+ exit_application("No presets found in configuration.", EXIT_SUCCESS)
1024
+
1025
+ if args.detailed:
1026
+ info_lines = ["Available presets:"]
1027
+ for preset in presets:
1028
+ preset_info = config_manager.get_preset_info(preset)
1029
+ valid = "✓" if config_manager.validate_preset(preset) else "✗"
1030
+ info_lines.append(f" {valid} {preset}: {preset_info['command']}")
1031
+ exit_application("\n".join(info_lines), EXIT_SUCCESS)
1032
+ else:
1033
+ exit_application(
1034
+ f"Available presets: {', '.join(presets)}", EXIT_SUCCESS
1035
+ )
1036
+
1037
+ except Exception as e:
1038
+ exit_application(f"Failed to list presets: {e}", EXIT_FAIL)
1039
+
1040
+
1041
+ def execute_temp_command(args: argparse.Namespace) -> None:
1042
+ """Execute temp management commands."""
1043
+ if args.temp_command == "clean":
1044
+ removed_count, size_mb = clean_deezy_temp_folders(
1045
+ max_age_hours=args.max_age, dry_run=args.dry_run
1046
+ )
1047
+
1048
+ if args.dry_run:
1049
+ if removed_count == 0:
1050
+ exit_application("No temp folders to clean.", EXIT_SUCCESS)
1051
+ else:
1052
+ exit_application(
1053
+ f"Would remove {removed_count} folders ({size_mb:.1f} MB)",
1054
+ EXIT_SUCCESS,
1055
+ )
1056
+ else:
1057
+ if removed_count == 0:
1058
+ exit_application("No temp folders to clean.", EXIT_SUCCESS)
1059
+ else:
1060
+ exit_application(
1061
+ f"Removed {removed_count} temp folders ({size_mb:.1f} MB)",
1062
+ EXIT_SUCCESS,
1063
+ )
1064
+
1065
+ elif args.temp_command == "info":
1066
+ deezy_temp_base, job_folders, total_size_mb = get_deezy_temp_info()
1067
+
1068
+ if not job_folders:
1069
+ exit_application("No DeeZy temp folders found.", EXIT_SUCCESS)
1070
+
1071
+ message = f"DeeZy temp folder: {deezy_temp_base}\n"
1072
+ message += f"Job folders: {len(job_folders)}\n"
1073
+ message += f"Total size: {total_size_mb:.1f} MB"
1074
+
1075
+ exit_application(message, EXIT_SUCCESS)