mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
The `content` property caused the video to render twice: once from the markdown body and once from the `likeOf` frontmatter in the Eleventy template. Likes now rely solely on `like-of` + `name`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
9.2 KiB
JavaScript
299 lines
9.2 KiB
JavaScript
/**
|
||
* YouTube Likes → Indiekit "like" posts sync
|
||
*
|
||
* On first run after connecting, snapshots all current liked video IDs
|
||
* as "known" without creating posts. Subsequent syncs only create posts
|
||
* for newly liked videos (ones not in the known set and not already
|
||
* in the posts collection).
|
||
*/
|
||
|
||
import { YouTubeClient } from "./youtube-client.js";
|
||
import { getValidAccessToken } from "./oauth.js";
|
||
|
||
/**
|
||
* Generate a deterministic slug from a YouTube video ID.
|
||
* @param {string} videoId
|
||
* @returns {string}
|
||
*/
|
||
function slugFromVideoId(videoId) {
|
||
return `yt-like-${videoId}`;
|
||
}
|
||
|
||
/**
|
||
* Fetch all current liked video IDs (up to maxPages) and store them
|
||
* as the baseline. No posts are created.
|
||
* @returns {Promise<number>} number of IDs snapshotted
|
||
*/
|
||
async function snapshotExistingLikes(db, client, accessToken, maxPages) {
|
||
const collection = db.collection("youtubeLikesSeen");
|
||
await collection.createIndex({ videoId: 1 }, { unique: true });
|
||
|
||
let pageToken;
|
||
let count = 0;
|
||
|
||
for (let page = 0; page < maxPages; page++) {
|
||
const result = await client.getLikedVideos(accessToken, 50, pageToken);
|
||
|
||
const ops = result.videos.map((v) => ({
|
||
updateOne: {
|
||
filter: { videoId: v.id },
|
||
update: { $setOnInsert: { videoId: v.id, seenAt: new Date() } },
|
||
upsert: true,
|
||
},
|
||
}));
|
||
|
||
if (ops.length > 0) {
|
||
await collection.bulkWrite(ops, { ordered: false });
|
||
count += ops.length;
|
||
}
|
||
|
||
pageToken = result.nextPageToken;
|
||
if (!pageToken) break;
|
||
}
|
||
|
||
// Mark that the baseline snapshot is done
|
||
await db.collection("youtubeMeta").updateOne(
|
||
{ key: "likes_baseline" },
|
||
{ $set: { key: "likes_baseline", completedAt: new Date(), count } },
|
||
{ upsert: true },
|
||
);
|
||
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* Prepare template properties by stripping internal mp-* and post-type keys,
|
||
* matching what Indiekit's micropub endpoint does before calling postTemplate.
|
||
* @param {object} properties
|
||
* @returns {object}
|
||
*/
|
||
function getTemplateProperties(properties) {
|
||
const templateProperties = structuredClone(properties);
|
||
const preserveMpProperties = ["mp-syndicate-to"];
|
||
|
||
for (const key in templateProperties) {
|
||
if (key.startsWith("mp-") && !preserveMpProperties.includes(key)) {
|
||
delete templateProperties[key];
|
||
}
|
||
if (key === "post-type") {
|
||
delete templateProperties[key];
|
||
}
|
||
}
|
||
|
||
return templateProperties;
|
||
}
|
||
|
||
/**
|
||
* Sync liked videos into the Indiekit posts collection.
|
||
*
|
||
* First-run behaviour: snapshots all existing likes as "seen" without
|
||
* creating posts. Only likes that appear after the baseline are turned
|
||
* into posts.
|
||
*
|
||
* @param {object} opts
|
||
* @param {import("mongodb").Db} opts.db
|
||
* @param {object} opts.youtubeConfig - endpoint options
|
||
* @param {object} opts.publication - Indiekit publication (with store, postTemplate, storeMessageTemplate)
|
||
* @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, baselined?: 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" };
|
||
}
|
||
|
||
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 });
|
||
|
||
// --- First-run: snapshot existing likes, create zero posts ---
|
||
const baseline = await db.collection("youtubeMeta").findOne({ key: "likes_baseline" });
|
||
if (!baseline) {
|
||
console.log("[YouTube] First sync — snapshotting existing likes (no posts will be created)");
|
||
const count = await snapshotExistingLikes(db, client, accessToken, maxPages);
|
||
console.log(`[YouTube] Baselined ${count} existing liked videos`);
|
||
return { synced: 0, skipped: 0, total: count, baselined: count };
|
||
}
|
||
|
||
// --- Normal sync: only create posts for new likes ---
|
||
const seenCollection = db.collection("youtubeLikesSeen");
|
||
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 videoId = video.id;
|
||
|
||
// Already seen? Skip.
|
||
const seen = await seenCollection.findOne({ videoId });
|
||
if (seen) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
// Mark as seen immediately (even if post insert fails, don't retry)
|
||
await seenCollection.updateOne(
|
||
{ videoId },
|
||
{ $setOnInsert: { videoId, seenAt: new Date() } },
|
||
{ upsert: true },
|
||
);
|
||
|
||
const slug = slugFromVideoId(videoId);
|
||
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||
|
||
// Also skip if a post already exists (belt-and-suspenders)
|
||
if (postsCollection) {
|
||
const existing = await postsCollection.findOne({
|
||
$or: [
|
||
{ "properties.mp-slug": slug },
|
||
{ "properties.like-of": videoUrl },
|
||
],
|
||
});
|
||
if (existing) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
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 postProperties = {
|
||
"post-type": "like",
|
||
"mp-slug": slug,
|
||
"like-of": videoUrl,
|
||
name: `${video.title} - ${video.channelTitle}`,
|
||
published: new Date().toISOString(),
|
||
url: postUrl,
|
||
visibility: "public",
|
||
"post-status": "draft",
|
||
"youtube-video-id": videoId,
|
||
"youtube-channel": video.channelTitle,
|
||
"youtube-thumbnail": video.thumbnail || "",
|
||
};
|
||
|
||
// Write markdown file to the store (e.g. GitHub)
|
||
if (publication?.postTemplate && publication?.store) {
|
||
try {
|
||
const templateProperties = getTemplateProperties(postProperties);
|
||
const content = await publication.postTemplate(templateProperties);
|
||
const message = publication.storeMessageTemplate
|
||
? publication.storeMessageTemplate({
|
||
action: "create",
|
||
result: "created",
|
||
fileType: "post",
|
||
postType: "like",
|
||
})
|
||
: `Create like post for ${videoId}`;
|
||
|
||
await publication.store.createFile(postPath, content, { message });
|
||
} catch (storeError) {
|
||
console.error(`[YouTube] Failed to write ${postPath} to store:`, storeError.message);
|
||
// Continue — still insert into MongoDB so it isn't retried
|
||
}
|
||
}
|
||
|
||
// Insert into MongoDB posts collection
|
||
const postDoc = { path: postPath, properties: postProperties };
|
||
if (postsCollection) {
|
||
await postsCollection.insertOne(postDoc);
|
||
}
|
||
synced++;
|
||
}
|
||
|
||
pageToken = result.nextPageToken;
|
||
if (!pageToken) break;
|
||
}
|
||
|
||
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;
|
||
|
||
async function run() {
|
||
const db = Indiekit.database;
|
||
if (!db) return;
|
||
|
||
const postsCollection = Indiekit.config?.application?.collections?.get("posts");
|
||
const publication = Indiekit.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`);
|
||
}
|
||
if (result.baselined) {
|
||
console.log(`[YouTube] Baseline complete: ${result.baselined} existing likes recorded`);
|
||
}
|
||
} catch (err) {
|
||
console.error("[YouTube] Likes sync error:", err.message);
|
||
}
|
||
}
|
||
|
||
setTimeout(() => {
|
||
run().catch(() => {});
|
||
setInterval(() => run().catch(() => {}), interval);
|
||
}, 15_000);
|
||
} |