diff --git a/src/views/Edit/Profile.js b/src/views/Edit/Profile.js index c5ee500..e614416 100644 --- a/src/views/Edit/Profile.js +++ b/src/views/Edit/Profile.js @@ -500,6 +500,7 @@ export default function Profile(props) { onChange={handleSourceSettingsChange} onRefresh={handleRefresh} onStore={handleStore} + onYoutubeMetadata={props.onYoutubeMetadata} /> {$videoProbe.status !== 'none' && ( @@ -807,4 +808,5 @@ Profile.defaultProps = { onStore: function (name, data) { return ''; }, + onYoutubeMetadata: function (title, description) {}, }; diff --git a/src/views/Edit/SourceSelect.js b/src/views/Edit/SourceSelect.js index e9991ee..922bdfb 100644 --- a/src/views/Edit/SourceSelect.js +++ b/src/views/Edit/SourceSelect.js @@ -102,6 +102,7 @@ export default function SourceSelect(props) { onProbe={handleProbe} onRefresh={handleRefresh} onStore={handleStore} + onYoutubeMetadata={props.onYoutubeMetadata} /> ); } @@ -135,6 +136,7 @@ SourceSelect.defaultProps = { onChange: function (type, device, settings) {}, onRefresh: function () {}, onStore: function (name, data) {}, + onYoutubeMetadata: function (title, description) {}, }; function Select(props) { diff --git a/src/views/Edit/Sources/Network.js b/src/views/Edit/Sources/Network.js index 283a3c7..ef66265 100644 --- a/src/views/Edit/Sources/Network.js +++ b/src/views/Edit/Sources/Network.js @@ -814,6 +814,14 @@ function Pull(props) { if (data && data.stream_url) { props.onChange('', 'address')({ target: { value: data.stream_url } }); setExtractorError(''); + // Propagar title y description si el servicio los devuelve + if (typeof props.onYoutubeMetadata === 'function') { + const title = data.title || data.video_title || ''; + const description = data.description || data.video_description || ''; + if (title || description) { + props.onYoutubeMetadata(title, description); + } + } } else { setExtractorError('No stream_url found in service response.'); } @@ -1255,7 +1263,7 @@ function Source(props) { {settings.mode === 'pull' ? ( - + ) : ( { + setData((prev) => ({ + ...prev, + meta: { + ...prev.meta, + ...(title ? { name: title } : {}), + ...(description ? { description: description } : {}), + }, + })); + if (title || description) { + notify.Dispatch('success', 'youtube:metadata', i18n._(t`Title and description filled from YouTube`)); + } + }; + const handleLicenseChange = (license) => { setData({ ...$data, @@ -472,6 +486,7 @@ export default function Edit(props) { onDone={handleSourceDone} onAbort={handleSourceAbort} onStore={handleSourceStore} + onYoutubeMetadata={handleYoutubeMetadata} /> )} diff --git a/src/views/Publication/Services/DLive.js b/src/views/Publication/Services/DLive.js index 4ae0af0..b8afbfb 100644 --- a/src/views/Publication/Services/DLive.js +++ b/src/views/Publication/Services/DLive.js @@ -41,6 +41,8 @@ function ServiceIcon(props) { function init(settings) { const initSettings = { key: '', + title: '', + description: '', ...settings, }; @@ -50,6 +52,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -79,6 +89,28 @@ function Service(props) { GET + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Facebook.js b/src/views/Publication/Services/Facebook.js index a6fb4b0..02109a2 100644 --- a/src/views/Publication/Services/Facebook.js +++ b/src/views/Publication/Services/Facebook.js @@ -4,10 +4,14 @@ import { faFacebook } from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Trans } from '@lingui/macro'; import Grid from '@mui/material/Grid'; +import Link from '@mui/material/Link'; +import MenuItem from '@mui/material/MenuItem'; import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; import Checkbox from '../../../misc/Checkbox'; import FormInlineButton from '../../../misc/FormInlineButton'; +import Select from '../../../misc/Select'; const id = 'facebook'; const name = 'Facebook Live'; @@ -16,14 +20,8 @@ const stream_key_link = 'https://www.facebook.com/live/producer?ref=datarhei/res const description = Live-Streaming to Facebook Live RTMP service; const image_copyright = More about licenses here; const author = { - creator: { - name: 'datarhei', - link: 'https://github.com/datarhei', - }, - maintainer: { - name: 'datarhei', - link: 'https://github.com/datarhei', - }, + creator: { name: 'datarhei', link: 'https://github.com/datarhei' }, + maintainer: { name: 'datarhei', link: 'https://github.com/datarhei' }, }; const category = 'platform'; const requires = { @@ -40,12 +38,13 @@ function ServiceIcon(props) { } function init(settings) { - const initSettings = { + return { stream_key_primary: '', stream_key_backup: '', rtmp_primary: true, rtmp_backup: false, - // new fields + // API fields + account_type: 'page', // 'page' | 'user' page_id: '', page_access_token: '', title: '', @@ -53,15 +52,26 @@ function init(settings) { create_live: false, ...settings, }; - - return initSettings; } function Service(props) { const settings = init(props.settings); + const [$loading, setLoading] = React.useState(false); + const [$apiError, setApiError] = React.useState(''); + const [$apiSuccess, setApiSuccess] = React.useState(''); + + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } const handleChange = (what) => (event) => { const value = event && event.target ? event.target.value : event; + setApiError(''); + setApiSuccess(''); if (['rtmp_primary', 'rtmp_backup', 'create_live'].includes(what)) { settings[what] = !settings[what]; @@ -69,97 +79,116 @@ function Service(props) { settings[what] = value; } - const output = createOutput(settings); - - props.onChange(output, settings); + props.onChange(createOutput(settings), settings); }; const createOutput = (settings) => { const outputs = []; - - const output_primary = { - address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_primary, - options: ['-f', 'flv'], - }; - - const output_backup = { - address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_backup, - options: ['-f', 'flv'], - }; - - if (settings.stream_key_primary.length !== 0) { - if (settings.rtmp_primary) { - outputs.push(output_primary); - } + if (settings.stream_key_primary.length !== 0 && settings.rtmp_primary) { + outputs.push({ + address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_primary, + options: ['-f', 'flv'], + }); } - - if (settings.stream_key_backup.length !== 0) { - if (settings.rtmp_backup) { - outputs.push(output_backup); - } + if (settings.stream_key_backup.length !== 0 && settings.rtmp_backup) { + outputs.push({ + address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_backup, + options: ['-f', 'flv'], + }); } - return outputs; }; - // Creates a Facebook live video via Graph API and fills the primary stream key + // Extraer stream key desde stream_url de Facebook + const extractKey = (streamUrl) => { + if (!streamUrl) return ''; + const parts = streamUrl.split('/'); + return parts[parts.length - 1] || ''; + }; + + // Crea un Facebook Live via Graph API const createFacebookLive = async () => { - if (!settings.page_id || !settings.page_access_token) { - window.alert('Please provide Page ID and Page Access Token to create a Facebook Live.'); + setApiError(''); + setApiSuccess(''); + + if (!settings.page_access_token) { + setApiError('You must provide a Page Access Token (or User Access Token for personal profile).'); return; } + // Para tipo 'page' se requiere page_id + if (settings.account_type === 'page' && !settings.page_id) { + setApiError('You must provide the Page ID when using a Page account. For personal profile, switch to "Personal profile".'); + return; + } + + setLoading(true); try { - const url = `https://graph.facebook.com/${encodeURIComponent(settings.page_id)}/live_videos`; + // El subject puede ser el page_id (para páginas) o 'me' (para perfil personal) + const subject = settings.account_type === 'page' + ? encodeURIComponent(settings.page_id) + : 'me'; + + const url = `https://graph.facebook.com/v19.0/${subject}/live_videos`; const form = new URLSearchParams(); if (settings.title) form.append('title', settings.title); if (settings.description) form.append('description', settings.description); + form.append('status', 'LIVE_NOW'); form.append('access_token', settings.page_access_token); const resp = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error('Facebook API error: ' + text); - } - const data = await resp.json(); - // Facebook returns a stream_url or stream_url/secure_stream_url — extract key - let stream = ''; - if (data.stream_url) stream = data.stream_url; - else if (data.secure_stream_url) stream = data.secure_stream_url; + if (!resp.ok || data.error) { + const errMsg = data.error + ? `[#${data.error.code}] ${data.error.message}` + : `HTTP ${resp.status}`; - if (stream.length === 0) { - window.alert('Facebook did not return a stream URL.'); + // Mensajes de error específicos con solución + if (data.error && data.error.code === 100) { + setApiError( + 'Error #100: The subject must be a Page account, not a personal profile. ' + + 'Switch to "Personal profile" mode or provide a valid Page ID with a Page Access Token. ' + + 'Get your Page Access Token at: https://developers.facebook.com/tools/explorer/' + ); + } else if (data.error && data.error.code === 190) { + setApiError('Error #190: Invalid or expired Access Token. Generate a new one at https://developers.facebook.com/tools/explorer/'); + } else if (data.error && data.error.code === 200 || data.error && data.error.code === 10) { + setApiError('Permission error: Your token needs the "publish_video" permission. Check your app settings at developers.facebook.com'); + } else { + setApiError('Facebook API error: ' + errMsg); + } return; } - // stream like rtmps://live-api-s.facebook.com:443/rtmp/STREAM_KEY - const parts = stream.split('/'); - const key = parts[parts.length - 1] || ''; + // Extraer stream_url + const streamUrl = data.stream_url || data.secure_stream_url || ''; + if (!streamUrl) { + setApiError('Facebook did not return a stream URL. Check that your token has the "publish_video" permission.'); + return; + } + const key = extractKey(streamUrl); settings.stream_key_primary = key; settings.create_live = true; - const outputs = createOutput(settings); - props.onChange(outputs, settings); - - window.alert('Facebook Live creado y stream key rellenada.'); + props.onChange(createOutput(settings), settings); + setApiSuccess(`✅ Facebook Live created! Stream key filled automatically. Live ID: ${data.id}`); } catch (err) { - console.error(err); - window.alert('Error creando Facebook Live: ' + err.message); + setApiError('Network error: ' + err.message + '. Check if CORS is enabled or try from a server-side proxy.'); + } finally { + setLoading(false); } }; return ( + {/* Stream keys */} {settings.rtmp_primary === true && ( - - {/* Button to create FB Live and populate stream key */} - - Create - )} {settings.rtmp_primary === true && ( @@ -201,27 +225,138 @@ function Service(props) { )} - - {/* New fields for page id, token, title and description */} - - Facebook Page ID} value={settings.page_id} onChange={handleChange('page_id')} /> - - - Page Access Token} value={settings.page_access_token} onChange={handleChange('page_access_token')} /> - - - - Title} value={settings.title} onChange={handleChange('title')} /> - - - Description} value={settings.description} onChange={handleChange('description')} /> - - Enable primary stream} checked={settings.rtmp_primary} onChange={handleChange('rtmp_primary')} /> Enable backup stream} checked={settings.rtmp_backup} onChange={handleChange('rtmp_backup')} /> - {/* Optionally mark that we created the live via API */} - Create live via API} checked={settings.create_live} onChange={handleChange('create_live')} /> + + + {/* Divider visual */} + + + Create Live via Facebook API (optional) + + + Fill in the fields below to automatically create the live broadcast and get the stream key.{' '} + + Get your token here + + + + + {/* Account type selector */} + + + + + {/* Page ID — solo visible cuando account_type = 'page' */} + {settings.account_type === 'page' && ( + + Facebook Page ID} + placeholder="123456789012345" + helperText={ + + Find it at: facebook.com/YOUR_PAGE → About → Page transparency → Page ID + + } + value={settings.page_id} + onChange={handleChange('page_id')} + /> + + )} + + {/* Access Token */} + + Page Access Token + : User Access Token + } + placeholder="EAAxxxxxxx..." + helperText={ + settings.account_type === 'page' + ? Must have publish_video permission. Get it from Graph API Explorer selecting your Page. + : User token with publish_video permission. Get it from Graph API Explorer. + } + value={settings.page_access_token} + onChange={handleChange('page_access_token')} + /> + + + {/* Title */} + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + {/* Description */} + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + + + {/* Error message */} + {$apiError && ( + + + ⚠️ {$apiError} + + + )} + + {/* Success message */} + {$apiSuccess && ( + + + {$apiSuccess} + + + )} + + {/* Create button */} + + + {$loading ? Creating... : Create Live & get stream key} + + + + Required permissions: publish_video. + For pages, also: pages_manage_posts, pages_read_engagement. + + ); diff --git a/src/views/Publication/Services/Instagram.js b/src/views/Publication/Services/Instagram.js index 2eaee4a..4973c77 100644 --- a/src/views/Publication/Services/Instagram.js +++ b/src/views/Publication/Services/Instagram.js @@ -47,6 +47,8 @@ function init(settings) { key: '', service_instafeed: false, service_yellowduck: false, + title: '', + description: '', ...settings, }; @@ -56,6 +58,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -85,6 +95,28 @@ function Service(props) { GET + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Linkedin.js b/src/views/Publication/Services/Linkedin.js index ef51577..182b00b 100644 --- a/src/views/Publication/Services/Linkedin.js +++ b/src/views/Publication/Services/Linkedin.js @@ -50,6 +50,8 @@ function init(settings) { const initSettings = { protocol: 'rtmp://', address: '', + title: '', + description: '', ...settings, }; @@ -59,6 +61,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -96,6 +106,28 @@ function Service(props) { placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}" /> + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Rumble.js b/src/views/Publication/Services/Rumble.js index 7a1677b..1503ade 100644 --- a/src/views/Publication/Services/Rumble.js +++ b/src/views/Publication/Services/Rumble.js @@ -50,6 +50,8 @@ function init(settings) { const initSettings = { server_url: '', stream_key: '', + title: '', + description: '', ...settings, }; @@ -59,6 +61,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -107,6 +117,28 @@ function Service(props) { GET + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Twitch.js b/src/views/Publication/Services/Twitch.js index fc97923..6d2dfd2 100644 --- a/src/views/Publication/Services/Twitch.js +++ b/src/views/Publication/Services/Twitch.js @@ -44,6 +44,8 @@ function init(settings) { const initSettings = { region: 'live', key: '', + title: '', + description: '', ...settings, }; @@ -53,6 +55,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -155,6 +165,28 @@ function Service(props) { GET + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Twitter.js b/src/views/Publication/Services/Twitter.js index 2798c4a..1b79fac 100644 --- a/src/views/Publication/Services/Twitter.js +++ b/src/views/Publication/Services/Twitter.js @@ -66,6 +66,8 @@ function init(settings) { mode: 'rtmps', stream_key: '', region: 'de', + title: '', + description: '', ...settings, }; @@ -75,6 +77,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -179,6 +189,28 @@ function Service(props) { GET + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); } diff --git a/src/views/Publication/Services/Youtube.js b/src/views/Publication/Services/Youtube.js index 747f12c..49ad272 100644 --- a/src/views/Publication/Services/Youtube.js +++ b/src/views/Publication/Services/Youtube.js @@ -68,6 +68,8 @@ function init(settings) { stream_key: '', primary: true, backup: false, + title: '', + description: '', ...settings, }; @@ -77,6 +79,14 @@ function init(settings) { function Service(props) { const settings = init(props.settings); + // Pre-fill title/description from channel metadata if not already set + if (!settings.title && props.metadata && props.metadata.name) { + settings.title = props.metadata.name; + } + if (!settings.description && props.metadata && props.metadata.description) { + settings.description = props.metadata.description; + } + const handleChange = (what) => (event) => { const value = event.target.value; @@ -197,6 +207,28 @@ function Service(props) { Primary stream} checked={settings.primary} onChange={handleChange('primary')} /> Backup stream} checked={settings.backup} onChange={handleChange('backup')} /> + + Stream title} + placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''} + value={settings.title} + onChange={handleChange('title')} + /> + + + Stream description} + placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''} + value={settings.description} + onChange={handleChange('description')} + /> + ); }