Initial commit: YouTube channel endpoint for Indiekit

Features:
- Display latest videos from any YouTube channel
- Live streaming status with animated badge
- Upcoming stream detection
- Admin dashboard with video grid
- Public JSON API for Eleventy integration
- Quota-efficient API usage (playlist method)
- Smart caching (5min videos, 1min live status)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-01-23 23:02:13 +01:00
commit 2b8de8027b
13 changed files with 6979 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
.DS_Store
*.log

191
README.md Normal file
View File

@@ -0,0 +1,191 @@
# @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 on your IndieWeb site.
## Installation
Install from npm:
```bash
npm install @rmdes/indiekit-endpoint-youtube
```
## Features
- **Admin Dashboard** - Overview of channel 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
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,
},
}),
],
};
```
## 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.
### 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 |
### 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 |
### 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
```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
}
```
### GET /youtube/api/videos
```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
}
```
## Options
| Option | Default | Description |
|--------|---------|-------------|
| `mountPath` | `/youtube` | URL path for the endpoint |
| `apiKey` | - | YouTube Data API key |
| `channelId` | - | Channel ID (UC...) |
| `channelHandle` | - | Channel handle (@...) |
| `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 |
## 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 | 2 units | Checks recent videos (efficient) |
| Full live search | 100 units | Only when explicitly requested |
With default settings (5-min cache), you'll use ~600 units/day for video checks.
## Requirements
- Indiekit >= 1.0.0-beta.25
- YouTube Data API v3 enabled
- Valid API key with YouTube Data API access
## License
MIT

View File

