Add YouTube URL extraction and proxy configuration for yt-dlp service
This commit is contained in:
parent
08b1dd0ba0
commit
0ee2475a1e
2
.gitignore
vendored
2
.gitignore
vendored
@ -16,7 +16,7 @@
|
||||
NONPUBLIC
|
||||
.DS_Store
|
||||
.VSCodeCounter
|
||||
.env.local
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
@ -91,8 +91,9 @@
|
||||
"@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"
|
||||
},
|
||||
"resolutions": {}
|
||||
}
|
||||
}
|
||||
|
||||
22
public/config.js
Normal file
22
public/config.js
Normal 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: '',
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
31
src/index.js
31
src/index.js
@ -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
60
src/setupProxy.js
Normal 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 }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Address</Trans>}
|
||||
placeholder="rtsp://ip:port/path"
|
||||
value={settings.address}
|
||||
onChange={props.onChange('', 'address')}
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
<Trans>Supports HTTP (HLS, DASH), RTP, RTSP, RTMP, SRT and more.</Trans>
|
||||
</Typography>
|
||||
<Grid container spacing={1} alignItems="flex-start">
|
||||
<Grid item xs>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Address</Trans>}
|
||||
placeholder="rtsp://ip:port/path or YouTube URL"
|
||||
value={settings.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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user