summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/youtube.ts253
-rw-r--r--src/lib/components/ChannelCard.svelte160
-rw-r--r--src/lib/components/SearchBar.svelte76
-rw-r--r--src/lib/components/VideoCard.svelte133
-rw-r--r--src/lib/components/VideoPlayer.svelte185
-rw-r--r--src/lib/server/ytdlp.ts329
-rw-r--r--src/lib/stores/playlists.ts123
-rw-r--r--src/lib/stores/subscriptions.ts65
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();