File size: 41,935 Bytes
132f09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff37c87
4452746
 
 
ff37c87
 
 
4452746
656ce65
4452746
 
656ce65
4452746
656ce65
 
4452746
 
 
656ce65
 
 
4452746
 
 
 
656ce65
 
 
4452746
 
656ce65
4452746
 
 
 
 
 
 
 
 
 
 
 
ff37c87
132f09c
 
 
 
 
 
 
 
 
 
 
 
4452746
 
132f09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4452746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6426f2
 
 
 
 
 
 
 
4452746
 
 
 
 
 
 
 
d6426f2
 
4452746
 
 
132f09c
 
 
 
 
 
 
3dc5240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7d5742
3dc5240
 
f7d5742
3dc5240
 
 
 
437ce9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dc5240
437ce9e
 
 
f7d5742
437ce9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7d5742
 
 
 
437ce9e
 
8c4d4e3
f7d5742
 
8c4d4e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437ce9e
8c4d4e3
3dc5240
 
 
 
 
 
 
 
 
 
 
 
 
 
8c4d4e3
3dc5240
 
437ce9e
8c4d4e3
 
 
3dc5240
8c4d4e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7d5742
 
8c4d4e3
3dc5240
 
 
f7d5742
3dc5240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132f09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457f453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132f09c
 
 
 
 
 
 
113afb7
 
 
 
132f09c
 
 
 
 
 
 
 
 
 
 
457f453
 
 
 
 
 
 
 
132f09c
 
457f453
132f09c
457f453
 
 
132f09c
 
 
 
 
457f453
132f09c
 
 
 
113afb7
 
 
 
 
 
 
 
 
 
 
132f09c
113afb7
 
ee2a4a8
437ce9e
 
 
 
 
 
3dc5240
437ce9e
3dc5240
ff37c87
437ce9e
3dc5240
 
 
 
 
 
 
 
20eeabf
3dc5240
 
20eeabf
3dc5240
f7d5742
 
 
 
 
3dc5240
f7d5742
 
3dc5240
f7d5742
3dc5240
 
b10f144
3dc5240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20eeabf
3dc5240
b10f144
3dc5240
b10f144
 
3dc5240
 
 
20eeabf
b10f144
 
 
 
 
 
 
3dc5240
 
b10f144
 
3dc5240
 
 
b10f144
3dc5240
 
 
b490bff
b10f144
3dc5240
b10f144
132f09c
ee2a4a8
132f09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457f453
132f09c
 
 
 
 
 
 
457f453
 
 
 
 
 
132f09c
 
 
 
 
 
 
457f453
 
 
 
 
 
 
 
 
 
 
 
132f09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457f453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132f09c
 
 
 
 
 
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
import os
import uuid
import subprocess
import time
import threading
import PIL.Image

# Monkeypatch PIL.Image.ANTIALIAS for compatibility with moviepy and newer Pillow versions
if not hasattr(PIL.Image, 'ANTIALIAS'):
    PIL.Image.ANTIALIAS = PIL.Image.Resampling.LANCZOS

from flask import Flask, request, jsonify, send_from_directory, render_template

from utils.audio_engine import (
    generate_speech, create_mixed_audio, generate_preview,
    generate_ai_video,
    VOICES, VOICE_STYLES, MOOD_LABELS, AGE_PRESETS
)
from utils.video_effects import concatenate_clips, apply_copyright_filters, slice_video

app = Flask(__name__, static_folder='static', static_url_path='')

# Configuration
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
OUTPUT_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'outputs')

os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)

# ── Write Cookies File from Environment Variable if present ──
# IMPORTANT: Store in app root, NOT uploads/, so the cleanup thread never deletes it
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
COOKIES_FILE_PATH = os.path.join(APP_ROOT, 'yt_cookies.txt')
yt_cookies_content = os.environ.get('YT_COOKIES')
if yt_cookies_content:
    try:
        import json as _json
        content_to_write = yt_cookies_content.strip()
        # Auto-convert JSON array format to Netscape format that yt-dlp reads
        if content_to_write.startswith('[') or content_to_write.startswith('{'):
            try:
                cookies_list = _json.loads(content_to_write)
                if isinstance(cookies_list, list):
                    netscape_lines = [
                        '# Netscape HTTP Cookie File',
                        '# http://curl.haxx.se/rfc/cookie_spec.html',
                        '# This is a generated file!  Do not edit.\n'
                    ]
                    for c in cookies_list:
                        if isinstance(c, dict):
                            domain  = c.get('domain', '')
                            flag    = 'TRUE' if domain.startswith('.') or not c.get('hostOnly', True) else 'FALSE'
                            path    = c.get('path', '/')
                            secure  = 'TRUE' if c.get('secure', False) else 'FALSE'
                            try:
                                expiry = str(int(float(c.get('expirationDate', 0))))
                            except (ValueError, TypeError):
                                expiry = '0'
                            name  = c.get('name', '')
                            value = c.get('value', '')
                            netscape_lines.append(f'{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{name}\t{value}')
                    content_to_write = '\n'.join(netscape_lines)
                    print(f'[startup] Converted {len(cookies_list)} JSON cookies β†’ Netscape format.')
            except Exception as _je:
                print(f'[startup] JSON parse failed, treating as raw text: {_je}')
        with open(COOKIES_FILE_PATH, 'w', encoding='utf-8') as _f:
            _f.write(content_to_write)
        print(f'[startup] cookies written β†’ {COOKIES_FILE_PATH} ({os.path.getsize(COOKIES_FILE_PATH)} bytes)')
    except Exception as _e:
        print(f'[startup] Failed to write cookies: {_e}')
