added rpc support for mpv and tracking

This commit is contained in:
2025-12-31 14:47:37 +01:00
parent 47433733d0
commit 9daa4f1ad8
2 changed files with 98 additions and 62 deletions

View File

@@ -1,5 +1,7 @@
import {FastifyReply, FastifyRequest} from 'fastify'; import {FastifyReply, FastifyRequest} from 'fastify';
import * as animeService from './anime.service'; import * as animeService from './anime.service';
import { setActivity } from '../rpc/rp.service';
import { upsertListEntry } from '../list/list.service';
import {getExtension} from '../../shared/extensions'; import {getExtension} from '../../shared/extensions';
import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types'; import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types';
import {spawn} from "node:child_process"; import {spawn} from "node:child_process";
@@ -7,6 +9,7 @@ import net from 'net';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import jwt from "jsonwebtoken";
export async function getAnime(req: AnimeRequest, reply: FastifyReply) { export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
try { try {
@@ -113,10 +116,13 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
export async function openInMPV(req: any, reply: any) { export async function openInMPV(req: any, reply: any) {
try { 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' }; 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 mediaTitle = title || 'Anime';
const proxyVideo = const proxyVideo =
@@ -136,9 +142,7 @@ export async function openInMPV(req: any, reply: any) {
let chaptersArg: string[] = []; let chaptersArg: string[] = [];
if (chapters.length) { if (chapters.length) {
chapters.sort((a: any, b: any) => a.interval.startTime - b.interval.startTime); chapters.sort((a: any, b: any) => a.interval.startTime - b.interval.startTime);
const lines = [';FFMETADATA1']; const lines = [';FFMETADATA1'];
for (let i = 0; i < chapters.length; i++) { 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); const end = Math.floor(c.interval.endTime * 1000);
lines.push( lines.push(
`[CHAPTER]`, `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${start}`, `END=${end}`, `title=${c.skipType.toUpperCase()}`
`TIMEBASE=1/1000`,
`START=${start}`,
`END=${end}`,
`title=${c.skipType.toUpperCase()}`
); );
if (i < chapters.length - 1) { if (i < chapters.length - 1) {
const nextStart = Math.floor(chapters[i + 1].interval.startTime * 1000); const nextStart = Math.floor(chapters[i + 1].interval.startTime * 1000);
if (nextStart - end > 1000) { if (nextStart - end > 1000) {
lines.push( lines.push(
`[CHAPTER]`, `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `END=${nextStart}`, `title=Episode`
`TIMEBASE=1/1000`,
`START=${end}`,
`END=${nextStart}`,
`title=Episode`
); );
} }
} else { } else {
lines.push( lines.push(
`[CHAPTER]`, `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `title=Episode`
`TIMEBASE=1/1000`,
`START=${end}`,
`title=Episode`
); );
} }
} }
const chaptersFile = path.join( const chaptersFile = path.join(os.tmpdir(), `mpv-chapters-${Date.now()}.txt`);
os.tmpdir(),
`mpv-chapters-${Date.now()}.txt`
);
fs.writeFileSync(chaptersFile, lines.join('\n')); fs.writeFileSync(chaptersFile, lines.join('\n'));
chaptersArg = [`--chapters-file=${chaptersFile}`]; chaptersArg = [`--chapters-file=${chaptersFile}`];
} }
@@ -198,21 +186,57 @@ export async function openInMPV(req: any, reply: any) {
`--input-ipc-server=${pipe}`, `--input-ipc-server=${pipe}`,
...chaptersArg ...chaptersArg
], ],
{ { stdio: 'ignore', windowsHide: true, detached: true }
stdio: 'ignore',
windowsHide: true,
detached: true
}
).unref(); ).unref();
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 400));
const socket = net.connect(pipe); const socket = net.connect(pipe);
let duration = 0;
let currentTime = 0;
let isPaused = false;
let titleChanged = false; let titleChanged = false;
let progressUpdated = false;
const safetyTimeout = setTimeout(() => { const sendRPC = (paused: boolean) => {
if (!socket.destroyed) socket.end(); let startTimestamp = undefined;
}, 15000); 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) => { socket.on('data', (data) => {
const chunks = data.toString().split('\n'); const chunks = data.toString().split('\n');
@@ -221,42 +245,56 @@ export async function openInMPV(req: any, reply: any) {
try { try {
const json = JSON.parse(chunk); const json = JSON.parse(chunk);
if ( if (json.event === 'property-change' && json.name === 'duration' && typeof json.data === 'number') {
!titleChanged && duration = json.data;
json.event === 'property-change' && if (!titleChanged) {
json.name === 'duration' && socket.write(JSON.stringify({ command: ['set_property', 'force-media-title', mediaTitle] }) + '\n');
typeof json.data === 'number'
) {
socket.write(JSON.stringify({
command: ['set_property', 'force-media-title', mediaTitle]
}) + '\n');
sendRPC(false);
titleChanged = true; titleChanged = true;
clearTimeout(safetyTimeout); clearTimeout(loadingTimeout);
setTimeout(() => {
if (!socket.destroyed) socket.end();
}, 500);
} }
} 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({ const commands = [
command: ['observe_property', 1, 'duration'] { command: ['observe_property', 1, 'duration'] },
}) + '\n'); { command: ['observe_property', 2, 'time-pos'] },
{ command: ['observe_property', 3, 'pause'] },
{ command: ['loadfile', proxyVideo, 'replace'] }
];
socket.write(JSON.stringify({ commands.forEach(cmd => socket.write(JSON.stringify(cmd) + '\n'));
command: ['loadfile', proxyVideo, 'replace']
}) + '\n');
for (const sub of proxySubs) { for (const sub of proxySubs) {
socket.write(JSON.stringify({ socket.write(JSON.stringify({ command: ['sub-add', sub, 'auto'] }) + '\n');
command: ['sub-add', sub, 'auto']
}) + '\n');
} }
return { success: true }; return { success: true };

View File

@@ -4,8 +4,6 @@ let rpcClient: Client | null = null;
let reconnectTimer: NodeJS.Timeout | null = null; let reconnectTimer: NodeJS.Timeout | null = null;
let connected = false; let connected = false;
type RPCMode = "watching" | "reading" | string;
interface RPCData { interface RPCData {
details?: string; details?: string;
state?: string; state?: string;