diff --git a/src/lib/divine-api.test.ts b/src/lib/divine-api.test.ts new file mode 100644 index 0000000..7e42220 --- /dev/null +++ b/src/lib/divine-api.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fetchVideo } from './divine-api'; + +describe('fetchVideo', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('normalizes nested event detail responses into the flat video shape used by the app', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + event: { + id: 'video-123', + pubkey: '7bae5c2eea581e66ffa062d6e59d8b60690353392ed3cce03753d4773d999b4e', + created_at: 1775354101, + kind: 34236, + tags: [ + ['d', 'asset-123'], + ['title', 'Nested response title'], + ['summary', 'Nested response summary'], + [ + 'imeta', + 'url https://media.divine.video/video-123.mp4', + 'image https://media.divine.video/video-123.jpg', + ], + ], + content: 'Nested response content', + sig: 'signature-123', + }, + stats: { + reactions: 3, + comments: 2, + reposts: 1, + engagement_score: 9, + author_name: 'Nested Author', + author_avatar: 'https://media.divine.video/avatar.jpg', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const video = await fetchVideo('video-123'); + + expect(video).toMatchObject({ + id: 'video-123', + pubkey: '7bae5c2eea581e66ffa062d6e59d8b60690353392ed3cce03753d4773d999b4e', + kind: 34236, + title: 'Nested response title', + content: 'Nested response content', + thumbnail: 'https://media.divine.video/video-123.jpg', + video_url: 'https://media.divine.video/video-123.mp4', + reactions: 3, + comments: 2, + reposts: 1, + engagement_score: 9, + author_name: 'Nested Author', + author_avatar: 'https://media.divine.video/avatar.jpg', + sig: 'signature-123', + }); + expect(video.tags).toHaveLength(4); + expect(video.d_tag).toBe('asset-123'); + expect(video.created_at).toBe(1775354101); + }); +}); diff --git a/src/lib/divine-api.ts b/src/lib/divine-api.ts index da71d9a..def340a 100644 --- a/src/lib/divine-api.ts +++ b/src/lib/divine-api.ts @@ -10,7 +10,7 @@ const API_BASE = 'https://relay.divine.video/api'; export interface VideoListItem { id: string; pubkey: string; - created_at: string; + created_at: string | number; kind: number; d_tag: string; title: string; @@ -50,6 +50,18 @@ export interface VideoWithEvent { stats: VideoStats; } +interface VideoDetailStats extends VideoStats { + author_name?: string; + author_avatar?: string; +} + +type VideoDetailResponse = + | (VideoListItem & { sig: string; tags: string[][] }) + | { + event: VideoWithEvent['event']; + stats: VideoDetailStats; + }; + export interface VideosEventsResponse { videos: VideoWithEvent[]; next_cursor?: string; @@ -149,6 +161,49 @@ export type LeaderboardPeriod = 'day' | 'week' | 'month' | 'year' | 'alltime'; // API functions +function getTagValue(tags: string[][], name: string): string { + return tags.find(([tag]) => tag === name)?.[1] ?? ''; +} + +function getImetaValue(tags: string[][], key: 'url' | 'image'): string { + const imetaTag = tags.find(([tag]) => tag === 'imeta'); + if (!imetaTag) return ''; + + return imetaTag + .slice(1) + .find((entry) => entry.startsWith(`${key} `)) + ?.slice(key.length + 1) ?? ''; +} + +function normalizeVideoDetail(data: VideoDetailResponse): VideoListItem & { sig: string; tags: string[][] } { + if (!('event' in data)) { + return data; + } + + const { event, stats } = data; + + return { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + d_tag: getTagValue(event.tags, 'd'), + title: getTagValue(event.tags, 'title') || getTagValue(event.tags, 'alt'), + content: event.content, + thumbnail: getImetaValue(event.tags, 'image'), + video_url: getImetaValue(event.tags, 'url'), + reactions: stats.reactions ?? 0, + comments: stats.comments ?? 0, + reposts: stats.reposts ?? 0, + engagement_score: stats.engagement_score ?? 0, + trending_score: stats.trending_score ?? 0, + author_name: stats.author_name, + author_avatar: stats.author_avatar, + sig: event.sig, + tags: event.tags, + }; +} + export async function fetchVideos(options: { sort?: VideoSort; kind?: number; @@ -192,7 +247,14 @@ export async function fetchVideosWithEvents(options: { export async function fetchVideo(id: string): Promise { const response = await fetch(`${API_BASE}/videos/${id}`); if (!response.ok) throw new Error('Failed to fetch video'); - return response.json(); + const data = await response.json() as VideoDetailResponse; + const video = normalizeVideoDetail(data); + + if (!video.id || !video.pubkey || !video.tags || !video.sig) { + throw new Error('Invalid video detail response'); + } + + return video; } export async function fetchVideoStats(id: string): Promise {