else:
    print('[startup] No YT_COOKIES env var found β€” downloads will run without cookies.')

# ── Background Cleanup Thread (30-minute expiration) ──
def start_cleanup_thread():
    def cleanup_loop():
        # wait a bit for startup to complete before the first sweep
        time.sleep(10)
        while True:
            try:
                now = time.time()
                for folder in [UPLOAD_FOLDER, OUTPUT_FOLDER]:
                    if not os.path.exists(folder):
                        continue
                    for f in os.listdir(folder):
                        # Never delete the cookies file
                        if f.startswith('.') or f == '.gitkeep' or f == 'yt_cookies.txt' or f == 'cookies.txt':
                            continue
                        fp = os.path.join(folder, f)
                        if os.path.isfile(fp):
                            mtime = os.path.getmtime(fp)
                            # 1800 seconds = 30 minutes
                            if now - mtime > 1800:
                                try:
                                    os.remove(fp)
                                    print(f"[background-cleanup] Removed expired file: {f}")
                                except Exception as err:
                                    print(f"[background-cleanup] Failed to delete {f}: {err}")
            except Exception as e:
                print(f"[background-cleanup] Error: {e}")
            time.sleep(300) # Run every 5 minutes

    t = threading.Thread(target=cleanup_loop, daemon=True)
    t.start()

start_cleanup_thread()


# ── Clean up any leftover temp/partial files on startup ──
for _f in os.listdir(OUTPUT_FOLDER):
    if '.temp' in _f or 'TEMP_MPY' in _f or _f.endswith('.part'):
        try:
            os.remove(os.path.join(OUTPUT_FOLDER, _f))
            print(f'[cleanup] Removed temp file: {_f}')
        except Exception:
            pass

@app.route('/api/debug')
def debug_info():
    """Debug endpoint β€” check cookies, yt-dlp version, env."""
    has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0
    cookie_lines = 0
    cookie_preview = ''
    if has_cookies:
        try:
            with open(COOKIES_FILE_PATH, 'r') as _f:
                lines = _f.readlines()
                cookie_lines = len([l for l in lines if l.strip() and not l.startswith('#')])
                cookie_preview = lines[3][:80] if len(lines) > 3 else '(empty)'
        except Exception as e:
            cookie_preview = str(e)

    import subprocess as _sp
    ytdlp_version = 'unknown'
    try:
        r = _sp.run(['yt-dlp', '--version'], capture_output=True, text=True, timeout=5)
        ytdlp_version = r.stdout.strip()
    except Exception:
        pass

    curl_cffi_loaded = False
    curl_cffi_error = ''
    try:
        import curl_cffi
        curl_cffi_loaded = True
    except Exception as ce:
        curl_cffi_error = str(ce)

    return jsonify({
        'cookies_file_path': COOKIES_FILE_PATH,
        'cookies_loaded': has_cookies,
        'cookie_entries': cookie_lines,
        'cookie_preview': cookie_preview,
        'yt_cookies_env_set': bool(os.environ.get('YT_COOKIES')),
        'yt_proxy_set': bool(os.environ.get('YT_PROXY')),
        'ytdlp_version': ytdlp_version,
        'curl_cffi_loaded': curl_cffi_loaded,
        'curl_cffi_error': curl_cffi_error,
    })


# Helper to execute shell commands (e.g. yt-dlp using local venv)
def get_pip_binary(binary_name):
    venv_bin = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'venv', 'bin', binary_name)
    if os.path.exists(venv_bin):
        return venv_bin
    return binary_name


def _extract_video_id(url):
    """Extract YouTube video ID from any YouTube URL format."""
    import re
    patterns = [
        r'(?:v=|youtu\.be/|/embed/|/v/|/shorts/)([A-Za-z0-9_-]{11})',
    ]
    for p in patterns:
        m = re.search(p, url)
        if m:
            return m.group(1)
    return None


