diff --git a/db/newtube.db b/db/newtube.db index c4ac250..5aa0686 100644 Binary files a/db/newtube.db and b/db/newtube.db differ diff --git a/server/index.mjs b/server/index.mjs index 465597e..5551688 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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'));