Files
classroomio/apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts
T

427 lines
12 KiB
TypeScript

import { presignApi } from '$features/course/api/presign.svelte';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
AssetStorageSummary,
AssetTranscriptPayload,
AssetUsage,
AssetUsageGraph,
CreateAssetRequest,
GetAssetTranscriptRequest,
GetAssetUsageRequest,
GetYouTubeMetadataRequest,
ListAssetsRequest,
OrganizationAsset,
UpdateAssetData,
UpdateAssetRequest,
YouTubeMetadata
} from '../utils/types';
import type {
TAssetAttach,
TAssetCreateUpload,
TAssetDetach,
TAssetListQuery,
TAssetStorageQuery,
TAssetUpdate,
TYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import {
ZAssetAttach,
ZAssetCreateUpload,
ZAssetDetach,
ZAssetListQuery,
ZAssetStorageQuery,
ZAssetUpdate,
ZYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import { mapAssetToLessonVideo } from '../utils/media-manager-utils';
import type { AttachAssetRequest } from '../utils/types';
import type { DetachAssetRequest } from '../utils/types';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import type { StartAssetTranscriptionRequest } from '$features/jobs/utils/types';
export class MediaApi extends BaseApiWithErrors {
assets = $state<OrganizationAsset[]>([]);
storageSummary = $state<AssetStorageSummary | null>(null);
pagination = $state<{
page: number;
limit: number;
total: number;
totalPages: number;
} | null>(null);
private normalizeDurationSeconds(value: number | null | undefined): number | undefined {
if (value == null || !Number.isFinite(value)) {
return undefined;
}
return Math.max(0, Math.round(value));
}
async listAssets(query: Partial<TAssetListQuery> = {}) {
const parsed = ZAssetListQuery.partial().safeParse(query);
if (!parsed.success) {
this.errors = mapZodErrorsToTranslations(parsed.error);
return;
}
await this.execute<ListAssetsRequest>({
requestFn: () => classroomio.organization.assets.$get({ query: parsed.data }),
logContext: 'listing organization assets',
onSuccess: (response) => {
this.assets = response.data;
this.pagination = response.pagination;
},
onError: () => {
snackbar.error('snackbar.media_manager.list_failed');
}
});
}
async getStorageSummary(query: Partial<TAssetStorageQuery> = {}) {
const parsed = ZAssetStorageQuery.partial().safeParse(query);
if (!parsed.success) {
this.errors = mapZodErrorsToTranslations(parsed.error);
return;
}
await this.execute<typeof classroomio.organization.assets.storage.$get>({
requestFn: () => classroomio.organization.assets.storage.$get({ query: parsed.data }),
logContext: 'fetching media manager storage summary',
onSuccess: (response) => {
this.storageSummary = response.data;
},
onError: () => {
snackbar.error('snackbar.media_manager.storage_failed');
}
});
}
async getYouTubeMetadata(url: string): Promise<YouTubeMetadata | null> {
const query: TYouTubeMetadataQuery = { url };
const result = ZYouTubeMetadataQuery.safeParse(query);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
let metadata: YouTubeMetadata | null = null;
await this.execute<GetYouTubeMetadataRequest>({
requestFn: () =>
classroomio.organization.assets['youtube-metadata'].$get({
query: result.data
}),
logContext: 'resolving YouTube metadata',
onSuccess: (response) => {
metadata = response.data;
}
});
return metadata;
}
async createAsset(fields: TAssetCreateUpload): Promise<OrganizationAsset | null> {
const normalizedFields = {
...fields,
durationSeconds: this.normalizeDurationSeconds(fields.durationSeconds)
};
const result = ZAssetCreateUpload.safeParse(normalizedFields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
let asset: OrganizationAsset | null = null;
await this.execute<CreateAssetRequest>({
requestFn: () =>
classroomio.organization.assets.$post({
json: result.data
}),
logContext: 'creating media asset',
onSuccess: (response) => {
asset = response.data;
},
onError: () => {
snackbar.error('snackbar.media_manager.create_failed');
}
});
return asset;
}
async updateAsset(assetId: string, fields: TAssetUpdate): Promise<UpdateAssetData | null> {
const normalizedFields = {
...fields,
durationSeconds: this.normalizeDurationSeconds(fields.durationSeconds)
};
const result = ZAssetUpdate.safeParse(normalizedFields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
let updated: UpdateAssetData | null = null;
await this.execute<UpdateAssetRequest>({
requestFn: () =>
classroomio.organization.assets[':assetId'].$put({
param: { assetId },
json: result.data
}),
logContext: 'updating media asset',
onSuccess: (response) => {
updated = response.data;
this.assets = this.assets.map((asset) => (asset.id === response.data.id ? response.data : asset));
},
onError: () => {
snackbar.error('snackbar.media_manager.update_failed');
}
});
return updated;
}
async getAssetUsage(assetId: string): Promise<AssetUsageGraph | null> {
let usage: AssetUsageGraph | null = null;
await this.execute<GetAssetUsageRequest>({
requestFn: () =>
classroomio.organization.assets[':assetId'].usage.$get({
param: { assetId }
}),
logContext: 'fetching media asset usage',
onSuccess: (response) => {
usage = response.data;
},
onError: () => {
snackbar.error('snackbar.media_manager.usage_failed');
}
});
return usage;
}
async attachAsset(assetId: string, fields: TAssetAttach): Promise<AssetUsage | null> {
const result = ZAssetAttach.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
let usage: AssetUsage | null = null;
await this.execute<AttachAssetRequest>({
requestFn: () =>
classroomio.organization.assets[':assetId'].attach.$post({
param: { assetId },
json: result.data
}),
logContext: 'attaching media asset',
onSuccess: (response) => {
usage = response.data;
},
onError: () => {
snackbar.error('snackbar.media_manager.attach_failed');
}
});
return usage;
}
async detachAsset(assetId: string, fields: TAssetDetach): Promise<boolean> {
const result = ZAssetDetach.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return false;
}
let success = false;
await this.execute<DetachAssetRequest>({
requestFn: () =>
classroomio.organization.assets[':assetId'].detach.$post({
param: { assetId },
json: result.data
}),
logContext: 'detaching media asset',
onSuccess: () => {
success = true;
},
onError: () => {
snackbar.error('snackbar.media_manager.detach_failed');
}
});
return success;
}
async getVideoPlaybackUrl(asset: OrganizationAsset): Promise<string | null> {
return this.getAssetDownloadUrl(asset, { forPlayback: true });
}
async getAssetDownloadUrl(
asset: OrganizationAsset,
options: {
forPlayback?: boolean;
} = {}
): Promise<string | null> {
if (asset.provider !== 'upload' || !asset.storageKey) {
return asset.sourceUrl ?? null;
}
const useDocumentEndpoint =
!options.forPlayback && (asset.kind === 'document' || asset.mimeType?.startsWith('application/'));
const urls = useDocumentEndpoint
? await presignApi.getDocumentDownloadUrls([asset.storageKey])
: await presignApi.getVideoDownloadUrls([asset.storageKey]);
return urls[asset.storageKey] ?? null;
}
async registerUploadedLessonVideo(params: {
lessonId: string;
position: number;
fileKey: string;
videoUrl: string;
fileName: string;
mimeType: string;
byteSize: number;
thumbnailUrl?: string;
durationSeconds?: number | null;
}): Promise<OrganizationAsset | null> {
const asset = await this.createAsset({
kind: 'video',
provider: 'upload',
storageProvider: 's3',
storageKey: params.fileKey,
sourceUrl: params.videoUrl,
mimeType: params.mimeType,
byteSize: params.byteSize,
title: params.fileName,
thumbnailUrl: params.thumbnailUrl,
durationSeconds: this.normalizeDurationSeconds(params.durationSeconds),
isExternal: false,
metadata: {
fileName: params.fileName,
createdAt: new Date().toISOString()
}
});
if (!asset) {
return null;
}
await this.attachAsset(asset.id, {
targetType: 'lesson',
targetId: params.lessonId,
slotType: 'lesson_video',
position: params.position
});
return asset;
}
async registerUploadedLessonDocument(params: {
lessonId: string;
position: number;
fileKey: string;
documentUrl: string;
fileName: string;
mimeType: string;
byteSize: number;
}): Promise<OrganizationAsset | null> {
const asset = await this.createAsset({
kind: 'document',
provider: 'upload',
storageProvider: 's3',
storageKey: params.fileKey,
sourceUrl: params.documentUrl,
mimeType: params.mimeType,
byteSize: params.byteSize,
title: params.fileName,
isExternal: false,
metadata: {
fileName: params.fileName,
createdAt: new Date().toISOString()
}
});
if (!asset) {
return null;
}
await this.attachAsset(asset.id, {
targetType: 'lesson',
targetId: params.lessonId,
slotType: 'lesson_document',
position: params.position
});
return asset;
}
async buildLessonVideoFromAsset(
asset: OrganizationAsset,
params: { lessonId?: string; position?: number; slotType?: string } = {}
) {
const url = await this.getVideoPlaybackUrl(asset);
if (!url) {
snackbar.error('snackbar.media_manager.resolve_video_failed');
return null;
}
if (params.lessonId) {
await this.attachAsset(asset.id, {
targetType: 'lesson',
targetId: params.lessonId,
slotType: params.slotType ?? 'lesson_video',
position: params.position
});
}
return mapAssetToLessonVideo(asset, {
url,
key: asset.storageKey
});
}
/**
* Returns transcript payload or null when none exists yet.
*/
async getAssetTranscript(assetId: string): Promise<AssetTranscriptPayload | null> {
let data: AssetTranscriptPayload | null = null;
await this.execute<GetAssetTranscriptRequest>({
requestFn: () => classroomio.organization.assets[':assetId'].transcript.$get({ param: { assetId } }),
logContext: 'fetching asset transcript',
onSuccess: (response) => {
data = response.data;
},
onError: () => {
snackbar.error('snackbar.media_manager.transcript_fetch_failed');
}
});
return data;
}
/** Enqueue extract-audio + Whisper for an existing upload. */
async generateTranscript(assetId: string): Promise<boolean> {
let ok = false;
await this.execute<StartAssetTranscriptionRequest>({
requestFn: () => classroomio.jobs.media.asset[':assetId'].transcribe.$post({ param: { assetId } }),
logContext: 'starting transcription job',
onSuccess: () => {
ok = true;
snackbar.success('snackbar.media_manager.transcription_started');
},
onError: () => {
snackbar.error('snackbar.media_manager.transcription_start_failed');
}
});
return ok;
}
}
export const mediaApi = new MediaApi();