From 9daa4f1ad810c11fd6971988a6ec1357607e6784 Mon Sep 17 00:00:00 2001 From: lenafx Date: Wed, 31 Dec 2025 14:47:37 +0100 Subject: [PATCH] added rpc support for mpv and tracking --- desktop/src/api/anime/anime.controller.ts | 158 ++++++++++++++-------- desktop/src/api/rpc/rp.service.ts | 2 - 2 files changed, 98 insertions(+), 62 deletions(-) diff --git a/desktop/src/api/anime/anime.controller.ts b/desktop/src/api/anime/anime.controller.ts index ef6ba03..84b94bf 100644 --- a/desktop/src/api/anime/anime.controller.ts +++ b/desktop/src/api/anime/anime.controller.ts @@ -1,5 +1,7 @@ import {FastifyReply, FastifyRequest} from 'fastify'; import * as animeService from './anime.service'; +import { setActivity } from '../rpc/rp.service'; +import { upsertListEntry } from '../list/list.service'; import {getExtension} from '../../shared/extensions'; import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types'; import {spawn} from "node:child_process"; @@ -7,6 +9,7 @@ import net from 'net'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import jwt from "jsonwebtoken"; export async function getAnime(req: AnimeRequest, reply: FastifyReply) { try { @@ -113,10 +116,13 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl export async function openInMPV(req: any, reply: any) { try { - const { title, video, subtitles = [], chapters = [] } = req.body; + + const { title, video, subtitles = [], chapters = [], animeId, episode, entrySource, token } = req.body; + if (!video?.url) return { error: 'Missing video url' }; - const proxyBase = 'http://localhost:54322/api/proxy'; + const PORT = 54322; + const proxyBase = `http://localhost:${PORT}/api/proxy`; const mediaTitle = title || 'Anime'; const proxyVideo = @@ -136,9 +142,7 @@ export async function openInMPV(req: any, reply: any) { let chaptersArg: string[] = []; if (chapters.length) { - chapters.sort((a: any, b: any) => a.interval.startTime - b.interval.startTime); - const lines = [';FFMETADATA1']; for (let i = 0; i < chapters.length; i++) { @@ -147,40 +151,24 @@ export async function openInMPV(req: any, reply: any) { const end = Math.floor(c.interval.endTime * 1000); lines.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${start}`, - `END=${end}`, - `title=${c.skipType.toUpperCase()}` + `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${start}`, `END=${end}`, `title=${c.skipType.toUpperCase()}` ); if (i < chapters.length - 1) { const nextStart = Math.floor(chapters[i + 1].interval.startTime * 1000); if (nextStart - end > 1000) { lines.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${end}`, - `END=${nextStart}`, - `title=Episode` + `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `END=${nextStart}`, `title=Episode` ); } } else { - lines.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${end}`, - `title=Episode` + `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `title=Episode` ); } } - const chaptersFile = path.join( - os.tmpdir(), - `mpv-chapters-${Date.now()}.txt` - ); - + const chaptersFile = path.join(os.tmpdir(), `mpv-chapters-${Date.now()}.txt`); fs.writeFileSync(chaptersFile, lines.join('\n')); chaptersArg = [`--chapters-file=${chaptersFile}`]; } @@ -198,21 +186,57 @@ export async function openInMPV(req: any, reply: any) { `--input-ipc-server=${pipe}`, ...chaptersArg ], - { - stdio: 'ignore', - windowsHide: true, - detached: true - } + { stdio: 'ignore', windowsHide: true, detached: true } ).unref(); - await new Promise(r => setTimeout(r, 300)); + await new Promise(r => setTimeout(r, 400)); const socket = net.connect(pipe); + let duration = 0; + let currentTime = 0; + let isPaused = false; let titleChanged = false; + let progressUpdated = false; - const safetyTimeout = setTimeout(() => { - if (!socket.destroyed) socket.end(); - }, 15000); + const sendRPC = (paused: boolean) => { + let startTimestamp = undefined; + let endTimestamp = undefined; + + if (!paused && duration > 0) { + const now = Math.floor(Date.now() / 1000); + const elapsed = Math.floor(currentTime); + + startTimestamp = now - elapsed; + endTimestamp = startTimestamp + Math.floor(duration); + } + setActivity({ + details: mediaTitle, + state: `Episode ${episode}`, + mode: "watching", + paused: paused, + startTimestamp: startTimestamp, + endTimestamp: endTimestamp + }) + }; + + const updateProgress = async () => { + if (!token || progressUpdated) return; + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any; + const userId = decoded.id; + await upsertListEntry({ + user_id: userId, + entry_id: animeId, + source: entrySource, + entry_type: "ANIME", + status: "CURRENT", + progress: episode + }); + progressUpdated = true; + } catch (e) { console.error("[MPV] Progress update failed", e); } + }; + + const loadingTimeout = setTimeout(() => { if (!socket.destroyed) socket.end(); }, 15000); socket.on('data', (data) => { const chunks = data.toString().split('\n'); @@ -221,42 +245,56 @@ export async function openInMPV(req: any, reply: any) { try { const json = JSON.parse(chunk); - if ( - !titleChanged && - json.event === 'property-change' && - json.name === 'duration' && - typeof json.data === 'number' - ) { + if (json.event === 'property-change' && json.name === 'duration' && typeof json.data === 'number') { + duration = json.data; + if (!titleChanged) { + socket.write(JSON.stringify({ command: ['set_property', 'force-media-title', mediaTitle] }) + '\n'); - socket.write(JSON.stringify({ - command: ['set_property', 'force-media-title', mediaTitle] - }) + '\n'); - - titleChanged = true; - clearTimeout(safetyTimeout); - - setTimeout(() => { - if (!socket.destroyed) socket.end(); - }, 500); + sendRPC(false); + titleChanged = true; + clearTimeout(loadingTimeout); + } } - } catch (err) { - } + if (json.event === 'property-change' && json.name === 'time-pos' && typeof json.data === 'number') { + currentTime = json.data; + + if (duration > 0 && !progressUpdated && (currentTime / duration) >= 0.8) { + updateProgress(); + } + } + + if (json.event === 'property-change' && json.name === 'pause') { + isPaused = json.data === true; + sendRPC(isPaused); + + } + + if (json.event === 'seek') { + + setTimeout(() => sendRPC(isPaused), 100); + } + + if (json.event === 'end-file' || json.event === 'shutdown') { + sendRPC(true); + if (!socket.destroyed) socket.destroy(); + } + + } catch (err) {} } }); - socket.write(JSON.stringify({ - command: ['observe_property', 1, 'duration'] - }) + '\n'); + const commands = [ + { command: ['observe_property', 1, 'duration'] }, + { command: ['observe_property', 2, 'time-pos'] }, + { command: ['observe_property', 3, 'pause'] }, + { command: ['loadfile', proxyVideo, 'replace'] } + ]; - socket.write(JSON.stringify({ - command: ['loadfile', proxyVideo, 'replace'] - }) + '\n'); + commands.forEach(cmd => socket.write(JSON.stringify(cmd) + '\n')); for (const sub of proxySubs) { - socket.write(JSON.stringify({ - command: ['sub-add', sub, 'auto'] - }) + '\n'); + socket.write(JSON.stringify({ command: ['sub-add', sub, 'auto'] }) + '\n'); } return { success: true }; diff --git a/desktop/src/api/rpc/rp.service.ts b/desktop/src/api/rpc/rp.service.ts index 406ff7b..bdaea8e 100644 --- a/desktop/src/api/rpc/rp.service.ts +++ b/desktop/src/api/rpc/rp.service.ts @@ -4,8 +4,6 @@ let rpcClient: Client | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let connected = false; -type RPCMode = "watching" | "reading" | string; - interface RPCData { details?: string; state?: string;