feat: add video like status endpoint and improve Odysee image proxy with fallback URLs

This commit is contained in:
Bruno Charest 2025-09-17 14:02:00 -04:00
parent 68f6c67240
commit 3dbfb04b15
2 changed files with 102 additions and 31 deletions

Binary file not shown.

View File

@ -1073,52 +1073,122 @@ r.delete('/user/likes', authMiddleware, (req, res) => {
return res.json(result);
});
// Like status for a specific video
r.get('/user/likes/status', authMiddleware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const liked = isVideoLiked({ userId: req.user.id, provider, videoId });
return res.json({ liked });
});
// Odysee image proxy to avoid CORS/ORB issues
r.get('/img/odysee', async (req, res) => {
try {
const imageUrl = req.query.u ? String(req.query.u) : '';
if (!imageUrl) return res.status(400).json({ error: 'Missing image URL' });
// Only allow specific Odysee domains for security
const allowedHosts = [
const u = String(req.query.u || '').trim();
if (!u) return res.status(400).json({ error: 'missing_url' });
let target = u;
// Ensure absolute https URL
if (target.startsWith('//')) target = 'https:' + target;
if (!/^https?:\/\//i.test(target)) target = 'https://' + target.replace(/^\/*/, '');
// Parse and validate host
let parsed;
try { parsed = new URL(target); } catch { return res.status(400).json({ error: 'invalid_url' }); }
const host = parsed.hostname.toLowerCase();
const allowed = new Set([
'thumbnails.odycdn.com',
'thumbs.odycdn.com',
'od.lk',
'cdn.lbryplayer.xyz',
'spee.ch',
'thumb.odycdn.com',
'static.odycdn.com',
'images.odycdn.com',
'cdn.lbryplayer.xyz',
'thumbnails.lbry.com',
'static.odycdn.com',
'thumbnails.odycdn.com'
];
'thumbnails.lbry.tech'
]);
const headers = {
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer': 'https://odysee.com/',
'User-Agent': 'Mozilla/5.0'
};
const url = new URL(imageUrl);
if (!allowedHosts.includes(url.hostname)) {
return res.status(403).json({ error: 'Forbidden domain' });
// Build candidates: extract inner URL after '/plain/' when present, try host alternates, toggle extension, strip query
function extractPlainUrl(href) {
try {
const idx = href.indexOf('/plain/');
if (idx !== -1) {
const tail = href.substring(idx + 7); // after '/plain/'
// Tail can be absolute URL possibly percent-encoded
try { return new URL(tail).toString(); } catch {}
try { return new URL(decodeURIComponent(tail)).toString(); } catch {}
}
} catch {}
return '';
}
// Fetch the image
const response = await fetch(imageUrl, {
headers: { 'User-Agent': 'NewTube/1.0' },
redirect: 'follow'
});
if (!response.ok) {
return res.status(response.status).send(`Error fetching image: ${response.statusText}`);
function toggleExt(u0) {
try {
const u1 = new URL(u0);
if (/\.webp(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.webp(\?|$)/i, '.jpg$1');
else if (/\.jpg(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.jpg(\?|$)/i, '.webp$1');
return u1.toString();
} catch { return u0; }
}
// Forward the image with appropriate caching
res.setHeader('Cache-Control', 'public, max-age=600');
res.setHeader('Content-Type', response.headers.get('content-type') || 'image/jpeg');
const arrayBuffer = await response.arrayBuffer();
res.send(Buffer.from(arrayBuffer));
} catch (error) {
console.error('Error proxying Odysee image:', error);
res.status(500).json({ error: 'Internal server error' });
function stripQuery(u0) {
try { const u1 = new URL(u0); u1.search = ''; return u1.toString(); } catch { return u0; }
}
const hosts = ['thumbs.odycdn.com','thumbnails.lbry.com'];
// const hosts = ['thumbnails.odycdn.com','thumbnails.lbry.com','thumbs.odycdn.com','thumb.odycdn.com','static.odycdn.com','images.odycdn.com','thumbnails.lbry.tech','cdn.lbryplayer.xyz'];
function swapHost(u0, h) { try { const u1 = new URL(u0); u1.hostname = h; return u1.toString(); } catch { return u0; } }
const baseHref = parsed.toString();
const inner = extractPlainUrl(baseHref);
const seed = inner && (() => { try { const p = new URL(inner); return allowed.has(p.hostname.toLowerCase()) ? inner : ''; } catch { return ''; } })() || baseHref;
const candidates = new Set();
candidates.add(seed);
candidates.add(stripQuery(seed));
candidates.add(toggleExt(seed));
// host alternatives
for (const h of hosts) { candidates.add(swapHost(seed, h)); }
// If it contained optimize/plain, also try removing that segment entirely
try {
if (/\/optimize\//.test(seed) && /\/plain\//.test(seed)) {
const idx = seed.indexOf('/plain/');
const after = seed.substring(idx + 7);
try { const direct = new URL(after).toString(); candidates.add(direct); candidates.add(stripQuery(direct)); candidates.add(toggleExt(direct)); } catch {}
try { const dec = new URL(decodeURIComponent(after)).toString(); candidates.add(dec); candidates.add(stripQuery(dec)); candidates.add(toggleExt(dec)); } catch {}
}
} catch {}
// Try sequentially and stream the first success
let lastStatus = 0;
for (const href of candidates) {
try {
const u2 = new URL(href);
if (!allowed.has(u2.hostname.toLowerCase())) continue;
const upstream = await axios.get(u2.toString(), { responseType: 'stream', maxRedirects: 3, timeout: 15000, headers, validateStatus: s => s >= 200 && s < 400 });
const ctype = upstream.headers['content-type'] || 'image/jpeg';
const clen = upstream.headers['content-length'];
res.setHeader('Content-Type', ctype);
if (clen) res.setHeader('Content-Length', String(clen));
res.setHeader('Cache-Control', 'public, max-age=600');
upstream.data.pipe(res);
return; // success
} catch (e) {
lastStatus = e?.response?.status || lastStatus || 0;
continue;
}
}
// All attempts failed
return res.status(lastStatus || 502).json({ error: 'odysee_img_proxy_error', details: 'no_variant_succeeded' });
} catch (e) {
const status = e?.response?.status || 502;
return res.status(status).json({ error: 'odysee_img_proxy_error', details: String(e?.message || e) });
}
});
// Mount API router (prod) and alias for dev proxy
app.use('/api', r);
// Alias to support Angular dev proxy paths in both dev and production builds
app.use('/proxy/api', r);
@ -1167,6 +1237,7 @@ async function forwardJson(req, res, base) {
}
app.all('/api/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotion.com'));
app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));