Files
indiekit-endpoint-youtube/README.md
svemagie 0191a2a957 docs: document store integration, draft posts, reset behavior
- Add store integration section explaining the postTemplate → createFile flow
- Update API response example to use new "Title - Author" name format
- Add reset route to admin routes table
- Update troubleshooting: reset button, store-only migration path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:01:13 +01:00

509 lines
17 KiB
Markdown

# @rmdes/indiekit-endpoint-youtube
[![npm version](https://img.shields.io/npm/v/@rmdes/indiekit-endpoint-youtube.svg)](https://www.npmjs.com/package/@rmdes/indiekit-endpoint-youtube)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
YouTube channel endpoint for [Indiekit](https://getindiekit.com/).
Display latest videos and live streaming status from any YouTube channel (or multiple channels) on your IndieWeb site.
## Installation
Install from npm:
```bash
npm install @rmdes/indiekit-endpoint-youtube
```
## Features
- **Single or Multi-Channel** - Monitor one channel or aggregate multiple channels
- **Admin Dashboard** - Overview of channel(s) with latest videos in Indiekit's admin UI
- **Live Status** - Shows when channel is live streaming (with animated badge)
- **Upcoming Streams** - Display scheduled upcoming live streams
- **Latest Videos** - Grid of recent uploads with thumbnails, duration, view counts
- **Public JSON API** - For integration with static site generators like Eleventy
- **Quota Efficient** - Uses YouTube API efficiently (playlist method vs search)
- **Smart Caching** - Respects API rate limits while staying current
## Configuration
### Single Channel
Add to your `indiekit.config.js`:
```javascript
import YouTubeEndpoint from "@rmdes/indiekit-endpoint-youtube";
export default {
plugins: [
new YouTubeEndpoint({
mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY,
channelId: process.env.YOUTUBE_CHANNEL_ID,
// OR use channel handle instead:
// channelHandle: "@YourChannel",
cacheTtl: 300_000, // 5 minutes
liveCacheTtl: 60_000, // 1 minute for live status
limits: {
videos: 10,
},
}),
],
};
```
### Multiple Channels
Monitor multiple YouTube channels simultaneously:
```javascript
import YouTubeEndpoint from "@rmdes/indiekit-endpoint-youtube";
export default {
plugins: [
new YouTubeEndpoint({
mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY,
channels: [
{ id: "UC...", name: "Main Channel" },
{ handle: "@SecondChannel", name: "Second Channel" },
{ id: "UC...", name: "Third Channel" },
],
cacheTtl: 300_000,
liveCacheTtl: 60_000,
limits: {
videos: 10,
},
}),
],
};
```
In multi-channel mode:
- Dashboard shows all channels with separate sections
- API endpoints aggregate data from all channels
- Videos are sorted by date across all channels
- Live status shows any channel that is currently live
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `YOUTUBE_API_KEY` | Yes | YouTube Data API v3 key |
| `YOUTUBE_CHANNEL_ID` | Yes* | Channel ID (starts with `UC...`) |
| `YOUTUBE_CHANNEL_HANDLE` | Yes* | Channel handle (e.g., `@YourChannel`) |
*Either `channelId` or `channelHandle` is required for single-channel mode. In multi-channel mode, use the `channels` array instead.
### Getting a YouTube API Key
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Enable the "YouTube Data API v3"
4. Go to Credentials > Create Credentials > API Key
5. (Optional) Restrict the key to YouTube Data API only
### Finding Your Channel ID
- Go to your YouTube channel
- The URL will be `youtube.com/channel/UC...` - the `UC...` part is your channel ID
- Or use a tool like [Comment Picker](https://commentpicker.com/youtube-channel-id.php)
## Routes
### Admin Routes (require authentication)
| Route | Description |
|-------|-------------|
| `GET /youtube/` | Dashboard with channel info, live status, latest videos |
| `POST /youtube/refresh` | Clear cache and refresh data (returns JSON) |
### Public API Routes (JSON)
| Route | Description |
|-------|-------------|
| `GET /youtube/api/videos` | Latest videos (supports `?limit=N`) |
| `GET /youtube/api/channel` | Channel information |
| `GET /youtube/api/live` | Live streaming status (efficient by default) |
| `GET /youtube/api/live?full=true` | Live status using search API (more accurate, costs more quota) |
### Example: Eleventy Integration
```javascript
// _data/youtube.js
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function() {
const baseUrl = process.env.SITE_URL || "https://example.com";
const [channel, videos, live] = await Promise.all([
EleventyFetch(`${baseUrl}/youtube/api/channel`, { duration: "15m", type: "json" }),
EleventyFetch(`${baseUrl}/youtube/api/videos?limit=6`, { duration: "5m", type: "json" }),
EleventyFetch(`${baseUrl}/youtube/api/live`, { duration: "1m", type: "json" }),
]);
return {
channel: channel.channel,
videos: videos.videos,
isLive: live.isLive,
liveStream: live.stream,
};
}
```
## API Response Examples
### GET /youtube/api/live
**Single channel:**
```json
{
"isLive": true,
"isUpcoming": false,
"stream": {
"videoId": "abc123",
"title": "Live Stream Title",
"thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
"url": "https://www.youtube.com/watch?v=abc123"
},
"cached": true
}
```
**Multi-channel:**
```json
{
"isLive": true,
"isUpcoming": false,
"stream": {
"videoId": "abc123",
"title": "Live Stream Title"
},
"liveStatuses": [
{
"channelConfigName": "Main Channel",
"isLive": true,
"stream": { "videoId": "abc123" }
},
{
"channelConfigName": "Second Channel",
"isLive": false,
"stream": null
}
],
"cached": true
}
```
### GET /youtube/api/videos
**Single channel:**
```json
{
"videos": [
{
"id": "abc123",
"title": "Video Title",
"thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
"duration": 3661,
"durationFormatted": "1:01:01",
"viewCount": 12345,
"publishedAt": "2024-01-15T10:00:00Z",
"url": "https://www.youtube.com/watch?v=abc123",
"isLive": false
}
],
"count": 10,
"cached": true
}
```
**Multi-channel:**
```json
{
"videos": [],
"videosByChannel": {
"Main Channel": [],
"Second Channel": []
},
"count": 20,
"cached": true
}
```
### GET /youtube/api/channel
**Single channel:**
```json
{
"channel": {
"id": "UC...",
"title": "Channel Name",
"description": "Channel description",
"thumbnail": "https://...",
"subscriberCount": 12345,
"videoCount": 100,
"viewCount": 999999
},
"cached": true
}
```
**Multi-channel:**
```json
{
"channels": [
{ "id": "UC...", "title": "Channel 1", "configName": "Main Channel" },
{ "id": "UC...", "title": "Channel 2", "configName": "Second Channel" }
],
"channel": {},
"cached": true
}
```
## Options
| Option | Default | Description |
|--------|---------|-------------|
| `mountPath` | `/youtube` | URL path for the endpoint |
| `apiKey` | - | YouTube Data API key |
| `channelId` | - | Channel ID (UC...) - single channel mode |
| `channelHandle` | - | Channel handle (@...) - single channel mode |
| `channels` | `null` | Array of channels for multi-channel mode |
| `cacheTtl` | `300000` | Cache TTL in ms (5 min) |
| `liveCacheTtl` | `60000` | Live status cache TTL in ms (1 min) |
| `limits.videos` | `10` | Number of videos to fetch per channel |
### Channels Array Format
For multi-channel mode, the `channels` option accepts an array of objects:
```javascript
channels: [
{ id: "UC...", name: "Display Name" }, // Using channel ID
{ handle: "@username", name: "Display Name" }, // Using handle
{ id: "UC..." } // Name defaults to channel title
]
```
Either `id` or `handle` is required. The `name` field is optional and used for display purposes.
## YouTube Likes Sync
Sync your YouTube liked videos as "like" posts on your IndieWeb blog. Only **new likes** (added after connecting) produce posts — existing likes are baselined without generating any content.
### How it works
```
First sync after connecting:
YouTube API → fetch all liked video IDs → store in youtubeLikesSeen collection
(no posts created — baseline snapshot only)
Every subsequent sync (hourly background + manual trigger):
YouTube API → fetch liked videos → compare against youtubeLikesSeen
↓ new like found (not in seen set)
Mark as seen → generate markdown via publication.postTemplate()
→ write file to store (e.g. GitHub) via store.createFile()
→ insert post document into MongoDB posts collection
↓ already seen
Skip
```
New like posts are created as **drafts** (`post-status: draft`) so they can be reviewed before publishing. The post content is `Video Title - Channel Name`.
The baseline prevents mass post creation when you connect an account with hundreds of existing likes.
### Store integration
Like posts are written to the configured Indiekit store (e.g. `@indiekit/store-github`) as markdown files, exactly like posts created via Micropub. The sync:
1. Builds JF2 properties (`like-of`, `name`, `content`, `post-status: draft`, etc.)
2. Strips internal `mp-*` and `post-type` keys (matching Micropub's `getPostTemplateProperties`)
3. Calls `publication.postTemplate(templateProperties)` to generate frontmatter + content
4. Calls `publication.store.createFile(path, content, { message })` to commit the file
5. Inserts the post document into MongoDB (Indiekit needs both)
The store commit message follows Indiekit's `storeMessageTemplate` format. If the store write fails, the error is logged but the MongoDB insert still happens (so the sync doesn't retry the same video).
Reset (`POST /youtube/likes/reset`) also deletes files from the store before removing MongoDB documents.
### Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an **OAuth 2.0 Client ID** (Application type: Web application)
3. Add an authorized redirect URI: `https://yourdomain.com/youtube/likes/callback`
4. Make sure **YouTube Data API v3** is enabled for the project
5. Set the environment variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `YOUTUBE_OAUTH_CLIENT_ID` | Yes | OAuth 2.0 client ID |
| `YOUTUBE_OAUTH_CLIENT_SECRET` | Yes | OAuth 2.0 client secret |
6. Add the OAuth config to your Indiekit configuration:
```javascript
"@rmdes/indiekit-endpoint-youtube": {
mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY,
channelId: process.env.YOUTUBE_CHANNEL_ID,
oauth: {
clientId: process.env.YOUTUBE_OAUTH_CLIENT_ID,
clientSecret: process.env.YOUTUBE_OAUTH_CLIENT_SECRET,
},
likes: {
syncInterval: 3_600_000, // 1 hour (default)
maxPages: 3, // 50 likes per page, up to 150 per sync
autoSync: true, // enable background sync
},
},
```
7. Visit `/youtube/likes` in the Indiekit admin panel and click **Connect YouTube Account**
8. Authorize access — your refresh token is stored in MongoDB and persists across restarts
> **Brand Account caveat:** If your YouTube channel runs under a Brand Account, you must select that account (not your personal Google account) during the OAuth consent screen. The `myRating=like` API only returns likes for the authenticated account. Selecting the wrong account results in an "account is closed" error.
### Likes Options
| Option | Default | Description |
|--------|---------|-------------|
| `oauth.clientId` | - | Google OAuth 2.0 client ID |
| `oauth.clientSecret` | - | Google OAuth 2.0 client secret |
| `likes.syncInterval` | `3600000` | Background sync interval in ms (1 hour) |
| `likes.maxPages` | `3` | Max pages per sync (50 likes/page) |
| `likes.autoSync` | `true` | Enable background periodic sync |
### Admin Dashboard (`/youtube/likes`)
The likes page in the Indiekit admin panel provides a full overview:
**Connection section**
- Green "Connected" badge when authorized, with a Disconnect button
- "Not connected" badge when not authorized, with a description and Connect button that initiates the OAuth flow
**Overview section** (only when connected)
- Summary table showing: videos seen (baseline + subsequent), like posts created, baseline status and timestamp, last sync timestamp
- Sync result counts from the most recent run (new / skipped / total)
**Sync section** (only when connected)
- "Sync Now" button to trigger a manual sync. Redirects back to the dashboard with a flash message showing results.
**Recent Likes section** (only when connected)
- List of the 10 most recent like posts with YouTube thumbnail, video title (linked), channel name, and publication date
- "View All" link to the JSON API when more than 10 likes exist
**Flash messages**
- Query-param driven via Indiekit's `notificationBanner`: `?connected=1` (success), `?disconnected=1` (notice), `?synced=N&skipped=N` (success), `?error=message` (error)
### Likes Routes
#### Admin Routes (require authentication)
| Route | Method | Description |
|-------|--------|-------------|
| `/youtube/likes` | GET | Dashboard: connection status, overview stats, sync controls, recent likes |
| `/youtube/likes/connect` | GET | Redirects to Google OAuth consent screen |
| `/youtube/likes/disconnect` | POST | Deletes stored OAuth tokens, redirects to dashboard |
| `/youtube/likes/sync` | POST | Triggers manual sync, redirects to dashboard with results |
| `/youtube/likes/reset` | POST | Deletes all like posts (store + MongoDB), seen IDs, and baseline |
#### Public Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/youtube/likes/callback` | GET | OAuth callback — Google redirects here after authorization |
| `/youtube/api/likes` | GET | JSON API for synced likes (`?limit=N&offset=N`, max 100) |
### MongoDB Collections
| Collection | Purpose |
|------------|---------|
| `youtubeMeta` | OAuth tokens (`key: "oauth_tokens"`), sync status (`key: "likes_sync"`), baseline flag (`key: "likes_baseline"`) |
| `youtubeLikesSeen` | Set of all video IDs ever seen (indexed on `videoId`, unique). Prevents duplicate posts and ensures only new likes after baseline produce posts. |
### Likes API Response
#### GET /youtube/api/likes
```json
{
"likes": [
{
"post-type": "like",
"like-of": "https://www.youtube.com/watch?v=abc123",
"name": "Video Title - Channel Name",
"published": "2024-01-15T10:00:00Z",
"url": "https://yourdomain.com/likes/yt-like-abc123/",
"youtube-video-id": "abc123",
"youtube-channel": "Channel Name",
"youtube-thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg"
}
],
"count": 20,
"total": 142,
"offset": 0
}
```
### Likes Quota Usage
Fetching liked videos uses `videos.list` with `myRating=like`**1 quota unit per page** (50 videos). With default settings (3 pages per sync, hourly), that's ~72 units/day.
### Eleventy Integration for Likes
```javascript
// _data/youtubeLikes.js
import EleventyFetch from "@11ty/eleventy-fetch";
export default async function() {
const baseUrl = process.env.SITE_URL || "https://example.com";
const data = await EleventyFetch(
`${baseUrl}/youtube/api/likes?limit=50`,
{ duration: "15m", type: "json" }
);
return data.likes;
}
```
### Troubleshooting
**"The YouTube account of the authenticated user is closed"**
You authorized the wrong Google account. Your liked videos live on a Brand Account, but OAuth used your personal account. Disconnect (`POST /youtube/likes/disconnect`), reconnect, and pick the correct account.
**First sync created zero posts**
This is expected. The first sync snapshots existing likes as baseline. Posts are only created for likes added after that point.
**Want to reset everything?**
Use the Reset button on the `/youtube/likes` dashboard (or `POST /youtube/likes/reset`). This deletes all like post files from the store, removes all MongoDB documents (posts, seen IDs, baseline, sync status), and starts fresh. The next sync will re-baseline.
**Posts created but files missing from store**
If you upgraded from a version that only wrote to MongoDB, use Reset to clear the old posts and re-sync. New syncs will write both the markdown file and the MongoDB document.
## Quota Efficiency
YouTube Data API has a daily quota (10,000 units by default). This plugin is optimized:
| Operation | Quota Cost | Method |
|-----------|------------|--------|
| Get videos | 2 units | Uses uploads playlist (not search) |
| Get channel | 1 unit | Cached for 24 hours |
| Check live status (efficient) | 2 units | Checks recent videos |
| Check live status (full) | 100 units | Only when explicitly requested |
**Single channel:** With default settings (5-min cache), ~600 units/day.
**Multi-channel:** Quota usage scales linearly. 3 channels = ~1,800 units/day.
## Requirements
- Indiekit >= 1.0.0-beta.25
- YouTube Data API v3 enabled
- Valid API key with YouTube Data API access
- Node.js >= 20
## License
MIT