@rmdes/indiekit-endpoint-youtube 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,39 @@
1
1
  import { YouTubeClient } from "../youtube-client.js";
2
2
 
3
+ /**
4
+ * Get all channels from config (supports both single and multi-channel modes)
5
+ * @param {object} config - YouTube configuration
6
+ * @returns {Array<{id?: string, handle?: string, name?: string}>}
7
+ */
8
+ function getAllChannels(config) {
9
+ const { channelId, channelHandle, channels } = config;
10
+
11
+ // Multi-channel mode
12
+ if (channels && Array.isArray(channels) && channels.length > 0) {
13
+ return channels.map((ch) => ({
14
+ id: ch.id,
15
+ handle: ch.handle,
16
+ name: ch.name,
17
+ }));
18
+ }
19
+
20
+ // Single channel mode (backward compatible)
21
+ if (channelId || channelHandle) {
22
+ return [{ id: channelId, handle: channelHandle }];
23
+ }
24
+
25
+ return [];
26
+ }
27
+
28
+ /**
29
+ * Get primary channel from config (for backward compatibility)
30
+ * Multi-channel mode uses first channel for dashboard
31
+ */
32
+ function getPrimaryChannel(config) {
33
+ const channels = getAllChannels(config);
34
+ return channels.length > 0 ? channels[0] : null;
35
+ }
36
+
3
37
  /**
4
38
  * Dashboard controller
5
39
  */
@@ -19,7 +53,7 @@ export const dashboardController = {
19
53
  });
20
54
  }
21
55
 
22
- const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
56
+ const { apiKey, cacheTtl, limits } = youtubeConfig;
23
57
 
