adds favorites
This commit is contained in:
137
server/index.js
137
server/index.js
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user