-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstt_function_v3.py
More file actions
2148 lines (1815 loc) · 85.8 KB
/
Copy pathstt_function_v3.py
File metadata and controls
2148 lines (1815 loc) · 85.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# library
import os, time, random, json, csv, re, html
import threading
from datetime import datetime
import pandas as pd
from tqdm import tqdm
from openai import OpenAI
try:
import mlx_whisper
except ImportError:
print("Warning: mlx_whisper 모듈이 설치되지 않았습니다. 'pip install mlx-whisper' 명령으로 설치해주세요.")
mlx_whisper = None
import subprocess
import tiktoken
from urllib.parse import urlparse, parse_qs
import logging
from dotenv import load_dotenv
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
try:
from pytubefix import YouTube
from pytubefix.cli import on_progress
except ImportError:
YouTube = None
on_progress = None
import moviepy
# Load environment variables from .env file (retry on OSError Errno 11 — often EDEADLK on macOS)
for _ in range(3):
try:
load_dotenv()
break
except OSError as e:
if getattr(e, "errno", None) == 11 and _ < 2:
time.sleep(2 * (_ + 1))
continue
raise
# Check for yt-dlp availability and warn if not available
if not YT_DLP_AVAILABLE:
import warnings
warnings.warn(
"yt-dlp is not installed. For better reliability, install it: pip install yt-dlp\n"
"Falling back to pytubefix which may have compatibility issues.",
UserWarning
)
# Last yt-dlp / downloader failure (legacy global; prefer thread-local via get_last_ytdlp_failure_reason)
_LAST_YTDLP_FAILURE_REASON: str = ""
_download_error_local = threading.local()
def _set_last_ytdlp_failure(msg: str) -> None:
global _LAST_YTDLP_FAILURE_REASON
reason = (msg or "")[:8000]
_LAST_YTDLP_FAILURE_REASON = reason
_download_error_local.reason = reason
def get_last_ytdlp_failure_reason() -> str:
"""Last yt-dlp download error for current thread (falls back to legacy global)."""
local = getattr(_download_error_local, "reason", None)
if local:
return local
return _LAST_YTDLP_FAILURE_REASON
def clear_last_ytdlp_failure() -> None:
_set_last_ytdlp_failure("")
def _subtitle_file_is_incomplete(path: str, video_duration_sec: float | None = None) -> bool:
"""
Detect truncated yt-dlp subtitle writes (often ~1KiB first HTTP chunk if the process dies mid-transfer).
"""
try:
sz = os.path.getsize(path)
except OSError:
return True
if sz <= 0:
return True
try:
with open(path, "rb") as f:
blob = f.read()
except OSError:
return True
low = path.lower()
if low.endswith(".vtt") and not blob.startswith(b"WEBVTT"):
return True
n_cues = sum(1 for line in blob.splitlines() if b"-->" in line)
# Complete writes from yt-dlp/ffmpeg virtually always end with a newline; mid-line truncation does not.
if not blob.endswith(b"\n"):
return True
# First chunk is often exactly 1024 bytes when the transfer is cut very early (seen in the wild May 2026).
if sz <= 1100 and (video_duration_sec is None or video_duration_sec >= 90):
return True
if video_duration_sec is not None and video_duration_sec >= 180:
if sz <= 4096 and n_cues < max(8, int(video_duration_sec / 120)):
return True
return False
# class YouTubeDownloader:
# def __init__(self, max_daily_downloads=100):
# self.max_daily_downloads = max_daily_downloads
# self.download_count = 0
# self.last_reset = datetime.now().date()
# self.tokens = []
# self.current_token_index = 0
# def reset_daily_count(self):
# """Reset daily download count"""
# today = datetime.now().date()
# if today != self.last_reset:
# self.download_count = 0
# self.last_reset = today
# def can_download(self):
# """Check if we can make another download"""
# self.reset_daily_count()
# return self.download_count < self.max_daily_downloads
# def get_multiple_po_tokens(self):
# """Generate multiple PO tokens for rotation"""
# tokens = []
# for _ in range(3): # Generate 3 tokens
# try:
# result = subprocess.run(
# ["node", "generate_token.js"],
# capture_output=True,
# text=True,
# check=True
# )
# token_data = json.loads(result.stdout)
# tokens.append(token_data)
# time.sleep(2) # Wait between token generations
# except Exception as e:
# print(f"Token generation error: {e}")
# return tokens
# def download_with_limits(self, URL, DOWNLOAD_PATH):
# """Enhanced downloader with rate limiting and token rotation"""
# if not self.can_download():
# print("Daily download limit reached")
# return None
# # Generate tokens if needed
# if not self.tokens:
# self.tokens = self.get_multiple_po_tokens()
# # Add random delay
# time.sleep(random.uniform(2, 5))
# try:
# # Use current token in rotation
# current_token = self.tokens[self.current_token_index % len(self.tokens)]
# # Enhanced download with token rotation
# result = self.yt_downloader_with_token_rotation(URL, DOWNLOAD_PATH, current_token)
# if result:
# self.download_count += 1
# self.current_token_index = (self.current_token_index + 1) % len(self.tokens)
# return result
# except Exception as e:
# print(f"Download failed: {e}")
# return None
# def set_token_environment(self, token):
# """Set token in environment for pytubefix to use"""
# if token and 'poToken' in token:
# os.environ['YOUTUBE_PO_TOKEN'] = token['poToken']
# if 'visitorData' in token:
# os.environ['YOUTUBE_VISITOR_DATA'] = token['visitorData']
# print("Token set in environment")
# else:
# print("Invalid token format")
# def yt_downloader_with_token_rotation(self, URL, DOWNLOAD_PATH, token):
# """Downloader that uses specific token"""
# try:
# # ACTUALLY USE the token by setting it in environment
# self.set_token_environment(token)
# yt = YouTube(URL,
# use_po_token=True,
# on_progress_callback=on_progress)
# # Get video info from YouTube object directly
# video_id = yt.video_id
# video_len = yt.length if yt.length is not None else 0
# channel_id = yt.channel_id
# channel_url = yt.channel_url
# # Download audio
# audio_stream = yt.streams.get_audio_only()
# filename = sanitize_filename(audio_stream.default_filename)
# audio_stream.download(output_path=DOWNLOAD_PATH, filename=filename)
# full_saved_path = f'{DOWNLOAD_PATH}/{filename}'
# print("Download and save completed")
# return full_saved_path, filename, video_id, video_len, channel_id, channel_url
# except Exception as e:
# print(f"Error in token rotation download: {e}")
# return None
# # Add this new function for batch processing
# def process_urls_in_batches(urls, downloader, DOWNLOAD_PATH, batch_size=10, delay_between_batches=300):
# """Process URLs in small batches with delays"""
# results = []
# for i in range(0, len(urls), batch_size):
# batch = urls[i:i+batch_size]
# print(f"Processing batch {i//batch_size + 1} of {(len(urls) + batch_size - 1) // batch_size}")
# for url in batch:
# result = downloader.download_with_limits(url, DOWNLOAD_PATH)
# results.append(result)
# time.sleep(random.uniform(3, 7)) # Delay between downloads
# # Longer delay between batches
# if i + batch_size < len(urls):
# print(f"Waiting {delay_between_batches} seconds before next batch...")
# time.sleep(delay_between_batches)
# return results
# # Add this enhanced error handling function
# def yt_downloader_robust(URL, DOWNLOAD_PATH, downloader, max_retries=3):
# """Robust downloader with multiple retry strategies"""
# for attempt in range(max_retries):
# try:
# result = downloader.download_with_limits(URL, DOWNLOAD_PATH)
# if result:
# return result
# except Exception as e:
# print(f"Attempt {attempt + 1} failed: {e}")
# if attempt < max_retries - 1:
# # Exponential backoff
# wait_time = (2 ** attempt) * random.uniform(5, 15)
# print(f"Waiting {wait_time:.1f} seconds before retry...")
# time.sleep(wait_time)
# else:
# print("All retry attempts failed")
# return None
# return None
# Proxy configuration from environment variable
def get_proxy_config():
"""Get proxy configuration from environment variable."""
proxy_address = os.getenv("PROXY_ADDRESS")
if proxy_address:
return {
"http": proxy_address,
"https": proxy_address
}
return None
# User-Agent rotation to avoid detection
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
]
def get_random_user_agent():
"""Get a random User-Agent string."""
return random.choice(USER_AGENTS)
proxy = get_proxy_config()
# v1 version
def get_youtube_po_token():
try:
# Run the Node.js script
result = subprocess.run(
["node", "generate_token.js"],
capture_output=True,
text=True,
check=True
)
# Parse the JSON output
token_data = json.loads(result.stdout)
return token_data
except subprocess.CalledProcessError as e:
print(f"Error: {e.stderr}")
return None
# Call the function - 주석 처리: 모듈 import 시점 실행 방지
# token = get_youtube_po_token()
# if token:
# print("Generated Token:", token)
# change file name-1
def sanitize_filename(filename, replacement="_", max_length=50):
# Split the base name and the extension
if "." in filename:
base_name, extension = filename.rsplit(".", 1)
else:
base_name, extension = filename, ""
# Sanitize the base name
invalid_chars = r'[<>:"/\\|?*\x00-\x1F]'
sanitized = re.sub(invalid_chars, replacement, base_name)
# Optionally strip leading/trailing whitespace or dots
sanitized = sanitized.strip(" ").rstrip(".")
# Reduce the base name length while keeping the extension
if len(sanitized) > max_length:
sanitized = sanitized[:max_length]
# Reassemble the sanitized name with the extension
if extension:
return f"{sanitized}.{extension}"
return sanitized
# change file name-2
def change_filename(filename, adding):
if "." in filename:
base_name, extension = filename.rsplit(".",1)
else:
base_name, extension = filename, ""
# adding
changed_name = base_name + adding
if extension:
return f"{changed_name}.{extension}" # name.extension
else:
print("error in file name so nothing changed")
return filename
def change_extension(filename, new_extension):
if "." in filename:
base_name, old_extension = filename.rsplit(".",1)
else:
base_name, old_extension = filename, ""
# change extension
if old_extension:
return f"{base_name}.{new_extension}" # name.new_extension
else:
print("error in the file name")
return filename
_INLINE_VTT_TS_RE = re.compile(r"<\d{2}:\d{2}:\d{2}(?:[\.,]\d{1,3})?>")
_INLINE_VTT_TAG_RE = re.compile(r"</?(?:c|i|b|u|ruby|rt|v)(?:\.[^>]*)?(?:\s+[^>]*)?>", re.IGNORECASE)
_ANY_ANGLE_TAG_RE = re.compile(r"<[^>]+>")
_CUE_SETTINGS_RE = re.compile(
r"\b(?:align|position|line|size|vertical|region):[^\s]+",
re.IGNORECASE,
)
_NOISE_ONLY_RE = re.compile(r"^[\W_]+$")
def _normalize_spaces(text: str) -> str:
if not text:
return ""
text = text.replace("\u00A0", " ").replace("\u200B", "")
text = re.sub(r"\s+", " ", text).strip()
return text
def _resolve_youtube_cookies_file(work_path=None):
"""Prefer local WORK_PATH cookies (launchd-safe); avoid Documents project path."""
env_path = os.getenv("YOUTUBE_COOKIES_FILE", "").strip()
if env_path and os.path.exists(env_path):
return env_path
candidates = []
if work_path:
candidates.append(os.path.join(work_path, "youtube_cookies.txt"))
candidates.extend([
os.path.join(os.path.dirname(__file__), "youtube_cookies.txt"),
])
for p in candidates:
if p and os.path.exists(p):
return p
return None
def _clean_subtitle_content_line(line: str) -> str:
"""Clean one subtitle content line (keep spoken text, remove VTT markup/noise)."""
if not line:
return ""
cleaned = html.unescape(line)
cleaned = _INLINE_VTT_TS_RE.sub("", cleaned)
cleaned = _INLINE_VTT_TAG_RE.sub("", cleaned)
cleaned = _ANY_ANGLE_TAG_RE.sub("", cleaned)
cleaned = _CUE_SETTINGS_RE.sub("", cleaned)
cleaned = _normalize_spaces(cleaned)
return cleaned
def subtitle_file_to_plain_text(path):
"""
Read a VTT or SRT subtitle file and return plain text (strip timing/cue lines and VTT inline tags).
Used when uploader subtitles exist so we skip Whisper and feed text to summary pipeline.
"""
if not path or not os.path.isfile(path):
return ""
logger = logging.getLogger(__name__)
text_lines = []
total_lines = 0
dropped_cue_lines = 0
dropped_noise_lines = 0
dedup_drops = 0
with open(path, "r", encoding="utf-8", errors="replace") as f:
for raw_line in f:
total_lines += 1
line = raw_line.strip()
if not line:
continue
# WebVTT/SRT metadata and cue numbers
if line.upper().startswith("WEBVTT") or line.lower().startswith("kind:") or line.lower().startswith("language:"):
dropped_cue_lines += 1
continue
if re.match(r"^\d+$", line): # SRT cue number or VTT cue id
dropped_cue_lines += 1
continue
# Full timestamp line with optional cue settings: 00:00:00.000 --> 00:00:00.000 align:start position:0%
if re.match(r"^\d{2}:\d{2}:\d{2}[\.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[\.,]\d{3}(?:\s+.*)?$", line):
dropped_cue_lines += 1
continue
cleaned = _clean_subtitle_content_line(line)
if not cleaned or _NOISE_ONLY_RE.match(cleaned):
dropped_noise_lines += 1
continue
if text_lines:
prev = text_lines[-1]
# Immediate duplicate
if cleaned == prev:
dedup_drops += 1
continue
# Rolling caption growth (keep longer line)
if cleaned.startswith(prev) and len(cleaned) > len(prev):
text_lines[-1] = cleaned
dedup_drops += 1
continue
if prev.startswith(cleaned):
dedup_drops += 1
continue
text_lines.append(cleaned)
logger.debug(
"subtitle cleanse: total=%d kept=%d dropped_cue=%d dropped_noise=%d dedup=%d path=%s",
total_lines,
len(text_lines),
dropped_cue_lines,
dropped_noise_lines,
dedup_drops,
path,
)
return "\n".join(text_lines).strip()
def append_video_metadata_jsonl(jsonl_path: str, upload_date: str, v_id: str,
transcript_date: str, method: str, md_path: str,
has_yid: bool = True) -> None:
"""
Append one record to video_metadata JSONL.
Schema: {upload_date, v_id, transcript_date, method, md_path, has_yid}
method: "whisper" | "subs" | "auto_subs" | "no_yid"
has_yid: True if v_id is valid, False for YID-less (method="no_yid")
"""
record = {
"upload_date": upload_date or "",
"v_id": v_id or "",
"transcript_date": transcript_date or "",
"method": method or "",
"md_path": md_path or "",
"has_yid": has_yid,
}
try:
parent = os.path.dirname(jsonl_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(jsonl_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
logging.getLogger(__name__).warning("Failed to append video_metadata JSONL: %s", e)
def _format_upload_date(info) -> str:
"""Format yt-dlp upload_date (YYYYMMDD) to YYYY-MM-DD."""
ud = (info or {}).get("upload_date") or ""
if len(ud) == 8:
return f"{ud[:4]}-{ud[4:6]}-{ud[6:8]}"
return ud
# url extractor
def extract_youtube_id(url):
# Parse the URL
parsed_url = urlparse(url)
# Handle 'youtu.be' short links
if parsed_url.netloc == "youtu.be":
return parsed_url.path[1:] # Video ID is in the path
# Handle 'youtube.com' links
if parsed_url.netloc in ["www.youtube.com", "youtube.com"]:
query_params = parse_qs(parsed_url.query)
return query_params.get("v", [None])[0] # Extract the 'v' parameter
raise ValueError("Invalid YouTube URL")
def _subtitle_pick_saved_file(subs_path: str, video_id: str, langs: list) -> tuple | None:
"""
Locate subtitle file after yt-dlp --write-subs / --write-auto-subs.
yt-dlp may write manual captions as {id}.{lang}-orig.vtt (not only {id}.{lang}.vtt).
"""
if not video_id or not langs:
return None
for lang in langs:
for ext in (".vtt", ".srt"):
for stem_suffix in ("", "-orig"):
p = os.path.join(subs_path, f"{video_id}.{lang}{stem_suffix}{ext}")
if os.path.isfile(p):
return (p, lang)
for ext in (".vtt", ".srt"):
p = os.path.join(subs_path, f"{video_id}{ext}")
if os.path.isfile(p):
return (p, "")
return None
def _parse_subs_langs(subs_langs_str):
"""Parse YOUTUBE_SUBS_LANGS string to list. Default: en,ko,ja,en-US,en-GB. Maps jp->ja (YouTube uses ja)."""
default = ["en", "ko", "ja", "en-US", "en-GB"]
if not subs_langs_str or not isinstance(subs_langs_str, str):
return default
parts = [x.strip() for x in subs_langs_str.split(",") if x.strip()]
# YouTube/yt-dlp use ISO 639-1: ja for Japanese, not jp
normalized = ["ja" if p.lower() == "jp" else p for p in parts]
return normalized if normalized else default
def _cleanup_ytdlp_subs_artifacts(subs_path: str, video_id: str, logger) -> None:
"""
Before retrying after Errno 11, remove stale yt-dlp fragments (.part, .tmp) and
zero-byte subtitle files for this video_id under subs_path.
"""
if not subs_path or not video_id or not os.path.isdir(subs_path):
return
try:
for name in os.listdir(subs_path):
if video_id not in name:
continue
path = os.path.join(subs_path, name)
if not os.path.isfile(path):
continue
low = name.lower()
if low.endswith(".part") or low.endswith(".tmp") or ".part" in low or low.endswith(".ytdl"):
try:
os.remove(path)
logger.info("Removed stale subs artifact before retry: %s", name)
except OSError as exc:
logger.debug("Could not remove %s: %s", path, exc)
elif low.endswith(".vtt") or low.endswith(".srt"):
try:
if os.path.getsize(path) == 0:
os.remove(path)
logger.info("Removed zero-byte subtitle before retry: %s", name)
except OSError:
pass
except OSError as exc:
logger.debug("cleanup_ytdlp_subs_artifacts: %s", exc)
def _resolve_primary_lang(info, prefer_lang, subs_langs):
"""
Resolve primary language for auto-captions download (single-lang to save bandwidth).
Priority: 1) prefer_lang if in automatic_captions, 2) first from subs_langs in ac, 3) first ac key.
Returns language code (e.g. "en", "en-US") or None.
"""
ac = (info or {}).get("automatic_captions") or {}
if not isinstance(ac, dict) or not ac:
return None
ac_keys = list(ac.keys())
def _match(ac_key, lang):
if not lang:
return False
base = lang.split("-")[0]
return ac_key == lang or ac_key == base or ac_key.startswith(base + "-")
# 1) prefer_lang이 있고 ac에 존재
if prefer_lang and str(prefer_lang).strip():
for k in ac_keys:
if _match(k, prefer_lang):
return k
# 2) subs_langs 순서대로 ac에 존재하는 첫 언어
for lang in (subs_langs or []):
for k in ac_keys:
if _match(k, lang):
return k
# 3) ac의 첫 키
return ac_keys[0] if ac_keys else None
def _yt_download_subs_only(URL, video_id, subs_path, logger, subs_langs=None, video_duration_sec=None):
"""
Download uploader subtitles only (--write-subs --skip-download).
Prefer VTT, then SRT. Saves to subs_path with filename {video_id}.{lang}.vtt.
subs_langs: list of language codes (e.g. ["en","ko","ja","en-US","en-GB"]). From config YOUTUBE_SUBS_LANGS.
video_duration_sec: optional VOD duration from extract_info (improves truncated-file detection).
Returns (path, lang) or None on failure.
"""
if not YT_DLP_AVAILABLE:
return None
try:
os.makedirs(subs_path, exist_ok=True)
except OSError as e:
logger.warning("Failed to create yt_subs dir %s: %s", subs_path, e)
return None
langs = subs_langs if subs_langs else _parse_subs_langs(None)
proxy_config = get_proxy_config()
user_agent = get_random_user_agent()
ydl_opts = {
"skip_download": True,
"writesubtitles": True,
"writeautomaticsub": False,
"subtitlesformat": "vtt/srt",
"subtitleslangs": langs,
"outtmpl": os.path.join(subs_path, "%(id)s.%(ext)s"),
"quiet": True,
"no_warnings": True,
# Avoid minicurses progress writing to stderr (Broken pipe under launchd log redirect).
"noprogress": True,
"noplaylist": True,
# Avoid .vtt.part + final rename on macOS (reduces Errno 11 / EDEADLK on some setups)
"nopart": True,
"user_agent": user_agent,
"referer": "https://www.youtube.com/",
}
if proxy_config:
proxy_url = proxy_config.get("http") or proxy_config.get("https")
if proxy_url:
ydl_opts["proxy"] = proxy_url
cookies_file = _resolve_youtube_cookies_file()
if cookies_file:
ydl_opts["cookiefile"] = cookies_file
max_subs_retries = 3
for subs_attempt in range(max_subs_retries):
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([URL])
picked = _subtitle_pick_saved_file(subs_path, video_id, langs)
if picked:
pp, plang = picked
psz = os.path.getsize(pp) if os.path.isfile(pp) else -1
vd = float(video_duration_sec) if video_duration_sec else None
if _subtitle_file_is_incomplete(pp, vd):
logger.warning(
"Discarding incomplete/truncated subtitle (%d bytes, duration_hint=%s): %s",
psz,
vd,
pp,
)
try:
os.remove(pp)
except OSError:
pass
_cleanup_ytdlp_subs_artifacts(subs_path, video_id, logger)
if subs_attempt < max_subs_retries - 1:
time.sleep(random.uniform(2, 6))
continue
return picked
return None
except Exception as e:
err_str = str(e)
is_errno11 = getattr(e, "errno", None) == 11 or "errno 11" in err_str.lower() or "resource deadlock" in err_str.lower()
if is_errno11 and subs_attempt < max_subs_retries - 1:
_cleanup_ytdlp_subs_artifacts(subs_path, video_id, logger)
wait_s = random.uniform(5, 12)
logger.warning("Subs-only download Errno 11 for %s, retry %d/%d in %.1fs", video_id, subs_attempt + 1, max_subs_retries, wait_s)
time.sleep(wait_s)
else:
logger.warning("Subs-only download failed for %s: %s", video_id, e)
_set_last_ytdlp_failure(f"subs_only: {err_str}")
return None
return None
def _yt_download_auto_subs_only(URL, video_id, subs_path, logger, subs_langs=None, prefer_lang=None, info=None, video_duration_sec=None):
"""
Download YouTube auto-generated captions only (--write-auto-subs --skip-download).
When info is provided, downloads only the primary language (saves bandwidth).
Prefer VTT, then SRT. Saves to subs_path with filename {video_id}.{lang}.vtt.
subs_langs: list of language codes. From config YOUTUBE_SUBS_LANGS.
prefer_lang: if set (e.g. from defaultAudioLanguage), used to resolve primary lang.
info: yt-dlp extract_info result; when set, primary lang is resolved and only that lang is downloaded.
video_duration_sec: optional VOD duration (same as extract_info duration); improves truncated-file detection.
Returns (path, lang) or None on failure.
"""
if not YT_DLP_AVAILABLE:
return None
try:
os.makedirs(subs_path, exist_ok=True)
except OSError as e:
logger.warning("Failed to create yt_subs dir %s: %s", subs_path, e)
return None
subs_langs_list = subs_langs if subs_langs else _parse_subs_langs(None)
primary_lang = _resolve_primary_lang(info, prefer_lang, subs_langs_list)
if primary_lang:
langs = [primary_lang]
logger.debug("Auto-subs: downloading single lang %s for %s", primary_lang, video_id)
else:
langs = subs_langs_list
if prefer_lang and prefer_lang not in langs:
langs = [prefer_lang] + [x for x in langs if x != prefer_lang]
elif prefer_lang:
langs = [prefer_lang] + [x for x in langs if x != prefer_lang]
proxy_config = get_proxy_config()
user_agent = get_random_user_agent()
ydl_opts = {
"skip_download": True,
"writesubtitles": False,
"writeautomaticsub": True,
"subtitlesformat": "vtt/srt",
"subtitleslangs": langs,
"outtmpl": os.path.join(subs_path, "%(id)s.%(ext)s"),
"quiet": True,
"no_warnings": True,
"noprogress": True,
"noplaylist": True,
"nopart": True,
"user_agent": user_agent,
"referer": "https://www.youtube.com/",
}
if proxy_config:
proxy_url = proxy_config.get("http") or proxy_config.get("https")
if proxy_url:
ydl_opts["proxy"] = proxy_url
cookies_file = _resolve_youtube_cookies_file()
if cookies_file:
ydl_opts["cookiefile"] = cookies_file
max_subs_retries = 3
for subs_attempt in range(max_subs_retries):
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([URL])
picked = _subtitle_pick_saved_file(subs_path, video_id, langs)
if picked:
pp, plang = picked
psz = os.path.getsize(pp) if os.path.isfile(pp) else -1
vd = float(video_duration_sec) if video_duration_sec else None
if info is not None and vd is None:
d0 = info.get("duration")
if d0:
vd = float(d0)
if _subtitle_file_is_incomplete(pp, vd):
logger.warning(
"Discarding incomplete/truncated auto subtitle (%d bytes, duration_hint=%s): %s",
psz,
vd,
pp,
)
try:
os.remove(pp)
except OSError:
pass
_cleanup_ytdlp_subs_artifacts(subs_path, video_id, logger)
if subs_attempt < max_subs_retries - 1:
time.sleep(random.uniform(2, 6))
continue
return picked
return None
except Exception as e:
err_str = str(e)
is_errno11 = getattr(e, "errno", None) == 11 or "errno 11" in err_str.lower() or "resource deadlock" in err_str.lower()
if is_errno11 and subs_attempt < max_subs_retries - 1:
_cleanup_ytdlp_subs_artifacts(subs_path, video_id, logger)
wait_s = random.uniform(5, 12)
logger.warning("Auto-subs download Errno 11 for %s, retry %d/%d in %.1fs", video_id, subs_attempt + 1, max_subs_retries, wait_s)
time.sleep(wait_s)
else:
logger.warning("Auto-subs download failed for %s: %s", video_id, e)
_set_last_ytdlp_failure(f"auto_subs: {err_str}")
return None
return None
# downloaded into m4a type (AUDIO ONLY - no video)
def yt_downloader(URL, DOWNLOAD_PATH, ONLY_AUDIO=True, ITAG=139, max_retries=3, config=None):
"""
Download YouTube video AUDIO ONLY (no video) with retry logic.
Uses yt-dlp if available (more reliable), falls back to pytubefix.
When config is provided and uploader subtitles exist, may skip video download
(YT_DOWNLOAD_IF_SUBS_Y=False) and return subs path as 7th element.
Args:
URL: YouTube video URL
DOWNLOAD_PATH: Path to save the audio file
ONLY_AUDIO: Whether to download only audio (default: True, always True)
ITAG: Stream ITAG (default: 139, not used with yt-dlp)
max_retries: Maximum number of retry attempts (default: 3)
config: Optional dict with BASE_PATH, WORK_PATH, YT_DOWNLOAD_IF_SUBS_Y, YOUTUBE_AUTO_SCRIPT, YOUTUBE_SUBS_LANGS for subs optimization
Returns:
10-tuple: (audio_path, filename, video_id, video_len, channel_id, channel_url, subs_path, subs_source, subs_lang, channel_name) or None if failed.
subs_source: "uploader" | "auto" | None. subs_lang: language code of subs used (e.g. "en") or None. channel_name: from yt-dlp uploader/channel.
"""
logger = logging.getLogger(__name__)
video_id = extract_youtube_id(URL)
if YT_DLP_AVAILABLE:
return yt_downloader_ytdlp(URL, DOWNLOAD_PATH, video_id, max_retries, config=config)
else:
logger.warning("yt-dlp not available, using pytubefix (may have issues)")
result = yt_downloader_pytubefix(URL, DOWNLOAD_PATH, video_id, max_retries)
if result is not None:
return (*result, None, None, None, "", "")
return None
def yt_downloader_ytdlp(URL, DOWNLOAD_PATH, video_id, max_retries=3, config=None):
"""
Download AUDIO ONLY using yt-dlp (recommended).
Returns 10-tuple: (audio_path, filename, video_id, video_len, channel_id, channel_url, subs_path, subs_source, subs_lang, channel_name).
subs_source: "uploader" | "auto" | None. subs_lang: language code or None. channel_name: from yt-dlp uploader/channel.
"""
logger = logging.getLogger(__name__)
base_path = config.get("BASE_PATH") if config else None
work_path = config.get("WORK_PATH") if config else None
yt_download_if_subs_y = config.get("YT_DOWNLOAD_IF_SUBS_Y", True) if config else True
use_auto_script = config.get("YOUTUBE_AUTO_SCRIPT", True) if config else True
subs_langs = _parse_subs_langs(config.get("YOUTUBE_SUBS_LANGS")) if config else _parse_subs_langs(None)
_set_last_ytdlp_failure("")
attempt = 0
max_attempts = max_retries
while attempt < max_attempts:
try:
if attempt > 0:
wait_time = random.uniform(3, 8) * (attempt + 1)
logger.info(f"Waiting {wait_time:.1f} seconds before retry...")
time.sleep(wait_time)
proxy_config = get_proxy_config()
user_agent = get_random_user_agent()
downloaded_filename = [None]
def progress_hook(d):
if d['status'] == 'finished':
downloaded_filename[0] = d.get('filename')
files_before = set()
if os.path.exists(DOWNLOAD_PATH):
files_before = {f for f in os.listdir(DOWNLOAD_PATH)
if os.path.isfile(os.path.join(DOWNLOAD_PATH, f))}
ydl_opts = {
'format': 'bestaudio[abr<=128]/bestaudio[abr<=160]/bestaudio[ext=m4a]/bestaudio[ext=opus]/bestaudio/best',
'outtmpl': os.path.join(DOWNLOAD_PATH, '%(title)s.%(ext)s'),
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'm4a',
'preferredquality': '128',
}],
'quiet': True,
'no_warnings': True,
'noprogress': True,
'extract_flat': False,
'noplaylist': True,
'progress_hooks': [progress_hook],
'writesubtitles': False,
'writeautomaticsub': False,
'writethumbnail': False,
'keepvideo': False,
'nopart': True,
'user_agent': user_agent,
'referer': 'https://www.youtube.com/',
'extractor_args': {
'youtube': {
'player_client': ['android', 'web'],
'player_skip': ['webpage', 'configs'],
}
},
'http_headers': {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
'Connection': 'keep-alive',
},
}
if proxy_config:
proxy_url = proxy_config.get('http') or proxy_config.get('https')
if proxy_url:
ydl_opts['proxy'] = proxy_url
cookies_file = _resolve_youtube_cookies_file(work_path)
if cookies_file:
ydl_opts['cookiefile'] = cookies_file
logger.debug(f"Using cookies file: {cookies_file}")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(URL, download=False)
# Skip only truly upcoming/live (no VOD). was_live/post_live may have VOD after processing.
live_status = (info or {}).get("live_status") or ""
if live_status in ("is_upcoming", "is_live"):
logger.info("Skipping (live_status=%s, no VOD): %s", live_status, URL)
return ("__LIVE_SCHEDULED__", None, video_id, None, None, None, None, None, None, "", _format_upload_date(info))
video_len = info.get('duration', 0)
channel_id = info.get('channel_id', '')
channel_url = info.get('channel_url', '')
channel_name = info.get('uploader') or info.get('channel') or ''
title = info.get('title', f'video_{video_id}')
has_uploader_subs = bool(info.get("subtitles"))
has_auto_captions = bool(info.get("automatic_captions"))
logger.info("UPLOADER SUBS FOUND: %s", has_uploader_subs)
# auto_subs_only channel: process only when 자막 OR 자동 자막 exists; skip if neither
auto_subs_only = bool(config.get("auto_subs_only")) if config else False
if auto_subs_only and not (has_uploader_subs or has_auto_captions):
logger.info("Skipping (auto_subs_only channel, no subs): %s", URL)
return ("__SKIP_AUTO_SUBS_ONLY__", None, video_id, None, None, None, None, None, None, "", _format_upload_date(info))
subs_path_result = None
subs_source = None
subs_lang = None
prefer_lang = config.get("default_audio_lang") if config else None
vd_hint = float(video_len) if video_len else None
job_subs_dir = config.get("JOB_SUBS_DIR") if config else None
if has_uploader_subs and (base_path or work_path or job_subs_dir):
if job_subs_dir:
subs_dir = job_subs_dir
else:
subs_base = work_path if work_path else base_path
subs_dir = os.path.join(subs_base, "yt_subs")
result = _yt_download_subs_only(
URL, video_id, subs_dir, logger, subs_langs=subs_langs, video_duration_sec=vd_hint
)
if result is not None:
subs_path_result, subs_lang = result
subs_source = "uploader"
else:
logger.warning("Uploader subs existed but subs download failed; trying auto-captions next")
if subs_path_result is None and use_auto_script and has_auto_captions and (base_path or work_path or job_subs_dir):
if job_subs_dir:
subs_dir = job_subs_dir
else:
subs_base = work_path if work_path else base_path
subs_dir = os.path.join(subs_base, "yt_subs")
result = _yt_download_auto_subs_only(
URL,
video_id,
subs_dir,
logger,
subs_langs=subs_langs,
prefer_lang=prefer_lang,
info=info,
video_duration_sec=vd_hint,
)
if result is not None:
subs_path_result, subs_lang = result
subs_source = "auto"
logger.info("YOUTUBE_AUTO_CAPTIONS_USED: True (Whisper skipped)")
if subs_path_result is not None and subs_source == "uploader" and not yt_download_if_subs_y:
return (None, title or video_id, video_id, video_len, channel_id, channel_url, subs_path_result, subs_source, subs_lang, channel_name, _format_upload_date(info))
if subs_path_result is not None and subs_source == "auto":
return (None, title or video_id, video_id, video_len, channel_id, channel_url, subs_path_result, subs_source, subs_lang, channel_name, _format_upload_date(info))
# Download audio (when no subs, or subs + YT_DOWNLOAD_IF_SUBS_Y=True)
ydl.download([URL])
# Find the downloaded file
downloaded_file = None
filename = None
# Method 1: Use progress hook filename
if downloaded_filename[0]:
downloaded_file = downloaded_filename[0]
filename = os.path.basename(downloaded_file)
# Postprocessor may change extension to m4a
if not os.path.exists(downloaded_file):
# Try with m4a extension
base_name = os.path.splitext(downloaded_file)[0]
m4a_file = base_name + '.m4a'
if os.path.exists(m4a_file):
downloaded_file = m4a_file