Files
usda-vision/management-dashboard-web-app/src/features/video-streaming/services/videoApi.ts

283 lines
7.8 KiB
TypeScript
Executable File

/**
* Video Streaming API Service
*
* This service handles all API interactions for the video streaming feature.
* It provides a clean interface for components to interact with the video API
* without knowing the implementation details.
*/
import {
type VideoListResponse,
type VideoInfoResponse,
type VideoStreamingInfo,
type VideoListParams,
type ThumbnailParams,
} from '../types';
import { performanceMonitor } from '../utils/performanceMonitor';
// Configuration - Prefer env var; default to relative "/api" so Vite proxy can route to the API container
const API_BASE_URL = import.meta.env.VITE_VISION_API_URL || '/api';
/**
* Custom error class for video API errors
*/
export class VideoApiError extends Error {
public code: string;
public details?: unknown;
constructor(
code: string,
message: string,
details?: unknown
) {
super(message);
this.name = 'VideoApiError';
this.code = code;
this.details = details;
}
}
/**
* Helper function to handle API responses
*/
async function handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
throw new VideoApiError(
`HTTP_${response.status}`,
`API request failed: ${response.statusText}`,
{ status: response.status, body: errorText }
);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
throw new VideoApiError(
'INVALID_RESPONSE',
'Expected JSON response from API'
);
}
/**
* Build query string from parameters
*/
function buildQueryString(params: VideoListParams | ThumbnailParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
}
/**
* Video API Service Class
*/
export class VideoApiService {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
/**
* Get total count of videos with filters (without pagination)
*/
private totalCountCache = new Map<string, { count: number; timestamp: number }>();
private readonly CACHE_DURATION = 30000; // 30 seconds cache
private async getTotalCount(params: Omit<VideoListParams, 'limit' | 'offset' | 'page'>): Promise<number> {
// Create cache key from params
const cacheKey = JSON.stringify(params);
const cached = this.totalCountCache.get(cacheKey);
// Return cached result if still valid
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.count;
}
const queryString = buildQueryString({ ...params, limit: 1000 }); // Use high limit to get accurate total
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
const result = await handleApiResponse<VideoListResponse>(response);
const count = result.videos.length; // Since backend returns wrong total_count, count the actual videos
// Cache the result
this.totalCountCache.set(cacheKey, { count, timestamp: Date.now() });
return count;
}
/**
* Get list of videos with optional filtering
*/
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
return performanceMonitor.trackOperation('get_videos', async () => {
// Convert page-based params to offset-based for API compatibility
const apiParams = { ...params };
// If page is provided, convert to offset
if (params.page && params.limit) {
apiParams.offset = (params.page - 1) * params.limit;
delete apiParams.page; // Remove page param as API expects offset
}
const queryString = buildQueryString(apiParams);
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
const result = await handleApiResponse<VideoListResponse>(response);
// Add pagination metadata if page was requested
if (params.page && params.limit) {
// Get accurate total count by calling without pagination
const totalCount = await this.getTotalCount({
camera_name: params.camera_name,
start_date: params.start_date,
end_date: params.end_date,
include_metadata: params.include_metadata,
});
const totalPages = Math.ceil(totalCount / params.limit);
return {
...result,
total_count: totalCount, // Use accurate total count
page: params.page,
total_pages: totalPages,
has_next: params.page < totalPages,
has_previous: params.page > 1,
};
}
return result;
}, { params });
}
/**
* Get detailed information about a specific video
*/
async getVideoInfo(fileId: string): Promise<VideoInfoResponse> {
try {
const response = await fetch(`${this.baseUrl}/videos/${fileId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
return await handleApiResponse<VideoInfoResponse>(response);
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
`Failed to fetch video info for ${fileId}`,
{ originalError: error, fileId }
);
}
}
/**
* Get streaming information for a video
*/
async getStreamingInfo(fileId: string): Promise<VideoStreamingInfo> {
try {
const response = await fetch(`${this.baseUrl}/videos/${fileId}/info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
return await handleApiResponse<VideoStreamingInfo>(response);
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
`Failed to fetch streaming info for ${fileId}`,
{ originalError: error, fileId }
);
}
}
/**
* Get the streaming URL for a video
*/
getStreamingUrl(fileId: string): string {
return `${this.baseUrl}/videos/${fileId}/stream`;
}
/**
* Get the thumbnail URL for a video
*/
getThumbnailUrl(fileId: string, params: ThumbnailParams = {}): string {
const queryString = buildQueryString(params);
return `${this.baseUrl}/videos/${fileId}/thumbnail${queryString ? `?${queryString}` : ''}`;
}
/**
* Download thumbnail as blob
*/
async getThumbnailBlob(fileId: string, params: ThumbnailParams = {}): Promise<Blob> {
return performanceMonitor.trackOperation('get_thumbnail', async () => {
const url = this.getThumbnailUrl(fileId, params);
const response = await fetch(url);
if (!response.ok) {
throw new VideoApiError(
`HTTP_${response.status}`,
`Failed to fetch thumbnail: ${response.statusText}`,
{ status: response.status, fileId }
);
}
return await response.blob();
}, { fileId, params });
}
/**
* Check if the video API is available
*/
async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/videos/`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
return response.ok;
} catch {
return false;
}
}
}
// Export a default instance
export const videoApiService = new VideoApiService();
// Export utility functions
export { buildQueryString, handleApiResponse };