From 3dbfb04b15de351f8e2e714565495f9062fb4f28 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 17 Sep 2025 14:02:00 -0400 Subject: [PATCH] feat: add video like status endpoint and improve Odysee image proxy with fallback URLs --- db/newtube.db | Bin 356352 -> 356352 bytes server/index.mjs | 133 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/db/newtube.db b/db/newtube.db index c4ac250d3dda502c985c7e0702517a40415e2959..5aa06862ba1373c45ac63896ea7a9c20a3a910bd 100644 GIT binary patch delta 964 zcmb7@Ur19?9LKkF?wt4TkJIT2H4Me{W!HP|y}P@2v0(FGS)+0~-L#x*(?m2gbt*-h zK|KVf?BPfd2v&v${khP{9&hVv|^YA@~^ZT6N`TSz5 zIkDB8v0Pv}GM?{N)+fQs1w}RoCpb7nGmI2q8P;ZHBrCGlp_E+yRiFt%T33?L#tPLX za%t=*RgeoMb>ood#PEenQJ*^!?P@*Q-|ml8R!8a0EqryIu*l6f9}tjEpGs)Q{2+Zw z4vx9f6K^I;)pX+B?NWJt!lZ>XwUE$GHUVlu&OF#oB|goVN<;^P94w;k3=^R9p;P@2 z8RuX{TI6If3v#j$wX8C}H5f8lbS1j?hO^YNoIO8J;aTmTVk)}EhY1o)SnlinQX$^ zNl*;PCWY5Zf-Vale+bG-!yLm!L{8#Fkqx(=6Bt{V6@>_Ivx}_6g+sh3uwg+8VKfW& z5jmRrB0N3=4&j)QFec#)P^V>?$zv*=!+1tFNLvJfc~q1MQy>O zFMx!Vi*s-2=Ri_F2fu%S8E{3RCk_C(qR>Bsh4CsxN%e=#aVx2E?%6FIUsNcAj|dl@ zzfW+69W#{XB=6lNT8`)HAg?mMG~CfQ>m1bn^l#dww5OUe!UuV!iUk&r6wpSgKCX#d z4QA5mbgK3;2WMhJX@@{)Z8hG$wno&=*c*JTzo%-bhOQJ^BUOzdzPhihySzHm0>hCG`q>7Vj)mDy}E!F?#f+Ph-?ABeR&P|bBw(_Vq zHX+B(?ITGZIvfa-a!Up&o6_^#es-WM7S*acU2tw$X2GlPKvcXSAqZvo+s4Imo$s6%94tfIMIm`>gSa^S8}gzP+yDRo delta 436 zcmZp8AlmRibb=HU|C@<2PC#;FLXG@%o3AV!EEWc)QPT~+vWPGPS)2dMvnDX>ayw0C zZ&2E--SCHNvVwx_W~c5$EX=wbXC|{xh-Bh9yID{ni(~TQ85*0v%n)E_*5!IJnSDVt zP_&I}^66CqAai*Zv#b^sFyQ*gz;}a}mp`6co%;!I+-9)@2OuVXJSEJ^Wx)KBf%`cR zD}U`~feX`_fmXRpe=Wjl)GU2vyYv;tm#3M9I8RMyU%;daG?P434j5&dZ6;1Ek&15+ps7k7xXBX3U zo?Xm8o-*^XECm|LA`3LKk!AbRB`opY5?m~K41D`}=kmmGTX9Knd}F`DwuyBDOCC^7 z5Q|`wye^+*Xd`Q*F0U}Bt*tG?^z<(*jwTgOZrPa;1_r*yE+rKyp(!pEPM)r*E?zl? z28kht>3*(Nk(Cw(9*&t&dL?P4h1qFt1%8tQYc!`Xuwv1guK$%q6% { 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'));