def download_via_cobalt(url, output_path):
    """
    Download via cobalt.tools API β€” works from datacenter IPs.
    Tries public cobalt instances with both old and new API formats.
    """
    import requests as _req
    # Only try top 2 instances to save time
    instances = [
        'https://api.cobalt.tools',
        'https://cobalt.api.timelessnesses.me',
    ]
    # Try both v7 (new) and v6 (old) payload formats
    payloads = [
        # cobalt v7+ format
        {
            'url': url,
            'videoQuality': '720',
            'filenameStyle': 'basic',
            'downloadMode': 'auto',
        },
        # cobalt v6 format (older instances)
        {
            'url': url,
            'vQuality': '720',
            'filenamePattern': 'basic',
            'isAudioOnly': False,
            'disableMetadata': True,
        },
    ]
    headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
    for instance in instances:
        for payload in payloads:
            try:
                print(f'[cobalt] Trying {instance} ...')
                resp = _req.post(f'{instance}/', json=payload, headers=headers, timeout=4)
                if resp.status_code not in (200, 201):
                    print(f'[cobalt] {instance} returned HTTP {resp.status_code}')
                    break  # try next instance, not next payload
                data = resp.json()
                status = data.get('status', '')
                dl_url = data.get('url', '')
                if status in ('stream', 'tunnel', 'redirect', 'local', 'picker') and dl_url:
                    print(f'[cobalt] Got URL ({status}), downloading ...')
                    with _req.get(dl_url, stream=True, timeout=300,
                                  headers={'User-Agent': 'Mozilla/5.0'}) as r:
                        r.raise_for_status()
                        with open(output_path, 'wb') as f:
                            for chunk in r.iter_content(65536):
                                f.write(chunk)
                    if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
                        print(f'[cobalt] Success! {os.path.getsize(output_path)//1024} KB')
                        return True
                else:
                    print(f'[cobalt] Unexpected status from {instance}: {status} | {data}')
                    break
            except Exception as e:
                print(f'[cobalt] Error with {instance}: {e}')
                break
    return False


def download_via_pytubefix(url, output_path):
    """
    Download via pytubefix β€” uses different request patterns than yt-dlp
    and often works from datacenter IPs where yt-dlp SSL-fails.
    """
    try:
        import socket
        # Set a tight socket timeout to prevent pytubefix from hanging the request
        socket.setdefaulttimeout(5)
        
        from pytubefix import YouTube
        from pytubefix.cli import on_progress
        
        # Try WEB and ANDROID client modes (WEB triggers Node-based PO generation)
        clients = ['WEB', 'ANDROID']
        
        for client in clients:
            try:
                print(f'[pytubefix] Trying client={client} for {url} ...')
                yt = YouTube(
                    url,
                    client=client,
                    on_progress_callback=on_progress,
                    use_oauth=False,
                    allow_oauth_cache=False,
                    use_po_token=False  # Do NOT use True, it prompts interactively in terminal
                )
                # Try progressive (combined audio+video) MP4 first
                stream = (
                    yt.streams
                      .filter(progressive=True, file_extension='mp4')
                      .order_by('resolution')
                      .last()  # highest resolution
                )
                if not stream:
                    # Fall back to any MP4
                    stream = yt.streams.filter(file_extension='mp4').first()
                if not stream:
                    print(f'[pytubefix] No suitable stream found with client={client}')
                    continue
                print(f'[pytubefix] Downloading {stream.resolution} stream with client={client} ...')
                import tempfile, shutil
                # pytubefix downloads to a directory, so use a temp dir
                tmp_dir = tempfile.mkdtemp()
                try:
                    dl_path = stream.download(output_path=tmp_dir, filename='video.mp4')
                    if dl_path and os.path.exists(dl_path) and os.path.getsize(dl_path) > 0:
                        shutil.move(dl_path, output_path)
                        print(f'[pytubefix] Success with client={client}! {os.path.getsize(output_path)//1024} KB')
                        return True
                finally:
                    shutil.rmtree(tmp_dir, ignore_errors=True)
            except Exception as client_err:
                print(f'[pytubefix] Client {client} failed: {client_err}')
                
    except Exception as e:
        print(f'[pytubefix] General Error: {e}')
    return False


