11import asyncio
2+ import glob
23import json
34import locale
5+ import os
6+ import re
47import subprocess
8+ import tempfile
59from pathlib import Path
610from typing import Optional
711
@@ -86,25 +90,32 @@ def _extract_video_list(self) -> tuple[list[VideoSchema], str]:
8690 return video_list , channel_id
8791
8892 @staticmethod
89- async def download_video (video_info : VideoDownloadSchema , format : str = "best[ext=mp4]" ) -> None :
90- if Path (video_info .video_file_download_path ).exists ():
91- logger .info (f"Видео уже скачано: { video_info .video_file_download_path } " )
93+ async def download_video (
94+ video_info : VideoDownloadSchema ,
95+ format : str = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" ,
96+ ensure_mp4 : bool = True ,
97+ ) -> None :
98+ out_path = Path (video_info .video_file_download_path )
99+ if out_path .exists ():
100+ logger .info (f"Видео уже скачано: { out_path } " )
92101 return
93102
94- command = f'yt-dlp -f "{ format } " -o "{ video_info .video_file_download_path } " { video_info .video_url } '
95- logger .debug ("Downloading video: {}" .format (video_info .video_url ))
96- logger .debug ("Executing command: {}" .format (command ))
97- process = await asyncio .create_subprocess_shell (
103+ postproc_flag = "--recode-video mp4" if ensure_mp4 else "--merge-output-format mp4"
104+ command = f'yt-dlp -f "{ format } " { postproc_flag } -o "{ out_path } " { video_info .video_url } '
105+
106+ logger .debug (f"Downloading video: { video_info .video_url } " )
107+ logger .debug (f"Command: { command } " )
108+
109+ proc = await asyncio .create_subprocess_shell (
98110 command , stdout = asyncio .subprocess .PIPE , stderr = asyncio .subprocess .PIPE
99111 )
112+ _ , stderr = await proc .communicate ()
100113
101- _ , stderr = await process .communicate ()
102-
103- if process .returncode == 0 :
104- logger .info (f"Видео скачано: { video_info .video_file_download_path } " )
114+ if proc .returncode == 0 :
115+ logger .info (f"Видео скачано: { out_path } " )
105116 else :
106- stderr_text = stderr .decode (locale .getpreferredencoding (False ), errors = "replace" ).strip ()
107- logger .error (f"Ошибка скачивания видео: { stderr_text } " )
117+ err = stderr .decode (locale .getpreferredencoding (False ), "replace" ).strip ()
118+ logger .error (f"Ошибка скачивания видео: { err } " )
108119
109120 def download_thumbnail (self , video_id : str ) -> None :
110121 video : Video = self ._repository .get_video (video_id )
@@ -127,7 +138,8 @@ def update_video_formats(self) -> None:
127138 self ._repository .add_video_format (format_data , v_id )
128139 logger .debug (f"[{ i + 1 } /{ len (video_ids )} ] Added video formats for v_id: { v_id } " )
129140
130- def get_video_formats (self , video_id : str ) -> list [YTFormatSchema ]:
141+ @staticmethod
142+ def get_video_formats (video_id : str ) -> list [YTFormatSchema ]:
131143 result = subprocess .run (
132144 [
133145 "yt-dlp" ,
@@ -159,6 +171,120 @@ def get_video_formats(self, video_id: str) -> list[YTFormatSchema]:
159171 logger .error (f"Не удалось декодировать JSON: { e } " )
160172 return []
161173
174+ @staticmethod
175+ def fetch_transcript (
176+ video_id : str ,
177+ preferred_langs : tuple [str , ...] = ("ru" ,),
178+ ) -> Optional [str ]:
179+ """
180+ Возвращает расшифровку речи YouTube-видео (чистый текст без тайм-кодов).
181+
182+ Порядок поиска языка:
183+ 1. Перебор preferred_langs в automatic_captions
184+ 2. Первый язык из automatic_captions
185+ 3. Перебор preferred_langs в subtitles
186+ 4. Первый язык из subtitles
187+ 5. None, если субтитров нет
188+
189+ Parameters
190+ ----------
191+ video_id : str
192+ Идентификатор ролика (например, '7NSBuTHngK0').
193+ preferred_langs : tuple[str, ...], optional
194+ Коды языков, которые пробуем в первую очередь.
195+
196+ Returns
197+ -------
198+ str | None
199+ Сплошной текст субтитров или None, если ничего не найдено.
200+ """
201+ video_url = f"https://www.youtube.com/watch?v={ video_id } "
202+
203+ # 1. Получаем метаданные ролика
204+ try :
205+ proc = subprocess .run (
206+ ["yt-dlp" , "-j" , video_url ],
207+ check = True ,
208+ capture_output = True ,
209+ text = True ,
210+ )
211+ info = json .loads (proc .stdout )
212+ except (subprocess .CalledProcessError , json .JSONDecodeError ) as e :
213+ logger .error (f"Не удалось получить JSON-метаданные: { e } " )
214+ return None
215+
216+ auto_caps : dict [str , list ] = info .get ("automatic_captions" , {}) or {}
217+ man_caps : dict [str , list ] = info .get ("subtitles" , {}) or {}
218+
219+ # 2. Выбираем язык и тип субтитров
220+ lang , use_auto = None , None
221+
222+ for pl in preferred_langs :
223+ if pl in auto_caps :
224+ lang , use_auto = pl , True
225+ break
226+ if lang is None and auto_caps :
227+ lang , use_auto = next (iter (auto_caps )), True
228+
229+ if lang is None :
230+ for pl in preferred_langs :
231+ if pl in man_caps :
232+ lang , use_auto = pl , False
233+ break
234+ if lang is None and man_caps :
235+ lang , use_auto = next (iter (man_caps )), False
236+
237+ if lang is None :
238+ logger .warning (f"У ролика { video_id } нет субтитров" )
239+ return None
240+
241+ # 3. Скачиваем json3 и читаем
242+ with tempfile .TemporaryDirectory () as tmpdir :
243+ outtmpl = os .path .join (tmpdir , "subs" ) # yt-dlp → subs.<lang>.json3
244+ cmd = [
245+ "yt-dlp" ,
246+ "--skip-download" ,
247+ "--sub-lang" ,
248+ lang ,
249+ "--sub-format" ,
250+ "json3" ,
251+ "-o" ,
252+ outtmpl ,
253+ video_url ,
254+ ]
255+ cmd .insert (1 , "--write-auto-subs" if use_auto else "--write-subs" )
256+
257+ try :
258+ subprocess .run (cmd , check = True , capture_output = True , text = True )
259+ except subprocess .CalledProcessError as e :
260+ logger .error (f"Ошибка скачивания субтитров: { e .stderr .strip ()} " )
261+ return None
262+
263+ pattern = os .path .join (tmpdir , f"subs.{ lang } *.json3" )
264+ files = glob .glob (pattern )
265+ if not files :
266+ logger .error (f"Файл субтитров не найден ({ pattern } )" )
267+ return None
268+
269+ try :
270+ with open (files [0 ], encoding = "utf-8" ) as fp :
271+ data = json .load (fp )
272+ except (OSError , json .JSONDecodeError ) as e :
273+ logger .error (f"Не удалось прочитать JSON3 файл: { e } " )
274+ return None
275+
276+ # 4. Превращаем JSON3 в сплошной текст
277+ if "events" not in data :
278+ logger .error ("JSON3 не содержит ключ 'events'" )
279+ return None
280+
281+ text_chunks = [seg ["utf8" ] for ev in data ["events" ] if "segs" in ev for seg in ev ["segs" ] if "utf8" in seg ]
282+ # Убираем лишние пробелы/переводы строк подряд
283+ transcript = re .sub (r"\s+" , " " , " " .join (text_chunks )).strip ()
284+
285+ logger .info (f"Расшифровка { video_id } ({ lang } ) успешно получена ({ len (transcript )} символов)" )
286+ return transcript
287+
162288 def channel_exist (self , channel_id : str ) -> bool :
163289 return bool (self ._repository .get_channel_by_id (channel_id ))
164290
0 commit comments