mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
Adds OAuth 2.0 flow to connect a YouTube account and sync liked videos as "like" posts on the blog. Includes: - OAuth authorize/callback/disconnect flow with token persistence - getLikedVideos() method using videos.list?myRating=like - Background periodic sync + manual sync trigger - Dashboard UI for connection status and sync controls - Public JSON API for querying synced likes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
/**
|
||
* 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")}`;
|
||
}
|
||
|
||
/**
|
||
* Make an authenticated API request using an OAuth access token.
|
||
* Falls back to the API‑key based `request()` when no token is given.
|
||
* @param {string} endpoint
|
||
* @param {object} params
|
||
* @param {string} accessToken - OAuth 2.0 access token
|
||
* @returns {Promise<object>}
|
||
*/
|
||
async authenticatedRequest(endpoint, params = {}, accessToken) {
|
||
if (!accessToken) return this.request(endpoint, params);
|
||
|
||
const url = new URL(`${API_BASE}/${endpoint}`);
|
||
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(), {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
});
|
||
|
||
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 the authenticated user's liked videos.
|
||
* Requires an OAuth 2.0 access token with youtube.readonly scope.
|
||
* Uses `videos.list?myRating=like` (1 quota unit per page).
|
||
* @param {string} accessToken
|
||
* @param {number} [maxResults=50]
|
||
* @param {string} [pageToken]
|
||
* @returns {Promise<{videos: Array, nextPageToken?: string, totalResults: number}>}
|
||
*/
|
||
async getLikedVideos(accessToken, maxResults = 50, pageToken) {
|
||
const params = {
|
||
part: "snippet,contentDetails,statistics",
|
||
myRating: "like",
|
||
maxResults: Math.min(maxResults, 50),
|
||
};
|
||
if (pageToken) params.pageToken = pageToken;
|
||
|
||
const data = await this.authenticatedRequest("videos", params, accessToken);
|
||
|
||
const videos = (data.items || []).map((video) => this.formatVideo(video));
|
||
|
||
return {
|
||
videos,
|
||
nextPageToken: data.nextPageToken || null,
|
||
totalResults: data.pageInfo?.totalResults || videos.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Clear all caches
|
||
*/
|
||
clearCache() {
|
||
cache.clear();
|
||
}
|
||
}
|