adds favorites

This commit is contained in:
2026-05-04 20:33:14 -07:00
parent 47dae48673
commit 5e2a2e1de7
7 changed files with 599 additions and 23 deletions

View File

@@ -6,18 +6,18 @@ 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, and globally stored recently played URLs.
- URL entry screen with a stream URL input, a `Next` 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.
The backend stores recently played URLs globally, not per-browser. The default path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. Docker Compose persists this through the `frame-stream-data` volume.
The backend stores recently played URLs and favorites globally, not per-browser. The default recent URL path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. The default favorites path is `data/favorites.json`, configurable with `FAVORITES_PATH`. Docker Compose persists both through the `frame-stream-data` volume.
## Core Architecture
The app is plain Node/Express plus browser JavaScript:
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL persistence.
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL and favorites persistence.
- `public/index.html`: frontend markup.
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
- `public/styles.css`: two-screen player UI.
@@ -28,6 +28,8 @@ 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`.
- `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.
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
- `GET /api/health`: exposes basic health and active playback connection mode.
@@ -170,6 +172,8 @@ Runtime:
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
- `RECENT_URLS_PATH`: recent URL JSON path.
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
- `FAVORITES_PATH`: favorites JSON path.
- `FAVORITES_LIMIT`: favorites count, default `50`.
- `MAX_WS_BUFFER_BYTES`: server-side WebSocket JPEG frame backlog cap, default `2097152`.
- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `16777216`.
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
@@ -218,6 +222,7 @@ Start a mode locally:
```sh
PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
FAVORITES_PATH=/tmp/carplay-relay-favorites.json \
FFMPEG_LOG_LEVEL=warning FFMPEG_INPUT_SEEKABLE=0 \
PLAYBACK_CONNECTION_MODE=relay npm start
```
@@ -225,7 +230,7 @@ PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
After smoke testing, remove generated assets:
```sh
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json /tmp/carplay-*-favorites.json
```
## Security Notes

View File

