Add YouTube playback and queue flow
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
307
public/app.js
307
public/app.js
@@ -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: '' }];
|
||||
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 renderFavoriteManager() {
|
||||
elements.favoritesEditorList.replaceChildren(
|
||||
...rows.map((item) => createFavoriteEditorRow(item)),
|
||||
...state.favorites.map((item, index) => createFavoriteManagerRow(item, index)),
|
||||
);
|
||||
}
|
||||
|
||||
function createFavoriteEditorRow(item = { title: '', url: '' }) {
|
||||
const row = document.createElement('div');
|
||||
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());
|
||||
if (state.favorites.length === 0) {
|
||||
elements.favoritesEditorList.replaceChildren(createEmptyListMessage('No favorites'));
|
||||
}
|
||||
}
|
||||
|
||||
function focusFirstFavoriteField() {
|
||||
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
|
||||
field?.focus();
|
||||
function createFavoriteManagerRow(item, index) {
|
||||
const row = document.createElement('div');
|
||||
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() {
|
||||
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
|
||||
const row = rows[rows.length - 1];
|
||||
row?.querySelector('[data-favorite-title]')?.focus();
|
||||
}
|
||||
function startFavoriteWizard(mode, index = -1) {
|
||||
const item = mode === 'edit' ? state.favorites[index] : null;
|
||||
|
||||
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.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;
|
||||
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
<div id="favorites-editor-list" class="favorites-editor-list"></div>
|
||||
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button>
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
261
server/index.js
261
server/index.js
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user