Add YouTube playback and queue flow

This commit is contained in:
2026-06-11 21:13:48 -07:00
parent 2866d33dec
commit 20f6d4d192
8 changed files with 612 additions and 101 deletions

View File

@@ -6,7 +6,7 @@ This project is a web video player for clients that can decode audio and still i
The UI intentionally has only two screens:
- URL entry screen with a stream URL input, a `Next` button, globally stored recently played URLs, and globally stored favorites.
- URL entry screen with a stream URL input, a `Play` button, a `Queue` button, globally stored recently played URLs, and globally stored favorites.
- Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element.
- Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube.
- Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI.
@@ -28,6 +28,7 @@ Main public endpoints:
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session.
- `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`.
- `POST /api/recent-urls`: validates a stream URL and stores it globally without creating a playback session.
- `GET /api/favorites`: returns global favorite entries with `title` and `url`.
- `PUT /api/favorites`: replaces the global favorites list. Each favorite has a user-provided `title` and stream `url`.
- `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
@@ -172,6 +173,9 @@ Runtime:
- `PORT`: HTTP port, default `3000`.
- `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`.
- `YT_DLP_PATH`: yt-dlp binary path, default `yt-dlp`.
- `YT_DLP_FORMAT`: yt-dlp format selector for YouTube URLs, default `best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]/best`.
- `YT_DLP_TIMEOUT_MS`: yt-dlp resolution timeout, default `45000`.
- `FFMPEG_LOG_LEVEL`: ffmpeg log level, default `warning`.
- `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`.
- `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`.
@@ -206,7 +210,7 @@ Session playback options are accepted by `POST /api/session` even though the UI
## Docker Notes
The Docker image installs ffmpeg and runs as non-root `node`.
The Docker image installs ffmpeg and yt-dlp and runs as non-root `node`. yt-dlp is installed from the upstream master branch when the image is built.
Hardware acceleration is not required. Device passthrough may help only if server CPU decode is saturated. It does not fix audio/frame coupling issues; `relay` was built for that.

View File

@@ -3,12 +3,19 @@ FROM node:22-bookworm-slim
ENV NODE_ENV=production
ENV PORT=3000
ARG DEBIAN_FRONTEND=noninteractive
ARG YT_DLP_PIP_SPEC="yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"
WORKDIR /app
ADD https://api.github.com/repos/yt-dlp/yt-dlp/commits/master /tmp/yt-dlp-master.json
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg python3 python3-pip \
&& python3 -m pip install --no-cache-dir --break-system-packages --upgrade "$YT_DLP_PIP_SPEC" \
&& yt-dlp --version \
&& apt-get purge -y --auto-remove python3-pip \
&& rm -f /tmp/yt-dlp-master.json \
&& rm -rf /root/.cache/pip /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev

View File

@@ -14,12 +14,13 @@ npm install
npm start
```
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Next`.
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Play`. Use `Queue` to add a URL to Recents without starting playback.
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at a different binary with:
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. YouTube page links also need `yt-dlp`; Docker installs it automatically from the upstream master branch at image build time. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at different binaries with:
```sh
FFMPEG_PATH=/path/to/ffmpeg npm start
YT_DLP_PATH=/path/to/yt-dlp npm start
```
## Docker
@@ -49,6 +50,8 @@ docker logs -f frame-stream-player
The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. For ffmpeg-owned HTTP inputs, reconnect handling is enabled by default with `FFMPEG_HTTP_RECONNECT=1`.
YouTube URLs are resolved server-side with `yt-dlp` before they enter the existing ffmpeg pipeline. Recents and favorites keep the original YouTube URL, while the short-lived playback session uses the resolved media URL and headers returned by `yt-dlp`. Tune the selected format with `YT_DLP_FORMAT` and the resolver timeout with `YT_DLP_TIMEOUT_MS`.
JPEG frames are dropped when the browser WebSocket falls behind instead of letting stale frames queue indefinitely. Tune the server-side backlog cap with `MAX_WS_BUFFER_BYTES`; the default is `2097152`.
In single mode, audio output from `ffmpeg` is buffered before it is written to the browser so short HTTP backpressure pauses are less likely to stall frame generation. Tune the cap with `MAX_AUDIO_QUEUE_BYTES`; the default is `4194304`.

