diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 42fbb63..aef23cd 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -264,6 +264,19 @@ export const api = { importFlickrStatus: () => request("/api/v1/admin/import/flickr"), + + importVimeo: (body: { username?: string; dryRun?: boolean; publish?: boolean }) => + request<{ + ok: boolean; + message: string; + status: ArtStationImportStatus; + }>("/api/v1/admin/import/vimeo", { + method: "POST", + body: JSON.stringify(body), + }), + + importVimeoStatus: () => + request("/api/v1/admin/import/vimeo"), }; export interface ArtStationImportStatus { diff --git a/client/src/pages/admin/AdminDashboard.tsx b/client/src/pages/admin/AdminDashboard.tsx index cb4c592..db0d007 100644 --- a/client/src/pages/admin/AdminDashboard.tsx +++ b/client/src/pages/admin/AdminDashboard.tsx @@ -25,6 +25,12 @@ export function AdminDashboard() { const [flStatus, setFlStatus] = useState(null); const flPollRef = useRef | null>(null); + const [vmUser, setVmUser] = useState("jmartgraphix"); + const [vmDryRun, setVmDryRun] = useState(false); + const [vmPublish, setVmPublish] = useState(false); + const [vmStatus, setVmStatus] = useState(null); + const vmPollRef = useRef | null>(null); + function refreshStats() { Promise.all([ api.adminProjects({ perPage: 1 }), @@ -47,9 +53,11 @@ export function AdminDashboard() { refreshStats(); api.importArtStationStatus().then(setImportStatus).catch(() => {}); api.importFlickrStatus().then(setFlStatus).catch(() => {}); + api.importVimeoStatus().then(setVmStatus).catch(() => {}); return () => { if (pollRef.current) clearInterval(pollRef.current); if (flPollRef.current) clearInterval(flPollRef.current); + if (vmPollRef.current) clearInterval(vmPollRef.current); }; }, []); @@ -145,10 +153,53 @@ export function AdminDashboard() { } } + function startVimeoPolling() { + if (vmPollRef.current) clearInterval(vmPollRef.current); + vmPollRef.current = setInterval(async () => { + try { + const s = await api.importVimeoStatus(); + setVmStatus(s); + if (!s.running) { + if (vmPollRef.current) clearInterval(vmPollRef.current); + vmPollRef.current = null; + refreshStats(); + if (s.result) { + setMsg( + `Vimeo import finished: ${s.result.imported} new, ${s.result.skipped} skipped, ${s.result.errors} errors (${s.result.found} on profile)` + ); + } else if (s.error) { + setErr(s.error); + } + } + } catch { + /* keep polling */ + } + }, 2000); + } + + async function runVimeoImport() { + setErr(null); + setMsg(null); + try { + const res = await api.importVimeo({ + username: vmUser.trim() || "jmartgraphix", + dryRun: vmDryRun, + publish: vmPublish, + }); + setVmStatus(res.status); + setMsg(res.message); + if (res.status.running) startVimeoPolling(); + } catch (e) { + setErr((e as Error).message); + } + } + const running = importStatus?.running; const logLines = importStatus?.log?.slice(-40) || []; const flRunning = flStatus?.running; const flLogLines = flStatus?.log?.slice(-40) || []; + const vmRunning = vmStatus?.running; + const vmLogLines = vmStatus?.log?.slice(-40) || []; return (
@@ -373,6 +424,97 @@ export function AdminDashboard() { )} +
+

Import from Vimeo

+

+ Pull public videos from your Vimeo profile. Tagged as Video (and{" "} + 3D Animation when the description suggests 3D). Embeds play in-page; + existing entries are skipped. +

+ +
+ + setVmUser(e.target.value)} + disabled={!!vmRunning} + placeholder="jmartgraphix" + /> +
+ +
+ + +
+ +
+ + {vmStatus?.finishedAt && !vmRunning && ( + + Last run {new Date(vmStatus.finishedAt).toLocaleString()} + + )} +
+ + {vmStatus?.result && !vmRunning && ( +
+ Found {vmStatus.result.found} · imported {vmStatus.result.imported} · skipped{" "} + {vmStatus.result.skipped} · errors {vmStatus.result.errors} + {vmStatus.result.created.length > 0 && ( + <> + {"\n"}New: {vmStatus.result.created.map((c) => c.title).join(", ")} + + )} +
+ )} + + {vmLogLines.length > 0 && ( +
+            {vmLogLines.join("\n")}
+          
+ )} +
+

Quick links for audiences

    diff --git a/package.json b/package.json index f37bb2e..5e40d13 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "db:migrate": "npm run db:migrate -w server", "db:seed": "npm run db:seed -w server", "import:artstation": "npm run import:artstation -w server", - "import:flickr": "npm run import:flickr -w server" + "import:flickr": "npm run import:flickr -w server", + "import:vimeo": "npm run import:vimeo -w server" }, "devDependencies": { "concurrently": "^9.1.2" diff --git a/server/package.json b/server/package.json index bd59412..43e114e 100644 --- a/server/package.json +++ b/server/package.json @@ -13,7 +13,8 @@ "db:seed": "tsx prisma/seed.ts", "db:push": "prisma db push", "import:artstation": "tsx scripts/import-artstation.ts", - "import:flickr": "tsx scripts/import-flickr.ts" + "import:flickr": "tsx scripts/import-flickr.ts", + "import:vimeo": "tsx scripts/import-vimeo.ts" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/server/scripts/import-vimeo.ts b/server/scripts/import-vimeo.ts new file mode 100644 index 0000000..11463db --- /dev/null +++ b/server/scripts/import-vimeo.ts @@ -0,0 +1,38 @@ +/** + * Vimeo profile video importer CLI. + * + * npm run import:vimeo -- --user jmartgraphix + * npm run import:vimeo -- --user jmartgraphix --publish + * npm run import:vimeo -- --user jmartgraphix --dry-run + */ +import "dotenv/config"; +import { runVimeoImport } from "../src/services/vimeo-import.js"; +import { prisma } from "../src/lib/prisma.js"; + +function arg(name: string, fallback?: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1]; + return fallback; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +async function main() { + const result = await runVimeoImport({ + username: arg("user", "jmartgraphix"), + dryRun: hasFlag("dry-run"), + publish: hasFlag("publish"), + }); + console.log( + `Summary: found=${result.found} imported=${result.imported} skipped=${result.skipped} errors=${result.errors}` + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/server/src/routes/admin-meta.ts b/server/src/routes/admin-meta.ts index 097a5fd..edb6bb6 100644 --- a/server/src/routes/admin-meta.ts +++ b/server/src/routes/admin-meta.ts @@ -12,6 +12,10 @@ import { getFlickrImportJobStatus, startFlickrImportJob, } from "../services/flickr-import.js"; +import { + getVimeoImportJobStatus, + startVimeoImportJob, +} from "../services/vimeo-import.js"; export async function adminMetaRoutes(app: FastifyInstance) { // Categories @@ -355,4 +359,38 @@ export async function adminMetaRoutes(app: FastifyInstance) { status: getFlickrImportJobStatus(), }); }); + + // Vimeo import + app.get("/api/v1/admin/import/vimeo", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return getVimeoImportJobStatus(); + }); + + app.post("/api/v1/admin/import/vimeo", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const body = z + .object({ + username: z.string().min(1).optional().default("jmartgraphix"), + dryRun: z.boolean().optional().default(false), + publish: z.boolean().optional().default(false), + }) + .parse(req.body ?? {}); + + const { started, message } = startVimeoImportJob({ + username: body.username, + dryRun: body.dryRun, + publish: body.publish, + }); + + if (!started) { + return reply + .code(409) + .send({ ok: false, message, status: getVimeoImportJobStatus() }); + } + return reply.code(202).send({ + ok: true, + message, + status: getVimeoImportJobStatus(), + }); + }); } diff --git a/server/src/services/vimeo-import.ts b/server/src/services/vimeo-import.ts new file mode 100644 index 0000000..e6eef09 --- /dev/null +++ b/server/src/services/vimeo-import.ts @@ -0,0 +1,367 @@ +/** + * Vimeo profile video importer (CLI + admin API). + * Uses the public Simple API: /api/v2/{user}/videos.json + */ +import { Visibility } from "@prisma/client"; +import slugify from "slugify"; +import { prisma } from "../lib/prisma.js"; +import { downloadRemoteImage } from "../lib/media.js"; +import { indexProject, reindexAllProjects } from "../lib/typesense.js"; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +export interface VimeoImportOptions { + username?: string; + dryRun?: boolean; + publish?: boolean; + onLog?: (line: string) => void; +} + +export interface VimeoImportResult { + username: string; + dryRun: boolean; + found: number; + imported: number; + skipped: number; + errors: number; + log: string[]; + created: { title: string; slug: string }[]; +} + +export type VimeoJobStatus = { + running: boolean; + startedAt?: string; + finishedAt?: string; + result?: VimeoImportResult; + error?: string; + log: string[]; +}; + +let currentJob: VimeoJobStatus = { running: false, log: [] }; + +export function getVimeoImportJobStatus(): VimeoJobStatus { + return { ...currentJob, log: [...currentJob.log] }; +} + +function log(lines: string[], onLog: VimeoImportOptions["onLog"], line: string) { + lines.push(line); + currentJob.log.push(line); + onLog?.(line); + console.log(line); +} + +function stripHtml(html: string): string { + return html + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function parseUsername(input: string): string { + const m = input.match(/vimeo\.com\/(?:user\d+\/)?([^/?#]+)/i); + if (m) return m[1].replace(/^user/i, (s) => s); // keep path alias + // https://vimeo.com/jmartgraphix + const m2 = input.match(/vimeo\.com\/([^/?#]+)/i); + if (m2 && !/^\d+$/.test(m2[1])) return m2[1]; + return input.replace(/^@/, "").trim(); +} + +interface VimeoSimpleVideo { + id: number; + title: string; + description?: string; + url: string; + upload_date?: string; + thumbnail_small?: string; + thumbnail_medium?: string; + thumbnail_large?: string; + duration?: number; + tags?: string; + width?: number; + height?: number; +} + +async function listVideos(username: string): Promise { + // Simple API returns up to ~20; page=2 returns 400 if none — try a few pages + const all: VimeoSimpleVideo[] = []; + for (let page = 1; page <= 10; page++) { + const url = `https://vimeo.com/api/v2/${encodeURIComponent(username)}/videos.json?page=${page}`; + const res = await fetch(url, { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) break; + const data = (await res.json()) as VimeoSimpleVideo[] | { error?: string }; + if (!Array.isArray(data) || data.length === 0) break; + all.push(...data); + if (data.length < 20) break; + } + return all; +} + +function inferSoftware(description: string, tags: string): string[] { + const hay = `${description} ${tags}`.toLowerCase(); + const tools: string[] = []; + const map: [string, string][] = [ + ["blender", "Blender"], + ["davinci", "DaVinci Resolve"], + ["fusion", "Fusion"], + ["after effects", "After Effects"], + ["modo", "Modo"], + ["maya", "Maya"], + ["cinema 4d", "Cinema 4D"], + ["premiere", "Premiere Pro"], + ]; + for (const [needle, label] of map) { + if (hay.includes(needle) && !tools.includes(label)) tools.push(label); + } + if (tools.length === 0) tools.push("Video"); + return tools; +} + +function extraCategorySlugs(description: string, title: string): string[] { + const hay = `${description} ${title}`.toLowerCase(); + const slugs: string[] = ["video"]; + if ( + hay.includes("3d") || + hay.includes("animation") || + hay.includes("blender") || + hay.includes("modo") || + hay.includes("maya") || + hay.includes("modeling") + ) { + slugs.push("3d-animation"); + } + return slugs; +} + +async function uniqueSlug(title: string): Promise { + const base = slugify(title, { lower: true, strict: true }) || "video"; + let slug = base; + let n = 2; + while (await prisma.project.findUnique({ where: { slug } })) { + slug = `${base}-${n++}`; + } + return slug; +} + +export async function runVimeoImport( + options: VimeoImportOptions = {} +): Promise { + const username = parseUsername(options.username || "jmartgraphix"); + const dryRun = !!options.dryRun; + const lines: string[] = []; + const onLog = options.onLog; + const created: { title: string; slug: string }[] = []; + let imported = 0; + let skipped = 0; + let errors = 0; + + log(lines, onLog, `Vimeo import (user=${username}, dryRun=${dryRun})`); + + const videos = await listVideos(username); + log(lines, onLog, `Found ${videos.length} public videos`); + + const cats = await prisma.category.findMany(); + const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c.id])); + + for (const video of videos) { + const sourceUrl = video.url || `https://vimeo.com/${video.id}`; + const title = (video.title || "").trim() || `Vimeo ${video.id}`; + try { + const existing = await prisma.project.findFirst({ where: { sourceUrl } }); + if (existing) { + log(lines, onLog, ` skip (exists): ${title}`); + skipped++; + continue; + } + + const desc = stripHtml(video.description || ""); + const tags = (video.tags || "") + .split(/[,\s]+/) + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 20); + const software = inferSoftware(desc, video.tags || ""); + const catSlugs = extraCategorySlugs(desc, title); + + log(lines, onLog, ` import: ${title} (${video.duration ?? "?"}s)`); + if (dryRun) { + imported++; + continue; + } + + const visibility = options.publish ? Visibility.published : Visibility.draft; + const slug = await uniqueSlug(title); + const project = await prisma.project.create({ + data: { + title, + slug, + description: desc, + shortDescription: desc.slice(0, 280) || null, + date: video.upload_date ? new Date(video.upload_date) : null, + software, + externalLinks: [{ label: "Vimeo", url: sourceUrl }], + featured: false, + displayPriority: 100, + visibility, + sourceUrl, + sourcePlatform: "vimeo", + publishedAt: visibility === Visibility.published ? new Date() : null, + }, + }); + + for (const cs of catSlugs) { + const cid = bySlug[cs]; + if (!cid) continue; + await prisma.projectCategory.create({ + data: { projectId: project.id, categoryId: cid }, + }); + } + + for (const t of tags) { + const tslug = slugify(t, { lower: true, strict: true }); + if (!tslug) continue; + const tag = await prisma.tag.upsert({ + where: { slug: tslug }, + create: { name: t, slug: tslug }, + update: {}, + }); + await prisma.projectTag.upsert({ + where: { projectId_tagId: { projectId: project.id, tagId: tag.id } }, + create: { projectId: project.id, tagId: tag.id }, + update: {}, + }); + } + + // Thumbnail image for cards + let thumbnailId: string | null = null; + const thumbUrl = + video.thumbnail_large || video.thumbnail_medium || video.thumbnail_small; + if (thumbUrl) { + const saved = await downloadRemoteImage(thumbUrl); + if (saved) { + const media = await prisma.media.create({ + data: { + projectId: project.id, + type: "image", + url: saved.url, + thumbnailUrl: saved.thumbnailUrl, + filename: saved.filename, + mimeType: saved.mimeType, + width: saved.width, + height: saved.height, + sizeBytes: saved.sizeBytes, + alt: title, + sortOrder: 0, + }, + }); + thumbnailId = media.id; + } + } + + // Vimeo embed media + await prisma.media.create({ + data: { + projectId: project.id, + type: "video", + url: `https://player.vimeo.com/video/${video.id}`, + externalUrl: sourceUrl, + videoSource: "vimeo", + videoId: String(video.id), + caption: title, + sortOrder: 1, + width: video.width, + height: video.height, + }, + }); + + if (thumbnailId) { + await prisma.project.update({ + where: { id: project.id }, + data: { thumbnailId }, + }); + } + + await indexProject(project.id); + imported++; + created.push({ title, slug: project.slug }); + log(lines, onLog, ` created ${visibility} project ${project.slug}`); + await new Promise((r) => setTimeout(r, 300)); + } catch (err) { + errors++; + log(lines, onLog, ` error importing ${title}: ${(err as Error).message}`); + } + } + + if (!dryRun && imported > 0) { + try { + const n = await reindexAllProjects(); + log(lines, onLog, `Typesense reindexed (${n} projects)`); + } catch (err) { + log(lines, onLog, `Typesense reindex warning: ${(err as Error).message}`); + } + } + + log( + lines, + onLog, + `Done. imported=${imported} skipped=${skipped} errors=${errors}` + ); + + return { + username, + dryRun, + found: videos.length, + imported, + skipped, + errors, + log: lines, + created, + }; +} + +export function startVimeoImportJob(options: VimeoImportOptions = {}): { + started: boolean; + message: string; +} { + if (currentJob.running) { + return { started: false, message: "A Vimeo import is already running" }; + } + currentJob = { + running: true, + startedAt: new Date().toISOString(), + log: [], + }; + runVimeoImport(options) + .then((result) => { + currentJob = { + running: false, + startedAt: currentJob.startedAt, + finishedAt: new Date().toISOString(), + result, + log: result.log, + }; + }) + .catch((err) => { + currentJob = { + running: false, + startedAt: currentJob.startedAt, + finishedAt: new Date().toISOString(), + error: (err as Error).message, + log: currentJob.log, + }; + }); + return { started: true, message: "Vimeo import started" }; +}