From 43090ce8afbad2313b9755b2f5f5ebda7fc857c3 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 24 Jan 2026 13:59:25 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 44 ++++++ includes/@indiekit-endpoint-youtube-live.njk | 16 ++ .../@indiekit-endpoint-youtube-videos.njk | 13 ++ .../@indiekit-endpoint-youtube-widget.njk | 145 ++---------------- index.js | 3 + lib/controllers/channel.js | 75 +++++++-- lib/controllers/dashboard.js | 41 ++++- lib/controllers/live.js | 97 ++++++++---- lib/controllers/videos.js | 72 +++++++-- package.json | 2 +- views/youtube.njk | 6 +- 11 files changed, 318 insertions(+), 196 deletions(-) create mode 100644 CLAUDE.md create mode 100644 includes/@indiekit-endpoint-youtube-live.njk create mode 100644 includes/@indiekit-endpoint-youtube-videos.njk 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 %} + +{% elif liveStatus and liveStatus.isUpcoming %} + +{% endif %} diff --git a/includes/@indiekit-endpoint-youtube-videos.njk b/includes/@indiekit-endpoint-youtube-videos.njk new file mode 100644 index 0000000..d582c11 --- /dev/null +++ b/includes/@indiekit-endpoint-youtube-videos.njk @@ -0,0 +1,13 @@ +{# Videos partial for embedding in other templates #} +{% if videos and videos.length > 0 %} + +{% endif %} diff --git a/includes/@indiekit-endpoint-youtube-widget.njk b/includes/@indiekit-endpoint-youtube-widget.njk index 647f44a..19e8e41 100644 --- a/includes/@indiekit-endpoint-youtube-widget.njk +++ b/includes/@indiekit-endpoint-youtube-widget.njk @@ -1,135 +1,12 @@ -{# - 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 -#} - - - -
-
-

YouTube

- {% if youtube.isLive %} - - - Live - - {% else %} - - Offline - - {% endif %} +{% call widget({ + title: __("youtube.title") +}) %} +

{{ __("youtube.widget.description") }}

+
+ {{ button({ + classes: "button--secondary-on-offset", + href: application.youtubeEndpoint or "/youtube", + text: __("youtube.widget.view") + }) }}
- - {% if youtube.liveStream %} -
- -
-

- - {{ youtube.liveStream.title }} - -

-

🔴 Streaming now

-
-
- {% elif youtube.videos and youtube.videos[0] %} - {% set video = youtube.videos[0] %} -
- -
-

- - {{ video.title }} - -

-

- {{ video.viewCount | localeNumber }} views -

-
-
- {% else %} -

No videos available

- {% endif %} -
+{% 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") }}