const net = require('net'); const path = require('path'); const https = require('https'); const crypto = require('crypto'); const DEFAULT_CLIENT_ID = '1394887879154598038'; const DEFAULT_LOGO = 'vyla_logo'; const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/w500'; const OP = { HANDSHAKE: 0, FRAME: 1, CLOSE: 2, PING: 3, PONG: 4 }; function ipcPaths() { const out = []; if (process.platform === 'win32') { for (let i = 0; i < 10; i++) out.push(`\\\\?\\pipe\\discord-ipc-${i}`); return out; } const bases = [...new Set([process.env.XDG_RUNTIME_DIR, process.env.TMPDIR, process.env.TMP, process.env.TEMP, '/tmp'].filter(Boolean))]; for (const base of bases) { for (let i = 0; i < 10; i++) { out.push(path.join(base, `discord-ipc-${i}`)); out.push(path.join(base, 'app', 'com.discordapp.Discord', `discord-ipc-${i}`)); out.push(path.join(base, 'snap.discord', `discord-ipc-${i}`)); } } return out; } class DiscordIpc { constructor(clientId, log) { this.clientId = clientId; this.log = log || (() => { }); this.socket = null; this.ready = false; this.connecting = false; this.stopped = true; this.buffer = Buffer.alloc(0); this.retryTimer = null; this.desired = null; } start() { this.stopped = false; this.connect(); } stop() { this.stopped = true; clearTimeout(this.retryTimer); const s = this.socket; this.socket = null; this.ready = false; if (s) { try { s.destroy(); } catch (e) { } } } scheduleRetry() { if (this.stopped) return; clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => this.connect(), 15000); } connect() { if (this.stopped || this.socket || this.connecting) return; this.connecting = true; const paths = ipcPaths(); const attempt = (i) => { if (this.stopped) { this.connecting = false; return; } if (i >= paths.length) { this.connecting = false; this.scheduleRetry(); return; } const sock = net.createConnection(paths[i]); let opened = false; sock.once('error', () => { if (!opened) { sock.destroy(); attempt(i + 1); } }); sock.once('connect', () => { opened = true; this.connecting = false; this.attach(sock); }); }; attempt(0); } attach(sock) { this.socket = sock; this.buffer = Buffer.alloc(0); this.ready = false; sock.on('data', (chunk) => this.onData(sock, chunk)); sock.on('error', () => { }); sock.on('close', () => { if (this.socket === sock) { this.socket = null; this.ready = false; this.log('Discord IPC closed'); this.scheduleRetry(); } }); this.send(OP.HANDSHAKE, { v: 1, client_id: this.clientId }); } send(op, payload) { if (!this.socket) return; const body = Buffer.from(JSON.stringify(payload), 'utf8'); const header = Buffer.alloc(8); header.writeInt32LE(op, 0); header.writeInt32LE(body.length, 4); try { this.socket.write(Buffer.concat([header, body])); } catch (e) { } } onData(sock, chunk) { if (this.socket !== sock) return; this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 8) { const op = this.buffer.readInt32LE(0); const len = this.buffer.readInt32LE(4); if (len < 0 || len > 1024 * 1024) { sock.destroy(); return; } if (this.buffer.length < 8 + len) return; const raw = this.buffer.slice(8, 8 + len).toString('utf8'); this.buffer = this.buffer.slice(8 + len); let payload = {}; try { payload = JSON.parse(raw); } catch (e) { } this.handle(sock, op, payload); } } handle(sock, op, payload) { if (op === OP.PING) { this.send(OP.PONG, payload); } else if (op === OP.CLOSE) { this.log(`Discord closed the connection: ${JSON.stringify(payload)}`); sock.destroy(); } else if (op === OP.FRAME) { if (payload.evt === 'READY') { this.ready = true; this.log('Discord RPC ready'); this.flush(); } else if (payload.evt === 'ERROR') { this.log(`Discord RPC error: ${JSON.stringify(payload.data)}`); } } } flush() { if (!this.ready) return; const args = { pid: process.pid }; if (this.desired) args.activity = this.desired; this.send(OP.FRAME, { cmd: 'SET_ACTIVITY', args, nonce: crypto.randomUUID() }); } setActivity(activity) { this.desired = activity || null; this.flush(); } } function parseRoute(href) { let u; try { u = new URL(href); } catch (e) { return { kind: 'idle' }; } if (u.pathname !== '/' && u.pathname !== '/index.html') return { kind: 'idle' }; const p = u.searchParams; const id = (p.get('id') || '').trim(); if (!/^\d+$/.test(id)) return { kind: 'idle' }; const type = (p.get('type') || '').toLowerCase(); if (type === 'movie' || type === 'tv') { return { kind: 'browse', type, id }; } const season = parseInt(p.get('season'), 10); const episode = parseInt(p.get('episode'), 10); if (Number.isFinite(season) && Number.isFinite(episode)) { return { kind: 'watch', type: 'tv', id, season, episode }; } return { kind: 'watch', type: 'movie', id }; } function getJson(url, headers) { return new Promise((resolve, reject) => { const req = https.get(url, { headers: { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/json', ...(headers || {}) } }, (res) => { let data = ''; res.on('data', (c) => { data += c; }); res.on('end', () => { if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; } try { resolve(JSON.parse(data)); } catch (e) { reject(new Error('Invalid JSON')); } }); }); req.on('error', reject); req.setTimeout(8000, () => { req.destroy(); reject(new Error('Request timeout')); }); }); } function clip(s, max = 128) { const str = String(s || '').trim(); return str.length > max ? str.slice(0, max - 1) + '…' : str; } function cleanPageTitle(t) { const s = String(t || '').replace(/\s*[-|–—•]\s*Vyla.*$/i, '').trim(); if (!s || /^vyla/i.test(s)) return null; return s; } class VylaPresence { constructor({ getConfig, log }) { this.getConfig = getConfig || (() => ({})); this.log = log || (() => { }); this.windows = new Map(); this.ipc = null; this.started = false; this.metaCache = new Map(); this.pageMemory = new Map(); this.lastStart = 0; this.watchKey = null; this.watchStart = 0; this.lastActivityKey = null; this.timer = null; this.token = 0; } settings() { const cfg = (this.getConfig() || {}).discord || {}; const clientId = [process.env.VYLA_DISCORD_CLIENT_ID, cfg.clientId, DEFAULT_CLIENT_ID] .map((v) => String(v || '').trim()) .find((v) => /^\d{17,20}$/.test(v)) || ''; const tmdbKey = String(process.env.VYLA_TMDB_API_KEY || cfg.tmdbApiKey || '').trim(); return { enabled: cfg.enabled !== false, clientId: /^\d{17,20}$/.test(clientId) ? clientId : '', logo: cfg.logoImage || DEFAULT_LOGO, tmdbKey: /^YOUR_/i.test(tmdbKey) ? '' : tmdbKey }; } start() { if (this.started) return; const s = this.settings(); if (!s.enabled) { this.log('Discord presence disabled in config.'); return; } if (!s.clientId) { this.log('Discord presence not started: no clientId configured.'); return; } this.started = true; this.ipc = new DiscordIpc(s.clientId, (m) => this.log(m)); this.ipc.start(); this.refresh(); } stop() { clearTimeout(this.timer); this.started = false; if (this.ipc) { this.ipc.stop(); this.ipc = null; } } report(payload) { if (!payload || !payload.label) return; this.log(`report ${payload.label}: ${payload.closed ? 'closed' : payload.url}`); if (payload.closed) { this.windows.delete(payload.label); } else { this.windows.set(payload.label, { url: String(payload.url || ''), title: String(payload.title || ''), image: String(payload.image || ''), position: Number(payload.position), duration: Number(payload.duration), paused: payload.paused === true, at: Date.now() }); } this.refresh(); } current() { let best = null; for (const w of this.windows.values()) { if (!best || w.at >= best.at) best = w; } return best; } refresh() { if (!this.started) return; clearTimeout(this.timer); this.timer = setTimeout(() => this.apply().catch((e) => this.log(`Presence update failed: ${e.message}`)), 400); } async fetchMeta(route) { const s = this.settings(); if (!s.tmdbKey) return null; const key = `${route.type}:${route.id}`; if (this.metaCache.has(key)) return this.metaCache.get(key); try { const bearer = s.tmdbKey.length > 40; const url = `https://api.themoviedb.org/3/${route.type}/${route.id}` + (bearer ? '' : `?api_key=${encodeURIComponent(s.tmdbKey)}`); const d = await getJson(url, bearer ? { Authorization: `Bearer ${s.tmdbKey}` } : null); const meta = { title: d.title || d.name || null, poster: d.poster_path ? `${TMDB_IMAGE_BASE}${d.poster_path}` : null, runtime: d.runtime || (Array.isArray(d.episode_run_time) && d.episode_run_time[0]) || 0 }; this.metaCache.set(key, meta); return meta; } catch (e) { this.log(`TMDB lookup failed for ${key}: ${e.message}`); return null; } } async fetchEpisodeName(route) { const s = this.settings(); if (!s.tmdbKey) return null; const key = `ep:${route.id}:${route.season}:${route.episode}`; if (this.metaCache.has(key)) return this.metaCache.get(key); try { const bearer = s.tmdbKey.length > 40; const url = `https://api.themoviedb.org/3/tv/${route.id}/season/${route.season}/episode/${route.episode}` + (bearer ? '' : `?api_key=${encodeURIComponent(s.tmdbKey)}`); const d = await getJson(url, bearer ? { Authorization: `Bearer ${s.tmdbKey}` } : null); const info = { name: d.name || null, runtime: d.runtime || 0 }; this.metaCache.set(key, info); return info; } catch (e) { return null; } } async apply() { const token = ++this.token; const s = this.settings(); const win = this.current(); const route = win ? parseRoute(win.url) : { kind: 'idle' }; let activity; if (route.kind === 'idle') { this.watchKey = null; activity = { type: 0, details: 'On Vyla Home', assets: { large_image: s.logo, large_text: 'Vyla Home' } }; } else { const meta = await this.fetchMeta(route); const episode = (route.kind === 'watch' && route.type === 'tv' ? await this.fetchEpisodeName(route) : null) || {}; if (token !== this.token) return; const pageTitle = win ? cleanPageTitle(win.title) : null; const pageImage = win && /^https:\/\//i.test(win.image) ? win.image : null; const memKey = `${route.type}:${route.id}`; const mem = this.pageMemory.get(memKey) || {}; if (pageTitle) mem.title = pageTitle; if (pageImage) mem.poster = pageImage; this.pageMemory.set(memKey, mem); const title = (meta && meta.title) || pageTitle || mem.title || (route.type === 'tv' ? 'a TV show' : 'a movie'); const poster = (meta && meta.poster) || pageImage || mem.poster; const assets = { large_image: poster || s.logo, large_text: clip(title) }; if (poster) { assets.small_image = s.logo; assets.small_text = 'Vyla Home'; } if (route.kind === 'browse') { this.watchKey = null; activity = { type: 0, details: 'Browsing Vyla', state: clip(`Browsing ${title}`), assets }; } else { const key = route.type === 'tv' ? `tv:${route.id}:${route.season}:${route.episode}` : `movie:${route.id}`; if (this.watchKey !== key) { this.watchKey = key; this.watchStart = Date.now(); this.lastStart = 0; } const runtimeMin = episode.runtime || (meta && meta.runtime) || 0; let timestamps; if (win && Number.isFinite(win.duration) && win.duration > 0) { if (win.paused) { this.lastStart = 0; } else { let start = Date.now() - Math.round(win.position * 1000); if (this.lastStart && Math.abs(start - this.lastStart) < 4000) start = this.lastStart; this.lastStart = start; timestamps = { start, end: start + Math.round(win.duration * 1000) }; } } else if (runtimeMin) { timestamps = { start: this.watchStart, end: this.watchStart + runtimeMin * 60000 }; } else { timestamps = { start: this.watchStart }; } activity = { type: 3, details: clip(`Watching ${title}`), assets }; if (timestamps) activity.timestamps = timestamps; if (route.type === 'tv') { activity.state = clip(`Season ${route.season}, Episode ${route.episode}` + (episode.name ? ` · ${episode.name}` : '')); } } } const sig = JSON.stringify(activity); if (sig === this.lastActivityKey) return; this.lastActivityKey = sig; this.log(`activity: ${sig}`); if (this.ipc) this.ipc.setActivity(activity); } } VylaPresence.parseRoute = parseRoute; module.exports = VylaPresence;