mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat: finish TASK-27.4 mpv-service protocol transport split
This commit is contained in:
@@ -5,7 +5,7 @@ status: In Progress
|
|||||||
assignee:
|
assignee:
|
||||||
- backend
|
- backend
|
||||||
created_date: '2026-02-13 17:13'
|
created_date: '2026-02-13 17:13'
|
||||||
updated_date: '2026-02-14 09:41'
|
updated_date: '2026-02-14 23:59'
|
||||||
labels:
|
labels:
|
||||||
- 'owner:backend'
|
- 'owner:backend'
|
||||||
- 'owner:architect'
|
- 'owner:architect'
|
||||||
@@ -72,4 +72,8 @@ Extracted app-ready startup dependency object into `createAppReadyRuntimeDeps():
|
|||||||
Added `SubsyncRuntimeDeps` typing to `getSubsyncRuntimeDeps()` for clearer composition-root contracts around subsync IPC/dependency wiring (`runSubsyncManualFromIpcRuntimeService`/`triggerSubsyncFromConfigRuntimeService` path).
|
Added `SubsyncRuntimeDeps` typing to `getSubsyncRuntimeDeps()` for clearer composition-root contracts around subsync IPC/dependency wiring (`runSubsyncManualFromIpcRuntimeService`/`triggerSubsyncFromConfigRuntimeService` path).
|
||||||
|
|
||||||
Extracted additional composition-root dependency composition for IPC command handlers into src/main/dependencies.ts: createCliCommandRuntimeServiceDeps(...) and createMpvCommandRuntimeServiceDeps(...). main.ts now inlines stateful callbacks into these shared builders while preserving behavior. Next step should be extracting startup/app-ready/lifecycle/overlay wiring into dedicated modules under src/main/.
|
Extracted additional composition-root dependency composition for IPC command handlers into src/main/dependencies.ts: createCliCommandRuntimeServiceDeps(...) and createMpvCommandRuntimeServiceDeps(...). main.ts now inlines stateful callbacks into these shared builders while preserving behavior. Next step should be extracting startup/app-ready/lifecycle/overlay wiring into dedicated modules under src/main/.
|
||||||
|
|
||||||
|
Progress update (2026-02-14): committed `bbfe2a9` (`refactor: extract overlay shortcuts runtime for task 27.2`). `src/main/overlay-shortcuts-runtime.ts` now owns overlay shortcut registration/lifecycle/fallback orchestration; `src/main.ts` and `src/main/cli-runtime.ts` now consume factory helpers with stricter typed async contracts. Build verified via `pnpm run build`.
|
||||||
|
|
||||||
|
Remaining for TASK-27.2: continue extracting remaining `main.ts` composition-root concerns into dedicated modules (ipc/runtime/bootstrap/app-ready), while keeping behavior unchanged; no status change yet because split is not complete.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-27.4
|
id: TASK-27.4
|
||||||
title: 'Split mpv-service.ts into protocol, transport, property, and facade layers'
|
title: 'Split mpv-service.ts into protocol, transport, property, and facade layers'
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee:
|
assignee:
|
||||||
- backend
|
- backend
|
||||||
created_date: '2026-02-13 17:13'
|
created_date: '2026-02-13 17:13'
|
||||||
updated_date: '2026-02-14 21:19'
|
updated_date: '2026-02-15 00:31'
|
||||||
labels:
|
labels:
|
||||||
- 'owner:backend'
|
- 'owner:backend'
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -37,13 +37,13 @@ Split mpv-service.ts (773 LOC) into thin, testable layers without changing wire
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 MpvIpcClient deps interface reduced to protocol-level concerns only (from current 22 properties).
|
- [x] #1 MpvIpcClient deps interface reduced to protocol-level concerns only (from current 22 properties).
|
||||||
- [ ] #2 Application-level reactions (subtitle broadcast, overlay sync, timing) handled via event emitter or external listeners registered by main.ts.
|
- [x] #2 Application-level reactions (subtitle broadcast, overlay sync, timing) handled via event emitter or external listeners registered by main.ts.
|
||||||
- [ ] #3 Create submodules for protocol parsing/dispatch, connection lifecycle/retry, and property subscriptions/state mapping.
|
- [x] #3 Create submodules for protocol parsing/dispatch, connection lifecycle/retry, and property subscriptions/state mapping.
|
||||||
- [ ] #4 The public mpv service API (MpvClient interface) remains compatible for all existing consumers.
|
- [x] #4 The public mpv service API (MpvClient interface) remains compatible for all existing consumers.
|
||||||
- [ ] #5 MpvIpcClient is testable without mocking 22 callbacks — protocol layer tests need only socket-level mocks.
|
- [x] #5 MpvIpcClient is testable without mocking 22 callbacks — protocol layer tests need only socket-level mocks.
|
||||||
- [ ] #6 Add at least one focused regression check for reconnect + property update flow.
|
- [x] #6 Add at least one focused regression check for reconnect + property update flow.
|
||||||
- [ ] #7 Document expected event flow in docs/structure-roadmap.md.
|
- [x] #7 Document expected event flow in docs/structure-roadmap.md.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
@@ -71,4 +71,18 @@ Milestone progress: extracted protocol buffer parsing into `src/core/services/mp
|
|||||||
Protocol extraction completed: full `MpvMessage` handling moved into `src/core/services/mpv-protocol.ts` via `splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`; `MpvIpcClient` now delegates all message parsing/dispatch through `MpvProtocolHandleMessageDeps` and resolves pending requests through `tryResolvePendingRequest`. `main.ts` wiring remains unchanged.
|
Protocol extraction completed: full `MpvMessage` handling moved into `src/core/services/mpv-protocol.ts` via `splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`; `MpvIpcClient` now delegates all message parsing/dispatch through `MpvProtocolHandleMessageDeps` and resolves pending requests through `tryResolvePendingRequest`. `main.ts` wiring remains unchanged.
|
||||||
|
|
||||||
Updated `docs/structure-roadmap.md` expected mpv flow snapshot to reflect protocol parse/dispatch extraction (`splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`) and façade delegation path via `MpvProtocolHandleMessageDeps`.
|
Updated `docs/structure-roadmap.md` expected mpv flow snapshot to reflect protocol parse/dispatch extraction (`splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`) and façade delegation path via `MpvProtocolHandleMessageDeps`.
|
||||||
|
|
||||||
|
Progress update: extracted socket connect/data/error/close/send/reconnect scheduling responsibilities into `MpvSocketTransport` (`src/core/services/mpv-transport.ts`) and wired `MpvIpcClient` to delegate connection lifecycle/send through it. Added `MpvSocketTransport` lifecycle tests in `src/core/services/mpv-transport.test.ts` covering connect/send/error/close behavior. Still in-progress on broader architectural refactor and API boundary reduction for `MpvIpcClient` deps beyond this transport split.
|
||||||
|
|
||||||
|
Added focused transport lifecycle regression coverage in `src/core/services/mpv-transport.test.ts`: connect/connect-idempotence, lifecycle callback ordering, and `shutdown()` resets connection/socket state. This covers reconnect/edge-case behavior at transport layer as part of criterion #6 toward protocol + lifecycle regression protection.
|
||||||
|
|
||||||
|
Added mpv-service unit regression for close lifecycle: `MpvIpcClient onClose resolves outstanding pending requests and triggers reconnect scheduling path via client transport callbacks (`src/core/services/mpv-service.test.ts`). This complements transport-level lifecycle tests for reconnect behavior regression coverage.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Split mpv-service internals into protocol, transport, and property/state-mapping boundaries; reduced MpvIpcClient deps to protocol-level concerns with event-based app reactions in main.ts; added mpv-service/mpv-transport tests for protocol dispatch, reconnect scheduling, and lifecycle regressions; documented expected event flow in docs/structure-roadmap.md.
|
||||||
|
|
||||||
|
Added mpv-service reconnect regression test that asserts a reconnect lifecycle replays mpv property bootstrap commands (`secondary-sub-visibility` reset, `observe_property`, and initial `get_property` state fetches) during reconnection.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Extract main.ts global state into an AppState container
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-11 08:20'
|
created_date: '2026-02-11 08:20'
|
||||||
updated_date: '2026-02-14 08:45'
|
updated_date: '2026-02-14 23:59'
|
||||||
labels:
|
labels:
|
||||||
- refactor
|
- refactor
|
||||||
- main
|
- main
|
||||||
@@ -41,4 +41,6 @@ Consolidate into a typed AppState object (or small set of domain-specific state
|
|||||||
Started centralizing module-level application state in `src/main.ts` via `appState` container and routing most state reads/writes through it. Initial rewrite completed; behavior verification pending and dependency-surface shrink pass still needed.
|
Started centralizing module-level application state in `src/main.ts` via `appState` container and routing most state reads/writes through it. Initial rewrite completed; behavior verification pending and dependency-surface shrink pass still needed.
|
||||||
|
|
||||||
Implemented Task-7 state migration to `appState` in main.ts and removed module-scope mutable state declarations; fixed a broken regression where several appState references were left as bare expressions in object literals (e.g., `appState.autoStartOverlay`), restoring valid typed dependency construction.
|
Implemented Task-7 state migration to `appState` in main.ts and removed module-scope mutable state declarations; fixed a broken regression where several appState references were left as bare expressions in object literals (e.g., `appState.autoStartOverlay`), restoring valid typed dependency construction.
|
||||||
|
|
||||||
|
Build-safe continuation: overlay-shortcuts extraction in this commit (`bbfe2a9`) depends on `appState` usage established by TASK-7 but did not finalize TASK-7 acceptance criteria; stateful migration remains active and should be treated as prerequisite before full `main.ts` module extraction per task sequencing.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -187,6 +187,86 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
|
|||||||
assert.equal(connectCalled, true);
|
assert.equal(connectCalled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("MpvIpcClient onClose resolves outstanding requests and schedules reconnect", () => {
|
||||||
|
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||||
|
const client = new MpvIpcClient(
|
||||||
|
"/tmp/mpv.sock",
|
||||||
|
makeDeps({
|
||||||
|
getReconnectTimer: () => null,
|
||||||
|
setReconnectTimer: (timer) => {
|
||||||
|
timers.push(timer);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved: Array<unknown> = [];
|
||||||
|
(client as any).pendingRequests.set(1, (message: unknown) => {
|
||||||
|
resolved.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
let reconnectConnectCount = 0;
|
||||||
|
(client as any).connect = () => {
|
||||||
|
reconnectConnectCount += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||||
|
handler();
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(client as any).transport.callbacks.onClose();
|
||||||
|
} finally {
|
||||||
|
(globalThis as any).setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(resolved.length, 1);
|
||||||
|
assert.deepEqual(resolved[0], { request_id: 1, error: "disconnected" });
|
||||||
|
assert.equal(reconnectConnectCount, 1);
|
||||||
|
assert.equal(timers.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MpvIpcClient reconnect replays property subscriptions and initial state requests", () => {
|
||||||
|
const commands: unknown[] = [];
|
||||||
|
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||||
|
(client as any).send = (command: unknown) => {
|
||||||
|
commands.push(command);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const callbacks = (client as any).transport.callbacks;
|
||||||
|
callbacks.onConnect();
|
||||||
|
|
||||||
|
commands.length = 0;
|
||||||
|
callbacks.onConnect();
|
||||||
|
|
||||||
|
const hasSecondaryVisibilityReset = commands.some(
|
||||||
|
(command) =>
|
||||||
|
Array.isArray((command as { command: unknown[] }).command) &&
|
||||||
|
(command as { command: unknown[] }).command[0] === "set_property" &&
|
||||||
|
(command as { command: unknown[] }).command[1] === "secondary-sub-visibility" &&
|
||||||
|
(command as { command: unknown[] }).command[2] === "no",
|
||||||
|
);
|
||||||
|
const hasTrackSubscription = commands.some(
|
||||||
|
(command) =>
|
||||||
|
Array.isArray((command as { command: unknown[] }).command) &&
|
||||||
|
(command as { command: unknown[] }).command[0] === "observe_property" &&
|
||||||
|
(command as { command: unknown[] }).command[1] === 1 &&
|
||||||
|
(command as { command: unknown[] }).command[2] === "sub-text",
|
||||||
|
);
|
||||||
|
const hasPathRequest = commands.some(
|
||||||
|
(command) =>
|
||||||
|
Array.isArray((command as { command: unknown[] }).command) &&
|
||||||
|
(command as { command: unknown[] }).command[0] === "get_property" &&
|
||||||
|
(command as { command: unknown[] }).command[1] === "path",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(hasSecondaryVisibilityReset, true);
|
||||||
|
assert.equal(hasTrackSubscription, true);
|
||||||
|
assert.equal(hasPathRequest, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("MpvIpcClient captures and disables secondary subtitle visibility on request", async () => {
|
test("MpvIpcClient captures and disables secondary subtitle visibility on request", async () => {
|
||||||
const commands: unknown[] = [];
|
const commands: unknown[] = [];
|
||||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as net from "net";
|
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
|
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
|
||||||
import {
|
import {
|
||||||
scheduleMpvReconnect,
|
scheduleMpvReconnect,
|
||||||
|
MpvSocketTransport,
|
||||||
} from "./mpv-transport";
|
} from "./mpv-transport";
|
||||||
import { resolveCurrentAudioStreamIndex } from "./mpv-state";
|
import { resolveCurrentAudioStreamIndex } from "./mpv-state";
|
||||||
|
|
||||||
@@ -49,9 +49,9 @@ export interface MpvIpcClientEventMap {
|
|||||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||||
|
|
||||||
export class MpvIpcClient implements MpvClient {
|
export class MpvIpcClient implements MpvClient {
|
||||||
private socketPath: string;
|
|
||||||
private deps: MpvIpcClientProtocolDeps;
|
private deps: MpvIpcClientProtocolDeps;
|
||||||
public socket: net.Socket | null = null;
|
private transport: MpvSocketTransport;
|
||||||
|
public socket: ReturnType<MpvSocketTransport["getSocket"]> = null;
|
||||||
private eventBus = new EventEmitter();
|
private eventBus = new EventEmitter();
|
||||||
private buffer = "";
|
private buffer = "";
|
||||||
public connected = false;
|
public connected = false;
|
||||||
@@ -95,8 +95,52 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
deps: MpvIpcClientDeps,
|
deps: MpvIpcClientDeps,
|
||||||
) {
|
) {
|
||||||
this.socketPath = socketPath;
|
|
||||||
this.deps = deps;
|
this.deps = deps;
|
||||||
|
|
||||||
|
this.transport = new MpvSocketTransport({
|
||||||
|
socketPath,
|
||||||
|
onConnect: () => {
|
||||||
|
console.log("Connected to MPV socket");
|
||||||
|
this.connected = true;
|
||||||
|
this.connecting = false;
|
||||||
|
this.socket = this.transport.getSocket();
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
onData: (data) => {
|
||||||
|
this.buffer += data.toString();
|
||||||
|
this.processBuffer();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
console.error("MPV socket error:", err.message);
|
||||||
|
this.failPendingRequests();
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
console.log("MPV socket closed");
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.socket = null;
|
||||||
|
this.failPendingRequests();
|
||||||
|
this.scheduleReconnect();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
on<EventName extends MpvIpcClientEventName>(
|
on<EventName extends MpvIpcClientEventName>(
|
||||||
@@ -131,7 +175,7 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSocketPath(socketPath: string): void {
|
setSocketPath(socketPath: string): void {
|
||||||
this.socketPath = socketPath;
|
this.transport.setSocketPath(socketPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
@@ -139,59 +183,8 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
this.socket = new net.Socket();
|
this.transport.connect();
|
||||||
|
|
||||||
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 {
|
private scheduleReconnect(): void {
|
||||||
@@ -349,9 +342,7 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
if (!this.connected || !this.socket) {
|
if (!this.connected || !this.socket) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const msg = JSON.stringify(command) + "\n";
|
return this.transport.send(command);
|
||||||
this.socket.write(msg);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request(command: unknown[]): Promise<MpvMessage> {
|
request(command: unknown[]): Promise<MpvMessage> {
|
||||||
|
|||||||
@@ -1,10 +1,49 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import * as net from "node:net";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
import {
|
import {
|
||||||
getMpvReconnectDelay,
|
getMpvReconnectDelay,
|
||||||
|
MpvSocketMessagePayload,
|
||||||
|
MpvSocketTransport,
|
||||||
scheduleMpvReconnect,
|
scheduleMpvReconnect,
|
||||||
} from "./mpv-transport";
|
} from "./mpv-transport";
|
||||||
|
|
||||||
|
class FakeSocket extends EventEmitter {
|
||||||
|
public connectedPaths: string[] = [];
|
||||||
|
public writePayloads: string[] = [];
|
||||||
|
public destroyed = false;
|
||||||
|
|
||||||
|
connect(path: string): void {
|
||||||
|
this.connectedPaths.push(path);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.emit("connect");
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
write(payload: string): boolean {
|
||||||
|
this.writePayloads.push(payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSocketMock<T>(fn: () => T): T {
|
||||||
|
const OriginalSocket = net.Socket;
|
||||||
|
(net as any).Socket = FakeSocket as any;
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
(net as any).Socket = OriginalSocket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
test("getMpvReconnectDelay follows existing reconnect ramp", () => {
|
test("getMpvReconnectDelay follows existing reconnect ramp", () => {
|
||||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||||
assert.equal(getMpvReconnectDelay(1, true), 1000);
|
assert.equal(getMpvReconnectDelay(1, true), 1000);
|
||||||
@@ -62,3 +101,143 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
|
|||||||
assert.equal(calls[0].delay, getMpvReconnectDelay(3, true));
|
assert.equal(calls[0].delay, getMpvReconnectDelay(3, true));
|
||||||
assert.equal(connected, 1);
|
assert.equal(connected, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("MpvSocketTransport connects and sends payloads over a live socket", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
await withSocketMock(async () => {
|
||||||
|
const transport = new MpvSocketTransport({
|
||||||
|
socketPath: "/tmp/mpv.sock",
|
||||||
|
onConnect: () => {
|
||||||
|
events.push("connect");
|
||||||
|
},
|
||||||
|
onData: () => {
|
||||||
|
events.push("data");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
events.push("error");
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
events.push("close");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: MpvSocketMessagePayload = {
|
||||||
|
command: ["sub-seek", 1],
|
||||||
|
request_id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(transport.send(payload), false);
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
assert.equal(events.includes("connect"), true);
|
||||||
|
assert.equal(transport.send(payload), true);
|
||||||
|
|
||||||
|
const fakeSocket = transport.getSocket() as unknown as FakeSocket;
|
||||||
|
assert.equal(fakeSocket.connectedPaths.at(0), "/tmp/mpv.sock");
|
||||||
|
assert.equal(fakeSocket.writePayloads.length, 1);
|
||||||
|
assert.equal(fakeSocket.writePayloads.at(0), `${JSON.stringify(payload)}\n`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MpvSocketTransport reports lifecycle transitions and callback order", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const fakeError = new Error("boom");
|
||||||
|
|
||||||
|
await withSocketMock(async () => {
|
||||||
|
const transport = new MpvSocketTransport({
|
||||||
|
socketPath: "/tmp/mpv.sock",
|
||||||
|
onConnect: () => {
|
||||||
|
events.push("connect");
|
||||||
|
},
|
||||||
|
onData: () => {
|
||||||
|
events.push("data");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
events.push("error");
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
events.push("close");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const socket = transport.getSocket() as unknown as FakeSocket;
|
||||||
|
socket.emit("error", fakeError);
|
||||||
|
socket.emit("data", Buffer.from("{}"));
|
||||||
|
socket.destroy();
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
assert.equal(events.includes("connect"), true);
|
||||||
|
assert.equal(events.includes("data"), true);
|
||||||
|
assert.equal(events.includes("error"), true);
|
||||||
|
assert.equal(events.includes("close"), true);
|
||||||
|
assert.equal(transport.isConnected, false);
|
||||||
|
assert.equal(transport.isConnecting, false);
|
||||||
|
assert.equal(socket.destroyed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MpvSocketTransport ignores connect requests while already connecting or connected", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
|
||||||
|
await withSocketMock(async () => {
|
||||||
|
const transport = new MpvSocketTransport({
|
||||||
|
socketPath: "/tmp/mpv.sock",
|
||||||
|
onConnect: () => {
|
||||||
|
events.push("connect");
|
||||||
|
},
|
||||||
|
onData: () => {
|
||||||
|
events.push("data");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
events.push("error");
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
events.push("close");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
assert.equal(events.includes("connect"), true);
|
||||||
|
const socket = transport.getSocket() as unknown as FakeSocket;
|
||||||
|
socket.emit("close");
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
assert.equal(events.filter((entry) => entry === "connect").length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => {
|
||||||
|
await withSocketMock(async () => {
|
||||||
|
const transport = new MpvSocketTransport({
|
||||||
|
socketPath: "/tmp/mpv.sock",
|
||||||
|
onConnect: () => {
|
||||||
|
},
|
||||||
|
onData: () => {
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
assert.equal(transport.isConnected, true);
|
||||||
|
|
||||||
|
transport.shutdown();
|
||||||
|
assert.equal(transport.isConnected, false);
|
||||||
|
assert.equal(transport.isConnecting, false);
|
||||||
|
assert.equal(transport.getSocket(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import * as net from "net";
|
||||||
|
|
||||||
export function getMpvReconnectDelay(
|
export function getMpvReconnectDelay(
|
||||||
attempt: number,
|
attempt: number,
|
||||||
hasConnectedOnce: boolean,
|
hasConnectedOnce: boolean,
|
||||||
@@ -52,3 +54,116 @@ export function scheduleMpvReconnect(
|
|||||||
);
|
);
|
||||||
return deps.attempt + 1;
|
return deps.attempt + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MpvSocketMessagePayload {
|
||||||
|
command: unknown[];
|
||||||
|
request_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MpvSocketTransportEvents {
|
||||||
|
onConnect: () => void;
|
||||||
|
onData: (data: Buffer) => void;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpvSocketTransportOptions {
|
||||||
|
socketPath: string;
|
||||||
|
onConnect: () => void;
|
||||||
|
onData: (data: Buffer) => void;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MpvSocketTransport {
|
||||||
|
private socketPath: string;
|
||||||
|
private readonly callbacks: MpvSocketTransportEvents;
|
||||||
|
private socketRef: net.Socket | null = null;
|
||||||
|
public socket: net.Socket | null = null;
|
||||||
|
public connected = false;
|
||||||
|
public connecting = false;
|
||||||
|
|
||||||
|
constructor(options: MpvSocketTransportOptions) {
|
||||||
|
this.socketPath = options.socketPath;
|
||||||
|
this.callbacks = {
|
||||||
|
onConnect: options.onConnect,
|
||||||
|
onData: options.onData,
|
||||||
|
onError: options.onError,
|
||||||
|
onClose: options.onClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocketPath(socketPath: string): void {
|
||||||
|
this.socketPath = socketPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socketRef) {
|
||||||
|
this.socketRef.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connecting = true;
|
||||||
|
this.socketRef = new net.Socket();
|
||||||
|
this.socket = this.socketRef;
|
||||||
|
|
||||||
|
this.socketRef.on("connect", () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.connecting = false;
|
||||||
|
this.callbacks.onConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketRef.on("data", (data: Buffer) => {
|
||||||
|
this.callbacks.onData(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketRef.on("error", (error: Error) => {
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.callbacks.onError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketRef.on("close", () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.callbacks.onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketRef.connect(this.socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload: MpvSocketMessagePayload): boolean {
|
||||||
|
if (!this.connected || !this.socketRef) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.stringify(payload) + "\n";
|
||||||
|
this.socketRef.write(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.socketRef) {
|
||||||
|
this.socketRef.destroy();
|
||||||
|
}
|
||||||
|
this.socketRef = null;
|
||||||
|
this.socket = null;
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocket(): net.Socket | null {
|
||||||
|
return this.socketRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnecting(): boolean {
|
||||||
|
return this.connecting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user