Merge pull request 'develop/yt-dlp-api_webadapted' (#1) from develop/yt-dlp-api_webadapted into develop/stable
Reviewed-on: #1
This commit is contained in:
commit
3ca522b345
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,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
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: '',
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
38
src/index.js
38
src/index.js
@ -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
72
src/setupProxy.js
Normal 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 }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user