diff options
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/api/youtube.ts | 253 | ||||
| -rw-r--r-- | src/lib/components/ChannelCard.svelte | 160 | ||||
| -rw-r--r-- | src/lib/components/SearchBar.svelte | 76 | ||||
| -rw-r--r-- | src/lib/components/VideoCard.svelte | 133 | ||||
| -rw-r--r-- | src/lib/components/VideoPlayer.svelte | 185 | ||||
| -rw-r--r-- | src/lib/server/ytdlp.ts | 329 | ||||
| -rw-r--r-- | src/lib/stores/playlists.ts | 123 | ||||
| -rw-r--r-- | src/lib/stores/subscriptions.ts | 65 |
8 files changed, 1324 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 || ''; +} diff --git a/src/lib/components/ChannelCard.svelte b/src/lib/components/ChannelCard.svelte new file mode 100644 index 0000000..8747efa --- /dev/null +++ b/src/lib/components/ChannelCard.svelte @@ -0,0 +1,160 @@ +<script lang="ts"> + import type { VideoThumbnail } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + + interface Props { + channelId: string; + channelName: string; + thumbnails: VideoThumbnail[]; + subCount?: number; + videoCount?: number; + showSubscribeButton?: boolean; + } + + let { + channelId, + channelName, + thumbnails, + subCount, + videoCount, + showSubscribeButton = true + }: Props = $props(); + + const thumbnail = $derived(thumbnails[0]?.url || ''); + + const isSubscribed = $derived(subscriptions.isSubscribed($subscriptions, channelId)); + + function formatSubCount(count: number | undefined): string { + if (!count) return ''; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`; + return `${count} subscribers`; + } + + function handleSubscribe(e: Event) { + e.preventDefault(); + e.stopPropagation(); + if (isSubscribed) { + subscriptions.remove(channelId); + } else { + subscriptions.add(channelId, channelName, thumbnails); + } + } +</script> + +<a href="/channel/{channelId}" class="channel-card"> + <div class="thumbnail"> + {#if thumbnail} + <img src={thumbnail} alt={channelName} loading="lazy" /> + {:else} + <div class="placeholder">{channelName[0]}</div> + {/if} + </div> + <div class="info"> + <h3 class="name">{channelName}</h3> + <div class="meta"> + {#if subCount} + <span>{formatSubCount(subCount)}</span> + {/if} + {#if videoCount} + <span class="separator">•</span> + <span>{videoCount} videos</span> + {/if} + </div> + </div> + {#if showSubscribeButton} + <button + class="subscribe-btn" + class:subscribed={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + {/if} +</a> + +<style> + .channel-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-decoration: none; + color: inherit; + border-radius: 8px; + transition: background 0.15s ease; + } + + .channel-card:hover { + background: var(--bg-hover); + } + + .thumbnail { + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-secondary); + flex-shrink: 0; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + } + + .info { + flex: 1; + min-width: 0; + } + + .name { + font-size: 1rem; + font-weight: 500; + margin: 0 0 0.25rem; + color: var(--text-primary); + } + + .meta { + font-size: 0.85rem; + color: var(--text-muted); + } + + .separator { + margin: 0 0.25rem; + } + + .subscribe-btn { + padding: 0.5rem 1rem; + border-radius: 20px; + border: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + background: var(--accent-color); + color: #fff; + transition: opacity 0.15s ease; + } + + .subscribe-btn:hover { + opacity: 0.9; + } + + .subscribe-btn.subscribed { + background: var(--bg-secondary); + color: var(--text-secondary); + } +</style> diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte new file mode 100644 index 0000000..d81ba14 --- /dev/null +++ b/src/lib/components/SearchBar.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + + let query = $state(''); + + function handleSubmit(e: Event) { + e.preventDefault(); + if (query.trim()) { + goto(`/search?q=${encodeURIComponent(query.trim())}`); + } + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + query = ''; + } + } +</script> + +<form class="search-bar" onsubmit={handleSubmit}> + <input + type="text" + bind:value={query} + placeholder="Search videos..." + onkeydown={handleKeydown} + /> + <button type="submit" aria-label="Search"> + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/> + </svg> + </button> +</form> + +<style> + .search-bar { + display: flex; + max-width: 600px; + width: 100%; + } + + input { + flex: 1; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-right: none; + border-radius: 4px 0 0 4px; + background: var(--bg-secondary); + color: var(--text-primary); + outline: none; + } + + input:focus { + border-color: var(--accent-color); + } + + input::placeholder { + color: var(--text-muted); + } + + button { + padding: 0.75rem 1.25rem; + border: 1px solid var(--border-color); + border-radius: 0 4px 4px 0; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + button:hover { + background: var(--bg-hover); + } +</style> diff --git a/src/lib/components/VideoCard.svelte b/src/lib/components/VideoCard.svelte new file mode 100644 index 0000000..61bf975 --- /dev/null +++ b/src/lib/components/VideoCard.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import { formatDuration, formatViews, getBestThumbnail, type VideoThumbnail } from '$lib/api/youtube'; + + interface Props { + videoId: string; + title: string; + author: string; + authorId: string; + thumbnails: VideoThumbnail[]; + viewCount: number; + publishedText: string; + lengthSeconds: number; + } + + let { + videoId, + title, + author, + authorId, + thumbnails, + viewCount, + publishedText, + lengthSeconds + }: Props = $props(); + + const thumbnail = $derived(getBestThumbnail(thumbnails)); + const duration = $derived(formatDuration(lengthSeconds)); + const views = $derived(formatViews(viewCount)); +</script> + +<a href="/watch/{videoId}" class="video-card"> + <div class="thumbnail"> + <img src={thumbnail} alt={title} loading="lazy" /> + <span class="duration">{duration}</span> + </div> + <div class="info"> + <h3 class="title">{title}</h3> + <span + class="author" + role="link" + tabindex="0" + onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${authorId}`; }} + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${authorId}`; } }} + > + {author} + </span> + <div class="meta"> + <span>{views}</span> + <span class="separator">•</span> + <span>{publishedText}</span> + </div> + </div> +</a> + +<style> + .video-card { + display: block; + text-decoration: none; + color: inherit; + border-radius: 8px; + overflow: hidden; + transition: transform 0.15s ease; + } + + .video-card:hover { + transform: translateY(-2px); + } + + .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .duration { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + } + + .info { + padding: 0.75rem 0; + } + + .title { + font-size: 0.95rem; + font-weight: 500; + line-height: 1.3; + margin: 0 0 0.5rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--text-primary); + } + + .author { + font-size: 0.85rem; + color: var(--text-secondary); + text-decoration: none; + display: block; + margin-bottom: 0.25rem; + cursor: pointer; + } + + .author:hover { + color: var(--text-primary); + } + + .meta { + font-size: 0.8rem; + color: var(--text-muted); + } + + .separator { + margin: 0 0.25rem; + } +</style> diff --git a/src/lib/components/VideoPlayer.svelte b/src/lib/components/VideoPlayer.svelte new file mode 100644 index 0000000..0829c8b --- /dev/null +++ b/src/lib/components/VideoPlayer.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + import type { VideoFormat } from '$lib/api/youtube'; + + interface Props { + formats: VideoFormat[]; + title: string; + } + + let { formats, title }: Props = $props(); + + let videoElement: HTMLVideoElement | undefined = $state(); + let currentQuality = $state(''); + + const sortedFormats = $derived( + [...formats] + .filter(f => f.url && f.height) + .sort((a, b) => (b.height || 0) - (a.height || 0)) + ); + + const selectedFormat = $derived( + currentQuality + ? sortedFormats.find(f => f.qualityLabel === currentQuality) || sortedFormats[0] + : sortedFormats[0] + ); + + function handleQualityChange(e: Event) { + const select = e.target as HTMLSelectElement; + const newQuality = select.value; + const currentTime = videoElement?.currentTime || 0; + const wasPlaying = videoElement && !videoElement.paused; + + currentQuality = newQuality; + + if (videoElement) { + videoElement.load(); + videoElement.currentTime = currentTime; + if (wasPlaying) { + videoElement.play(); + } + } + } + + function handleKeydown(e: KeyboardEvent) { + if (!videoElement) return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + switch (e.key) { + case ' ': + case 'k': + e.preventDefault(); + if (videoElement.paused) { + videoElement.play(); + } else { + videoElement.pause(); + } + break; + case 'f': + e.preventDefault(); + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + videoElement.requestFullscreen(); + } + break; + case 'm': + e.preventDefault(); + videoElement.muted = !videoElement.muted; + break; + case 'ArrowLeft': + e.preventDefault(); + videoElement.currentTime -= 5; + break; + case 'ArrowRight': + e.preventDefault(); + videoElement.currentTime += 5; + break; + case 'ArrowUp': + e.preventDefault(); + videoElement.volume = Math.min(1, videoElement.volume + 0.1); + break; + case 'ArrowDown': + e.preventDefault(); + videoElement.volume = Math.max(0, videoElement.volume - 0.1); + break; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + e.preventDefault(); + videoElement.currentTime = (parseInt(e.key) / 10) * videoElement.duration; + break; + } + } + + function getMimeType(ext: string): string { + const types: Record<string, string> = { + mp4: 'video/mp4', + webm: 'video/webm', + mkv: 'video/x-matroska' + }; + return types[ext] || 'video/mp4'; + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<div class="player-container"> + {#if selectedFormat} + <video + bind:this={videoElement} + controls + autoplay + {title} + > + <source src={selectedFormat.url} type={getMimeType(selectedFormat.ext)} /> + Your browser does not support the video tag. + </video> + {:else} + <div class="no-video"> + <p>No playable video format found</p> + </div> + {/if} + + {#if sortedFormats.length > 1} + <div class="quality-selector"> + <label for="quality">Quality:</label> + <select id="quality" onchange={handleQualityChange} value={selectedFormat?.qualityLabel}> + {#each sortedFormats as format} + <option value={format.qualityLabel}>{format.qualityLabel || format.height + 'p'}</option> + {/each} + </select> + </div> + {/if} +</div> + +<style> + .player-container { + width: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; + } + + video { + width: 100%; + aspect-ratio: 16 / 9; + display: block; + } + + .no-video { + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + } + + .quality-selector { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-secondary); + } + + .quality-selector label { + font-size: 0.9rem; + color: var(--text-secondary); + } + + .quality-selector select { + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + } +</style> diff --git a/src/lib/server/ytdlp.ts b/src/lib/server/ytdlp.ts new file mode 100644 index 0000000..2f6f47e --- /dev/null +++ b/src/lib/server/ytdlp.ts @@ -0,0 +1,329 @@ +import { spawn } from 'child_process'; + +export interface YtdlpVideo { + id: string; + title: string; + description: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; + like_count?: number; + formats: YtdlpFormat[]; +} + +export interface YtdlpFormat { + format_id: string; + url: string; + ext: string; + resolution?: string; + height?: number; + width?: number; + vcodec?: string; + acodec?: string; + format_note?: string; +} + +export interface YtdlpSearchResult { + id: string; + title: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; +} + +export 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 runYtdlp(args: string[]): Promise<string> { + return new Promise((resolve, reject) => { + const proc = spawn('yt-dlp', args); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || `yt-dlp exited with code ${code}`)); + } + }); + + proc.on('error', (err: Error) => { + reject(err); + }); + }); +} + +export async function search(query: string, limit = 20): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + `ytsearch${limit}:${query}`, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON lines + } + } + + return results; +} + +export async function getVideo(videoId: string): Promise<YtdlpVideo> { + const output = await runYtdlp([ + `https://www.youtube.com/watch?v=${videoId}`, + '--dump-json', + '--no-warnings' + ]); + + const data = JSON.parse(output); + return { + id: data.id, + title: data.title, + description: data.description || '', + channel: data.channel || data.uploader || '', + channel_id: data.channel_id || data.uploader_id || '', + channel_url: data.channel_url || data.uploader_url || '', + thumbnail: data.thumbnail || data.thumbnails?.[0]?.url || '', + thumbnails: data.thumbnails || [], + duration: data.duration || 0, + view_count: data.view_count || 0, + upload_date: data.upload_date || '', + like_count: data.like_count, + formats: (data.formats || []).filter((f: YtdlpFormat) => + f.vcodec !== 'none' && f.acodec !== 'none' && f.url + ).map((f: YtdlpFormat) => ({ + format_id: f.format_id, + url: f.url, + ext: f.ext, + resolution: f.resolution, + height: f.height, + width: f.width, + vcodec: f.vcodec, + acodec: f.acodec, + format_note: f.format_note + })) + }; +} + +export async function getChannel(channelId: string): Promise<YtdlpChannel> { + const url = channelId.startsWith('@') + ? `https://www.youtube.com/${channelId}` + : `https://www.youtube.com/channel/${channelId}`; + + const output = await runYtdlp([ + `${url}/videos`, + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let channelInfo: Partial<YtdlpChannel> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + if (item.channel_id || item.uploader_id) { + channelInfo = { + id: item.channel_id || item.uploader_id, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + description: item.description || '', + channel_follower_count: item.channel_follower_count || 0, + thumbnails: item.thumbnails || [] + }; + } + if (item.id && item.title && item.duration && item.duration >= 60) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: channelInfo.channel_id || channelId, + channel: channelInfo.channel || '', + channel_id: channelInfo.channel_id || channelId, + description: channelInfo.description || '', + channel_follower_count: channelInfo.channel_follower_count || 0, + thumbnails: channelInfo.thumbnails || [], + entries + }; +} + +export interface YtdlpPlaylist { + id: string; + title: string; + channel: string; + channel_id: string; + entries: YtdlpSearchResult[]; +} + +export async function getRelatedVideos(title: string, excludeId: string): Promise<YtdlpSearchResult[]> { + // Extract key words from title for search + const keywords = title + .replace(/[^\w\s]/g, '') + .split(/\s+/) + .filter(w => w.length > 3) + .slice(0, 5) + .join(' '); + + if (!keywords) return []; + + const results = await search(keywords, 15); + return results.filter(r => r.id !== excludeId).slice(0, 10); +} + +export async function getPlaylist(playlistUrl: string): Promise<YtdlpPlaylist> { + const output = await runYtdlp([ + playlistUrl, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let playlistInfo: Partial<YtdlpPlaylist> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + + // Capture playlist metadata from first item + if (!playlistInfo.id && item.playlist_id) { + playlistInfo = { + id: item.playlist_id, + title: item.playlist_title || item.playlist || 'Imported Playlist', + channel: item.playlist_uploader || item.channel || item.uploader || '', + channel_id: item.playlist_uploader_id || item.channel_id || item.uploader_id || '' + }; + } + + if (item.id && item.title) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`, + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: playlistInfo.id || '', + title: playlistInfo.title || 'Imported Playlist', + channel: playlistInfo.channel || '', + channel_id: playlistInfo.channel_id || '', + entries + }; +} + +export async function getTrending(): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + 'https://www.youtube.com/feed/trending', + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return results; +} diff --git a/src/lib/stores/playlists.ts b/src/lib/stores/playlists.ts new file mode 100644 index 0000000..ee516dc --- /dev/null +++ b/src/lib/stores/playlists.ts @@ -0,0 +1,123 @@ +import { writable } from 'svelte/store'; +import { get, set } from 'idb-keyval'; + +export interface PlaylistVideo { + videoId: string; + title: string; + author: string; + authorId: string; + thumbnail: string; + lengthSeconds: number; +} + +export interface Playlist { + id: string; + name: string; + videos: PlaylistVideo[]; + createdAt: number; + updatedAt: number; +} + +const STORAGE_KEY = 'actualyt-playlists'; + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} + +function createPlaylistStore() { + const { subscribe, set: setStore, update } = writable<Playlist[]>([]); + let initialized = false; + + async function init() { + if (initialized) return; + try { + const stored = await get<Playlist[]>(STORAGE_KEY); + if (stored) { + setStore(stored); + } + initialized = true; + } catch (e) { + console.error('Failed to load playlists:', e); + } + } + + async function persist(playlists: Playlist[]) { + try { + await set(STORAGE_KEY, playlists); + } catch (e) { + console.error('Failed to save playlists:', e); + } + } + + return { + subscribe, + init, + create: async (name: string): Promise<string> => { + const id = generateId(); + const now = Date.now(); + const newPlaylist: Playlist = { + id, + name, + videos: [], + createdAt: now, + updatedAt: now + }; + update(playlists => { + const newPlaylists = [...playlists, newPlaylist]; + persist(newPlaylists); + return newPlaylists; + }); + return id; + }, + delete: async (id: string) => { + update(playlists => { + const newPlaylists = playlists.filter(p => p.id !== id); + persist(newPlaylists); + return newPlaylists; + }); + }, + rename: async (id: string, name: string) => { + update(playlists => { + const newPlaylists = playlists.map(p => + p.id === id ? { ...p, name, updatedAt: Date.now() } : p + ); + persist(newPlaylists); + return newPlaylists; + }); + }, + addVideo: async (playlistId: string, video: PlaylistVideo) => { + update(playlists => { + const newPlaylists = playlists.map(p => { + if (p.id !== playlistId) return p; + if (p.videos.some(v => v.videoId === video.videoId)) return p; + return { + ...p, + videos: [...p.videos, video], + updatedAt: Date.now() + }; + }); + persist(newPlaylists); + return newPlaylists; + }); + }, + removeVideo: async (playlistId: string, videoId: string) => { + update(playlists => { + const newPlaylists = playlists.map(p => { + if (p.id !== playlistId) return p; + return { + ...p, + videos: p.videos.filter(v => v.videoId !== videoId), + updatedAt: Date.now() + }; + }); + persist(newPlaylists); + return newPlaylists; + }); + }, + getById: (playlists: Playlist[], id: string): Playlist | undefined => { + return playlists.find(p => p.id === id); + } + }; +} + +export const playlists = createPlaylistStore(); diff --git a/src/lib/stores/subscriptions.ts b/src/lib/stores/subscriptions.ts new file mode 100644 index 0000000..91f3d36 --- /dev/null +++ b/src/lib/stores/subscriptions.ts @@ -0,0 +1,65 @@ +import { writable } from 'svelte/store'; +import { get, set } from 'idb-keyval'; +import type { VideoThumbnail } from '$lib/api/youtube'; + +export interface Subscription { + channelId: string; + channelName: string; + thumbnail: string; +} + +const STORAGE_KEY = 'actualyt-subscriptions'; + +function createSubscriptionStore() { + const { subscribe, set: setStore, update } = writable<Subscription[]>([]); + let initialized = false; + + async function init() { + if (initialized) return; + try { + const stored = await get<Subscription[]>(STORAGE_KEY); + if (stored) { + setStore(stored); + } + initialized = true; + } catch (e) { + console.error('Failed to load subscriptions:', e); + } + } + + async function persist(subs: Subscription[]) { + try { + await set(STORAGE_KEY, subs); + } catch (e) { + console.error('Failed to save subscriptions:', e); + } + } + + return { + subscribe, + init, + add: async (channelId: string, channelName: string, thumbnails: VideoThumbnail[]) => { + update(subs => { + if (subs.some(s => s.channelId === channelId)) { + return subs; + } + const thumbnail = thumbnails[0]?.url || ''; + const newSubs = [...subs, { channelId, channelName, thumbnail }]; + persist(newSubs); + return newSubs; + }); + }, + remove: async (channelId: string) => { + update(subs => { + const newSubs = subs.filter(s => s.channelId !== channelId); + persist(newSubs); + return newSubs; + }); + }, + isSubscribed: (subs: Subscription[], channelId: string): boolean => { + return subs.some(s => s.channelId === channelId); + } + }; +} + +export const subscriptions = createSubscriptionStore(); |
