@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.
- package/includes/@indiekit-endpoint-youtube-live.njk +16 -0
- package/includes/@indiekit-endpoint-youtube-videos.njk +13 -0
- package/includes/@indiekit-endpoint-youtube-widget.njk +11 -134
- package/index.js +3 -0
- package/lib/controllers/channel.js +63 -12
- package/lib/controllers/live.js +71 -26
- package/lib/controllers/videos.js +58 -14
- package/package.json +1 -1
- package/views/youtube.njk +3 -3
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
40
|
+
const { apiKey, cacheTtl } = youtubeConfig;
|
|
41
|
+
const channelConfigs = getChannelsFromConfig(youtubeConfig);
|
|
20
42
|
|
|
21
|
-
if (!apiKey ||
|
|
43
|
+
if (!apiKey || channelConfigs.length === 0) {
|
|
22
44
|
return response.status(500).json({ error: "Invalid configuration" });
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
71
|
+
const channelsData = await Promise.all(channelPromises);
|
|
72
|
+
const channels = channelsData.filter(Boolean);
|
|
33
73
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 };
|
package/lib/controllers/live.js
CHANGED
|
@@ -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,
|
|
23
|
+
const { apiKey, liveCacheTtl } = youtubeConfig;
|
|
24
|
+
const channelConfigs = getChannelsFromConfig(youtubeConfig);
|
|
22
25
|
|
|
23
|
-
if (!apiKey ||
|
|
26
|
+
if (!apiKey || channelConfigs.length === 0) {
|
|
24
27
|
return response.status(500).json({ error: "Invalid configuration" });
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
43
|
-
isUpcoming:
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
21
|
+
const { apiKey, cacheTtl, limits } = youtubeConfig;
|
|
22
|
+
const channelConfigs = getChannelsFromConfig(youtubeConfig);
|
|
20
23
|
|
|
21
|
-
if (!apiKey ||
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
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
|
|
192
|
-
<span>{{ channel.videoCount
|
|
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
|
|
258
|
+
{{ video.viewCount }} {{ __("youtube.views") }}
|
|
259
259
|
</div>
|
|
260
260
|
</div>
|
|
261
261
|
</li>
|