Files
indiekit-endpoint-youtube/lib/youtube-client.js
svemagie 3dda28d3dc feat: add YouTube liked videos sync via OAuth 2.0
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>
2026-03-18 20:53:38 +01:00

390 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 APIkey 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();
}
}