feat: added youttube support b3a4001b
Steve · 2025-11-06 20:42 6 file(s) · +206 −12
src/App.tsx +2 −2
135 135
							publishedDate: extractPostDate(post),
136 136
							link: sanitizedPost.link,
137 137
							feedId: result.value.id,
138 -
							content: extractPostContent(post),
138 +
							content: extractPostContent(post, sanitizedPost.link),
139 139
						});
140 140
					}
141 141
254 254
					publishedDate: extractPostDate(post),
255 255
					link: sanitizedPost.link,
256 256
					feedId: result.value.id,
257 -
					content: extractPostContent(post),
257 +
					content: extractPostContent(post, sanitizedPost.link),
258 258
				});
259 259
			}
260 260
src/components/add-feed-dialog.tsx +20 −2
22 22
	extractPostDate,
23 23
	sanitizeFeedData,
24 24
	sanitizePostData,
25 +
	isYouTubeUrl,
26 +
	convertYouTubeUrlToFeed,
25 27
} from "@/lib/feed-operations";
26 28
27 29
interface AddFeedDialogProps {
51 53
			let feedUrl = urlInput;
52 54
			let xmlData: string | null = null;
53 55
54 -
			if (!looksLikeFeedUrl(urlInput)) {
56 +
			// Check if it's a YouTube URL and convert it
57 +
			if (isYouTubeUrl(urlInput)) {
58 +
				setStatusMessage("Detecting YouTube channel...");
59 +
				const youtubeFeedUrl = await convertYouTubeUrlToFeed(urlInput);
60 +
61 +
				if (!youtubeFeedUrl) {
62 +
					setStatusMessage(
63 +
						"Could not extract YouTube channel ID. Please try a direct channel URL.",
64 +
					);
65 +
					setIsAddingFeed(false);
66 +
					return;
67 +
				}
68 +
69 +
				feedUrl = youtubeFeedUrl;
70 +
				xmlData = await fetchFeedWithFallback(feedUrl);
71 +
			} else if (!looksLikeFeedUrl(urlInput)) {
72 +
				setStatusMessage("Discovering RSS feed...");
55 73
				const discovered = await discoverFeed(urlInput);
56 74
57 75
				if (!discovered) {
98 116
					publishedDate: extractPostDate(post),
99 117
					link: sanitizedPost.link,
100 118
					feedId: result.value.id,
101 -
					content: extractPostContent(post),
119 +
					content: extractPostContent(post, sanitizedPost.link),
102 120
				});
103 121
			}
104 122
src/components/app-sidebar.tsx +1 −1
325 325
							publishedDate: extractPostDate(post),
326 326
							link: postLink,
327 327
							feedId: feed.id,
328 -
							content: extractPostContent(post),
328 +
							content: extractPostContent(post, postLink),
329 329
						});
330 330
						newPostsCount++;
331 331
					}
