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.
Files changed (38) hide show
  1. mkv_episode_matcher/__init__.py +8 -0
  2. mkv_episode_matcher/__main__.py +2 -177
  3. mkv_episode_matcher/asr_models.py +506 -0
  4. mkv_episode_matcher/cli.py +558 -0
  5. mkv_episode_matcher/core/config_manager.py +100 -0
  6. mkv_episode_matcher/core/engine.py +577 -0
  7. mkv_episode_matcher/core/matcher.py +214 -0
  8. mkv_episode_matcher/core/models.py +91 -0
  9. mkv_episode_matcher/core/providers/asr.py +85 -0
  10. mkv_episode_matcher/core/providers/subtitles.py +341 -0
  11. mkv_episode_matcher/core/utils.py +148 -0
  12. mkv_episode_matcher/episode_identification.py +550 -118
  13. mkv_episode_matcher/subtitle_utils.py +82 -0
  14. mkv_episode_matcher/tmdb_client.py +56 -14
  15. mkv_episode_matcher/ui/flet_app.py +708 -0
  16. mkv_episode_matcher/utils.py +262 -139
  17. mkv_episode_matcher-1.0.0.dist-info/METADATA +242 -0
  18. mkv_episode_matcher-1.0.0.dist-info/RECORD +23 -0
  19. {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/WHEEL +1 -1
  20. mkv_episode_matcher-1.0.0.dist-info/licenses/LICENSE +21 -0
  21. mkv_episode_matcher/config.py +0 -82
  22. mkv_episode_matcher/episode_matcher.py +0 -100
  23. mkv_episode_matcher/libraries/pgs2srt/.gitignore +0 -2
  24. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/SubZero.py +0 -321
  25. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/dictionaries/data.py +0 -16700
  26. mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/post_processing.py +0 -260
  27. mkv_episode_matcher/libraries/pgs2srt/README.md +0 -26
  28. mkv_episode_matcher/libraries/pgs2srt/__init__.py +0 -0
  29. mkv_episode_matcher/libraries/pgs2srt/imagemaker.py +0 -89
  30. mkv_episode_matcher/libraries/pgs2srt/pgs2srt.py +0 -150
  31. mkv_episode_matcher/libraries/pgs2srt/pgsreader.py +0 -225
  32. mkv_episode_matcher/libraries/pgs2srt/requirements.txt +0 -4
  33. mkv_episode_matcher/mkv_to_srt.py +0 -302
  34. mkv_episode_matcher/speech_to_text.py +0 -90
  35. mkv_episode_matcher-0.3.3.dist-info/METADATA +0 -125
  36. mkv_episode_matcher-0.3.3.dist-info/RECORD +0 -25
  37. {mkv_episode_matcher-0.3.3.dist-info → mkv_episode_matcher-1.0.0.dist-info}/entry_points.txt +0 -0
  38. {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,4 +0,0 @@
1
- pytesseract==0.3.7
2
- numpy==1.19.4
3
- Pillow==8.2.0
4
- tld~=0.12.3
@@ -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