mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
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:
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Indiekit plugin that adds a YouTube channel endpoint. It displays latest videos and live streaming status from a YouTube channel, with both an admin dashboard and public JSON API endpoints.
|
||||
|
||||
## Development
|
||||
|
||||
This is an ESM module with no build step. Install dependencies with `npm install`.
|
||||
|
||||
No test suite is configured. Testing requires a running Indiekit instance with valid YouTube API credentials.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Plugin Entry Point** (`index.js`):
|
||||
- Exports a `YouTubeEndpoint` class that Indiekit loads as a plugin
|
||||
- Registers protected routes (admin dashboard) and public routes (JSON API)
|
||||
- Stores configuration in `Indiekit.config.application.youtubeConfig` for controller access
|
||||
|
||||
**YouTube API Client** (`lib/youtube-client.js`):
|
||||
- Handles all YouTube Data API v3 interactions
|
||||
- Implements in-memory caching with configurable TTL
|
||||
- Uses uploads playlist method for quota efficiency (2 units) instead of search (100 units)
|
||||
- Channel info cached for 24 hours; videos cached per `cacheTtl` option
|
||||
|
||||
**Controllers** (`lib/controllers/`):
|
||||
- `dashboard.js` - Admin page rendering, cache refresh
|
||||
- `videos.js` - `/api/videos` JSON endpoint
|
||||
- `channel.js` - `/api/channel` JSON endpoint
|
||||
- `live.js` - `/api/live` JSON endpoint with efficient vs full search modes
|
||||
|
||||
**Views/Templates**:
|
||||
- `views/youtube.njk` - Admin dashboard template (Nunjucks)
|
||||
- `includes/@indiekit-endpoint-youtube-widget.njk` - Widget component
|
||||
|
||||
## API Quota Considerations
|
||||
|
||||
YouTube Data API has a 10,000 units/day default quota:
|
||||
- `channels.list`, `playlistItems.list`, `videos.list`: 1 unit each
|
||||
- `search.list`: 100 units (used only for full live status check with `?full=true`)
|
||||
|
||||
The plugin uses the playlist method by default for quota efficiency.
|
||||
16
includes/@indiekit-endpoint-youtube-live.njk
Normal file
16
includes/@indiekit-endpoint-youtube-live.njk
Normal file
@@ -0,0 +1,16 @@
|
||||
{# Live status partial for embedding in other templates #}
|
||||
{% if liveStatus and liveStatus.isLive %}
|
||||
<div class="youtube-live youtube-live--active">
|
||||
<span class="youtube-live__badge">Live</span>
|
||||
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
|
||||
{{ liveStatus.title }}
|
||||
</a>
|
||||
</div>
|
||||
{% elif liveStatus and liveStatus.isUpcoming %}
|
||||
<div class="youtube-live youtube-live--upcoming">
|
||||
<span class="youtube-live__badge">Upcoming</span>
|
||||
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
|
||||
{{ liveStatus.title }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
13
includes/@indiekit-endpoint-youtube-videos.njk
Normal file
13
includes/@indiekit-endpoint-youtube-videos.njk
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Videos partial for embedding in other templates #}
|
||||
{% if videos and videos.length > 0 %}
|
||||
<ul class="youtube-list youtube-list--compact">
|
||||
{% for video in videos %}
|
||||
{% if loop.index <= 5 %}
|
||||
<li class="youtube-list__item">
|
||||
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
|
||||
<small class="youtube-meta">{{ video.viewCount }} views</small>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -1,135 +1,12 @@
|
||||
{#
|
||||
YouTube Widget - Embeddable component showing latest video and live status
|
||||
|
||||
Usage in your templates:
|
||||
{% include "@indiekit-endpoint-youtube-widget.njk" %}
|
||||
|
||||
Requires youtube data to be fetched and passed to the template context
|
||||
#}
|
||||
|
||||
<style>
|
||||
.youtube-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-offset, #f5f5f5);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.youtube-widget__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.youtube-widget__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
.youtube-widget__live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.youtube-widget__live--on {
|
||||
background: #ff0000;
|
||||
color: white;
|
||||
}
|
||||
.youtube-widget__live--off {
|
||||
background: #e5e5e5;
|
||||
color: #666;
|
||||
}
|
||||
.youtube-widget__live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.youtube-widget__video {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.youtube-widget__thumb {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.youtube-widget__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.youtube-widget__video-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.25rem 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.youtube-widget__video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.youtube-widget__video-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.youtube-widget__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="youtube-widget">
|
||||
<div class="youtube-widget__header">
|
||||
<h3 class="youtube-widget__title">YouTube</h3>
|
||||
{% if youtube.isLive %}
|
||||
<span class="youtube-widget__live youtube-widget__live--on">
|
||||
<span class="youtube-widget__live-dot"></span>
|
||||
Live
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="youtube-widget__live youtube-widget__live--off">
|
||||
Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
{% call widget({
|
||||
title: __("youtube.title")
|
||||
}) %}
|
||||
<p class="prose">{{ __("youtube.widget.description") }}</p>
|
||||
<div class="button-grid">
|
||||
{{ button({
|
||||
classes: "button--secondary-on-offset",
|
||||
href: application.youtubeEndpoint or "/youtube",
|
||||
text: __("youtube.widget.view")
|
||||
}) }}
|
||||
</div>
|
||||
|
||||
{% if youtube.liveStream %}
|
||||
<div class="youtube-widget__video">
|
||||
<img src="{{ youtube.liveStream.thumbnail }}" alt="" class="youtube-widget__thumb">
|
||||
<div class="youtube-widget__info">
|
||||
<h4 class="youtube-widget__video-title">
|
||||
<a href="{{ youtube.liveStream.url }}" target="_blank" rel="noopener">
|
||||
{{ youtube.liveStream.title }}
|
||||
</a>
|
||||
</h4>
|
||||
<p class="youtube-widget__meta">🔴 Streaming now</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif youtube.videos and youtube.videos[0] %}
|
||||
{% set video = youtube.videos[0] %}
|
||||
<div class="youtube-widget__video">
|
||||
<img src="{{ video.thumbnail }}" alt="" class="youtube-widget__thumb">
|
||||
<div class="youtube-widget__info">
|
||||
<h4 class="youtube-widget__video-title">
|
||||
<a href="{{ video.url }}" target="_blank" rel="noopener">
|
||||
{{ video.title }}
|
||||
</a>
|
||||
</h4>
|
||||
<p class="youtube-widget__meta">
|
||||
{{ video.viewCount | localeNumber }} views
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="youtube-widget__meta">No videos available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
3
index.js
3
index.js
@@ -15,8 +15,11 @@ const publicRouter = express.Router();
|
||||
const defaults = {
|
||||
mountPath: "/youtube",
|
||||
apiKey: process.env.YOUTUBE_API_KEY,
|
||||
// Single channel (backward compatible)
|
||||
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
||||
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
|
||||
// Multiple channels support: array of {id, handle, name}
|
||||
channels: null,
|
||||
cacheTtl: 300_000, // 5 minutes
|
||||
liveCacheTtl: 60_000, // 1 minute for live status
|
||||
limits: {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
// Fetch all channels in parallel
|
||||
const channelPromises = channelConfigs.map(async (channelConfig) => {
|
||||
const client = new YouTubeClient({
|
||||
apiKey,
|
||||
channelId,
|
||||
channelHandle,
|
||||
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 channelsData = await Promise.all(channelPromises);
|
||||
const channels = channelsData.filter(Boolean);
|
||||
|
||||
// Return single channel for backward compatibility when only one configured
|
||||
if (channelConfigs.length === 1) {
|
||||
response.json({
|
||||
channel,
|
||||
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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,27 +20,32 @@ 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 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,
|
||||
channelHandle,
|
||||
channelId: channelConfig.id,
|
||||
channelHandle: channelConfig.handle,
|
||||
liveCacheTtl,
|
||||
});
|
||||
|
||||
// Use full search only if explicitly requested
|
||||
const useFullSearch = request.query.full === "true";
|
||||
try {
|
||||
const liveStatus = useFullSearch
|
||||
? await client.getLiveStatus()
|
||||
: await client.getLiveStatusEfficient();
|
||||
|
||||
if (liveStatus) {
|
||||
response.json({
|
||||
return {
|
||||
channelConfigName: channelConfig.name,
|
||||
isLive: liveStatus.isLive || false,
|
||||
isUpcoming: liveStatus.isUpcoming || false,
|
||||
stream: {
|
||||
@@ -49,13 +56,51 @@ export const liveController = {
|
||||
scheduledStart: liveStatus.scheduledStart,
|
||||
actualStart: liveStatus.actualStart,
|
||||
},
|
||||
cached: true,
|
||||
});
|
||||
} else {
|
||||
response.json({
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const liveStatuses = await Promise.all(livePromises);
|
||||
|
||||
// For single channel, return flat response (backward compatible)
|
||||
if (channelConfigs.length === 1) {
|
||||
const status = liveStatuses[0];
|
||||
response.json({
|
||||
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({
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-youtube",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
@@ -188,8 +188,8 @@
|
||||
<div class="yt-channel__info">
|
||||
<h2 class="yt-channel__name">{{ channel.title }}</h2>
|
||||
<div class="yt-channel__stats">
|
||||
<span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span>
|
||||
<span>{{ channel.videoCount | localeNumber }} videos</span>
|
||||
<span>{{ channel.subscriberCount }} {{ __("youtube.subscribers") }}</span>
|
||||
<span>{{ channel.videoCount }} videos</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if isLive %}
|
||||
@@ -255,7 +255,7 @@
|
||||
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
|
||||
</h3>
|
||||
<div class="yt-video__meta">
|
||||
{{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
|
||||
{{ video.viewCount }} {{ __("youtube.views") }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user