fix: dashboard controller support for multi-channel mode

The Indiekit backend UI was broken when using channels array
because the dashboard controller only checked for channelId/channelHandle.

Now uses getPrimaryChannel() helper to extract the first channel
from either single-channel or multi-channel configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-01-24 13:59:25 +01:00
parent 2b8de8027b
commit 43090ce8af
11 changed files with 318 additions and 196 deletions

44
CLAUDE.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,16 @@
{# Live status partial for embedding in other templates #}
{% if liveStatus and liveStatus.isLive %}
<div class="youtube-live youtube-live--active">
<span class="youtube-live__badge">Live</span>
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
{{ liveStatus.title }}
</a>
</div>
{% elif liveStatus and liveStatus.isUpcoming %}
<div class="youtube-live youtube-live--upcoming">
<span class="youtube-live__badge">Upcoming</span>
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
{{ liveStatus.title }}
</a>
</div>
{% endif %}

View File

@@ -0,0 +1,13 @@
{# Videos partial for embedding in other templates #}
{% if videos and videos.length > 0 %}
<ul class="youtube-list youtube-list--compact">
{% for video in videos %}
{% if loop.index <= 5 %}
<li class="youtube-list__item">
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
<small class="youtube-meta">{{ video.viewCount }} views</small>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -1,135 +1,12 @@
{# {% call widget({
YouTube Widget - Embeddable component showing latest video and live status title: __("youtube.title")
}) %}
Usage in your templates: <p class="prose">{{ __("youtube.widget.description") }}</p>
{% include "@indiekit-endpoint-youtube-widget.njk" %} <div class="button-grid">
{{ button({
Requires youtube data to be fetched and passed to the template context classes: "button--secondary-on-offset",
#} href: application.youtubeEndpoint or "/youtube",
text: __("youtube.widget.view")
<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> </div>
{% endcall %}
{% 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>

View File

@@ -15,8 +15,11 @@ const publicRouter = express.Router();
const defaults = { const defaults = {
mountPath: "/youtube", mountPath: "/youtube",
apiKey: process.env.YOUTUBE_API_KEY, apiKey: process.env.YOUTUBE_API_KEY,
// Single channel (backward compatible)
channelId: process.env.YOUTUBE_CHANNEL_ID, channelId: process.env.YOUTUBE_CHANNEL_ID,
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE, channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
// Multiple channels support: array of {id, handle, name}
channels: null,
cacheTtl: 300_000, // 5 minutes cacheTtl: 300_000, // 5 minutes
liveCacheTtl: 60_000, // 1 minute for live status liveCacheTtl: 60_000, // 1 minute for live status
limits: { limits: {

View File

@@ -1,11 +1,32 @@
import { YouTubeClient } from "../youtube-client.js"; 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 * Channel controller
*/ */
export const channelController = { export const channelController = {
/** /**
* Get channel info (JSON API) * Get channel info (JSON API)
* Returns array of channels if multiple configured
* @type {import("express").RequestHandler} * @type {import("express").RequestHandler}
*/ */
async api(request, response) { async api(request, response) {
@@ -16,28 +37,58 @@ export const channelController = {
return response.status(500).json({ error: "Not configured" }); 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" }); return response.status(500).json({ error: "Invalid configuration" });
} }
const client = new YouTubeClient({ // Fetch all channels in parallel
apiKey, const channelPromises = channelConfigs.map(async (channelConfig) => {
channelId, const client = new YouTubeClient({
channelHandle, apiKey,
cacheTtl, 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({ // Return single channel for backward compatibility when only one configured
channel, if (channelConfigs.length === 1) {
cached: true, 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) { } catch (error) {
console.error("[YouTube] Channel API error:", error); console.error("[YouTube] Channel API error:", error);
response.status(500).json({ error: error.message }); response.status(500).json({ error: error.message });
} }
}, },
}; };
export { getChannelsFromConfig };

View File

@@ -1,5 +1,26 @@
import { YouTubeClient } from "../youtube-client.js"; 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 * Dashboard controller
*/ */
@@ -19,7 +40,7 @@ export const dashboardController = {
}); });
} }
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig; const { apiKey, cacheTtl, limits } = youtubeConfig;
if (!apiKey) { if (!apiKey) {
return response.render("youtube", { return response.render("youtube", {
@@ -28,7 +49,9 @@ export const dashboardController = {
}); });
} }
if (!channelId && !channelHandle) { const primaryChannel = getPrimaryChannel(youtubeConfig);
if (!primaryChannel) {
return response.render("youtube", { return response.render("youtube", {
title: response.locals.__("youtube.title"), title: response.locals.__("youtube.title"),
error: { message: response.locals.__("youtube.error.noChannel") }, error: { message: response.locals.__("youtube.error.noChannel") },
@@ -37,8 +60,8 @@ export const dashboardController = {
const client = new YouTubeClient({ const client = new YouTubeClient({
apiKey, apiKey,
channelId, channelId: primaryChannel.id,
channelHandle, channelHandle: primaryChannel.handle,
cacheTtl, cacheTtl,
}); });
@@ -93,10 +116,16 @@ export const dashboardController = {
return response.status(500).json({ error: "Not configured" }); 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({ const client = new YouTubeClient({
apiKey: youtubeConfig.apiKey, apiKey: youtubeConfig.apiKey,
channelId: youtubeConfig.channelId, channelId: primaryChannel.id,
channelHandle: youtubeConfig.channelHandle, channelHandle: primaryChannel.handle,
}); });
// Clear cache and refetch // Clear cache and refetch

View File

@@ -1,4 +1,5 @@
import { YouTubeClient } from "../youtube-client.js"; import { YouTubeClient } from "../youtube-client.js";
import { getChannelsFromConfig } from "./channel.js";
/** /**
* Live status controller * Live status controller
@@ -8,6 +9,7 @@ export const liveController = {
* Get live status (JSON API) * Get live status (JSON API)
* Uses efficient method (checking recent videos) by default * Uses efficient method (checking recent videos) by default
* Use ?full=true for full search (costs 100 quota units) * Use ?full=true for full search (costs 100 quota units)
* Returns live status for all configured channels
* @type {import("express").RequestHandler} * @type {import("express").RequestHandler}
*/ */
async api(request, response) { async api(request, response) {
@@ -18,44 +20,87 @@ export const liveController = {
return response.status(500).json({ error: "Not configured" }); 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" }); return response.status(500).json({ error: "Invalid configuration" });
} }
const client = new YouTubeClient({ const useFullSearch = request.query.full === "true";
apiKey,
channelId, // Fetch live status from all channels in parallel
channelHandle, const livePromises = channelConfigs.map(async (channelConfig) => {
liveCacheTtl, 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 liveStatuses = await Promise.all(livePromises);
const useFullSearch = request.query.full === "true";
const liveStatus = useFullSearch
? await client.getLiveStatus()
: await client.getLiveStatusEfficient();
if (liveStatus) { // For single channel, return flat response (backward compatible)
if (channelConfigs.length === 1) {
const status = liveStatuses[0];
response.json({ response.json({
isLive: liveStatus.isLive || false, isLive: status.isLive,
isUpcoming: liveStatus.isUpcoming || false, isUpcoming: status.isUpcoming,
stream: { stream: status.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, cached: true,
}); });
} else { } 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({ response.json({
isLive: false, // Backward compat: primary live status (prefer live over upcoming)
isUpcoming: false, isLive: !!anyLive,
stream: null, isUpcoming: !anyLive && !!anyUpcoming,
stream: anyLive?.stream || anyUpcoming?.stream || null,
// Multi-channel data
liveStatuses,
cached: true, cached: true,
}); });
} }

View File

@@ -1,4 +1,5 @@
import { YouTubeClient } from "../youtube-client.js"; import { YouTubeClient } from "../youtube-client.js";
import { getChannelsFromConfig } from "./channel.js";
/** /**
* Videos controller * Videos controller
@@ -6,6 +7,7 @@ import { YouTubeClient } from "../youtube-client.js";
export const videosController = { export const videosController = {
/** /**
* Get latest videos (JSON API) * Get latest videos (JSON API)
* Returns videos from all configured channels
* @type {import("express").RequestHandler} * @type {import("express").RequestHandler}
*/ */
async api(request, response) { async api(request, response) {
@@ -16,31 +18,73 @@ export const videosController = {
return response.status(500).json({ error: "Not configured" }); 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" }); return response.status(500).json({ error: "Invalid configuration" });
} }
const client = new YouTubeClient({
apiKey,
channelId,
channelHandle,
cacheTtl,
});
const maxResults = Math.min( const maxResults = Math.min(
parseInt(request.query.limit, 10) || limits?.videos || 10, parseInt(request.query.limit, 10) || limits?.videos || 10,
50 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({ try {
videos, const videos = await client.getLatestVideos(maxResults);
count: videos.length, // Add channel info to each video
cached: true, 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) { } catch (error) {
console.error("[YouTube] Videos API error:", error); console.error("[YouTube] Videos API error:", error);
response.status(500).json({ error: error.message }); response.status(500).json({ error: error.message });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-youtube", "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.", "description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
"keywords": [ "keywords": [
"indiekit", "indiekit",

View File

@@ -188,8 +188,8 @@
<div class="yt-channel__info"> <div class="yt-channel__info">
<h2 class="yt-channel__name">{{ channel.title }}</h2> <h2 class="yt-channel__name">{{ channel.title }}</h2>
<div class="yt-channel__stats"> <div class="yt-channel__stats">
<span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span> <span>{{ channel.subscriberCount }} {{ __("youtube.subscribers") }}</span>
<span>{{ channel.videoCount | localeNumber }} videos</span> <span>{{ channel.videoCount }} videos</span>
</div> </div>
</div> </div>
{% if isLive %} {% if isLive %}
@@ -255,7 +255,7 @@
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a> <a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
</h3> </h3>
<div class="yt-video__meta"> <div class="yt-video__meta">
{{ video.viewCount | localeNumber }} {{ __("youtube.views") }} {{ video.viewCount }} {{ __("youtube.views") }}
</div> </div>
</div> </div>
</li> </li>