def download_via_invidious(url, output_path):
    """
    Download via public Invidious instances β€” these proxy YouTube traffic
    and are not datacenter-blocked.
    """
    import requests as _req
    vid_id = _extract_video_id(url)
    if not vid_id:
        print('[invidious] Could not extract video ID')
        return False

    # Default fallback list
    instances = [
        'https://inv.nadeko.net',
        'https://invidious.nerdvpn.de',
        'https://invidious.f5.si',
        'https://yt.chocolatemoo53.com',
        'https://inv.thepixora.com',
    ]

    try:
        print('[invidious] Querying public instances API for live hosts ...')
        api_resp = _req.get('https://api.invidious.io/instances.json', timeout=10)
        if api_resp.status_code == 200:
            live_instances = []
            for item in api_resp.json():
                try:
                    info = item[1]
                    monitor = info.get('monitor')
                    if monitor and not monitor.get('down', True) and monitor.get('last_status') == 200:
                        uri = info.get('uri')
                        if uri:
                            live_instances.append(uri.rstrip('/'))
                except Exception:
                    pass
            if live_instances:
                print(f'[invidious] Found {len(live_instances)} live healthy instances.')
                # Prioritize live instances, appending defaults just in case
                instances = live_instances + [inst for inst in instances if inst not in live_instances]
    except Exception as api_err:
        print(f'[invidious] Failed to fetch live instances: {api_err}. Using defaults.')

    # Try at most 2 instances to keep latency under 6 seconds
    for instance in instances[:2]:
        instance = instance.rstrip('/')
        try:
            print(f'[invidious] Querying {instance} for {vid_id} ...')
            api = f'{instance}/api/v1/videos/{vid_id}?fields=formatStreams'
            resp = _req.get(api, timeout=3, headers={'User-Agent': 'Mozilla/5.0'})
            if resp.status_code != 200:
                print(f'[invidious] {instance} returned HTTP {resp.status_code}')
                continue
            streams = resp.json().get('formatStreams', [])
            # Pick best MP4 at or below 720p
            best = None
            for s in streams:
                if s.get('container') == 'mp4':
                    try:
                        q = int(s.get('qualityLabel', '0p').replace('p', '').split()[0])
                    except Exception:
                        q = 0
                    if q <= 720:
                        if best is None or q > int(best.get('qualityLabel', '0p').replace('p', '').split()[0]):
                            best = s
            if best and best.get('url'):
                stream_url = best['url']
                print(f'[invidious] Downloading {best.get("qualityLabel")} from {instance} ...')
                with _req.get(stream_url, stream=True, timeout=300,
                              headers={'User-Agent': 'Mozilla/5.0'}) as r:
                    r.raise_for_status()
                    with open(output_path, 'wb') as f:
                        for chunk in r.iter_content(65536):
                            f.write(chunk)
                if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
                    print(f'[invidious] Success! {os.path.getsize(output_path)//1024} KB')
                    return True
            else:
                print(f'[invidious] No suitable MP4 stream found on {instance}')
        except Exception as e:
            print(f'[invidious] Error with {instance}: {e}')
    return False

@app.route('/')
def index():
    return send_from_directory('static', 'index.html')

@app.route('/api/voices', methods=['GET'])
def get_voices():
    """Returns voices + style + age metadata."""
    return jsonify({
        'voices':       VOICES,
        'voice_styles': VOICE_STYLES,
        'mood_labels':  MOOD_LABELS,
        'age_presets':  AGE_PRESETS,
    })


@app.route('/api/preview-voice', methods=['POST'])
def preview_voice():
    """
    Generate a short voice preview sample and stream it back as audio/mpeg.
    Body: { voice, lang, style, style_degree, rate, pitch }
    """
    import tempfile
    from flask import send_file

    voice        = request.form.get('voice', 'en-US-EmmaMultilingualNeural')
    lang         = request.form.get('lang', 'en-US')
    style        = request.form.get('style', '')
    style_degree = float(request.form.get('style_degree', 1.0))
    rate         = request.form.get('rate', '+0%')
    pitch        = request.form.get('pitch', '+0Hz')

    # Write to a temp file
    tmp = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False, dir=UPLOAD_FOLDER)
    tmp.close()
    out_path = tmp.name

    ok = generate_preview(voice=voice, lang_prefix=lang,
                          style=style, style_degree=style_degree,
                          rate=rate, pitch=pitch,
                          output_path=out_path)
    if not ok or not os.path.exists(out_path):
        return jsonify({'error': 'Preview generation failed'}), 500

    return send_file(out_path, mimetype='audio/mpeg',
                     as_attachment=False,
                     download_name='preview.mp3')


@app.route('/api/clear-library', methods=['POST'])
def clear_library():
    """Delete all exported files from the outputs folder."""
    deleted, failed = [], []
    for f in os.listdir(OUTPUT_FOLDER):
        if f.endswith('.mp4') or f.endswith('.mp3'):
            try:
                os.remove(os.path.join(OUTPUT_FOLDER, f))
                deleted.append(f)
            except Exception as ex:
                failed.append({'file': f, 'error': str(ex)})
    return jsonify({'deleted': len(deleted), 'failed': failed})

