@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 +191 -0
- package/includes/@indiekit-endpoint-youtube-widget.njk +135 -0
- package/index.js +88 -0
- package/lib/controllers/channel.js +43 -0
- package/lib/controllers/dashboard.js +120 -0
- package/lib/controllers/live.js +67 -0
- package/lib/controllers/videos.js +49 -0
- package/lib/youtube-client.js +330 -0
- package/locales/en.json +26 -0
- package/package.json +52 -0
- package/views/youtube.njk +281 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @rmdes/indiekit-endpoint-youtube
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@rmdes/indiekit-endpoint-youtube)
|
|
4
|
+
[](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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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 %}
|