{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "54c01a23",
   "metadata": {},
   "source": [
    "# 🇰🇷 Korean subs — build once, run forever\n",
    "\n",
    "**Every session:** `Runtime → Change runtime type → L4 GPU`, then `Runtime → Run all`.\n",
    "\n",
    "- A box will pop up asking for YouTube links — paste them in. Or drop audio/video files into `MyDrive/korean_subs/inbox/` from your Mac.\n",
    "- Finished `.srt` files land in `MyDrive/korean_subs/subs/`. Processed media moves to `done/` (keep it for asbplayer).\n",
    "- The model (~3 GB) downloads to your Drive **once**. After that it just loads.\n",
    "- Already-finished files are skipped, so re-running never redoes work."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ba19836d",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 1 — Setup (Drive + packages; pip cache lives on Drive so reinstalls are quick)\n",
    "from google.colab import drive\n",
    "drive.mount('/content/drive')\n",
    "\n",
    "import os\n",
    "BASE = '/content/drive/MyDrive/korean_subs'\n",
    "for d in ['inbox', 'subs', 'done', 'models', 'pipcache']:\n",
    "    os.makedirs(f'{BASE}/{d}', exist_ok=True)\n",
    "\n",
    "!pip install -q --cache-dir {BASE}/pipcache -U faster-whisper \"yt-dlp[default]\"\n",
    "\n",
    "# YouTube now needs a JavaScript runtime (deno) for yt-dlp\n",
    "import subprocess\n",
    "if not os.path.exists('/root/.deno/bin/deno'):\n",
    "    subprocess.run('curl -fsSL https://deno.land/install.sh | sh -s -- -y', shell=True, capture_output=True)\n",
    "os.environ['PATH'] = '/root/.deno/bin:' + os.environ['PATH']\n",
    "\n",
    "COOKIES = f'{BASE}/cookies.txt'\n",
    "print('🍪 cookies.txt found' if os.path.exists(COOKIES) else '🍪 no cookies.txt in korean_subs/ (YouTube may block Colab)')\n",
    "print('✅ setup done')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "56ead004",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 2 — Settings + ask for links\n",
    "PREFER_HUMAN_SUBS = True   # if the video already has real (non-auto) Korean subs, use those instead of Whisper\n",
    "DOWNLOAD_VIDEO    = False  # True = save the full video (for asbplayer); False = audio only (smaller, faster)\n",
    "\n",
    "MODEL     = 'large-v3'     # most accurate. Alternative to try if a file comes out badly: 'large-v2'\n",
    "BEAM_SIZE = 5              # higher = slightly more accurate, slower\n",
    "\n",
    "# A natural Korean sentence nudges Whisper toward proper spacing and punctuation.\n",
    "INITIAL_PROMPT = '안녕하세요. 오늘은 날씨가 정말 좋네요. 그럼 시작해 볼까요?'\n",
    "\n",
    "print('Paste YouTube links one at a time and press Enter after each.')\n",
    "print('Press Enter on an empty box when you are done (or right away to only process inbox/).')\n",
    "URLS = []\n",
    "while True:\n",
    "    link = input(f'Link {len(URLS) + 1}: ').strip()\n",
    "    if not link:\n",
    "        break\n",
    "    URLS.append(link)\n",
    "print(f'✅ {len(URLS)} link(s) queued')\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2f002a58",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 3 — Download from YouTube (human subs if available, otherwise media → inbox/)\n",
    "import re, glob, yt_dlp\n",
    "\n",
    "def safe(name):\n",
    "    return re.sub(r'[\\\\/:*?\"<>|]+', '_', name).strip()[:120]\n",
    "\n",
    "def already_done(stem):\n",
    "    return bool(glob.glob(f'{BASE}/subs/{glob.escape(stem)}*.srt'))\n",
    "\n",
    "def with_cookies(opts):\n",
    "    if os.path.exists(COOKIES):\n",
    "        opts['cookiefile'] = COOKIES\n",
    "    return opts\n",
    "\n",
    "def fetch(url):\n",
    "    with yt_dlp.YoutubeDL(with_cookies({'quiet': True, 'skip_download': True})) as y:\n",
    "        info = y.extract_info(url, download=False)\n",
    "    stem = f\"{safe(info['title'])} [{info['id']}]\"\n",
    "    if already_done(stem):\n",
    "        print(f'⏭  already have subs: {stem}'); return\n",
    "\n",
    "    human = [k for k in (info.get('subtitles') or {}) if k.startswith('ko')]\n",
    "    if PREFER_HUMAN_SUBS and human:\n",
    "        opts = {'quiet': True, 'skip_download': True,\n",
    "                'writesubtitles': True, 'writeautomaticsub': False,\n",
    "                'subtitleslangs': [human[0]],\n",
    "                'outtmpl': f'{BASE}/subs/{stem}.%(ext)s',\n",
    "                'postprocessors': [{'key': 'FFmpegSubtitlesConvertor', 'format': 'srt'}]}\n",
    "        with yt_dlp.YoutubeDL(with_cookies(opts)) as y: y.download([url])\n",
    "        print(f'📝 human subs saved: {stem}')\n",
    "        if not DOWNLOAD_VIDEO: return   # subs done; no need for media\n",
    "\n",
    "    fmt = 'bv*[height<=720]+ba/b[height<=720]/b' if DOWNLOAD_VIDEO else 'bestaudio/best'\n",
    "    opts = {'quiet': True, 'format': fmt, 'outtmpl': f'{BASE}/inbox/{stem}.%(ext)s'}\n",
    "    if DOWNLOAD_VIDEO: opts['merge_output_format'] = 'mp4'\n",
    "    with yt_dlp.YoutubeDL(with_cookies(opts)) as y: y.download([url])\n",
    "    print(f'⬇️  downloaded: {stem}')\n",
    "\n",
    "for url in URLS:\n",
    "    try:\n",
    "        fetch(url)\n",
    "    except Exception as e:\n",
    "        print(f'❌ {url}\\n   {e}')\n",
    "        print('   Blocked? Put cookies.txt in MyDrive/korean_subs/, or download on your Mac and drop the file into inbox/.')\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7870e5ca",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 4 — Transcribe everything in inbox/ with maximum-accuracy settings\n",
    "import shutil, time, ctranslate2\n",
    "from faster_whisper import WhisperModel\n",
    "\n",
    "MEDIA = ('.mp3', '.m4a', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.flac', '.aac', '.ogg', '.mov')\n",
    "\n",
    "def ts(t):\n",
    "    total = int(round(t * 1000))\n",
    "    h, r = divmod(total, 3_600_000); m, r = divmod(r, 60_000); s, ms = divmod(r, 1000)\n",
    "    return f'{h:02}:{m:02}:{s:02},{ms:03}'\n",
    "\n",
    "def split_segment(seg, max_dur=6.0, max_gap=0.8):\n",
    "    # Break long Whisper segments into short subtitle lines using word timings\n",
    "    words = seg.words or []\n",
    "    if not words:\n",
    "        yield seg.start, seg.end, seg.text.strip(); return\n",
    "    cur = []\n",
    "    for w in words:\n",
    "        if cur:\n",
    "            gap = w.start - cur[-1].end\n",
    "            dur = w.end - cur[0].start\n",
    "            sentence_end = cur[-1].word.strip()[-1:] in '.?!' and cur[-1].end - cur[0].start >= 1.5\n",
    "            if gap > max_gap or dur > max_dur or sentence_end:\n",
    "                yield cur[0].start, cur[-1].end, ''.join(x.word for x in cur).strip()\n",
    "                cur = []\n",
    "        cur.append(w)\n",
    "    if cur:\n",
    "        yield cur[0].start, cur[-1].end, ''.join(x.word for x in cur).strip()\n",
    "\n",
    "files = sorted(f for f in os.listdir(f'{BASE}/inbox') if f.lower().endswith(MEDIA))\n",
    "todo = [f for f in files if not already_done(os.path.splitext(f)[0])]\n",
    "print(f'{len(todo)} file(s) to transcribe')\n",
    "\n",
    "if todo:\n",
    "    gpu = ctranslate2.get_cuda_device_count() > 0\n",
    "    if not gpu:\n",
    "        print('⚠️  No GPU! Runtime → Change runtime type → T4 GPU. (CPU works but is extremely slow.)')\n",
    "    print('Loading model (first run downloads ~3 GB to Drive)…')\n",
    "    model = WhisperModel(MODEL, device='cuda' if gpu else 'cpu',\n",
    "                         compute_type='float16' if gpu else 'int8',\n",
    "                         download_root=f'{BASE}/models')\n",
    "\n",
    "    for f in todo:\n",
    "        src, stem = f'{BASE}/inbox/{f}', os.path.splitext(f)[0]\n",
    "        print(f'\\n🎧 {f}')\n",
    "        t0 = time.time()\n",
    "        segments, info = model.transcribe(\n",
    "            src,\n",
    "            language='ko',\n",
    "            beam_size=BEAM_SIZE,\n",
    "            best_of=5,\n",
    "            temperature=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0],  # retries hard spots instead of guessing\n",
    "            condition_on_previous_text=False,            # prevents runaway repetition loops\n",
    "            initial_prompt=INITIAL_PROMPT,\n",
    "            vad_filter=True,                             # skips silence/music → fewer hallucinated lines\n",
    "            vad_parameters={'min_silence_duration_ms': 500},\n",
    "            word_timestamps=True,                        # tighter subtitle timing\n",
    "        )\n",
    "        tmp = f'/content/{stem}.srt'\n",
    "        with open(tmp, 'w', encoding='utf-8') as out:\n",
    "            n = 0\n",
    "            for seg in segments:\n",
    "                for start, end, text in split_segment(seg):\n",
    "                    if not text: continue\n",
    "                    n += 1\n",
    "                    out.write(f'{n}\\n{ts(start)} --> {ts(end)}\\n{text}\\n\\n')\n",
    "                print(f'\\r   {seg.end / info.duration:5.1%}', end='')\n",
    "        shutil.move(tmp, f'{BASE}/subs/{stem}.srt')\n",
    "        shutil.move(src, f'{BASE}/done/{f}')\n",
    "        print(f'\\r   ✅ {n} lines in {(time.time() - t0) / 60:.1f} min → subs/{stem}.srt')\n",
    "\n",
    "print('\\n🎉 all done')\n"
   ]
  }
 ],
 "metadata": {
  "accelerator": "GPU",
  "colab": {
   "gpuType": "T4",
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
