mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
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:
56
index.js
56
index.js
@@ -6,6 +6,8 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
|
|||||||
import { videosController } from "./lib/controllers/videos.js";
|
import { videosController } from "./lib/controllers/videos.js";
|
||||||
import { channelController } from "./lib/controllers/channel.js";
|
import { channelController } from "./lib/controllers/channel.js";
|
||||||
import { liveController } from "./lib/controllers/live.js";
|
import { liveController } from "./lib/controllers/live.js";
|
||||||
|
import { likesController } from "./lib/controllers/likes.js";
|
||||||
|
import { startLikesSync } from "./lib/likes-sync.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -25,18 +27,40 @@ const defaults = {
|
|||||||
limits: {
|
limits: {
|
||||||
videos: 10,
|
videos: 10,
|
||||||
},
|
},
|
||||||
|
// OAuth 2.0 for liked-videos sync
|
||||||
|
oauth: {
|
||||||
|
clientId: process.env.YOUTUBE_OAUTH_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.YOUTUBE_OAUTH_CLIENT_SECRET || "",
|
||||||
|
},
|
||||||
|
// Likes sync settings
|
||||||
|
likes: {
|
||||||
|
syncInterval: 3_600_000, // 1 hour
|
||||||
|
maxPages: 3, // 50 likes per page → up to 150 likes per sync
|
||||||
|
autoSync: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class YouTubeEndpoint {
|
export default class YouTubeEndpoint {
|
||||||
name = "YouTube channel endpoint";
|
name = "YouTube channel endpoint";
|
||||||
|
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.options = { ...defaults, ...options };
|
this.options = {
|
||||||
|
...defaults,
|
||||||
|
...options,
|
||||||
|
oauth: { ...defaults.oauth, ...options.oauth },
|
||||||
|
likes: { ...defaults.likes, ...options.likes },
|
||||||
|
};
|
||||||
this.mountPath = this.options.mountPath;
|
this.mountPath = this.options.mountPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
get environment() {
|
get environment() {
|
||||||
return ["YOUTUBE_API_KEY", "YOUTUBE_CHANNEL_ID", "YOUTUBE_CHANNEL_HANDLE"];
|
return [
|
||||||
|
"YOUTUBE_API_KEY",
|
||||||
|
"YOUTUBE_CHANNEL_ID",
|
||||||
|
"YOUTUBE_CHANNEL_HANDLE",
|
||||||
|
"YOUTUBE_OAUTH_CLIENT_ID",
|
||||||
|
"YOUTUBE_OAUTH_CLIENT_SECRET",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
get localesDirectory() {
|
get localesDirectory() {
|
||||||
@@ -60,12 +84,18 @@ export default class YouTubeEndpoint {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected routes (require authentication)
|
* Protected routes (require authentication)
|
||||||
* Admin dashboard
|
* Admin dashboard + likes management
|
||||||
*/
|
*/
|
||||||
get routes() {
|
get routes() {
|
||||||
protectedRouter.get("/", dashboardController.get);
|
protectedRouter.get("/", dashboardController.get);
|
||||||
protectedRouter.post("/refresh", dashboardController.refresh);
|
protectedRouter.post("/refresh", dashboardController.refresh);
|
||||||
|
|
||||||
|
// Likes / OAuth routes (protected except callback)
|
||||||
|
protectedRouter.get("/likes", likesController.get);
|
||||||
|
protectedRouter.get("/likes/connect", likesController.connect);
|
||||||
|
protectedRouter.post("/likes/disconnect", likesController.disconnect);
|
||||||
|
protectedRouter.post("/likes/sync", likesController.sync);
|
||||||
|
|
||||||
return protectedRouter;
|
return protectedRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +107,10 @@ export default class YouTubeEndpoint {
|
|||||||
publicRouter.get("/api/videos", videosController.api);
|
publicRouter.get("/api/videos", videosController.api);
|
||||||
publicRouter.get("/api/channel", channelController.api);
|
publicRouter.get("/api/channel", channelController.api);
|
||||||
publicRouter.get("/api/live", liveController.api);
|
publicRouter.get("/api/live", liveController.api);
|
||||||
|
publicRouter.get("/api/likes", likesController.api);
|
||||||
|
|
||||||
|
// OAuth callback must be public (Google redirects here)
|
||||||
|
publicRouter.get("/likes/callback", likesController.callback);
|
||||||
|
|
||||||
return publicRouter;
|
return publicRouter;
|
||||||
}
|
}
|
||||||
@@ -84,8 +118,24 @@ export default class YouTubeEndpoint {
|
|||||||
init(Indiekit) {
|
init(Indiekit) {
|
||||||
Indiekit.addEndpoint(this);
|
Indiekit.addEndpoint(this);
|
||||||
|
|
||||||
|
// Register MongoDB collections
|
||||||
|
Indiekit.addCollection("youtubeMeta");
|
||||||
|
|
||||||
// Store YouTube config in application for controller access
|
// Store YouTube config in application for controller access
|
||||||
Indiekit.config.application.youtubeConfig = this.options;
|
Indiekit.config.application.youtubeConfig = this.options;
|
||||||
Indiekit.config.application.youtubeEndpoint = this.mountPath;
|
Indiekit.config.application.youtubeEndpoint = this.mountPath;
|
||||||
|
|
||||||
|
// Store database getter for controller access
|
||||||
|
Indiekit.config.application.getYoutubeDb = () => Indiekit.database;
|
||||||
|
|
||||||
|
// Start background likes sync if OAuth is configured and autoSync is on
|
||||||
|
if (
|
||||||
|
this.options.oauth?.clientId &&
|
||||||
|
this.options.oauth?.clientSecret &&
|
||||||
|
this.options.likes?.autoSync !== false &&
|
||||||
|
Indiekit.config.application.mongodbUrl
|
||||||
|
) {
|
||||||
|
startLikesSync(Indiekit, this.options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
lib/controllers/likes.js
Normal file
212
lib/controllers/likes.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Likes controller
|
||||||
|
*
|
||||||
|
* Handles the OAuth flow (connect / callback / disconnect)
|
||||||
|
* and manual sync trigger for liked videos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAuthUrl,
|
||||||
|
exchangeCode,
|
||||||
|
saveTokens,
|
||||||
|
loadTokens,
|
||||||
|
deleteTokens,
|
||||||
|
} from "../oauth.js";
|
||||||
|
import { syncLikes, getLastSyncStatus } from "../likes-sync.js";
|
||||||
|
|
||||||
|
export const likesController = {
|
||||||
|
/**
|
||||||
|
* GET /likes — show OAuth status & synced likes info
|
||||||
|
*/
|
||||||
|
async get(request, response, next) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
const db = request.app.locals.application.getYoutubeDb?.();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return response.render("youtube-likes", {
|
||||||
|
title: response.locals.__("youtube.likes.title"),
|
||||||
|
error: { message: "Database not available" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth = youtubeConfig?.oauth;
|
||||||
|
if (!oauth?.clientId) {
|
||||||
|
return response.render("youtube-likes", {
|
||||||
|
title: response.locals.__("youtube.likes.title"),
|
||||||
|
error: { message: response.locals.__("youtube.likes.error.noOAuth") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await loadTokens(db);
|
||||||
|
const isConnected = Boolean(tokens?.refreshToken);
|
||||||
|
const lastSync = await getLastSyncStatus(db);
|
||||||
|
|
||||||
|
response.render("youtube-likes", {
|
||||||
|
title: response.locals.__("youtube.likes.title"),
|
||||||
|
isConnected,
|
||||||
|
lastSync,
|
||||||
|
mountPath: request.baseUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Likes page error:", error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /likes/connect — redirect to Google OAuth
|
||||||
|
*/
|
||||||
|
async connect(request, response) {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
const oauth = youtubeConfig?.oauth;
|
||||||
|
|
||||||
|
if (!oauth?.clientId) {
|
||||||
|
return response.status(400).json({ error: "OAuth client ID not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${request.app.locals.application.url || ""}${request.baseUrl}/likes/callback`;
|
||||||
|
|
||||||
|
const authUrl = buildAuthUrl({
|
||||||
|
clientId: oauth.clientId,
|
||||||
|
redirectUri,
|
||||||
|
state: "youtube-likes",
|
||||||
|
});
|
||||||
|
|
||||||
|
response.redirect(authUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /likes/callback — handle Google OAuth callback
|
||||||
|
*/
|
||||||
|
async callback(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
const db = request.app.locals.application.getYoutubeDb?.();
|
||||||
|
const oauth = youtubeConfig?.oauth;
|
||||||
|
|
||||||
|
if (!db || !oauth?.clientId || !oauth?.clientSecret) {
|
||||||
|
return response.status(500).send("OAuth not properly configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, error } = request.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[YouTube] OAuth error:", error);
|
||||||
|
return response.redirect(`${request.baseUrl}/likes?error=${encodeURIComponent(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return response.redirect(`${request.baseUrl}/likes?error=no_code`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${request.app.locals.application.url || ""}${request.baseUrl}/likes/callback`;
|
||||||
|
|
||||||
|
const tokens = await exchangeCode({
|
||||||
|
code,
|
||||||
|
clientId: oauth.clientId,
|
||||||
|
clientSecret: oauth.clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveTokens(db, tokens);
|
||||||
|
|
||||||
|
console.log("[YouTube] OAuth tokens saved successfully");
|
||||||
|
response.redirect(`${request.baseUrl}/likes?connected=1`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] OAuth callback error:", error);
|
||||||
|
response.redirect(`${request.baseUrl}/likes?error=${encodeURIComponent(error.message)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /likes/disconnect — revoke and delete tokens
|
||||||
|
*/
|
||||||
|
async disconnect(request, response) {
|
||||||
|
try {
|
||||||
|
const db = request.app.locals.application.getYoutubeDb?.();
|
||||||
|
if (db) {
|
||||||
|
await deleteTokens(db);
|
||||||
|
}
|
||||||
|
response.redirect(`${request.baseUrl}/likes?disconnected=1`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Disconnect error:", error);
|
||||||
|
response.redirect(`${request.baseUrl}/likes?error=${encodeURIComponent(error.message)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /likes/sync — trigger manual sync
|
||||||
|
*/
|
||||||
|
async sync(request, response) {
|
||||||
|
try {
|
||||||
|
const { youtubeConfig } = request.app.locals.application;
|
||||||
|
const db = request.app.locals.application.getYoutubeDb?.();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return response.status(503).json({ error: "Database not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const postsCollection = request.app.locals.application.collections?.get("posts");
|
||||||
|
const publication = request.app.locals.publication;
|
||||||
|
|
||||||
|
const result = await syncLikes({
|
||||||
|
db,
|
||||||
|
youtubeConfig,
|
||||||
|
publication,
|
||||||
|
postsCollection,
|
||||||
|
maxPages: youtubeConfig.likes?.maxPages || 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.accepts("json")) {
|
||||||
|
return response.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.redirect(`${request.baseUrl}/likes?synced=${result.synced}&skipped=${result.skipped}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Manual sync error:", error);
|
||||||
|
if (request.accepts("json")) {
|
||||||
|
return response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
response.redirect(`${request.baseUrl}/likes?error=${encodeURIComponent(error.message)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/likes — public JSON API for synced likes
|
||||||
|
*/
|
||||||
|
async api(request, response) {
|
||||||
|
try {
|
||||||
|
const postsCollection = request.app.locals.application.collections?.get("posts");
|
||||||
|
|
||||||
|
if (!postsCollection) {
|
||||||
|
return response.status(503).json({ error: "Database not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(parseInt(request.query.limit, 10) || 20, 100);
|
||||||
|
const offset = parseInt(request.query.offset, 10) || 0;
|
||||||
|
|
||||||
|
const likes = await postsCollection
|
||||||
|
.find({ "properties.post-type": "like", "properties.youtube-video-id": { $exists: true } })
|
||||||
|
.sort({ "properties.published": -1 })
|
||||||
|
.skip(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const total = await postsCollection.countDocuments({
|
||||||
|
"properties.post-type": "like",
|
||||||
|
"properties.youtube-video-id": { $exists: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
likes: likes.map((l) => l.properties),
|
||||||
|
count: likes.length,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[YouTube] Likes API error:", error);
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
200
lib/likes-sync.js
Normal file
200
lib/likes-sync.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Likes → Indiekit "like" posts sync
|
||||||
|
*
|
||||||
|
* Fetches the authenticated user's liked videos and creates
|
||||||
|
* corresponding "like" posts via the Micropub posts collection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { YouTubeClient } from "./youtube-client.js";
|
||||||
|
import { getValidAccessToken } from "./oauth.js";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deterministic slug from a YouTube video ID.
|
||||||
|
* @param {string} videoId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function slugFromVideoId(videoId) {
|
||||||
|
return `yt-like-${videoId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync liked videos into the Indiekit posts collection.
|
||||||
|
*
|
||||||
|
* For each liked video that doesn't already have a corresponding post
|
||||||
|
* in the database we insert a new "like" post document.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {import("mongodb").Db} opts.db
|
||||||
|
* @param {object} opts.youtubeConfig - endpoint options
|
||||||
|
* @param {object} opts.publication - Indiekit publication config
|
||||||
|
* @param {import("mongodb").Collection} [opts.postsCollection]
|
||||||
|
* @param {number} [opts.maxPages=3] - max pages to fetch (50 likes/page)
|
||||||
|
* @returns {Promise<{synced: number, skipped: number, total: number, error?: string}>}
|
||||||
|
*/
|
||||||
|
export async function syncLikes({ db, youtubeConfig, publication, postsCollection, maxPages = 3 }) {
|
||||||
|
const { apiKey, oauth } = youtubeConfig;
|
||||||
|
if (!oauth?.clientId || !oauth?.clientSecret) {
|
||||||
|
return { synced: 0, skipped: 0, total: 0, error: "OAuth not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a valid access token (auto‑refreshes if needed)
|
||||||
|
let accessToken;
|
||||||
|
try {
|
||||||
|
accessToken = await getValidAccessToken(db, {
|
||||||
|
clientId: oauth.clientId,
|
||||||
|
clientSecret: oauth.clientSecret,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { synced: 0, skipped: 0, total: 0, error: `Token error: ${err.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return { synced: 0, skipped: 0, total: 0, error: "Not authorized – connect YouTube first" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new YouTubeClient({ apiKey: apiKey || "unused", cacheTtl: 0 });
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let total = 0;
|
||||||
|
let pageToken;
|
||||||
|
|
||||||
|
const publicationUrl = (publication?.me || "").replace(/\/+$/, "");
|
||||||
|
const likePostType = publication?.postTypes?.like;
|
||||||
|
|
||||||
|
for (let page = 0; page < maxPages; page++) {
|
||||||
|
const result = await client.getLikedVideos(accessToken, 50, pageToken);
|
||||||
|
total = result.totalResults;
|
||||||
|
|
||||||
|
for (const video of result.videos) {
|
||||||
|
const slug = slugFromVideoId(video.id);
|
||||||
|
const videoUrl = `https://www.youtube.com/watch?v=${video.id}`;
|
||||||
|
|
||||||
|
// Check if we already synced this like
|
||||||
|
if (postsCollection) {
|
||||||
|
const existing = await postsCollection.findOne({
|
||||||
|
$or: [
|
||||||
|
{ "properties.mp-slug": slug },
|
||||||
|
{ "properties.like-of": videoUrl },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the post path/url from the publication's like post type config
|
||||||
|
const postPath = likePostType?.post?.path
|
||||||
|
? likePostType.post.path.replace("{slug}", slug)
|
||||||
|
: `content/likes/${slug}.md`;
|
||||||
|
|
||||||
|
const postUrl = likePostType?.post?.url
|
||||||
|
? likePostType.post.url.replace("{slug}", slug)
|
||||||
|
: `${publicationUrl}/likes/${slug}/`;
|
||||||
|
|
||||||
|
const postDoc = {
|
||||||
|
path: postPath,
|
||||||
|
properties: {
|
||||||
|
"post-type": "like",
|
||||||
|
"mp-slug": slug,
|
||||||
|
"like-of": videoUrl,
|
||||||
|
name: `Liked "${video.title}" by ${video.channelTitle}`,
|
||||||
|
content: {
|
||||||
|
text: `Liked "${video.title}" by ${video.channelTitle} on YouTube`,
|
||||||
|
html: `Liked "<a href="${videoUrl}">${escapeHtml(video.title)}</a>" by ${escapeHtml(video.channelTitle)} on YouTube`,
|
||||||
|
},
|
||||||
|
published: video.publishedAt || new Date().toISOString(),
|
||||||
|
url: postUrl,
|
||||||
|
visibility: "public",
|
||||||
|
"post-status": "published",
|
||||||
|
"youtube-video-id": video.id,
|
||||||
|
"youtube-channel": video.channelTitle,
|
||||||
|
"youtube-thumbnail": video.thumbnail || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (postsCollection) {
|
||||||
|
await postsCollection.insertOne(postDoc);
|
||||||
|
}
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = result.nextPageToken;
|
||||||
|
if (!pageToken) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync metadata
|
||||||
|
await db.collection("youtubeMeta").updateOne(
|
||||||
|
{ key: "likes_sync" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
key: "likes_sync",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
synced,
|
||||||
|
skipped,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { synced, skipped, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last sync status.
|
||||||
|
* @param {import("mongodb").Db} db
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
export async function getLastSyncStatus(db) {
|
||||||
|
return db.collection("youtubeMeta").findOne({ key: "likes_sync" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start background periodic sync.
|
||||||
|
* @param {object} Indiekit
|
||||||
|
* @param {object} options - endpoint options
|
||||||
|
*/
|
||||||
|
export function startLikesSync(Indiekit, options) {
|
||||||
|
const interval = options.likes?.syncInterval || 3_600_000; // default 1 hour
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const db = Indiekit.database;
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const postsCollection = Indiekit.config?.application?.collections?.get("posts");
|
||||||
|
const publication = Indiekit.config?.publication;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncLikes({
|
||||||
|
db,
|
||||||
|
youtubeConfig: options,
|
||||||
|
publication,
|
||||||
|
postsCollection,
|
||||||
|
maxPages: options.likes?.maxPages || 3,
|
||||||
|
});
|
||||||
|
if (result.synced > 0) {
|
||||||
|
console.log(`[YouTube] Likes sync: ${result.synced} new, ${result.skipped} skipped`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[YouTube] Likes sync error:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First run after a short delay to let the DB connect
|
||||||
|
setTimeout(() => {
|
||||||
|
run().catch(() => {});
|
||||||
|
// Then repeat on interval
|
||||||
|
setInterval(() => run().catch(() => {}), interval);
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
164
lib/oauth.js
Normal file
164
lib/oauth.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* YouTube OAuth 2.0 helpers
|
||||||
|
*
|
||||||
|
* Uses Google's OAuth 2.0 endpoints to obtain a user token with
|
||||||
|
* `youtube.readonly` scope so we can read the authenticated user's
|
||||||
|
* liked‑videos list.
|
||||||
|
*
|
||||||
|
* Tokens (access + refresh) are persisted in a MongoDB collection
|
||||||
|
* (`youtubeMeta`) so the user only has to authorize once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
|
const SCOPES = "https://www.googleapis.com/auth/youtube.readonly";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Google OAuth authorization URL.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.clientId
|
||||||
|
* @param {string} opts.redirectUri
|
||||||
|
* @param {string} [opts.state]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function buildAuthUrl({ clientId, redirectUri, state }) {
|
||||||
|
const url = new URL(GOOGLE_AUTH_URL);
|
||||||
|
url.searchParams.set("client_id", clientId);
|
||||||
|
url.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", SCOPES);
|
||||||
|
url.searchParams.set("access_type", "offline");
|
||||||
|
url.searchParams.set("prompt", "consent");
|
||||||
|
if (state) url.searchParams.set("state", state);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange an authorization code for tokens.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.code
|
||||||
|
* @param {string} opts.clientId
|
||||||
|
* @param {string} opts.clientSecret
|
||||||
|
* @param {string} opts.redirectUri
|
||||||
|
* @returns {Promise<{access_token: string, refresh_token?: string, expires_in: number}>}
|
||||||
|
*/
|
||||||
|
export async function exchangeCode({ code, clientId, clientSecret, redirectUri }) {
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(`Token exchange failed: ${err.error_description || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh an access token using a refresh token.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.refreshToken
|
||||||
|
* @param {string} opts.clientId
|
||||||
|
* @param {string} opts.clientSecret
|
||||||
|
* @returns {Promise<{access_token: string, expires_in: number}>}
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken({ refreshToken, clientId, clientSecret }) {
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(`Token refresh failed: ${err.error_description || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist tokens to MongoDB.
|
||||||
|
* @param {import("mongodb").Db} db
|
||||||
|
* @param {object} tokens - { access_token, refresh_token?, expires_in }
|
||||||
|
*/
|
||||||
|
export async function saveTokens(db, tokens) {
|
||||||
|
const expiresAt = new Date(Date.now() + (tokens.expires_in || 3600) * 1000);
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
$set: {
|
||||||
|
key: "oauth_tokens",
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only overwrite refresh_token when a new one is provided
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
update.$set.refreshToken = tokens.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("youtubeMeta").updateOne(
|
||||||
|
{ key: "oauth_tokens" },
|
||||||
|
update,
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens from MongoDB.
|
||||||
|
* @param {import("mongodb").Db} db
|
||||||
|
* @returns {Promise<{accessToken: string, refreshToken: string, expiresAt: Date}|null>}
|
||||||
|
*/
|
||||||
|
export async function loadTokens(db) {
|
||||||
|
return db.collection("youtubeMeta").findOne({ key: "oauth_tokens" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a valid access token, refreshing if needed.
|
||||||
|
* @param {import("mongodb").Db} db
|
||||||
|
* @param {object} opts - { clientId, clientSecret }
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
export async function getValidAccessToken(db, { clientId, clientSecret }) {
|
||||||
|
const stored = await loadTokens(db);
|
||||||
|
if (!stored?.refreshToken) return null;
|
||||||
|
|
||||||
|
// If the access token hasn't expired yet (with 60s buffer), use it
|
||||||
|
if (stored.accessToken && stored.expiresAt && new Date(stored.expiresAt) > new Date(Date.now() + 60_000)) {
|
||||||
|
return stored.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
const fresh = await refreshAccessToken({
|
||||||
|
refreshToken: stored.refreshToken,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveTokens(db, fresh);
|
||||||
|
return fresh.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored tokens (disconnect).
|
||||||
|
* @param {import("mongodb").Db} db
|
||||||
|
*/
|
||||||
|
export async function deleteTokens(db) {
|
||||||
|
await db.collection("youtubeMeta").deleteOne({ key: "oauth_tokens" });
|
||||||
|
}
|
||||||
@@ -321,6 +321,65 @@ export class YouTubeClient {
|
|||||||
return `${minutes}:${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
|
* Clear all caches
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,6 +21,22 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"description": "Vollständigen Kanal auf YouTube anzeigen",
|
"description": "Vollständigen Kanal auf YouTube anzeigen",
|
||||||
"view": "YouTube-Kanal öffnen"
|
"view": "YouTube-Kanal öffnen"
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"title": "YouTube Likes",
|
||||||
|
"description": "Verbinde dein YouTube-Konto, um deine gelikten Videos als Like-Beiträge auf deinem Blog zu synchronisieren.",
|
||||||
|
"connect": "YouTube-Konto verbinden",
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"disconnect": "Trennen",
|
||||||
|
"sync": "Likes-Synchronisierung",
|
||||||
|
"syncNow": "Jetzt synchronisieren",
|
||||||
|
"lastSync": "Letzte Synchronisierung",
|
||||||
|
"newLikes": "neu",
|
||||||
|
"skippedLikes": "bereits synchronisiert",
|
||||||
|
"totalLikes": "insgesamt auf YouTube",
|
||||||
|
"error": {
|
||||||
|
"noOAuth": "YouTube OAuth ist nicht konfiguriert. Setze YOUTUBE_OAUTH_CLIENT_ID und YOUTUBE_OAUTH_CLIENT_SECRET."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"description": "View full channel on YouTube",
|
"description": "View full channel on YouTube",
|
||||||
"view": "Open YouTube Channel"
|
"view": "Open YouTube Channel"
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"title": "YouTube Likes",
|
||||||
|
"description": "Connect your YouTube account to sync your liked videos as like posts on your blog.",
|
||||||
|
"connect": "Connect YouTube Account",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"sync": "Likes Sync",
|
||||||
|
"syncNow": "Sync Now",
|
||||||
|
"lastSync": "Last sync",
|
||||||
|
"newLikes": "new",
|
||||||
|
"skippedLikes": "already synced",
|
||||||
|
"totalLikes": "total on YouTube",
|
||||||
|
"error": {
|
||||||
|
"noOAuth": "YouTube OAuth is not configured. Set YOUTUBE_OAUTH_CLIENT_ID and YOUTUBE_OAUTH_CLIENT_SECRET."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-youtube",
|
"name": "@rmdes/indiekit-endpoint-youtube",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
|
"description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
53
views/youtube-likes.njk
Normal file
53
views/youtube-likes.njk
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "layouts/youtube.njk" %}
|
||||||
|
|
||||||
|
{% block youtube %}
|
||||||
|
<h2>{{ __("youtube.likes.title") }}</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
{{ prose({ text: error.message }) }}
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# OAuth connection status #}
|
||||||
|
<div class="youtube-likes-status">
|
||||||
|
{% if isConnected %}
|
||||||
|
<div class="youtube-likes-connected">
|
||||||
|
<span class="youtube-likes-status__badge youtube-likes-status__badge--connected">
|
||||||
|
{{ __("youtube.likes.connected") }}
|
||||||
|
</span>
|
||||||
|
<form method="post" action="{{ mountPath }}/likes/disconnect" style="display:inline;">
|
||||||
|
{{ button({ type: "submit", text: __("youtube.likes.disconnect"), classes: "button--secondary" }) }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ __("youtube.likes.description") }}</p>
|
||||||
|
{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Sync controls (only when connected) #}
|
||||||
|
{% if isConnected %}
|
||||||
|
<div class="youtube-likes-sync" style="margin-block-start: var(--space-l);">
|
||||||
|
{% call section({ title: __("youtube.likes.sync") }) %}
|
||||||
|
<form method="post" action="{{ mountPath }}/likes/sync">
|
||||||
|
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if lastSync %}
|
||||||
|
<div class="youtube-likes-sync__info" style="margin-block-start: var(--space-s);">
|
||||||
|
<p>
|
||||||
|
{{ __("youtube.likes.lastSync") }}:
|
||||||
|
<time datetime="{{ lastSync.lastSyncAt }}">{{ lastSync.lastSyncAt }}</time>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ lastSync.synced }} {{ __("youtube.likes.newLikes") }},
|
||||||
|
{{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }},
|
||||||
|
{{ lastSync.total }} {{ __("youtube.likes.totalLikes") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user