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.
- mkvpriority/__init__.py +42 -0
- mkvpriority/__main__.py +4 -0
- mkvpriority/entrypoint.py +201 -0
- mkvpriority/extensions/__init__.py +0 -0
- mkvpriority/extensions/multiplexer.py +112 -0
- mkvpriority/extensions/subtitle_extractor.py +74 -0
- mkvpriority/extensions/subtitle_restyler.py +199 -0
- mkvpriority/main.py +679 -0
- mkvpriority-1.5.0.dist-info/METADATA +14 -0
- mkvpriority-1.5.0.dist-info/RECORD +13 -0
- mkvpriority-1.5.0.dist-info/WHEEL +4 -0
- mkvpriority-1.5.0.dist-info/entry_points.txt +2 -0
- mkvpriority-1.5.0.dist-info/licenses/LICENSE +21 -0
mkvpriority/__init__.py
ADDED
|
@@ -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
|
+
]
|
mkvpriority/__main__.py
ADDED
|
@@ -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)
|