@@ -39,7 +39,7 @@ docker compose -f docker-compose-example.yml up --build
The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups, but hardware acceleration is usually only useful when server CPU is saturated.
Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
Recently played URLs and favorites are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
`ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run:

View File

@@ -19,6 +19,7 @@ services:
MAX_AUDIO_QUEUE_BYTES: "16777216"
MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216"
RECENT_URLS_PATH: /app/data/recent-urls.json
FAVORITES_PATH: /app/data/favorites.json
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:

View File

@@ -5,8 +5,20 @@ const elements = {
url: document.querySelector('#stream-url'),
next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
favoritesPanel: document.querySelector('#favorites-panel'),
favoritesList: document.querySelector('#favorites-list'),
editFavorites: document.querySelector('#edit-favorites'),
favoritesModal: document.querySelector('#favorites-modal'),
favoritesForm: document.querySelector('#favorites-form'),
closeFavorites: document.querySelector('#close-favorites'),
cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'),
favoritesEditorList: document.querySelector('#favorites-editor-list'),
favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
@@ -49,6 +61,8 @@ const state = {
controlsVisible: true,
hideControlsTimer: 0,
recentUrls: [],
favorites: [],
favoriteModalTrigger: null,
playbackOffset: 0,
duration: null,
seekable: false,
@@ -60,7 +74,7 @@ const state = {
lastPlaybackRestartAt: 0,
};
void loadRecentUrls();
void loadEntryLists();
elements.form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -110,6 +124,68 @@ elements.recentList.addEventListener('click', (event) => {
elements.form.requestSubmit();
});
elements.favoritesList.addEventListener('click', (event) => {
const button = event.target.closest('[data-favorite-index]');
if (!button) {
return;
}
const item = state.favorites[Number(button.dataset.favoriteIndex)];
if (!item) {
return;
}
elements.url.value = item.url;
elements.form.requestSubmit();
});
elements.editFavorites.addEventListener('click', () => {
openFavoritesModal();
});
elements.closeFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.cancelFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.addFavorite.addEventListener('click', () => {
elements.favoritesEditorList.append(createFavoriteEditorRow());
focusLastFavoriteTitle();
});
elements.favoritesEditorList.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-favorite]');
if (!button) {
return;
}
button.closest('.favorite-editor-row')?.remove();
ensureFavoriteEditorRows();
});
elements.favoritesForm.addEventListener('submit', (event) => {
event.preventDefault();
void saveFavoritesFromModal();
});
elements.favoritesModal.addEventListener('click', (event) => {
if (event.target === elements.favoritesModal) {
closeFavoritesModal();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !elements.favoritesModal.hidden) {
closeFavoritesModal();
}
});
elements.stage.addEventListener('pointerup', (event) => {
if (!state.session || event.target.closest('.controls')) {
return;
@@ -939,7 +1015,7 @@ function syncControlLabels() {
function showEntry() {
elements.playerScreen.hidden = true;
elements.entryScreen.hidden = false;
void loadRecentUrls();
void loadEntryLists();
elements.url.focus();
}
@@ -964,11 +1040,18 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.next.disabled = isBusy;
for (const button of elements.recentList.querySelectorAll('button')) {
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
}
}
async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
}
async function loadRecentUrls() {
try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
@@ -991,12 +1074,170 @@ function renderRecentUrls() {
...state.recentUrls.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-url';
button.className = 'url-item';
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
elements.recentPanel.hidden = state.recentUrls.length === 0;
if (state.recentUrls.length === 0) {
elements.recentList.replaceChildren(createEmptyListMessage('No recents'));
}
}
async function loadFavorites() {
try {
const response = await fetch('/api/favorites', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load favorites.');
}
const payload = await response.json();
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
} catch {
state.favorites = [];
renderFavorites();
}
}
function renderFavorites() {
elements.favoritesList.replaceChildren(
...state.favorites.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'url-item';
button.dataset.favoriteIndex = String(index);
button.textContent = item.title;
return button;
}),
);
if (state.favorites.length === 0) {
elements.favoritesList.replaceChildren(createEmptyListMessage('No favorites'));
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';
message.textContent = text;
return message;
}
function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites);
elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField();
}
function closeFavoritesModal() {
elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = '';
state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null;
}
function renderFavoriteEditorRows(items) {
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)),
);
}
function 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());
}
}
function focusFirstFavoriteField() {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
field?.focus();
}
function focusLastFavoriteTitle() {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus();
}
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = '';
setFavoritesModalBusy(true);
try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favorites }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to save favorites.');
}
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
closeFavoritesModal();
} catch (error) {
elements.favoritesMessage.textContent = error.message;
} finally {
setFavoritesModalBusy(false);
}
}
function readFavoriteEditorRows() {
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
.map((row) => ({
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
}))
.filter((item) => item.title || item.url);
}
function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
}

View File

@@ -22,8 +22,20 @@
>
<button id="next" type="submit">Next</button>
</form>
<div id="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden>
<div id="recent-list" class="recent-list"></div>
<div id="library-panel" class="library-panel" aria-label="Saved streams">
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
<div class="column-heading">
<h2 id="recent-heading">Recents</h2>
</div>
<div id="recent-list" class="url-list"></div>
</section>
<section id="favorites-panel" class="url-column" aria-labelledby="favorites-heading">
<div class="column-heading">
<h2 id="favorites-heading">Favorites</h2>
<button id="edit-favorites" type="button" class="small-button">Edit</button>
</div>
<div id="favorites-list" class="url-list"></div>
</section>
</div>
</div>
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
@@ -48,6 +60,22 @@
</div>
<audio id="audio" preload="none"></audio>
</section>
<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>
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">&times;</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-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>
</div>
</form>
</div>
</main>
<script src="/app.js" type="module"></script>

View File

@@ -64,7 +64,7 @@ button:disabled {
.entry-stack {
display: grid;
width: min(760px, 100%);
width: min(980px, 100%);
gap: 0.75rem;
}
@@ -96,7 +96,12 @@ button:disabled {
}
.url-form button,
.control-button {
.control-button,
.small-button,
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
@@ -111,28 +116,61 @@ button:disabled {
color: #070707;
}
.recent-panel {
.library-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.url-column {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.recent-list {
.column-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 3.2rem;
padding: 0.55rem 0.75rem 0.2rem 1rem;
}
.column-heading h2,
.modal-heading h2 {
margin: 0;
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
color: var(--soft);
}
.small-button {
flex: 0 0 auto;
min-width: 4.2rem;
padding: 0.45rem 0.8rem;
font-size: 0.84rem;
font-weight: 800;
}
.url-list {
display: grid;
max-height: min(42vh, 24rem);
overflow: auto;
padding: 0.35rem;
}
.recent-url {
.url-item {
display: block;
width: 100%;
overflow: hidden;
border: 0;
border-radius: 20px;
border-radius: 16px;
padding: 0.85rem 1rem;
color: var(--soft);
text-align: left;
@@ -141,13 +179,19 @@ button:disabled {
background: transparent;
}
.recent-url:hover,
.recent-url:focus {
.url-item:hover,
.url-item:focus {
color: var(--fg);
background: rgba(255, 255, 255, 0.09);
outline: none;
}
.empty-list {
margin: 0;
padding: 0.85rem 1rem;
color: rgba(246, 241, 232, 0.42);
}
.message {
position: fixed;
bottom: 2rem;
@@ -365,6 +409,107 @@ audio {
display: none;
}
.modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
}
.modal-panel {
display: grid;
width: min(860px, 100%);
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
gap: 0.85rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--glass-strong);
box-shadow: 0 28px 120px rgba(0, 0, 0, 0.55);
}
.modal-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.icon-button {
display: grid;
width: 2.3rem;
height: 2.3rem;
place-items: center;
padding: 0;
font-size: 1.2rem;
font-weight: 800;
line-height: 1;
}
.favorites-editor-list {
display: grid;
gap: 0.55rem;
}
.favorite-editor-row {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto;
gap: 0.55rem;
}
.favorite-editor-row input {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.72rem 0.8rem;
color: var(--fg);
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 {
border-color: rgba(232, 168, 79, 0.7);
}
.secondary-button,
.primary-button,
.remove-favorite {
min-height: 2.7rem;
padding: 0.65rem 0.95rem;
font-weight: 800;
}
.primary-button {
border-color: transparent;
color: #070707;
background: var(--fg);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
}
.modal-message {
min-height: 1.2rem;
color: var(--soft);
}
.modal-message:empty {
display: none;
}
[hidden] {
display: none !important;
}
@@ -386,6 +531,29 @@ audio {
width: 100%;
}
.library-panel {
grid-template-columns: 1fr;
}
.url-list {
max-height: 28vh;
}
.favorite-editor-row {
grid-template-columns: 1fr;
}
.remove-favorite,
.secondary-button,
.primary-button {
width: 100%;
}
.modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.controls {
right: 0.75rem;
bottom: 0.75rem;

View File

@@ -24,6 +24,8 @@ const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE);
const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json');
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
const SESSION_TTL_MS = 60 * 60 * 1000;
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
@@ -46,9 +48,11 @@ const sourceTokens = new Map();
const playbacks = new Map();
let recentUrls = [];
let recentWrite = Promise.resolve();
let favorites = [];
let favoritesWrite = Promise.resolve();
app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' }));
app.use(express.json({ limit: '256kb' }));
app.use(express.static(publicDir));
app.get('/api/health', (_request, response) => {
@@ -65,6 +69,34 @@ app.get('/api/recent-urls', (_request, response) => {
});
});
app.get('/api/favorites', (_request, response) => {
response.json({ favorites: formatFavoritesPayload() });
});
app.put('/api/favorites', async (request, response) => {
let nextFavorites;
try {
nextFavorites = parseFavoritesPayload(request.body?.favorites);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
favorites = nextFavorites;
favoritesWrite = favoritesWrite.catch(() => {}).then(() => saveFavorites());
try {
await favoritesWrite;
} catch (error) {
console.warn(`Failed to store favorites: ${error.message}`);
response.status(500).json({ error: 'Failed to store favorites.' });
return;
}
response.json({ favorites: formatFavoritesPayload() });
});
app.post('/api/session', async (request, response) => {
let url;
@@ -293,7 +325,10 @@ setInterval(() => {
}
}, 60 * 1000).unref();
await loadRecentUrls();
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
server.listen(PORT, () => {
console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`);
@@ -501,6 +536,25 @@ async function loadRecentUrls() {
}
}
async function loadFavorites() {
try {
const raw = await fs.readFile(FAVORITES_PATH, 'utf8');
const parsed = JSON.parse(raw);
const items = Array.isArray(parsed?.favorites) ? parsed.favorites : [];
favorites = items
.map(normalizeFavorite)
.filter(Boolean)
.slice(0, FAVORITES_LIMIT);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn(`Failed to read favorites: ${error.message}`);
}
favorites = [];
}
}
async function addRecentUrl(url) {
const lastPlayedAt = new Date().toISOString();
recentUrls = [
@@ -522,6 +576,85 @@ async function saveRecentUrls() {
await fs.rename(temporaryPath, RECENT_URLS_PATH);
}
async function saveFavorites() {
const directory = path.dirname(FAVORITES_PATH);
const temporaryPath = `${FAVORITES_PATH}.${process.pid}.tmp`;
const payload = `${JSON.stringify({ favorites }, null, 2)}\n`;
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(temporaryPath, payload, 'utf8');
await fs.rename(temporaryPath, FAVORITES_PATH);
}
function formatFavoritesPayload() {
return favorites.map((item) => ({
title: item.title,
url: item.url,
}));
}
function parseFavoritesPayload(value) {
if (!Array.isArray(value)) {
throw new Error('Favorites must be a list.');
}
if (value.length > FAVORITES_LIMIT) {
throw new Error(`Favorites are limited to ${FAVORITES_LIMIT} items.`);
}
return value.map((item, index) => parseFavorite(item, index));
}
function parseFavorite(item, index) {
if (!item || typeof item !== 'object') {
throw new Error(`Favorite ${index + 1} is invalid.`);
}
const title = parseFavoriteTitle(item.title, index);
let url;
try {
url = parseStreamUrl(item.url);
} catch (error) {
throw new Error(`Favorite ${index + 1}: ${error.message}`);
}
return { title, url };
}
function normalizeFavorite(item) {
if (!item || typeof item !== 'object') {
return null;
}
try {
return {
title: parseFavoriteTitle(item.title, 0),
url: parseStreamUrl(item.url),
};
} catch {
return null;
}
}
function parseFavoriteTitle(value, index) {
if (typeof value !== 'string') {
throw new Error(`Favorite ${index + 1} needs a title.`);
}
const title = value.trim();
if (!title) {
throw new Error(`Favorite ${index + 1} needs a title.`);
}
if (title.length > 120) {
throw new Error(`Favorite ${index + 1} title is too long.`);
}
return title;
}
function getSession(sessionId) {
const session = sessions.get(sessionId);