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

View File

@@ -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 };

View File

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

View File

@@ -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,
});
}

View File

@@ -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 });