引入m3u8
This commit is contained in:
@@ -126,6 +126,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const seekTotalTime = document.getElementById('seek-total-time');
|
const seekTotalTime = document.getElementById('seek-total-time');
|
||||||
const seekingOverlay = document.getElementById('seeking-overlay');
|
const seekingOverlay = document.getElementById('seeking-overlay');
|
||||||
|
|
||||||
|
let hlsInstance = null;
|
||||||
let currentPollInterval = null;
|
let currentPollInterval = null;
|
||||||
let selectedBucket = null;
|
let selectedBucket = null;
|
||||||
let selectedKey = null;
|
let selectedKey = null;
|
||||||
@@ -141,14 +142,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildStreamUrl = (targetSeconds = null) => {
|
const buildHlsPlaylistUrl = () => {
|
||||||
const decoder = decoderSelect?.value || 'auto';
|
const decoder = decoderSelect?.value || 'auto';
|
||||||
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
||||||
const streamSessionId = createStreamSessionId();
|
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
|
||||||
let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
|
|
||||||
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
|
|
||||||
streamUrl += `&ss=${targetSeconds}`;
|
|
||||||
}
|
|
||||||
if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`;
|
if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`;
|
||||||
if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`;
|
if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`;
|
||||||
return streamUrl;
|
return streamUrl;
|
||||||
@@ -429,25 +426,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Browsers usually cannot seek inside a live fragmented MP4 stream.
|
|
||||||
// When the user drags the native video controls, remap that action to our server-side seek flow.
|
|
||||||
videoPlayer.addEventListener('seeking', () => {
|
|
||||||
if (internalSeeking || !isStreamActive || videoDuration <= 0) return;
|
|
||||||
|
|
||||||
const requestedAbsoluteTime = Math.max(0, Math.min(seekOffset + (videoPlayer.currentTime || 0), videoDuration - 0.5));
|
|
||||||
const drift = Math.abs(requestedAbsoluteTime - lastAbsolutePlaybackTime);
|
|
||||||
|
|
||||||
if (drift < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
internalSeeking = true;
|
|
||||||
setPlaybackStatus('Seeking', 'seeking');
|
|
||||||
seekToTime(requestedAbsoluteTime);
|
|
||||||
setTimeout(() => {
|
|
||||||
internalSeeking = false;
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seek bar interaction: click or drag
|
// Seek bar interaction: click or drag
|
||||||
const getSeekRatio = (e) => {
|
const getSeekRatio = (e) => {
|
||||||
@@ -549,49 +528,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekToTime = (targetSeconds) => {
|
|
||||||
if (!selectedKey || !selectedBucket || videoDuration <= 0) return;
|
|
||||||
targetSeconds = Math.max(0, Math.min(targetSeconds, videoDuration - 0.5));
|
|
||||||
seekOffset = targetSeconds;
|
|
||||||
isStreamActive = false;
|
|
||||||
lastAbsolutePlaybackTime = targetSeconds;
|
|
||||||
updateSeekBarPosition(targetSeconds);
|
|
||||||
|
|
||||||
// Show seeking indicator
|
|
||||||
if (seekingOverlay) seekingOverlay.classList.remove('hidden');
|
|
||||||
setPlaybackStatus('Seeking', 'seeking');
|
|
||||||
revealPlaybackChrome();
|
|
||||||
if (pendingSeekTimeout) {
|
|
||||||
clearTimeout(pendingSeekTimeout);
|
|
||||||
pendingSeekTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build new stream URL with ss parameter
|
|
||||||
const streamUrl = buildStreamUrl(targetSeconds);
|
|
||||||
|
|
||||||
// Changing src automatically aborts the previous HTTP request,
|
|
||||||
// which triggers res.on('close') on the server, killing the old ffmpeg process
|
|
||||||
videoPlayer.src = streamUrl;
|
|
||||||
videoPlayer.load();
|
|
||||||
|
|
||||||
const onCanPlay = () => {
|
|
||||||
if (seekingOverlay) seekingOverlay.classList.add('hidden');
|
|
||||||
isStreamActive = true;
|
|
||||||
lastAbsolutePlaybackTime = targetSeconds;
|
|
||||||
videoPlayer.play().catch(() => {});
|
|
||||||
updatePlayControls();
|
|
||||||
schedulePlaybackChromeHide();
|
|
||||||
videoPlayer.removeEventListener('canplay', onCanPlay);
|
|
||||||
};
|
|
||||||
videoPlayer.addEventListener('canplay', onCanPlay, { once: true });
|
|
||||||
|
|
||||||
// Timeout fallback: hide seeking overlay after 8s even if canplay doesn't fire
|
|
||||||
pendingSeekTimeout = setTimeout(() => {
|
|
||||||
if (seekingOverlay) seekingOverlay.classList.add('hidden');
|
|
||||||
isStreamActive = true;
|
|
||||||
pendingSeekTimeout = null;
|
|
||||||
}, 8000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- End custom seek bar ---
|
// --- End custom seek bar ---
|
||||||
|
|
||||||
@@ -875,6 +812,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
videoPlayer.classList.add('hidden');
|
videoPlayer.classList.add('hidden');
|
||||||
videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
videoPlayer.removeAttribute('src');
|
videoPlayer.removeAttribute('src');
|
||||||
|
if (hlsInstance) {
|
||||||
|
hlsInstance.destroy();
|
||||||
|
hlsInstance = null;
|
||||||
|
}
|
||||||
videoPlayer.load();
|
videoPlayer.load();
|
||||||
isStreamActive = false;
|
isStreamActive = false;
|
||||||
videoDuration = 0;
|
videoDuration = 0;
|
||||||
@@ -932,22 +873,57 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!selectedBucket) throw new Error('No bucket selected');
|
if (!selectedBucket) throw new Error('No bucket selected');
|
||||||
const streamUrl = buildStreamUrl();
|
const streamUrl = buildHlsPlaylistUrl();
|
||||||
|
if (hlsInstance) {
|
||||||
|
hlsInstance.destroy();
|
||||||
|
hlsInstance = null;
|
||||||
|
}
|
||||||
|
if (window.Hls && Hls.isSupported()) {
|
||||||
|
hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 });
|
||||||
|
hlsInstance.loadSource(streamUrl);
|
||||||
|
hlsInstance.attachMedia(videoPlayer);
|
||||||
|
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
transcodingOverlay.classList.add('hidden');
|
||||||
|
videoPlayer.classList.remove('hidden');
|
||||||
|
isStreamActive = true;
|
||||||
|
videoPlayer.play().catch(()=>{});
|
||||||
|
showSeekBar();
|
||||||
|
showCustomControls();
|
||||||
|
updatePlayControls();
|
||||||
|
updateVolumeControls();
|
||||||
|
updateFullscreenControls();
|
||||||
|
schedulePlaybackChromeHide();
|
||||||
|
});
|
||||||
|
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
|
||||||
|
if (data.fatal) {
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
hlsInstance.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
hlsInstance.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
hlsInstance.destroy();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
videoPlayer.src = streamUrl;
|
videoPlayer.src = streamUrl;
|
||||||
videoPlayer.load();
|
|
||||||
videoPlayer.addEventListener('loadedmetadata', () => {
|
videoPlayer.addEventListener('loadedmetadata', () => {
|
||||||
transcodingOverlay.classList.add('hidden');
|
transcodingOverlay.classList.add('hidden');
|
||||||
videoPlayer.classList.remove('hidden');
|
videoPlayer.classList.remove('hidden');
|
||||||
isStreamActive = true;
|
isStreamActive = true;
|
||||||
lastAbsolutePlaybackTime = seekOffset;
|
videoPlayer.play().catch(()=>{});
|
||||||
showSeekBar();
|
showSeekBar();
|
||||||
showCustomControls();
|
showCustomControls();
|
||||||
updateSeekBarPosition(seekOffset);
|
|
||||||
updatePlayControls();
|
updatePlayControls();
|
||||||
updateVolumeControls();
|
updateVolumeControls();
|
||||||
updateFullscreenControls();
|
updateFullscreenControls();
|
||||||
schedulePlaybackChromeHide();
|
schedulePlaybackChromeHide();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
transcodingOverlay.innerHTML = `<p style="color: #ef4444;">Transcode Failed: ${err.message}</p>`;
|
transcodingOverlay.innerHTML = `<p style="color: #ef4444;">Transcode Failed: ${err.message}</p>`;
|
||||||
@@ -969,6 +945,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!res.ok) throw new Error(data.error || 'Failed to stop transcode');
|
if (!res.ok) throw new Error(data.error || 'Failed to stop transcode');
|
||||||
|
|
||||||
handleProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' });
|
handleProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' });
|
||||||
|
if (hlsInstance) {
|
||||||
|
hlsInstance.destroy();
|
||||||
|
hlsInstance = null;
|
||||||
|
}
|
||||||
isStreamActive = false;
|
isStreamActive = false;
|
||||||
videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
videoPlayer.removeAttribute('src');
|
videoPlayer.removeAttribute('src');
|
||||||
@@ -1091,34 +1071,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
document.addEventListener('fullscreenchange', updateFullscreenControls);
|
document.addEventListener('fullscreenchange', updateFullscreenControls);
|
||||||
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (!isStreamActive) return;
|
|
||||||
const tagName = event.target?.tagName;
|
|
||||||
if (tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA' || tagName === 'BUTTON') return;
|
|
||||||
|
|
||||||
if (event.code === 'Space') {
|
|
||||||
event.preventDefault();
|
|
||||||
if (videoPlayer.paused) {
|
|
||||||
videoPlayer.play().catch(() => {});
|
|
||||||
} else {
|
|
||||||
videoPlayer.pause();
|
|
||||||
}
|
|
||||||
revealPlaybackChrome();
|
|
||||||
schedulePlaybackChromeHide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === 'ArrowRight') {
|
|
||||||
event.preventDefault();
|
|
||||||
seekToTime((seekOffset + (videoPlayer.currentTime || 0)) + 5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === 'ArrowLeft') {
|
|
||||||
event.preventDefault();
|
|
||||||
seekToTime((seekOffset + (videoPlayer.currentTime || 0)) - 5);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updatePlayControls();
|
updatePlayControls();
|
||||||
updateVolumeControls();
|
updateVolumeControls();
|
||||||
|
|||||||
156
server.js
156
server.js
@@ -446,6 +446,162 @@ app.post('/api/stop-transcode', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const HLS_SEGMENT_TIME = 6;
|
||||||
|
const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
|
||||||
|
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (fs.existsSync(m3u8Path)) {
|
||||||
|
const m3u8Content = fs.readFileSync(m3u8Path, 'utf8');
|
||||||
|
if (m3u8Content.includes(`segment_${segIndex}.ts`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (m3u8Content.includes(`#EXT-X-ENDLIST`)) {
|
||||||
|
if (fs.existsSync(segPath)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
||||||
|
const bucket = req.query.bucket;
|
||||||
|
const key = req.query.key;
|
||||||
|
if (!bucket || !key) return res.status(400).send('Bad Request');
|
||||||
|
|
||||||
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
|
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||||
|
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
|
|
||||||
|
const auth = extractS3Credentials(req);
|
||||||
|
const s3Client = createS3Client(auth);
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
if (!fs.existsSync(tmpInputPath)) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
totalBytes = response.ContentLength;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const writeStream = fs.createWriteStream(tmpInputPath);
|
||||||
|
response.Body.pipe(writeStream);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
});
|
||||||
|
} catch(err) {
|
||||||
|
return res.status(500).send('S3 Download Failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = 0;
|
||||||
|
try {
|
||||||
|
const metadata = await probeFile(tmpInputPath);
|
||||||
|
duration = parseFloat(metadata.format?.duration || 0);
|
||||||
|
} catch(err) {}
|
||||||
|
|
||||||
|
if (duration <= 0) duration = 3600;
|
||||||
|
|
||||||
|
const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME);
|
||||||
|
|
||||||
|
let m3u8 = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${HLS_SEGMENT_TIME}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`;
|
||||||
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
|
let segDur = HLS_SEGMENT_TIME;
|
||||||
|
if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) {
|
||||||
|
segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME;
|
||||||
|
}
|
||||||
|
m3u8 += `#EXTINF:${segDur.toFixed(6)},\nsegment.ts?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}&seg=${i}&encoder=${req.query.encoder||'h264_rkmpp'}&decoder=${req.query.decoder||'auto'}\n`;
|
||||||
|
}
|
||||||
|
m3u8 += `#EXT-X-ENDLIST\n`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.send(m3u8);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hlsProcesses = new Map();
|
||||||
|
|
||||||
|
app.get('/api/hls/segment.ts', async (req, res) => {
|
||||||
|
const bucket = req.query.bucket;
|
||||||
|
const key = req.query.key;
|
||||||
|
const seg = parseInt(req.query.seg || '0');
|
||||||
|
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
|
||||||
|
const requestedDecoder = req.query.decoder || 'auto';
|
||||||
|
|
||||||
|
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
|
||||||
|
|
||||||
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
|
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||||
|
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
|
|
||||||
|
const progressKey = safeKeySegments.join('/');
|
||||||
|
const hlsDir = path.join(os.tmpdir(), `hls-${safeBucket}-${progressKey}`);
|
||||||
|
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
|
||||||
|
|
||||||
|
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
|
||||||
|
let currentProcess = hlsProcesses.get(progressKey);
|
||||||
|
const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && Math.abs((currentProcess.currentSeg || 0) - seg) > 2);
|
||||||
|
|
||||||
|
if (needsNewProcess) {
|
||||||
|
if (currentProcess && currentProcess.command) {
|
||||||
|
try { currentProcess.command.kill('SIGKILL'); } catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
|
||||||
|
|
||||||
|
let sourceMetadata = null;
|
||||||
|
try { sourceMetadata = await probeFile(tmpInputPath); } catch(e){}
|
||||||
|
|
||||||
|
const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
|
||||||
|
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
||||||
|
|
||||||
|
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
||||||
|
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
|
||||||
|
|
||||||
|
const ffmpegCommand = ffmpeg().input(tmpInputPath);
|
||||||
|
if (startTime > 0) ffmpegCommand.seekInput(startTime);
|
||||||
|
|
||||||
|
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
|
||||||
|
|
||||||
|
if (isVaapiCodec(encoderName)) {
|
||||||
|
ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload');
|
||||||
|
}
|
||||||
|
const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName;
|
||||||
|
if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
|
||||||
|
|
||||||
|
const segmentFilename = path.join(hlsDir, `segment_%d.ts`);
|
||||||
|
const hlsOptions = createFfmpegOptions(encoderName).concat([
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', HLS_SEGMENT_TIME.toString(),
|
||||||
|
'-hls_list_size', '0',
|
||||||
|
'-hls_segment_filename', segmentFilename,
|
||||||
|
'-start_number', seg.toString()
|
||||||
|
]);
|
||||||
|
|
||||||
|
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
|
||||||
|
ffmpegCommand.on('error', (err) => {
|
||||||
|
console.error('HLS FFmpeg Error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegCommand.run();
|
||||||
|
currentProcess = { command: ffmpegCommand, currentSeg: seg };
|
||||||
|
hlsProcesses.set(progressKey, currentProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = await waitForSegment(hlsDir, seg);
|
||||||
|
if (!ready) {
|
||||||
|
return res.status(500).send('Segment generation timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'video/MP2T');
|
||||||
|
res.sendFile(targetSegPath);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/stream', async (req, res) => {
|
app.get('/api/stream', async (req, res) => {
|
||||||
const bucket = req.query.bucket;
|
const bucket = req.query.bucket;
|
||||||
const key = req.query.key;
|
const key = req.query.key;
|
||||||
|
|||||||
Reference in New Issue
Block a user