@rmdes/indiekit-endpoint-youtube 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # @rmdes/indiekit-endpoint-youtube
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@rmdes/indiekit-endpoint-youtube.svg)](https://www.npmjs.com/package/@rmdes/indiekit-endpoint-youtube)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ YouTube channel endpoint for [Indiekit](https://getindiekit.com/).
7
+
8
+ Display latest videos and live streaming status from any YouTube channel on your IndieWeb site.
9
+
10
+ ## Installation
11
+
12
+ Install from npm:
13
+
14
+ ```bash
15
+ npm install @rmdes/indiekit-endpoint-youtube
16
+ ```
17
+
18
+ ## Features
19
+
20
+ - **Admin Dashboard** - Overview of channel with latest videos in Indiekit's admin UI
21
+ - **Live Status** - Shows when channel is live streaming (with animated badge)
22
+ - **Upcoming Streams** - Display scheduled upcoming live streams
23
+ - **Latest Videos** - Grid of recent uploads with thumbnails, duration, view counts
24
+ - **Public JSON API** - For integration with static site generators like Eleventy
25
+ - **Quota Efficient** - Uses YouTube API efficiently (playlist method vs search)
26
+ - **Smart Caching** - Respects API rate limits while staying current
27
+
28
+ ## Configuration
29
+
30
+ Add to your `indiekit.config.js`:
31
+
32
+ ```javascript
33
+ import YouTubeEndpoint from "@rmdes/indiekit-endpoint-youtube";
34
+
35
+ export default {
36
+ plugins: [
37
+ new YouTubeEndpoint({
38
+ mountPath: "/youtube",
39
+ apiKey: process.env.YOUTUBE_API_KEY,
40
+ channelId: process.env.YOUTUBE_CHANNEL_ID,
41
+ // OR use channel handle instead:
42
+ // channelHandle: "@YourChannel",
43
+ cacheTtl: 300_000, // 5 minutes
44
+ liveCacheTtl: 60_000, // 1 minute for live status
45
+ limits: {
46
+ videos: 10,
47
+ },
48
+ }),
49
+ ],
50
+ };
51
+ ```
52
+
53
+ ## Environment Variables
54
+
55
+ | Variable | Required | Description |
56
+ |----------|----------|-------------|
57
+ | `YOUTUBE_API_KEY` | Yes | YouTube Data API v3 key |
58
+ | `YOUTUBE_CHANNEL_ID` | Yes* | Channel ID (starts with `UC...`) |
59
+ | `YOUTUBE_CHANNEL_HANDLE` | Yes* | Channel handle (e.g., `@YourChannel`) |
60
+
61
+ *Either `channelId` or `channelHandle` is required.
62
+
63
+ ### Getting a YouTube API Key
64
+
65
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
66
+ 2. Create a new project or select an existing one
67
+ 3. Enable the "YouTube Data API v3"
68
+ 4. Go to Credentials > Create Credentials > API Key
69
+ 5. (Optional) Restrict the key to YouTube Data API only
70
+
71
+ ### Finding Your Channel ID
72
+
73
+ - Go to your YouTube channel
74
+ - The URL will be `youtube.com/channel/UC...` - the `UC...` part is your channel ID
75
+ - Or use a tool like [Comment Picker](https://commentpicker.com/youtube-channel-id.php)
76
+
77
+ ## Routes
78
+
79
+ ### Admin Routes (require authentication)
80
+
81
+ | Route | Description |
82
+ |-------|-------------|
83
+ | `GET /youtube/` | Dashboard with channel info, live status, latest videos |
84
+ | `POST /youtube/refresh` | Clear cache and refresh data |
85
+
86
+ ### Public API Routes (JSON)
87
+
88
+ | Route | Description |
89
+ |-------|-------------|
90
+ | `GET /youtube/api/videos` | Latest videos (supports `?limit=N`) |
91
+ | `GET /youtube/api/channel` | Channel information |
92
+ | `GET /youtube/api/live` | Live streaming status |
93
+
94
+ ### Example: Eleventy Integration
95
+
96
+ ```javascript
97
+ // _data/youtube.js
98
+ import EleventyFetch from "@11ty/eleventy-fetch";
99
+
100
+ export default async function() {
101
+ const baseUrl = process.env.SITE_URL || "https://example.com";
102
+
103
+ const [channel, videos, live] = await Promise.all([
104
+ EleventyFetch(`${baseUrl}/youtube/api/channel`, { duration: "15m", type: "json" }),
105
+ EleventyFetch(`${baseUrl}/youtube/api/videos?limit=6`, { duration: "5m", type: "json" }),
106
+ EleventyFetch(`${baseUrl}/youtube/api/live`, { duration: "1m", type: "json" }),
107
+ ]);
108
+
109
+ return {
110
+ channel: channel.channel,
111
+ videos: videos.videos,
112
+ isLive: live.isLive,
113
+ liveStream: live.stream,
114
+ };
115
+ }
116
+ ```
117
+
118
+ ## API Response Examples
119
+
120
+ ### GET /youtube/api/live
121
+
122
+ ```json
123
+ {
124
+ "isLive": true,
125
+ "isUpcoming": false,
126
+ "stream": {
127
+ "videoId": "abc123",
128
+ "title": "Live Stream Title",
129
+ "thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
130
+ "url": "https://www.youtube.com/watch?v=abc123"
131
+ },
132
+ "cached": true
133
+ }
134
+ ```
135
+
136
+ ### GET /youtube/api/videos
137
+
138
+ ```json
139
+ {
140
+ "videos": [
141
+ {
142
+ "id": "abc123",
143
+ "title": "Video Title",
144
+ "thumbnail": "https://i.ytimg.com/vi/abc123/mqdefault.jpg",
145
+ "duration": 3661,
146
+ "durationFormatted": "1:01:01",
147
+ "viewCount": 12345,
148
+ "publishedAt": "2024-01-15T10:00:00Z",
149
+ "url": "https://www.youtube.com/watch?v=abc123",
150
+ "isLive": false
151
+ }
152
+ ],
153
+ "count": 10,
154
+ "cached": true
155
+ }
156
+ ```
157
+
158
+ ## Options
159
+
160
+ | Option | Default | Description |
161
+ |--------|---------|-------------|
162
+ | `mountPath` | `/youtube` | URL path for the endpoint |
163
+ | `apiKey` | - | YouTube Data API key |
164
+ | `channelId` | - | Channel ID (UC...) |
165
+ | `channelHandle` | - | Channel handle (@...) |
166
+ | `cacheTtl` | `300000` | Cache TTL in ms (5 min) |
167
+ | `liveCacheTtl` | `60000` | Live status cache TTL in ms (1 min) |
168
+ | `limits.videos` | `10` | Number of videos to fetch |
169
+
170
+ ## Quota Efficiency
171
+
172
+ YouTube Data API has a daily quota (10,000 units by default). This plugin is optimized:
173
+
174
+ | Operation | Quota Cost | Method |
175
+ |-----------|------------|--------|
176
+ | Get videos | 2 units | Uses uploads playlist (not search) |
177
+ | Get channel | 1 unit | Cached for 24 hours |
178
+ | Check live status | 2 units | Checks recent videos (efficient) |
179
+ | Full live search | 100 units | Only when explicitly requested |
180
+
181
+ With default settings (5-min cache), you'll use ~600 units/day for video checks.
182
+
183
+ ## Requirements
184
+
185
+ - Indiekit >= 1.0.0-beta.25
186
+ - YouTube Data API v3 enabled
187
+ - Valid API key with YouTube Data API access
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,135 @@
1
+ {#
2
+ YouTube Widget - Embeddable component showing latest video and live status
3
+
4
+ Usage in your templates:
5
+ {% include "@indiekit-endpoint-youtube-widget.njk" %}
6
+
7
+ Requires youtube data to be fetched and passed to the template context
8
+ #}
9
+
10
+ <style>
11
+ .youtube-widget {
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 0.75rem;
15
+ padding: 1rem;
16
+ background: var(--color-offset, #f5f5f5);
17
+ border-radius: 0.5rem;
18
+ }
19
+ .youtube-widget__header {
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ }
24
+ .youtube-widget__title {
25
+ font-weight: 600;
26
+ font-size: 0.875rem;
27
+ margin: 0;
28
+ }
29
+ .youtube-widget__live {
30
+ display: inline-flex;
31
+ align-items: center;
32
+ gap: 0.375rem;
33
+ padding: 0.125rem 0.5rem;
34
+ border-radius: 1rem;
35
+ font-size: 0.625rem;
36
+ font-weight: 600;
37
+ text-transform: uppercase;
38
+ }
39
+ .youtube-widget__live--on {
40
+ background: #ff0000;
41
+ color: white;
42
+ }
43
+ .youtube-widget__live--off {
44
+ background: #e5e5e5;
45
+ color: #666;
46
+ }
47
+ .youtube-widget__live-dot {
48
+ width: 6px;
49
+ height: 6px;
50
+ border-radius: 50%;
51
+ background: currentColor;
52
+ }
53
+ .youtube-widget__video {
54
+ display: flex;
55
+ gap: 0.75rem;
56
+ }
57
+ .youtube-widget__thumb {
58
+ width: 120px;
59
+ height: 68px;
60
+ object-fit: cover;
61
+ border-radius: 0.25rem;
62
+ flex-shrink: 0;
63
+ }
64
+ .youtube-widget__info {
65
+ flex: 1;
66
+ min-width: 0;
67
+ }
68
+ .youtube-widget__video-title {
69
+ font-size: 0.875rem;
70
+ font-weight: 500;
71
+ margin: 0 0 0.25rem 0;
72
+ display: -webkit-box;
73
+ -webkit-line-clamp: 2;
74
+ -webkit-box-orient: vertical;
75
+ overflow: hidden;
76
+ }
77
+ .youtube-widget__video-title a {
78
+ color: inherit;
79
+ text-decoration: none;
80
+ }
81
+ .youtube-widget__video-title a:hover {
82
+ text-decoration: underline;
83
+ }
84
+ .youtube-widget__meta {
85
+ font-size: 0.75rem;
86
+ color: var(--color-text-secondary, #666);
87
+ }
88
+ </style>
89
+
90
+ <div class="youtube-widget">
91
+ <div class="youtube-widget__header">
92
+ <h3 class="youtube-widget__title">YouTube</h3>
93
+ {% if youtube.isLive %}
94
+ <span class="youtube-widget__live youtube-widget__live--on">
95
+ <span class="youtube-widget__live-dot"></span>
96
+ Live
97
+ </span>
98
+ {% else %}
99
+ <span class="youtube-widget__live youtube-widget__live--off">
100
+ Offline
101
+ </span>
102
+ {% endif %}
103
+ </div>
104
+
105
+ {% if youtube.liveStream %}
106
+ <div class="youtube-widget__video">
107
+ <img src="{{ youtube.liveStream.thumbnail }}" alt="" class="youtube-widget__thumb">
108
+ <div class="youtube-widget__info">
109
+ <h4 class="youtube-widget__video-title">
110
+ <a href="{{ youtube.liveStream.url }}" target="_blank" rel="noopener">
111
+ {{ youtube.liveStream.title }}
112
+ </a>
113
+ </h4>
114
+ <p class="youtube-widget__meta">🔴 Streaming now</p>
115
+ </div>
116
+ </div>
117
+ {% elif youtube.videos and youtube.videos[0] %}
118
+ {% set video = youtube.videos[0] %}
119
+ <div class="youtube-widget__video">
120
+ <img src="{{ video.thumbnail }}" alt="" class="youtube-widget__thumb">
121
+ <div class="youtube-widget__info">
122
+ <h4 class="youtube-widget__video-title">
123
+ <a href="{{ video.url }}" target="_blank" rel="noopener">
124
+ {{ video.title }}
125
+ </a>
126
+ </h4>
127
+ <p class="youtube-widget__meta">
128
+ {{ video.viewCount | localeNumber }} views
129
+ </p>
130
+ </div>
131
+ </div>
132
+ {% else %}
133
+ <p class="youtube-widget__meta">No videos available</p>
134
+ {% endif %}
135
+ </div>
package/index.js ADDED
@@ -0,0 +1,88 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { dashboardController } from "./lib/controllers/dashboard.js";
6
+ import { videosController } from "./lib/controllers/videos.js";
7
+ import { channelController } from "./lib/controllers/channel.js";
8
+ import { liveController } from "./lib/controllers/live.js";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const protectedRouter = express.Router();
13
+ const publicRouter = express.Router();
14
+
15
+ const defaults = {
16
+ mountPath: "/youtube",
17
+ apiKey: process.env.YOUTUBE_API_KEY,
18
+ channelId: process.env.YOUTUBE_CHANNEL_ID,
19
+ channelHandle: process.env.YOUTUBE_CHANNEL_HANDLE,
20
+ cacheTtl: 300_000, // 5 minutes
21
+ liveCacheTtl: 60_000, // 1 minute for live status
22
+ limits: {
23
+ videos: 10,
24
+ },
25
+ };
26
+
27
+ export default class YouTubeEndpoint {
28
+ name = "YouTube channel endpoint";
29
+
30
+ constructor(options = {}) {
31
+ this.options = { ...defaults, ...options };
32
+ this.mountPath = this.options.mountPath;
33
+ }
34
+
35
+ get environment() {
36
+ return ["YOUTUBE_API_KEY", "YOUTUBE_CHANNEL_ID", "YOUTUBE_CHANNEL_HANDLE"];
37
+ }
38
+
39
+ get localesDirectory() {
40
+ return path.join(__dirname, "locales");
41
+ }
42
+
43
+ get navigationItems() {
44
+ return {
45
+ href: this.options.mountPath,
46
+ text: "youtube.title",
47
+ };
48
+ }
49
+
50
+ get shortcutItems() {
51
+ return {
52
+ url: this.options.mountPath,
53
+ name: "youtube.videos",
54
+ iconName: "syndicate",
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Protected routes (require authentication)
60
+ * Admin dashboard
61
+ */
62
+ get routes() {
63
+ protectedRouter.get("/", dashboardController.get);
64
+ protectedRouter.post("/refresh", dashboardController.refresh);
65
+
66
+ return protectedRouter;
67
+ }
68
+
69
+ /**
70
+ * Public routes (no authentication required)
71
+ * JSON API endpoints for Eleventy frontend
72
+ */
73
+ get routesPublic() {
74
+ publicRouter.get("/api/videos", videosController.api);
75
+ publicRouter.get("/api/channel", channelController.api);
76
+ publicRouter.get("/api/live", liveController.api);
77
+
78
+ return publicRouter;
79
+ }
80
+
81
+ init(Indiekit) {
82
+ Indiekit.addEndpoint(this);
83
+
84
+ // Store YouTube config in application for controller access
85
+ Indiekit.config.application.youtubeConfig = this.options;
86
+ Indiekit.config.application.youtubeEndpoint = this.mountPath;
87
+ }
88
+ }
@@ -0,0 +1,43 @@
1
+ import { YouTubeClient } from "../youtube-client.js";
2
+
3
+ /**
4
+ * Channel controller
5
+ */
6
+ export const channelController = {
7
+ /**
8
+ * Get channel info (JSON API)
9
+ * @type {import("express").RequestHandler}
10
+ */
11
+ async api(request, response) {
12
+ try {
13
+ const { youtubeConfig } = request.app.locals.application;
14
+
15
+ if (!youtubeConfig) {
16
+ return response.status(500).json({ error: "Not configured" });
17
+ }
18
+
19
+ const { apiKey, channelId, channelHandle, cacheTtl } = youtubeConfig;
20
+
21
+ if (!apiKey || (!channelId && !channelHandle)) {
22
+ return response.status(500).json({ error: "Invalid configuration" });
23
+ }
24
+
25
+ const client = new YouTubeClient({
26
+ apiKey,
27
+ channelId,
28
+ channelHandle,
29
+ cacheTtl,
30
+ });
31
+
32
+ const channel = await client.getChannelInfo();
33
+
34
+ response.json({
35
+ channel,
36
+ cached: true,
37
+ });
38
+ } catch (error) {
39
+ console.error("[YouTube] Channel API error:", error);
40
+ response.status(500).json({ error: error.message });
41
+ }
42
+ },
43
+ };
@@ -0,0 +1,120 @@
1
+ import { YouTubeClient } from "../youtube-client.js";
2
+
3
+ /**
4
+ * Dashboard controller
5
+ */
6
+ export const dashboardController = {
7
+ /**
8
+ * Render dashboard page
9
+ * @type {import("express").RequestHandler}
10
+ */
11
+ async get(request, response, next) {
12
+ try {
13
+ const { youtubeConfig, youtubeEndpoint } = request.app.locals.application;
14
+
15
+ if (!youtubeConfig) {
16
+ return response.status(500).render("youtube", {
17
+ title: "YouTube",
18
+ error: { message: "YouTube endpoint not configured" },
19
+ });
20
+ }
21
+
22
+ const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
23
+
24
+ if (!apiKey) {
25
+ return response.render("youtube", {
26
+ title: response.locals.__("youtube.title"),
27
+ error: { message: response.locals.__("youtube.error.noApiKey") },
28
+ });
29
+ }
30
+
31
+ if (!channelId && !channelHandle) {
32
+ return response.render("youtube", {
33
+ title: response.locals.__("youtube.title"),
34
+ error: { message: response.locals.__("youtube.error.noChannel") },
35
+ });
36
+ }
37
+
38
+ const client = new YouTubeClient({
39
+ apiKey,
40
+ channelId,
41
+ channelHandle,
42
+ cacheTtl,
43
+ });
44
+
45
+ let channel = null;
46
+ let videos = [];
47
+ let liveStatus = null;
48
+
49
+ try {
50
+ [channel, videos, liveStatus] = await Promise.all([
51
+ client.getChannelInfo(),
52
+ client.getLatestVideos(limits?.videos || 6),
53
+ client.getLiveStatusEfficient(),
54
+ ]);
55
+ } catch (apiError) {
56
+ console.error("[YouTube] API error:", apiError.message);
57
+ return response.render("youtube", {
58
+ title: response.locals.__("youtube.title"),
59
+ error: { message: response.locals.__("youtube.error.connection") },
60
+ });
61
+ }
62
+
63
+ // Determine public frontend URL
64
+ const publicUrl = youtubeEndpoint
65
+ ? youtubeEndpoint.replace(/api$/, "")
66
+ : "/youtube";
67
+
68
+ response.render("youtube", {
69
+ title: response.locals.__("youtube.title"),
70
+ channel,
71
+ videos: videos.slice(0, limits?.videos || 6),
72
+ liveStatus,
73
+ isLive: liveStatus?.isLive || false,
74
+ isUpcoming: liveStatus?.isUpcoming || false,
75
+ publicUrl,
76
+ mountPath: request.baseUrl,
77
+ });
78
+ } catch (error) {
79
+ console.error("[YouTube] Dashboard error:", error);
80
+ next(error);
81
+ }
82
+ },
83
+
84
+ /**
85
+ * Trigger manual cache refresh
86
+ * @type {import("express").RequestHandler}
87
+ */
88
+ async refresh(request, response) {
89
+ try {
90
+ const { youtubeConfig } = request.app.locals.application;
91
+
92
+ if (!youtubeConfig) {
93
+ return response.status(500).json({ error: "Not configured" });
94
+ }
95
+
96
+ const client = new YouTubeClient({
97
+ apiKey: youtubeConfig.apiKey,
98
+ channelId: youtubeConfig.channelId,
99
+ channelHandle: youtubeConfig.channelHandle,
100
+ });
101
+
102
+ // Clear cache and refetch
103
+ client.clearCache();
104
+ const [channel, videos] = await Promise.all([
105
+ client.getChannelInfo(),
106
+ client.getLatestVideos(youtubeConfig.limits?.videos || 10),
107
+ ]);
108
+
109
+ response.json({
110
+ success: true,
111
+ channel: channel.title,
112
+ videoCount: videos.length,
113
+ message: `Refreshed ${videos.length} videos from ${channel.title}`,
114
+ });
115
+ } catch (error) {
116
+ console.error("[YouTube] Refresh error:", error);
117
+ response.status(500).json({ error: error.message });
118
+ }
119
+ },
120
+ };
@@ -0,0 +1,67 @@
1
+ import { YouTubeClient } from "../youtube-client.js";
2
+
3
+ /**
4
+ * Live status controller
5
+ */
6
+ export const liveController = {
7
+ /**
8
+ * Get live status (JSON API)
9
+ * Uses efficient method (checking recent videos) by default
10
+ * Use ?full=true for full search (costs 100 quota units)
11
+ * @type {import("express").RequestHandler}
12
+ */
13
+ async api(request, response) {
14
+ try {
15
+ const { youtubeConfig } = request.app.locals.application;
16
+
17
+ if (!youtubeConfig) {
18
+ return response.status(500).json({ error: "Not configured" });
19
+ }
20
+
21
+ const { apiKey, channelId, channelHandle, liveCacheTtl } = youtubeConfig;
22
+
23
+ if (!apiKey || (!channelId && !channelHandle)) {
24
+ return response.status(500).json({ error: "Invalid configuration" });
25
+ }
26
+
27
+ const client = new YouTubeClient({
28
+ apiKey,
29
+ channelId,
30
+ channelHandle,
31
+ liveCacheTtl,
32
+ });
33
+
34
+ // Use full search only if explicitly requested
35
+ const useFullSearch = request.query.full === "true";
36
+ const liveStatus = useFullSearch
37
+ ? await client.getLiveStatus()
38
+ : await client.getLiveStatusEfficient();
39
+
40
+ if (liveStatus) {
41
+ response.json({
42
+ isLive: liveStatus.isLive || false,
43
+ isUpcoming: liveStatus.isUpcoming || false,
44
+ stream: {
45
+ videoId: liveStatus.videoId,
46
+ title: liveStatus.title,
47
+ thumbnail: liveStatus.thumbnail,
48
+ url: `https://www.youtube.com/watch?v=${liveStatus.videoId}`,
49
+ scheduledStart: liveStatus.scheduledStart,
50
+ actualStart: liveStatus.actualStart,
51
+ },
52
+ cached: true,
53
+ });
54
+ } else {
55
+ response.json({
56
+ isLive: false,
57
+ isUpcoming: false,
58
+ stream: null,
59
+ cached: true,
60
+ });
61
+ }
62
+ } catch (error) {
63
+ console.error("[YouTube] Live API error:", error);
64
+ response.status(500).json({ error: error.message });
65
+ }
66
+ },
67
+ };
@@ -0,0 +1,49 @@
1
+ import { YouTubeClient } from "../youtube-client.js";
2
+
3
+ /**
4
+ * Videos controller
5
+ */
6
+ export const videosController = {
7
+ /**
8
+ * Get latest videos (JSON API)
9
+ * @type {import("express").RequestHandler}
10
+ */
11
+ async api(request, response) {
12
+ try {
13
+ const { youtubeConfig } = request.app.locals.application;
14
+
15
+ if (!youtubeConfig) {
16
+ return response.status(500).json({ error: "Not configured" });
17
+ }
18
+
19
+ const { apiKey, channelId, channelHandle, cacheTtl, limits } = youtubeConfig;
20
+
21
+ if (!apiKey || (!channelId && !channelHandle)) {
22
+ return response.status(500).json({ error: "Invalid configuration" });
23
+ }
24
+
25
+ const client = new YouTubeClient({
26
+ apiKey,
27
+ channelId,
28
+ channelHandle,
29
+ cacheTtl,
30
+ });
31
+
32
+ const maxResults = Math.min(
33
+ parseInt(request.query.limit, 10) || limits?.videos || 10,
34
+ 50
35
+ );
36
+
37
+ const videos = await client.getLatestVideos(maxResults);
38
+
39
+ response.json({
40
+ videos,
41
+ count: videos.length,
42
+ cached: true,
43
+ });
44
+ } catch (error) {
45
+ console.error("[YouTube] Videos API error:", error);
46
+ response.status(500).json({ error: error.message });
47
+ }
48
+ },
49
+ };
@@ -0,0 +1,330 @@
1
+ /**
2
+ * YouTube Data API v3 client
3
+ * Optimized for quota efficiency (10,000 units/day default)
4
+ *
5
+ * Quota costs:
6
+ * - channels.list: 1 unit
7
+ * - playlistItems.list: 1 unit
8
+ * - videos.list: 1 unit
9
+ * - search.list: 100 units (avoid!)
10
+ */
11
+
12
+ const API_BASE = "https://www.googleapis.com/youtube/v3";
13
+
14
+ // In-memory cache
15
+ const cache = new Map();
16
+
17
+ /**
18
+ * Get cached data or null if expired
19
+ * @param {string} key - Cache key
20
+ * @param {number} ttl - TTL in milliseconds
21
+ * @returns {any|null}
22
+ */
23
+ function getCache(key, ttl) {
24
+ const cached = cache.get(key);
25
+ if (!cached) return null;
26
+ if (Date.now() - cached.time > ttl) {
27
+ cache.delete(key);
28
+ return null;
29
+ }
30
+ return cached.data;
31
+ }
32
+
33
+ /**
34
+ * Set cache data
35
+ * @param {string} key - Cache key
36
+ * @param {any} data - Data to cache
37
+ */
38
+ function setCache(key, data) {
39
+ cache.set(key, { data, time: Date.now() });
40
+ }
41
+
42
+ export class YouTubeClient {
43
+ /**
44
+ * @param {object} options
45
+ * @param {string} options.apiKey - YouTube Data API key
46
+ * @param {string} options.channelId - Channel ID (UC...)
47
+ * @param {string} [options.channelHandle] - Channel handle (@...)
48
+ * @param {number} [options.cacheTtl] - Cache TTL in ms (default: 5 min)
49
+ * @param {number} [options.liveCacheTtl] - Live status cache TTL in ms (default: 1 min)
50
+ */
51
+ constructor(options) {
52
+ this.apiKey = options.apiKey;
53
+ this.channelId = options.channelId;
54
+ this.channelHandle = options.channelHandle;
55
+ this.cacheTtl = options.cacheTtl || 300_000; // 5 minutes
56
+ this.liveCacheTtl = options.liveCacheTtl || 60_000; // 1 minute
57
+ }
58
+
59
+ /**
60
+ * Make API request
61
+ * @param {string} endpoint - API endpoint
62
+ * @param {object} params - Query parameters
63
+ * @returns {Promise<object>}
64
+ */
65
+ async request(endpoint, params = {}) {
66
+ const url = new URL(`${API_BASE}/${endpoint}`);
67
+ url.searchParams.set("key", this.apiKey);
68
+ for (const [key, value] of Object.entries(params)) {
69
+ if (value !== undefined && value !== null) {
70
+ url.searchParams.set(key, String(value));
71
+ }
72
+ }
73
+
74
+ const response = await fetch(url.toString());
75
+
76
+ if (!response.ok) {
77
+ const error = await response.json().catch(() => ({}));
78
+ const message = error.error?.message || response.statusText;
79
+ throw new Error(`YouTube API error: ${message}`);
80
+ }
81
+
82
+ return response.json();
83
+ }
84
+
85
+ /**
86
+ * Get channel info (cached)
87
+ * @returns {Promise<object>} - Channel info including uploads playlist ID
88
+ */
89
+ async getChannelInfo() {
90
+ const cacheKey = `channel:${this.channelId || this.channelHandle}`;
91
+ const cached = getCache(cacheKey, 86_400_000); // 24 hour cache for channel info
92
+ if (cached) return cached;
93
+
94
+ const params = {
95
+ part: "snippet,contentDetails,statistics,brandingSettings",
96
+ };
97
+
98
+ // Use channelId if available, otherwise resolve from handle
99
+ if (this.channelId) {
100
+ params.id = this.channelId;
101
+ } else if (this.channelHandle) {
102
+ // Remove @ if present
103
+ const handle = this.channelHandle.replace(/^@/, "");
104
+ params.forHandle = handle;
105
+ } else {
106
+ throw new Error("Either channelId or channelHandle is required");
107
+ }
108
+
109
+ const data = await this.request("channels", params);
110
+
111
+ if (!data.items || data.items.length === 0) {
112
+ throw new Error("Channel not found");
113
+ }
114
+
115
+ const channel = data.items[0];
116
+ const result = {
117
+ id: channel.id,
118
+ title: channel.snippet.title,
119
+ description: channel.snippet.description,
120
+ customUrl: channel.snippet.customUrl,
121
+ thumbnail: channel.snippet.thumbnails?.medium?.url,
122
+ subscriberCount: parseInt(channel.statistics.subscriberCount, 10) || 0,
123
+ videoCount: parseInt(channel.statistics.videoCount, 10) || 0,
124
+ viewCount: parseInt(channel.statistics.viewCount, 10) || 0,
125
+ uploadsPlaylistId: channel.contentDetails?.relatedPlaylists?.uploads,
126
+ bannerUrl: channel.brandingSettings?.image?.bannerExternalUrl,
127
+ };
128
+
129
+ setCache(cacheKey, result);
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Get latest videos from channel
135
+ * Uses uploads playlist for quota efficiency (1 unit vs 100 for search)
136
+ * @param {number} [maxResults=10] - Number of videos to fetch
137
+ * @returns {Promise<Array>} - List of videos
138
+ */
139
+ async getLatestVideos(maxResults = 10) {
140
+ const cacheKey = `videos:${this.channelId || this.channelHandle}:${maxResults}`;
141
+ const cached = getCache(cacheKey, this.cacheTtl);
142
+ if (cached) return cached;
143
+
144
+ // Get channel info to get uploads playlist ID
145
+ const channel = await this.getChannelInfo();
146
+ if (!channel.uploadsPlaylistId) {
147
+ throw new Error("Could not find uploads playlist");
148
+ }
149
+
150
+ // Get playlist items (1 quota unit)
151
+ const playlistData = await this.request("playlistItems", {
152
+ part: "snippet,contentDetails",
153
+ playlistId: channel.uploadsPlaylistId,
154
+ maxResults: Math.min(maxResults, 50),
155
+ });
156
+
157
+ if (!playlistData.items || playlistData.items.length === 0) {
158
+ setCache(cacheKey, []);
159
+ return [];
160
+ }
161
+
162
+ // Get video details for duration, view count, live status (1 quota unit)
163
+ const videoIds = playlistData.items
164
+ .map((item) => item.contentDetails.videoId)
165
+ .join(",");
166
+
167
+ const videosData = await this.request("videos", {
168
+ part: "snippet,contentDetails,statistics,liveStreamingDetails",
169
+ id: videoIds,
170
+ });
171
+
172
+ const videos = videosData.items.map((video) => this.formatVideo(video));
173
+
174
+ setCache(cacheKey, videos);
175
+ return videos;
176
+ }
177
+
178
+ /**
179
+ * Check if channel is currently live
180
+ * @returns {Promise<object|null>} - Live stream info or null
181
+ */
182
+ async getLiveStatus() {
183
+ const cacheKey = `live:${this.channelId || this.channelHandle}`;
184
+ const cached = getCache(cacheKey, this.liveCacheTtl);
185
+ if (cached !== undefined) return cached;
186
+
187
+ // Get channel info first to ensure we have the channel ID
188
+ const channel = await this.getChannelInfo();
189
+
190
+ // Search for live broadcasts (costs 100 quota units - use sparingly)
191
+ // Only do this check periodically
192
+ try {
193
+ const data = await this.request("search", {
194
+ part: "snippet",
195
+ channelId: channel.id,
196
+ eventType: "live",
197
+ type: "video",
198
+ maxResults: 1,
199
+ });
200
+
201
+ if (data.items && data.items.length > 0) {
202
+ const liveItem = data.items[0];
203
+ const result = {
204
+ isLive: true,
205
+ videoId: liveItem.id.videoId,
206
+ title: liveItem.snippet.title,
207
+ thumbnail: liveItem.snippet.thumbnails?.medium?.url,
208
+ startedAt: liveItem.snippet.publishedAt,
209
+ };
210
+ setCache(cacheKey, result);
211
+ return result;
212
+ }
213
+
214
+ setCache(cacheKey, null);
215
+ return null;
216
+ } catch (error) {
217
+ console.error("[YouTube] Live status check error:", error.message);
218
+ setCache(cacheKey, null);
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get live status efficiently by checking recent videos
225
+ * This uses less quota than search.list
226
+ * @returns {Promise<object|null>} - Live stream info or null
227
+ */
228
+ async getLiveStatusEfficient() {
229
+ const cacheKey = `live-eff:${this.channelId || this.channelHandle}`;
230
+ const cached = getCache(cacheKey, this.liveCacheTtl);
231
+ if (cached !== undefined) return cached;
232
+
233
+ // Get latest videos and check if any are live
234
+ const videos = await this.getLatestVideos(5);
235
+ const liveVideo = videos.find((v) => v.isLive || v.isUpcoming);
236
+
237
+ if (liveVideo) {
238
+ const result = {
239
+ isLive: liveVideo.isLive,
240
+ isUpcoming: liveVideo.isUpcoming,
241
+ videoId: liveVideo.id,
242
+ title: liveVideo.title,
243
+ thumbnail: liveVideo.thumbnail,
244
+ scheduledStart: liveVideo.scheduledStart,
245
+ actualStart: liveVideo.actualStart,
246
+ };
247
+ setCache(cacheKey, result);
248
+ return result;
249
+ }
250
+
251
+ setCache(cacheKey, null);
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Format video data
257
+ * @param {object} video - Raw video data from API
258
+ * @returns {object} - Formatted video
259
+ */
260
+ formatVideo(video) {
261
+ const liveDetails = video.liveStreamingDetails;
262
+ const isLive = liveDetails?.actualStartTime && !liveDetails?.actualEndTime;
263
+ const isUpcoming = liveDetails?.scheduledStartTime && !liveDetails?.actualStartTime;
264
+
265
+ return {
266
+ id: video.id,
267
+ title: video.snippet.title,
268
+ description: video.snippet.description,
269
+ thumbnail: video.snippet.thumbnails?.medium?.url,
270
+ thumbnailHigh: video.snippet.thumbnails?.high?.url,
271
+ channelId: video.snippet.channelId,
272
+ channelTitle: video.snippet.channelTitle,
273
+ publishedAt: video.snippet.publishedAt,
274
+ duration: this.parseDuration(video.contentDetails?.duration),
275
+ durationFormatted: this.formatDuration(video.contentDetails?.duration),
276
+ viewCount: parseInt(video.statistics?.viewCount, 10) || 0,
277
+ likeCount: parseInt(video.statistics?.likeCount, 10) || 0,
278
+ commentCount: parseInt(video.statistics?.commentCount, 10) || 0,
279
+ isLive,
280
+ isUpcoming,
281
+ scheduledStart: liveDetails?.scheduledStartTime,
282
+ actualStart: liveDetails?.actualStartTime,
283
+ concurrentViewers: liveDetails?.concurrentViewers
284
+ ? parseInt(liveDetails.concurrentViewers, 10)
285
+ : null,
286
+ url: `https://www.youtube.com/watch?v=${video.id}`,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Parse ISO 8601 duration to seconds
292
+ * @param {string} duration - ISO 8601 duration (PT1H2M3S)
293
+ * @returns {number} - Duration in seconds
294
+ */
295
+ parseDuration(duration) {
296
+ if (!duration) return 0;
297
+ const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
298
+ if (!match) return 0;
299
+ const hours = parseInt(match[1], 10) || 0;
300
+ const minutes = parseInt(match[2], 10) || 0;
301
+ const seconds = parseInt(match[3], 10) || 0;
302
+ return hours * 3600 + minutes * 60 + seconds;
303
+ }
304
+
305
+ /**
306
+ * Format duration for display
307
+ * @param {string} duration - ISO 8601 duration
308
+ * @returns {string} - Formatted duration (1:02:03 or 2:03)
309
+ */
310
+ formatDuration(duration) {
311
+ const totalSeconds = this.parseDuration(duration);
312
+ if (totalSeconds === 0) return "0:00";
313
+
314
+ const hours = Math.floor(totalSeconds / 3600);
315
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
316
+ const seconds = totalSeconds % 60;
317
+
318
+ if (hours > 0) {
319
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
320
+ }
321
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
322
+ }
323
+
324
+ /**
325
+ * Clear all caches
326
+ */
327
+ clearCache() {
328
+ cache.clear();
329
+ }
330
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "youtube": {
3
+ "title": "YouTube",
4
+ "videos": "Latest Videos",
5
+ "channel": "Channel",
6
+ "live": "Live Now",
7
+ "upcoming": "Upcoming",
8
+ "offline": "Offline",
9
+ "subscribers": "subscribers",
10
+ "views": "views",
11
+ "watchNow": "Watch Now",
12
+ "viewChannel": "View Channel",
13
+ "viewAll": "View All Videos",
14
+ "noVideos": "No videos found",
15
+ "refreshed": "Refreshed",
16
+ "error": {
17
+ "noApiKey": "YouTube API key not configured",
18
+ "noChannel": "YouTube channel not specified",
19
+ "connection": "Could not connect to YouTube API"
20
+ },
21
+ "widget": {
22
+ "description": "View full channel on YouTube",
23
+ "view": "Open YouTube Channel"
24
+ }
25
+ }
26
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-youtube",
3
+ "version": "1.0.0",
4
+ "description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "youtube",
10
+ "videos",
11
+ "live",
12
+ "streaming"
13
+ ],
14
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-youtube",
15
+ "bugs": {
16
+ "url": "https://github.com/rmdes/indiekit-endpoint-youtube/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/rmdes/indiekit-endpoint-youtube.git"
21
+ },
22
+ "author": {
23
+ "name": "Ricardo Mendes",
24
+ "url": "https://rmendes.net"
25
+ },
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "type": "module",
31
+ "main": "index.js",
32
+ "exports": {
33
+ ".": "./index.js"
34
+ },
35
+ "files": [
36
+ "includes",
37
+ "lib",
38
+ "locales",
39
+ "views",
40
+ "index.js"
41
+ ],
42
+ "dependencies": {
43
+ "@indiekit/error": "^1.0.0-beta.25",
44
+ "express": "^5.0.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@indiekit/indiekit": ">=1.0.0-beta.25"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,281 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <style>
5
+ .yt-section { margin-bottom: 2rem; }
6
+ .yt-section h2 { margin-bottom: 1rem; }
7
+
8
+ /* Channel header */
9
+ .yt-channel {
10
+ display: flex;
11
+ gap: 1rem;
12
+ padding: 1rem;
13
+ background: var(--color-offset, #f5f5f5);
14
+ border-radius: 0.5rem;
15
+ align-items: center;
16
+ margin-bottom: 1.5rem;
17
+ }
18
+ .yt-channel__avatar {
19
+ width: 64px;
20
+ height: 64px;
21
+ border-radius: 50%;
22
+ object-fit: cover;
23
+ flex-shrink: 0;
24
+ }
25
+ .yt-channel__info { flex: 1; }
26
+ .yt-channel__name {
27
+ font-weight: 600;
28
+ font-size: 1.125rem;
29
+ margin: 0 0 0.25rem 0;
30
+ }
31
+ .yt-channel__stats {
32
+ display: flex;
33
+ gap: 1rem;
34
+ font-size: 0.875rem;
35
+ color: var(--color-text-secondary, #666);
36
+ }
37
+
38
+ /* Live status badge */
39
+ .yt-live-badge {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 0.5rem;
43
+ padding: 0.25rem 0.75rem;
44
+ border-radius: 2rem;
45
+ font-size: 0.75rem;
46
+ font-weight: 600;
47
+ text-transform: uppercase;
48
+ }
49
+ .yt-live-badge--live {
50
+ background: #ff0000;
51
+ color: white;
52
+ animation: yt-pulse 2s infinite;
53
+ }
54
+ .yt-live-badge--upcoming {
55
+ background: #065fd4;
56
+ color: white;
57
+ }
58
+ .yt-live-badge--offline {
59
+ background: var(--color-offset, #e5e5e5);
60
+ color: var(--color-text-secondary, #666);
61
+ }
62
+ @keyframes yt-pulse {
63
+ 0%, 100% { opacity: 1; }
64
+ 50% { opacity: 0.7; }
65
+ }
66
+ .yt-live-dot {
67
+ width: 8px;
68
+ height: 8px;
69
+ border-radius: 50%;
70
+ background: currentColor;
71
+ }
72
+
73
+ /* Live stream card */
74
+ .yt-live-stream {
75
+ display: flex;
76
+ gap: 1rem;
77
+ padding: 1rem;
78
+ background: linear-gradient(135deg, #ff000010, #ff000005);
79
+ border: 1px solid #ff000030;
80
+ border-radius: 0.5rem;
81
+ margin-bottom: 1.5rem;
82
+ }
83
+ .yt-live-stream__thumb {
84
+ width: 160px;
85
+ height: 90px;
86
+ object-fit: cover;
87
+ border-radius: 0.25rem;
88
+ flex-shrink: 0;
89
+ }
90
+ .yt-live-stream__info { flex: 1; }
91
+ .yt-live-stream__title {
92
+ font-weight: 600;
93
+ margin: 0 0 0.5rem 0;
94
+ }
95
+ .yt-live-stream__title a {
96
+ color: inherit;
97
+ text-decoration: none;
98
+ }
99
+ .yt-live-stream__title a:hover { text-decoration: underline; }
100
+
101
+ /* Video grid */
102
+ .yt-video-grid {
103
+ display: grid;
104
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
105
+ gap: 1rem;
106
+ list-style: none;
107
+ padding: 0;
108
+ margin: 0;
109
+ }
110
+ .yt-video {
111
+ display: flex;
112
+ flex-direction: column;
113
+ background: var(--color-offset, #f5f5f5);
114
+ border-radius: 0.5rem;
115
+ overflow: hidden;
116
+ }
117
+ .yt-video__thumb-wrapper {
118
+ position: relative;
119
+ aspect-ratio: 16/9;
120
+ }
121
+ .yt-video__thumb {
122
+ width: 100%;
123
+ height: 100%;
124
+ object-fit: cover;
125
+ }
126
+ .yt-video__duration {
127
+ position: absolute;
128
+ bottom: 0.5rem;
129
+ right: 0.5rem;
130
+ background: rgba(0, 0, 0, 0.8);
131
+ color: white;
132
+ padding: 0.125rem 0.375rem;
133
+ border-radius: 0.25rem;
134
+ font-size: 0.75rem;
135
+ font-weight: 500;
136
+ }
137
+ .yt-video__info {
138
+ padding: 0.75rem;
139
+ flex: 1;
140
+ display: flex;
141
+ flex-direction: column;
142
+ }
143
+ .yt-video__title {
144
+ font-weight: 500;
145
+ font-size: 0.875rem;
146
+ margin: 0 0 0.5rem 0;
147
+ display: -webkit-box;
148
+ -webkit-line-clamp: 2;
149
+ -webkit-box-orient: vertical;
150
+ overflow: hidden;
151
+ }
152
+ .yt-video__title a {
153
+ color: inherit;
154
+ text-decoration: none;
155
+ }
156
+ .yt-video__title a:hover { text-decoration: underline; }
157
+ .yt-video__meta {
158
+ font-size: 0.75rem;
159
+ color: var(--color-text-secondary, #666);
160
+ margin-top: auto;
161
+ }
162
+
163
+ /* Public link banner */
164
+ .yt-public-link {
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: space-between;
168
+ padding: 1rem;
169
+ background: var(--color-offset, #f5f5f5);
170
+ border-radius: 0.5rem;
171
+ margin-top: 2rem;
172
+ }
173
+ .yt-public-link p {
174
+ margin: 0;
175
+ color: var(--color-text-secondary, #666);
176
+ }
177
+ </style>
178
+
179
+ {% if error %}
180
+ {{ prose({ text: error.message }) }}
181
+ {% else %}
182
+ {# Channel Header #}
183
+ {% if channel %}
184
+ <div class="yt-channel">
185
+ {% if channel.thumbnail %}
186
+ <img src="{{ channel.thumbnail }}" alt="" class="yt-channel__avatar">
187
+ {% endif %}
188
+ <div class="yt-channel__info">
189
+ <h2 class="yt-channel__name">{{ channel.title }}</h2>
190
+ <div class="yt-channel__stats">
191
+ <span>{{ channel.subscriberCount | localeNumber }} {{ __("youtube.subscribers") }}</span>
192
+ <span>{{ channel.videoCount | localeNumber }} videos</span>
193
+ </div>
194
+ </div>
195
+ {% if isLive %}
196
+ <span class="yt-live-badge yt-live-badge--live">
197
+ <span class="yt-live-dot"></span>
198
+ {{ __("youtube.live") }}
199
+ </span>
200
+ {% elif isUpcoming %}
201
+ <span class="yt-live-badge yt-live-badge--upcoming">
202
+ {{ __("youtube.upcoming") }}
203
+ </span>
204
+ {% else %}
205
+ <span class="yt-live-badge yt-live-badge--offline">
206
+ {{ __("youtube.offline") }}
207
+ </span>
208
+ {% endif %}
209
+ </div>
210
+ {% endif %}
211
+
212
+ {# Live Stream (if live) #}
213
+ {% if liveStatus and (liveStatus.isLive or liveStatus.isUpcoming) %}
214
+ <section class="yt-section">
215
+ <h2>{% if liveStatus.isLive %}{{ __("youtube.live") }}{% else %}{{ __("youtube.upcoming") }}{% endif %}</h2>
216
+ <div class="yt-live-stream">
217
+ {% if liveStatus.thumbnail %}
218
+ <img src="{{ liveStatus.thumbnail }}" alt="" class="yt-live-stream__thumb">
219
+ {% endif %}
220
+ <div class="yt-live-stream__info">
221
+ <h3 class="yt-live-stream__title">
222
+ <a href="https://www.youtube.com/watch?v={{ liveStatus.videoId }}" target="_blank" rel="noopener">
223
+ {{ liveStatus.title }}
224
+ </a>
225
+ </h3>
226
+ {{ button({
227
+ href: "https://www.youtube.com/watch?v=" + liveStatus.videoId,
228
+ text: __("youtube.watchNow"),
229
+ target: "_blank"
230
+ }) }}
231
+ </div>
232
+ </div>
233
+ </section>
234
+ {% endif %}
235
+
236
+ {# Latest Videos #}
237
+ <section class="yt-section">
238
+ <h2>{{ __("youtube.videos") }}</h2>
239
+ {% if videos and videos.length > 0 %}
240
+ <ul class="yt-video-grid">
241
+ {% for video in videos %}
242
+ <li class="yt-video">
243
+ <div class="yt-video__thumb-wrapper">
244
+ <a href="{{ video.url }}" target="_blank" rel="noopener">
245
+ <img src="{{ video.thumbnail }}" alt="" class="yt-video__thumb" loading="lazy">
246
+ </a>
247
+ {% if video.durationFormatted and not video.isLive %}
248
+ <span class="yt-video__duration">{{ video.durationFormatted }}</span>
249
+ {% elif video.isLive %}
250
+ <span class="yt-video__duration" style="background:#ff0000">LIVE</span>
251
+ {% endif %}
252
+ </div>
253
+ <div class="yt-video__info">
254
+ <h3 class="yt-video__title">
255
+ <a href="{{ video.url }}" target="_blank" rel="noopener">{{ video.title }}</a>
256
+ </h3>
257
+ <div class="yt-video__meta">
258
+ {{ video.viewCount | localeNumber }} {{ __("youtube.views") }}
259
+ </div>
260
+ </div>
261
+ </li>
262
+ {% endfor %}
263
+ </ul>
264
+ {% else %}
265
+ {{ prose({ text: __("youtube.noVideos") }) }}
266
+ {% endif %}
267
+ </section>
268
+
269
+ {# Link to YouTube channel #}
270
+ {% if channel %}
271
+ <div class="yt-public-link">
272
+ <p>{{ __("youtube.widget.description") }}</p>
273
+ {{ button({
274
+ href: "https://www.youtube.com/channel/" + channel.id,
275
+ text: __("youtube.widget.view"),
276
+ target: "_blank"
277
+ }) }}
278
+ </div>
279
+ {% endif %}
280
+ {% endif %}
281
+ {% endblock %}