@rmdes/indiekit-endpoint-youtube 1.0.0 → 1.1.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.
@@ -0,0 +1,16 @@
1
+ {# Live status partial for embedding in other templates #}
2
+ {% if liveStatus and liveStatus.isLive %}
3
+ <div class="youtube-live youtube-live--active">
4
+ <span class="youtube-live__badge">Live</span>
5
+ <a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
6
+ {{ liveStatus.title }}
7
+ </a>
8
+ </div>
9
+ {% elif liveStatus and liveStatus.isUpcoming %}
10
+ <div class="youtube-live youtube-live--upcoming">
11
+ <span class="youtube-live__badge">Upcoming</span>
12
+ <a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
13
+ {{ liveStatus.title }}
14
+ </a>
15
+ </div>
16
+ {% endif %}
@@ -0,0 +1,13 @@
1
+ {# Videos partial for embedding in other templates #}
2
+ {% if videos and videos.length > 0 %}
3
+ <ul class="youtube-list youtube-list--compact">
4
+ {% for video in videos %}
5
+ {% if loop.index <= 5 %}
6
+ <li class="youtube-list__item">
7
+ <a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
8
+ <small class="youtube-meta">{{ video.viewCount }} views</small>
9
+ </li>
10
+ {% endif %}
11
+ {% endfor %}
12
+ </ul>
13
+ {% endif %}
@@ -1,135 +1,12 @@
1
- {#
2
- YouTube Widget - Embeddable component showing latest video and live status
3
-
4
- Usage in your templates:
5
- {% include "@indiekit-endpoint-youtube-widget.njk" %}
6
-
7
- Requires youtube data to be fetched and passed to the template context
8
- #}
9
-
10
- <style>
11
- .youtube-widget {
12
- display: flex;
13
- flex-direction: column;
14
- gap: 0.75rem;
15
- padding: 1rem;
16
- background: var(--color-offset, #f5f5f5);
17
- border-radius: 0.5rem;
18
- }
19
- .youtube-widget__header {
20
- display: flex;
21
- align-items: center;
22
- justify-content: space-between;
23
- }
24
- .youtube-widget__title {
25
- font-weight: 600;
26
- font-size: 0.875rem;
27
- margin: 0;
28
- }
29
- .youtube-widget__live {
30
- display: inline-flex;
31
- align-items: center;
32
- gap: 0.375rem;
33
- padding: 0.125rem 0.5rem;
34
- border-radius: 1rem;
35
- font-size: 0.625rem;
36
- font-weight: 600;
37
- text-transform: uppercase;
38
- }
39
- .youtube-widget__live--on {
40
- background: #ff0000;
41
- color: white;
42
- }
43
- .youtube-widget__live--off {
44
- background: #e5e5e5;
45
- color: #666;
46
- }
47
- .youtube-widget__live-dot {
48
- width: 6px;
49
- height: 6px;
50
- border-radius: 50%;
51
- background: currentColor;
52
- }
53
- .youtube-widget__video {
54
- display: flex;
55
- gap: 0.75rem;
56
- }
57
- .youtube-widget__thumb {
58
- width: 120px;
59
- height: 68px;
60
- object-fit: cover;
61
- border-radius: 0.25rem;
62
- flex-shrink: 0;
63
- }
64
- .youtube-widget__info {
65
- flex: 1;
66
- min-width: 0;
67
- }
68
- .youtube-widget__video-title {
69
- font-size: 0.875rem;
70
- font-weight: 500;
71
- margin: 0 0 0.25rem 0;
72
- display: -webkit-box;
73
- -webkit-line-clamp: 2;
74
- -webkit-box-orient: vertical;
75
- overflow: hidden;
76
- }
77
- .youtube-widget__video-title a {
78
- color: inherit;
79
- text-decoration: none;
80
- }
81
- .youtube-widget__video-title a:hover {
82
- text-decoration: underline;
83
- }
84
- .youtube-widget__meta {
85
- font-size: 0.75rem;
86
- color: var(--color-text-secondary, #666);
87
- }
88
- </style>
89
-
90
- <div class="youtube-widget">
91
- <div class="youtube-widget__header">
92
- <h3 class="youtube-widget__title">YouTube</h3>
93
- {% if youtube.isLive %}
94
- <span class="youtube-widget__live youtube-widget__live--on">
95
- <span class="youtube-widget__live-dot"></span>
96
- Live
97
- </span>
98
- {% else %}
99
- <span class="youtube-widget__live youtube-widget__live--off">
100
- Offline
101
- </span>
102
- {% endif %}
1
+ {% call widget({
2
+ title: __("youtube.title")
3
+ }) %}
4
+ <p class="prose">{{ __("youtube.widget.description") }}</p>
5
+ <div class="button-grid">
6
+ {{ button({
7
+ classes: "button--secondary-on-offset",
8
+ href: application.youtubeEndpoint or "/youtube",
9
+ text: __("youtube.widget.view")
10
+ }) }}
103
11
  </div>
104
-
105
- {% if youtube.liveStream %}
106
- <div class="youtube-widget__video">
107
- <img src="{{ youtube.liveStream.thumbnail }}" alt="" class="youtube-widget__thumb">
108
- <div class="youtube-widget__info">
109
- <h4 class="youtube-widget__video-title">
110
- <a href="{{ youtube.liveStream.url }}" target="_blank" rel="noopener">
111
- {{ youtube.liveStream.title }}
112
- </a>
113
- </h4>
114
- <p class="youtube-widget__meta">🔴 Streaming now</p>
115
- </div>
116
- </div>
117
- {% elif youtube.videos and youtube.videos[0] %}
118
- {% set video = youtube.videos[0] %}
119
- <div class="youtube-widget__video">
120
- <img src="{{ video.thumbnail }}" alt="" class="youtube-widget__thumb">
121
- <div class="youtube-widget__info">
122
- <h4 class="youtube-widget__video-title">
123
- <a href="{{ video.url }}" target="_blank" rel="noopener">
124
- {{ video.title }}
125
- </a>
126
- </h4>
127
- <p class="youtube-widget__meta">
128
- {{ video.viewCount | localeNumber }} views
129
- </p>
130
- </div>
131
- </div>
132
- {% else %}
133
- <p class="youtube-widget__meta">No videos available</p>
134
- {% endif %}
135
- </div>
12
+ {% endcall %}
package/index.js CHANGED
@@ -15,8 +15,11 @@ const publicRouter = express.Router();
15
15
  const defaults = {
16
16
  mountPath: "/youtube",
17
17
  apiKey: process.env.YOUTUBE_API_KEY,
18
+ // Single channel (backward compatible)
18
19
  channelId: process.env.YOUTUBE_CHANNEL_ID,
19
20
  channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
21
+ // Multiple channels support: array of {id, handle, name}
22
+ channels: null,
20
23
  cacheTtl: 300_000, // 5 minutes
21
24
  liveCacheTtl: 60_000, // 1 minute for live status
22
25
  limits: {
@@ -1,11 +1,32 @@
1
1
  import { YouTubeClient } from "../youtube-client.js";
2
2
 
3
+ /**
4
+ * Get normalized channels array from config
5
+ * Supports both single channel (backward compat) and multiple channels
6
+ */
7
+ function getChannelsFromConfig(youtubeConfig) {
8
+ const { channelId, channelHandle, channels } = youtubeConfig;
9
+
10
+ // If channels array is provided, use it
11
+ if (channels && Array.isArray(channels) && channels.length > 0) {
12
+ return channels;
13
+ }
14
+
15
+ // Fallback to single channel config (backward compatible)
16
+ if (channelId || channelHandle) {
17
+ return [{ id: channelId, handle: channelHandle, name: "Primary" }];
18
+ }
19
+
20
+ return [];
21
+ }
22
+
3
23
  /**
4
24
  * Channel controller
5
25
  */
6
26
  export const channelController = {
7
27
  /**
8
28
  * Get channel info (JSON API)
29
+ * Returns array of channels if multiple configured
9
30
  * @type {import("express").RequestHandler}
10
31
  */
11
32
  async api(request, response) {
@@ -16,28 +37,58 @@ export const channelController = {
16
37
  return response.status(500).json({ error: "Not configured" });
17
38
  }
18
39
 
19
- const { apiKey, channelId, channelHandle, cacheTtl } = youtubeConfig;
40
+ const { apiKey, cacheTtl } = youtubeConfig;
41
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
20
42
 
21
- if (!apiKey || (!channelId && !channelHandle)) {
43
+ if (!apiKey || channelConfigs.length === 0) {
22
44
  return response.status(500).json({ error: "Invalid configuration" });
23
45
  }
24
46
 
25
- const client = new YouTubeClient({
26
- apiKey,
27
- channelId,
28
- channelHandle,
29
- cacheTtl,
47
+ // Fetch all channels in parallel
48
+ const channelPromises = channelConfigs.map(async (channelConfig) => {
49
+ const client = new YouTubeClient({
50
+ apiKey,
51
+ channelId: channelConfig.id,
52
+ channelHandle: channelConfig.handle,
53
+ cacheTtl,
54
+ });
55
+
56
+ try {
57
+ const channel = await client.getChannelInfo();
58
+ return {
59
+ ...channel,
60
+ configName: channelConfig.name,
61
+ };
62
+ } catch (error) {
63
+ console.error(
64
+ `[YouTube] Failed to fetch channel ${channelConfig.name || channelConfig.handle}:`,
65
+ error.message
66
+ );
67
+ return null;
68
+ }
30
69
  });
31
70
 
32
- const channel = await client.getChannelInfo();
71
+ const channelsData = await Promise.all(channelPromises);
72
+ const channels = channelsData.filter(Boolean);
33
73
 
34
- response.json({
35
- channel,
36
- cached: true,
37
- });
74
+ // Return single channel for backward compatibility when only one configured
75
+ if (channelConfigs.length === 1) {
76
+ response.json({
77
+ channel: channels[0] || null,
78
+ cached: true,
79
+ });
80
+ } else {
81
+ response.json({
82
+ channels,
83
+ channel: channels[0] || null, // Primary channel for backward compat
84
+ cached: true,
85
+ });
86
+ }
38
87
  } catch (error) {
39
88
  console.error("[YouTube] Channel API error:", error);
40
89
  response.status(500).json({ error: error.message });
41
90
  }
42
91
  },
43
92
  };
93
+
94
+ export { getChannelsFromConfig };
@@ -1,4 +1,5 @@
1
1
  import { YouTubeClient } from "../youtube-client.js";
2
+ import { getChannelsFromConfig } from "./channel.js";
2
3
 
3
4
  /**
4
5
  * Live status controller
@@ -8,6 +9,7 @@ export const liveController = {
8
9
  * Get live status (JSON API)
9
10
  * Uses efficient method (checking recent videos) by default
10
11
  * Use ?full=true for full search (costs 100 quota units)
12
+ * Returns live status for all configured channels
11
13
  * @type {import("express").RequestHandler}
12
14
  */
13
15
  async api(request, response) {
@@ -18,44 +20,87 @@ export const liveController = {
18
20
  return response.status(500).json({ error: "Not configured" });
19
21
  }
20
22
 
21
- const { apiKey, channelId, channelHandle, liveCacheTtl } = youtubeConfig;
23
+ const { apiKey, liveCacheTtl } = youtubeConfig;
24
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
22
25
 
23
- if (!apiKey || (!channelId && !channelHandle)) {
26
+ if (!apiKey || channelConfigs.length === 0) {
24
27
  return response.status(500).json({ error: "Invalid configuration" });
25
28
  }
26
29
 
27
- const client = new YouTubeClient({
28
- apiKey,
29
- channelId,
30
- channelHandle,
31
- liveCacheTtl,
30
+ const useFullSearch = request.query.full === "true";
31
+
32
+ // Fetch live status from all channels in parallel
33
+ const livePromises = channelConfigs.map(async (channelConfig) => {
34
+ const client = new YouTubeClient({
35
+ apiKey,
36
+ channelId: channelConfig.id,
37
+ channelHandle: channelConfig.handle,
38
+ liveCacheTtl,
39
+ });
40
+
41
+ try {
42
+ const liveStatus = useFullSearch
43
+ ? await client.getLiveStatus()
44
+ : await client.getLiveStatusEfficient();
45
+
46
+ if (liveStatus) {
47
+ return {
48
+ channelConfigName: channelConfig.name,
49
+ isLive: liveStatus.isLive || false,
50
+ isUpcoming: liveStatus.isUpcoming || false,
51
+ stream: {
52
+ videoId: liveStatus.videoId,
53
+ title: liveStatus.title,
54
+ thumbnail: liveStatus.thumbnail,
55
+ url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
56
+ scheduledStart: liveStatus.scheduledStart,
57
+ actualStart: liveStatus.actualStart,
58
+ },
59
+ };
60
+ }
61
+ return {
62
+ channelConfigName: channelConfig.name,
63
+ isLive: false,
64
+ isUpcoming: false,
65
+ stream: null,
66
+ };
67
+ } catch (error) {
68
+ console.error(
69
+ `[YouTube] Failed to fetch live status for ${channelConfig.name || channelConfig.handle}:`,
70
+ error.message
71
+ );
72
+ return {
73
+ channelConfigName: channelConfig.name,
74
+ isLive: false,
75
+ isUpcoming: false,
76
+ stream: null,
77
+ };
78
+ }
32
79
  });
33
80
 
34
- // Use full search only if explicitly requested
35
- const useFullSearch = request.query.full === "true";
36
- const liveStatus = useFullSearch
37
- ? await client.getLiveStatus()
38
- : await client.getLiveStatusEfficient();
81
+ const liveStatuses = await Promise.all(livePromises);
39
82
 
40
- if (liveStatus) {
83
+ // For single channel, return flat response (backward compatible)
84
+ if (channelConfigs.length === 1) {
85
+ const status = liveStatuses[0];
41
86
  response.json({
42
- isLive: liveStatus.isLive || false,
43
- isUpcoming: liveStatus.isUpcoming || false,
44
- stream: {
45
- videoId: liveStatus.videoId,
46
- title: liveStatus.title,
47
- thumbnail: liveStatus.thumbnail,
48
- url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
49
- scheduledStart: liveStatus.scheduledStart,
50
- actualStart: liveStatus.actualStart,
51
- },
87
+ isLive: status.isLive,
88
+ isUpcoming: status.isUpcoming,
89
+ stream: status.stream,
52
90
  cached: true,
53
91
  });
54
92
  } else {
93
+ // For multiple channels, find any that are live
94
+ const anyLive = liveStatuses.find((s) => s.isLive);
95
+ const anyUpcoming = liveStatuses.find((s) => s.isUpcoming && !s.isLive);
96
+
55
97
  response.json({
56
- isLive: false,
57
- isUpcoming: false,
58
- stream: null,
98
+ // Backward compat: primary live status (prefer live over upcoming)
99
+ isLive: !!anyLive,
100
+ isUpcoming: !anyLive && !!anyUpcoming,
101
+ stream: anyLive?.stream || anyUpcoming?.stream || null,
102
+ // Multi-channel data
103
+ liveStatuses,
59
104
  cached: true,
60
105
  });
61
106
  }
@@ -1,4 +1,5 @@
1
1
  import { YouTubeClient } from "../youtube-client.js";
2
+ import { getChannelsFromConfig } from "./channel.js";
2
3
 
3
4
  /**
4
5
  * Videos controller
@@ -6,6 +7,7 @@ import { YouTubeClient } from "../youtube-client.js";
6
7
  export const videosController = {
7
8
  /**
8
9
  * Get latest videos (JSON API)
10
+ * Returns videos from all configured channels
9
11
  * @type {import("express").RequestHandler}
10
12
  */
11
13
  async api(request, response) {
@@ -16,31 +18,73 @@ export const videosController = {
16
18
  return response.status(500).json({ error: "Not configured" });
17
19
  }
18
20
 
19
- const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
21
+ const { apiKey, cacheTtl, limits } = youtubeConfig;
22
+ const channelConfigs = getChannelsFromConfig(youtubeConfig);
20
23
 
21
- if (!apiKey || (!channelId && !channelHandle)) {
24
+ if (!apiKey || channelConfigs.length === 0) {
22
25
  return response.status(500).json({ error: "Invalid configuration" });
23
26
  }
24
27
 
25
- const client = new YouTubeClient({
26
- apiKey,
27
- channelId,
28
- channelHandle,
29
- cacheTtl,
30
- });
31
-
32
28
  const maxResults = Math.min(
33
29
  parseInt(request.query.limit, 10) || limits?.videos || 10,
34
30
  50
35
31
  );
36
32
 
37
- const videos = await client.getLatestVideos(maxResults);
33
+ // Fetch videos from all channels in parallel
34
+ const videosPromises = channelConfigs.map(async (channelConfig) => {
35
+ const client = new YouTubeClient({
36
+ apiKey,
37
+ channelId: channelConfig.id,
38
+ channelHandle: channelConfig.handle,
39
+ cacheTtl,
40
+ });
38
41
 
39
- response.json({
40
- videos,
41
- count: videos.length,
42
- cached: true,
42
+ try {
43
+ const videos = await client.getLatestVideos(maxResults);
44
+ // Add channel info to each video
45
+ return videos.map((video) => ({
46
+ ...video,
47
+ channelConfigName: channelConfig.name,
48
+ }));
49
+ } catch (error) {
50
+ console.error(
51
+ `[YouTube] Failed to fetch videos for ${channelConfig.name || channelConfig.handle}:`,
52
+ error.message
53
+ );
54
+ return [];
55
+ }
43
56
  });
57
+
58
+ const videosArrays = await Promise.all(videosPromises);
59
+
60
+ // For single channel, return flat array (backward compatible)
61
+ if (channelConfigs.length === 1) {
62
+ const videos = videosArrays[0] || [];
63
+ response.json({
64
+ videos,
65
+ count: videos.length,
66
+ cached: true,
67
+ });
68
+ } else {
69
+ // For multiple channels, return grouped by channel
70
+ const videosByChannel = {};
71
+ channelConfigs.forEach((config, index) => {
72
+ videosByChannel[config.name || config.handle] = videosArrays[index] || [];
73
+ });
74
+
75
+ // Also provide flat array sorted by date
76
+ const allVideos = videosArrays
77
+ .flat()
78
+ .sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
79
+ .slice(0, maxResults);
80
+
81
+ response.json({
82
+ videos: allVideos, // Backward compat: flat array
83
+ videosByChannel,
84
+ count: allVideos.length,
85
+ cached: true,
86
+ });
87
+ }
44
88
  } catch (error) {
45
89
  console.error("[YouTube] Videos API error:", error);
46
90
  response.status(500).json({ error: error.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-youtube",
3
- "version": "1.0.0",
3
+ "version": "1.1.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
@@ -188,8 +188,8 @@
188
188
  <div class="yt-channel__info">
189
189
  <h2 class="yt-channel__name">{{ channel.title }}</h2>
190
190
  <div class="yt-channel__stats">
191
- <span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span>
192
- <span>{{ channel.videoCount | localeNumber }} videos</span>
191
+ <span>{{ channel.subscriberCount }} {{ __("youtube.subscribers") }}</span>
192
+ <span>{{ channel.videoCount }} videos</span>
193
193
  </div>
194
194
  </div>
195
195
  {% if isLive %}
@@ -255,7 +255,7 @@
255
255
  <a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
256
256
  </h3>
257
257
  <div class="yt-video__meta">
258
- {{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
258
+ {{ video.viewCount }} {{ __("youtube.views") }}
259
259
  </div>
260
260
  </div>
261
261
  </li>