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: 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. - 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. - 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. - 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. - `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`. - `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`. - `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`. - `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. - `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
@@ -172,6 +173,9 @@ Runtime:
- `PORT`: HTTP port, default `3000`. - `PORT`: HTTP port, default `3000`.
- `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`. - `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_LOG_LEVEL`: ffmpeg log level, default `warning`.
- `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`. - `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`.
- `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`. - `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 ## 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. 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 NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ARG DEBIAN_FRONTEND=noninteractive 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 WORKDIR /app
ADD https://api.github.com/repos/yt-dlp/yt-dlp/commits/master /tmp/yt-dlp-master.json
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \ && apt-get install -y --no-install-recommends ca-certificates ffmpeg python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* && 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 ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev

View File

@@ -14,12 +14,13 @@ npm install
npm start 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 ```sh
FFMPEG_PATH=/path/to/ffmpeg npm start FFMPEG_PATH=/path/to/ffmpeg npm start
YT_DLP_PATH=/path/to/yt-dlp npm start
``` ```
## Docker ## 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`. 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`. 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`. 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: environment:
PORT: "3000" PORT: "3000"
NODE_ENV: production NODE_ENV: production
YT_DLP_TIMEOUT_MS: "45000"
# split: smoothest, two upstream connections. # split: smoothest, two upstream connections.
# relay: one upstream connection, separate audio/frame ffmpeg workers. # relay: one upstream connection, separate audio/frame ffmpeg workers.
# single: one upstream connection and one ffmpeg worker; fallback only. # single: one upstream connection and one ffmpeg worker; fallback only.

View File

