Compare commits

...

4 Commits

8 changed files with 2590 additions and 1933 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

@ -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,40 @@ 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:
*
* 1. ?address= explicit URL param (always wins)
* 2. window.__RESTREAMER_CONFIG__ public/config.js (Docker/production runtime)
* 3. /ui/ path detection strip /ui/ suffix (embedded inside Core)
* 4. process.env.REACT_APP_CORE_URL .env / .env.local (CRA injects at build time)
* Used both for the proxy (Node.js) AND as the
* browser-side address so hostname is never empty.
* 5. window.location.origin same-origin fallback (production same host)
*/
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')) {
// 1. Explicit override via URL param
address = urlParams.get('address');
} else if (runtimeConfig.CORE_ADDRESS && runtimeConfig.CORE_ADDRESS.trim() !== '') {
// 2. Runtime config.js — Docker / production without /ui/ path
address = runtimeConfig.CORE_ADDRESS.trim().replace(/\/$/, '');
} else if (window.location.pathname.includes('/ui/')) {
// 3. Embedded inside Core at /ui/ path
address = window.location.protocol + '//' + window.location.host +
window.location.pathname.replace(/\/ui\/.*$/, '');
} else if (process.env.REACT_APP_CORE_URL && process.env.REACT_APP_CORE_URL.trim() !== '') {
// 4. .env / .env.local — CRA injects at build time.
// Use window.location.origin so all /api/* and /memfs/* calls go through
// the CRA dev proxy (setupProxy.js) which forwards them to REACT_APP_CORE_URL.
address = window.location.origin;
} else {
// 5. Same-origin production (Core and UI on the same host/port)
address = window.location.origin;
}
createRoot(document.getElementById('root')).render(

72
src/setupProxy.js Normal file
View File

@ -0,0 +1,72 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
/**
* Development proxy (CRA - only active with `npm start`).
*
* .env / .env.local:
* REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net
* REACT_APP_YTDLP_URL=http://192.168.1.20:8282
*/
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('\n[setupProxy] ─────────────────────────────────────');
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
console.log('[setupProxy] ─────────────────────────────────────\n');
let coreUrl;
try {
coreUrl = new URL(CORE_TARGET);
} catch (e) {
console.error(`[setupProxy] Invalid REACT_APP_CORE_URL: "${CORE_TARGET}"`);
coreUrl = new URL('http://localhost:8080');
}
// Shared proxy instance for all Core paths (/api, /memfs, /diskfs)
const coreProxy = createProxyMiddleware({
target: CORE_TARGET,
changeOrigin: true,
secure: false,
ws: false,
onProxyReq: (proxyReq) => {
proxyReq.setHeader('Host', coreUrl.host);
},
onError: (err, req, res) => {
console.error(`[setupProxy] Core proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Proxy error', target: CORE_TARGET, message: err.message }));
}
},
});
module.exports = function (app) {
// Restreamer Core REST API
app.use('/api', coreProxy);
// Restreamer Core in-memory FS (HLS playlists, snapshots)
app.use('/memfs', coreProxy);
// Restreamer Core disk FS
app.use('/diskfs', coreProxy);
// yt-dlp stream extractor: /yt-stream/{VIDEO_ID} → /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-dlp proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Proxy error', target: YTDLP_TARGET, 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