View File

@@ -9,6 +9,7 @@ services:
environment:
PORT: "3000"
NODE_ENV: production
YT_DLP_TIMEOUT_MS: "45000"
# split: smoothest, two upstream connections.
# relay: one upstream connection, separate audio/frame ffmpeg workers.
# single: one upstream connection and one ffmpeg worker; fallback only.

View File

@@ -3,7 +3,8 @@ const elements = {
playerScreen: document.querySelector('#player-screen'),
form: document.querySelector('#stream-form'),
url: document.querySelector('#stream-url'),
next: document.querySelector('#next'),
play: document.querySelector('#play'),
queue: document.querySelector('#queue'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
@@ -17,7 +18,15 @@ const elements = {
cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'),
favoritesManager: document.querySelector('#favorites-manager'),
favoritesEditorList: document.querySelector('#favorites-editor-list'),
favoriteWizard: document.querySelector('#favorite-wizard'),
favoriteWizardStep: document.querySelector('#favorite-wizard-step'),
favoriteWizardBack: document.querySelector('#favorite-wizard-back'),
favoriteTitleField: document.querySelector('#favorite-title-field'),
favoriteUrlField: document.querySelector('#favorite-url-field'),
favoriteTitle: document.querySelector('#favorite-title'),
favoriteUrl: document.querySelector('#favorite-url'),
favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
@@ -67,6 +76,7 @@ const state = {
recentUrls: [],
favorites: [],
favoriteModalTrigger: null,
favoriteWizard: createFavoriteWizardState(),
playbackOffset: 0,
duration: null,
seekable: false,
@@ -86,6 +96,14 @@ void loadEntryLists();
elements.form.addEventListener('submit', async (event) => {
event.preventDefault();
await playCurrentUrl();
});
elements.queue.addEventListener('click', () => {
void queueCurrentUrl();
});
async function playCurrentUrl() {
setEntryMessage('');
setFormBusy(true);
@@ -113,7 +131,33 @@ elements.form.addEventListener('submit', async (event) => {
} finally {
setFormBusy(false);
}
}
async function queueCurrentUrl() {
setEntryMessage('');
setFormBusy(true);
try {
const response = await fetch('/api/recent-urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: elements.url.value }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to queue URL.');
}
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
renderRecentUrls();
setEntryMessage('Queued');
} catch (error) {
setEntryMessage(error.message);
} finally {
setFormBusy(false);
}
}
elements.recentList.addEventListener('click', (event) => {
const button = event.target.closest('[data-recent-index]');
@@ -158,28 +202,49 @@ elements.closeFavorites.addEventListener('click', () => {
});
elements.cancelFavorites.addEventListener('click', () => {
if (state.favoriteWizard.active) {
showFavoritesManager();
return;
}
closeFavoritesModal();
});
elements.addFavorite.addEventListener('click', () => {
elements.favoritesEditorList.append(createFavoriteEditorRow());
focusLastFavoriteTitle();
startFavoriteWizard('add');
});
elements.favoritesEditorList.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-favorite]');
const removeButton = event.target.closest('[data-remove-favorite]');
const editButton = event.target.closest('[data-edit-favorite]');
if (!button) {
if (removeButton) {
void removeFavorite(Number(removeButton.dataset.removeFavorite));
return;
}
button.closest('.favorite-editor-row')?.remove();
ensureFavoriteEditorRows();
if (editButton) {
startFavoriteWizard('edit', Number(editButton.dataset.editFavorite));
}
});
elements.favoritesForm.addEventListener('submit', (event) => {
event.preventDefault();
void saveFavoritesFromModal();
void advanceFavoriteWizard();
});
elements.favoriteWizardBack.addEventListener('click', () => {
if (!state.favoriteWizard.active) {
return;
}
if (state.favoriteWizard.step === 'url') {
state.favoriteWizard.step = 'title';
syncFavoriteWizard();
return;
}
showFavoritesManager();
});
elements.favoritesModal.addEventListener('click', (event) => {
@@ -855,6 +920,17 @@ function createClientTelemetry() {
};
}
function createFavoriteWizardState() {
return {
active: false,
mode: 'add',
index: -1,
step: 'title',
title: '',
url: '',
};
}
function noteClientTelemetry(name, count = 1) {
if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) {
return;
@@ -1259,7 +1335,8 @@ function setEntryMessage(message) {
function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.next.disabled = isBusy;
elements.play.disabled = isBusy;
elements.queue.disabled = isBusy;
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
@@ -1351,82 +1428,178 @@ function createEmptyListMessage(text) {
function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites);
showFavoritesManager();
elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField();
elements.addFavorite.focus();
}
function closeFavoritesModal() {
elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = '';
state.favoriteWizard = createFavoriteWizardState();
state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null;
}
function renderFavoriteEditorRows(items) {
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)),
);
function showFavoritesManager() {
state.favoriteWizard = createFavoriteWizardState();
elements.favoritesMessage.textContent = '';
elements.favoritesManager.hidden = false;
elements.favoriteWizard.hidden = true;
elements.favoriteWizardBack.hidden = true;
elements.saveFavorites.hidden = true;
elements.cancelFavorites.textContent = 'Done';
renderFavoriteManager();
}
function createFavoriteEditorRow(item = { title: '', url: '' }) {
function renderFavoriteManager() {
elements.favoritesEditorList.replaceChildren(
...state.favorites.map((item, index) => createFavoriteManagerRow(item, index)),
);
if (state.favorites.length === 0) {
elements.favoritesEditorList.replaceChildren(createEmptyListMessage('No favorites'));
}
}
function createFavoriteManagerRow(item, index) {
const row = document.createElement('div');
row.className = 'favorite-editor-row';
row.className = 'favorite-manager-row';
const title = document.createElement('input');
title.type = 'text';
title.placeholder = 'Title';
title.setAttribute('aria-label', 'Favorite title');
title.autocomplete = 'off';
title.maxLength = 120;
title.value = item.title ?? '';
title.dataset.favoriteTitle = '';
const summary = document.createElement('div');
summary.className = 'favorite-summary';
const url = document.createElement('input');
url.type = 'url';
url.placeholder = 'URL';
url.setAttribute('aria-label', 'Favorite URL');
url.autocomplete = 'off';
url.value = item.url ?? '';
url.dataset.favoriteUrl = '';
const title = document.createElement('div');
title.className = 'favorite-title';
title.textContent = item.title;
const url = document.createElement('div');
url.className = 'favorite-url';
url.textContent = item.url;
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'secondary-button favorite-action';
edit.dataset.editFavorite = String(index);
edit.textContent = 'Edit';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-favorite';
remove.dataset.removeFavorite = '';
remove.setAttribute('aria-label', 'Remove favorite');
remove.className = 'remove-favorite favorite-action';
remove.dataset.removeFavorite = String(index);
remove.textContent = 'Remove';
row.append(title, url, remove);
summary.append(title, url);
row.append(summary, edit, remove);
return row;
}
function ensureFavoriteEditorRows() {
if (elements.favoritesEditorList.children.length === 0) {
elements.favoritesEditorList.append(createFavoriteEditorRow());
}
function startFavoriteWizard(mode, index = -1) {
const item = mode === 'edit' ? state.favorites[index] : null;
if (mode === 'edit' && !item) {
return;
}
function focusFirstFavoriteField() {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
field?.focus();
}
state.favoriteWizard = {
active: true,
mode,
index,
step: 'title',
title: item?.title ?? '',
url: item?.url ?? elements.url.value.trim(),
};
function focusLastFavoriteTitle() {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus();
}
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = '';
elements.favoritesManager.hidden = true;
elements.favoriteWizard.hidden = false;
elements.favoriteWizardBack.hidden = false;
elements.saveFavorites.hidden = false;
elements.cancelFavorites.textContent = 'Cancel';
syncFavoriteWizard();
}
function syncFavoriteWizard() {
const wizard = state.favoriteWizard;
const isUrlStep = wizard.step === 'url';
elements.favoriteWizardStep.textContent = isUrlStep ? 'URL' : 'Title';
elements.favoriteTitleField.hidden = isUrlStep;
elements.favoriteUrlField.hidden = !isUrlStep;
elements.favoriteTitle.value = wizard.title;
elements.favoriteUrl.value = wizard.url;
elements.saveFavorites.textContent = isUrlStep ? 'Save' : 'Next';
window.setTimeout(() => {
if (isUrlStep) {
elements.favoriteUrl.focus();
return;
}
elements.favoriteTitle.focus();
}, 0);
}
async function advanceFavoriteWizard() {
if (!state.favoriteWizard.active) {
return;
}
elements.favoritesMessage.textContent = '';
const wizard = state.favoriteWizard;
if (wizard.step === 'title') {
const title = elements.favoriteTitle.value.trim();
if (!title) {
elements.favoritesMessage.textContent = 'Favorite needs a title.';
elements.favoriteTitle.focus();
return;
}
wizard.title = title;
wizard.step = 'url';
syncFavoriteWizard();
return;
}
const url = elements.favoriteUrl.value.trim();
if (!url) {
elements.favoritesMessage.textContent = 'Favorite needs a URL.';
elements.favoriteUrl.focus();
return;
}
wizard.url = url;
const nextFavorites = wizard.mode === 'edit'
? state.favorites.map((item, index) => (index === wizard.index ? { title: wizard.title, url: wizard.url } : item))
: [...state.favorites, { title: wizard.title, url: wizard.url }];
await saveFavoritesList(nextFavorites, {
onSuccess: () => {
showFavoritesManager();
elements.addFavorite.focus();
},
});
}
async function removeFavorite(index) {
if (!Number.isInteger(index) || index < 0 || index >= state.favorites.length) {
return;
}
const nextFavorites = state.favorites.filter((_item, itemIndex) => itemIndex !== index);
await saveFavoritesList(nextFavorites);
}
async function saveFavoritesList(favorites, { onSuccess = () => {} } = {}) {
setFavoritesModalBusy(true);
try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1440,7 +1613,8 @@ async function saveFavoritesFromModal() {
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
closeFavoritesModal();
renderFavoriteManager();
onSuccess();
} catch (error) {
elements.favoritesMessage.textContent = error.message;
} finally {
@@ -1448,15 +1622,6 @@ async function saveFavoritesFromModal() {
}
}
function readFavoriteEditorRows() {
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
.map((row) => ({
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
}))
.filter((item) => item.title || item.url);
}
function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy;

View File

@@ -20,7 +20,8 @@
autocomplete="off"
required
>
<button id="next" type="submit">Next</button>
<button id="queue" type="button" class="secondary-submit">Queue</button>
<button id="play" type="submit">Play</button>
</form>
<div id="library-panel" class="library-panel" aria-label="Saved streams">
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
@@ -64,15 +65,29 @@
<div id="favorites-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="favorites-modal-title" hidden>
<form id="favorites-form" class="modal-panel">
<div class="modal-heading">
<h2 id="favorites-modal-title">Edit Favorites</h2>
<h2 id="favorites-modal-title">Favorites</h2>
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">&times;</button>
</div>
<div id="favorites-manager" class="favorites-manager">
<div id="favorites-editor-list" class="favorites-editor-list"></div>
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button>
</div>
<div id="favorite-wizard" class="favorite-wizard" hidden>
<div id="favorite-wizard-step" class="wizard-step">Title</div>
<label id="favorite-title-field" class="wizard-field" for="favorite-title">
<span>Title</span>
<input id="favorite-title" type="text" autocomplete="off" maxlength="120">
</label>
<label id="favorite-url-field" class="wizard-field" for="favorite-url" hidden>
<span>URL</span>
<input id="favorite-url" type="url" autocomplete="off">
</label>
</div>
<div id="favorites-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button id="cancel-favorites" type="button" class="secondary-button">Cancel</button>
<button id="save-favorites" type="submit" class="primary-button">Save</button>
<button id="cancel-favorites" type="button" class="secondary-button">Done</button>
<button id="favorite-wizard-back" type="button" class="secondary-button" hidden>Back</button>
<button id="save-favorites" type="submit" class="primary-button" hidden>Next</button>
</div>
</form>
</div>

View File

@@ -116,6 +116,11 @@ button:disabled {
color: #070707;
}
.url-form .secondary-submit {
color: var(--fg);
background: rgba(255, 255, 255, 0.10);
}
.library-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -423,7 +428,7 @@ audio {
.modal-panel {
display: grid;
width: min(860px, 100%);
width: min(620px, 100%);
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
gap: 0.85rem;
@@ -456,29 +461,85 @@ audio {
.favorites-editor-list {
display: grid;
gap: 0.55rem;
max-height: min(52vh, 28rem);
overflow: auto;
}
.favorite-editor-row {
.favorites-manager,
.favorite-wizard {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto;
gap: 0.55rem;
gap: 0.75rem;
}
.favorite-editor-row input {
.favorite-manager-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.5rem;
min-height: 4rem;
padding: 0.55rem;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
}
.favorite-summary {
min-width: 0;
}
.favorite-title,
.favorite-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite-title {
color: var(--fg);
font-weight: 800;
}
.favorite-url {
margin-top: 0.22rem;
color: rgba(246, 241, 232, 0.52);
font-size: 0.84rem;
}
.favorite-action {
min-width: 5.2rem;
}
.wizard-step {
color: var(--soft);
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.wizard-field {
display: grid;
gap: 0.45rem;
color: var(--soft);
font-size: 0.86rem;
font-weight: 800;
text-transform: uppercase;
}
.wizard-field input {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.72rem 0.8rem;
padding: 0.9rem 0.95rem;
color: var(--fg);
font-size: 1rem;
font-weight: 500;
text-transform: none;
background: rgba(255, 255, 255, 0.08);
outline: none;
}
.favorite-editor-row input::placeholder {
color: rgba(246, 241, 232, 0.42);
}
.favorite-editor-row input:focus {
.wizard-field input:focus {
border-color: rgba(232, 168, 79, 0.7);
}
@@ -540,10 +601,14 @@ audio {
max-height: 28vh;
}
.favorite-editor-row {
.favorite-manager-row {
grid-template-columns: 1fr;
}
.favorite-action {
width: 100%;
}
.remove-favorite,
.secondary-button,
.primary-button {
@@ -552,7 +617,7 @@ audio {
.modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
.controls {

View File

@@ -19,6 +19,9 @@ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe';
const YT_DLP_PATH = process.env.YT_DLP_PATH ?? 'yt-dlp';
const YT_DLP_FORMAT = process.env.YT_DLP_FORMAT ?? 'best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]/best';
const YT_DLP_TIMEOUT_MS = clampInteger(process.env.YT_DLP_TIMEOUT_MS, 45 * 1000, 5 * 1000, 120 * 1000);
const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning';
const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const FFMPEG_HTTP_RECONNECT = parseBoolean(process.env.FFMPEG_HTTP_RECONNECT, true);
@@ -39,6 +42,7 @@ const FRAME_CONDITION_LOG_INTERVAL_MS = 5000;
const MAX_WS_BUFFER_BYTES = clampInteger(process.env.MAX_WS_BUFFER_BYTES, 2 * 1024 * 1024, 128 * 1024, 64 * 1024 * 1024);
const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 4 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024);
const MAX_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 8 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024);
const MAX_YT_DLP_OUTPUT_BYTES = 8 * 1024 * 1024;
const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
@@ -78,6 +82,33 @@ app.get('/api/recent-urls', (_request, response) => {
});
});
app.post('/api/recent-urls', async (request, response) => {
let url;
try {
url = parseStreamUrl(request.body?.url);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
try {
await addRecentUrl(url);
} catch (error) {
console.warn(`Failed to store recent URL: ${error.message}`);
response.status(500).json({ error: 'Failed to store recent URL.' });
return;
}
response.status(201).json({
urls: recentUrls.map((item) => ({
url: item.url,
displayUrl: redactSecrets(item.url),
lastPlayedAt: item.lastPlayedAt,
})),
});
});
app.get('/api/favorites', (_request, response) => {
response.json({ favorites: formatFavoritesPayload() });
});
@@ -108,6 +139,7 @@ app.put('/api/favorites', async (request, response) => {
app.post('/api/session', async (request, response) => {
let url;
let source;
try {
url = parseStreamUrl(request.body?.url);
@@ -116,6 +148,13 @@ app.post('/api/session', async (request, response) => {
return;
}
try {
source = await resolvePlaybackSource(url);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
const options = parsePlaybackOptions(request.body);
const id = randomUUID();
@@ -123,7 +162,10 @@ app.post('/api/session', async (request, response) => {
sessions.set(id, {
id,
url,
url: source.url,
originalUrl: url,
sourceKind: source.kind,
sourceHeaders: source.headers,
options,
duration: null,
metadataStatus,
@@ -133,7 +175,7 @@ app.post('/api/session', async (request, response) => {
lastUsedAt: Date.now(),
});
logInfo(`session created id=${shortId(id)} mode=${getSessionPlaybackConnectionMode(sessions.get(id))} fps=${options.fps} width=${options.width} quality=${options.quality}`);
logInfo(`session created id=${shortId(id)} source=${source.kind} mode=${getSessionPlaybackConnectionMode(sessions.get(id))} fps=${options.fps} width=${options.width} quality=${options.quality}`);
if (METADATA_PROBE_ENABLED) {
startSessionMetadataProbe(sessions.get(id));
@@ -222,7 +264,7 @@ app.all('/_source/:token', async (request, response) => {
response.on('close', cleanup);
try {
const headers = new Headers();
const headers = createSourceRequestHeaders(session);
const range = request.get('range');
if (range) {
@@ -374,6 +416,205 @@ function parseStreamUrl(value) {
return parsed.toString();
}
function parseResolvedMediaUrl(value) {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error('Resolved media URL is empty.');
}
if (value.length > 32768) {
throw new Error('Resolved media URL is too long.');
}
const parsed = new URL(value.trim());
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Resolved media URL is not an HTTP(S) stream.');
}
return parsed.toString();
}
async function resolvePlaybackSource(url) {
if (!isYouTubeUrl(url)) {
return {
kind: 'direct',
url,
headers: {},
};
}
const resolved = await resolveYouTubeSource(url);
return {
kind: 'youtube',
url: resolved.url,
headers: resolved.headers,
};
}
function isYouTubeUrl(value) {
let parsed;
try {
parsed = new URL(value);
} catch {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return hostname === 'youtu.be'
|| hostname.endsWith('.youtu.be')
|| hostname === 'youtube.com'
|| hostname.endsWith('.youtube.com')
|| hostname === 'youtube-nocookie.com'
|| hostname.endsWith('.youtube-nocookie.com');
}
async function resolveYouTubeSource(url) {
const startedAt = Date.now();
logInfo(`yt-dlp resolving source host=${new URL(url).hostname}`);
let payload;
try {
payload = await runYtDlpJson([
'--no-playlist',
'--no-warnings',
'--no-progress',
'--dump-single-json',
'--format',
YT_DLP_FORMAT,
url,
]);
} catch (error) {
throw new Error(`yt-dlp failed to resolve YouTube URL: ${error.message}`);
}
const mediaUrl = typeof payload?.url === 'string' ? payload.url : '';
if (!mediaUrl) {
throw new Error('yt-dlp did not return a playable media URL.');
}
const parsed = parseResolvedMediaUrl(mediaUrl);
logInfo(`yt-dlp resolved source host=${new URL(parsed).hostname} durationMs=${Date.now() - startedAt}`);
return {
url: parsed,
headers: normalizeSourceHeaders(payload?.http_headers),
};
}
function runYtDlpJson(args) {
return new Promise((resolve, reject) => {
const ytDlp = spawn(YT_DLP_PATH, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stdoutBytes = 0;
let stderr = '';
let timedOut = false;
let settled = false;
const finish = (callback) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
callback();
};
const timer = setTimeout(() => {
timedOut = true;
stopProcess(ytDlp);
}, YT_DLP_TIMEOUT_MS);
timer.unref();
ytDlp.stdout.on('data', (chunk) => {
stdoutBytes += chunk.length;
if (stdoutBytes > MAX_YT_DLP_OUTPUT_BYTES) {
finish(() => {
stopProcess(ytDlp);
reject(new Error('yt-dlp output was too large.'));
});
return;
}
stdout += chunk.toString('utf8');
});
ytDlp.stderr.on('data', (chunk) => {
stderr = appendTail(stderr, chunk);
});
ytDlp.on('error', (error) => {
finish(() => {
if (error.code === 'ENOENT') {
reject(new Error(`yt-dlp was not found at "${YT_DLP_PATH}".`));
return;
}
reject(error);
});
});
ytDlp.on('close', (code, signal) => {
finish(() => {
if (timedOut) {
reject(new Error('yt-dlp timed out.'));
return;
}
if (code !== 0) {
const detail = redactSecrets(stderr).trim();
reject(new Error(detail ? `yt-dlp exited with code ${code}: ${detail}` : `yt-dlp exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`));
return;
}
try {
resolve(JSON.parse(stdout));
} catch (error) {
reject(new Error(`yt-dlp returned invalid JSON: ${error.message}`));
}
});
});
});
}
function normalizeSourceHeaders(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const headers = {};
for (const [name, rawValue] of Object.entries(value)) {
const normalizedName = name.toLowerCase();
if (!isAllowedSourceHeader(normalizedName) || typeof rawValue !== 'string') {
continue;
}
headers[normalizedName] = rawValue;
}
return headers;
}
function isAllowedSourceHeader(name) {
return ![
'accept-encoding',
'connection',
'content-length',
'host',
'range',
'transfer-encoding',
].includes(name);
}
function parsePlaybackOptions(body) {
return {
fps: clampInteger(body?.fps, defaults.fps, 1, 30),
@@ -1153,7 +1394,7 @@ function createRelayPlayback(session) {
sourceController = new AbortController();
try {
const headers = new Headers();
const headers = createSourceRequestHeaders(session);
headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, {
@@ -2470,6 +2711,16 @@ function createLineLogger(callback) {
};
}
function createSourceRequestHeaders(session) {
const headers = new Headers();
for (const [name, value] of Object.entries(session.sourceHeaders ?? {})) {
headers.set(name, value);
}
return headers;
}
function copyUpstreamHeaders(upstreamHeaders, response) {
for (const header of [
'accept-ranges',
@@ -2541,7 +2792,7 @@ function summarizeFfmpegExit(code, signal, stderr) {
}
function redactSecrets(text) {
return text.replace(/([?&](?:api_key|apikey|access_token|token|key)=)[^&\s]+/gi, '$1[redacted]');
return text.replace(/([?&](?:api_key|apikey|access_token|token|key|sig|signature|lsig|n|expire|ip)=)[^&\s]+/gi, '$1[redacted]');
}
function isChildRunning(child) {