@rmdes/indiekit-endpoint-youtube 1.0.1 → 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.
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.1",
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",