"use server"; // Server actions for social (posts) channels. Kept separate from // pipelineActions.ts because runPipelineAction is video-shaped throughout — // disk-space gates, download archives, yt-dlp handling overrides — none of // which applies to a post fetch. A social channel has exactly two stages // (Fetch → Index), and this file owns the first. import path from "node:path"; import { revalidatePath } from "next/cache"; import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { downloadQueueKey, resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { patchChannelConfig, readChannelConfig, } from "yt-dlp-transcript-common/controller/channels"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import { emptyAccountOlderProblem, fetchPosts, FULL_AND_OLDER_REFUSAL, OLDER_FROM_REFUSAL, olderPostsProblem, } from "yt-dlp-transcript-common/controller/fetchPosts"; import { isUtcDay } from "yt-dlp-transcript-common/social/olderBackfill"; import { checkPostAvailability, type CheckPostAvailabilityMode, } from "yt-dlp-transcript-common/controller/checkPostAvailability"; import { capturePosts, capturePostsProblem, NOTHING_TO_CAPTURE, nothingToCapture, strayCaptureIds, strayIdsRefusal, } from "yt-dlp-transcript-common/controller/capturePosts"; import { readPostFetchState, readSeenPostIds, } from "yt-dlp-transcript-common/lib/posts-server"; import { getSocialFetcher, listSocialFetchers, resolveSocialFetcher, } from "yt-dlp-transcript-common/social/fetchers"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { importForumPages } from "yt-dlp-transcript-common/controller/importForumPages"; import { connectForumSession, readForumSessionStatus, type ForumSessionRecord, type ForumSessionStatus, } from "yt-dlp-transcript-common/social/forumSession"; import { parseXenforoThreadUrl } from "yt-dlp-transcript-common/social/xenforoParse"; // Switch which SocialFetcher drives this channel. The fetchers differ in what // they can actually reach — for X, gallery-dl needs credentials for depth, // while the Nitter path needs none — so this is an operational choice that // belongs on the channel page rather than only at creation time. export async function setPostFetcherAction( slug: string, fetcherId: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config)) { return { ok: false, error: `${slug} is not a social channel.` }; } await registerBuiltinSocialFetchers(); const wanted = fetcherId.trim(); const fetcher = getSocialFetcher(wanted); if (!fetcher) return { ok: false, error: `Unknown fetcher: ${wanted}` }; if (fetcher.platform !== config.platform) { return { ok: false, error: `${fetcher.label} handles ${fetcher.platform}, not ${config.platform}.`, }; } await patchChannelConfig(paths, slug, { postFetcher: wanted }); revalidatePath(`/channels/${slug}`); return { ok: true }; } // The fetchers that can drive this channel's platform, as client-safe // descriptors for the picker. export async function listPostFetchersFor( platform: string, ): Promise<{ id: string; label: string }[]> { await registerBuiltinSocialFetchers(); return listSocialFetchers() .filter((f) => f.platform === platform) .map((f) => ({ id: f.id, label: f.label })); } // Deferred side-effect imports so the heavy fetchers stay out of any module // graph a client component pulls in. async function registerBuiltinSocialFetchers(): Promise { await import("yt-dlp-transcript-common/social/blueskyFetcher"); await import("yt-dlp-transcript-common/social/xGalleryDlFetcher"); await import("yt-dlp-transcript-common/social/xPlaywrightFetcher"); await import("yt-dlp-transcript-common/social/xNitterFetcher"); await import("yt-dlp-transcript-common/social/xenforoFetcher"); } // The posts analogue of the video availability check. export async function checkPostAvailabilityAction( slug: string, queueKey?: string, mode?: CheckPostAvailabilityMode, limit?: number, ): Promise { const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config)) { return { ok: false, error: `${slug} is not a social channel.` }; } const key = resolveQueueKey(downloadQueueKey(config), queueKey); return runManagedFunction({ kind: "check-post-availability", queueKey: key, paths, channelSlug: slug, spec: { kind: "check-post-availability", slug, params: { queueKey, mode, limit }, }, fn: async (onLog, signal) => { const result = await checkPostAvailability({ paths, slug, mode, limit, onLog, signal, }); safeRevalidate([`/channels/${slug}`]); if (!result.ok) throw new Error(result.error ?? "Availability check failed"); }, }); } // `older` walks the account's history backwards from the oldest archived post // (the fetcher's `fetchOlder` — X: search windows) instead of fetching new // posts; `floor` (YYYY-MM-DD) is the date that walk stops at. Both are refused // HERE, before a job exists, when they cannot run: with `full`, on a fetcher // that has no older walk, with a floor that is not a date, or on an account // that shows no posts (emptyAccountOlderProblem) unless `force`. export async function fetchPostsAction( slug: string, queueKey?: string, full?: boolean, limit?: number, older?: boolean, floor?: string, force?: boolean, // A page walker's cap (a forum thread: its latest N pages this run). pages?: number, // The older walk's fresh start date (FetchPostsOptions.from). from?: string, ): Promise { if (full && older) return { ok: false, error: FULL_AND_OLDER_REFUSAL }; if (pages !== undefined && !(Number.isInteger(pages) && pages > 0)) { return { ok: false, error: "Pages must be a whole number above 0." }; } if (floor !== undefined && !older) { return { ok: false, error: "A floor date applies only to an older-posts fetch." }; } if (from !== undefined && !older) return { ok: false, error: OLDER_FROM_REFUSAL }; if (from !== undefined && !isUtcDay(from)) { return { ok: false, error: `"${from}" is not a date (YYYY-MM-DD).` }; } if (force && !older) { return { ok: false, error: "\"force\" applies only to an older-posts fetch." }; } if (floor !== undefined && !isUtcDay(floor)) { return { ok: false, error: `"${floor}" is not a date (YYYY-MM-DD).` }; } const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config)) { return { ok: false, error: `${slug} is not a social channel.` }; } if (older) { await registerBuiltinSocialFetchers(); const problem = olderPostsProblem( resolveSocialFetcher(config.postFetcher, config.url), ); if (problem) return { ok: false, error: problem }; if (!force) { const channelRoot = path.join(paths.channelsDir, slug); const empty = emptyAccountOlderProblem( (await readSeenPostIds(channelRoot)).size, await readPostFetchState(channelRoot), ); if (empty) return { ok: false, error: empty }; } } // queueKeyForUrl() already routes x.com / bsky.app to platform:x.com / // platform:bsky.app, so per-platform serialization and the existing 429 // backoff come free with no new queue plumbing. const key = resolveQueueKey(downloadQueueKey(config), queueKey); return runManagedFunction({ kind: "fetch-posts", queueKey: key, paths, channelSlug: slug, spec: { kind: "fetch-posts", slug, params: { queueKey, full, limit, older, floor, force, ...(pages ? { pages } : {}), ...(from ? { from } : {}) }, }, fn: async (onLog, signal, _progress, ctx) => { const result = await fetchPosts({ paths, slug, settings: getSettings(), full, older, floor, from, force, limit, pages, onLog, signal, // A drained fetch keeps its resume point and returns ok: the job // ends done, not failed. drain: ctx.drainSignal, }); safeRevalidate([`/channels/${slug}`]); // Surface a failed fetch as a failed JOB (the managed wrapper turns a // throw into status "failed"), so it shows up in the jobs list the same // way a failed download does rather than silently logging. if (!result.ok) throw new Error(result.error ?? "Post fetch failed"); }, }); } // A screenshot and the attached media of specific archived posts, into the // channel's `posts-media//`. On the platform queue, as a fetch is, so the // two never run against the same source at once. Refused HERE, before a job // exists: nothing asked for, no ids, a channel that is not social, a fetcher // that cannot capture, or an id that is not in the channel's posts archive // (named). Posts already captured are skipped unless `force`. A post that // links to an X Article gets the article too unless `articles` is false. export async function capturePostsAction( slug: string, ids: string[], queueKey?: string, shots?: boolean, media?: boolean, force?: boolean, articles?: boolean, ): Promise { if (nothingToCapture({ shots, media, articles })) return { ok: false, error: NOTHING_TO_CAPTURE }; const wanted = [...new Set(ids)]; if (wanted.length === 0) return { ok: false, error: "No post ids to capture." }; const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config)) { return { ok: false, error: `${slug} is not a social channel.` }; } await registerBuiltinSocialFetchers(); const problem = capturePostsProblem( resolveSocialFetcher(config.postFetcher, config.url), ); if (problem) return { ok: false, error: problem }; const stray = strayCaptureIds( wanted, await readSeenPostIds(path.join(paths.channelsDir, slug)), ); if (stray) return { ok: false, error: strayIdsRefusal(slug, stray) }; const key = resolveQueueKey(downloadQueueKey(config), queueKey); return runManagedFunction({ kind: "capture-posts", queueKey: key, paths, channelSlug: slug, spec: { kind: "capture-posts", slug, params: { queueKey, ids: wanted, shots, media, force, articles }, }, fn: async (onLog, signal, _progress, ctx) => { const result = await capturePosts({ paths, slug, settings: getSettings(), ids: wanted, shots, media, force, articles, onLog, signal, drain: ctx.drainSignal, }); safeRevalidate([`/channels/${slug}`]); if (!result.ok) throw new Error(result.error ?? "Post capture failed"); }, }); } // --- forum threads (platform "xenforo") ------------------------------------------ async function forumChannel( slug: string, ): Promise<{ ok: true; url: string; host: string } | { ok: false; error: string }> { const config = await readChannelConfig(getPaths(), slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config) || config.platform !== "xenforo") { return { ok: false, error: `${slug} is not a forum-thread channel.` }; } const thread = parseXenforoThreadUrl(config.url ?? ""); if (!thread) return { ok: false, error: `${slug}'s URL is not a XenForo thread URL.` }; return { ok: true, url: thread.base, host: thread.host }; } export async function forumSessionStatusAction( slug: string, ): Promise<{ ok: true; status: ForumSessionStatus } | { ok: false; error: string }> { const ch = await forumChannel(slug); if (!ch.ok) return ch; return { ok: true, status: await readForumSessionStatus(getPaths(), ch.host) }; } // CONNECT: a HEADED browser window on the forum host's profile, opened at the // thread on the machine running the editor, for the operator to clear a check, // answer a captcha or log in — then close. The headless fetcher and capture // reuse the profile. Only ever this explicit action opens a window; a fetch // that meets a check it cannot clear stops and says to come here. export async function connectForumSessionAction( slug: string, ): Promise<{ ok: true; record: ForumSessionRecord } | { ok: false; error: string }> { const ch = await forumChannel(slug); if (!ch.ok) return ch; try { const record = await connectForumSession(getPaths(), ch.url, { onLog: (line) => console.log(`[forum-session] ${line}`), }); revalidatePath(`/channels/${slug}`); return { ok: true, record }; } catch (e) { return { ok: false, error: (e as Error).message }; } } // IMPORT: thread pages saved from a browser ("Save page as", .html), read from // a path on the editor's machine — a file or a directory of them. New posts // are appended, edited ones updated. A job on the channel's platform queue, so // it never writes beside a fetch. export async function importForumPagesAction( slug: string, inputPath: string, queueKey?: string, ): Promise { const wanted = (inputPath ?? "").trim(); if (!wanted) return { ok: false, error: "Name a saved page or a directory of them." }; const ch = await forumChannel(slug); if (!ch.ok) return ch; const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; const key = resolveQueueKey(downloadQueueKey(config), queueKey); return runManagedFunction({ kind: "import-forum-pages", queueKey: key, paths, channelSlug: slug, fn: async (onLog) => { const result = await importForumPages({ paths, slug, inputs: [wanted], onLog }); for (const p of result.pages) { if (p.skipped) onLog(`skipped ${p.file}: ${p.skipped}`); } safeRevalidate([`/channels/${slug}`]); if (!result.ok) throw new Error(result.error ?? "Import failed"); }, }); }