summaryrefslogtreecommitdiff
path: root/src/lib/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/api')
-rw-r--r--src/lib/api/youtube.ts253
1 files changed, 253 insertions, 0 deletions
diff --git a/src/lib/api/youtube.ts b/src/lib/api/youtube.ts
new file mode 100644
index 0000000..8ade3c5
--- /dev/null
+++ b/src/lib/api/youtube.ts
@@ -0,0 +1,253 @@
+export interface VideoThumbnail {
+ url: string;
+ width?: number;
+ height?: number;
+}
+
+export interface VideoInfo {
+ videoId: string;
+ title: string;
+ author: string;
+ authorId: string;
+ videoThumbnails: VideoThumbnail[];
+ description: string;
+ viewCount: number;
+ publishedText: string;
+ lengthSeconds: number;
+}
+
+export interface VideoFormat {
+ formatId: string;
+ url: string;
+ ext: string;
+ resolution?: string;
+ height?: number;
+ width?: number;
+ qualityLabel?: string;
+}
+
+export interface VideoDetails extends VideoInfo {
+ likeCount?: number;
+ formats: VideoFormat[];
+}
+
+export interface ChannelInfo {
+ author: string;
+ authorId: string;
+ authorThumbnails: VideoThumbnail[];
+ subCount: number;
+ description: string;
+ videos: VideoInfo[];
+}
+
+interface YtdlpSearchResult {
+ id: string;
+ title: string;
+ channel: string;
+ channel_id: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+}
+
+interface YtdlpVideo {
+ id: string;
+ title: string;
+ description: string;
+ channel: string;
+ channel_id: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+ like_count?: number;
+ formats: {
+ format_id: string;
+ url: string;
+ ext: string;
+ resolution?: string;
+ height?: number;
+ width?: number;
+ format_note?: string;
+ }[];
+}
+
+interface YtdlpChannel {
+ id: string;
+ channel: string;
+ channel_id: string;
+ description: string;
+ channel_follower_count: number;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ entries?: YtdlpSearchResult[];
+}
+
+function formatUploadDate(dateStr: string): string {
+ if (!dateStr || dateStr.length !== 8) return '';
+ const year = dateStr.slice(0, 4);
+ const month = dateStr.slice(4, 6);
+ const day = dateStr.slice(6, 8);
+ const date = new Date(`${year}-${month}-${day}`);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return 'Today';
+ if (days === 1) return 'Yesterday';
+ if (days < 7) return `${days} days ago`;
+ if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
+ if (days < 365) return `${Math.floor(days / 30)} months ago`;
+ return `${Math.floor(days / 365)} years ago`;
+}
+
+function transformSearchResult(item: YtdlpSearchResult): VideoInfo {
+ return {
+ videoId: item.id,
+ title: item.title,
+ author: item.channel,
+ authorId: item.channel_id,
+ videoThumbnails: item.thumbnails?.length
+ ? item.thumbnails
+ : [{ url: item.thumbnail }],
+ description: '',
+ viewCount: item.view_count || 0,
+ publishedText: formatUploadDate(item.upload_date),
+ lengthSeconds: item.duration || 0
+ };
+}
+
+function transformVideo(item: YtdlpVideo): VideoDetails {
+ return {
+ videoId: item.id,
+ title: item.title,
+ author: item.channel,
+ authorId: item.channel_id,
+ videoThumbnails: item.thumbnails?.length
+ ? item.thumbnails
+ : [{ url: item.thumbnail }],
+ description: item.description || '',
+ viewCount: item.view_count || 0,
+ publishedText: formatUploadDate(item.upload_date),
+ lengthSeconds: item.duration || 0,
+ likeCount: item.like_count,
+ formats: item.formats.map(f => ({
+ formatId: f.format_id,
+ url: f.url,
+ ext: f.ext,
+ resolution: f.resolution,
+ height: f.height,
+ width: f.width,
+ qualityLabel: f.format_note || (f.height ? `${f.height}p` : undefined)
+ }))
+ };
+}
+
+function transformChannel(item: YtdlpChannel): ChannelInfo {
+ return {
+ author: item.channel,
+ authorId: item.channel_id,
+ authorThumbnails: item.thumbnails || [],
+ subCount: item.channel_follower_count || 0,
+ description: item.description || '',
+ videos: (item.entries || []).map(transformSearchResult)
+ };
+}
+
+export async function search(query: string): Promise<VideoInfo[]> {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
+ if (!response.ok) {
+ throw new Error('Search failed');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export async function getVideo(videoId: string): Promise<VideoDetails> {
+ const response = await fetch(`/api/video/${videoId}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch video');
+ }
+ const video: YtdlpVideo = await response.json();
+ return transformVideo(video);
+}
+
+export async function getChannel(channelId: string): Promise<ChannelInfo> {
+ const response = await fetch(`/api/channel/${encodeURIComponent(channelId)}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch channel');
+ }
+ const channel: YtdlpChannel = await response.json();
+ return transformChannel(channel);
+}
+
+export async function getTrending(): Promise<VideoInfo[]> {
+ const response = await fetch('/api/trending');
+ if (!response.ok) {
+ throw new Error('Failed to fetch trending');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export async function getRelatedVideos(videoId: string): Promise<VideoInfo[]> {
+ const response = await fetch(`/api/related/${videoId}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch related videos');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export interface ImportedPlaylist {
+ id: string;
+ title: string;
+ channel: string;
+ channelId: string;
+ videos: VideoInfo[];
+}
+
+export async function importPlaylist(playlistUrl: string): Promise<ImportedPlaylist> {
+ const response = await fetch(`/api/playlist?url=${encodeURIComponent(playlistUrl)}`);
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to import playlist');
+ }
+ const data = await response.json();
+ return {
+ id: data.id,
+ title: data.title,
+ channel: data.channel,
+ channelId: data.channel_id,
+ videos: data.entries.map(transformSearchResult)
+ };
+}
+
+export function formatDuration(seconds: number): string {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+}
+
+export function formatViews(views: number): string {
+ if (views >= 1000000) {
+ return `${(views / 1000000).toFixed(1)}M views`;
+ }
+ if (views >= 1000) {
+ return `${(views / 1000).toFixed(1)}K views`;
+ }
+ return `${views} views`;
+}
+
+export function getBestThumbnail(thumbnails: VideoThumbnail[]): string {
+ if (!thumbnails || thumbnails.length === 0) return '';
+ const sorted = [...thumbnails].sort((a, b) => (b.width || 0) - (a.width || 0));
+ return sorted[0]?.url || '';
+}