mkvpriority 1.5.0__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.
@@ -0,0 +1,42 @@
1
+ import importlib.metadata
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ from .main import (
6
+ Config,
7
+ Database,
8
+ Extension,
9
+ Track,
10
+ extract_tracks,
11
+ identify_tracks,
12
+ modify_tracks,
13
+ process_file,
14
+ process_tracks,
15
+ restore_file,
16
+ restore_tracks,
17
+ )
18
+
19
+ try:
20
+ __version__ = importlib.metadata.version('mkvpriority')
21
+ except importlib.metadata.PackageNotFoundError:
22
+ __version__ = '(Unknown Version)'
23
+ try:
24
+ with Path('/app/pyproject.toml').open('rb') as f:
25
+ __version__ = tomllib.load(f)['project']['version']
26
+ except FileNotFoundError, KeyError:
27
+ pass
28
+
29
+
30
+ __all__ = [
31
+ 'Config',
32
+ 'Database',
33
+ 'Extension',
34
+ 'Track',
35
+ 'extract_tracks',
36
+ 'identify_tracks',
37
+ 'modify_tracks',
38
+ 'process_file',
39
+ 'process_tracks',
40
+ 'restore_file',
41
+ 'restore_tracks',
42
+ ]
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,201 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import shlex
6
+ import shutil
7
+ import signal
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ import cron_descriptor
12
+ import pycountry
13
+ from aiohttp import web
14
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
15
+ from apscheduler.triggers.cron import CronTrigger
16
+ from cron_descriptor import FormatError
17
+
18
+ import mkvpriority
19
+ from mkvpriority import __version__
20
+ from mkvpriority.main import setup_logging
21
+
22
+ entrypoint_logger = logging.getLogger('entrypoint')
23
+ processing_queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
24
+
25
+
26
+ MKVPRIORITY_ARGS = ['-c', '/config/config.toml'] + shlex.split(os.getenv('MKVPRIORITY_ARGS', ''))
27
+ LOG_MAX_BYTES, LOG_MAX_FILES = os.getenv('LOG_MAX_BYTES'), os.getenv('LOG_MAX_FILES')
28
+
29
+ CUSTOM_SCRIPT = os.getenv('CUSTOM_SCRIPT', 'false').lower() in ('true', '1', 't')
30
+ WEBHOOK_RECEIVER = os.getenv('WEBHOOK_RECEIVER', 'false').lower() in ('true', '1', 't')
31
+ WEBHOOK_PORT_STR = os.getenv('WEBHOOK_PORT') or ('8080' if WEBHOOK_RECEIVER else None)
32
+ WEBHOOK_PORT = int(WEBHOOK_PORT_STR) if WEBHOOK_PORT_STR else None
33
+
34
+ CRON_MACROS = {
35
+ '@yearly': '0 0 1 1 *',
36
+ '@annually': '0 0 1 1 *',
37
+ '@monthly': '0 0 1 * *',
38
+ '@weekly': '0 0 * * 0',
39
+ '@daily': '0 0 * * *',
40
+ '@midnight': '0 0 * * *',
41
+ '@hourly': '0 * * * *',
42
+ }
43
+ CRON_SCHEDULE = os.getenv('CRON_SCHEDULE')
44
+ CRON_TARGET_PATHS = shlex.split(os.getenv('CRON_TARGET_PATHS', ''))
45
+
46
+
47
+ def get_alpha_3_code(lang_name: str) -> str | None:
48
+ try:
49
+ lang = pycountry.languages.lookup(lang_name)
50
+ return cast(str, lang.alpha_3) # ISO 639-3
51
+ except LookupError:
52
+ return None
53
+
54
+
55
+ async def process_item(file_path: str, item_tags: str, orig_lang: str | None) -> None:
56
+ if item_tags:
57
+ file_path += f'::{re.split(r"[,;|]", item_tags)[0]}'
58
+ try:
59
+ argv = [*MKVPRIORITY_ARGS, file_path]
60
+ await asyncio.to_thread(mkvpriority.main.main, argv, orig_lang)
61
+ except Exception:
62
+ entrypoint_logger.exception(f"error occurred: '{file_path}'")
63
+
64
+
65
+ async def queue_worker() -> None:
66
+ while True:
67
+ file_path, item_tags, item_id = await processing_queue.get()
68
+ await process_item(file_path, item_tags, item_id)
69
+ processing_queue.task_done()
70
+
71
+
72
+ async def process_handler(request: web.Request) -> web.Response:
73
+ args = await request.json()
74
+ file_path = args.get('file_path')
75
+ item_tags = args.get('item_tags', '')
76
+ orig_lang = get_alpha_3_code(args.get('orig_lang', ''))
77
+ await processing_queue.put((file_path, item_tags, orig_lang))
78
+ return web.json_response({'message': f"received '{file_path}'"})
79
+
80
+
81
+ async def create_runner(host: str, port: int) -> web.AppRunner:
82
+ app = web.Application()
83
+ app.router.add_post('/process', process_handler)
84
+
85
+ async def on_startup(app: web.Application) -> None:
86
+ app['worker'] = asyncio.create_task(queue_worker())
87
+
88
+ async def on_cleanup(app: web.Application) -> None:
89
+ app['worker'].cancel()
90
+ try:
91
+ await app['worker']
92
+ except asyncio.CancelledError:
93
+ pass
94
+
95
+ app.on_startup.append(on_startup)
96
+ app.on_cleanup.append(on_cleanup)
97
+
98
+ runner = web.AppRunner(app)
99
+ await runner.setup()
100
+ site = web.TCPSite(runner, host, port)
101
+ await site.start()
102
+ return runner
103
+
104
+
105
+ async def create_scheduler(expr: str, timezone: str | None) -> AsyncIOScheduler:
106
+ scheduler = AsyncIOScheduler()
107
+ trigger = CronTrigger.from_crontab(expr, timezone)
108
+ cron_argv = MKVPRIORITY_ARGS + CRON_TARGET_PATHS
109
+ scheduler.add_job(lambda: mkvpriority.main.main(cron_argv), trigger)
110
+ scheduler.start()
111
+ return scheduler
112
+
113
+
114
+ def main() -> None:
115
+ config_dir = Path('/config')
116
+ try:
117
+ config_dir.mkdir(parents=True, exist_ok=True)
118
+ config_file = config_dir / 'config.toml'
119
+ if not config_file.is_file():
120
+ shutil.copy2('config.toml', config_file)
121
+
122
+ extensions_dir = Path('/config/extensions')
123
+ extensions_dir.mkdir(parents=True, exist_ok=True)
124
+ init_file = extensions_dir / '__init__.py'
125
+ init_file.touch(exist_ok=True)
126
+
127
+ script_file = config_dir / 'mkvpriority.sh'
128
+ if not script_file.is_file():
129
+ shutil.copy2('mkvpriority.sh', script_file)
130
+
131
+ database_file = config_dir / 'archive.db'
132
+ database_file.touch(exist_ok=True)
133
+ except PermissionError:
134
+ entrypoint_logger.warning(f'recreate {config_dir} with correct PUID/PGID ownership')
135
+ raise
136
+
137
+ max_bytes = 5242880 if LOG_MAX_BYTES is None else int(LOG_MAX_BYTES)
138
+ max_files = 3 if LOG_MAX_FILES is None else int(LOG_MAX_FILES)
139
+ setup_logging('/config/mkvpriority.log', max_bytes, max_files)
140
+
141
+ entrypoint_logger.setLevel(logging.INFO)
142
+ logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
143
+
144
+ async def run_all() -> None:
145
+ stop_event = asyncio.Event()
146
+ loop = asyncio.get_running_loop()
147
+
148
+ def handle_signal(sig_name: str) -> None:
149
+ entrypoint_logger.info(f'received {sig_name} signal')
150
+ stop_event.set()
151
+
152
+ loop.add_signal_handler(signal.SIGTERM, lambda: handle_signal('SIGTERM'))
153
+ loop.add_signal_handler(signal.SIGINT, lambda: handle_signal('SIGINT'))
154
+
155
+ try:
156
+ runner = None
157
+ if WEBHOOK_PORT:
158
+ runner = await create_runner('0.0.0.0', WEBHOOK_PORT)
159
+ entrypoint_logger.info(f'webhook listener started on 0.0.0.0:{WEBHOOK_PORT}')
160
+
161
+ scheduler = None
162
+ if expr := CRON_SCHEDULE:
163
+ if expr.startswith('@'):
164
+ macro = expr
165
+ try:
166
+ expr = CRON_MACROS[macro]
167
+ except KeyError as e:
168
+ e.add_note(f"unsupported cron macro '{macro}'")
169
+ raise
170
+ timezone = os.getenv('TZ', 'UTC')
171
+ try:
172
+ expr_desc = cron_descriptor.get_description(expr)
173
+ expr_desc = expr_desc[0].lower() + expr_desc[1:]
174
+ scheduler = await create_scheduler(expr, timezone)
175
+ except (FormatError, ValueError) as e:
176
+ e.add_note(f"unsupported cron expression '{expr}'")
177
+ raise
178
+ entrypoint_logger.info(f'scheduled task to run {expr_desc} ({timezone})')
179
+
180
+ await stop_event.wait()
181
+
182
+ finally:
183
+ if scheduler:
184
+ scheduler.shutdown(wait=False)
185
+ if runner:
186
+ await runner.cleanup()
187
+
188
+ entrypoint_logger.info(f'MKVPriority {__version__}')
189
+ asyncio.run(run_all())
190
+
191
+
192
+ if __name__ == '__main__':
193
+ if CUSTOM_SCRIPT or WEBHOOK_RECEIVER:
194
+ entrypoint_logger.warning(
195
+ 'CUSTOM_SCRIPT and WEBHOOK_RECEIVER are deprecated; use WEBHOOK_PORT instead'
196
+ )
197
+ main()
198
+ elif WEBHOOK_PORT or CRON_SCHEDULE:
199
+ main()
200
+ else:
201
+ mkvpriority.main.main()
File without changes
@@ -0,0 +1,112 @@
1
+ import json
2
+ import subprocess
3
+ import tomllib
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+ from typing import Any
7
+
8
+ from mkvpriority import Config, Extension, Track
9
+ from mkvpriority.main import mkvmerge_logger
10
+
11
+
12
+ class Multiplexer(Extension):
13
+ def __init__(self) -> None:
14
+ super().__init__('multiplexer')
15
+ self.parameters: dict[str, Any] = {}
16
+
17
+ def process_file(
18
+ self,
19
+ file_path: Path,
20
+ video_tracks: list[Track],
21
+ audio_tracks: list[Track],
22
+ subtitle_tracks: list[Track],
23
+ config: Config,
24
+ dry_run: bool = False,
25
+ ) -> None:
26
+ if config.toml_path in self.parameters:
27
+ parameters = self.parameters[config.toml_path]
28
+ else:
29
+ with open(config.toml_path, 'rb') as f:
30
+ toml_file = tomllib.load(f)
31
+ parameters = toml_file.get('multiplexer', {})
32
+ self.parameters[config.toml_path] = parameters
33
+ self.strip: bool = parameters.get('strip_tracks', False)
34
+ self.reorder: bool = parameters.get('reorder_tracks', False)
35
+ self.filter_tracks(file_path, video_tracks, audio_tracks, subtitle_tracks, config, dry_run)
36
+
37
+ def multiplex_tracks(self, arguments: list[str]) -> None:
38
+ with NamedTemporaryFile('w+', encoding='utf-8', suffix='.json', delete=False) as temp_file:
39
+ json.dump(arguments, temp_file)
40
+ temp_file_path = Path(temp_file.name)
41
+
42
+ try:
43
+ result = subprocess.run(
44
+ ['mkvmerge', f'@{temp_file_path}'],
45
+ capture_output=True,
46
+ encoding='utf-8',
47
+ check=True,
48
+ text=True,
49
+ )
50
+ mkvmerge_logger.debug(result.stdout.strip())
51
+ finally:
52
+ temp_file_path.unlink(missing_ok=True)
53
+
54
+ def filter_tracks(
55
+ self,
56
+ file_path: Path,
57
+ video_tracks: list[Track],
58
+ audio_tracks: list[Track],
59
+ subtitle_tracks: list[Track],
60
+ config: Config,
61
+ dry_run: bool = False,
62
+ ) -> None:
63
+ track_order: list[str] = []
64
+ audio_strip: list[str] = []
65
+ subtitle_strip: list[str] = []
66
+
67
+ def process_tracks(
68
+ tracks: list[Track],
69
+ track_order: list[str],
70
+ track_strip: list[str],
71
+ track_langs: dict[str, int],
72
+ ) -> None:
73
+ for track in tracks:
74
+ if self.strip and self.reorder:
75
+ if track.language in track_langs:
76
+ track_order.append(f'0:{track.index}')
77
+ else:
78
+ track_strip.append(f'!{track.index}')
79
+ elif self.strip:
80
+ if track.language not in track_langs:
81
+ track_strip.append(f'!{track.index}')
82
+ elif self.reorder:
83
+ track_order.append(f'0:{track.index}')
84
+
85
+ for track in video_tracks:
86
+ track_order.append(f'0:{track.index}')
87
+ process_tracks(audio_tracks, track_order, audio_strip, config.audio_languages)
88
+ process_tracks(subtitle_tracks, track_order, subtitle_strip, config.subtitle_languages)
89
+
90
+ temp_output_path = file_path.with_name(f'{file_path.stem}_temp.mkv')
91
+ mkv_args = ['-o', str(temp_output_path)]
92
+ if audio_strip:
93
+ mkv_args += ['--audio-tracks', ','.join(audio_strip)]
94
+ if subtitle_strip:
95
+ mkv_args += ['--subtitle-tracks', ','.join(subtitle_strip)]
96
+ mkv_args += [str(file_path)]
97
+ if track_order and any(
98
+ int(id_a.split(':')[1]) > int(id_b.split(':')[1])
99
+ for id_a, id_b in zip(track_order, track_order[1:])
100
+ ):
101
+ mkv_args += ['--track-order', ','.join(track_order)]
102
+
103
+ if len(mkv_args) > 3:
104
+ self.extension_logger.info(' '.join(mkv_args))
105
+ if not dry_run:
106
+ try:
107
+ self.multiplex_tracks(mkv_args)
108
+ temp_output_path.replace(file_path)
109
+ except subprocess.CalledProcessError as e:
110
+ mkvmerge_logger.error((e.stderr or e.stdout or str(e)).strip())
111
+ temp_output_path.unlink(missing_ok=True)
112
+ raise
@@ -0,0 +1,74 @@
1
+ import json
2
+ import subprocess
3
+ import tomllib
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+ from typing import Any
7
+
8
+ from mkvpriority import Config, Extension, Track
9
+
10
+ SUBTITLE_EXTENSIONS = {'ASS': 'ass', 'SSA': 'ssa', 'UTF8': 'srt', 'WEBVTT': 'vtt'}
11
+
12
+
13
+ class SubtitleExtractor(Extension):
14
+ def __init__(self) -> None:
15
+ super().__init__('subtitle_extractor')
16
+ self.parameters: dict[str, Any] = {}
17
+
18
+ def process_file(
19
+ self,
20
+ file_path: Path,
21
+ video_tracks: list[Track],
22
+ audio_tracks: list[Track],
23
+ subtitle_tracks: list[Track],
24
+ config: Config,
25
+ dry_run: bool = False,
26
+ ) -> None:
27
+ if not subtitle_tracks:
28
+ return
29
+
30
+ if config.toml_path in self.parameters:
31
+ extract = self.parameters[config.toml_path]
32
+ else:
33
+ with open(config.toml_path, 'rb') as f:
34
+ toml_file = tomllib.load(f)
35
+ extract = toml_file.get('extract_embedded_subtitles', False)
36
+ self.parameters[config.toml_path] = extract
37
+
38
+ if extract:
39
+ subtitle_track = max(subtitle_tracks, key=lambda track: track.score)
40
+ subtitle_path = self.build_subtitle_path(file_path, subtitle_track)
41
+ if subtitle_path and not subtitle_path.is_file():
42
+ self.extract_subtitles(file_path, subtitle_path, subtitle_track.index)
43
+
44
+ def build_subtitle_path(self, file_path: Path, subtitle_track: Track) -> Path | None:
45
+ if not subtitle_track.codec.startswith('S_TEXT/'):
46
+ return None
47
+ subtitle_format = subtitle_track.codec.split('/')[-1]
48
+ if subtitle_format not in SUBTITLE_EXTENSIONS:
49
+ return None
50
+ extension = SUBTITLE_EXTENSIONS[subtitle_format]
51
+ subtitle_suffix = f'.{subtitle_track.language}'
52
+ if subtitle_track.default:
53
+ subtitle_suffix += '.default'
54
+ if subtitle_track.forced:
55
+ subtitle_suffix += '.forced'
56
+ return Path(file_path).with_suffix(f'{subtitle_suffix}.{extension}')
57
+
58
+ def extract_subtitles(self, file_path: Path, subtitle_path: Path, index: int) -> None:
59
+ self.extension_logger.info(f"extracting embedded subtitles to '{subtitle_path}'")
60
+ with NamedTemporaryFile('w+', encoding='utf-8', suffix='.json', delete=False) as temp_file:
61
+ json.dump(['tracks', str(file_path), f'{index}:{subtitle_path}'], temp_file)
62
+ temp_file_path = Path(temp_file.name)
63
+
64
+ try:
65
+ result = subprocess.run(
66
+ ['mkvextract', f'@{temp_file_path}'],
67
+ capture_output=True,
68
+ encoding='utf-8',
69
+ check=True,
70
+ text=True,
71
+ )
72
+ self.extension_logger.debug(result.stdout.strip())
73
+ finally:
74
+ temp_file_path.unlink(missing_ok=True)
@@ -0,0 +1,199 @@
1
+ import re
2
+ import tomllib
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from mkvpriority import Config, Extension, Track
8
+
9
+ SAFE_ATTRS = {
10
+ 'Fontname',
11
+ 'PrimaryColour',
12
+ 'SecondaryColour',
13
+ 'OutlineColour',
14
+ 'BackColour',
15
+ 'Bold',
16
+ 'Italic',
17
+ 'Underline',
18
+ 'StrikeOut',
19
+ 'ScaleX',
20
+ 'ScaleY',
21
+ 'Angle',
22
+ 'Alignment',
23
+ 'BorderStyle',
24
+ 'Encoding',
25
+ }
26
+ RES_DEP_X = {'Spacing', 'MarginL', 'MarginR'}
27
+ RES_DEP_Y = {'Fontsize', 'Outline', 'Shadow', 'MarginV'}
28
+ ASS_ATTR_MAP = {attr.lower(): attr for attr in SAFE_ATTRS | RES_DEP_X | RES_DEP_Y}
29
+
30
+
31
+ class SubtitleRestyler(Extension):
32
+ def __init__(self, max_ratio: float = 0.15, max_allowance: int = 2):
33
+ super().__init__('subtitle_restyler')
34
+ self.parameters: dict[str, Any] = {}
35
+ self.max_ratio = max_ratio
36
+ self.max_allowance = max_allowance
37
+
38
+ def process_file(
39
+ self,
40
+ file_path: Path,
41
+ video_tracks: list[Track],
42
+ audio_tracks: list[Track],
43
+ subtitle_tracks: list[Track],
44
+ config: Config,
45
+ dry_run: bool = False,
46
+ ) -> None:
47
+ if not subtitle_tracks:
48
+ return
49
+
50
+ if config.toml_path in self.parameters:
51
+ attributes = self.parameters[config.toml_path]
52
+ else:
53
+ with open(config.toml_path, 'rb') as f:
54
+ toml_file = tomllib.load(f)
55
+ attributes = toml_file.get('subtitle_styles', {})
56
+ self.parameters[config.toml_path] = attributes
57
+
58
+ if attributes:
59
+ subtitle_track = max(subtitle_tracks, key=lambda track: track.score)
60
+ subtitle_path = self.build_subtitle_path(file_path, subtitle_track)
61
+ if subtitle_path and subtitle_path.is_file():
62
+ self.modify_subtitle_styles(subtitle_path, attributes)
63
+
64
+ def build_subtitle_path(self, file_path: Path, subtitle_track: Track) -> Path | None:
65
+ if not subtitle_track.codec.startswith('S_TEXT/'):
66
+ return None
67
+ if subtitle_track.codec.split('/')[-1] != 'ASS':
68
+ return None
69
+ subtitle_suffix = f'.{subtitle_track.language}'
70
+ if subtitle_track.default:
71
+ subtitle_suffix += '.default'
72
+ if subtitle_track.forced:
73
+ subtitle_suffix += '.forced'
74
+ return Path(file_path).with_suffix(f'{subtitle_suffix}.ass')
75
+
76
+ def scale_style_attributes(
77
+ self, input_lines: list[str], attributes: dict[str, Any]
78
+ ) -> dict[str, str]:
79
+ playres_x, playres_y = 1920.0, 1080.0
80
+ for line in input_lines:
81
+ if line.startswith('PlayResX:'):
82
+ playres_x = float(line.split(':')[1].strip())
83
+ elif line.startswith('PlayResY:'):
84
+ playres_y = float(line.split(':')[1].strip())
85
+ elif line.startswith('[Events]'):
86
+ break
87
+
88
+ scale_x = playres_x / 1920.0
89
+ scale_y = playres_y / 1080.0
90
+ scaled_attributes: dict[str, str] = {}
91
+ for attr, val in attributes.items():
92
+ attr = ASS_ATTR_MAP.get(attr.lower(), attr)
93
+ if attr in SAFE_ATTRS:
94
+ scaled_attributes[attr] = str(val)
95
+ elif attr in RES_DEP_X:
96
+ scaled_val = float(val) * scale_x
97
+ scaled_attributes[attr] = str(
98
+ int(round(scaled_val)) if 'Margin' in attr else round(scaled_val, 2)
99
+ )
100
+ elif attr in RES_DEP_Y:
101
+ scaled_val = float(val) * scale_y
102
+ scaled_attributes[attr] = str(
103
+ int(round(scaled_val)) if 'Margin' in attr else round(scaled_val, 2)
104
+ )
105
+ else:
106
+ self.extension_logger.warning(f"style '{attr}' is not in [V4+ Styles]")
107
+
108
+ return scaled_attributes
109
+
110
+ def detect_dialogue_styles(self, input_lines: list[str]) -> set[str]:
111
+ style_stats: dict[str, dict[str, int]] = defaultdict(
112
+ lambda: {'count_spatial': 0, 'count_karaoke': 0, 'count_drawing': 0, 'total': 0}
113
+ )
114
+ in_events_section = False
115
+
116
+ position_pattern = re.compile(r'\\(pos|move|org|clip|iclip)\s*\(|\\an[1-9]', re.IGNORECASE)
117
+ rotation_pattern = re.compile(r'\\(fr[xyz]|fa[xy])\s*-?\d', re.IGNORECASE)
118
+ karaoke_pattern = re.compile(r'\\[kK][fo]?[0-9]+')
119
+ drawing_pattern = re.compile(r'\\[pP][1-9]')
120
+
121
+ for line in input_lines:
122
+ if line.startswith('[Events]'):
123
+ in_events_section = True
124
+ continue
125
+ elif line.startswith('['):
126
+ in_events_section = False
127
+
128
+ if in_events_section and line.startswith('Dialogue:'):
129
+ parts = line.split(':', 1)[1].strip().split(',', 9)
130
+ if len(parts) > 9:
131
+ style_name = parts[3].strip()
132
+ text = parts[9]
133
+ style_stats[style_name]['total'] += 1
134
+ if position_pattern.search(text) or rotation_pattern.search(text):
135
+ style_stats[style_name]['count_spatial'] += 1
136
+ if karaoke_pattern.search(text):
137
+ style_stats[style_name]['count_karaoke'] += 1
138
+ if drawing_pattern.search(text):
139
+ style_stats[style_name]['count_drawing'] += 1
140
+
141
+ subtitle_styles: set[str] = set()
142
+ for style, stats in style_stats.items():
143
+ ratio_karaoke = stats['count_karaoke'] / stats['total']
144
+ if stats['count_karaoke'] > 0 and ratio_karaoke > 0.1:
145
+ continue
146
+ if stats['count_drawing'] > 0:
147
+ continue
148
+ ratio_spatial = stats['count_spatial'] / stats['total']
149
+ is_dialogue = ratio_spatial <= self.max_ratio
150
+ if not is_dialogue and stats['count_spatial'] <= self.max_allowance:
151
+ if ratio_spatial < 1.0:
152
+ is_dialogue = True
153
+ if is_dialogue:
154
+ subtitle_styles.add(style)
155
+
156
+ return subtitle_styles
157
+
158
+ def modify_subtitle_styles(self, file_path: Path, attributes: dict[str, Any]) -> None:
159
+ with open(file_path, encoding='utf-8-sig') as f:
160
+ input_lines = f.readlines()
161
+ scaled_attributes = self.scale_style_attributes(input_lines, attributes)
162
+ if not scaled_attributes:
163
+ return
164
+ subtitle_styles = self.detect_dialogue_styles(input_lines)
165
+ if not subtitle_styles:
166
+ return
167
+
168
+ attr_indices: dict[str, int] = {}
169
+ output_lines: list[str] = []
170
+ in_styles_section = False
171
+ for line in input_lines:
172
+ if line.startswith('[V4+ Styles]'):
173
+ in_styles_section = True
174
+ output_lines.append(line)
175
+ continue
176
+ elif line.startswith('['):
177
+ in_styles_section = False
178
+
179
+ if in_styles_section:
180
+ if line.startswith('Format:'):
181
+ format_string = line.split(':', 1)[1].strip()
182
+ format_parts = [p.strip() for p in format_string.split(',')]
183
+ for attr in scaled_attributes.keys():
184
+ if attr in format_parts:
185
+ attr_indices[attr] = format_parts.index(attr)
186
+ elif line.startswith('Style:') and attr_indices:
187
+ style_parts = line.split(':', 1)[1].strip().split(',')
188
+ style_name = style_parts[0].strip()
189
+ if style_name in subtitle_styles:
190
+ for attr, val in scaled_attributes.items():
191
+ if attr in attr_indices:
192
+ index = attr_indices[attr]
193
+ style_parts[index] = val
194
+ line = 'Style: ' + ','.join(style_parts) + '\n'
195
+ output_lines.append(line)
196
+
197
+ self.extension_logger.info(f'restyling external subtitles for {sorted(subtitle_styles)}')
198
+ with open(file_path, 'w', encoding='utf-8-sig') as f:
199
+ f.writelines(output_lines)