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