24
58
  if (!apiKey) {
25
59
  return response.render("youtube", {
@@ -28,36 +62,53 @@ export const dashboardController = {
28
62
  });
29
63
  }
30
64
 
31
- if (!channelId && !channelHandle) {
65
+ const allChannels = getAllChannels(youtubeConfig);
66
+
67
+ if (allChannels.length === 0) {
32
68
  return response.render("youtube", {
33
69
  title: response.locals.__("youtube.title"),
34
70
  error: { message: response.locals.__("youtube.error.noChannel") },
35
71
  });
36
72
  }
37
73
 
38
- const client = new YouTubeClient({
39
- apiKey,
40
- channelId,
41
- channelHandle,
42
- cacheTtl,
43
- });
74
+ // Fetch data for all configured channels
75
+ const channelsData = [];
44
76
 
45
- let channel = null;
46
- let videos = [];
47
- let liveStatus = null;
48
-
49
- try {
50
- [channel, videos, liveStatus] = await Promise.all([
51
- client.getChannelInfo(),
52
- client.getLatestVideos(limits?.videos || 6),
53
- client.getLiveStatusEfficient(),
54
- ]);
55
- } catch (apiError) {
56
- console.error("[YouTube] API error:", apiError.message);
57
- return response.render("youtube", {
58
- title: response.locals.__("youtube.title"),
59
- error: { message: response.locals.__("youtube.error.connection") },
77
+ for (const channelConfig of allChannels) {
78
+ const client = new YouTubeClient({
79
+ apiKey,
80
+ channelId: channelConfig.id,
81
+ channelHandle: channelConfig.handle,
82
+ cacheTtl,
60
83
  });
84
+
85
+ try {
86
+ const [channel, videos, liveStatus] = await Promise.all([
87
+ client.getChannelInfo(),
88
+ client.getLatestVideos(limits?.videos || 6),
89
+ client.getLiveStatusEfficient(),
90
+ ]);
91
+
92
+ channelsData.push({
93
+ name: channelConfig.name || channel.title,
94
+ channel,
95
+ videos: videos.slice(0, limits?.videos || 6),
96
+ liveStatus,
97
+ isLive: liveStatus?.isLive || false,
98
+ isUpcoming: liveStatus?.isUpcoming || false,
99
+ });
100
+ } catch (apiError) {
101
+ console.error(
102
+ `[YouTube] API error for channel ${channelConfig.name || channelConfig.id || channelConfig.handle}:`,
103
+ apiError.message,
104
+ );
105
+ // Continue with other channels even if one fails
106
+ channelsData.push({
107
+ name:
108
+ channelConfig.name || channelConfig.id || channelConfig.handle,
109
+ error: apiError.message,
110
+ });
111
+ }
61
112
  }
62
113
 
63
114
  // Determine public frontend URL
@@ -65,13 +116,20 @@ export const dashboardController = {
65
116
  ? youtubeEndpoint.replace(/api$/, "")
66
117
  : "/youtube";
67
118
 
119
+ // For backward compatibility, also expose first channel's data at top level
120
+ const primaryData = channelsData[0] || {};
121
+
68
122
  response.render("youtube", {
69
123
  title: response.locals.__("youtube.title"),
70
- channel,
71
- videos: videos.slice(0, limits?.videos || 6),
72
- liveStatus,
73
- isLive: liveStatus?.isLive || false,
74
- isUpcoming: liveStatus?.isUpcoming || false,
124
+ // Multi-channel data
125
+ channelsData,
126
+ isMultiChannel: allChannels.length > 1,
127
+ // Backward compatible single-channel data (first channel)
128
+ channel: primaryData.channel,
129
+ videos: primaryData.videos,
130
+ liveStatus: primaryData.liveStatus,
131
+ isLive: primaryData.isLive || false,
132
+ isUpcoming: primaryData.isUpcoming || false,
75
133
  publicUrl,
76
134
  mountPath: request.baseUrl,
77
135
  });
@@ -93,10 +151,16 @@ export const dashboardController = {
93
151
  return response.status(500).json({ error: "Not configured" });
94
152
  }
95
153
 
154
+ const primaryChannel = getPrimaryChannel(youtubeConfig);
155
+
156
+ if (!primaryChannel) {
157
+ return response.status(500).json({ error: "No channel configured" });
158
+ }
159
+
96
160
  const client = new YouTubeClient({
97
161
  apiKey: youtubeConfig.apiKey,
98
- channelId: youtubeConfig.channelId,
99
- channelHandle: youtubeConfig.channelHandle,
162
+ channelId: primaryChannel.id,
163
+ channelHandle: primaryChannel.handle,
100
164
  });
101
165
 
102
166
  // Clear cache and refetch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-youtube",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/youtube.njk CHANGED
@@ -178,7 +178,117 @@
178
178
 
179
179
  {% if error %}
180
180
  {{ prose({ text: error.message }) }}
181
+ {% elif isMultiChannel and channelsData %}
182
+ {# Multi-channel mode: show all channels #}
183
+ {% for chData in channelsData %}
184
+ <div class="yt-channel-section" style="{% if not loop.first %}margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--color-border, #e5e5e5);{% endif %}">
185
+ {# Channel Header #}
186
+ {% if chData.error %}
187
+ <div class="yt-channel" style="border: 1px solid #ff6b6b;">
188
+ <div class="yt-channel__info">
189
+ <h2 class="yt-channel__name">{{ chData.name }}</h2>
190
+ <p style="color: #ff6b6b; margin: 0;">Error: {{ chData.error }}</p>
191
+ </div>
192
+ </div>
193
+ {% elif chData.channel %}
194
+ <div class="yt-channel">
195
+ {% if chData.channel.thumbnail %}
196
+ <img src="{{ chData.channel.thumbnail }}" alt="" class="yt-channel__avatar">
197
+ {% endif %}
198
+ <div class="yt-channel__info">
199
+ <h2 class="yt-channel__name">{{ chData.channel.title }}</h2>
200
+ <div class="yt-channel__stats">
201
+ <span>{{ chData.channel.subscriberCount }} {{ __("youtube.subscribers") }}</span>
202
+ <span>{{ chData.channel.videoCount }} videos</span>
203
+ </div>
204
+ </div>
205
+ {% if chData.isLive %}
206
+ <span class="yt-live-badge yt-live-badge--live">
207
+ <span class="yt-live-dot"></span>
208
+ {{ __("youtube.live") }}
209
+ </span>
210
+ {% elif chData.isUpcoming %}
211
+ <span class="yt-live-badge yt-live-badge--upcoming">
212
+ {{ __("youtube.upcoming") }}
213
+ </span>
214
+ {% else %}
215
+ <span class="yt-live-badge yt-live-badge--offline">
216
+ {{ __("youtube.offline") }}
217
+ </span>
218
+ {% endif %}
219
+ </div>
220
+
221
+ {# Live Stream (if live) #}
222
+ {% if chData.liveStatus and (chData.liveStatus.isLive or chData.liveStatus.isUpcoming) %}
223
+ <section class="yt-section">
224
+ <h3>{% if chData.liveStatus.isLive %}{{ __("youtube.live") }}{% else %}{{ __("youtube.upcoming") }}{% endif %}</h3>
225
+ <div class="yt-live-stream">
226
+ {% if chData.liveStatus.thumbnail %}
227
+ <img src="{{ chData.liveStatus.thumbnail }}" alt="" class="yt-live-stream__thumb">
228
+ {% endif %}
229
+ <div class="yt-live-stream__info">
230
+ <h4 class="yt-live-stream__title">
231
+ <a href="https://www.youtube.com/watch?v={{ chData.liveStatus.videoId }}" target="_blank" rel="noopener">
232
+ {{ chData.liveStatus.title }}
233
+ </a>
234
+ </h4>
235
+ {{ button({
236
+ href: "https://www.youtube.com/watch?v=" + chData.liveStatus.videoId,
237
+ text: __("youtube.watchNow"),
238
+ target: "_blank"
239
+ }) }}
240
+ </div>
241
+ </div>
242
+ </section>
243
+ {% endif %}
244
+
245
+ {# Latest Videos #}
246
+ <section class="yt-section">
247
+ <h3>{{ __("youtube.videos") }}</h3>
248
+ {% if chData.videos and chData.videos.length > 0 %}
249
+ <ul class="yt-video-grid">
250
+ {% for video in chData.videos %}
251
+ <li class="yt-video">
252
+ <div class="yt-video__thumb-wrapper">
253
+ <a href="{{ video.url }}" target="_blank" rel="noopener">
254
+ <img src="{{ video.thumbnail }}" alt="" class="yt-video__thumb" loading="lazy">
255
+ </a>
256
+ {% if video.durationFormatted and not video.isLive %}
257
+ <span class="yt-video__duration">{{ video.durationFormatted }}</span>
258
+ {% elif video.isLive %}
259
+ <span class="yt-video__duration" style="background:#ff0000">LIVE</span>
260
+ {% endif %}
261
+ </div>
262
+ <div class="yt-video__info">
263
+ <h4 class="yt-video__title">
264
+ <a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
265
+ </h4>
266
+ <div class="yt-video__meta">
267
+ {{ video.viewCount }} {{ __("youtube.views") }}
268
+ </div>
269
+ </div>
270
+ </li>
271
+ {% endfor %}
272
+ </ul>
273
+ {% else %}
274
+ {{ prose({ text: __("youtube.noVideos") }) }}
275
+ {% endif %}
276
+ </section>
277
+
278
+ {# Link to YouTube channel #}
279
+ <div class="yt-public-link" style="margin-top: 1rem;">
280
+ <p>{{ __("youtube.widget.description") }}</p>
281
+ {{ button({
282
+ href: "https://www.youtube.com/channel/" + chData.channel.id,
283
+ text: __("youtube.widget.view"),
284
+ target: "_blank"
285
+ }) }}
286
+ </div>
287
+ {% endif %}
288
+ </div>
289
+ {% endfor %}
181
290
  {% else %}
291
+ {# Single channel mode (backward compatible) #}
182
292
  {# Channel Header #}
183
293
  {% if channel %}
184
294
  <div class="yt-channel">