@app.route('/api/merge', methods=['POST'])
def merge_videos():
    """
    Merge uploaded video clips and overlay generated/uploaded audio.
    Supports style, rate, pitch, style_degree for TTS voices.
    """
    try:
        aspect_ratio  = request.form.get('aspect_ratio', 'vertical')
        audio_source  = request.form.get('audio_source', 'script')
        language      = request.form.get('language', 'ur-PK')
        voice_id      = request.form.get('voice', 'ur-PK-UzmaNeural')
        script_text   = request.form.get('script_text', '')
        trim_audio    = request.form.get('trim_audio', 'true') == 'true'
        # Voice style / mood params
        voice_style        = request.form.get('style', '')
        voice_style_degree = float(request.form.get('style_degree', 1.0))
        voice_rate         = request.form.get('rate', '+0%')
        voice_pitch        = request.form.get('pitch', '+0Hz')
        
        # Handle video uploads
        uploaded_videos = request.files.getlist('videos')
        if not uploaded_videos or len(uploaded_videos) == 0 or uploaded_videos[0].filename == '':
            return jsonify({'success': False, 'error': 'No video files uploaded.'}), 400
            
        video_paths = []
        job_id = str(uuid.uuid4())
        
        # Save video uploads
        for idx, file in enumerate(uploaded_videos):
            filename = f"{job_id}_video_{idx}.mp4"
            filepath = os.path.join(UPLOAD_FOLDER, filename)
            file.save(filepath)
            video_paths.append(filepath)
            
        # 1. Merge videos together
        merged_temp_video = os.path.join(UPLOAD_FOLDER, f"{job_id}_merged_raw.mp4")
        duration = concatenate_clips(video_paths, aspect_ratio=aspect_ratio, output_path=merged_temp_video)
        
        # 2. Process / Generate audio
        final_audio_path = None
        speech_path = None
        bg_music_path = None
        
        if audio_source == 'script' and script_text.strip():
            # Generate speech audio
            speech_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_speech.mp3")
            generate_speech(script_text, voice_id, speech_path,
                            rate=voice_rate, pitch=voice_pitch,
                            style=voice_style, style_degree=voice_style_degree)
            
        elif audio_source == 'upload':
            uploaded_audio = request.files.get('audio_file')
            if uploaded_audio and uploaded_audio.filename != '':
                speech_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_user_audio.mp3")
                uploaded_audio.save(speech_path)
                
        # Handle optional background music upload
        bg_music_file = request.files.get('bg_music_file')
        if bg_music_file and bg_music_file.filename != '':
            bg_music_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_bg_music.mp3")
            bg_music_file.save(bg_music_path)
            
        # Mix audio tracks if we have speech or background music
        if speech_path or bg_music_path:
            mixed_audio = os.path.join(UPLOAD_FOLDER, f"{job_id}_mixed.mp3")
            ok = create_mixed_audio(
                voiceover_path=speech_path,
                bg_music_path=bg_music_path,
                target_duration=duration,
                output_path=mixed_audio
            )
            if ok and os.path.exists(mixed_audio):
                final_audio_path = mixed_audio
            
        # 3. Combine merged video and final audio
        output_filename = f"merged_{job_id[:8]}.mp4"
        final_output_path = os.path.join(OUTPUT_FOLDER, output_filename)
        
        if final_audio_path and os.path.exists(final_audio_path):
            # Combine via FFmpeg for speed and precision
            cmd = [
                "ffmpeg", "-y", "-i", merged_temp_video, "-i", final_audio_path,
                "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac",
                "-shortest" if trim_audio else "", final_output_path
            ]
            # Remove empty arguments from command
            cmd = [c for c in cmd if c != ""]
            subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        else:
            # No audio overlay, rename raw merged video
            os.rename(merged_temp_video, final_output_path)
            
        # Cleanup temp upload files
        for p in video_paths + [merged_temp_video, speech_path, bg_music_path, final_audio_path]:
            if p and os.path.exists(p):
                try:
                    os.remove(p)
                except Exception:
                    pass
                    
        return jsonify({
            'success': True,
            'message': 'Videos merged successfully!',
            'filename': output_filename,
            'duration': f"{duration:.2f}s"
        })
        
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'success': False, 'error': str(e)}), 500

def download_youtube_subtitles(url, lang, job_id):
    """
    Downloads subtitles or auto-generated subtitles for a YouTube video using yt-dlp.
    Respects rate limits by employing cookies, proxy, and user impersonation.
    """
    ytdlp_bin = get_pip_binary('yt-dlp')
    has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0
    yt_proxy = os.environ.get('YT_PROXY', '')
    
    try:
        import curl_cffi
        has_curl_cffi = True
    except ImportError:
        has_curl_cffi = False

    output_template = os.path.join(UPLOAD_FOLDER, f"{job_id}_subs")
    
    cmd = [
        ytdlp_bin, '--skip-download',
        '--write-subs', '--write-auto-subs',
        '--sub-format', 'srt',
        '--sub-langs', lang,
        '--geo-bypass', '--no-check-certificates', '-4',
        '--socket-timeout', '15', '--retries', '1',
        '-o', f"{output_template}.%(ext)s"
    ]
    
    if has_cookies:
        cmd.extend(['--cookies', COOKIES_FILE_PATH])
    if yt_proxy:
        cmd.extend(['--proxy', yt_proxy])
    if has_curl_cffi:
        cmd.extend(['--impersonate', 'chrome'])
        cmd.extend(['--extractor-args', 'youtube:player_client=ios,android,web'])
    else:
        cmd.extend(['--extractor-args', 'youtube:player_client=default,-tv'])
        
    cmd.append(url)
    
    print(f"[subtitles] Requesting subtitles ({lang}) for {url} ...")
    try:
        res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
        print(f"[subtitles] yt-dlp exit status: {res.returncode}")
        
        # Look for matching srt file in UPLOAD_FOLDER
        import glob
        matches = glob.glob(os.path.join(UPLOAD_FOLDER, f"{job_id}_subs.*.srt"))
        if matches:
            print(f"[subtitles] Found downloaded subtitles: {matches[0]}")
            return matches[0]
        else:
            stderr_out = res.stderr.decode('utf-8', errors='ignore')[-300:]
            print(f"[subtitles] Subtitle download returned no srt file. Stderr: {stderr_out}")
    except Exception as e:
        print(f"[subtitles] Exception during subtitle download: {e}")
    return None

