Add Vimeo video import with embeds and admin re-run

Import public videos from jmartgraphix as Video (and 3D Animation
when relevant), with player embeds, thumbnails, and dashboard UI.
This commit is contained in:
2026-07-24 12:13:23 -04:00
parent 6ff7fde867
commit b924ca8a62
7 changed files with 602 additions and 2 deletions
+38
View File
@@ -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(),
});
});
}
+367
View File
@@ -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(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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<VimeoSimpleVideo[]> {
// 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<string> {
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<VimeoImportResult> {
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" };
}