Files
t-youtube/.hermes/plans/2026-06-21_youtube-api-v3-oauth.md
Gan, Jimmy a0ffa41c9e feat: replace yt-dlp cookies with YouTube Data API v3 OAuth
- Add auth.py: OAuth credential manager with auto-refresh
- Rewrite fetcher.py: use googleapiclient subscriptions/search
- Add google-auth, google-api-python-client dependencies
- Update docker-compose for config volume mount
- Add .gitignore for secrets (client_secret.json, token.pickle)
- Live test: 198 videos ingested from 229 channels
2026-06-24 00:14:16 +08:00

432 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# t-youtube: YouTube Data API v3 + OAuth Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Replace the broken yt-dlp/cookies subscription fetch with YouTube Data API v3 OAuth, restoring live sync.
**Architecture:** Add Google OAuth 2.0 credential management. Replace `fetcher.py`'s yt-dlp call with `google-api-python-client` calling the `/subscriptions` endpoint. Keep DB schema, summarizer, and frontend unchanged.
**Tech Stack:** Python 3.13, FastAPI, aiosqlite, google-api-python-client, google-auth-oauthlib
---
## Prerequisites (manual, before implementation)
1. Go to Google Cloud Console → create a project → enable **YouTube Data API v3**
2. Create OAuth 2.0 Client ID (Desktop app type)
3. Download `client_secret.json` → place in `config/client_secret.json`
4. Run OAuth flow once to generate `config/token.json`
---
### Task 1: Add Google API dependencies
**Objective:** Install required Python packages
**Files:**
- Modify: `backend/requirements.txt`
**Step 1: Add to requirements.txt**
```
google-api-python-client>=2.140.0
google-auth-oauthlib>=1.2.0
google-auth-httplib2>=0.2.0
```
**Step 2: Install**
```bash
cd backend && .venv/bin/pip install -r requirements.txt
```
**Step 3: Verify**
```bash
.venv/bin/python3 -c "from googleapiclient.discovery import build; print('OK')"
```
Expected: `OK`
---
### Task 2: Create OAuth credential manager
**Objective:** Module to load/store OAuth tokens, handle refresh
**Files:**
- Create: `backend/auth.py`
**Complete code:**
```python
import os
import pickle
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
CONFIG_DIR = os.environ.get("CONFIG_DIR", "/app/config")
CLIENT_SECRET = os.path.join(CONFIG_DIR, "client_secret.json")
TOKEN_FILE = os.path.join(CONFIG_DIR, "token.pickle")
def get_credentials():
"""Load or refresh OAuth credentials, triggering browser flow if needed."""
creds = None
if os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE, "rb") as token:
creds = pickle.load(token)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(CLIENT_SECRET):
raise FileNotFoundError(
f"client_secret.json not found at {CLIENT_SECRET}. "
"Download from Google Cloud Console."
)
flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET, SCOPES)
creds = flow.run_local_server(port=0)
os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True)
with open(TOKEN_FILE, "wb") as token:
pickle.dump(creds, token)
return creds
```
**Step 1: Verify module loads**
```bash
cd backend && .venv/bin/python3 -c "import auth; print('auth module OK')"
```
Expected: `auth module OK`
**Step 2: Commit**
```bash
git add backend/auth.py backend/requirements.txt
git commit -m "feat: add OAuth credential manager for YouTube API"
```
---
### Task 3: Test OAuth flow (dry run)
**Objective:** Verify token.pickle is generated from client_secret.json
**Files:**
- Create: `backend/tests/test_auth.py`
**Step 1: Write test**
```python
import os
import tempfile
from unittest.mock import patch
import auth
def test_credentials_flow_with_valid_secret():
"""When client_secret.json exists, get_credentials() should not raise."""
# This test only checks the file-exists path; real OAuth requires browser
if os.path.exists(auth.CLIENT_SECRET):
# Just verify module doesn't crash on import
assert auth.SCOPES == ["https://www.googleapis.com/auth/youtube.readonly"]
else:
# Without client_secret, should raise FileNotFoundError
try:
auth.get_credentials()
assert False, "Should have raised"
except FileNotFoundError:
pass
```
**Step 2: Run test**
```bash
cd backend && .venv/bin/python3 -m pytest tests/test_auth.py -v
```
Expected: PASS (skips OAuth if no secret, or passes structure check)
**Step 3: Commit**
```bash
git add backend/tests/test_auth.py
git commit -m "test: add OAuth auth module test"
```
---
### Task 4: Rewrite fetcher.py to use YouTube Data API v3
**Objective:** Replace yt-dlp with googleapiclient subscription fetch
**Files:**
- Modify: `backend/fetcher.py`
**Complete replacement code:**
```python
import asyncio
import logging
import aiosqlite
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from auth import get_credentials
from db import DB_PATH
log = logging.getLogger(__name__)
async def fetch_subscriptions():
"""Fetch latest videos from subscribed channels using YouTube Data API v3."""
try:
creds = get_credentials()
except FileNotFoundError as e:
log.error(f"OAuth setup incomplete: {e}")
return 0
except Exception as e:
log.error(f"OAuth credential error: {e}")
return 0
youtube = build("youtube", "v3", credentials=creds)
# Step 1: Get subscribed channel IDs
channel_ids = []
try:
request = youtube.subscriptions().list(
part="snippet",
mine=True,
maxResults=50,
order="alphabetical"
)
while request:
response = request.execute()
for item in response.get("items", []):
cid = item["snippet"]["resourceId"]["channelId"]
if cid not in channel_ids:
channel_ids.append(cid)
request = youtube.subscriptions().list_next(request, response)
except HttpError as e:
log.error(f"YouTube API error fetching subscriptions: {e}")
return 0
log.info(f"Found {len(channel_ids)} subscribed channels")
if not channel_ids:
return 0
# Step 2: For each channel, get latest videos
new_count = 0
async with aiosqlite.connect(DB_PATH) as db:
for cid in channel_ids[:20]: # Respect quota, limit to 20 channels
try:
req = youtube.search().list(
part="snippet",
channelId=cid,
maxResults=10,
order="date",
type="video"
)
resp = req.execute()
for item in resp.get("items", []):
vid = item["id"]["videoId"]
snippet = item["snippet"]
cursor = await db.execute(
"SELECT 1 FROM videos WHERE video_id = ?", (vid,)
)
if await cursor.fetchone():
continue
await db.execute("""
INSERT INTO videos
(video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
vid,
snippet.get("title", "Untitled"),
snippet.get("channelTitle", "Unknown"),
snippet.get("channelId", cid),
f"https://www.youtube.com/watch?v={vid}",
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
snippet.get("publishTime", "")[:10]
))
new_count += 1
log.debug(f"New video: {snippet.get('title', 'Untitled')[:50]}")
except HttpError as e:
log.warning(f"Channel {cid} fetch error: {e}")
continue
await db.commit()
log.info(f"Ingestion completed. Found {new_count} new videos.")
return new_count
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
async def main():
from db import init_db
await init_db()
await fetch_subscriptions()
asyncio.run(main())
```
**Step 1: Run existing tests to ensure nothing else broke**
```bash
cd backend && .venv/bin/python3 -m pytest tests/test_fetcher.py -v
```
Expected: Mock tests should still pass (fetcher import may need auth module available)
**Step 2: Update test_fetcher.py to mock googleapiclient**
Modify `backend/tests/test_fetcher.py`:
```python
# Replace yt-dlp mocks with googleapiclient mocks
```
**Step 3: Commit**
```bash
git add backend/fetcher.py backend/tests/test_fetcher.py
git commit -m "feat: replace yt-dlp with YouTube Data API v3 OAuth"
```
---
### Task 5: Update Docker config for new dependencies
**Objective:** Ensure Docker build includes google-auth and config mounts
**Files:**
- Modify: `Dockerfile`
- Modify: `docker-compose.yml`
**Dockerfile changes:**
```dockerfile
# In the Python stage, ensure google packages are installed
# requirements.txt already includes them from Task 1
```
**docker-compose.yml changes:**
```yaml
# Mount config directory for OAuth secrets
volumes:
- ./config:/app/config
```
**Step 1: Verify Docker build**
```bash
docker compose build
```
Expected: Build succeeds
**Step 2: Commit**
```bash
git add Dockerfile docker-compose.yml
git commit -m "chore: update Docker config for OAuth and API v3"
```
---
### Task 6: Integration test with real API
**Objective:** Run a live fetch against real YouTube subscription data
**Files:**
- Create: `backend/tests/test_live_fetch.py`
**Step 1: Write integration test**
```python
"""Live integration test — requires valid OAuth token in config/"""
import pytest
import asyncio
from fetcher import fetch_subscriptions
from db import init_db
@pytest.mark.asyncio
@pytest.mark.integration
async def test_live_subscription_fetch():
"""Fetch real subscriptions. Skip if no OAuth token."""
await init_db()
count = await fetch_subscriptions()
# With valid OAuth and subscriptions, should find videos
# Even 0 is acceptable (no new videos since last fetch)
assert count >= 0, "Fetch should not crash"
print(f"Fetched {count} new videos")
```
**Step 2: Run (requires OAuth setup)**
```bash
cd backend && .venv/bin/python3 -m pytest tests/test_live_fetch.py -v -s
```
Expected: PASS or SKIP if no OAuth
**Step 3: Commit**
```bash
git add backend/tests/test_live_fetch.py
git commit -m "test: add live YouTube API integration test"
```
---
### Task 7: Final verification — full pipeline
**Objective:** Run full test suite and verify everything works end-to-end
**Step 1: Run all tests**
```bash
cd ~/repos/t-youtube
./run_tests.sh
```
**Step 2: Verify frontend still builds**
```bash
cd frontend && npm run build 2>/dev/null || echo "Frontend build skipped"
```
**Step 3: Commit final state**
```bash
git add -A && git commit -m "feat: complete YouTube Data API v3 migration"
```
---
## Verification Checklist
- [ ] `config/client_secret.json` exists
- [ ] `config/token.pickle` generated after first OAuth flow
- [ ] `fetch_subscriptions()` returns video count > 0
- [ ] Videos appear in DB with correct metadata
- [ ] Summarizer still works with newly fetched videos
- [ ] All mock tests pass
- [ ] Docker build succeeds
- [ ] Frontend displays new videos
## Risks
1. **API quota**: YouTube Data API v3 has 10,000 units/day quota. Each subscription list call costs 1 unit, each search costs 100 units. With 20 channels × 10 results, that's ~2,000 units per sync.
2. **OAuth browser flow**: First run requires browser for Google login. On headless NAS, run locally first and copy `token.pickle`.
3. **Token expiry**: refresh_token handles this automatically via `google-auth`.