$npx -y skills add LjyYano/skill-pack --skill video-to-noteUse when the user provides a YouTube or Bilibili URL and wants it summarized into an Obsidian note. Prefers auto-generated subtitles over ASR. Uses Obsidian CLI for note creation.
| 1 | # Video → Obsidian Note |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | Get transcript from video subtitles (preferred) or Qwen ASR fallback (Alibaba Cloud), then write a structured Obsidian note via the `obsidian` CLI. |
| 6 | |
| 7 | **Supported platforms:** YouTube, Bilibili |
| 8 | |
| 9 | **Output directory:** |
| 10 | - YouTube → `AI/YouTube/` |
| 11 | - Bilibili → `AI/Bilibili/` |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## Prerequisites |
| 16 | |
| 17 | - `yt-dlp` — subtitle download & audio download (supports YouTube & Bilibili) |
| 18 | - `ffmpeg` — audio conversion & splitting (ASR fallback only) |
| 19 | - `python3` + `requests` — API calls (ASR fallback only) |
| 20 | - `ALIYUN_API_KEY` — DashScope API Key for Qwen ASR (ASR fallback only) |
| 21 | - `obsidian` CLI — note creation (Obsidian must be running) |
| 22 | - Bilibili cookies (optional) — for member-only or age-restricted Bilibili videos, use `--cookies-from-browser chrome` |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ## Step-by-Step Workflow |
| 27 | |
| 28 | ### 0. Detect platform |
| 29 | |
| 30 | ```python |
| 31 | import re |
| 32 | url = "URL" |
| 33 | if re.match(r'https?://(www\.)?(bilibili\.com|b23\.tv)', url): |
| 34 | platform = "bilibili" |
| 35 | output_dir = "AI/Bilibili" |
| 36 | elif re.match(r'https?://(www\.)?(youtube\.com|youtu\.be)', url): |
| 37 | platform = "youtube" |
| 38 | output_dir = "AI/YouTube" |
| 39 | else: |
| 40 | print("Unsupported platform") |
| 41 | exit(1) |
| 42 | ``` |
| 43 | |
| 44 | > Use `platform` and `output_dir` throughout the workflow. |
| 45 | |
| 46 | ### 1. Get video metadata + detect subtitles |
| 47 | |
| 48 | ```bash |
| 49 | yt-dlp --dump-json --skip-download "URL" 2>&1 | python3 -c " |
| 50 | import json, sys |
| 51 | for line in sys.stdin.read().strip().split('\n'): |
| 52 | try: |
| 53 | d = json.loads(line) |
| 54 | print('TITLE:', d.get('title','')) |
| 55 | # Bilibili uses 'uploader', YouTube uses 'channel' |
| 56 | print('CHANNEL:', d.get('channel','') or d.get('uploader','')) |
| 57 | print('UPLOAD_DATE:', d.get('upload_date','')) |
| 58 | print('DURATION:', d.get('duration_string','')) |
| 59 | print('VIEW_COUNT:', d.get('view_count','')) |
| 60 | print('DESCRIPTION:', d.get('description','')[:500]) |
| 61 | # Detect auto-generated subtitles |
| 62 | auto_subs = d.get('automatic_captions', {}) |
| 63 | manual_subs = d.get('subtitles', {}) |
| 64 | if auto_subs: |
| 65 | langs = list(auto_subs.keys()) |
| 66 | print('AUTO_SUBS_AVAILABLE:', ','.join(langs)) |
| 67 | if manual_subs: |
| 68 | langs = list(manual_subs.keys()) |
| 69 | print('MANUAL_SUBS_AVAILABLE:', ','.join(langs)) |
| 70 | if not auto_subs and not manual_subs: |
| 71 | print('NO_SUBS_AVAILABLE') |
| 72 | break |
| 73 | except: continue |
| 74 | " |
| 75 | ``` |
| 76 | |
| 77 | > **Bilibili note:** If download fails (member-only / age-restricted), retry with `--cookies-from-browser chrome`. |
| 78 | |
| 79 | **Subtitle decision logic:** |
| 80 | |
| 81 | | Condition | Action | |
| 82 | |-----------|--------| |
| 83 | | `AUTO_SUBS_AVAILABLE` contains `en` or `zh-Hans` or `zh` | Download subtitle → go to Step 2A | |
| 84 | | `MANUAL_SUBS_AVAILABLE` contains `en` or `zh-Hans` or `zh` | Download subtitle → go to Step 2A | |
| 85 | | Any auto/manual subs available | Download the first available language → go to Step 2A | |
| 86 | | `NO_SUBS_AVAILABLE` | Fall back to ASR → go to Step 2B | |
| 87 | |
| 88 | > **Priority:** Prefer `zh-Hans` > `zh` > `en` > first available language. |
| 89 | |
| 90 | ### 2A. Download subtitle (preferred path) |
| 91 | |
| 92 | ```bash |
| 93 | # Use best subtitle format (srv3/vtt/srt), yt-dlp auto-selects best |
| 94 | yt-dlp --write-auto-sub --sub-lang LANG --sub-format srv3/vtt/srt \ |
| 95 | --skip-download -o "./.yt_sub_%(id)s" "URL" 2>&1 | tail -3 |
| 96 | ``` |
| 97 | |
| 98 | Then parse the subtitle file to extract clean text with **logical paragraph segmentation** (based on timestamp gaps > 3s): |
| 99 | |
| 100 | ```python |
| 101 | import re |
| 102 | |
| 103 | def _parse_timestamp(ts_str): |
| 104 | """Parse SRT/VTT timestamp to seconds.""" |
| 105 | # Handle both SRT (00:01:23,456) and VTT (00:01:23.456) formats |
| 106 | ts_str = ts_str.strip().replace(',', '.') |
| 107 | parts = ts_str.split(':') |
| 108 | if len(parts) == 3: |
| 109 | h, m, s = parts |
| 110 | return int(h) * 3600 + int(m) * 60 + float(s) |
| 111 | elif len(parts) == 2: |
| 112 | m, s = parts |
| 113 | return int(m) * 60 + float(s) |
| 114 | return 0.0 |
| 115 | |
| 116 | def _segment_by_gaps(cues, gap_threshold=3.0): |
| 117 | """Group cues into paragraphs based on timestamp gaps. |
| 118 | |
| 119 | When the gap between consecutive cues exceeds `gap_threshold` seconds, |
| 120 | a new paragraph is started. This creates natural logical breaks. |
| 121 | """ |
| 122 | if not cues: |
| 123 | return "" |
| 124 | paragraphs = [] |
| 125 | current_lines = [cues[0][1]] |
| 126 | for i in range(1, len(cues)): |
| 127 | gap = cues[i][0] - cues[i-1][0] |
| 128 | if gap > gap_threshold: |
| 129 | paragraphs.append(" ".join(current_lines)) |
| 130 | current_lines = [cues[i][1]] |
| 131 | else: |
| 132 | current_lines.append(cues[i][1]) |
| 133 | if current_lines: |
| 134 | paragraphs.append(" ".join(current_lines)) |
| 135 | return "\n\n".join(paragraphs) |
| 136 | |
| 137 | def parse_srt(srt_path): |
| 138 | """Parse SRT file and return paragraph-segmented text.""" |
| 139 | with open(srt_path, 'r', encoding='utf-8') as f: |
| 140 | content = f.read() |
| 141 | blocks = re.split(r'\n\n+', content.strip()) |
| 142 | cues = [] # list of (end_time, text) |