@@ -3,7 +3,8 @@ const elements = {
playerScreen: document.querySelector('#player-screen'), playerScreen: document.querySelector('#player-screen'),
form: document.querySelector('#stream-form'), form: document.querySelector('#stream-form'),
url: document.querySelector('#stream-url'), url: document.querySelector('#stream-url'),
next: document.querySelector('#next'), play: document.querySelector('#play'),
queue: document.querySelector('#queue'),
entryMessage: document.querySelector('#entry-message'), entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'), libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'), recentPanel: document.querySelector('#recent-panel'),
@@ -17,7 +18,15 @@ const elements = {
cancelFavorites: document.querySelector('#cancel-favorites'), cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'), addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'), saveFavorites: document.querySelector('#save-favorites'),
favoritesManager: document.querySelector('#favorites-manager'),
favoritesEditorList: document.querySelector('#favorites-editor-list'), 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'), favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'), audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'), canvas: document.querySelector('#screen'),
@@ -67,6 +76,7 @@ const state = {
recentUrls: [], recentUrls: [],
favorites: [], favorites: [],
favoriteModalTrigger: null, favoriteModalTrigger: null,
favoriteWizard: createFavoriteWizardState(),
playbackOffset: 0, playbackOffset: 0,
duration: null, duration: null,
seekable: false, seekable: false,
@@ -86,6 +96,14 @@ void loadEntryLists();
elements.form.addEventListener('submit', async (event) => { elements.form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
await playCurrentUrl();
});
elements.queue.addEventListener('click', () => {
void queueCurrentUrl();
});
async function playCurrentUrl() {
setEntryMessage(''); setEntryMessage('');
setFormBusy(true); setFormBusy(true);
@@ -113,7 +131,33 @@ elements.form.addEventListener('submit', async (event) => {
} finally { } finally {
setFormBusy(false); 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) => { elements.recentList.addEventListener('click', (event) => {
const button = event.target.closest('[data-recent-index]'); const button = event.target.closest('[data-recent-index]');
@@ -158,28 +202,49 @@ elements.closeFavorites.addEventListener('click', () => {
}); });
elements.cancelFavorites.addEventListener('click', () => { elements.cancelFavorites.addEventListener('click', () => {
if (state.favoriteWizard.active) {
showFavoritesManager();
return;
}
closeFavoritesModal(); closeFavoritesModal();
}); });
elements.addFavorite.addEventListener('click', () => { elements.addFavorite.addEventListener('click', () => {
elements.favoritesEditorList.append(createFavoriteEditorRow()); startFavoriteWizard('add');
focusLastFavoriteTitle();
}); });
elements.favoritesEditorList.addEventListener('click', (event) => { 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; return;
} }
button.closest('.favorite-editor-row')?.remove(); if (editButton) {
ensureFavoriteEditorRows(); startFavoriteWizard('edit', Number(editButton.dataset.editFavorite));
}
}); });
elements.favoritesForm.addEventListener('submit', (event) => { elements.favoritesForm.addEventListener('submit', (event) => {
event.preventDefault(); 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) => { 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) { function noteClientTelemetry(name, count = 1) {
if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) { if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) {
return; return;
@@ -1259,7 +1335,8 @@ function setEntryMessage(message) {
function setFormBusy(isBusy) { function setFormBusy(isBusy) {
elements.url.disabled = 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')) { for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy; button.disabled = isBusy;
@@ -1351,82 +1428,178 @@ function createEmptyListMessage(text) {
function openFavoritesModal() { function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = ''; elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites); showFavoritesManager();
elements.favoritesModal.hidden = false; elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open'); elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField(); elements.addFavorite.focus();
} }
function closeFavoritesModal() { function closeFavoritesModal() {
elements.favoritesModal.hidden = true; elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open'); elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = ''; elements.favoritesMessage.textContent = '';
state.favoriteWizard = createFavoriteWizardState();
state.favoriteModalTrigger?.focus(); state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null; state.favoriteModalTrigger = null;
} }
function renderFavoriteEditorRows(items) { function showFavoritesManager() {
const rows = items.length > 0 ? items : [{ title: '', url: '' }]; 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 renderFavoriteManager() {
elements.favoritesEditorList.replaceChildren( elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)), ...state.favorites.map((item, index) => createFavoriteManagerRow(item, index)),
); );
}
function createFavoriteEditorRow(item = { title: '', url: '' }) { if (state.favorites.length === 0) {
const row = document.createElement('div'); elements.favoritesEditorList.replaceChildren(createEmptyListMessage('No favorites'));
row.className = 'favorite-editor-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 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 remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-favorite';
remove.dataset.removeFavorite = '';
remove.setAttribute('aria-label', 'Remove favorite');
remove.textContent = 'Remove';
row.append(title, url, remove);
return row;
}
function ensureFavoriteEditorRows() {
if (elements.favoritesEditorList.children.length === 0) {
elements.favoritesEditorList.append(createFavoriteEditorRow());
} }
} }
function focusFirstFavoriteField() { function createFavoriteManagerRow(item, index) {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]'); const row = document.createElement('div');
field?.focus(); row.className = 'favorite-manager-row';
const summary = document.createElement('div');
summary.className = 'favorite-summary';
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 favorite-action';
remove.dataset.removeFavorite = String(index);
remove.textContent = 'Remove';
summary.append(title, url);
row.append(summary, edit, remove);
return row;
} }
function focusLastFavoriteTitle() { function startFavoriteWizard(mode, index = -1) {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row'); const item = mode === 'edit' ? state.favorites[index] : null;
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus(); if (mode === 'edit' && !item) {
} return;
}
state.favoriteWizard = {
active: true,
mode,
index,
step: 'title',
title: item?.title ?? '',
url: item?.url ?? elements.url.value.trim(),
};
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = ''; 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); setFavoritesModalBusy(true);
try { try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', { const response = await fetch('/api/favorites', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1440,7 +1613,8 @@ async function saveFavoritesFromModal() {
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : []; state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites(); renderFavorites();
closeFavoritesModal(); renderFavoriteManager();
onSuccess();
} catch (error) { } catch (error) {
elements.favoritesMessage.textContent = error.message; elements.favoritesMessage.textContent = error.message;
} finally { } 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) { function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) { for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy; field.disabled = isBusy;

View File

@@ -20,7 +20,8 @@
autocomplete="off" autocomplete="off"
required 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> </form>
<div id="library-panel" class="library-panel" aria-label="Saved streams"> <div id="library-panel" class="library-panel" aria-label="Saved streams">
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading"> <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> <div id="favorites-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="favorites-modal-title" hidden>
<form id="favorites-form" class="modal-panel"> <form id="favorites-form" class="modal-panel">
<div class="modal-heading"> <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> <button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">&times;</button>
</div> </div>
<div id="favorites-manager" class="favorites-manager">
<div id="favorites-editor-list" class="favorites-editor-list"></div> <div id="favorites-editor-list" class="favorites-editor-list"></div>
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button> <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 id="favorites-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="modal-actions"> <div class="modal-actions">
<button id="cancel-favorites" type="button" class="secondary-button">Cancel</button> <button id="cancel-favorites" type="button" class="secondary-button">Done</button>
<button id="save-favorites" type="submit" class="primary-button">Save</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> </div>
</form> </form>
</div> </div>

View File

@@ -116,6 +116,11 @@ button:disabled {
color: #070707; color: #070707;
} }
.url-form .secondary-submit {
color: var(--fg);
background: rgba(255, 255, 255, 0.10);
}
.library-panel { .library-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -423,7 +428,7 @@ audio {
.modal-panel { .modal-panel {
display: grid; display: grid;
width: min(860px, 100%); width: min(620px, 100%);
max-height: min(760px, calc(100vh - 2rem)); max-height: min(760px, calc(100vh - 2rem));
overflow: auto; overflow: auto;
gap: 0.85rem; gap: 0.85rem;
@@ -456,29 +461,85 @@ audio {
.favorites-editor-list { .favorites-editor-list {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
max-height: min(52vh, 28rem);
overflow: auto;
} }
.favorite-editor-row { .favorites-manager,
.favorite-wizard {
display: grid; display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto; gap: 0.75rem;
gap: 0.55rem;
} }
.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; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 0.72rem 0.8rem; padding: 0.9rem 0.95rem;
color: var(--fg); color: var(--fg);
font-size: 1rem;
font-weight: 500;
text-transform: none;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
outline: none; outline: none;
} }
.favorite-editor-row input::placeholder { .wizard-field input:focus {
color: rgba(246, 241, 232, 0.42);
}
.favorite-editor-row input:focus {
border-color: rgba(232, 168, 79, 0.7); border-color: rgba(232, 168, 79, 0.7);
} }
@@ -540,10 +601,14 @@ audio {
max-height: 28vh; max-height: 28vh;
} }
.favorite-editor-row { .favorite-manager-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.favorite-action {
width: 100%;
}
.remove-favorite, .remove-favorite,
.secondary-button, .secondary-button,
.primary-button { .primary-button {
@@ -552,7 +617,7 @@ audio {
.modal-actions { .modal-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
} }
.controls { .controls {

View File

@@ -19,6 +19,9 @@ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
const PORT = Number(process.env.PORT ?? 3000); const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg'; const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe'; 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_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning';
const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0'; const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const FFMPEG_HTTP_RECONNECT = parseBoolean(process.env.FFMPEG_HTTP_RECONNECT, true); 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_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_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_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 RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
const JPEG_SOI = Buffer.from([0xff, 0xd8]); const JPEG_SOI = Buffer.from([0xff, 0xd8]);
const JPEG_EOI = Buffer.from([0xff, 0xd9]); 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) => { app.get('/api/favorites', (_request, response) => {
response.json({ favorites: formatFavoritesPayload() }); response.json({ favorites: formatFavoritesPayload() });
}); });
@@ -108,6 +139,7 @@ app.put('/api/favorites', async (request, response) => {
app.post('/api/session', async (request, response) => { app.post('/api/session', async (request, response) => {
let url; let url;
let source;
try { try {
url = parseStreamUrl(request.body?.url); url = parseStreamUrl(request.body?.url);
@@ -116,6 +148,13 @@ app.post('/api/session', async (request, response) => {
return; return;
} }
try {
source = await resolvePlaybackSource(url);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
const options = parsePlaybackOptions(request.body); const options = parsePlaybackOptions(request.body);
const id = randomUUID(); const id = randomUUID();
@@ -123,7 +162,10 @@ app.post('/api/session', async (request, response) => {
sessions.set(id, { sessions.set(id, {
id, id,
url, url: source.url,
originalUrl: url,
sourceKind: source.kind,
sourceHeaders: source.headers,
options, options,
duration: null, duration: null,
metadataStatus, metadataStatus,
@@ -133,7 +175,7 @@ app.post('/api/session', async (request, response) => {
lastUsedAt: Date.now(), 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) { if (METADATA_PROBE_ENABLED) {
startSessionMetadataProbe(sessions.get(id)); startSessionMetadataProbe(sessions.get(id));
@@ -222,7 +264,7 @@ app.all('/_source/:token', async (request, response) => {
response.on('close', cleanup); response.on('close', cleanup);
try { try {
const headers = new Headers(); const headers = createSourceRequestHeaders(session);
const range = request.get('range'); const range = request.get('range');
if (range) { if (range) {
@@ -374,6 +416,205 @@ function parseStreamUrl(value) {
return parsed.toString(); 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) { function parsePlaybackOptions(body) {
return { return {
fps: clampInteger(body?.fps, defaults.fps, 1, 30), fps: clampInteger(body?.fps, defaults.fps, 1, 30),
@@ -1153,7 +1394,7 @@ function createRelayPlayback(session) {
sourceController = new AbortController(); sourceController = new AbortController();
try { try {
const headers = new Headers(); const headers = createSourceRequestHeaders(session);
headers.set('accept-encoding', 'identity'); headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, { 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) { function copyUpstreamHeaders(upstreamHeaders, response) {
for (const header of [ for (const header of [
'accept-ranges', 'accept-ranges',
@@ -2541,7 +2792,7 @@ function summarizeFfmpegExit(code, signal, stderr) {
} }
function redactSecrets(text) { 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) { function isChildRunning(child) {