src/components/dashboard.tsx +33 −0
29 29
import rehypeRaw from "rehype-raw";
30 30
import remarkGfm from "remark-gfm";
31 31
import { toast } from "sonner";
32 +
import { extractYouTubeVideoId, isYouTubePost } from "@/lib/feed-operations";
32 33
33 34
function Dashboard() {
34 35
	const [selectedFeedId, setSelectedFeedId] = React.useState<string | null>(
67 68
	const selectedPost = selectedPostId
68 69
		? allPosts.find((p) => p.id === selectedPostId)
69 70
		: null;
71 +
72 +
	// Get the feed for the selected post to check if it's YouTube
73 +
	const selectedPostFeed = React.useMemo(() => {
74 +
		if (!selectedPost?.feedId) return null;
75 +
		return allFeeds.find((f) => f.id === selectedPost.feedId);
76 +
	}, [selectedPost?.feedId, allFeeds]);
70 77
71 78
	// Check if current post is read
72 79
	const isCurrentPostRead = React.useMemo(() => {
249 256
									</span>
250 257
								</div>
251 258
								<Separator />
259 +
								{/* YouTube Embed - show if this is a YouTube video */}
260 +
								{selectedPostFeed &&
261 +
									isYouTubePost(selectedPostFeed.feedUrl) &&
262 +
									selectedPost.link &&
263 +
									(() => {
264 +
										const videoId = extractYouTubeVideoId(selectedPost.link);
265 +
										if (videoId) {
266 +
											return (
267 +
												<div className="w-full mb-6">
268 +
													<div
269 +
														className="relative w-full"
270 +
														style={{ paddingBottom: "56.25%" }}
271 +
													>
272 +
														<iframe
273 +
															className="absolute top-0 left-0 w-full h-full rounded-lg"
274 +
															src={`https://www.youtube.com/embed/${videoId}`}
275 +
															title={selectedPost.title}
276 +
															allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
277 +
															allowFullScreen
278 +
														/>
279 +
													</div>
280 +
												</div>
281 +
											);
282 +
										}
283 +
										return null;
284 +
									})()}
252 285
								{selectedPost.content ? (
253 286
									<div className="prose prose-gray dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-foreground prose-a:text-primary prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-blockquote:text-muted-foreground prose-li:text-foreground space-y-4">
254 287
										<ReactMarkdown
src/components/nav-user.tsx +3 −2
129 129
					}
130 130
131 131
					for (const post of posts) {
132 +
						const postLink = extractPostLink(post, isAtom);
132 133
						evolu.insert("rssPost", {
133 134
							title: post.title,
134 135
							author: extractPostAuthor(post, isAtom, feedData.title),
135 136
							feedTitle: feed.title,
136 137
							publishedDate: extractPostDate(post),
137 -
							link: extractPostLink(post, isAtom),
138 +
							link: postLink,
138 139
							feedId: result.value.id,
139 -
							content: extractPostContent(post),
140 +
							content: extractPostContent(post, postLink),
140 141
						});
141 142
					}
142 143
src/lib/feed-operations.ts +147 −5
139 139
}
140 140
141 141
/**
142 +
 * Extracts YouTube channel ID from various YouTube URL formats
143 +
 * Supports:
144 +
 * - https://www.youtube.com/@ChannelHandle
145 +
 * - https://www.youtube.com/channel/UC...
146 +
 * - https://www.youtube.com/c/ChannelName
147 +
 * - https://www.youtube.com/user/Username
148 +
 */
149 +
export async function extractYouTubeChannelId(
150 +
	url: string,
151 +
): Promise<string | null> {
152 +
	try {
153 +
		const urlObj = new URL(url);
154 +
155 +
		// Direct channel ID format
156 +
		if (url.includes("/channel/")) {
157 +
			const match = url.match(/\/channel\/([^/?]+)/);
158 +
			return match ? match[1] : null;
159 +
		}
160 +
161 +
		// Handle @ format - need to fetch the page to get channel ID
162 +
		if (url.includes("/@")) {
163 +
			const handle = url.match(/\/@([^/?]+)/)?.[1];
164 +
			if (!handle) return null;
165 +
166 +
			// Fetch the YouTube page to extract the channel ID from meta tags
167 +
			try {
168 +
				const response = await fetch(
169 +
					`https://corsproxy.io/?url=${encodeURIComponent(url)}`,
170 +
				);
171 +
				const html = await response.text();
172 +
173 +
				// Look for channel ID in various places
174 +
				const channelIdMatch = html.match(/channelId":"([^"]+)"/);
175 +
				if (channelIdMatch) {
176 +
					return channelIdMatch[1];
177 +
				}
178 +
179 +
				// Alternative: look in meta tags
180 +
				const metaMatch = html.match(
181 +
					/<meta itemprop="channelId" content="([^"]+)">/,
182 +
				);
183 +
				if (metaMatch) {
184 +
					return metaMatch[1];
185 +
				}
186 +
187 +
				// Alternative: look in link tags
188 +
				const linkMatch = html.match(
189 +
					/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/([^"]+)">/,
190 +
				);
191 +
				if (linkMatch) {
192 +
					return linkMatch[1];
193 +
				}
194 +
			} catch (error) {
195 +
				console.error("Failed to fetch YouTube page for channel ID:", error);
196 +
				return null;
197 +
			}
198 +
		}
199 +
200 +
		// For /c/ and /user/ formats, we also need to fetch the page
201 +
		if (url.includes("/c/") || url.includes("/user/")) {
202 +
			try {
203 +
				const response = await fetch(
204 +
					`https://corsproxy.io/?url=${encodeURIComponent(url)}`,
205 +
				);
206 +
				const html = await response.text();
207 +
208 +
				const channelIdMatch = html.match(/channelId":"([^"]+)"/);
209 +
				if (channelIdMatch) {
210 +
					return channelIdMatch[1];
211 +
				}
212 +
			} catch (error) {
213 +
				console.error("Failed to fetch YouTube page for channel ID:", error);
214 +
				return null;
215 +
			}
216 +
		}
217 +
218 +
		return null;
219 +
	} catch (error) {
220 +
		console.error("Error extracting YouTube channel ID:", error);
221 +
		return null;
222 +
	}
223 +
}
224 +
225 +
/**
226 +
 * Converts YouTube channel URL to RSS feed URL
227 +
 */
228 +
export async function convertYouTubeUrlToFeed(
229 +
	url: string,
230 +
): Promise<string | null> {
231 +
	const channelId = await extractYouTubeChannelId(url);
232 +
	if (!channelId) return null;
233 +
234 +
	return `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
235 +
}
236 +
237 +
/**
238 +
 * Checks if a URL is a YouTube URL
239 +
 */
240 +
export function isYouTubeUrl(url: string): boolean {
241 +
	return url.includes("youtube.com") || url.includes("youtu.be");
242 +
}
243 +
244 +
/**
245 +
 * Extracts YouTube video ID from a video URL
246 +
 * Supports:
247 +
 * - https://www.youtube.com/watch?v=VIDEO_ID
248 +
 * - https://youtu.be/VIDEO_ID
249 +
 * - https://www.youtube.com/embed/VIDEO_ID
250 +
 */
251 +
export function extractYouTubeVideoId(url: string): string | null {
252 +
	try {
253 +
		// Standard watch URL
254 +
		const watchMatch = url.match(/[?&]v=([^&]+)/);
255 +
		if (watchMatch) return watchMatch[1];
256 +
257 +
		// Short URL format
258 +
		const shortMatch = url.match(/youtu\.be\/([^?]+)/);
259 +
		if (shortMatch) return shortMatch[1];
260 +
261 +
		// Embed URL format
262 +
		const embedMatch = url.match(/youtube\.com\/embed\/([^?]+)/);
263 +
		if (embedMatch) return embedMatch[1];
264 +
265 +
		return null;
266 +
	} catch {
267 +
		return null;
268 +
	}
269 +
}
270 +
271 +
/**
272 +
 * Checks if a post is from a YouTube feed
273 +
 */
274 +
export function isYouTubePost(feedUrl: string | null): boolean {
275 +
	if (!feedUrl) return false;
276 +
	return feedUrl.includes("youtube.com/feeds/videos.xml");
277 +
}
278 +
279 +
/**
142 280
 * Extracts post link from RSS or Atom post entry
143 281
 */
144 282
export function extractPostLink(post: any, isAtom: boolean): string {
204 342
/**
205 343
 * Extracts content from RSS or Atom post entry
206 344
 */
207 -
export function extractPostContent(post: any): string {
345 +
export function extractPostContent(post: any, postLink?: string): string {
208 346
	// Try various content fields in order of preference
209 347
	const content =
210 348
		post["content:encoded"] || post.content || post.description || post.summary;
349 +
350 +
	// Default fallback message
351 +
	const fallbackMessage = postLink
352 +
		? `<p><a href="${postLink}" target="_blank" rel="noopener noreferrer">View post</a></p>`
353 +
		: "Please open on the web";
211 354
212 355
	// Handle different content structures
213 356
	if (typeof content === "string") {
214 357
		const trimmed = content.trim();
215 -
		// If content is too short or empty, return default message
216 -
		return trimmed.length > 0 ? trimmed : "Please open on the web";
358 +
		return trimmed.length > 0 ? trimmed : fallbackMessage;
217 359
	} else if (content && typeof content === "object") {
218 360
		// Handle CDATA or nested text
219 361
		const extracted = content.__cdata || content["#text"] || "";
220 362
		const trimmed = String(extracted).trim();
221 -
		return trimmed.length > 0 ? trimmed : "Please open on the web";
363 +
		return trimmed.length > 0 ? trimmed : fallbackMessage;
222 364
	}
223 365
224 366
	// No content found - this is fine for link-only feeds
225 -
	return "Please open on the web";
367 +
	return fallbackMessage;
226 368
}
227 369
228 370
/**