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 @@
|
|||||||
{#
|
{% call widget({
|
||||||
YouTube Widget - Embeddable component showing latest video and live status
|
title: __("youtube.title")
|
||||||
|
}) %}
|
||||||
Usage in your templates:
|
<p class="prose">{{ __("youtube.widget.description") }}</p>
|
||||||
{% include "@indiekit-endpoint-youtube-widget.njk" %}
|
<div class="button-grid">
|
||||||
|
{{ button({
|
||||||
Requires youtube data to be fetched and passed to the template context
|
classes: "button--secondary-on-offset",
|
||||||
#}
|
href: application.youtubeEndpoint or "/youtube",
|
||||||
|
text: __("youtube.widget.view")
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endcall %}
|
||||||
{% 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>
|
|
||||||
|
|||||||
3
index.js
3
index.js
@@ -15,8 +15,11 @@ const publicRouter = express.Router();
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
mountPath: "/youtube",
|
mountPath: "/youtube",
|
||||||
apiKey: process.env.YOUTUBE_API_KEY,
|
apiKey: process.env.YOUTUBE_API_KEY,
|
||||||
|
// Single channel (backward compatible)
|
||||||
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
||||||
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
|
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
|
||||||
|
// Multiple channels support: array of {id, handle, name}
|
||||||
|
channels: null,
|
||||||
cacheTtl: 300_000, // 5 minutes
|
cacheTtl: 300_000, // 5 minutes
|
||||||
liveCacheTtl: 60_000, // 1 minute for live status
|
liveCacheTtl: 60_000, // 1 minute for live status
|
||||||
limits: {
|
limits: {
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { YouTubeClient } from "../youtube-client.js";
|
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
|
* Channel controller
|
||||||
*/
|
*/
|
||||||
export const channelController = {
|
export const channelController = {
|
||||||
/**
|
/**
|
||||||
* Get channel info (JSON API)
|
* Get channel info (JSON API)
|
||||||
|
* Returns array of channels if multiple configured
|
||||||
* @type {import("express").RequestHandler}
|
* @type {import("express").RequestHandler}
|
||||||
*/
|
*/
|
||||||
async api(request, response) {
|
async api(request, response) {
|
||||||
@@ -16,28 +37,58 @@ export const channelController = {
|
|||||||
return response.status(500).json({ error: "Not configured" });
|
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" });
|
return response.status(500).json({ error: "Invalid configuration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new YouTubeClient({
|
// Fetch all channels in parallel
|
||||||
apiKey,
|
const channelPromises = channelConfigs.map(async (channelConfig) => {
|
||||||
channelId,
|
const client = new YouTubeClient({
|
||||||
channelHandle,
|
apiKey,
|
||||||
cacheTtl,
|
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 channel = await client.getChannelInfo();
|
const channelsData = await Promise.all(channelPromises);
|
||||||
|
const channels = channelsData.filter(Boolean);
|
||||||
|
|
||||||
response.json({
|
// Return single channel for backward compatibility when only one configured
|
||||||
channel,
|
if (channelConfigs.length === 1) {
|
||||||
cached: true,
|
response.json({
|
||||||
});
|
channel: channels[0] || null,
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response.json({
|
||||||
|
channels,
|
||||||
|
channel: channels[0] || null, // Primary channel for backward compat
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[YouTube] Channel API error:", error);
|
console.error("[YouTube] Channel API error:", error);
|
||||||
response.status(500).json({ error: error.message });
|
response.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getChannelsFromConfig };
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { YouTubeClient } from "../youtube-client.js";
|
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
|
* Dashboard controller
|
||||||
*/
|
*/
|
||||||
@@ -19,7 +40,7 @@ export const dashboardController = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
|
const { apiKey, cacheTtl, limits } = youtubeConfig;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return response.render("youtube", {
|
return response.render("youtube", {
|
||||||
@@ -28,7 +49,9 @@ export const dashboardController = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!channelId && !channelHandle) {
|
const primaryChannel = getPrimaryChannel(youtubeConfig);
|
||||||
|
|
||||||
|
if (!primaryChannel) {
|
||||||
return response.render("youtube", {
|
return response.render("youtube", {
|
||||||
title: response.locals.__("youtube.title"),
|
title: response.locals.__("youtube.title"),
|
||||||
error: { message: response.locals.__("youtube.error.noChannel") },
|
error: { message: response.locals.__("youtube.error.noChannel") },
|
||||||
@@ -37,8 +60,8 @@ export const dashboardController = {
|
|||||||
|
|
||||||
const client = new YouTubeClient({
|
const client = new YouTubeClient({
|
||||||
apiKey,
|
apiKey,
|
||||||
channelId,
|
channelId: primaryChannel.id,
|
||||||
channelHandle,
|
channelHandle: primaryChannel.handle,
|
||||||
cacheTtl,
|
cacheTtl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,10 +116,16 @@ export const dashboardController = {
|
|||||||
return response.status(500).json({ error: "Not configured" });
|
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({
|
const client = new YouTubeClient({
|
||||||
apiKey: youtubeConfig.apiKey,
|
apiKey: youtubeConfig.apiKey,
|
||||||
channelId: youtubeConfig.channelId,
|
channelId: primaryChannel.id,
|
||||||
channelHandle: youtubeConfig.channelHandle,
|
channelHandle: primaryChannel.handle,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear cache and refetch
|
// Clear cache and refetch
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { YouTubeClient } from "../youtube-client.js";
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
import { getChannelsFromConfig } from "./channel.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live status controller
|
* Live status controller
|
||||||
@@ -8,6 +9,7 @@ export const liveController = {
|
|||||||
* Get live status (JSON API)
|
* Get live status (JSON API)
|
||||||
* Uses efficient method (checking recent videos) by default
|
* Uses efficient method (checking recent videos) by default
|
||||||
* Use ?full=true for full search (costs 100 quota units)
|
* Use ?full=true for full search (costs 100 quota units)
|
||||||
|
* Returns live status for all configured channels
|
||||||
* @type {import("express").RequestHandler}
|
* @type {import("express").RequestHandler}
|
||||||
*/
|
*/
|
||||||
async api(request, response) {
|
async api(request, response) {
|
||||||
@@ -18,44 +20,87 @@ export const liveController = {
|
|||||||
return response.status(500).json({ error: "Not configured" });
|
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" });
|
return response.status(500).json({ error: "Invalid configuration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new YouTubeClient({
|
const useFullSearch = request.query.full === "true";
|
||||||
apiKey,
|
|
||||||
channelId,
|
// Fetch live status from all channels in parallel
|
||||||
channelHandle,
|
const livePromises = channelConfigs.map(async (channelConfig) => {
|
||||||
liveCacheTtl,
|
const client = new YouTubeClient({
|
||||||
|
apiKey,
|
||||||
|
channelId: channelConfig.id,
|
||||||
|
channelHandle: channelConfig.handle,
|
||||||
|
liveCacheTtl,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const liveStatus = useFullSearch
|
||||||
|
? await client.getLiveStatus()
|
||||||
|
: await client.getLiveStatusEfficient();
|
||||||
|
|
||||||
|
if (liveStatus) {
|
||||||
|
return {
|
||||||
|
channelConfigName: channelConfig.name,
|
||||||
|
isLive: liveStatus.isLive || false,
|
||||||
|
isUpcoming: liveStatus.isUpcoming || false,
|
||||||
|
stream: {
|
||||||
|
videoId: liveStatus.videoId,
|
||||||
|
title: liveStatus.title,
|
||||||
|
thumbnail: liveStatus.thumbnail,
|
||||||
|
url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
|
||||||
|
scheduledStart: liveStatus.scheduledStart,
|
||||||
|
actualStart: liveStatus.actualStart,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use full search only if explicitly requested
|
const liveStatuses = await Promise.all(livePromises);
|
||||||
const useFullSearch = request.query.full === "true";
|
|
||||||
const liveStatus = useFullSearch
|
|
||||||
? await client.getLiveStatus()
|
|
||||||
: await client.getLiveStatusEfficient();
|
|
||||||
|
|
||||||
if (liveStatus) {
|
// For single channel, return flat response (backward compatible)
|
||||||
|
if (channelConfigs.length === 1) {
|
||||||
|
const status = liveStatuses[0];
|
||||||
response.json({
|
response.json({
|
||||||
isLive: liveStatus.isLive || false,
|
isLive: status.isLive,
|
||||||
isUpcoming: liveStatus.isUpcoming || false,
|
isUpcoming: status.isUpcoming,
|
||||||
stream: {
|
stream: status.stream,
|
||||||
videoId: liveStatus.videoId,
|
|
||||||
title: liveStatus.title,
|
|
||||||
thumbnail: liveStatus.thumbnail,
|
|
||||||
url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
|
|
||||||
scheduledStart: liveStatus.scheduledStart,
|
|
||||||
actualStart: liveStatus.actualStart,
|
|
||||||
},
|
|
||||||
cached: true,
|
cached: true,
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
response.json({
|
||||||
isLive: false,
|
// Backward compat: primary live status (prefer live over upcoming)
|
||||||
isUpcoming: false,
|
isLive: !!anyLive,
|
||||||
stream: null,
|
isUpcoming: !anyLive && !!anyUpcoming,
|
||||||
|
stream: anyLive?.stream || anyUpcoming?.stream || null,
|
||||||
|
// Multi-channel data
|
||||||
|
liveStatuses,
|
||||||
cached: true,
|
cached: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { YouTubeClient } from "../youtube-client.js";
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
import { getChannelsFromConfig } from "./channel.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Videos controller
|
* Videos controller
|
||||||
@@ -6,6 +7,7 @@ import { YouTubeClient } from "../youtube-client.js";
|
|||||||
export const videosController = {
|
export const videosController = {
|
||||||
/**
|
/**
|
||||||
* Get latest videos (JSON API)
|
* Get latest videos (JSON API)
|
||||||
|
* Returns videos from all configured channels
|
||||||
* @type {import("express").RequestHandler}
|
* @type {import("express").RequestHandler}
|
||||||
*/
|
*/
|
||||||
async api(request, response) {
|
async api(request, response) {
|
||||||
@@ -16,31 +18,73 @@ export const videosController = {
|
|||||||
return response.status(500).json({ error: "Not configured" });
|
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" });
|
return response.status(500).json({ error: "Invalid configuration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new YouTubeClient({
|
|
||||||
apiKey,
|
|
||||||
channelId,
|
|
||||||
channelHandle,
|
|
||||||
cacheTtl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxResults = Math.min(
|
const maxResults = Math.min(
|
||||||
parseInt(request.query.limit, 10) || limits?.videos || 10,
|
parseInt(request.query.limit, 10) || limits?.videos || 10,
|
||||||
50
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
response.json({
|
try {
|
||||||
videos,
|
const videos = await client.getLatestVideos(maxResults);
|
||||||
count: videos.length,
|
// Add channel info to each video
|
||||||
cached: true,
|
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) {
|
} catch (error) {
|
||||||
console.error("[YouTube] Videos API error:", error);
|
console.error("[YouTube] Videos API error:", error);
|
||||||
response.status(500).json({ error: error.message });
|
response.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-youtube",
|
"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.",
|
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -188,8 +188,8 @@
|
|||||||
<div class="yt-channel__info">
|
<div class="yt-channel__info">
|
||||||
<h2 class="yt-channel__name">{{ channel.title }}</h2>
|
<h2 class="yt-channel__name">{{ channel.title }}</h2>
|
||||||
<div class="yt-channel__stats">
|
<div class="yt-channel__stats">
|
||||||
<span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span>
|
<span>{{ channel.subscriberCount }} {{ __("youtube.subscribers") }}</span>
|
||||||
<span>{{ channel.videoCount | localeNumber }} videos</span>
|
<span>{{ channel.videoCount }} videos</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if isLive %}
|
{% if isLive %}
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
|
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="yt-video__meta">
|
<div class="yt-video__meta">
|
||||||
{{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
|
{{ video.viewCount }} {{ __("youtube.views") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user