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:
@@ -264,6 +264,19 @@ export const api = {
|
|||||||
|
|
||||||
importFlickrStatus: () =>
|
importFlickrStatus: () =>
|
||||||
request<ArtStationImportStatus>("/api/v1/admin/import/flickr"),
|
request<ArtStationImportStatus>("/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<ArtStationImportStatus>("/api/v1/admin/import/vimeo"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ArtStationImportStatus {
|
export interface ArtStationImportStatus {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export function AdminDashboard() {
|
|||||||
const [flStatus, setFlStatus] = useState<ArtStationImportStatus | null>(null);
|
const [flStatus, setFlStatus] = useState<ArtStationImportStatus | null>(null);
|
||||||
const flPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const flPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const [vmUser, setVmUser] = useState("jmartgraphix");
|
||||||
|
const [vmDryRun, setVmDryRun] = useState(false);
|
||||||
|
const [vmPublish, setVmPublish] = useState(false);
|
||||||
|
const [vmStatus, setVmStatus] = useState<ArtStationImportStatus | null>(null);
|
||||||
|
const vmPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
function refreshStats() {
|
function refreshStats() {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.adminProjects({ perPage: 1 }),
|
api.adminProjects({ perPage: 1 }),
|
||||||
@@ -47,9 +53,11 @@ export function AdminDashboard() {
|
|||||||
refreshStats();
|
refreshStats();
|
||||||
api.importArtStationStatus().then(setImportStatus).catch(() => {});
|
api.importArtStationStatus().then(setImportStatus).catch(() => {});
|
||||||
api.importFlickrStatus().then(setFlStatus).catch(() => {});
|
api.importFlickrStatus().then(setFlStatus).catch(() => {});
|
||||||
|
api.importVimeoStatus().then(setVmStatus).catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
if (flPollRef.current) clearInterval(flPollRef.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 running = importStatus?.running;
|
||||||
const logLines = importStatus?.log?.slice(-40) || [];
|
const logLines = importStatus?.log?.slice(-40) || [];
|
||||||
const flRunning = flStatus?.running;
|
const flRunning = flStatus?.running;
|
||||||
const flLogLines = flStatus?.log?.slice(-40) || [];
|
const flLogLines = flStatus?.log?.slice(-40) || [];
|
||||||
|
const vmRunning = vmStatus?.running;
|
||||||
|
const vmLogLines = vmStatus?.log?.slice(-40) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
@@ -373,6 +424,97 @@ export function AdminDashboard() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-import" style={{ marginTop: "2.5rem", maxWidth: 720 }}>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", margin: "0 0 0.35rem" }}>Import from Vimeo</h2>
|
||||||
|
<p style={{ color: "var(--text-muted)", margin: "0 0 1rem", fontSize: "0.9rem" }}>
|
||||||
|
Pull public videos from your Vimeo profile. Tagged as <strong>Video</strong> (and{" "}
|
||||||
|
<strong>3D Animation</strong> when the description suggests 3D). Embeds play in-page;
|
||||||
|
existing entries are skipped.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="vm-user">Vimeo username</label>
|
||||||
|
<input
|
||||||
|
id="vm-user"
|
||||||
|
value={vmUser}
|
||||||
|
onChange={(e) => setVmUser(e.target.value)}
|
||||||
|
disabled={!!vmRunning}
|
||||||
|
placeholder="jmartgraphix"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-check-grid" style={{ marginBottom: "1rem" }}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={vmDryRun}
|
||||||
|
disabled={!!vmRunning}
|
||||||
|
onChange={(e) => setVmDryRun(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Dry run (list only, no writes)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={vmPublish}
|
||||||
|
disabled={!!vmRunning || vmDryRun}
|
||||||
|
onChange={(e) => setVmPublish(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Publish new imports immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-page__toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={!!vmRunning}
|
||||||
|
onClick={runVimeoImport}
|
||||||
|
>
|
||||||
|
{vmRunning ? "Import running…" : "Import from Vimeo"}
|
||||||
|
</button>
|
||||||
|
{vmStatus?.finishedAt && !vmRunning && (
|
||||||
|
<span style={{ color: "var(--text-dim)", fontSize: "0.85rem" }}>
|
||||||
|
Last run {new Date(vmStatus.finishedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vmStatus?.result && !vmRunning && (
|
||||||
|
<div
|
||||||
|
className="admin-msg admin-msg--ok"
|
||||||
|
style={{ marginTop: "1rem", whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
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(", ")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vmLogLines.length > 0 && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
lineHeight: 1.45,
|
||||||
|
maxHeight: 280,
|
||||||
|
overflow: "auto",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{vmLogLines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section style={{ marginTop: "2.5rem" }}>
|
<section style={{ marginTop: "2.5rem" }}>
|
||||||
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links for audiences</h2>
|
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links for audiences</h2>
|
||||||
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
|
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@
|
|||||||
"db:migrate": "npm run db:migrate -w server",
|
"db:migrate": "npm run db:migrate -w server",
|
||||||
"db:seed": "npm run db:seed -w server",
|
"db:seed": "npm run db:seed -w server",
|
||||||
"import:artstation": "npm run import:artstation -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": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"import:artstation": "tsx scripts/import-artstation.ts",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.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());
|
||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
getFlickrImportJobStatus,
|
getFlickrImportJobStatus,
|
||||||
startFlickrImportJob,
|
startFlickrImportJob,
|
||||||
} from "../services/flickr-import.js";
|
} from "../services/flickr-import.js";
|
||||||
|
import {
|
||||||
|
getVimeoImportJobStatus,
|
||||||
|
startVimeoImportJob,
|
||||||
|
} from "../services/vimeo-import.js";
|
||||||
|
|
||||||
export async function adminMetaRoutes(app: FastifyInstance) {
|
export async function adminMetaRoutes(app: FastifyInstance) {
|
||||||
// Categories
|
// Categories
|
||||||
@@ -355,4 +359,38 @@ export async function adminMetaRoutes(app: FastifyInstance) {
|
|||||||
status: getFlickrImportJobStatus(),
|
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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(/ /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<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" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user