Add YtMetadataInput component to fetch YouTube video title and description
This commit is contained in:
parent
e1da7c43e7
commit
e7694a2b1f
@ -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
|
||||
|
||||
57
Dockerfile.build
Normal file
57
Dockerfile.build
Normal file
@ -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"]
|
||||
|
||||
20
Dockerfile.build.dockerignore
Normal file
20
Dockerfile.build.dockerignore
Normal file
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Grid item xs={12}>
|
||||
<TextField variant="outlined" fullWidth label={<Trans>Stream key</Trans>} value={settings.key} onChange={handleChange('key')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
@ -89,6 +94,11 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -29,6 +29,7 @@ import Checkbox from '../../../misc/Checkbox';
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import fbOAuth from '../../../utils/fbOAuth';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'facebook';
|
||||
const name = 'Facebook Live';
|
||||
@ -449,6 +450,12 @@ function Service(props) {
|
||||
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
||||
</Grid>
|
||||
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
props.onChange(createOutput(settings), settings);
|
||||
}} />
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField variant="outlined" fullWidth
|
||||
label="Live title"
|
||||
|
||||
@ -7,6 +7,7 @@ import Grid from '@mui/material/Grid';
|
||||
import TextField from '@mui/material/TextField';
|
||||
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'instagram';
|
||||
const name = 'Instagram';
|
||||
@ -68,14 +69,15 @@ function Service(props) {
|
||||
|
||||
const handleChange = (what) => (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) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -7,6 +7,7 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '../../../misc/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'linkedin';
|
||||
const name = 'LinkedIn';
|
||||
@ -71,14 +72,15 @@ function Service(props) {
|
||||
|
||||
const handleChange = (what) => (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}"
|
||||
/>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField';
|
||||
|
||||
import Logo from './logos/rumble.svg';
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'rumble';
|
||||
const name = 'Rumble';
|
||||
@ -71,14 +72,15 @@ function Service(props) {
|
||||
|
||||
const handleChange = (what) => (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) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -9,6 +9,7 @@ import TextField from '@mui/material/TextField';
|
||||
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'twitch';
|
||||
const name = 'Twitch';
|
||||
@ -65,14 +66,15 @@ function Service(props) {
|
||||
|
||||
const handleChange = (what) => (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) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -11,6 +11,7 @@ import TextField from '@mui/material/TextField';
|
||||
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'twitter';
|
||||
const name = 'Twitter';
|
||||
@ -87,14 +88,15 @@ function Service(props) {
|
||||
|
||||
const handleChange = (what) => (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) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
||||
@ -14,6 +14,7 @@ import Checkbox from '../../../misc/Checkbox';
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import ytOAuth from '../../../utils/ytOAuth';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'youtube';
|
||||
const name = 'YouTube Live';
|
||||
@ -87,6 +88,7 @@ function Service(props) {
|
||||
const [$connecting, setConnecting] = React.useState(false);
|
||||
const [$globalCreds, setGlobalCreds] = React.useState(() => ytOAuth.getCredentials());
|
||||
|
||||
|
||||
// Sincronizar credenciales y cuentas desde el servidor al montar
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
@ -546,6 +548,21 @@ function Service(props) {
|
||||
<Checkbox label={<Trans>Backup stream</Trans>} checked={settings.backup} onChange={handleChange('backup')} />
|
||||
</Grid>
|
||||
|
||||
{/* ── Importar título/descripción desde URL de YouTube ──────── */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h4" style={{ marginBottom: 6 }}>
|
||||
<Trans>Stream Settings</Trans>
|
||||
</Typography>
|
||||
<Typography variant="caption" style={{ color: '#aaa', display: 'block', marginBottom: 0 }}>
|
||||
<Trans>Optionally paste a YouTube URL or Video ID to auto-fill the title and description below.</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings(settings);
|
||||
}} />
|
||||
|
||||
{/* ── Título y descripción ────────────────────────────────── */}
|
||||
<Grid item xs={12}>
|
||||
<MuiTextField
|
||||
|
||||
125
src/views/Publication/Services/YtMetadataInput.js
Normal file
125
src/views/Publication/Services/YtMetadataInput.js
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* YtMetadataInput
|
||||
* ───────────────
|
||||
* Input reutilizable para pegar una URL de YouTube (o Video ID) y obtener
|
||||
* el título y la descripción automáticamente vía el servicio yt-dlp.
|
||||
*
|
||||
* Uso:
|
||||
* <YtMetadataInput
|
||||
* onFetch={(title, description) => { 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 (
|
||||
<Grid item xs={12}>
|
||||
<MuiTextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label="YouTube URL or Video ID (optional)"
|
||||
placeholder="https://www.youtube.com/watch?v=… or video ID"
|
||||
value={$url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(''); }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleFetch}
|
||||
disabled={$fetching || !videoId}
|
||||
title="Fetch title & description from YouTube"
|
||||
size="small"
|
||||
style={{ color: videoId ? '#FF0000' : undefined }}
|
||||
>
|
||||
{$fetching
|
||||
? <CircularProgress size={20} color="inherit" />
|
||||
: <YouTubeIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{videoId && !$fetching && !$error && (
|
||||
<Typography variant="caption" style={{ color: '#4caf50', display: 'block', marginTop: 2 }}>
|
||||
✅ Video ID: <strong>{videoId}</strong> — click the button to auto-fill title & description
|
||||
</Typography>
|
||||
)}
|
||||
{$fetching && (
|
||||
<Typography variant="caption" style={{ color: '#2196f3', display: 'block', marginTop: 2 }}>
|
||||
⏳ Fetching metadata from YouTube…
|
||||
</Typography>
|
||||
)}
|
||||
{$error && (
|
||||
<Typography variant="caption" style={{ color: '#f44336', display: 'block', marginTop: 2 }}>
|
||||
{$error}
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user