@@ -0,0 +1,135 @@
{#
YouTube Widget - Embeddable component showing latest video and live status
Usage in your templates:
{% include "@indiekit-endpoint-youtube-widget.njk" %}
Requires youtube data to be fetched and passed to the template context
#}
<style>
.youtube-widget {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--color-offset, #f5f5f5);
border-radius: 0.5rem;
}
.youtube-widget__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.youtube-widget__title {
font-weight: 600;
font-size: 0.875rem;
margin: 0;
}
.youtube-widget__live {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.youtube-widget__live--on {
background: #ff0000;
color: white;
}
.youtube-widget__live--off {
background: #e5e5e5;
color: #666;
}
.youtube-widget__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.youtube-widget__video {
display: flex;
gap: 0.75rem;
}
.youtube-widget__thumb {
width: 120px;
height: 68px;
object-fit: cover;
border-radius: 0.25rem;
flex-shrink: 0;
}
.youtube-widget__info {
flex: 1;
min-width: 0;
}
.youtube-widget__video-title {
font-size: 0.875rem;
font-weight: 500;
margin: 0 0 0.25rem 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.youtube-widget__video-title a {
color: inherit;
text-decoration: none;
}
.youtube-widget__video-title a:hover {
text-decoration: underline;
}
.youtube-widget__meta {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
}
</style>
<div class="youtube-widget">
<div class="youtube-widget__header">
<h3 class="youtube-widget__title">YouTube</h3>
{% if youtube.isLive %}
<span class="youtube-widget__live youtube-widget__live--on">
<span class="youtube-widget__live-dot"></span>
Live
</span>
{% else %}
<span class="youtube-widget__live youtube-widget__live--off">
Offline
</span>
{% endif %}
</div>
{% if youtube.liveStream %}
<div class="youtube-widget__video">
<img src="{{ youtube.liveStream.thumbnail }}" alt="" class="youtube-widget__thumb">
<div class="youtube-widget__info">
<h4 class="youtube-widget__video-title">
<a href="{{ youtube.liveStream.url }}" target="_blank" rel="noopener">
{{ youtube.liveStream.title }}
</a>
</h4>
<p class="youtube-widget__meta">🔴 Streaming now</p>
</div>
</div>
{% elif youtube.videos and youtube.videos[0] %}
{% set video = youtube.videos[0] %}
<div class="youtube-widget__video">
<img src="{{ video.thumbnail }}" alt="" class="youtube-widget__thumb">
<div class="youtube-widget__info">
<h4 class="youtube-widget__video-title">
<a href="{{ video.url }}" target="_blank" rel="noopener">
{{ video.title }}
</a>
</h4>
<p class="youtube-widget__meta">
{{ video.viewCount | localeNumber }} views
</p>
</div>
</div>
{% else %}
<p class="youtube-widget__meta">No videos available</p>
{% endif %}
</div>

88
index.js Normal file
View File

@@ -0,0 +1,88 @@
import express from "express";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { dashboardController } from "./lib/controllers/dashboard.js";
import { videosController } from "./lib/controllers/videos.js";
import { channelController } from "./lib/controllers/channel.js";
import { liveController } from "./lib/controllers/live.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const protectedRouter = express.Router();
const publicRouter = express.Router();
const defaults = {
mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY,
channelId: process.env.YOUTUBE_CHANNEL_ID,
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
cacheTtl: 300_000, // 5 minutes
liveCacheTtl: 60_000, // 1 minute for live status
limits: {
videos: 10,
},
};
export default class YouTubeEndpoint {
name = "YouTube channel endpoint";
constructor(options = {}) {
this.options = { ...defaults, ...options };
this.mountPath = this.options.mountPath;
}
get environment() {
return ["YOUTUBE_API_KEY", "YOUTUBE_CHANNEL_ID", "YOUTUBE_CHANNEL_HANDLE"];
}
get localesDirectory() {
return path.join(__dirname, "locales");
}
get navigationItems() {
return {
href: this.options.mountPath,
text: "youtube.title",
};
}
get shortcutItems() {
return {
url: this.options.mountPath,
name: "youtube.videos",
iconName: "syndicate",
};
}
/**
* Protected routes (require authentication)
* Admin dashboard
*/
get routes() {
protectedRouter.get("/", dashboardController.get);
protectedRouter.post("/refresh", dashboardController.refresh);
return protectedRouter;
}
/**
* Public routes (no authentication required)
* JSON API endpoints for Eleventy frontend
*/
get routesPublic() {
publicRouter.get("/api/videos", videosController.api);
publicRouter.get("/api/channel", channelController.api);
publicRouter.get("/api/live", liveController.api);
return publicRouter;
}
init(Indiekit) {
Indiekit.addEndpoint(this);
// Store YouTube config in application for controller access
Indiekit.config.application.youtubeConfig = this.options;
Indiekit.config.application.youtubeEndpoint = this.mountPath;
}
}

View File

@@ -0,0 +1,43 @@
import { YouTubeClient } from "../youtube-client.js";
/**
* Channel controller
*/
export const channelController = {
/**
* Get channel info (JSON API)
* @type {import("express").RequestHandler}
*/
async api(request, response) {
try {
const { youtubeConfig } = request.app.locals.application;
if (!youtubeConfig) {
return response.status(500).json({ error: "Not configured" });
}
const { apiKey, channelId, channelHandle, cacheTtl } = youtubeConfig;
if (!apiKey || (!channelId && !channelHandle)) {
return response.status(500).json({ error: "Invalid configuration" });
}
const client = new YouTubeClient({
apiKey,
channelId,
channelHandle,
cacheTtl,
});
const channel = await client.getChannelInfo();
response.json({
channel,
cached: true,
});
} catch (error) {
console.error("[YouTube] Channel API error:", error);
response.status(500).json({ error: error.message });
}
},
};

View File

@@ -0,0 +1,120 @@
import { YouTubeClient } from "../youtube-client.js";
/**
* Dashboard controller
*/
export const dashboardController = {
/**
* Render dashboard page
* @type {import("express").RequestHandler}
*/
async get(request, response, next) {
try {
const { youtubeConfig, youtubeEndpoint } = request.app.locals.application;
if (!youtubeConfig) {
return response.status(500).render("youtube", {
title: "YouTube",
error: { message: "YouTube endpoint not configured" },
});
}
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
if (!apiKey) {
return response.render("youtube", {
title: response.locals.__("youtube.title"),
error: { message: response.locals.__("youtube.error.noApiKey") },
});
}
if (!channelId && !channelHandle) {
return response.render("youtube", {
title: response.locals.__("youtube.title"),
error: { message: response.locals.__("youtube.error.noChannel") },
});
}
const client = new YouTubeClient({
apiKey,
channelId,
channelHandle,
cacheTtl,
});
let channel = null;
let videos = [];
let liveStatus = null;
try {
[channel, videos, liveStatus] = await Promise.all([
client.getChannelInfo(),
client.getLatestVideos(limits?.videos || 6),
client.getLiveStatusEfficient(),
]);
} catch (apiError) {
console.error("[YouTube] API error:", apiError.message);
return response.render("youtube", {
title: response.locals.__("youtube.title"),
error: { message: response.locals.__("youtube.error.connection") },
});
}
// Determine public frontend URL
const publicUrl = youtubeEndpoint
? youtubeEndpoint.replace(/api$/, "")
: "/youtube";
response.render("youtube", {
title: response.locals.__("youtube.title"),
channel,
videos: videos.slice(0, limits?.videos || 6),
liveStatus,
isLive: liveStatus?.isLive || false,
isUpcoming: liveStatus?.isUpcoming || false,
publicUrl,
mountPath: request.baseUrl,
});
} catch (error) {
console.error("[YouTube] Dashboard error:", error);
next(error);
}
},
/**
* Trigger manual cache refresh
* @type {import("express").RequestHandler}
*/
async refresh(request, response) {
try {
const { youtubeConfig } = request.app.locals.application;
if (!youtubeConfig) {
return response.status(500).json({ error: "Not configured" });
}
const client = new YouTubeClient({
apiKey: youtubeConfig.apiKey,
channelId: youtubeConfig.channelId,
channelHandle: youtubeConfig.channelHandle,
});
// Clear cache and refetch
client.clearCache();
const [channel, videos] = await Promise.all([
client.getChannelInfo(),
client.getLatestVideos(youtubeConfig.limits?.videos || 10),
]);
response.json({
success: true,
channel: channel.title,
videoCount: videos.length,
message: `Refreshed ${videos.length} videos from ${channel.title}`,
});
} catch (error) {
console.error("[YouTube] Refresh error:", error);
response.status(500).json({ error: error.message });
}
},
};

67
lib/controllers/live.js Normal file
View File

@@ -0,0 +1,67 @@
import { YouTubeClient } from "../youtube-client.js";
/**
* Live status controller
*/
export const liveController = {
/**
* Get live status (JSON API)
* Uses efficient method (checking recent videos) by default
* Use ?full=true for full search (costs 100 quota units)
* @type {import("express").RequestHandler}
*/
async api(request, response) {
try {
const { youtubeConfig } = request.app.locals.application;
if (!youtubeConfig) {
return response.status(500).json({ error: "Not configured" });
}
const { apiKey, channelId, channelHandle, liveCacheTtl } = youtubeConfig;
if (!apiKey || (!channelId && !channelHandle)) {
return response.status(500).json({ error: "Invalid configuration" });
}
const client = new YouTubeClient({
apiKey,
channelId,
channelHandle,
liveCacheTtl,
});
// Use full search only if explicitly requested
const useFullSearch = request.query.full === "true";
const liveStatus = useFullSearch
? await client.getLiveStatus()
: await client.getLiveStatusEfficient();
if (liveStatus) {
response.json({
isLive: liveStatus.isLive || false,
isUpcoming: liveStatus.isUpcoming || false,
stream: {
videoId: liveStatus.videoId,
title: liveStatus.title,
thumbnail: liveStatus.thumbnail,
url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
scheduledStart: liveStatus.scheduledStart,
actualStart: liveStatus.actualStart,
},
cached: true,
});
} else {
response.json({
isLive: false,
isUpcoming: false,
stream: null,
cached: true,
});
}
} catch (error) {
console.error("[YouTube] Live API error:", error);
response.status(500).json({ error: error.message });
}
},
};

49
lib/controllers/videos.js Normal file
View File

@@ -0,0 +1,49 @@
import { YouTubeClient } from "../youtube-client.js";
/**
* Videos controller
*/
export const videosController = {
/**
* Get latest videos (JSON API)
* @type {import("express").RequestHandler}
*/
async api(request, response) {
try {
const { youtubeConfig } = request.app.locals.application;
if (!youtubeConfig) {
return response.status(500).json({ error: "Not configured" });
}
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
if (!apiKey || (!channelId && !channelHandle)) {
return response.status(500).json({ error: "Invalid configuration" });
}
const client = new YouTubeClient({
apiKey,
channelId,
channelHandle,
cacheTtl,
});
const maxResults = Math.min(
parseInt(request.query.limit, 10) || limits?.videos || 10,
50
);
const videos = await client.getLatestVideos(maxResults);
response.json({
videos,
count: videos.length,
cached: true,
});
} catch (error) {
console.error("[YouTube] Videos API error:", error);
response.status(500).json({ error: error.message });
}
},
};

330
lib/youtube-client.js Normal file
View File

@@ -0,0 +1,330 @@
/**
* YouTube Data API v3 client
* Optimized for quota efficiency (10,000 units/day default)
*
* Quota costs:
* - channels.list: 1 unit
* - playlistItems.list: 1 unit
* - videos.list: 1 unit
* - search.list: 100 units (avoid!)
*/
const API_BASE = "https://www.googleapis.com/youtube/v3";
// In-memory cache
const cache = new Map();
/**
* Get cached data or null if expired
* @param {string} key - Cache key
* @param {number} ttl - TTL in milliseconds
* @returns {any|null}
*/
function getCache(key, ttl) {
const cached = cache.get(key);
if (!cached) return null;
if (Date.now() - cached.time > ttl) {
cache.delete(key);
return null;
}
return cached.data;
}
/**
* Set cache data
* @param {string} key - Cache key
* @param {any} data - Data to cache
*/
function setCache(key, data) {
cache.set(key, { data, time: Date.now() });
}
export class YouTubeClient {
/**
* @param {object} options
* @param {string} options.apiKey - YouTube Data API key
* @param {string} options.channelId - Channel ID (UC...)
* @param {string} [options.channelHandle] - Channel handle (@...)
* @param {number} [options.cacheTtl] - Cache TTL in ms (default: 5 min)
* @param {number} [options.liveCacheTtl] - Live status cache TTL in ms (default: 1 min)
*/
constructor(options) {
this.apiKey = options.apiKey;
this.channelId = options.channelId;
this.channelHandle = options.channelHandle;
this.cacheTtl = options.cacheTtl || 300_000; // 5 minutes
this.liveCacheTtl = options.liveCacheTtl || 60_000; // 1 minute
}
/**
* Make API request
* @param {string} endpoint - API endpoint
* @param {object} params - Query parameters
* @returns {Promise<object>}
*/
async request(endpoint, params = {}) {
const url = new URL(`${API_BASE}/${endpoint}`);
url.searchParams.set("key", this.apiKey);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
const response = await fetch(url.toString());
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = error.error?.message || response.statusText;
throw new Error(`YouTube API error: ${message}`);
}
return response.json();
}
/**
* Get channel info (cached)
* @returns {Promise<object>} - Channel info including uploads playlist ID
*/
async getChannelInfo() {
const cacheKey = `channel:${this.channelId || this.channelHandle}`;
const cached = getCache(cacheKey, 86_400_000); // 24 hour cache for channel info
if (cached) return cached;
const params = {
part: "snippet,contentDetails,statistics,brandingSettings",
};
// Use channelId if available, otherwise resolve from handle
if (this.channelId) {
params.id = this.channelId;
} else if (this.channelHandle) {
// Remove @ if present
const handle = this.channelHandle.replace(/^@/, "");
params.forHandle = handle;
} else {
throw new Error("Either channelId or channelHandle is required");
}
const data = await this.request("channels", params);
if (!data.items || data.items.length === 0) {
throw new Error("Channel not found");
}
const channel = data.items[0];
const result = {
id: channel.id,
title: channel.snippet.title,
description: channel.snippet.description,
customUrl: channel.snippet.customUrl,
thumbnail: channel.snippet.thumbnails?.medium?.url,
subscriberCount: parseInt(channel.statistics.subscriberCount, 10) || 0,
videoCount: parseInt(channel.statistics.videoCount, 10) || 0,
viewCount: parseInt(channel.statistics.viewCount, 10) || 0,
uploadsPlaylistId: channel.contentDetails?.relatedPlaylists?.uploads,
bannerUrl: channel.brandingSettings?.image?.bannerExternalUrl,
};
setCache(cacheKey, result);
return result;
}
/**
* Get latest videos from channel
* Uses uploads playlist for quota efficiency (1 unit vs 100 for search)
* @param {number} [maxResults=10] - Number of videos to fetch
* @returns {Promise<Array>} - List of videos
*/
async getLatestVideos(maxResults = 10) {
const cacheKey = `videos:${this.channelId || this.channelHandle}:${maxResults}`;
const cached = getCache(cacheKey, this.cacheTtl);
if (cached) return cached;
// Get channel info to get uploads playlist ID
const channel = await this.getChannelInfo();
if (!channel.uploadsPlaylistId) {
throw new Error("Could not find uploads playlist");
}
// Get playlist items (1 quota unit)
const playlistData = await this.request("playlistItems", {
part: "snippet,contentDetails",
playlistId: channel.uploadsPlaylistId,
maxResults: Math.min(maxResults, 50),
});
if (!playlistData.items || playlistData.items.length === 0) {
setCache(cacheKey, []);
return [];
}
// Get video details for duration, view count, live status (1 quota unit)
const videoIds = playlistData.items
.map((item) => item.contentDetails.videoId)
.join(",");
const videosData = await this.request("videos", {
part: "snippet,contentDetails,statistics,liveStreamingDetails",
id: videoIds,
});
const videos = videosData.items.map((video) => this.formatVideo(video));
setCache(cacheKey, videos);
return videos;
}
/**
* Check if channel is currently live
* @returns {Promise<object|null>} - Live stream info or null
*/
async getLiveStatus() {
const cacheKey = `live:${this.channelId || this.channelHandle}`;
const cached = getCache(cacheKey, this.liveCacheTtl);
if (cached !== undefined) return cached;
// Get channel info first to ensure we have the channel ID
const channel = await this.getChannelInfo();
// Search for live broadcasts (costs 100 quota units - use sparingly)
// Only do this check periodically
try {
const data = await this.request("search", {
part: "snippet",
channelId: channel.id,
eventType: "live",
type: "video",
maxResults: 1,
});
if (data.items && data.items.length > 0) {
const liveItem = data.items[0];
const result = {
isLive: true,
videoId: liveItem.id.videoId,
title: liveItem.snippet.title,
thumbnail: liveItem.snippet.thumbnails?.medium?.url,
startedAt: liveItem.snippet.publishedAt,
};
setCache(cacheKey, result);
return result;
}
setCache(cacheKey, null);
return null;
} catch (error) {
console.error("[YouTube] Live status check error:", error.message);
setCache(cacheKey, null);
return null;
}
}
/**
* Get live status efficiently by checking recent videos
* This uses less quota than search.list
* @returns {Promise<object|null>} - Live stream info or null
*/
async getLiveStatusEfficient() {
const cacheKey = `live-eff:${this.channelId || this.channelHandle}`;
const cached = getCache(cacheKey, this.liveCacheTtl);
if (cached !== undefined) return cached;
// Get latest videos and check if any are live
const videos = await this.getLatestVideos(5);
const liveVideo = videos.find((v) => v.isLive || v.isUpcoming);
if (liveVideo) {
const result = {
isLive: liveVideo.isLive,
isUpcoming: liveVideo.isUpcoming,
videoId: liveVideo.id,
title: liveVideo.title,
thumbnail: liveVideo.thumbnail,
scheduledStart: liveVideo.scheduledStart,
actualStart: liveVideo.actualStart,
};
setCache(cacheKey, result);
return result;
}
setCache(cacheKey, null);
return null;
}
/**
* Format video data
* @param {object} video - Raw video data from API
* @returns {object} - Formatted video
*/
formatVideo(video) {
const liveDetails = video.liveStreamingDetails;
const isLive = liveDetails?.actualStartTime && !liveDetails?.actualEndTime;
const isUpcoming = liveDetails?.scheduledStartTime && !liveDetails?.actualStartTime;
return {
id: video.id,
title: video.snippet.title,
description: video.snippet.description,
thumbnail: video.snippet.thumbnails?.medium?.url,
thumbnailHigh: video.snippet.thumbnails?.high?.url,
channelId: video.snippet.channelId,
channelTitle: video.snippet.channelTitle,
publishedAt: video.snippet.publishedAt,
duration: this.parseDuration(video.contentDetails?.duration),
durationFormatted: this.formatDuration(video.contentDetails?.duration),
viewCount: parseInt(video.statistics?.viewCount, 10) || 0,
likeCount: parseInt(video.statistics?.likeCount, 10) || 0,
commentCount: parseInt(video.statistics?.commentCount, 10) || 0,
isLive,
isUpcoming,
scheduledStart: liveDetails?.scheduledStartTime,
actualStart: liveDetails?.actualStartTime,
concurrentViewers: liveDetails?.concurrentViewers
? parseInt(liveDetails.concurrentViewers, 10)
: null,
url: `https://www.youtube.com/watch?v=${video.id}`,
};
}
/**
* Parse ISO 8601 duration to seconds
* @param {string} duration - ISO 8601 duration (PT1H2M3S)
* @returns {number} - Duration in seconds
*/
parseDuration(duration) {
if (!duration) return 0;
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = parseInt(match[1], 10) || 0;
const minutes = parseInt(match[2], 10) || 0;
const seconds = parseInt(match[3], 10) || 0;
return hours * 3600 + minutes * 60 + seconds;
}
/**
* Format duration for display
* @param {string} duration - ISO 8601 duration
* @returns {string} - Formatted duration (1:02:03 or 2:03)
*/
formatDuration(duration) {
const totalSeconds = this.parseDuration(duration);
if (totalSeconds === 0) return "0:00";
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Clear all caches
*/
clearCache() {
cache.clear();
}
}

26
locales/en.json Normal file
View File

@@ -0,0 +1,26 @@
{
"youtube": {
"title": "YouTube",
"videos": "Latest Videos",
"channel": "Channel",
"live": "Live Now",
"upcoming": "Upcoming",
"offline": "Offline",
"subscribers": "subscribers",
"views": "views",
"watchNow": "Watch Now",
"viewChannel": "View Channel",
"viewAll": "View All Videos",
"noVideos": "No videos found",
"refreshed": "Refreshed",
"error": {
"noApiKey": "YouTube API key not configured",
"noChannel": "YouTube channel not specified",
"connection": "Could not connect to YouTube API"
},
"widget": {
"description": "View full channel on YouTube",
"view": "Open YouTube Channel"
}
}
}

5593
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "@rmdes/indiekit-endpoint-youtube",
"version": "1.0.0",
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
"keywords": [
"indiekit",
"indiekit-plugin",
"indieweb",
"youtube",
"videos",
"live",
"streaming"
],
"homepage": "https://github.com/rmdes/indiekit-endpoint-youtube",
"bugs": {
"url": "https://github.com/rmdes/indiekit-endpoint-youtube/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/rmdes/indiekit-endpoint-youtube.git"
},
"author": {
"name": "Ricardo Mendes",
"url": "https://rmendes.net"
},
"license": "MIT",
"engines": {
"node": ">=20"
},
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js"
},
"files": [
"includes",
"lib",
"locales",
"views",
"index.js"
],
"dependencies": {
"@indiekit/error": "^1.0.0-beta.25",
"express": "^5.0.0"
},
"peerDependencies": {
"@indiekit/indiekit": ">=1.0.0-beta.25"
},
"publishConfig": {
"access": "public"
}
}

281
views/youtube.njk Normal file
View File

@@ -0,0 +1,281 @@
{% extends "document.njk" %}
{% block content %}
<style>
.yt-section { margin-bottom: 2rem; }
.yt-section h2 { margin-bottom: 1rem; }
/* Channel header */
.yt-channel {
display: flex;
gap: 1rem;
padding: 1rem;
background: var(--color-offset, #f5f5f5);
border-radius: 0.5rem;
align-items: center;
margin-bottom: 1.5rem;
}
.yt-channel__avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.yt-channel__info { flex: 1; }
.yt-channel__name {
font-weight: 600;
font-size: 1.125rem;
margin: 0 0 0.25rem 0;
}
.yt-channel__stats {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
}
/* Live status badge */
.yt-live-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 2rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.yt-live-badge--live {
background: #ff0000;
color: white;
animation: yt-pulse 2s infinite;
}
.yt-live-badge--upcoming {
background: #065fd4;
color: white;
}
.yt-live-badge--offline {
background: var(--color-offset, #e5e5e5);
color: var(--color-text-secondary, #666);
}
@keyframes yt-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.yt-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
/* Live stream card */
.yt-live-stream {
display: flex;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, #ff000010, #ff000005);
border: 1px solid #ff000030;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.yt-live-stream__thumb {
width: 160px;
height: 90px;
object-fit: cover;
border-radius: 0.25rem;
flex-shrink: 0;
}
.yt-live-stream__info { flex: 1; }
.yt-live-stream__title {
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.yt-live-stream__title a {
color: inherit;
text-decoration: none;
}
.yt-live-stream__title a:hover { text-decoration: underline; }
/* Video grid */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.yt-video {
display: flex;
flex-direction: column;
background: var(--color-offset, #f5f5f5);
border-radius: 0.5rem;
overflow: hidden;
}
.yt-video__thumb-wrapper {
position: relative;
aspect-ratio: 16/9;
}
.yt-video__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-video__duration {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.yt-video__info {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
}
.yt-video__title {
font-weight: 500;
font-size: 0.875rem;
margin: 0 0 0.5rem 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-video__title a {
color: inherit;
text-decoration: none;
}
.yt-video__title a:hover { text-decoration: underline; }
.yt-video__meta {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
margin-top: auto;
}
/* Public link banner */
.yt-public-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--color-offset, #f5f5f5);
border-radius: 0.5rem;
margin-top: 2rem;
}
.yt-public-link p {
margin: 0;
color: var(--color-text-secondary, #666);
}
</style>
{% if error %}
{{ prose({ text: error.message }) }}
{% else %}
{# Channel Header #}
{% if channel %}
<div class="yt-channel">
{% if channel.thumbnail %}
<img src="{{ channel.thumbnail }}" alt="" class="yt-channel__avatar">
{% endif %}
<div class="yt-channel__info">
<h2 class="yt-channel__name">{{ channel.title }}</h2>
<div class="yt-channel__stats">
<span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span>
<span>{{ channel.videoCount | localeNumber }} videos</span>
</div>
</div>
{% if isLive %}
<span class="yt-live-badge yt-live-badge--live">
<span class="yt-live-dot"></span>
{{ __("youtube.live") }}
</span>
{% elif isUpcoming %}
<span class="yt-live-badge yt-live-badge--upcoming">
{{ __("youtube.upcoming") }}
</span>
{% else %}
<span class="yt-live-badge yt-live-badge--offline">
{{ __("youtube.offline") }}
</span>
{% endif %}
</div>
{% endif %}
{# Live Stream (if live) #}
{% if liveStatus and (liveStatus.isLive or liveStatus.isUpcoming) %}
<section class="yt-section">
<h2>{% if liveStatus.isLive %}{{ __("youtube.live") }}{% else %}{{ __("youtube.upcoming") }}{% endif %}</h2>
<div class="yt-live-stream">
{% if liveStatus.thumbnail %}
<img src="{{ liveStatus.thumbnail }}" alt="" class="yt-live-stream__thumb">
{% endif %}
<div class="yt-live-stream__info">
<h3 class="yt-live-stream__title">
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
{{ liveStatus.title }}
</a>
</h3>
{{ button({
href: "https://www.youtube.com/watch?v=" + liveStatus.videoId,
text: __("youtube.watchNow"),
target: "_blank"
}) }}
</div>
</div>
</section>
{% endif %}
{# Latest Videos #}
<section class="yt-section">
<h2>{{ __("youtube.videos") }}</h2>
{% if videos and videos.length > 0 %}
<ul class="yt-video-grid">
{% for video in videos %}
<li class="yt-video">
<div class="yt-video__thumb-wrapper">
<a href="{{ video.url }}" target="_blank" rel="noopener">
<img src="{{ video.thumbnail }}" alt="" class="yt-video__thumb" loading="lazy">
</a>
{% if video.durationFormatted and not video.isLive %}
<span class="yt-video__duration">{{ video.durationFormatted }}</span>
{% elif video.isLive %}
<span class="yt-video__duration" style="background:#ff0000">LIVE</span>
{% endif %}
</div>
<div class="yt-video__info">
<h3 class="yt-video__title">
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
</h3>
<div class="yt-video__meta">
{{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("youtube.noVideos") }) }}
{% endif %}
</section>
{# Link to YouTube channel #}
{% if channel %}
<div class="yt-public-link">
<p>{{ __("youtube.widget.description") }}</p>
{{ button({
href: "https://www.youtube.com/channel/" + channel.id,
text: __("youtube.widget.view"),
target: "_blank"
}) }}
</div>
{% endif %}
{% endif %}
{% endblock %}