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_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
|
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 ──────────────────────────────────────────
|
# ── yt-dlp / stream extractor ──────────────────────────────────────────
|
||||||
# Host:puerto del servicio extractor (usado por Caddy para reverse_proxy).
|
# Host:puerto del servicio extractor (usado por Caddy para reverse_proxy).
|
||||||
# Caddy expondrá el servicio en http://localhost:3000/yt-stream/
|
# 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.
|
# 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).
|
# Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS).
|
||||||
@ -41,6 +41,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Persistencia de tokens OAuth2 (Facebook, YouTube, etc.)
|
# Persistencia de tokens OAuth2 (Facebook, YouTube, etc.)
|
||||||
- restreamer-ui-fb-data:/data/fb
|
- restreamer-ui-fb-data:/data/fb
|
||||||
|
devices:
|
||||||
|
- "/dev/video1:/dev/video1"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
restreamer-ui-fb-data:
|
restreamer-ui-fb-data:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Logo from './logos/dlive.svg';
|
import Logo from './logos/dlive.svg';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'dlive';
|
const id = 'dlive';
|
||||||
const name = 'dlive';
|
const name = 'dlive';
|
||||||
@ -70,6 +71,10 @@ function Service(props) {
|
|||||||
props.onChange([output], settings);
|
props.onChange([output], settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange([createOutput(settings)], settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
const output = {
|
const output = {
|
||||||
address: 'rtmp://stream.dlive.tv/live/' + settings.key,
|
address: 'rtmp://stream.dlive.tv/live/' + settings.key,
|
||||||
@ -81,7 +86,7 @@ function Service(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<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')} />
|
<TextField variant="outlined" fullWidth label={<Trans>Stream key</Trans>} value={settings.key} onChange={handleChange('key')} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={3}>
|
<Grid item xs={12} md={3}>
|
||||||
@ -89,6 +94,11 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import Checkbox from '../../../misc/Checkbox';
|
|||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import fbOAuth from '../../../utils/fbOAuth';
|
import fbOAuth from '../../../utils/fbOAuth';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'facebook';
|
const id = 'facebook';
|
||||||
const name = 'Facebook Live';
|
const name = 'Facebook Live';
|
||||||
@ -449,6 +450,12 @@ function Service(props) {
|
|||||||
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
}} />
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField variant="outlined" fullWidth
|
<TextField variant="outlined" fullWidth
|
||||||
label="Live title"
|
label="Live title"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Grid from '@mui/material/Grid';
|
|||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'instagram';
|
const id = 'instagram';
|
||||||
const name = 'Instagram';
|
const name = 'Instagram';
|
||||||
@ -68,14 +69,15 @@ function Service(props) {
|
|||||||
|
|
||||||
const handleChange = (what) => (event) => {
|
const handleChange = (what) => (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
||||||
settings[what] = value;
|
settings[what] = value;
|
||||||
|
|
||||||
const output = createOutput(settings);
|
const output = createOutput(settings);
|
||||||
|
|
||||||
props.onChange([output], settings);
|
props.onChange([output], settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange([createOutput(settings)], settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
const output = {
|
const output = {
|
||||||
address: 'http://instagram.com:443/rtmp/' + settings.key,
|
address: 'http://instagram.com:443/rtmp/' + settings.key,
|
||||||
@ -95,6 +97,11 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
|||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'linkedin';
|
const id = 'linkedin';
|
||||||
const name = 'LinkedIn';
|
const name = 'LinkedIn';
|
||||||
@ -71,14 +72,15 @@ function Service(props) {
|
|||||||
|
|
||||||
const handleChange = (what) => (event) => {
|
const handleChange = (what) => (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
||||||
settings[what] = value;
|
settings[what] = value;
|
||||||
|
|
||||||
const output = createOutput(settings);
|
const output = createOutput(settings);
|
||||||
|
|
||||||
props.onChange([output], settings);
|
props.onChange([output], settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange([createOutput(settings)], settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
const output = {
|
const output = {
|
||||||
address: settings.protocol + settings.address,
|
address: settings.protocol + settings.address,
|
||||||
@ -106,6 +108,11 @@ function Service(props) {
|
|||||||
placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}"
|
placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
|
|
||||||
import Logo from './logos/rumble.svg';
|
import Logo from './logos/rumble.svg';
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'rumble';
|
const id = 'rumble';
|
||||||
const name = 'Rumble';
|
const name = 'Rumble';
|
||||||
@ -71,14 +72,15 @@ function Service(props) {
|
|||||||
|
|
||||||
const handleChange = (what) => (event) => {
|
const handleChange = (what) => (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
||||||
settings[what] = value;
|
settings[what] = value;
|
||||||
|
|
||||||
const output = createOutput(settings);
|
const output = createOutput(settings);
|
||||||
|
|
||||||
props.onChange([output], settings);
|
props.onChange([output], settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange([createOutput(settings)], settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
const output = {
|
const output = {
|
||||||
address: settings.server_url + '/' + settings.stream_key,
|
address: settings.server_url + '/' + settings.stream_key,
|
||||||
@ -117,6 +119,11 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'twitch';
|
const id = 'twitch';
|
||||||
const name = 'Twitch';
|
const name = 'Twitch';
|
||||||
@ -65,14 +66,15 @@ function Service(props) {
|
|||||||
|
|
||||||
const handleChange = (what) => (event) => {
|
const handleChange = (what) => (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
||||||
settings[what] = value;
|
settings[what] = value;
|
||||||
|
|
||||||
const output = createOutput(settings);
|
const output = createOutput(settings);
|
||||||
|
|
||||||
props.onChange([output], settings);
|
props.onChange([output], settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange([createOutput(settings)], settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
let region_postfix = '.twitch.tv';
|
let region_postfix = '.twitch.tv';
|
||||||
if (settings.region.includes('live-video.net')) {
|
if (settings.region.includes('live-video.net')) {
|
||||||
@ -165,6 +167,11 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'twitter';
|
const id = 'twitter';
|
||||||
const name = 'Twitter';
|
const name = 'Twitter';
|
||||||
@ -87,14 +88,15 @@ function Service(props) {
|
|||||||
|
|
||||||
const handleChange = (what) => (event) => {
|
const handleChange = (what) => (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
||||||
settings[what] = value;
|
settings[what] = value;
|
||||||
|
|
||||||
const outputs = createOutput(settings);
|
const outputs = createOutput(settings);
|
||||||
|
|
||||||
props.onChange(outputs, settings);
|
props.onChange(outputs, settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushSettings = () => {
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
};
|
||||||
|
|
||||||
const createOutput = (settings) => {
|
const createOutput = (settings) => {
|
||||||
const outputs = [];
|
const outputs = [];
|
||||||
|
|
||||||
@ -189,6 +191,11 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<YtMetadataInput onFetch={(title, desc) => {
|
||||||
|
if (title) settings.title = title;
|
||||||
|
if (desc) settings.description = desc;
|
||||||
|
pushSettings();
|
||||||
|
}} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import Checkbox from '../../../misc/Checkbox';
|
|||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import ytOAuth from '../../../utils/ytOAuth';
|
import ytOAuth from '../../../utils/ytOAuth';
|
||||||
|
import YtMetadataInput from './YtMetadataInput';
|
||||||
|
|
||||||
const id = 'youtube';
|
const id = 'youtube';
|
||||||
const name = 'YouTube Live';
|
const name = 'YouTube Live';
|
||||||
@ -87,6 +88,7 @@ function Service(props) {
|
|||||||
const [$connecting, setConnecting] = React.useState(false);
|
const [$connecting, setConnecting] = React.useState(false);
|
||||||
const [$globalCreds, setGlobalCreds] = React.useState(() => ytOAuth.getCredentials());
|
const [$globalCreds, setGlobalCreds] = React.useState(() => ytOAuth.getCredentials());
|
||||||
|
|
||||||
|
|
||||||
// Sincronizar credenciales y cuentas desde el servidor al montar
|
// Sincronizar credenciales y cuentas desde el servidor al montar
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -546,6 +548,21 @@ function Service(props) {
|
|||||||
<Checkbox label={<Trans>Backup stream</Trans>} checked={settings.backup} onChange={handleChange('backup')} />
|
<Checkbox label={<Trans>Backup stream</Trans>} checked={settings.backup} onChange={handleChange('backup')} />
|
||||||
</Grid>
|
</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 ────────────────────────────────── */}
|
{/* ── Título y descripción ────────────────────────────────── */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<MuiTextField
|
<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