@app.route('/api/clip', methods=['POST'])
def clip_youtube_video():
    """
    Downloads a YouTube video, cuts it into clips, and applies anti-copyright filters.
    """
    try:
        url = request.form.get('url')
        video_file = request.files.get('video_file')
        
        if not url and (not video_file or video_file.filename == ''):
            return jsonify({'success': False, 'error': 'YouTube URL or local video file upload is required.'}), 400
            
        mode = request.form.get('mode', 'auto') # 'auto' or 'timestamps'
        interval = int(request.form.get('interval', 8))
        timestamps_str = request.form.get('timestamps', '')
        
        # Filter options
        try:
            b_width = int(request.form.get('border_width', 0))
        except ValueError:
            b_width = 0
            
        job_id = str(uuid.uuid4())
        
        # Subtitle Handling
        caption_lang = request.form.get('caption_lang', 'none')
        subtitle_file_path = None
        if caption_lang != 'none' and url:
            subtitle_file_path = download_youtube_subtitles(url, caption_lang, job_id)
            
        filters = {
            'aspect_ratio': request.form.get('aspect_ratio', 'original'),
            'mirror_mode': request.form.get('mirror_mode', 'none'),
            'zoom': request.form.get('zoom', 'true') == 'true',
            'color_filter': request.form.get('color_filter', 'none'),
            'vignette': request.form.get('vignette', 'false') == 'true',
            'noise_grain': request.form.get('noise_grain', 'false') == 'true',
            'speed': float(request.form.get('speed', 1.04)),
            'pitch_shift': float(request.form.get('pitch_shift', 0.8)),
            'border_color': request.form.get('border_color', 'none'),
            'border_width': b_width,
            'text_overlay': request.form.get('text_overlay', ''),
            'subtitle_file_path': subtitle_file_path
        }
        
        raw_download_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_raw.mp4")
        
        dl_success = False
        
        # 1. Acquire Video File (either via direct upload or YouTube download)
        if video_file and video_file.filename != '':
            print(f'[upload] Saving uploaded video file to {raw_download_path} ...')
            video_file.save(raw_download_path)
            if os.path.exists(raw_download_path) and os.path.getsize(raw_download_path) > 0:
                dl_success = True
                print('[upload] Video file successfully uploaded and verified!')
        else:
            print(f'[download] Starting YouTube download for {url}')

            # ── Method A: cobalt.tools (best β€” no IP restrictions) ──────────────
            dl_success = download_via_cobalt(url, raw_download_path)

        # ── Method B: pytubefix (different request path β€” bypasses SSL block) ─
        if not dl_success:
            print('[download] cobalt failed, trying pytubefix ...')
            dl_success = download_via_pytubefix(url, raw_download_path)

        # ── Method C: Invidious API (proxy fallback) ─────────────────────────
        if not dl_success:
            print('[download] pytubefix failed, trying Invidious ...')
            dl_success = download_via_invidious(url, raw_download_path)

        # ── Method D: yt-dlp with cookies (last resort) ──────────────────────
        if not dl_success:
            print('[download] Invidious failed, trying yt-dlp with cookies ...')
            ytdlp_bin = get_pip_binary('yt-dlp')
            try:
                import curl_cffi
                has_curl_cffi = True
            except ImportError:
                has_curl_cffi = False

            has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0
            yt_proxy    = os.environ.get('YT_PROXY', '')

            attempts = []
            if has_curl_cffi:
                # Impersonate chrome to bypass TLS fingerprint blocking
                quality = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best' if has_cookies else 'bestvideo[height<=480][ext=mp4]+bestaudio/best[height<=480]/best'
                attempts.append({'impersonate': 'chrome', 'player_client': 'ios,android,web',
                                 'quality': quality, 'timeout': 25})
            else:
                # Fallback if curl-cffi is not available
                quality = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best' if has_cookies else 'worst[ext=mp4]/worst'
                attempts.append({'impersonate': None, 'player_client': 'default,-tv',
                                 'quality': quality, 'timeout': 25})

            stderr_text = ''
            attempt_errors = []
            for idx, attempt in enumerate(attempts, 1):
                for path in [raw_download_path, raw_download_path + '.part']:
                    if os.path.exists(path):
                        try: os.remove(path)
                        except Exception: pass

                cmd = [ytdlp_bin, '-f', attempt['quality'], '--merge-output-format', 'mp4',
                       '--geo-bypass', '--no-check-certificates', '-4',
                       '--socket-timeout', '15', '--retries', '1', '--fragment-retries', '2',
                       '-o', raw_download_path]

                if has_cookies: cmd.extend(['--cookies', COOKIES_FILE_PATH])
                if yt_proxy:    cmd.extend(['--proxy', yt_proxy])
                if attempt['impersonate']:    cmd.extend(['--impersonate', attempt['impersonate']])
                if attempt['player_client']:  cmd.extend(['--extractor-args', f"youtube:player_client={attempt['player_client']}"])
                cmd.append(url)

                try:
                    print(f"[yt-dlp] Running attempt {idx} (impersonate={attempt['impersonate']}, client={attempt['player_client']})")
                    res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=attempt['timeout'])
                    err = res.stderr.decode('utf-8', errors='ignore')
                    print(f"[yt-dlp] Attempt {idx} finished with return code {res.returncode}")
                    if res.returncode == 0 and os.path.exists(raw_download_path) and os.path.getsize(raw_download_path) > 0:
                        dl_success = True
                        print(f'[yt-dlp] Succeeded on attempt {idx}')
                        break
                    
                    attempt_errors.append(f"Attempt {idx} (impersonate={attempt['impersonate']}): {err.strip()[-300:]}")
                    stderr_text = err
                    
                    # If we got a bot/sign-in message, cookies might be bad or missing. Don't continue to avoid lockout.
                    if any(k in err for k in ['Sign in', 'bot', 'Private', 'members-only']):
                        print(f"[yt-dlp] Aborting attempts due to bot/auth detection on attempt {idx}")
                        break
                except subprocess.TimeoutExpired:
                    print(f"[yt-dlp] Attempt {idx} timed out.")
                    attempt_errors.append(f"Attempt {idx} timed out.")
                    stderr_text = 'Download timed out.'
                except Exception as e:
                    print(f"[yt-dlp] Attempt {idx} exception: {e}")
                    attempt_errors.append(f"Attempt {idx} exception: {e}")
                    stderr_text = str(e)
                    continue

        if not dl_success:
            details = " | ".join(attempt_errors) if attempt_errors else (stderr_text[-600:].strip() if stderr_text else 'All download methods failed')
            return jsonify({'success': False,
                            'error': f'Download failed β€” tried cobalt, pytubefix, invidious, yt-dlp+cookies. Details: {details}'}), 500


            
        # 2. Slice downloaded video
        temp_clips_dir = os.path.join(UPLOAD_FOLDER, f"{job_id}_slices")
        os.makedirs(temp_clips_dir, exist_ok=True)
        
        custom_ranges = []
        if mode == 'timestamps' and timestamps_str:
            # Parse timestamps "10-20, 30-45"
            parts = timestamps_str.split(',')
            for part in parts:
                subparts = part.strip().split('-')
                if len(subparts) == 2:
                    try:
                        start_t = float(subparts[0].strip())
                        end_t = float(subparts[1].strip())
                        custom_ranges.append([start_t, end_t])
                    except ValueError:
                        pass
                        
        sliced_files = slice_video(
            raw_download_path,
            temp_clips_dir,
            mode=mode,
            intervals=interval,
            custom_ranges=custom_ranges
        )
        
        if not sliced_files:
            return jsonify({'success': False, 'error': 'No clips generated during slicing.'}), 500
            
        # 3. Apply safety filters to each clip and save to outputs
        processed_files = []
        for idx, file_path in enumerate(sliced_files, 1):
            out_filename = f"clip_{job_id[:8]}_{idx}.mp4"
            out_path = os.path.join(OUTPUT_FOLDER, out_filename)
            
            apply_copyright_filters(file_path, out_path, filters)
            if os.path.exists(out_path):
                processed_files.append(out_filename)
                
        # Cleanup raw downloaded file & sliced folder & subtitles
        if os.path.exists(raw_download_path):
            os.remove(raw_download_path)
            
        import shutil
        if os.path.exists(temp_clips_dir):
            shutil.rmtree(temp_clips_dir)
            
        if subtitle_file_path and os.path.exists(subtitle_file_path):
            try:
                os.remove(subtitle_file_path)
            except Exception:
                pass
            
        return jsonify({
            'success': True,
            'message': f'YouTube video processed into {len(processed_files)} clips!',
            'filenames': processed_files
        })
        
    except Exception as e:
        import shutil
        # Cleanup files in case of error
        if 'raw_download_path' in locals() and os.path.exists(raw_download_path):
            try: os.remove(raw_download_path)
            except Exception: pass
        if 'temp_clips_dir' in locals() and os.path.exists(temp_clips_dir):
            try: shutil.rmtree(temp_clips_dir)
            except Exception: pass
        if 'subtitle_file_path' in locals() and subtitle_file_path and os.path.exists(subtitle_file_path):
            try: os.remove(subtitle_file_path)
            except Exception: pass
            
        import traceback
        traceback.print_exc()
        return jsonify({'success': False, 'error': str(e)}), 500

