Files
SubMiner/src/core/services/mpv-service.ts

456 lines
14 KiB
TypeScript

import * as net from "net";
import { EventEmitter } from "events";
import {
Config,
MpvClient,
MpvSubtitleRenderMetrics,
} from "../../types";
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from "./mpv-protocol";
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
import {
scheduleMpvReconnect,
} from "./mpv-transport";
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from "./mpv-protocol";
export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
}
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap {
"subtitle-change": { text: string; isOverlayVisible: boolean };
"subtitle-ass-change": { text: string };
"subtitle-timing": { text: string; start: number; end: number };
"secondary-subtitle-change": { text: string };
"media-path-change": { path: string };
"media-title-change": { title: string | null };
"subtitle-metrics-change": { patch: Partial<MpvSubtitleRenderMetrics> };
"secondary-subtitle-visibility": { visible: boolean };
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private socketPath: string;
private deps: MpvIpcClientProtocolDeps;
public socket: net.Socket | null = null;
private eventBus = new EventEmitter();
private buffer = "";
public connected = false;
private connecting = false;
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
public currentVideoPath = "";
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
public currentSubText = "";
public currentSecondarySubText = "";
public currentAudioStreamIndex: number | null = null;
private currentAudioTrackId: number | null = null;
private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: "",
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: "yes",
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
private previousSecondarySubVisibility: boolean | null = null;
private pauseAtTime: number | null = null;
private pendingPauseAtSubEnd = false;
private nextDynamicRequestId = 1000;
private pendingRequests = new Map<number, (message: MpvMessage) => void>();
constructor(
socketPath: string,
deps: MpvIpcClientDeps,
) {
this.socketPath = socketPath;
this.deps = deps;
}
on<EventName extends MpvIpcClientEventName>(
event: EventName,
listener: (payload: MpvIpcClientEventMap[EventName]) => void,
): void {
this.eventBus.on(event as string, listener);
}
off<EventName extends MpvIpcClientEventName>(
event: EventName,
listener: (payload: MpvIpcClientEventMap[EventName]) => void,
): void {
this.eventBus.off(event as string, listener);
}
private emit<EventName extends MpvIpcClientEventName>(
event: EventName,
payload: MpvIpcClientEventMap[EventName],
): void {
this.eventBus.emit(event as string, payload);
}
private emitSubtitleMetricsChange(
patch: Partial<MpvSubtitleRenderMetrics>,
): void {
this.mpvSubtitleRenderMetrics = {
...this.mpvSubtitleRenderMetrics,
...patch,
};
this.emit("subtitle-metrics-change", { patch });
}
setSocketPath(socketPath: string): void {
this.socketPath = socketPath;
}
connect(): void {
if (this.connected || this.connecting) {
return;
}
if (this.socket) {
this.socket.destroy();
}
this.connecting = true;
this.socket = new net.Socket();
this.socket.on("connect", () => {
console.log("Connected to MPV socket");
this.connected = true;
this.connecting = false;
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
const shouldAutoStart =
this.deps.autoStartOverlay ||
this.deps.getResolvedConfig().auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
console.log("Auto-starting overlay, hiding mpv subtitles");
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
}
this.firstConnection = false;
});
this.socket.on("data", (data: Buffer) => {
this.buffer += data.toString();
this.processBuffer();
});
this.socket.on("error", (err: Error) => {
console.error("MPV socket error:", err.message);
this.connected = false;
this.connecting = false;
this.failPendingRequests();
});
this.socket.on("close", () => {
console.log("MPV socket closed");
this.connected = false;
this.connecting = false;
this.failPendingRequests();
this.scheduleReconnect();
});
this.socket.connect(this.socketPath);
}
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
hasConnectedOnce: this.hasConnectedOnce,
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
console.log(
`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`,
);
},
connect: () => {
this.connect();
},
});
}
private processBuffer(): void {
const parsed = splitMpvMessagesFromBuffer(
this.buffer,
(message) => {
this.handleMessage(message);
},
(line, error) => {
console.error("Failed to parse MPV message:", line, error);
},
);
this.buffer = parsed.nextBuffer;
}
private async handleMessage(msg: MpvMessage): Promise<void> {
await dispatchMpvProtocolMessage(msg, this.createProtocolMessageDeps());
}
private createProtocolMessageDeps(): MpvProtocolHandleMessageDeps {
return {
getResolvedConfig: () => this.deps.getResolvedConfig(),
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
emitSubtitleChange: (payload) => {
this.emit("subtitle-change", payload);
},
emitSubtitleAssChange: (payload) => {
this.emit("subtitle-ass-change", payload);
},
emitSubtitleTiming: (payload) => {
this.emit("subtitle-timing", payload);
},
emitSecondarySubtitleChange: (payload) => {
this.emit("secondary-subtitle-change", payload);
},
getCurrentSubText: () => this.currentSubText,
setCurrentSubText: (text: string) => {
this.currentSubText = text;
},
setCurrentSubStart: (value: number) => {
this.currentSubStart = value;
},
getCurrentSubStart: () => this.currentSubStart,
setCurrentSubEnd: (value: number) => {
this.currentSubEnd = value;
},
getCurrentSubEnd: () => this.currentSubEnd,
emitMediaPathChange: (payload) => {
this.emit("media-path-change", payload);
},
emitMediaTitleChange: (payload) => {
this.emit("media-title-change", payload);
},
emitSubtitleMetricsChange: (patch) => {
this.emitSubtitleMetricsChange(patch);
},
setCurrentSecondarySubText: (text: string) => {
this.currentSecondarySubText = text;
},
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
this.tryResolvePendingRequest(requestId, message),
setSecondarySubVisibility: (visible: boolean) =>
this.setSecondarySubVisibility(visible),
syncCurrentAudioStreamIndex: () => {
this.syncCurrentAudioStreamIndex();
},
setCurrentAudioTrackId: (value: number | null) => {
this.currentAudioTrackId = value;
},
setCurrentTimePos: (value: number) => {
this.currentTimePos = value;
},
getCurrentTimePos: () => this.currentTimePos,
getPendingPauseAtSubEnd: () => this.pendingPauseAtSubEnd,
setPendingPauseAtSubEnd: (value: boolean) => {
this.pendingPauseAtSubEnd = value;
},
getPauseAtTime: () => this.pauseAtTime,
setPauseAtTime: (value: number | null) => {
this.pauseAtTime = value;
},
autoLoadSecondarySubTrack: () => {
this.autoLoadSecondarySubTrack();
},
setCurrentVideoPath: (value: string) => {
this.currentVideoPath = value;
},
emitSecondarySubtitleVisibility: (payload) => {
this.emit("secondary-subtitle-visibility", payload);
},
setCurrentAudioStreamIndex: (tracks) => {
this.updateCurrentAudioStreamIndex(tracks);
},
sendCommand: (payload) => this.send(payload),
restorePreviousSecondarySubVisibility: () => {
this.restorePreviousSecondarySubVisibility();
},
};
}
private autoLoadSecondarySubTrack(): void {
const config = this.deps.getResolvedConfig();
if (!config.secondarySub?.autoLoadSecondarySub) return;
const languages = config.secondarySub.secondarySubLanguages;
if (!languages || languages.length === 0) return;
setTimeout(() => {
this.send({
command: ["get_property", "track-list"],
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
});
}, 500);
}
private syncCurrentAudioStreamIndex(): void {
this.send({
command: ["get_property", "track-list"],
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
});
}
private updateCurrentAudioStreamIndex(
tracks: Array<{
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
}>,
): void {
if (!Array.isArray(tracks)) {
this.currentAudioStreamIndex = null;
return;
}
const audioTracks = tracks.filter((track) => track.type === "audio");
const activeTrack =
audioTracks.find((track) => track.id === this.currentAudioTrackId) ||
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"];
this.currentAudioStreamIndex =
typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
? ffIndex
: null;
}
send(command: { command: unknown[]; request_id?: number }): boolean {
if (!this.connected || !this.socket) {
return false;
}
const msg = JSON.stringify(command) + "\n";
this.socket.write(msg);
return true;
}
request(command: unknown[]): Promise<MpvMessage> {
return new Promise((resolve, reject) => {
if (!this.connected || !this.socket) {
reject(new Error("MPV not connected"));
return;
}
const requestId = this.nextDynamicRequestId++;
this.pendingRequests.set(requestId, resolve);
const sent = this.send({ command, request_id: requestId });
if (!sent) {
this.pendingRequests.delete(requestId);
reject(new Error("Failed to send MPV request"));
return;
}
setTimeout(() => {
if (this.pendingRequests.delete(requestId)) {
reject(new Error("MPV request timed out"));
}
}, 4000);
});
}
async requestProperty(name: string): Promise<unknown> {
const response = await this.request(["get_property", name]);
if (response.error && response.error !== "success") {
throw new Error(
`Failed to read MPV property '${name}': ${response.error}`,
);
}
return response.data;
}
private failPendingRequests(): void {
for (const [requestId, resolve] of this.pendingRequests.entries()) {
resolve({ request_id: requestId, error: "disconnected" });
}
this.pendingRequests.clear();
}
private tryResolvePendingRequest(
requestId: number,
message: MpvMessage,
): boolean {
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return false;
}
this.pendingRequests.delete(requestId);
pending(message);
return true;
}
setSubVisibility(visible: boolean): void {
this.send({
command: ["set_property", "sub-visibility", visible ? "yes" : "no"],
});
}
replayCurrentSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 0] });
}
playNextSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 1] });
}
restorePreviousSecondarySubVisibility(): void {
const previous = this.previousSecondarySubVisibility;
if (previous === null) return;
this.send({
command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"],
});
this.previousSecondarySubVisibility = null;
}
private setSecondarySubVisibility(visible: boolean): void {
this.send({
command: [
"set_property",
"secondary-sub-visibility",
visible ? "yes" : "no",
],
});
}
}