loomClient.ts•7.43 kB
export interface LoomConfig {
apiKey?: string
personalAccessToken?: string
baseUrl: string
}
export interface LoomTranscript {
text: string
segments: Array<{
text: string
start_time: number
end_time: number
confidence?: number
}>
}
export interface LoomVideoOwner {
id: string
name: string
email?: string
}
export interface LoomVideoMetadata {
id: string
name: string
description?: string
duration: number
created_at: string
updated_at: string
owner: LoomVideoOwner
privacy: 'public' | 'private' | 'password' | 'organization'
thumbnail_url: string
gif_thumbnail_url?: string
video_url?: string
embed_url: string
transcript_available: boolean
}
export interface VideoChapter {
id: string
title: string
start_time: number
end_time: number
}
export class LoomClient {
private config: LoomConfig
private headers: Record<string, string>
constructor(config: LoomConfig) {
this.config = {
baseUrl: 'https://api.loom.com/v2',
...config,
}
this.headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
}
if (config.personalAccessToken) {
this.headers['Authorization'] = `Bearer ${config.personalAccessToken}`
} else if (config.apiKey) {
this.headers['X-API-Key'] = config.apiKey
}
}
async getVideo(videoId: string): Promise<LoomVideoMetadata | null> {
try {
const response = await fetch(`${this.config.baseUrl}/videos/${videoId}`, {
method: 'GET',
headers: this.headers,
})
if (!response.ok) {
if (response.status === 404) {
console.error(`Loom video not found: ${videoId}`)
return null
}
if (response.status === 401 || response.status === 403) {
console.error(`Loom API authentication failed for video: ${videoId}`)
return null
}
throw new Error(`Failed to fetch Loom video: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching Loom video metadata:', error)
return null
}
}
async getTranscript(videoId: string): Promise<LoomTranscript | null> {
try {
const response = await fetch(`${this.config.baseUrl}/videos/${videoId}/transcript`, {
method: 'GET',
headers: this.headers,
})
if (!response.ok) {
if (response.status === 404) {
console.error(`Transcript not available for video: ${videoId}`)
return null
}
throw new Error(`Failed to fetch transcript: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching Loom transcript:', error)
return null
}
}
async getVideoChapters(videoId: string): Promise<VideoChapter[]> {
try {
const response = await fetch(`${this.config.baseUrl}/videos/${videoId}/chapters`, {
method: 'GET',
headers: this.headers,
})
if (!response.ok) {
if (response.status === 404) {
return []
}
throw new Error(`Failed to fetch chapters: ${response.statusText}`)
}
const data = await response.json()
return data.chapters || []
} catch (error) {
console.error('Error fetching Loom chapters:', error)
return []
}
}
/**
* Get video metadata without authentication (for public videos)
* This uses the OEmbed endpoint which doesn't require authentication
*/
async getPublicVideoInfo(videoId: string): Promise<any> {
try {
// Try the share page API endpoint first (which includes HLS sources)
const shareUrl = `https://www.loom.com/share/${videoId}`
const pageResponse = await fetch(shareUrl, {
method: 'GET',
headers: {
Accept: 'text/html',
'User-Agent': 'Mozilla/5.0 (compatible; LoomBot/1.0)',
},
})
if (pageResponse.ok) {
const html = await pageResponse.text()
// Extract the __NEXT_DATA__ JSON from the page
const scriptMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/
)
if (scriptMatch && scriptMatch[1]) {
try {
const nextData = JSON.parse(scriptMatch[1])
const videoData = nextData?.props?.pageProps?.videoData
if (videoData) {
console.log('Successfully extracted video data from share page')
return videoData
}
} catch (parseError) {
console.error('Failed to parse __NEXT_DATA__:', parseError)
}
}
}
// Fallback to OEmbed API
const oembedUrl = `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${videoId}&format=json`
const response = await fetch(oembedUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch public video info: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching public Loom video info:', error)
return null
}
}
/**
* Get video metadata (alias for getVideo for compatibility)
*/
async getVideoMetadata(videoId: string): Promise<any> {
// First try authenticated API
const videoData = await this.getVideo(videoId)
if (videoData) {
return videoData
}
// Fall back to public API
return await this.getPublicVideoInfo(videoId)
}
/**
* Get video transcript (returns in expected format for video analysis)
*/
async getVideoTranscript(videoId: string): Promise<any> {
const transcript = await this.getTranscript(videoId)
if (!transcript) return null
return {
segments: transcript.segments,
fullText: transcript.text,
}
}
/**
* Extract video ID from various Loom URL formats
*/
static extractVideoId(url: string): string | null {
const patterns = [
/loom\.com\/share\/([a-zA-Z0-9]+)/,
/loom\.com\/embed\/([a-zA-Z0-9]+)/,
/loom\.com\/v\/([a-zA-Z0-9]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
}