diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a5f8a92
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,44 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is an Indiekit plugin that adds a YouTube channel endpoint. It displays latest videos and live streaming status from a YouTube channel, with both an admin dashboard and public JSON API endpoints.
+
+## Development
+
+This is an ESM module with no build step. Install dependencies with `npm install`.
+
+No test suite is configured. Testing requires a running Indiekit instance with valid YouTube API credentials.
+
+## Architecture
+
+**Plugin Entry Point** (`index.js`):
+- Exports a `YouTubeEndpoint` class that Indiekit loads as a plugin
+- Registers protected routes (admin dashboard) and public routes (JSON API)
+- Stores configuration in `Indiekit.config.application.youtubeConfig` for controller access
+
+**YouTube API Client** (`lib/youtube-client.js`):
+- Handles all YouTube Data API v3 interactions
+- Implements in-memory caching with configurable TTL
+- Uses uploads playlist method for quota efficiency (2 units) instead of search (100 units)
+- Channel info cached for 24 hours; videos cached per `cacheTtl` option
+
+**Controllers** (`lib/controllers/`):
+- `dashboard.js` - Admin page rendering, cache refresh
+- `videos.js` - `/api/videos` JSON endpoint
+- `channel.js` - `/api/channel` JSON endpoint
+- `live.js` - `/api/live` JSON endpoint with efficient vs full search modes
+
+**Views/Templates**:
+- `views/youtube.njk` - Admin dashboard template (Nunjucks)
+- `includes/@indiekit-endpoint-youtube-widget.njk` - Widget component
+
+## API Quota Considerations
+
+YouTube Data API has a 10,000 units/day default quota:
+- `channels.list`, `playlistItems.list`, `videos.list`: 1 unit each
+- `search.list`: 100 units (used only for full live status check with `?full=true`)
+
+The plugin uses the playlist method by default for quota efficiency.
diff --git a/includes/@indiekit-endpoint-youtube-live.njk b/includes/@indiekit-endpoint-youtube-live.njk
new file mode 100644
index 0000000..91efd31
--- /dev/null
+++ b/includes/@indiekit-endpoint-youtube-live.njk
@@ -0,0 +1,16 @@
+{# Live status partial for embedding in other templates #}
+{% if liveStatus and liveStatus.isLive %}
+
-
+{% endcall %}
diff --git a/index.js b/index.js
index 70ed639..a89c161 100644
--- a/index.js
+++ b/index.js
@@ -15,8 +15,11 @@ const publicRouter = express.Router();
const defaults = {
mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY,
+ // Single channel (backward compatible)
channelId: process.env.YOUTUBE_CHANNEL_ID,
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
+ // Multiple channels support: array of {id, handle, name}
+ channels: null,
cacheTtl: 300_000, // 5 minutes
liveCacheTtl: 60_000, // 1 minute for live status
limits: {
diff --git a/lib/controllers/channel.js b/lib/controllers/channel.js
index 5d29109..73af1c0 100644
--- a/lib/controllers/channel.js
+++ b/lib/controllers/channel.js
@@ -1,11 +1,32 @@
import { YouTubeClient } from "../youtube-client.js";
+/**
+ * Get normalized channels array from config
+ * Supports both single channel (backward compat) and multiple channels
+ */
+function getChannelsFromConfig(youtubeConfig) {
+ const { channelId, channelHandle, channels } = youtubeConfig;
+
+ // If channels array is provided, use it
+ if (channels && Array.isArray(channels) && channels.length > 0) {
+ return channels;
+ }
+
+ // Fallback to single channel config (backward compatible)
+ if (channelId || channelHandle) {
+ return [{ id: channelId, handle: channelHandle, name: "Primary" }];
+ }
+
+ return [];
+}
+
/**
* Channel controller
*/
export const channelController = {
/**
* Get channel info (JSON API)
+ * Returns array of channels if multiple configured
* @type {import("express").RequestHandler}
*/
async api(request, response) {
@@ -16,28 +37,58 @@ export const channelController = {
return response.status(500).json({ error: "Not configured" });
}
- const { apiKey, channelId, channelHandle, cacheTtl } = youtubeConfig;
+ const { apiKey, cacheTtl } = youtubeConfig;
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
- if (!apiKey || (!channelId && !channelHandle)) {
+ if (!apiKey || channelConfigs.length === 0) {
return response.status(500).json({ error: "Invalid configuration" });
}
- const client = new YouTubeClient({
- apiKey,
- channelId,
- channelHandle,
- cacheTtl,
+ // Fetch all channels in parallel
+ const channelPromises = channelConfigs.map(async (channelConfig) => {
+ const client = new YouTubeClient({
+ apiKey,
+ channelId: channelConfig.id,
+ channelHandle: channelConfig.handle,
+ cacheTtl,
+ });
+
+ try {
+ const channel = await client.getChannelInfo();
+ return {
+ ...channel,
+ configName: channelConfig.name,
+ };
+ } catch (error) {
+ console.error(
+ `[YouTube] Failed to fetch channel ${channelConfig.name || channelConfig.handle}:`,
+ error.message
+ );
+ return null;
+ }
});
- const channel = await client.getChannelInfo();
+ const channelsData = await Promise.all(channelPromises);
+ const channels = channelsData.filter(Boolean);
- response.json({
- channel,
- cached: true,
- });
+ // Return single channel for backward compatibility when only one configured
+ if (channelConfigs.length === 1) {
+ response.json({
+ channel: channels[0] || null,
+ cached: true,
+ });
+ } else {
+ response.json({
+ channels,
+ channel: channels[0] || null, // Primary channel for backward compat
+ cached: true,
+ });
+ }
} catch (error) {
console.error("[YouTube] Channel API error:", error);
response.status(500).json({ error: error.message });
}
},
};
+
+export { getChannelsFromConfig };
diff --git a/lib/controllers/dashboard.js b/lib/controllers/dashboard.js
index 54fb53e..ddd0ec0 100644
--- a/lib/controllers/dashboard.js
+++ b/lib/controllers/dashboard.js
@@ -1,5 +1,26 @@
import { YouTubeClient } from "../youtube-client.js";
+/**
+ * Get primary channel from config (for backward compatibility)
+ * Multi-channel mode uses first channel for dashboard
+ */
+function getPrimaryChannel(config) {
+ const { channelId, channelHandle, channels } = config;
+
+ // Multi-channel mode: use first channel
+ if (channels && Array.isArray(channels) && channels.length > 0) {
+ const first = channels[0];
+ return { id: first.id, handle: first.handle };
+ }
+
+ // Single channel mode (backward compatible)
+ if (channelId || channelHandle) {
+ return { id: channelId, handle: channelHandle };
+ }
+
+ return null;
+}
+
/**
* Dashboard controller
*/
@@ -19,7 +40,7 @@ export const dashboardController = {
});
}
- const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
+ const { apiKey, cacheTtl, limits } = youtubeConfig;
if (!apiKey) {
return response.render("youtube", {
@@ -28,7 +49,9 @@ export const dashboardController = {
});
}
- if (!channelId && !channelHandle) {
+ const primaryChannel = getPrimaryChannel(youtubeConfig);
+
+ if (!primaryChannel) {
return response.render("youtube", {
title: response.locals.__("youtube.title"),
error: { message: response.locals.__("youtube.error.noChannel") },
@@ -37,8 +60,8 @@ export const dashboardController = {
const client = new YouTubeClient({
apiKey,
- channelId,
- channelHandle,
+ channelId: primaryChannel.id,
+ channelHandle: primaryChannel.handle,
cacheTtl,
});
@@ -93,10 +116,16 @@ export const dashboardController = {
return response.status(500).json({ error: "Not configured" });
}
+ const primaryChannel = getPrimaryChannel(youtubeConfig);
+
+ if (!primaryChannel) {
+ return response.status(500).json({ error: "No channel configured" });
+ }
+
const client = new YouTubeClient({
apiKey: youtubeConfig.apiKey,
- channelId: youtubeConfig.channelId,
- channelHandle: youtubeConfig.channelHandle,
+ channelId: primaryChannel.id,
+ channelHandle: primaryChannel.handle,
});
// Clear cache and refetch
diff --git a/lib/controllers/live.js b/lib/controllers/live.js
index b72f94a..9fd0bd3 100644
--- a/lib/controllers/live.js
+++ b/lib/controllers/live.js
@@ -1,4 +1,5 @@
import { YouTubeClient } from "../youtube-client.js";
+import { getChannelsFromConfig } from "./channel.js";
/**
* Live status controller
@@ -8,6 +9,7 @@ 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)
+ * Returns live status for all configured channels
* @type {import("express").RequestHandler}
*/
async api(request, response) {
@@ -18,44 +20,87 @@ export const liveController = {
return response.status(500).json({ error: "Not configured" });
}
- const { apiKey, channelId, channelHandle, liveCacheTtl } = youtubeConfig;
+ const { apiKey, liveCacheTtl } = youtubeConfig;
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
- if (!apiKey || (!channelId && !channelHandle)) {
+ if (!apiKey || channelConfigs.length === 0) {
return response.status(500).json({ error: "Invalid configuration" });
}
- const client = new YouTubeClient({
- apiKey,
- channelId,
- channelHandle,
- liveCacheTtl,
+ const useFullSearch = request.query.full === "true";
+
+ // Fetch live status from all channels in parallel
+ const livePromises = channelConfigs.map(async (channelConfig) => {
+ const client = new YouTubeClient({
+ apiKey,
+ channelId: channelConfig.id,
+ channelHandle: channelConfig.handle,
+ liveCacheTtl,
+ });
+
+ try {
+ const liveStatus = useFullSearch
+ ? await client.getLiveStatus()
+ : await client.getLiveStatusEfficient();
+
+ if (liveStatus) {
+ return {
+ channelConfigName: channelConfig.name,
+ 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,
+ },
+ };
+ }
+ return {
+ channelConfigName: channelConfig.name,
+ isLive: false,
+ isUpcoming: false,
+ stream: null,
+ };
+ } catch (error) {
+ console.error(
+ `[YouTube] Failed to fetch live status for ${channelConfig.name || channelConfig.handle}:`,
+ error.message
+ );
+ return {
+ channelConfigName: channelConfig.name,
+ isLive: false,
+ isUpcoming: false,
+ stream: null,
+ };
+ }
});
- // Use full search only if explicitly requested
- const useFullSearch = request.query.full === "true";
- const liveStatus = useFullSearch
- ? await client.getLiveStatus()
- : await client.getLiveStatusEfficient();
+ const liveStatuses = await Promise.all(livePromises);
- if (liveStatus) {
+ // For single channel, return flat response (backward compatible)
+ if (channelConfigs.length === 1) {
+ const status = liveStatuses[0];
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,
- },
+ isLive: status.isLive,
+ isUpcoming: status.isUpcoming,
+ stream: status.stream,
cached: true,
});
} else {
+ // For multiple channels, find any that are live
+ const anyLive = liveStatuses.find((s) => s.isLive);
+ const anyUpcoming = liveStatuses.find((s) => s.isUpcoming && !s.isLive);
+
response.json({
- isLive: false,
- isUpcoming: false,
- stream: null,
+ // Backward compat: primary live status (prefer live over upcoming)
+ isLive: !!anyLive,
+ isUpcoming: !anyLive && !!anyUpcoming,
+ stream: anyLive?.stream || anyUpcoming?.stream || null,
+ // Multi-channel data
+ liveStatuses,
cached: true,
});
}
diff --git a/lib/controllers/videos.js b/lib/controllers/videos.js
index db116b2..afff2f9 100644
--- a/lib/controllers/videos.js
+++ b/lib/controllers/videos.js
@@ -1,4 +1,5 @@
import { YouTubeClient } from "../youtube-client.js";
+import { getChannelsFromConfig } from "./channel.js";
/**
* Videos controller
@@ -6,6 +7,7 @@ import { YouTubeClient } from "../youtube-client.js";
export const videosController = {
/**
* Get latest videos (JSON API)
+ * Returns videos from all configured channels
* @type {import("express").RequestHandler}
*/
async api(request, response) {
@@ -16,31 +18,73 @@ export const videosController = {
return response.status(500).json({ error: "Not configured" });
}
- const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
+ const { apiKey, cacheTtl, limits } = youtubeConfig;
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
- if (!apiKey || (!channelId && !channelHandle)) {
+ if (!apiKey || channelConfigs.length === 0) {
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);
+ // Fetch videos from all channels in parallel
+ const videosPromises = channelConfigs.map(async (channelConfig) => {
+ const client = new YouTubeClient({
+ apiKey,
+ channelId: channelConfig.id,
+ channelHandle: channelConfig.handle,
+ cacheTtl,
+ });
- response.json({
- videos,
- count: videos.length,
- cached: true,
+ try {
+ const videos = await client.getLatestVideos(maxResults);
+ // Add channel info to each video
+ return videos.map((video) => ({
+ ...video,
+ channelConfigName: channelConfig.name,
+ }));
+ } catch (error) {
+ console.error(
+ `[YouTube] Failed to fetch videos for ${channelConfig.name || channelConfig.handle}:`,
+ error.message
+ );
+ return [];
+ }
});
+
+ const videosArrays = await Promise.all(videosPromises);
+
+ // For single channel, return flat array (backward compatible)
+ if (channelConfigs.length === 1) {
+ const videos = videosArrays[0] || [];
+ response.json({
+ videos,
+ count: videos.length,
+ cached: true,
+ });
+ } else {
+ // For multiple channels, return grouped by channel
+ const videosByChannel = {};
+ channelConfigs.forEach((config, index) => {
+ videosByChannel[config.name || config.handle] = videosArrays[index] || [];
+ });
+
+ // Also provide flat array sorted by date
+ const allVideos = videosArrays
+ .flat()
+ .sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
+ .slice(0, maxResults);
+
+ response.json({
+ videos: allVideos, // Backward compat: flat array
+ videosByChannel,
+ count: allVideos.length,
+ cached: true,
+ });
+ }
} catch (error) {
console.error("[YouTube] Videos API error:", error);
response.status(500).json({ error: error.message });
diff --git a/package.json b/package.json
index 7df16cc..f681f22 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-youtube",
- "version": "1.0.0",
+ "version": "1.1.1",
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
"keywords": [
"indiekit",
diff --git a/views/youtube.njk b/views/youtube.njk
index f92dd94..c8b0874 100644
--- a/views/youtube.njk
+++ b/views/youtube.njk
@@ -188,8 +188,8 @@
{{ channel.title }}
- {{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}
- {{ channel.videoCount | localeNumber }} videos
+ {{ channel.subscriberCount }} {{ __("youtube.subscribers") }}
+ {{ channel.videoCount }} videos
{% if isLive %}
@@ -255,7 +255,7 @@
{{ video.title }}
- {{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
+ {{ video.viewCount }} {{ __("youtube.views") }}