mkv-episode-matcher 0.3.3__py3-none-any.whl → 1.0.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.
- mkv_episode_matcher/__init__.py +8 -0
- mkv_episode_matcher/__main__.py +2 -177
- mkv_episode_matcher/asr_models.py +506 -0
- mkv_episode_matcher/cli.py +558 -0
- mkv_episode_matcher/core/config_manager.py +100 -0
- mkv_episode_matcher/core/engine.py +577 -0
- mkv_episode_matcher/core/matcher.py +214 -0
- mkv_episode_matcher/core/models.py +91 -0
- mkv_episode_matcher/core/providers/asr.py +85 -0
- mkv_episode_matcher/core/providers/subtitles.py +341 -0
- mkv_episode_matcher/core/utils.py +148 -0
- mkv_episode_matcher/episode_identification.py +550 -118
- mkv_episode_matcher/subtitle_utils.py +82 -0
- mkv_episode_matcher/tmdb_client.py +56 -14
- mkv_episode_matcher/ui/flet_app.py +708 -0
- mkv_episode_matcher/utils.py +262 -139
- mkv_episode_matcher-1.0.0.dist-info/METADATA +242 -0
- mkv_episode_matcher-1.0.0.dist-info/RECORD +23 -0
- {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/WHEEL +1 -1
- mkv_episode_matcher-1.0.0.dist-info/licenses/LICENSE +21 -0
- mkv_episode_matcher/config.py +0 -82
- mkv_episode_matcher/episode_matcher.py +0 -100
- mkv_episode_matcher/libraries/pgs2srt/.gitignore +0 -2
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/SubZero.py +0 -321
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/dictionaries/data.py +0 -16700
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/post_processing.py +0 -260
- mkv_episode_matcher/libraries/pgs2srt/README.md +0 -26
- mkv_episode_matcher/libraries/pgs2srt/__init__.py +0 -0
- mkv_episode_matcher/libraries/pgs2srt/imagemaker.py +0 -89
- mkv_episode_matcher/libraries/pgs2srt/pgs2srt.py +0 -150
- mkv_episode_matcher/libraries/pgs2srt/pgsreader.py +0 -225
- mkv_episode_matcher/libraries/pgs2srt/requirements.txt +0 -4
- mkv_episode_matcher/mkv_to_srt.py +0 -302
- mkv_episode_matcher/speech_to_text.py +0 -90
- mkv_episode_matcher-0.3.3.dist-info/METADATA +0 -125
- mkv_episode_matcher-0.3.3.dist-info/RECORD +0 -25
- {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/entry_points.txt +0 -0
- {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
from collections import namedtuple
|
|
4
|
-
from os.path import split as pathsplit
|
|
5
|
-
|
|
6
|
-
# Constants for Segments
|
|
7
|
-
PDS = int("0x14", 16)
|
|
8
|
-
ODS = int("0x15", 16)
|
|
9
|
-
PCS = int("0x16", 16)
|
|
10
|
-
WDS = int("0x17", 16)
|
|
11
|
-
END = int("0x80", 16)
|
|
12
|
-
|
|
13
|
-
# Named tuple access for static PDS palettes
|
|
14
|
-
Palette = namedtuple("Palette", "Y Cr Cb Alpha")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class InvalidSegmentError(Exception):
|
|
18
|
-
"""Raised when a segment does not match PGS specification"""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class PGSReader:
|
|
22
|
-
def __init__(self, filepath):
|
|
23
|
-
self.filedir, self.file = pathsplit(filepath)
|
|
24
|
-
with open(filepath, "rb") as f:
|
|
25
|
-
self.bytes = f.read()
|
|
26
|
-
|
|
27
|
-
def make_segment(self, bytes_):
|
|
28
|
-
cls = SEGMENT_TYPE[bytes_[10]]
|
|
29
|
-
return cls(bytes_)
|
|
30
|
-
|
|
31
|
-
def iter_segments(self):
|
|
32
|
-
bytes_ = self.bytes[:]
|
|
33
|
-
while bytes_:
|
|
34
|
-
size = 13 + int(bytes_[11:13].hex(), 16)
|
|
35
|
-
yield self.make_segment(bytes_[:size])
|
|
36
|
-
bytes_ = bytes_[size:]
|
|
37
|
-
|
|
38
|
-
def iter_displaysets(self):
|
|
39
|
-
ds = []
|
|
40
|
-
for s in self.iter_segments():
|
|
41
|
-
ds.append(s)
|
|
42
|
-
if s.type == "END":
|
|
43
|
-
yield DisplaySet(ds)
|
|
44
|
-
ds = []
|
|
45
|
-
|
|
46
|
-
@property
|
|
47
|
-
def segments(self):
|
|
48
|
-
if not hasattr(self, "_segments"):
|
|
49
|
-
self._segments = list(self.iter_segments())
|
|
50
|
-
return self._segments
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def displaysets(self):
|
|
54
|
-
if not hasattr(self, "_displaysets"):
|
|
55
|
-
self._displaysets = list(self.iter_displaysets())
|
|
56
|
-
return self._displaysets
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class BaseSegment:
|
|
60
|
-
SEGMENT = {PDS: "PDS", ODS: "ODS", PCS: "PCS", WDS: "WDS", END: "END"}
|
|
61
|
-
|
|
62
|
-
def __init__(self, bytes_):
|
|
63
|
-
self.bytes = bytes_
|
|
64
|
-
if bytes_[:2] != b"PG":
|
|
65
|
-
raise InvalidSegmentError
|
|
66
|
-
self.pts = int(bytes_[2:6].hex(), base=16) / 90
|
|
67
|
-
self.dts = int(bytes_[6:10].hex(), base=16) / 90
|
|
68
|
-
self.type = self.SEGMENT[bytes_[10]]
|
|
69
|
-
self.size = int(bytes_[11:13].hex(), base=16)
|
|
70
|
-
self.data = bytes_[13:]
|
|
71
|
-
|
|
72
|
-
def __len__(self):
|
|
73
|
-
return self.size
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def presentation_timestamp(self):
|
|
77
|
-
return self.pts
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def decoding_timestamp(self):
|
|
81
|
-
return self.dts
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def segment_type(self):
|
|
85
|
-
return self.type
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class PresentationCompositionSegment(BaseSegment):
|
|
89
|
-
class CompositionObject:
|
|
90
|
-
def __init__(self, bytes_):
|
|
91
|
-
self.bytes = bytes_
|
|
92
|
-
self.object_id = int(bytes_[0:2].hex(), base=16)
|
|
93
|
-
self.window_id = bytes_[2]
|
|
94
|
-
self.cropped = bool(bytes_[3])
|
|
95
|
-
self.x_offset = int(bytes_[4:6].hex(), base=16)
|
|
96
|
-
self.y_offset = int(bytes_[6:8].hex(), base=16)
|
|
97
|
-
if self.cropped:
|
|
98
|
-
self.crop_x_offset = int(bytes_[8:10].hex(), base=16)
|
|
99
|
-
self.crop_y_offset = int(bytes_[10:12].hex(), base=16)
|
|
100
|
-
self.crop_width = int(bytes_[12:14].hex(), base=16)
|
|
101
|
-
self.crop_height = int(bytes_[14:16].hex(), base=16)
|
|
102
|
-
|
|
103
|
-
STATE = {
|
|
104
|
-
int("0x00", base=16): "Normal",
|
|
105
|
-
int("0x40", base=16): "Acquisition Point",
|
|
106
|
-
int("0x80", base=16): "Epoch Start",
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
def __init__(self, bytes_):
|
|
110
|
-
BaseSegment.__init__(self, bytes_)
|
|
111
|
-
self.width = int(self.data[0:2].hex(), base=16)
|
|
112
|
-
self.height = int(self.data[2:4].hex(), base=16)
|
|
113
|
-
self.frame_rate = self.data[4]
|
|
114
|
-
self._num = int(self.data[5:7].hex(), base=16)
|
|
115
|
-
self._state = self.STATE[self.data[7]]
|
|
116
|
-
self.palette_update = bool(self.data[8])
|
|
117
|
-
self.palette_id = self.data[9]
|
|
118
|
-
self._num_comps = self.data[10]
|
|
119
|
-
|
|
120
|
-
@property
|
|
121
|
-
def composition_number(self):
|
|
122
|
-
return self._num
|
|
123
|
-
|
|
124
|
-
@property
|
|
125
|
-
def composition_state(self):
|
|
126
|
-
return self._state
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def composition_objects(self):
|
|
130
|
-
if not hasattr(self, "_composition_objects"):
|
|
131
|
-
self._composition_objects = self.get_composition_objects()
|
|
132
|
-
if len(self._composition_objects) != self._num_comps:
|
|
133
|
-
print(
|
|
134
|
-
"Warning: Number of composition objects asserted "
|
|
135
|
-
"does not match the amount found."
|
|
136
|
-
)
|
|
137
|
-
return self._composition_objects
|
|
138
|
-
|
|
139
|
-
def get_composition_objects(self):
|
|
140
|
-
bytes_ = self.data[11:]
|
|
141
|
-
comps = []
|
|
142
|
-
while bytes_:
|
|
143
|
-
length = 8 * (1 + bool(bytes_[3]))
|
|
144
|
-
comps.append(self.CompositionObject(bytes_[:length]))
|
|
145
|
-
bytes_ = bytes_[length:]
|
|
146
|
-
return comps
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class WindowDefinitionSegment(BaseSegment):
|
|
150
|
-
def __init__(self, bytes_):
|
|
151
|
-
BaseSegment.__init__(self, bytes_)
|
|
152
|
-
self.num_windows = self.data[0]
|
|
153
|
-
self.window_id = self.data[1]
|
|
154
|
-
self.x_offset = int(self.data[2:4].hex(), base=16)
|
|
155
|
-
self.y_offset = int(self.data[4:6].hex(), base=16)
|
|
156
|
-
self.width = int(self.data[6:8].hex(), base=16)
|
|
157
|
-
self.height = int(self.data[8:10].hex(), base=16)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
class PaletteDefinitionSegment(BaseSegment):
|
|
161
|
-
def __init__(self, bytes_):
|
|
162
|
-
BaseSegment.__init__(self, bytes_)
|
|
163
|
-
self.palette_id = self.data[0]
|
|
164
|
-
self.version = self.data[1]
|
|
165
|
-
self.palette = [Palette(0, 0, 0, 0)] * 256
|
|
166
|
-
# Slice from byte 2 til end of segment. Divide by 5 to determine number of palette entries
|
|
167
|
-
# Iterate entries. Explode the 5 bytes into namedtuple Palette. Must be exploded
|
|
168
|
-
for entry in range(len(self.data[2:]) // 5):
|
|
169
|
-
i = 2 + entry * 5
|
|
170
|
-
self.palette[self.data[i]] = Palette(*self.data[i + 1 : i + 5])
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
class ObjectDefinitionSegment(BaseSegment):
|
|
174
|
-
SEQUENCE = {
|
|
175
|
-
int("0x40", base=16): "Last",
|
|
176
|
-
int("0x80", base=16): "First",
|
|
177
|
-
int("0xc0", base=16): "First and last",
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
def __init__(self, bytes_):
|
|
181
|
-
BaseSegment.__init__(self, bytes_)
|
|
182
|
-
self.id = int(self.data[0:2].hex(), base=16)
|
|
183
|
-
self.version = self.data[2]
|
|
184
|
-
self.in_sequence = self.SEQUENCE[self.data[3]]
|
|
185
|
-
self.data_len = int(self.data[4:7].hex(), base=16)
|
|
186
|
-
self.width = int(self.data[7:9].hex(), base=16)
|
|
187
|
-
self.height = int(self.data[9:11].hex(), base=16)
|
|
188
|
-
self.img_data = self.data[11:]
|
|
189
|
-
if len(self.img_data) != self.data_len - 4:
|
|
190
|
-
print(
|
|
191
|
-
"Warning: Image data length asserted does not match the length found."
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
class EndSegment(BaseSegment):
|
|
196
|
-
@property
|
|
197
|
-
def is_end(self):
|
|
198
|
-
return True
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
SEGMENT_TYPE = {
|
|
202
|
-
PDS: PaletteDefinitionSegment,
|
|
203
|
-
ODS: ObjectDefinitionSegment,
|
|
204
|
-
PCS: PresentationCompositionSegment,
|
|
205
|
-
WDS: WindowDefinitionSegment,
|
|
206
|
-
END: EndSegment,
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
class DisplaySet:
|
|
211
|
-
def __init__(self, segments):
|
|
212
|
-
self.segments = segments
|
|
213
|
-
self.segment_types = [s.type for s in segments]
|
|
214
|
-
self.has_image = "ODS" in self.segment_types
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def segment_by_type_getter(type_):
|
|
218
|
-
def f(self):
|
|
219
|
-
return [s for s in self.segments if s.type == type_]
|
|
220
|
-
|
|
221
|
-
return f
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
for type_ in BaseSegment.SEGMENT.values():
|
|
225
|
-
setattr(DisplaySet, type_.lower(), property(segment_by_type_getter(type_)))
|
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
# Get the absolute path of the parent directory of the current script.
|
|
6
|
-
parent_dir = os.path.dirname(os.path.abspath(__file__))
|
|
7
|
-
# Add the 'pgs2srt' directory to the Python path.
|
|
8
|
-
sys.path.append(os.path.join(parent_dir, "libraries", "pgs2srt"))
|
|
9
|
-
import re
|
|
10
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
-
from datetime import datetime, timedelta
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
import pytesseract
|
|
14
|
-
from imagemaker import make_image
|
|
15
|
-
from loguru import logger
|
|
16
|
-
from pgsreader import PGSReader
|
|
17
|
-
from PIL import Image, ImageOps
|
|
18
|
-
from typing import Optional
|
|
19
|
-
from mkv_episode_matcher.__main__ import CONFIG_FILE
|
|
20
|
-
from mkv_episode_matcher.config import get_config
|
|
21
|
-
def check_if_processed(filename: str) -> bool:
|
|
22
|
-
"""
|
|
23
|
-
Check if the file has already been processed (has SxxExx format)
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
filename (str): Filename to check
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
bool: True if file is already processed
|
|
30
|
-
"""
|
|
31
|
-
import re
|
|
32
|
-
match = re.search(r"S\d+E\d+", filename)
|
|
33
|
-
return bool(match)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def convert_mkv_to_sup(mkv_file, output_dir):
|
|
37
|
-
"""
|
|
38
|
-
Convert an .mkv file to a .sup file using FFmpeg and pgs2srt.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
mkv_file (str): Path to the .mkv file.
|
|
42
|
-
output_dir (str): Path to the directory where the .sup file will be saved.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
str: Path to the converted .sup file.
|
|
46
|
-
"""
|
|
47
|
-
# Get the base name of the .mkv file without the extension
|
|
48
|
-
base_name = os.path.splitext(os.path.basename(mkv_file))[0]
|
|
49
|
-
|
|
50
|
-
# Construct the output .sup file path
|
|
51
|
-
sup_file = os.path.join(output_dir, f"{base_name}.sup")
|
|
52
|
-
if not os.path.exists(sup_file):
|
|
53
|
-
logger.info(f"Processing {mkv_file} to {sup_file}")
|
|
54
|
-
# FFmpeg command to convert .mkv to .sup
|
|
55
|
-
ffmpeg_cmd = ["ffmpeg", "-i", mkv_file, "-map", "0:s:0", "-c", "copy", sup_file]
|
|
56
|
-
try:
|
|
57
|
-
subprocess.run(ffmpeg_cmd, check=True)
|
|
58
|
-
logger.info(f"Converted {mkv_file} to {sup_file}")
|
|
59
|
-
except subprocess.CalledProcessError as e:
|
|
60
|
-
logger.error(f"Error converting {mkv_file}: {e}")
|
|
61
|
-
else:
|
|
62
|
-
logger.info(f"File {sup_file} already exists, skipping")
|
|
63
|
-
return sup_file
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@logger.catch
|
|
67
|
-
def perform_ocr(sup_file_path: str) -> Optional[str]:
|
|
68
|
-
"""
|
|
69
|
-
Perform OCR on a .sup file and save the extracted text to a .srt file.
|
|
70
|
-
Returns the path to the created SRT file.
|
|
71
|
-
"""
|
|
72
|
-
# Get the base name of the .sup file without the extension
|
|
73
|
-
base_name = os.path.splitext(os.path.basename(sup_file_path))[0]
|
|
74
|
-
output_dir = os.path.dirname(sup_file_path)
|
|
75
|
-
logger.info(f"Performing OCR on {sup_file_path}")
|
|
76
|
-
|
|
77
|
-
# Construct the output .srt file path
|
|
78
|
-
srt_file = os.path.join(output_dir, f"{base_name}.srt")
|
|
79
|
-
|
|
80
|
-
if os.path.exists(srt_file):
|
|
81
|
-
logger.info(f"SRT file {srt_file} already exists, skipping OCR")
|
|
82
|
-
return srt_file
|
|
83
|
-
|
|
84
|
-
# Load a PGS/SUP file.
|
|
85
|
-
pgs = PGSReader(sup_file_path)
|
|
86
|
-
|
|
87
|
-
# Set index
|
|
88
|
-
i = 0
|
|
89
|
-
|
|
90
|
-
# Complete subtitle track index
|
|
91
|
-
si = 0
|
|
92
|
-
|
|
93
|
-
tesseract_lang = "eng"
|
|
94
|
-
tesseract_config = f"-c tessedit_char_blacklist=[] --psm 6 --oem {1}"
|
|
95
|
-
|
|
96
|
-
config = get_config(CONFIG_FILE)
|
|
97
|
-
tesseract_path = config.get("tesseract_path")
|
|
98
|
-
logger.debug(f"Setting Teesseract Path to {tesseract_path}")
|
|
99
|
-
pytesseract.pytesseract.tesseract_cmd = str(tesseract_path)
|
|
100
|
-
|
|
101
|
-
# SubRip output
|
|
102
|
-
output = ""
|
|
103
|
-
|
|
104
|
-
if not os.path.exists(srt_file):
|
|
105
|
-
# Iterate the pgs generator
|
|
106
|
-
for ds in pgs.iter_displaysets():
|
|
107
|
-
# If set has image, parse the image
|
|
108
|
-
if ds.has_image:
|
|
109
|
-
# Get Palette Display Segment
|
|
110
|
-
pds = ds.pds[0]
|
|
111
|
-
# Get Object Display Segment
|
|
112
|
-
ods = ds.ods[0]
|
|
113
|
-
|
|
114
|
-
if pds and ods:
|
|
115
|
-
# Create and show the bitmap image and convert it to RGBA
|
|
116
|
-
src = make_image(ods, pds).convert("RGBA")
|
|
117
|
-
|
|
118
|
-
# Create grayscale image with black background
|
|
119
|
-
img = Image.new("L", src.size, "BLACK")
|
|
120
|
-
# Paste the subtitle bitmap
|
|
121
|
-
img.paste(src, (0, 0), src)
|
|
122
|
-
# Invert images so the text is readable by Tesseract
|
|
123
|
-
img = ImageOps.invert(img)
|
|
124
|
-
|
|
125
|
-
# Parse the image with tesesract
|
|
126
|
-
text = pytesseract.image_to_string(
|
|
127
|
-
img, lang=tesseract_lang, config=tesseract_config
|
|
128
|
-
).strip()
|
|
129
|
-
|
|
130
|
-
# Replace "|" with "I"
|
|
131
|
-
# Works better than blacklisting "|" in Tesseract,
|
|
132
|
-
# which results in I becoming "!" "i" and "1"
|
|
133
|
-
text = re.sub(r"[|/\\]", "I", text)
|
|
134
|
-
text = re.sub(r"[_]", "L", text)
|
|
135
|
-
start = datetime.fromtimestamp(ods.presentation_timestamp / 1000)
|
|
136
|
-
start = start + timedelta(hours=-1)
|
|
137
|
-
|
|
138
|
-
else:
|
|
139
|
-
# Get Presentation Composition Segment
|
|
140
|
-
pcs = ds.pcs[0]
|
|
141
|
-
|
|
142
|
-
if pcs:
|
|
143
|
-
end = datetime.fromtimestamp(pcs.presentation_timestamp / 1000)
|
|
144
|
-
end = end + timedelta(hours=-1)
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
isinstance(start, datetime)
|
|
148
|
-
and isinstance(end, datetime)
|
|
149
|
-
and len(text)
|
|
150
|
-
):
|
|
151
|
-
si = si + 1
|
|
152
|
-
sub_output = str(si) + "\n"
|
|
153
|
-
sub_output += (
|
|
154
|
-
start.strftime("%H:%M:%S,%f")[0:12]
|
|
155
|
-
+ " --> "
|
|
156
|
-
+ end.strftime("%H:%M:%S,%f")[0:12]
|
|
157
|
-
+ "\n"
|
|
158
|
-
)
|
|
159
|
-
sub_output += text + "\n\n"
|
|
160
|
-
|
|
161
|
-
output += sub_output
|
|
162
|
-
start = end = text = None
|
|
163
|
-
i = i + 1
|
|
164
|
-
with open(srt_file, "w") as f:
|
|
165
|
-
f.write(output)
|
|
166
|
-
logger.info(f"Saved to: {srt_file}")
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
# def convert_mkv_to_srt(season_path, mkv_files):
|
|
170
|
-
# """
|
|
171
|
-
# Converts MKV files to SRT format.
|
|
172
|
-
|
|
173
|
-
# Args:
|
|
174
|
-
# season_path (str): The path to the season directory.
|
|
175
|
-
# mkv_files (list): List of MKV files to convert.
|
|
176
|
-
|
|
177
|
-
# Returns:
|
|
178
|
-
# None
|
|
179
|
-
# """
|
|
180
|
-
# logger.info(f"Converting {len(mkv_files)} files to SRT")
|
|
181
|
-
# output_dir = os.path.join(season_path, "ocr")
|
|
182
|
-
# os.makedirs(output_dir, exist_ok=True)
|
|
183
|
-
# sup_files = []
|
|
184
|
-
# for mkv_file in mkv_files:
|
|
185
|
-
# sup_file = convert_mkv_to_sup(mkv_file, output_dir)
|
|
186
|
-
# sup_files.append(sup_file)
|
|
187
|
-
# with ThreadPoolExecutor() as executor:
|
|
188
|
-
# for sup_file in sup_files:
|
|
189
|
-
# executor.submit(perform_ocr, sup_file)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def extract_subtitles(mkv_file: str, output_dir: str) -> Optional[str]:
|
|
194
|
-
"""
|
|
195
|
-
Extract subtitles from MKV file based on detected subtitle type.
|
|
196
|
-
"""
|
|
197
|
-
subtitle_type, stream_index = detect_subtitle_type(mkv_file)
|
|
198
|
-
if not subtitle_type:
|
|
199
|
-
logger.error(f"No supported subtitle streams found in {mkv_file}")
|
|
200
|
-
return None
|
|
201
|
-
|
|
202
|
-
base_name = Path(mkv_file).stem
|
|
203
|
-
|
|
204
|
-
if subtitle_type == 'subrip':
|
|
205
|
-
# For SRT subtitles, extract directly to .srt
|
|
206
|
-
output_file = os.path.join(output_dir, f"{base_name}.srt")
|
|
207
|
-
if not os.path.exists(output_file):
|
|
208
|
-
cmd = [
|
|
209
|
-
"ffmpeg", "-i", mkv_file,
|
|
210
|
-
"-map", f"0:{stream_index}",
|
|
211
|
-
output_file
|
|
212
|
-
]
|
|
213
|
-
else:
|
|
214
|
-
# For DVD or PGS subtitles, extract to SUP format first
|
|
215
|
-
output_file = os.path.join(output_dir, f"{base_name}.sup")
|
|
216
|
-
if not os.path.exists(output_file):
|
|
217
|
-
cmd = [
|
|
218
|
-
"ffmpeg", "-i", mkv_file,
|
|
219
|
-
"-map", f"0:{stream_index}",
|
|
220
|
-
"-c", "copy",
|
|
221
|
-
output_file
|
|
222
|
-
]
|
|
223
|
-
|
|
224
|
-
if not os.path.exists(output_file):
|
|
225
|
-
try:
|
|
226
|
-
subprocess.run(cmd, check=True)
|
|
227
|
-
logger.info(f"Extracted subtitles from {mkv_file} to {output_file}")
|
|
228
|
-
return output_file
|
|
229
|
-
except subprocess.CalledProcessError as e:
|
|
230
|
-
logger.error(f"Error extracting subtitles: {e}")
|
|
231
|
-
return None
|
|
232
|
-
else:
|
|
233
|
-
logger.info(f"Subtitle file {output_file} already exists, skipping extraction")
|
|
234
|
-
return output_file
|
|
235
|
-
|
|
236
|
-
def convert_mkv_to_srt(season_path: str, mkv_files: list[str]) -> None:
|
|
237
|
-
"""
|
|
238
|
-
Convert subtitles from MKV files to SRT format.
|
|
239
|
-
"""
|
|
240
|
-
logger.info(f"Converting {len(mkv_files)} files to SRT")
|
|
241
|
-
|
|
242
|
-
# Filter out already processed files
|
|
243
|
-
unprocessed_files = []
|
|
244
|
-
for mkv_file in mkv_files:
|
|
245
|
-
if check_if_processed(os.path.basename(mkv_file)):
|
|
246
|
-
logger.info(f"Skipping {mkv_file} - already processed")
|
|
247
|
-
continue
|
|
248
|
-
unprocessed_files.append(mkv_file)
|
|
249
|
-
|
|
250
|
-
if not unprocessed_files:
|
|
251
|
-
logger.info("No new files to process")
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
# Create OCR directory
|
|
255
|
-
output_dir = os.path.join(season_path, "ocr")
|
|
256
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
257
|
-
|
|
258
|
-
for mkv_file in unprocessed_files:
|
|
259
|
-
subtitle_file = extract_subtitles(mkv_file, output_dir)
|
|
260
|
-
if not subtitle_file:
|
|
261
|
-
continue
|
|
262
|
-
|
|
263
|
-
if subtitle_file.endswith('.srt'):
|
|
264
|
-
# Already have SRT, keep it in OCR directory
|
|
265
|
-
logger.info(f"Extracted SRT subtitle to {subtitle_file}")
|
|
266
|
-
else:
|
|
267
|
-
# For SUP files (DVD or PGS), perform OCR
|
|
268
|
-
srt_file = perform_ocr(subtitle_file)
|
|
269
|
-
if srt_file:
|
|
270
|
-
logger.info(f"Created SRT from OCR: {srt_file}")
|
|
271
|
-
|
|
272
|
-
def detect_subtitle_type(mkv_file: str) -> tuple[Optional[str], Optional[int]]:
|
|
273
|
-
"""
|
|
274
|
-
Detect the type and index of subtitle streams in an MKV file.
|
|
275
|
-
"""
|
|
276
|
-
cmd = ["ffmpeg", "-i", mkv_file]
|
|
277
|
-
|
|
278
|
-
try:
|
|
279
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
280
|
-
|
|
281
|
-
subtitle_streams = []
|
|
282
|
-
for line in result.stderr.split('\n'):
|
|
283
|
-
if 'Subtitle' in line:
|
|
284
|
-
stream_index = int(line.split('#0:')[1].split('(')[0])
|
|
285
|
-
if 'subrip' in line:
|
|
286
|
-
subtitle_streams.append(('subrip', stream_index))
|
|
287
|
-
elif 'dvd_subtitle' in line:
|
|
288
|
-
subtitle_streams.append(('dvd_subtitle', stream_index))
|
|
289
|
-
elif 'hdmv_pgs_subtitle' in line:
|
|
290
|
-
subtitle_streams.append(('hdmv_pgs_subtitle', stream_index))
|
|
291
|
-
|
|
292
|
-
# Prioritize subtitle formats: SRT > DVD > PGS
|
|
293
|
-
for format_priority in ['subrip', 'dvd_subtitle', 'hdmv_pgs_subtitle']:
|
|
294
|
-
for format_type, index in subtitle_streams:
|
|
295
|
-
if format_type == format_priority:
|
|
296
|
-
return format_type, index
|
|
297
|
-
|
|
298
|
-
return None, None
|
|
299
|
-
|
|
300
|
-
except subprocess.CalledProcessError as e:
|
|
301
|
-
logger.error(f"Error detecting subtitle type: {e}")
|
|
302
|
-
return None, None
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
# mkv_episode_matcher/speech_to_text.py
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import whisper
|
|
7
|
-
import torch
|
|
8
|
-
from loguru import logger
|
|
9
|
-
|
|
10
|
-
def process_speech_to_text(mkv_file, output_dir):
|
|
11
|
-
"""
|
|
12
|
-
Convert MKV file to transcript using Whisper.
|
|
13
|
-
|
|
14
|
-
Args:
|
|
15
|
-
mkv_file (str): Path to MKV file
|
|
16
|
-
output_dir (str): Directory to save transcript files
|
|
17
|
-
"""
|
|
18
|
-
# Extract audio if not already done
|
|
19
|
-
wav_file = extract_audio(mkv_file, output_dir)
|
|
20
|
-
if not wav_file:
|
|
21
|
-
return None
|
|
22
|
-
|
|
23
|
-
# Load model
|
|
24
|
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
25
|
-
if device == "cuda":
|
|
26
|
-
logger.info(f"CUDA is available. Using GPU: {torch.cuda.get_device_name(0)}")
|
|
27
|
-
else:
|
|
28
|
-
logger.info("CUDA not available. Using CPU.")
|
|
29
|
-
|
|
30
|
-
model = whisper.load_model("base", device=device)
|
|
31
|
-
|
|
32
|
-
# Generate transcript
|
|
33
|
-
segments_file = os.path.join(output_dir, f"{Path(mkv_file).stem}.segments.json")
|
|
34
|
-
if not os.path.exists(segments_file):
|
|
35
|
-
try:
|
|
36
|
-
result = model.transcribe(
|
|
37
|
-
wav_file,
|
|
38
|
-
task="transcribe",
|
|
39
|
-
language="en",
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
# Save segments
|
|
43
|
-
import json
|
|
44
|
-
with open(segments_file, 'w', encoding='utf-8') as f:
|
|
45
|
-
json.dump(result["segments"], f, indent=2)
|
|
46
|
-
|
|
47
|
-
logger.info(f"Transcript saved to {segments_file}")
|
|
48
|
-
|
|
49
|
-
except Exception as e:
|
|
50
|
-
logger.error(f"Error during transcription: {e}")
|
|
51
|
-
return None
|
|
52
|
-
else:
|
|
53
|
-
logger.info(f"Using existing transcript: {segments_file}")
|
|
54
|
-
|
|
55
|
-
return segments_file
|
|
56
|
-
|
|
57
|
-
def extract_audio(mkv_file, output_dir):
|
|
58
|
-
"""
|
|
59
|
-
Extract audio from MKV file using FFmpeg.
|
|
60
|
-
|
|
61
|
-
Args:
|
|
62
|
-
mkv_file (str): Path to MKV file
|
|
63
|
-
output_dir (str): Directory to save WAV file
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
str: Path to extracted WAV file
|
|
67
|
-
"""
|
|
68
|
-
wav_file = os.path.join(output_dir, f"{Path(mkv_file).stem}.wav")
|
|
69
|
-
|
|
70
|
-
if not os.path.exists(wav_file):
|
|
71
|
-
logger.info(f"Extracting audio from {mkv_file}")
|
|
72
|
-
try:
|
|
73
|
-
cmd = [
|
|
74
|
-
'ffmpeg',
|
|
75
|
-
'-i', mkv_file,
|
|
76
|
-
'-vn', # Disable video
|
|
77
|
-
'-acodec', 'pcm_s16le', # Convert to PCM format
|
|
78
|
-
'-ar', '16000', # Set sample rate to 16kHz
|
|
79
|
-
'-ac', '1', # Convert to mono
|
|
80
|
-
wav_file
|
|
81
|
-
]
|
|
82
|
-
subprocess.run(cmd, check=True, capture_output=True)
|
|
83
|
-
logger.info(f"Audio extracted to {wav_file}")
|
|
84
|
-
except subprocess.CalledProcessError as e:
|
|
85
|
-
logger.error(f"Error extracting audio: {e}")
|
|
86
|
-
return None
|
|
87
|
-
else:
|
|
88
|
-
logger.info(f"Audio file {wav_file} already exists, skipping extraction")
|
|
89
|
-
|
|
90
|
-
return wav_file
|