Files
Home/discord-rpc.js
2026-11-05 19:22:42 -08:00

442 lines
15 KiB
JavaScript

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;