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

@@ -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);