diff --git a/Channel-Backend/src/controllers/ecosystem.controller.ts b/Channel-Backend/src/controllers/ecosystem.controller.ts index a9c269e..6e1d902 100644 --- a/Channel-Backend/src/controllers/ecosystem.controller.ts +++ b/Channel-Backend/src/controllers/ecosystem.controller.ts @@ -134,8 +134,20 @@ export class EcosystemController { try { const data = req.body; - // Auto-extract YouTube thumbnail if not provided + let title = data.title; + let description = data.description || null; let thumbnailUrl = data.thumbnailUrl || null; + + if ((!title || !description || !thumbnailUrl) && data.youtubeUrl) { + const meta = await this.scrapeMetaInternal(data.youtubeUrl); + if (meta) { + if (!title) title = meta.title; + if (!description) description = meta.description || null; + if (!thumbnailUrl) thumbnailUrl = meta.thumbnailUrl || null; + } + } + + // Auto-extract YouTube thumbnail if not provided and it's YouTube if (!thumbnailUrl && data.youtubeUrl) { const videoId = this.extractYouTubeVideoId(data.youtubeUrl); if (videoId) { @@ -145,8 +157,8 @@ export class EcosystemController { const created = await prisma.contentShowcase.create({ data: { - title: data.title, - description: data.description || null, + title: title || 'Media Showcase Item', + description, youtubeUrl: data.youtubeUrl, thumbnailUrl, redirectUrl: data.redirectUrl || null, @@ -167,9 +179,20 @@ export class EcosystemController { const { id } = req.params; const data = req.body; - // Auto-extract YouTube thumbnail if URL changed and no explicit thumbnail + let title = data.title; + let description = data.description; let thumbnailUrl = data.thumbnailUrl; - if (thumbnailUrl === undefined && data.youtubeUrl) { + + if (data.youtubeUrl) { + const meta = await this.scrapeMetaInternal(data.youtubeUrl); + if (meta) { + if (title === undefined || title === null || title === '') title = meta.title; + if (description === undefined) description = meta.description || null; + if (thumbnailUrl === undefined) thumbnailUrl = meta.thumbnailUrl || null; + } + } + + if (!thumbnailUrl && data.youtubeUrl) { const videoId = this.extractYouTubeVideoId(data.youtubeUrl); if (videoId) { thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; @@ -179,8 +202,8 @@ export class EcosystemController { const updated = await prisma.contentShowcase.update({ where: { id }, data: { - title: data.title, - description: data.description, + title, + description, youtubeUrl: data.youtubeUrl, thumbnailUrl, redirectUrl: data.redirectUrl, @@ -210,6 +233,21 @@ export class EcosystemController { // Helper: Extract video ID from various YouTube URL formats private extractYouTubeVideoId(url: string): string | null { + try { + const urlObj = new URL(url); + if (urlObj.hostname.includes('youtu.be')) { + return urlObj.pathname.slice(1).split(/[?#]/)[0]; + } + if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) { + const parts = urlObj.pathname.split('/'); + return parts.pop()?.split(/[?#]/)[0] || null; + } + if (urlObj.searchParams.has('v')) { + return urlObj.searchParams.get('v'); + } + } catch (e) { + // Fallback to regex + } const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, /(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, @@ -221,6 +259,113 @@ export class EcosystemController { return null; } + private async scrapeMetaInternal(url: string): Promise<{ title?: string; description?: string; thumbnailUrl?: string } | null> { + try { + const normUrl = url.trim(); + const lowerUrl = normUrl.toLowerCase(); + + // Case 1: Twitter / X + if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) { + const oembedUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(normUrl)}`; + const resOembed = await fetch(oembedUrl); + if (resOembed.ok) { + const data = await resOembed.json() as any; + let description = ''; + if (data.html) { + const pMatch = data.html.match(/
]*>([\s\S]*?)<\/p>/i);
+ if (pMatch) {
+ description = pMatch[1].replace(/<[^>]*>/g, '').trim();
+ }
+ }
+ return {
+ title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content',
+ description,
+ thumbnailUrl: undefined
+ };
+ }
+ }
+
+ // Case 2: Instagram
+ if (lowerUrl.includes('instagram.com')) {
+ const oembedUrl = `https://graph.facebook.com/v25.0/instagram_oembed?url=${encodeURIComponent(normUrl)}`;
+ const resOembed = await fetch(oembedUrl);
+ if (resOembed.ok) {
+ const data = await resOembed.json() as any;
+ return {
+ title: data.author_name ? `Instagram post by ${data.author_name}` : 'Instagram Post/Reel',
+ description: data.title || 'Instagram Content',
+ thumbnailUrl: data.thumbnail_url || undefined
+ };
+ }
+ }
+
+ // Case 3: YouTube
+ const videoId = this.extractYouTubeVideoId(normUrl);
+ if (videoId) {
+ let title = '';
+ let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
+
+ try {
+ const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
+ const oembedRes = await fetch(oembedUrl);
+ if (oembedRes.ok) {
+ const data = await oembedRes.json() as any;
+ title = data.title || '';
+ thumbnailUrl = data.thumbnail_url || thumbnailUrl;
+ }
+ } catch (err) {
+ console.error('oEmbed fetch error:', err);
+ }
+
+ let description = '';
+ try {
+ const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
+ const pageRes = await fetch(watchUrl, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
+ 'Accept-Language': 'en-US,en;q=0.9'
+ }
+ });
+ if (pageRes.ok) {
+ const html = await pageRes.text();
+ const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = ');
+ if (playerResponse && playerResponse.videoDetails) {
+ description = playerResponse.videoDetails.shortDescription || '';
+ if (playerResponse.videoDetails.title && !title) {
+ title = playerResponse.videoDetails.title;
+ }
+ }
+
+ if (!description) {
+ const descMatch = html.match(/')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'");
+ }
+ }
+ }
+ } catch (err) {
+ console.error('HTML scrape error:', err);
+ }
+
+ return {
+ title: title || undefined,
+ description: description || undefined,
+ thumbnailUrl
+ };
+ }
+ } catch (e) {
+ console.error('scrapeMetaInternal error:', e);
+ }
+ return null;
+ }
+
private extractJsonFromHtml(html: string, targetStr: string): any {
const idx = html.indexOf(targetStr);
if (idx === -1) return null;
diff --git a/Channel-Frontend/src/features/assets/components/AssetCard.tsx b/Channel-Frontend/src/features/assets/components/AssetCard.tsx
index 5cddc67..75f216e 100644
--- a/Channel-Frontend/src/features/assets/components/AssetCard.tsx
+++ b/Channel-Frontend/src/features/assets/components/AssetCard.tsx
@@ -153,7 +153,7 @@ export const AssetCard: React.FC
@@ -195,7 +210,7 @@ export const ShowcasePage: React.FC = () => {
There are no featured videos available in the showcase right now. Check back later!
+
{item.title}
{item.description && (
diff --git a/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx b/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx
index 835e4cc..6be19c2 100644
--- a/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx
+++ b/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx
@@ -30,6 +30,7 @@ import {
getYoutubeMeta,
} from '../../services/ecosystem-api';
import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api';
+import { useToast } from '../../hooks/use-toast';
// Custom Brand Logo renderer with remote URL preset load + high-fidelity SVG fallback
export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name, className = "h-7" }) => {
@@ -126,6 +127,7 @@ const extractYouTubeVideoId = (url: string): string | null => {
// ── Content Showcase Manager Component ──
const ContentShowcaseManager: React.FC = () => {
+ const { success, error } = useToast();
const [items, setItems] = useState