diff --git a/index.js b/index.js index a89c161..5b50e5b 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,8 @@ import { dashboardController } from "./lib/controllers/dashboard.js"; import { videosController } from "./lib/controllers/videos.js"; import { channelController } from "./lib/controllers/channel.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)); @@ -25,18 +27,40 @@ const defaults = { limits: { 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 { name = "YouTube channel endpoint"; 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; } 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() { @@ -60,12 +84,18 @@ export default class YouTubeEndpoint { /** * Protected routes (require authentication) - * Admin dashboard + * Admin dashboard + likes management */ get routes() { protectedRouter.get("/", dashboardController.get); 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; } @@ -77,6 +107,10 @@ export default class YouTubeEndpoint { publicRouter.get("/api/videos", videosController.api); publicRouter.get("/api/channel", channelController.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; } @@ -84,8 +118,24 @@ export default class YouTubeEndpoint { init(Indiekit) { Indiekit.addEndpoint(this); + // Register MongoDB collections + Indiekit.addCollection("youtubeMeta"); + // Store YouTube config in application for controller access Indiekit.config.application.youtubeConfig = this.options; 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); + } } } diff --git a/lib/controllers/likes.js b/lib/controllers/likes.js new file mode 100644 index 0000000..60e1375 --- /dev/null +++ b/lib/controllers/likes.js @@ -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 }); + } + }, +}; diff --git a/lib/likes-sync.js b/lib/likes-sync.js new file mode 100644 index 0000000..027b0bb --- /dev/null +++ b/lib/likes-sync.js @@ -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 "${escapeHtml(video.title)}" 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} + */ +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, """); +} diff --git a/lib/oauth.js b/lib/oauth.js new file mode 100644 index 0000000..7b3e25f --- /dev/null +++ b/lib/oauth.js @@ -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} + */ +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" }); +} diff --git a/lib/youtube-client.js b/lib/youtube-client.js index 9439389..10df641 100644 --- a/lib/youtube-client.js +++ b/lib/youtube-client.js @@ -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 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} + */ + 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 */ diff --git a/locales/de.json b/locales/de.json index 0682576..49562d9 100644 --- a/locales/de.json +++ b/locales/de.json @@ -21,6 +21,22 @@ "widget": { "description": "Vollständigen Kanal auf YouTube anzeigen", "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." + } } } } diff --git a/locales/en.json b/locales/en.json index 6459244..f1cb108 100644 --- a/locales/en.json +++ b/locales/en.json @@ -21,6 +21,22 @@ "widget": { "description": "View full channel on YouTube", "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." + } } } } diff --git a/package.json b/package.json index 5597445..2ac5226 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit", diff --git a/views/youtube-likes.njk b/views/youtube-likes.njk new file mode 100644 index 0000000..eddd2b0 --- /dev/null +++ b/views/youtube-likes.njk @@ -0,0 +1,53 @@ +{% extends "layouts/youtube.njk" %} + +{% block youtube %} +

{{ __("youtube.likes.title") }}

+ + {% if error %} + {{ prose({ text: error.message }) }} + {% else %} + + {# OAuth connection status #} +
+ {% if isConnected %} +
+ + {{ __("youtube.likes.connected") }} + +
+ {{ button({ type: "submit", text: __("youtube.likes.disconnect"), classes: "button--secondary" }) }} +
+
+ {% else %} +

{{ __("youtube.likes.description") }}

+ {{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }} + {% endif %} +
+ + {# Sync controls (only when connected) #} + {% if isConnected %} +
+ {% call section({ title: __("youtube.likes.sync") }) %} +
+ {{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }} +
+ + {% if lastSync %} +
+

+ {{ __("youtube.likes.lastSync") }}: + +

+

+ {{ lastSync.synced }} {{ __("youtube.likes.newLikes") }}, + {{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }}, + {{ lastSync.total }} {{ __("youtube.likes.totalLikes") }} +

+
+ {% endif %} + {% endcall %} +
+ {% endif %} + + {% endif %} +{% endblock %}