From e7694a2b1f69ac8c8b0d8ebadeb2bcfd9ada1935 Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Mon, 9 Mar 2026 11:36:43 -0700 Subject: [PATCH] Add YtMetadataInput component to fetch YouTube video title and description --- .env.local | 2 +- Dockerfile.build | 57 ++++++++ Dockerfile.build.dockerignore | 20 +++ docker-compose.yml | 4 +- src/views/Publication/Services/DLive.js | 12 +- src/views/Publication/Services/Facebook.js | 7 + src/views/Publication/Services/Instagram.js | 13 +- src/views/Publication/Services/Linkedin.js | 13 +- src/views/Publication/Services/Rumble.js | 13 +- src/views/Publication/Services/Twitch.js | 13 +- src/views/Publication/Services/Twitter.js | 13 +- src/views/Publication/Services/Youtube.js | 17 +++ .../Publication/Services/YtMetadataInput.js | 125 ++++++++++++++++++ 13 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 Dockerfile.build create mode 100644 Dockerfile.build.dockerignore create mode 100644 src/views/Publication/Services/YtMetadataInput.js diff --git a/.env.local b/.env.local index dfe57dd..029037b 100644 --- a/.env.local +++ b/.env.local @@ -1,3 +1,3 @@ REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net -REACT_APP_YTDLP_URL=http://192.168.1.20:8282 +REACT_APP_YTDLP_URL=http://100.73.244.28:8080 REACT_APP_FB_SERVER_URL=http://localhost:3002 diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..436582b --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,57 @@ +ARG CADDY_IMAGE=caddy:2.8.4-alpine +ARG NODE_IMAGE=node:21-alpine3.20 + +# ── Stage 1: Build React app ────────────────────────────────────────────────── +FROM $NODE_IMAGE AS builder +WORKDIR /app + +# Install deps first (layer cache) +COPY package.json package-lock.json* yarn.lock* ./ +RUN NODE_OPTIONS=--max-old-space-size=4096 npm install --legacy-peer-deps --prefer-offline 2>/dev/null || \ + NODE_OPTIONS=--max-old-space-size=4096 npm install --legacy-peer-deps + +# Copy source and build +COPY . . +RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build + +# ── Stage 2: Install server deps ────────────────────────────────────────────── +FROM $NODE_IMAGE AS server-deps +WORKDIR /srv +COPY server/package.json server/package-lock.json* ./ +RUN npm install --omit=dev --no-audit --prefer-offline + +# ── Stage 3: Production image (Caddy + Node.js) ─────────────────────────────── +FROM $CADDY_IMAGE + +# Install Node.js to run the Facebook OAuth2 microserver +RUN apk add --no-cache nodejs + +# Copy built React app from builder +COPY --from=builder /app/build /ui/build + +# Copy Caddy config +COPY Caddyfile /ui/Caddyfile + +# Copy Node.js FB server + its deps +COPY server /ui/server +COPY --from=server-deps /srv/node_modules /ui/server/node_modules + +# Copy entrypoint script +COPY docker-entrypoint.sh /ui/docker-entrypoint.sh +RUN chmod +x /ui/docker-entrypoint.sh + +# Persistent volume for FB OAuth2 tokens (config.json) +VOLUME ["/data/fb"] + +WORKDIR /ui + +EXPOSE 3000 + +# Runtime environment variables (overridden at runtime via -e or docker-compose) +ENV CORE_ADDRESS="" +ENV YTDLP_URL="" +ENV FB_SERVER_URL="" +ENV YTDLP_HOST="192.168.1.20:8282" + +CMD ["/ui/docker-entrypoint.sh"] + diff --git a/Dockerfile.build.dockerignore b/Dockerfile.build.dockerignore new file mode 100644 index 0000000..bce760a --- /dev/null +++ b/Dockerfile.build.dockerignore @@ -0,0 +1,20 @@ +node_modules/ +server/node_modules/ +docker-build.log +yarn-build.log +build-docker.ps1 +build-docker.bat +start-docker.bat +run-docker.ps1 +.yarn/cache +.eslintcache +.github +.github_build +.build +NONPUBLIC/ +.env +.env.local +.prettierignore +.prettierrc +.eslintignore + diff --git a/docker-compose.yml b/docker-compose.yml index 3cd9170..19880ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: # ── yt-dlp / stream extractor ────────────────────────────────────────── # Host:puerto del servicio extractor (usado por Caddy para reverse_proxy). # Caddy expondrá el servicio en http://localhost:3000/yt-stream/ - YTDLP_HOST: "192.168.1.20:8282" + YTDLP_HOST: "100.73.244.28:8080" # YTDLP_URL: URL completa del servicio yt-dlp vista desde el NAVEGADOR. # Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS). @@ -41,6 +41,8 @@ services: volumes: # Persistencia de tokens OAuth2 (Facebook, YouTube, etc.) - restreamer-ui-fb-data:/data/fb + devices: + - "/dev/video1:/dev/video1" volumes: restreamer-ui-fb-data: diff --git a/src/views/Publication/Services/DLive.js b/src/views/Publication/Services/DLive.js index b8afbfb..6c9ef54 100644 --- a/src/views/Publication/Services/DLive.js +++ b/src/views/Publication/Services/DLive.js @@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField'; import Logo from './logos/dlive.svg'; import FormInlineButton from '../../../misc/FormInlineButton'; +import YtMetadataInput from './YtMetadataInput'; const id = 'dlive'; const name = 'dlive'; @@ -70,6 +71,10 @@ function Service(props) { props.onChange([output], settings); }; + const pushSettings = () => { + props.onChange([createOutput(settings)], settings); + }; + const createOutput = (settings) => { const output = { address: 'rtmp://stream.dlive.tv/live/' + settings.key, @@ -81,7 +86,7 @@ function Service(props) { return ( - + Stream key} value={settings.key} onChange={handleChange('key')} /> @@ -89,6 +94,11 @@ function Service(props) { GET + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> Stream settings + { + if (title) settings.title = title; + if (desc) settings.description = desc; + props.onChange(createOutput(settings), settings); + }} /> + (event) => { const value = event.target.value; - settings[what] = value; - const output = createOutput(settings); - props.onChange([output], settings); }; + const pushSettings = () => { + props.onChange([createOutput(settings)], settings); + }; + const createOutput = (settings) => { const output = { address: 'http://instagram.com:443/rtmp/' + settings.key, @@ -95,6 +97,11 @@ function Service(props) { GET + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> (event) => { const value = event.target.value; - settings[what] = value; - const output = createOutput(settings); - props.onChange([output], settings); }; + const pushSettings = () => { + props.onChange([createOutput(settings)], settings); + }; + const createOutput = (settings) => { const output = { address: settings.protocol + settings.address, @@ -106,6 +108,11 @@ function Service(props) { placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}" /> + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> (event) => { const value = event.target.value; - settings[what] = value; - const output = createOutput(settings); - props.onChange([output], settings); }; + const pushSettings = () => { + props.onChange([createOutput(settings)], settings); + }; + const createOutput = (settings) => { const output = { address: settings.server_url + '/' + settings.stream_key, @@ -117,6 +119,11 @@ function Service(props) { GET + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> (event) => { const value = event.target.value; - settings[what] = value; - const output = createOutput(settings); - props.onChange([output], settings); }; + const pushSettings = () => { + props.onChange([createOutput(settings)], settings); + }; + const createOutput = (settings) => { let region_postfix = '.twitch.tv'; if (settings.region.includes('live-video.net')) { @@ -165,6 +167,11 @@ function Service(props) { GET + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> (event) => { const value = event.target.value; - settings[what] = value; - const outputs = createOutput(settings); - props.onChange(outputs, settings); }; + const pushSettings = () => { + props.onChange(createOutput(settings), settings); + }; + const createOutput = (settings) => { const outputs = []; @@ -189,6 +191,11 @@ function Service(props) { GET + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(); + }} /> ytOAuth.getCredentials()); + // Sincronizar credenciales y cuentas desde el servidor al montar React.useEffect(() => { (async () => { @@ -546,6 +548,21 @@ function Service(props) { Backup stream} checked={settings.backup} onChange={handleChange('backup')} /> + {/* ── Importar título/descripción desde URL de YouTube ──────── */} + + + Stream Settings + + + Optionally paste a YouTube URL or Video ID to auto-fill the title and description below. + + + { + if (title) settings.title = title; + if (desc) settings.description = desc; + pushSettings(settings); + }} /> + {/* ── Título y descripción ────────────────────────────────── */} { settings.title = title; ... }} + * /> + */ +import React from 'react'; + +import CircularProgress from '@mui/material/CircularProgress'; +import Grid from '@mui/material/Grid'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import MuiTextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import YouTubeIcon from '@mui/icons-material/YouTube'; + +// ── yt-dlp service base ──────────────────────────────────────────────────── +const _runtimeCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {}; +const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL + ? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/' + : '/yt-stream/'; + +export const extractYouTubeVideoId = (url) => { + if (!url) return ''; + const trimmed = url.trim(); + const patterns = [ + /[?&]v=([a-zA-Z0-9_-]{11})(?:[&?/]|$)/, + /youtu\.be\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/, + /\/(?:live|embed|shorts|v)\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/, + ]; + for (const pattern of patterns) { + const match = trimmed.match(pattern); + if (match) return match[1]; + } + if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed; + return ''; +}; + +export default function YtMetadataInput({ onFetch }) { + const [$url, setUrl] = React.useState(''); + const [$fetching, setFetching] = React.useState(false); + const [$error, setError] = React.useState(''); + + const videoId = extractYouTubeVideoId($url); + + const handleFetch = async () => { + setError(''); + if (!videoId) { + setError('No se detectó un ID de YouTube válido.'); + return; + } + setFetching(true); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!res.ok) throw new Error('HTTP ' + res.status + ' – ' + res.statusText); + const data = await res.json(); + const title = data.title || data.video_title || ''; + const description = data.description || data.video_description || ''; + if (title || description) { + if (typeof onFetch === 'function') onFetch(title, description); + } else { + setError('No se encontró título ni descripción en la respuesta del servicio.'); + } + } catch (e) { + clearTimeout(timeoutId); + setError(e.name === 'AbortError' ? 'Tiempo de espera agotado. Intenta nuevamente.' : 'Error: ' + e.message); + } finally { + setFetching(false); + } + }; + + return ( + + { setUrl(e.target.value); setError(''); }} + InputProps={{ + endAdornment: ( + + + {$fetching + ? + : } + + + ), + }} + /> + {videoId && !$fetching && !$error && ( + + ✅ Video ID: {videoId} — click the button to auto-fill title & description + + )} + {$fetching && ( + + ⏳ Fetching metadata from YouTube… + + )} + {$error && ( + + {$error} + + )} + + ); +} +