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:
|
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.
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
307
public/app.js
307
public/app.js
@@ -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;
|
||||||
|
|||||||
@@ -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">×</button>
|
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">×</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user