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>
This commit is contained in:
svemagie
2026-03-18 20:53:38 +01:00
parent f14453368c
commit 3dda28d3dc
9 changed files with 774 additions and 4 deletions

View File

@@ -321,6 +321,65 @@ export class YouTubeClient {
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
*/