def get_video_duration(path):
    try:
        cmd = [
            "ffprobe", "-v", "error", "-show_entries", "format=duration",
            "-of", "default=noprint_wrappers=1:nokey=1", path
        ]
        res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        if res.returncode == 0:
            duration = float(res.stdout.strip())
            return f"{duration:.1f}s"
    except Exception:
        pass
    return "unknown"

@app.route('/api/outputs', methods=['GET'])
def get_outputs():
    """Lists all real (non-temp) output files, newest first."""
    SKIP_PATTERNS = ('.temp', 'TEMP_MPY', '.part', '.tmp')
    files = []
    for f in os.listdir(OUTPUT_FOLDER):
        # Only include clean .mp4 files β€” skip any temp/partial artifacts
        if not f.endswith('.mp4'):
            continue
        if any(pat in f for pat in SKIP_PATTERNS):
            # Also delete them from disk so they don't pile up
            try:
                os.remove(os.path.join(OUTPUT_FOLDER, f))
            except Exception:
                pass
            continue
        path = os.path.join(OUTPUT_FOLDER, f)
        size_mb = os.path.getsize(path) / (1024 * 1024)
        files.append({
            'filename': f,
            'size': f"{size_mb:.2f} MB",
            'duration': get_video_duration(path)
        })
    # Sort newest first
    files.sort(
        key=lambda x: os.path.getmtime(os.path.join(OUTPUT_FOLDER, x['filename'])),
        reverse=True
    )
    return jsonify(files)

