mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
Initial commit: YouTube channel endpoint for Indiekit
Features: - Display latest videos from any YouTube channel - Live streaming status with animated badge - Upcoming stream detection - Admin dashboard with video grid - Public JSON API for Eleventy integration - Quota-efficient API usage (playlist method) - Smart caching (5min videos, 1min live status) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
191
README.md
Normal file
191
README.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# @rmdes/indiekit-endpoint-youtube
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/@rmdes/indiekit-endpoint-youtube)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
YouTube channel endpoint for [Indiekit](https://getindiekit.com/).
|
||||||
|
|
||||||
|
Display latest videos and live streaming status from any YouTube channel on your IndieWeb site.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install from npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @rmdes/indiekit-endpoint-youtube
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Admin Dashboard** - Overview of channel with latest videos in Indiekit's admin UI
|
||||||
|
- **Live Status** - Shows when channel is live streaming (with animated badge)
|
||||||
|
- **Upcoming Streams** - Display scheduled upcoming live streams
|
||||||
|
- **Latest Videos** - Grid of recent uploads with thumbnails, duration, view counts
|
||||||
|
- **Public JSON API** - For integration with static site generators like Eleventy
|
||||||
|
- **Quota Efficient** - Uses YouTube API efficiently (playlist method vs search)
|
||||||
|
- **Smart Caching** - Respects API rate limits while staying current
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `indiekit.config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import YouTubeEndpoint from "@rmdes/indiekit-endpoint-youtube";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
new YouTubeEndpoint({
|
||||||
|
mountPath: "/youtube",
|
||||||
|
apiKey: process.env.YOUTUBE_API_KEY,
|
||||||
|
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
||||||
|
// OR use channel handle instead:
|
||||||
|
// channelHandle: "@YourChannel",
|
||||||
|
cacheTtl: 300_000, // 5 minutes
|
||||||
|
liveCacheTtl: 60_000, // 1 minute for live status
|
||||||
|
limits: {
|
||||||
|
videos: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `YOUTUBE_API_KEY` | Yes | YouTube Data API v3 key |
|
||||||
|
| `YOUTUBE_CHANNEL_ID` | Yes* | Channel ID (starts with `UC...`) |
|
||||||
|
| `YOUTUBE_CHANNEL_HANDLE` | Yes* | Channel handle (e.g., `@YourChannel`) |
|
||||||
|
|
||||||
|
*Either `channelId` or `channelHandle` is required.
|
||||||
|
|
||||||
|
### Getting a YouTube API Key
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Create a new project or select an existing one
|
||||||
|
3. Enable the "YouTube Data API v3"
|
||||||
|
4. Go to Credentials > Create Credentials > API Key
|
||||||
|
5. (Optional) Restrict the key to YouTube Data API only
|
||||||
|
|
||||||
|
### Finding Your Channel ID
|
||||||
|
|
||||||
|
- Go to your YouTube channel
|
||||||
|
- The URL will be `youtube.com/channel/UC...` - the `UC...` part is your channel ID
|
||||||
|
- Or use a tool like [Comment Picker](https://commentpicker.com/youtube-channel-id.php)
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Admin Routes (require authentication)
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `GET /youtube/` | Dashboard with channel info, live status, latest videos |
|
||||||
|
| `POST /youtube/refresh` | Clear cache and refresh data |
|
||||||
|
|
||||||
|
### Public API Routes (JSON)
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `GET /youtube/api/videos` | Latest videos (supports `?limit=N`) |
|
||||||
|
| `GET /youtube/api/channel` | Channel information |
|
||||||
|
| `GET /youtube/api/live` | Live streaming status |
|
||||||
|
|
||||||
|
### Example: Eleventy Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// _data/youtube.js
|
||||||
|
import EleventyFetch from "@11ty/eleventy-fetch";
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
const baseUrl = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
|
const [channel, videos, live] = await Promise.all([
|
||||||
|
EleventyFetch(`${baseUrl}/youtube/api/channel`, { duration: "15m", type: "json" }),
|
||||||
|
EleventyFetch(`${baseUrl}/youtube/api/videos?limit=6`, { duration: "5m", type: "json" }),
|
||||||
|
EleventyFetch(`${baseUrl}/youtube/api/live`, { duration: "1m", type: "json" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: channel.channel,
|
||||||
|
videos: videos.videos,
|
||||||
|
isLive: live.isLive,
|
||||||
|
liveStream: live.stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Response Examples
|
||||||
|
|
||||||
|
### GET /youtube/api/live
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isLive": true,
|
||||||
|
"isUpcoming": false,
|
||||||
|
"stream": {
|
||||||
|
"videoId": "abc123",
|
||||||
|
"title": "Live Stream Title",
|
||||||
|
"thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
|
||||||
|
"url": "https://www.youtube.com/watch?v=abc123"
|
||||||
|
},
|
||||||
|
"cached": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /youtube/api/videos
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"videos": [
|
||||||
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"title": "Video Title",
|
||||||
|
"thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
|
||||||
|
"duration": 3661,
|
||||||
|
"durationFormatted": "1:01:01",
|
||||||
|
"viewCount": 12345,
|
||||||
|
"publishedAt": "2024-01-15T10:00:00Z",
|
||||||
|
"url": "https://www.youtube.com/watch?v=abc123",
|
||||||
|
"isLive": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 10,
|
||||||
|
"cached": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `mountPath` | `/youtube` | URL path for the endpoint |
|
||||||
|
| `apiKey` | - | YouTube Data API key |
|
||||||
|
| `channelId` | - | Channel ID (UC...) |
|
||||||
|
| `channelHandle` | - | Channel handle (@...) |
|
||||||
|
| `cacheTtl` | `300000` | Cache TTL in ms (5 min) |
|
||||||
|
| `liveCacheTtl` | `60000` | Live status cache TTL in ms (1 min) |
|
||||||
|
| `limits.videos` | `10` | Number of videos to fetch |
|
||||||
|
|
||||||
|
## Quota Efficiency
|
||||||
|
|
||||||
|
YouTube Data API has a daily quota (10,000 units by default). This plugin is optimized:
|
||||||
|
|
||||||
|
| Operation | Quota Cost | Method |
|
||||||
|
|-----------|------------|--------|
|
||||||
|
| Get videos | 2 units | Uses uploads playlist (not search) |
|
||||||
|
| Get channel | 1 unit | Cached for 24 hours |
|
||||||
|
| Check live status | 2 units | Checks recent videos (efficient) |
|
||||||
|
| Full live search | 100 units | Only when explicitly requested |
|
||||||
|
|
||||||
|
With default settings (5-min cache), you'll use ~600 units/day for video checks.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Indiekit >= 1.0.0-beta.25
|
||||||
|
- YouTube Data API v3 enabled
|
||||||
|
- Valid API key with YouTube Data API access
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
135
includes/@indiekit-endpoint-youtube-widget.njk
Normal file
135
includes/@indiekit-endpoint-youtube-widget.njk
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{#
|
||||||
|
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 %}
|
||||||
|
</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>
|
||||||
88
index.js
Normal file
88
index.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { dashboardController } from "./lib/controllers/dashboard.js";
|
||||||
|
import { videosController } from "./lib/controllers/videos.js";
|
||||||
|
import { channelController } from "./lib/controllers/channel.js";
|
||||||
|
import { liveController } from "./lib/controllers/live.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const protectedRouter = express.Router();
|
||||||
|
const publicRouter = express.Router();
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
mountPath: "/youtube",
|
||||||
|
apiKey: process.env.YOUTUBE_API_KEY,
|
||||||
|
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
||||||
|
channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
|
||||||
|
cacheTtl: 300_000, // 5 minutes
|
||||||
|
liveCacheTtl: 60_000, // 1 minute for live status
|
||||||
|
limits: {
|
||||||
|
videos: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class YouTubeEndpoint {
|
||||||
|
name = "YouTube channel endpoint";
|
||||||
|
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = { ...defaults, ...options };
|
||||||
|
this.mountPath = this.options.mountPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
get environment() {
|
||||||
|
return ["YOUTUBE_API_KEY", "YOUTUBE_CHANNEL_ID", "YOUTUBE_CHANNEL_HANDLE"];
|
||||||
|
}
|
||||||
|
|
||||||
|
get localesDirectory() {
|
||||||
|
return path.join(__dirname, "locales");
|
||||||
|
}
|
||||||
|
|
||||||
|
get navigationItems() {
|
||||||
|
return {
|
||||||
|
href: this.options.mountPath,
|
||||||
|
text: "youtube.title",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get shortcutItems() {
|
||||||
|
return {
|
||||||
|
url: this.options.mountPath,
|
||||||
|
name: "youtube.videos",
|
||||||
|
iconName: "syndicate",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected routes (require authentication)
|
||||||
|
* Admin dashboard
|
||||||
|
*/
|
||||||
|
get routes() {
|
||||||
|
protectedRouter.get("/", dashboardController.get);
|
||||||
|
protectedRouter.post("/refresh", dashboardController.refresh);
|
||||||
|
|
||||||
|
return protectedRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public routes (no authentication required)
|
||||||
|
* JSON API endpoints for Eleventy frontend
|
||||||
|
*/
|
||||||
|
get routesPublic() {
|
||||||
|
publicRouter.get("/api/videos", videosController.api);
|
||||||
|
publicRouter.get("/api/channel", channelController.api);
|
||||||
|
publicRouter.get("/api/live", liveController.api);
|
||||||
|
|
||||||
|
return publicRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(Indiekit) {
|
||||||
|
Indiekit.addEndpoint(this);
|
||||||
|
|
||||||
|
// Store YouTube config in application for controller access
|
||||||
|
Indiekit.config.application.youtubeConfig = this.options;
|
||||||
|
Indiekit.config.application.youtubeEndpoint = this.mountPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/controllers/channel.js
Normal file
43
lib/controllers/channel.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel controller
|
||||||
|
*/
|
||||||
|
export const channelController = {
|
||||||
|
/**
|
||||||
|
* Get channel info (JSON API)
|
||||||
|
* @type {import("express").RequestHandler}
|
||||||
|
*/
|
||||||
|
async api(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
|
||||||
|
if (!youtubeConfig) {
|
||||||
|
return response.status(500).json({ error: "Not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey, channelId, channelHandle, cacheTtl } = youtubeConfig;
|
||||||
|
|
||||||
|
if (!apiKey || (!channelId && !channelHandle)) {
|
||||||
|
return response.status(500).json({ error: "Invalid configuration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new YouTubeClient({
|
||||||
|
apiKey,
|
||||||
|
channelId,
|
||||||
|
channelHandle,
|
||||||
|
cacheTtl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = await client.getChannelInfo();
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
channel,
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Channel API error:", error);
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
120
lib/controllers/dashboard.js
Normal file
120
lib/controllers/dashboard.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard controller
|
||||||
|
*/
|
||||||
|
export const dashboardController = {
|
||||||
|
/**
|
||||||
|
* Render dashboard page
|
||||||
|
* @type {import("express").RequestHandler}
|
||||||
|
*/
|
||||||
|
async get(request, response, next) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig, youtubeEndpoint } = request.app.locals.application;
|
||||||
|
|
||||||
|
if (!youtubeConfig) {
|
||||||
|
return response.status(500).render("youtube", {
|
||||||
|
title: "YouTube",
|
||||||
|
error: { message: "YouTube endpoint not configured" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return response.render("youtube", {
|
||||||
|
title: response.locals.__("youtube.title"),
|
||||||
|
error: { message: response.locals.__("youtube.error.noApiKey") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelId && !channelHandle) {
|
||||||
|
return response.render("youtube", {
|
||||||
|
title: response.locals.__("youtube.title"),
|
||||||
|
error: { message: response.locals.__("youtube.error.noChannel") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new YouTubeClient({
|
||||||
|
apiKey,
|
||||||
|
channelId,
|
||||||
|
channelHandle,
|
||||||
|
cacheTtl,
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = null;
|
||||||
|
let videos = [];
|
||||||
|
let liveStatus = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
[channel, videos, liveStatus] = await Promise.all([
|
||||||
|
client.getChannelInfo(),
|
||||||
|
client.getLatestVideos(limits?.videos || 6),
|
||||||
|
client.getLiveStatusEfficient(),
|
||||||
|
]);
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error("[YouTube] API error:", apiError.message);
|
||||||
|
return response.render("youtube", {
|
||||||
|
title: response.locals.__("youtube.title"),
|
||||||
|
error: { message: response.locals.__("youtube.error.connection") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine public frontend URL
|
||||||
|
const publicUrl = youtubeEndpoint
|
||||||
|
? youtubeEndpoint.replace(/api$/, "")
|
||||||
|
: "/youtube";
|
||||||
|
|
||||||
|
response.render("youtube", {
|
||||||
|
title: response.locals.__("youtube.title"),
|
||||||
|
channel,
|
||||||
|
videos: videos.slice(0, limits?.videos || 6),
|
||||||
|
liveStatus,
|
||||||
|
isLive: liveStatus?.isLive || false,
|
||||||
|
isUpcoming: liveStatus?.isUpcoming || false,
|
||||||
|
publicUrl,
|
||||||
|
mountPath: request.baseUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Dashboard error:", error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger manual cache refresh
|
||||||
|
* @type {import("express").RequestHandler}
|
||||||
|
*/
|
||||||
|
async refresh(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
|
||||||
|
if (!youtubeConfig) {
|
||||||
|
return response.status(500).json({ error: "Not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new YouTubeClient({
|
||||||
|
apiKey: youtubeConfig.apiKey,
|
||||||
|
channelId: youtubeConfig.channelId,
|
||||||
|
channelHandle: youtubeConfig.channelHandle,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear cache and refetch
|
||||||
|
client.clearCache();
|
||||||
|
const [channel, videos] = await Promise.all([
|
||||||
|
client.getChannelInfo(),
|
||||||
|
client.getLatestVideos(youtubeConfig.limits?.videos || 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
channel: channel.title,
|
||||||
|
videoCount: videos.length,
|
||||||
|
message: `Refreshed ${videos.length} videos from ${channel.title}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Refresh error:", error);
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
67
lib/controllers/live.js
Normal file
67
lib/controllers/live.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live status controller
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
* @type {import("express").RequestHandler}
|
||||||
|
*/
|
||||||
|
async api(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
|
||||||
|
if (!youtubeConfig) {
|
||||||
|
return response.status(500).json({ error: "Not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey, channelId, channelHandle, liveCacheTtl } = youtubeConfig;
|
||||||
|
|
||||||
|
if (!apiKey || (!channelId && !channelHandle)) {
|
||||||
|
return response.status(500).json({ error: "Invalid configuration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new YouTubeClient({
|
||||||
|
apiKey,
|
||||||
|
channelId,
|
||||||
|
channelHandle,
|
||||||
|
liveCacheTtl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use full search only if explicitly requested
|
||||||
|
const useFullSearch = request.query.full === "true";
|
||||||
|
const liveStatus = useFullSearch
|
||||||
|
? await client.getLiveStatus()
|
||||||
|
: await client.getLiveStatusEfficient();
|
||||||
|
|
||||||
|
if (liveStatus) {
|
||||||
|
response.json({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response.json({
|
||||||
|
isLive: false,
|
||||||
|
isUpcoming: false,
|
||||||
|
stream: null,
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Live API error:", error);
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
49
lib/controllers/videos.js
Normal file
49
lib/controllers/videos.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { YouTubeClient } from "../youtube-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Videos controller
|
||||||
|
*/
|
||||||
|
export const videosController = {
|
||||||
|
/**
|
||||||
|
* Get latest videos (JSON API)
|
||||||
|
* @type {import("express").RequestHandler}
|
||||||
|
*/
|
||||||
|
async api(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
|
||||||
|
if (!youtubeConfig) {
|
||||||
|
return response.status(500).json({ error: "Not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
|
||||||
|
|
||||||
|
if (!apiKey || (!channelId && !channelHandle)) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
videos,
|
||||||
|
count: videos.length,
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Videos API error:", error);
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
330
lib/youtube-client.js
Normal file
330
lib/youtube-client.js
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Data API v3 client
|
||||||
|
* Optimized for quota efficiency (10,000 units/day default)
|
||||||
|
*
|
||||||
|
* Quota costs:
|
||||||
|
* - channels.list: 1 unit
|
||||||
|
* - playlistItems.list: 1 unit
|
||||||
|
* - videos.list: 1 unit
|
||||||
|
* - search.list: 100 units (avoid!)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = "https://www.googleapis.com/youtube/v3";
|
||||||
|
|
||||||
|
// In-memory cache
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached data or null if expired
|
||||||
|
* @param {string} key - Cache key
|
||||||
|
* @param {number} ttl - TTL in milliseconds
|
||||||
|
* @returns {any|null}
|
||||||
|
*/
|
||||||
|
function getCache(key, ttl) {
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (!cached) return null;
|
||||||
|
if (Date.now() - cached.time > ttl) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cache data
|
||||||
|
* @param {string} key - Cache key
|
||||||
|
* @param {any} data - Data to cache
|
||||||
|
*/
|
||||||
|
function setCache(key, data) {
|
||||||
|
cache.set(key, { data, time: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YouTubeClient {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.apiKey - YouTube Data API key
|
||||||
|
* @param {string} options.channelId - Channel ID (UC...)
|
||||||
|
* @param {string} [options.channelHandle] - Channel handle (@...)
|
||||||
|
* @param {number} [options.cacheTtl] - Cache TTL in ms (default: 5 min)
|
||||||
|
* @param {number} [options.liveCacheTtl] - Live status cache TTL in ms (default: 1 min)
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.apiKey = options.apiKey;
|
||||||
|
this.channelId = options.channelId;
|
||||||
|
this.channelHandle = options.channelHandle;
|
||||||
|
this.cacheTtl = options.cacheTtl || 300_000; // 5 minutes
|
||||||
|
this.liveCacheTtl = options.liveCacheTtl || 60_000; // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request
|
||||||
|
* @param {string} endpoint - API endpoint
|
||||||
|
* @param {object} params - Query parameters
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
async request(endpoint, params = {}) {
|
||||||
|
const url = new URL(`${API_BASE}/${endpoint}`);
|
||||||
|
url.searchParams.set("key", this.apiKey);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
const message = error.error?.message || response.statusText;
|
||||||
|
throw new Error(`YouTube API error: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channel info (cached)
|
||||||
|
* @returns {Promise<object>} - Channel info including uploads playlist ID
|
||||||
|
*/
|
||||||
|
async getChannelInfo() {
|
||||||
|
const cacheKey = `channel:${this.channelId || this.channelHandle}`;
|
||||||
|
const cached = getCache(cacheKey, 86_400_000); // 24 hour cache for channel info
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
part: "snippet,contentDetails,statistics,brandingSettings",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use channelId if available, otherwise resolve from handle
|
||||||
|
if (this.channelId) {
|
||||||
|
params.id = this.channelId;
|
||||||
|
} else if (this.channelHandle) {
|
||||||
|
// Remove @ if present
|
||||||
|
const handle = this.channelHandle.replace(/^@/, "");
|
||||||
|
params.forHandle = handle;
|
||||||
|
} else {
|
||||||
|
throw new Error("Either channelId or channelHandle is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.request("channels", params);
|
||||||
|
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
throw new Error("Channel not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = data.items[0];
|
||||||
|
const result = {
|
||||||
|
id: channel.id,
|
||||||
|
title: channel.snippet.title,
|
||||||
|
description: channel.snippet.description,
|
||||||
|
customUrl: channel.snippet.customUrl,
|
||||||
|
thumbnail: channel.snippet.thumbnails?.medium?.url,
|
||||||
|
subscriberCount: parseInt(channel.statistics.subscriberCount, 10) || 0,
|
||||||
|
videoCount: parseInt(channel.statistics.videoCount, 10) || 0,
|
||||||
|
viewCount: parseInt(channel.statistics.viewCount, 10) || 0,
|
||||||
|
uploadsPlaylistId: channel.contentDetails?.relatedPlaylists?.uploads,
|
||||||
|
bannerUrl: channel.brandingSettings?.image?.bannerExternalUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest videos from channel
|
||||||
|
* Uses uploads playlist for quota efficiency (1 unit vs 100 for search)
|
||||||
|
* @param {number} [maxResults=10] - Number of videos to fetch
|
||||||
|
* @returns {Promise<Array>} - List of videos
|
||||||
|
*/
|
||||||
|
async getLatestVideos(maxResults = 10) {
|
||||||
|
const cacheKey = `videos:${this.channelId || this.channelHandle}:${maxResults}`;
|
||||||
|
const cached = getCache(cacheKey, this.cacheTtl);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Get channel info to get uploads playlist ID
|
||||||
|
const channel = await this.getChannelInfo();
|
||||||
|
if (!channel.uploadsPlaylistId) {
|
||||||
|
throw new Error("Could not find uploads playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get playlist items (1 quota unit)
|
||||||
|
const playlistData = await this.request("playlistItems", {
|
||||||
|
part: "snippet,contentDetails",
|
||||||
|
playlistId: channel.uploadsPlaylistId,
|
||||||
|
maxResults: Math.min(maxResults, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!playlistData.items || playlistData.items.length === 0) {
|
||||||
|
setCache(cacheKey, []);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video details for duration, view count, live status (1 quota unit)
|
||||||
|
const videoIds = playlistData.items
|
||||||
|
.map((item) => item.contentDetails.videoId)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
const videosData = await this.request("videos", {
|
||||||
|
part: "snippet,contentDetails,statistics,liveStreamingDetails",
|
||||||
|
id: videoIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videos = videosData.items.map((video) => this.formatVideo(video));
|
||||||
|
|
||||||
|
setCache(cacheKey, videos);
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if channel is currently live
|
||||||
|
* @returns {Promise<object|null>} - Live stream info or null
|
||||||
|
*/
|
||||||
|
async getLiveStatus() {
|
||||||
|
const cacheKey = `live:${this.channelId || this.channelHandle}`;
|
||||||
|
const cached = getCache(cacheKey, this.liveCacheTtl);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
// Get channel info first to ensure we have the channel ID
|
||||||
|
const channel = await this.getChannelInfo();
|
||||||
|
|
||||||
|
// Search for live broadcasts (costs 100 quota units - use sparingly)
|
||||||
|
// Only do this check periodically
|
||||||
|
try {
|
||||||
|
const data = await this.request("search", {
|
||||||
|
part: "snippet",
|
||||||
|
channelId: channel.id,
|
||||||
|
eventType: "live",
|
||||||
|
type: "video",
|
||||||
|
maxResults: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
const liveItem = data.items[0];
|
||||||
|
const result = {
|
||||||
|
isLive: true,
|
||||||
|
videoId: liveItem.id.videoId,
|
||||||
|
title: liveItem.snippet.title,
|
||||||
|
thumbnail: liveItem.snippet.thumbnails?.medium?.url,
|
||||||
|
startedAt: liveItem.snippet.publishedAt,
|
||||||
|
};
|
||||||
|
setCache(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Live status check error:", error.message);
|
||||||
|
setCache(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get live status efficiently by checking recent videos
|
||||||
|
* This uses less quota than search.list
|
||||||
|
* @returns {Promise<object|null>} - Live stream info or null
|
||||||
|
*/
|
||||||
|
async getLiveStatusEfficient() {
|
||||||
|
const cacheKey = `live-eff:${this.channelId || this.channelHandle}`;
|
||||||
|
const cached = getCache(cacheKey, this.liveCacheTtl);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
// Get latest videos and check if any are live
|
||||||
|
const videos = await this.getLatestVideos(5);
|
||||||
|
const liveVideo = videos.find((v) => v.isLive || v.isUpcoming);
|
||||||
|
|
||||||
|
if (liveVideo) {
|
||||||
|
const result = {
|
||||||
|
isLive: liveVideo.isLive,
|
||||||
|
isUpcoming: liveVideo.isUpcoming,
|
||||||
|
videoId: liveVideo.id,
|
||||||
|
title: liveVideo.title,
|
||||||
|
thumbnail: liveVideo.thumbnail,
|
||||||
|
scheduledStart: liveVideo.scheduledStart,
|
||||||
|
actualStart: liveVideo.actualStart,
|
||||||
|
};
|
||||||
|
setCache(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format video data
|
||||||
|
* @param {object} video - Raw video data from API
|
||||||
|
* @returns {object} - Formatted video
|
||||||
|
*/
|
||||||
|
formatVideo(video) {
|
||||||
|
const liveDetails = video.liveStreamingDetails;
|
||||||
|
const isLive = liveDetails?.actualStartTime && !liveDetails?.actualEndTime;
|
||||||
|
const isUpcoming = liveDetails?.scheduledStartTime && !liveDetails?.actualStartTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
title: video.snippet.title,
|
||||||
|
description: video.snippet.description,
|
||||||
|
thumbnail: video.snippet.thumbnails?.medium?.url,
|
||||||
|
thumbnailHigh: video.snippet.thumbnails?.high?.url,
|
||||||
|
channelId: video.snippet.channelId,
|
||||||
|
channelTitle: video.snippet.channelTitle,
|
||||||
|
publishedAt: video.snippet.publishedAt,
|
||||||
|
duration: this.parseDuration(video.contentDetails?.duration),
|
||||||
|
durationFormatted: this.formatDuration(video.contentDetails?.duration),
|
||||||
|
viewCount: parseInt(video.statistics?.viewCount, 10) || 0,
|
||||||
|
likeCount: parseInt(video.statistics?.likeCount, 10) || 0,
|
||||||
|
commentCount: parseInt(video.statistics?.commentCount, 10) || 0,
|
||||||
|
isLive,
|
||||||
|
isUpcoming,
|
||||||
|
scheduledStart: liveDetails?.scheduledStartTime,
|
||||||
|
actualStart: liveDetails?.actualStartTime,
|
||||||
|
concurrentViewers: liveDetails?.concurrentViewers
|
||||||
|
? parseInt(liveDetails.concurrentViewers, 10)
|
||||||
|
: null,
|
||||||
|
url: `https://www.youtube.com/watch?v=${video.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ISO 8601 duration to seconds
|
||||||
|
* @param {string} duration - ISO 8601 duration (PT1H2M3S)
|
||||||
|
* @returns {number} - Duration in seconds
|
||||||
|
*/
|
||||||
|
parseDuration(duration) {
|
||||||
|
if (!duration) return 0;
|
||||||
|
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||||
|
if (!match) return 0;
|
||||||
|
const hours = parseInt(match[1], 10) || 0;
|
||||||
|
const minutes = parseInt(match[2], 10) || 0;
|
||||||
|
const seconds = parseInt(match[3], 10) || 0;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration for display
|
||||||
|
* @param {string} duration - ISO 8601 duration
|
||||||
|
* @returns {string} - Formatted duration (1:02:03 or 2:03)
|
||||||
|
*/
|
||||||
|
formatDuration(duration) {
|
||||||
|
const totalSeconds = this.parseDuration(duration);
|
||||||
|
if (totalSeconds === 0) return "0:00";
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all caches
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
locales/en.json
Normal file
26
locales/en.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"youtube": {
|
||||||
|
"title": "YouTube",
|
||||||
|
"videos": "Latest Videos",
|
||||||
|
"channel": "Channel",
|
||||||
|
"live": "Live Now",
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"offline": "Offline",
|
||||||
|
"subscribers": "subscribers",
|
||||||
|
"views": "views",
|
||||||
|
"watchNow": "Watch Now",
|
||||||
|
"viewChannel": "View Channel",
|
||||||
|
"viewAll": "View All Videos",
|
||||||
|
"noVideos": "No videos found",
|
||||||
|
"refreshed": "Refreshed",
|
||||||
|
"error": {
|
||||||
|
"noApiKey": "YouTube API key not configured",
|
||||||
|
"noChannel": "YouTube channel not specified",
|
||||||
|
"connection": "Could not connect to YouTube API"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"description": "View full channel on YouTube",
|
||||||
|
"view": "Open YouTube Channel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5593
package-lock.json
generated
Normal file
5593
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "@rmdes/indiekit-endpoint-youtube",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
|
||||||
|
"keywords": [
|
||||||
|
"indiekit",
|
||||||
|
"indiekit-plugin",
|
||||||
|
"indieweb",
|
||||||
|
"youtube",
|
||||||
|
"videos",
|
||||||
|
"live",
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/rmdes/indiekit-endpoint-youtube",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/rmdes/indiekit-endpoint-youtube/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/rmdes/indiekit-endpoint-youtube.git"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Ricardo Mendes",
|
||||||
|
"url": "https://rmendes.net"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"includes",
|
||||||
|
"lib",
|
||||||
|
"locales",
|
||||||
|
"views",
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@indiekit/error": "^1.0.0-beta.25",
|
||||||
|
"express": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@indiekit/indiekit": ">=1.0.0-beta.25"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
281
views/youtube.njk
Normal file
281
views/youtube.njk
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
{% extends "document.njk" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.yt-section { margin-bottom: 2rem; }
|
||||||
|
.yt-section h2 { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Channel header */
|
||||||
|
.yt-channel {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-offset, #f5f5f5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.yt-channel__avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.yt-channel__info { flex: 1; }
|
||||||
|
.yt-channel__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
.yt-channel__stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live status badge */
|
||||||
|
.yt-live-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.yt-live-badge--live {
|
||||||
|
background: #ff0000;
|
||||||
|
color: white;
|
||||||
|
animation: yt-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
.yt-live-badge--upcoming {
|
||||||
|
background: #065fd4;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.yt-live-badge--offline {
|
||||||
|
background: var(--color-offset, #e5e5e5);
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
}
|
||||||
|
@keyframes yt-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
.yt-live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live stream card */
|
||||||
|
.yt-live-stream {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, #ff000010, #ff000005);
|
||||||
|
border: 1px solid #ff000030;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.yt-live-stream__thumb {
|
||||||
|
width: 160px;
|
||||||
|
height: 90px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.yt-live-stream__info { flex: 1; }
|
||||||
|
.yt-live-stream__title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.yt-live-stream__title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.yt-live-stream__title a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Video grid */
|
||||||
|
.yt-video-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.yt-video {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-offset, #f5f5f5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.yt-video__thumb-wrapper {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
.yt-video__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.yt-video__duration {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.yt-video__info {
|
||||||
|
padding: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.yt-video__title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.yt-video__title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.yt-video__title a:hover { text-decoration: underline; }
|
||||||
|
.yt-video__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Public link banner */
|
||||||
|
.yt-public-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-offset, #f5f5f5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.yt-public-link p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
{{ prose({ text: error.message }) }}
|
||||||
|
{% else %}
|
||||||
|
{# Channel Header #}
|
||||||
|
{% if channel %}
|
||||||
|
<div class="yt-channel">
|
||||||
|
{% if channel.thumbnail %}
|
||||||
|
<img src="{{ channel.thumbnail }}" alt="" class="yt-channel__avatar">
|
||||||
|
{% endif %}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if isLive %}
|
||||||
|
<span class="yt-live-badge yt-live-badge--live">
|
||||||
|
<span class="yt-live-dot"></span>
|
||||||
|
{{ __("youtube.live") }}
|
||||||
|
</span>
|
||||||
|
{% elif isUpcoming %}
|
||||||
|
<span class="yt-live-badge yt-live-badge--upcoming">
|
||||||
|
{{ __("youtube.upcoming") }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="yt-live-badge yt-live-badge--offline">
|
||||||
|
{{ __("youtube.offline") }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Live Stream (if live) #}
|
||||||
|
{% if liveStatus and (liveStatus.isLive or liveStatus.isUpcoming) %}
|
||||||
|
<section class="yt-section">
|
||||||
|
<h2>{% if liveStatus.isLive %}{{ __("youtube.live") }}{% else %}{{ __("youtube.upcoming") }}{% endif %}</h2>
|
||||||
|
<div class="yt-live-stream">
|
||||||
|
{% if liveStatus.thumbnail %}
|
||||||
|
<img src="{{ liveStatus.thumbnail }}" alt="" class="yt-live-stream__thumb">
|
||||||
|
{% endif %}
|
||||||
|
<div class="yt-live-stream__info">
|
||||||
|
<h3 class="yt-live-stream__title">
|
||||||
|
<a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
|
||||||
|
{{ liveStatus.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
{{ button({
|
||||||
|
href: "https://www.youtube.com/watch?v=" + liveStatus.videoId,
|
||||||
|
text: __("youtube.watchNow"),
|
||||||
|
target: "_blank"
|
||||||
|
}) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Latest Videos #}
|
||||||
|
<section class="yt-section">
|
||||||
|
<h2>{{ __("youtube.videos") }}</h2>
|
||||||
|
{% if videos and videos.length > 0 %}
|
||||||
|
<ul class="yt-video-grid">
|
||||||
|
{% for video in videos %}
|
||||||
|
<li class="yt-video">
|
||||||
|
<div class="yt-video__thumb-wrapper">
|
||||||
|
<a href="{{ video.url }}" target="_blank" rel="noopener">
|
||||||
|
<img src="{{ video.thumbnail }}" alt="" class="yt-video__thumb" loading="lazy">
|
||||||
|
</a>
|
||||||
|
{% if video.durationFormatted and not video.isLive %}
|
||||||
|
<span class="yt-video__duration">{{ video.durationFormatted }}</span>
|
||||||
|
{% elif video.isLive %}
|
||||||
|
<span class="yt-video__duration" style="background:#ff0000">LIVE</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="yt-video__info">
|
||||||
|
<h3 class="yt-video__title">
|
||||||
|
<a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
|
||||||
|
</h3>
|
||||||
|
<div class="yt-video__meta">
|
||||||
|
{{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("youtube.noVideos") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Link to YouTube channel #}
|
||||||
|
{% if channel %}
|
||||||
|
<div class="yt-public-link">
|
||||||
|
<p>{{ __("youtube.widget.description") }}</p>
|
||||||
|
{{ button({
|
||||||
|
href: "https://www.youtube.com/channel/" + channel.id,
|
||||||
|
text: __("youtube.widget.view"),
|
||||||
|
target: "_blank"
|
||||||
|
}) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user