Add YouTube URL extraction and proxy configuration for yt-dlp service

This commit is contained in:
CesarMendivil 2026-02-28 14:17:57 -07:00
parent 08b1dd0ba0
commit 0ee2475a1e
8 changed files with 2572 additions and 1934 deletions

2
.gitignore vendored
View File

@ -16,7 +16,7 @@
NONPUBLIC
.DS_Store
.VSCodeCounter
.env.local
.env
.env.development.local
.env.test.local
.env.production.local

View File

@ -91,6 +91,7 @@
"@lingui/cli": "^4.11.4",
"babel-core": "^6.26.3",
"eslint-config-react-app": "^7.0.1",
"http-proxy-middleware": "^2.0.7",
"prettier": "^3.3.3",
"react-error-overlay": "^6.0.11"
},

22
public/config.js Normal file
View File

@ -0,0 +1,22 @@
/**
* Restreamer UI - Runtime Configuration
*
* This file is loaded BEFORE the React app and can be modified
* without rebuilding the application.
*
* In Docker, mount this file or generate it via entrypoint script.
*
* CORE_ADDRESS: Restreamer Core URL. Leave empty to auto-detect from window.location.
* YTDLP_URL: yt-dlp stream extractor service URL (used to extract stream_url from YouTube).
* In development this is proxied via /yt-stream by setupProxy.js.
* In production set it to the actual service URL.
*
* Examples:
* CORE_ADDRESS = 'https://restreamer.nextream.sytes.net'
* YTDLP_URL = 'http://192.168.1.20:8282'
*/
window.__RESTREAMER_CONFIG__ = {
CORE_ADDRESS: '',
YTDLP_URL: '',
};

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
o e<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -9,6 +9,7 @@
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>Restreamer</title>
<script src="config.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -9,14 +9,33 @@ import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme';
import RestreamerUI from './RestreamerUI';
let address = window.location.protocol + '//' + window.location.host;
if (window.location.pathname.endsWith('/ui/')) {
address += window.location.pathname.replace(/ui\/$/, '');
}
/**
* Resolve the Restreamer Core address using the following priority:
*
* 1. ?address= query param (explicit override, useful for dev/testing)
* 2. window.__RESTREAMER_CONFIG__.CORE_ADDRESS (set in public/config.js editable at runtime)
* 3. Auto-detect from window.location:
* a. If served under /ui/ path strip it (standard production setup inside Core)
* b. Otherwise use empty string all /api/* calls are relative, handled by
* CRA proxy (setupProxy.js) in development or the same-origin server in production.
*/
const urlParams = new URLSearchParams(window.location.search.substring(1));
if (urlParams.has('address') === true) {
const runtimeConfig = window.__RESTREAMER_CONFIG__ || {};
let address;
if (urlParams.has('address')) {
// Priority 1: explicit ?address= param
address = urlParams.get('address');
} else if (runtimeConfig.CORE_ADDRESS && runtimeConfig.CORE_ADDRESS.trim() !== '') {
// Priority 2: runtime config.js (Docker / production override)
address = runtimeConfig.CORE_ADDRESS.trim();
} else if (window.location.pathname.endsWith('/ui/')) {
// Priority 3a: served inside Core at /ui/
address = window.location.protocol + '//' + window.location.host + window.location.pathname.replace(/ui\/$/, '');
} else {
// Priority 3b: development proxy or same-origin production
address = '';
}
createRoot(document.getElementById('root')).render(

60
src/setupProxy.js Normal file
View File

@ -0,0 +1,60 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
/**
* Development proxy configuration (CRA - only active with `npm start`).
*
* Set in your .env.local:
* REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net <- Restreamer Core
* REACT_APP_YTDLP_URL=http://192.168.1.20:8282 <- yt-dlp stream extractor
*/
const CORE_TARGET = process.env.REACT_APP_CORE_URL || 'http://localhost:8080';
const YTDLP_TARGET = process.env.REACT_APP_YTDLP_URL || 'http://localhost:8282';
console.log(`[setupProxy] /api → ${CORE_TARGET}`);
console.log(`[setupProxy] /yt-stream → ${YTDLP_TARGET}`);
const coreUrl = new URL(CORE_TARGET);
module.exports = function (app) {
// Proxy /api/* → Restreamer Core
app.use(
'/api',
createProxyMiddleware({
target: CORE_TARGET,
changeOrigin: true,
secure: false,
ws: false, // NO WebSocket — Core uses HTTP REST only
onProxyReq: (proxyReq) => {
// Fix Host header for HTTPS targets (required by some reverse proxies)
proxyReq.setHeader('Host', coreUrl.host);
},
onError: (err, req, res) => {
console.error(`[setupProxy] /api error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Proxy error', message: err.message }));
}
},
}),
);
// Proxy /yt-stream/* → yt-dlp extractor service → /stream/{VIDEO_ID}
app.use(
'/yt-stream',
createProxyMiddleware({
target: YTDLP_TARGET,
changeOrigin: true,
secure: false,
ws: false,
pathRewrite: { '^/yt-stream': '/stream' },
onError: (err, req, res) => {
console.error(`[setupProxy] /yt-stream error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Proxy error', message: err.message }));
}
},
}),
);
};

View File

@ -10,6 +10,7 @@ import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/AccountTree';
import MenuItem from '@mui/material/MenuItem';
@ -17,6 +18,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import WarningIcon from '@mui/icons-material/Warning';
import YouTubeIcon from '@mui/icons-material/YouTube';
import BoxTextarea from '../../../misc/BoxTextarea';
import BoxText from '../../../misc/BoxText';
@ -32,6 +34,17 @@ const useStyles = makeStyles((theme) => ({
gridContainer: {
marginTop: '0.5em',
},
youtubeButton: {
height: '56px',
minWidth: '56px',
color: 'rgba(255,255,255,0.5)',
borderColor: 'rgba(255,255,255,0.23)',
'&:hover': {
borderColor: '#FF0000',
color: '#FF0000',
backgroundColor: 'transparent',
},
},
}));
const initSettings = (initialSettings, config) => {
@ -744,6 +757,34 @@ function AdvancedSettings(props) {
);
}
// yt-dlp stream extractor endpoint.
// - Development: /yt-stream/{VIDEO_ID} is proxied by setupProxy.js → REACT_APP_YTDLP_URL/stream/{VIDEO_ID}
// - Production: reads window.__RESTREAMER_CONFIG__.YTDLP_URL set in public/config.js
// If not set, falls back to relative /yt-stream/ (same-origin service).
const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
: '/yt-stream/';
const extractYouTubeVideoId = (url) => {
if (!url) return '';
// Handles: youtu.be/ID, ?v=ID, /embed/ID, /shorts/ID
const patterns = [
/[?&]v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/,
/\/embed\/([a-zA-Z0-9_-]{11})/,
/\/shorts\/([a-zA-Z0-9_-]{11})/,
/\/v\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
// If it's already just an ID (11 chars alphanumeric)
if (/^[a-zA-Z0-9_-]{11}$/.test(url.trim())) return url.trim();
return '';
};
function Pull(props) {
const classes = useStyles();
const settings = props.settings;
@ -751,6 +792,38 @@ function Pull(props) {
const validURL = isValidURL(settings.address);
const supportedProtocol = isSupportedProtocol(settings.address, props.skills.protocols.input);
const [extractorLoading, setExtractorLoading] = React.useState(false);
const [extractorError, setExtractorError] = React.useState('');
const detectedYouTubeId = extractYouTubeVideoId(settings.address);
const handleExtract = async () => {
setExtractorError('');
const videoId = extractYouTubeVideoId(settings.address);
if (!videoId) {
setExtractorError('No YouTube URL detected in the address field.');
return;
}
setExtractorLoading(true);
try {
const response = await fetch(STREAM_SERVICE_BASE + videoId);
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' - ' + response.statusText);
}
const data = await response.json();
if (data && data.stream_url) {
props.onChange('', 'address')({ target: { value: data.stream_url } });
setExtractorError('');
} else {
setExtractorError('No stream_url found in service response.');
}
} catch (e) {
setExtractorError('Error: ' + e.message);
} finally {
setExtractorLoading(false);
}
};
return (
<Grid container alignItems="flex-start" spacing={2} className={classes.gridContainer}>
<Grid item xs={12}>
@ -759,17 +832,42 @@ function Pull(props) {
</Typography>
</Grid>
<Grid item xs={12}>
<Grid container spacing={1} alignItems="flex-start">
<Grid item xs>
<TextField
variant="outlined"
fullWidth
label={<Trans>Address</Trans>}
placeholder="rtsp://ip:port/path"
placeholder="rtsp://ip:port/path or YouTube URL"
value={settings.address}
onChange={props.onChange('', 'address')}
onChange={(e) => {
setExtractorError('');
props.onChange('', 'address')(e);
}}
/>
<Typography variant="caption">
<Trans>Supports HTTP (HLS, DASH), RTP, RTSP, RTMP, SRT and more.</Trans>
</Typography>
{extractorError && (
<Typography variant="caption" style={{ color: '#f44336', display: 'block' }}>
{extractorError}
</Typography>
)}
</Grid>
{detectedYouTubeId && (
<Grid item>
<Button
variant="outlined"
onClick={handleExtract}
disabled={extractorLoading}
className={classes.youtubeButton}
title="Extract M3U8 from YouTube URL"
>
{extractorLoading ? <CircularProgress size={20} color="inherit" /> : <YouTubeIcon />}
</Button>
</Grid>
)}
</Grid>
</Grid>
{validURL === true && (
<React.Fragment>

4265
yarn.lock

File diff suppressed because it is too large Load Diff