@app.route('/api/generate-video', methods=['POST'])
def generate_video():
    """
    End-to-end AI script-to-video generation route.
    """
    try:
        script_text   = request.form.get('script_text', '')
        theme         = request.form.get('theme', 'auto')
        aspect_ratio  = request.form.get('aspect_ratio', 'vertical')
        voice_id      = request.form.get('voice', 'ur-PK-UzmaNeural')
        voice_rate    = request.form.get('rate', '+0%')
        voice_pitch   = request.form.get('pitch', '+0Hz')
        trim_audio    = request.form.get('trim_audio', 'true') == 'true'
        
        if not script_text.strip():
            return jsonify({'success': False, 'error': 'Script text is required.'}), 400
            
        bg_music_file = request.files.get('bg_music_file')
        
        job_id = str(uuid.uuid4())
        output_filename = f"ai_video_{job_id[:8]}.mp4"
        final_output_path = os.path.join(OUTPUT_FOLDER, output_filename)
        
        res = generate_ai_video(
            script_text=script_text,
            theme=theme,
            aspect_ratio=aspect_ratio,
            voice_id=voice_id,
            rate=voice_rate,
            pitch=voice_pitch,
            bg_music_file=bg_music_file,
            trim_audio=trim_audio,
            output_path=final_output_path
        )
        
        if res.get('success'):
            return jsonify({
                'success': True,
                'message': 'AI Video generated successfully!',
                'filename': output_filename,
                'duration': f"{res.get('duration'):.2f}s",
                'slides': res.get('sentences_count')
            })
        else:
            return jsonify({'success': False, 'error': 'Video generation failed.'}), 500
            
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'success': False, 'error': str(e)}), 500


@app.route('/api/outputs/<filename>', methods=['GET'])
def download_file(filename):
    """Serves file from outputs folder."""
    return send_from_directory(OUTPUT_FOLDER, filename)


@app.route('/api/download-all', methods=['GET'])
def download_all():
    """
    ZIP all exported MP4 clips in the outputs directory and return as a single download.
    """
    import zipfile
    import io
    from flask import send_file
    from datetime import datetime

    try:
        # Create in-memory zip
        zip_buffer = io.BytesIO()
        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
            for f in os.listdir(OUTPUT_FOLDER):
                # skip temp / partial files
                if f.endswith('.mp4') and not any(pat in f for pat in ['.temp', 'TEMP_MPY', '.part', '.tmp']):
                    path = os.path.join(OUTPUT_FOLDER, f)
                    if os.path.exists(path) and os.path.getsize(path) > 0:
                        zip_file.write(path, f)
                        
        zip_buffer.seek(0)
        
        zip_filename = f"exported_clips_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
        return send_file(
            zip_buffer,
            mimetype='application/zip',
            as_attachment=True,
            download_name=zip_filename
        )
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'success': False, 'error': f"Failed to zip files: {str(e)}"}), 500


if __name__ == '__main__':
    import os
    port = int(os.environ.get('PORT', 5005))
    host = os.environ.get('HOST', '127.0.0.1')
    debug = os.environ.get('FLASK_ENV', 'production') == 'development'
    app.run(debug=debug, host=host, port=port)