mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
442 lines
15 KiB
JavaScript
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; |