489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { ICastConfig, ICastDeviceInfo, ICastMediaLoadOptions, ICastMediaStatus, ICastReceiverStatus, ICastSnapshot, ICastVolume } from './cast.types.js';
|
|
|
|
const castPort = 8009;
|
|
const httpInfoPort = 8008;
|
|
const defaultMediaReceiverAppId = 'CC1AD845';
|
|
const senderId = 'sender-0';
|
|
const receiverId = 'receiver-0';
|
|
const connectionNamespace = 'urn:x-cast:com.google.cast.tp.connection';
|
|
const heartbeatNamespace = 'urn:x-cast:com.google.cast.tp.heartbeat';
|
|
const receiverNamespace = 'urn:x-cast:com.google.cast.receiver';
|
|
const mediaNamespace = 'urn:x-cast:com.google.cast.media';
|
|
|
|
interface ICastV2Message {
|
|
protocolVersion?: number;
|
|
sourceId?: string;
|
|
destinationId?: string;
|
|
namespace?: string;
|
|
payloadType?: number;
|
|
payloadUtf8?: string;
|
|
}
|
|
|
|
interface ICastJsonPayload extends Record<string, unknown> {
|
|
type?: string;
|
|
requestId?: number;
|
|
}
|
|
|
|
interface IPendingRequest {
|
|
expectedTypes: string[];
|
|
resolve(payloadArg: ICastJsonPayload): void;
|
|
reject(errorArg: Error): void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export class CastClient {
|
|
private channel?: CastV2Channel;
|
|
|
|
constructor(private readonly config: ICastConfig) {}
|
|
|
|
public async getSnapshot(): Promise<ICastSnapshot> {
|
|
const deviceInfo = await this.getDeviceInfo();
|
|
const receiverStatus = await this.getReceiverStatus();
|
|
const mediaStatus = await this.getMediaStatus(receiverStatus);
|
|
return { deviceInfo, receiverStatus, mediaStatus };
|
|
}
|
|
|
|
public async getDeviceInfo(): Promise<ICastDeviceInfo> {
|
|
if (this.config.deviceInfo) {
|
|
return this.config.deviceInfo;
|
|
}
|
|
const payload = await this.requestJson<unknown>('/setup/eureka_info?options=detail');
|
|
return this.mapEurekaInfo(payload);
|
|
}
|
|
|
|
public async getReceiverStatus(): Promise<ICastReceiverStatus> {
|
|
if (this.config.receiverStatus) {
|
|
return this.config.receiverStatus;
|
|
}
|
|
return (await this.getChannel()).getReceiverStatus();
|
|
}
|
|
|
|
public async getMediaStatus(receiverStatusArg?: ICastReceiverStatus): Promise<ICastMediaStatus | undefined> {
|
|
if (this.config.mediaStatus) {
|
|
return this.config.mediaStatus;
|
|
}
|
|
const receiverStatus = receiverStatusArg || await this.getReceiverStatus();
|
|
const app = receiverStatus.applications?.find((appArg) => appArg.transportId);
|
|
if (!app?.transportId) {
|
|
return undefined;
|
|
}
|
|
return (await this.getChannel()).getMediaStatus(app.transportId);
|
|
}
|
|
|
|
public async turnOn(): Promise<void> {
|
|
await this.launchApp(this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId);
|
|
}
|
|
|
|
public async turnOff(): Promise<void> {
|
|
const receiverStatus = await this.getReceiverStatus();
|
|
const app = receiverStatus.applications?.[0];
|
|
if (app?.sessionId) {
|
|
await (await this.getChannel()).stopApp(app.sessionId);
|
|
}
|
|
}
|
|
|
|
public async launchApp(appIdArg: string): Promise<ICastReceiverStatus> {
|
|
return (await this.getChannel()).launchApp(appIdArg);
|
|
}
|
|
|
|
public async loadMedia(optionsArg: ICastMediaLoadOptions): Promise<ICastMediaStatus | undefined> {
|
|
const channel = await this.getChannel();
|
|
const appId = optionsArg.appId || this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId;
|
|
const receiverStatus = await channel.launchApp(appId);
|
|
const app = receiverStatus.applications?.find((appArg) => appArg.appId === appId) || receiverStatus.applications?.[0];
|
|
if (!app?.transportId) {
|
|
throw new Error('Cast receiver did not return an application transport id.');
|
|
}
|
|
return channel.loadMedia(app.transportId, app.sessionId, optionsArg);
|
|
}
|
|
|
|
public async play(): Promise<void> {
|
|
await this.sendMediaCommand('PLAY');
|
|
}
|
|
|
|
public async pause(): Promise<void> {
|
|
await this.sendMediaCommand('PAUSE');
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
await this.sendMediaCommand('STOP');
|
|
}
|
|
|
|
public async seek(positionArg: number): Promise<void> {
|
|
await this.sendMediaCommand('SEEK', { currentTime: positionArg });
|
|
}
|
|
|
|
public async setVolumeLevel(levelArg: number): Promise<void> {
|
|
await (await this.getChannel()).setVolume({ level: Math.max(0, Math.min(1, levelArg)) });
|
|
}
|
|
|
|
public async setMuted(mutedArg: boolean): Promise<void> {
|
|
await (await this.getChannel()).setVolume({ muted: mutedArg });
|
|
}
|
|
|
|
public async stepVolume(deltaArg: number): Promise<void> {
|
|
const status = await this.getReceiverStatus();
|
|
const currentLevel = typeof status.volume?.level === 'number' ? status.volume.level : 0.5;
|
|
await this.setVolumeLevel(currentLevel + deltaArg);
|
|
}
|
|
|
|
public async destroy(): Promise<void> {
|
|
this.channel?.destroy();
|
|
this.channel = undefined;
|
|
}
|
|
|
|
private async sendMediaCommand(typeArg: string, dataArg: Record<string, unknown> = {}): Promise<void> {
|
|
const receiverStatus = await this.getReceiverStatus();
|
|
const app = receiverStatus.applications?.find((appArg) => appArg.transportId);
|
|
if (!app?.transportId) {
|
|
throw new Error('Cast media command requires an active receiver application.');
|
|
}
|
|
const mediaStatus = await this.getMediaStatus(receiverStatus);
|
|
if (typeof mediaStatus?.mediaSessionId !== 'number') {
|
|
throw new Error('Cast media command requires an active media session.');
|
|
}
|
|
await (await this.getChannel()).sendMediaCommand(app.transportId, typeArg, mediaStatus.mediaSessionId, dataArg);
|
|
}
|
|
|
|
private async getChannel(): Promise<CastV2Channel> {
|
|
if (this.channel) {
|
|
return this.channel;
|
|
}
|
|
if (!this.config.host) {
|
|
throw new Error('Cast host is required when fixture data is not provided.');
|
|
}
|
|
const channel = new CastV2Channel(this.config.host, this.config.port || castPort);
|
|
await channel.connect();
|
|
this.channel = channel;
|
|
return channel;
|
|
}
|
|
|
|
private async requestJson<TResult>(pathArg: string): Promise<TResult> {
|
|
if (!this.config.host) {
|
|
throw new Error('Cast host is required when fixture data is not provided.');
|
|
}
|
|
const response = await globalThis.fetch(`${this.httpBaseUrl()}${pathArg}`);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Cast request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
|
}
|
|
return JSON.parse(text) as TResult;
|
|
}
|
|
|
|
private mapEurekaInfo(valueArg: unknown): ICastDeviceInfo {
|
|
const root = this.asRecord(valueArg);
|
|
const deviceInfo = this.asRecord(root.device_info);
|
|
const uuid = this.stripUuid(this.stringValue(deviceInfo.ssdp_udn) || this.stringValue(root.ssdp_udn) || this.config.uuid);
|
|
return {
|
|
uuid,
|
|
friendlyName: this.stringValue(root.name) || this.stringValue(deviceInfo.name) || this.config.host,
|
|
modelName: this.stringValue(deviceInfo.model_name) || this.stringValue(root.model_name),
|
|
manufacturer: this.stringValue(deviceInfo.manufacturer) || 'Google',
|
|
castType: this.stringValue(deviceInfo.cast_type) || this.stringValue(root.cast_type),
|
|
host: this.config.host,
|
|
port: this.config.port || castPort,
|
|
buildVersion: this.stringValue(root.build_version),
|
|
firmwareVersion: this.stringValue(root.cast_build_revision),
|
|
};
|
|
}
|
|
|
|
private httpBaseUrl(): string {
|
|
return `http://${this.config.host}:${this.config.httpPort || httpInfoPort}`;
|
|
}
|
|
|
|
private asRecord(valueArg: unknown): Record<string, unknown> {
|
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : {};
|
|
}
|
|
|
|
private stringValue(valueArg: unknown): string | undefined {
|
|
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
|
}
|
|
|
|
private stripUuid(valueArg?: string): string | undefined {
|
|
return valueArg?.replace(/^uuid:/i, '').replace(/-/g, '').toLowerCase();
|
|
}
|
|
}
|
|
|
|
class CastV2Channel {
|
|
private socket?: plugins.tls.TLSSocket;
|
|
private buffer = Buffer.alloc(0);
|
|
private nextRequestId = 1;
|
|
private readonly pendingRequests = new Map<number, IPendingRequest>();
|
|
|
|
constructor(private readonly host: string, private readonly port: number) {}
|
|
|
|
public async connect(): Promise<void> {
|
|
if (this.socket && !this.socket.destroyed) {
|
|
return;
|
|
}
|
|
this.socket = await new Promise<plugins.tls.TLSSocket>((resolve, reject) => {
|
|
const socket = plugins.tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false, servername: this.host });
|
|
socket.once('secureConnect', () => resolve(socket));
|
|
socket.once('error', reject);
|
|
});
|
|
this.socket.on('data', (chunkArg) => this.handleData(chunkArg));
|
|
this.socket.on('error', (errorArg) => this.rejectPending(errorArg));
|
|
this.socket.on('close', () => this.rejectPending(new Error('Cast socket closed.')));
|
|
await this.connectNamespace(receiverId);
|
|
}
|
|
|
|
public async getReceiverStatus(): Promise<ICastReceiverStatus> {
|
|
const payload = await this.sendReceiverRequest('GET_STATUS', {}, ['RECEIVER_STATUS']);
|
|
return (payload.status || {}) as ICastReceiverStatus;
|
|
}
|
|
|
|
public async launchApp(appIdArg: string): Promise<ICastReceiverStatus> {
|
|
const payload = await this.sendReceiverRequest('LAUNCH', { appId: appIdArg }, ['RECEIVER_STATUS']);
|
|
return (payload.status || {}) as ICastReceiverStatus;
|
|
}
|
|
|
|
public async stopApp(sessionIdArg: string): Promise<void> {
|
|
await this.sendReceiverRequest('STOP', { sessionId: sessionIdArg }, ['RECEIVER_STATUS']);
|
|
}
|
|
|
|
public async setVolume(volumeArg: ICastVolume): Promise<void> {
|
|
await this.sendReceiverRequest('SET_VOLUME', { volume: volumeArg }, ['RECEIVER_STATUS']);
|
|
}
|
|
|
|
public async getMediaStatus(transportIdArg: string): Promise<ICastMediaStatus | undefined> {
|
|
await this.connectNamespace(transportIdArg);
|
|
const payload = await this.sendRequest(transportIdArg, mediaNamespace, { type: 'GET_STATUS' }, ['MEDIA_STATUS']);
|
|
return this.firstMediaStatus(payload);
|
|
}
|
|
|
|
public async loadMedia(transportIdArg: string, sessionIdArg: string | undefined, optionsArg: ICastMediaLoadOptions): Promise<ICastMediaStatus | undefined> {
|
|
await this.connectNamespace(transportIdArg);
|
|
const metadata: Record<string, unknown> = {};
|
|
if (optionsArg.title) {
|
|
metadata.title = optionsArg.title;
|
|
}
|
|
if (optionsArg.subtitle) {
|
|
metadata.subtitle = optionsArg.subtitle;
|
|
}
|
|
if (optionsArg.artist) {
|
|
metadata.artist = optionsArg.artist;
|
|
}
|
|
if (optionsArg.albumName) {
|
|
metadata.albumName = optionsArg.albumName;
|
|
}
|
|
if (optionsArg.imageUrl) {
|
|
metadata.images = [{ url: optionsArg.imageUrl }];
|
|
}
|
|
const media: Record<string, unknown> = {
|
|
contentId: optionsArg.contentId,
|
|
contentType: optionsArg.contentType,
|
|
streamType: optionsArg.streamType || 'BUFFERED',
|
|
};
|
|
if (Object.keys(metadata).length) {
|
|
media.metadata = metadata;
|
|
}
|
|
const payload = await this.sendRequest(transportIdArg, mediaNamespace, {
|
|
type: 'LOAD',
|
|
sessionId: sessionIdArg,
|
|
media,
|
|
autoplay: true,
|
|
currentTime: 0,
|
|
}, ['MEDIA_STATUS']);
|
|
return this.firstMediaStatus(payload);
|
|
}
|
|
|
|
public async sendMediaCommand(transportIdArg: string, typeArg: string, mediaSessionIdArg: number, dataArg: Record<string, unknown>): Promise<void> {
|
|
await this.connectNamespace(transportIdArg);
|
|
await this.sendRequest(transportIdArg, mediaNamespace, { ...dataArg, type: typeArg, mediaSessionId: mediaSessionIdArg }, ['MEDIA_STATUS']);
|
|
}
|
|
|
|
public destroy(): void {
|
|
this.rejectPending(new Error('Cast socket destroyed.'));
|
|
this.socket?.destroy();
|
|
this.socket = undefined;
|
|
}
|
|
|
|
private async sendReceiverRequest(typeArg: string, dataArg: Record<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
|
|
return this.sendRequest(receiverId, receiverNamespace, { ...dataArg, type: typeArg }, expectedTypesArg);
|
|
}
|
|
|
|
private async connectNamespace(destinationIdArg: string): Promise<void> {
|
|
await this.sendMessage(destinationIdArg, connectionNamespace, { type: 'CONNECT' });
|
|
}
|
|
|
|
private async sendRequest(destinationIdArg: string, namespaceArg: string, payloadArg: Record<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
|
|
const requestId = this.nextRequestId++;
|
|
const payload = { ...payloadArg, requestId };
|
|
const promise = new Promise<ICastJsonPayload>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pendingRequests.delete(requestId);
|
|
reject(new Error(`Cast request ${String(payloadArg.type)} timed out.`));
|
|
}, 7000);
|
|
this.pendingRequests.set(requestId, { expectedTypes: expectedTypesArg, resolve, reject, timer });
|
|
});
|
|
await this.sendMessage(destinationIdArg, namespaceArg, payload);
|
|
return promise;
|
|
}
|
|
|
|
private async sendMessage(destinationIdArg: string, namespaceArg: string, payloadArg: Record<string, unknown>): Promise<void> {
|
|
if (!this.socket || this.socket.destroyed) {
|
|
throw new Error('Cast socket is not connected.');
|
|
}
|
|
const message = this.encodeMessage({
|
|
protocolVersion: 0,
|
|
sourceId: senderId,
|
|
destinationId: destinationIdArg,
|
|
namespace: namespaceArg,
|
|
payloadType: 0,
|
|
payloadUtf8: JSON.stringify(payloadArg),
|
|
});
|
|
const frame = Buffer.alloc(4 + message.length);
|
|
frame.writeUInt32BE(message.length, 0);
|
|
message.copy(frame, 4);
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.socket?.write(frame, (errorArg) => errorArg ? reject(errorArg) : resolve());
|
|
});
|
|
}
|
|
|
|
private handleData(chunkArg: Buffer | string): void {
|
|
const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg);
|
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
while (this.buffer.length >= 4) {
|
|
const length = this.buffer.readUInt32BE(0);
|
|
if (this.buffer.length < 4 + length) {
|
|
return;
|
|
}
|
|
const frame = this.buffer.subarray(4, 4 + length);
|
|
this.buffer = this.buffer.subarray(4 + length);
|
|
this.handleMessage(this.decodeMessage(frame));
|
|
}
|
|
}
|
|
|
|
private handleMessage(messageArg: ICastV2Message): void {
|
|
if (!messageArg.payloadUtf8) {
|
|
return;
|
|
}
|
|
const payload = JSON.parse(messageArg.payloadUtf8) as ICastJsonPayload;
|
|
if (messageArg.namespace === heartbeatNamespace && payload.type === 'PING') {
|
|
void this.sendMessage(messageArg.sourceId || receiverId, heartbeatNamespace, { type: 'PONG' });
|
|
return;
|
|
}
|
|
const requestId = typeof payload.requestId === 'number' ? payload.requestId : undefined;
|
|
if (requestId === undefined) {
|
|
return;
|
|
}
|
|
const pending = this.pendingRequests.get(requestId);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
if (payload.type === 'INVALID_REQUEST' || payload.type === 'ERROR') {
|
|
clearTimeout(pending.timer);
|
|
this.pendingRequests.delete(requestId);
|
|
pending.reject(new Error(`Cast request failed: ${messageArg.payloadUtf8}`));
|
|
return;
|
|
}
|
|
if (!pending.expectedTypes.length || pending.expectedTypes.includes(payload.type || '')) {
|
|
clearTimeout(pending.timer);
|
|
this.pendingRequests.delete(requestId);
|
|
pending.resolve(payload);
|
|
}
|
|
}
|
|
|
|
private firstMediaStatus(payloadArg: ICastJsonPayload): ICastMediaStatus | undefined {
|
|
const status = payloadArg.status;
|
|
return Array.isArray(status) ? status[0] as ICastMediaStatus | undefined : undefined;
|
|
}
|
|
|
|
private rejectPending(errorArg: Error): void {
|
|
for (const [requestId, pending] of this.pendingRequests) {
|
|
clearTimeout(pending.timer);
|
|
this.pendingRequests.delete(requestId);
|
|
pending.reject(errorArg);
|
|
}
|
|
}
|
|
|
|
private encodeMessage(messageArg: ICastV2Message): Buffer {
|
|
const chunks = [
|
|
this.encodeVarintField(1, messageArg.protocolVersion || 0),
|
|
this.encodeStringField(2, messageArg.sourceId || senderId),
|
|
this.encodeStringField(3, messageArg.destinationId || receiverId),
|
|
this.encodeStringField(4, messageArg.namespace || receiverNamespace),
|
|
this.encodeVarintField(5, messageArg.payloadType || 0),
|
|
];
|
|
if (messageArg.payloadUtf8 !== undefined) {
|
|
chunks.push(this.encodeStringField(6, messageArg.payloadUtf8));
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
private decodeMessage(bufferArg: Buffer): ICastV2Message {
|
|
const message: ICastV2Message = {};
|
|
let offset = 0;
|
|
while (offset < bufferArg.length) {
|
|
const key = this.decodeVarint(bufferArg, offset);
|
|
offset = key.offset;
|
|
const fieldNumber = key.value >> 3;
|
|
const wireType = key.value & 7;
|
|
if (wireType === 0) {
|
|
const value = this.decodeVarint(bufferArg, offset);
|
|
offset = value.offset;
|
|
if (fieldNumber === 1) {
|
|
message.protocolVersion = value.value;
|
|
} else if (fieldNumber === 5) {
|
|
message.payloadType = value.value;
|
|
}
|
|
continue;
|
|
}
|
|
if (wireType === 2) {
|
|
const length = this.decodeVarint(bufferArg, offset);
|
|
offset = length.offset;
|
|
const value = bufferArg.subarray(offset, offset + length.value).toString('utf8');
|
|
offset += length.value;
|
|
if (fieldNumber === 2) {
|
|
message.sourceId = value;
|
|
} else if (fieldNumber === 3) {
|
|
message.destinationId = value;
|
|
} else if (fieldNumber === 4) {
|
|
message.namespace = value;
|
|
} else if (fieldNumber === 6) {
|
|
message.payloadUtf8 = value;
|
|
}
|
|
continue;
|
|
}
|
|
throw new Error(`Unsupported Cast protobuf wire type: ${wireType}`);
|
|
}
|
|
return message;
|
|
}
|
|
|
|
private encodeVarintField(fieldNumberArg: number, valueArg: number): Buffer {
|
|
return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 0), this.encodeVarint(valueArg)]);
|
|
}
|
|
|
|
private encodeStringField(fieldNumberArg: number, valueArg: string): Buffer {
|
|
const value = Buffer.from(valueArg, 'utf8');
|
|
return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 2), this.encodeVarint(value.length), value]);
|
|
}
|
|
|
|
private encodeVarint(valueArg: number): Buffer {
|
|
const bytes: number[] = [];
|
|
let value = valueArg >>> 0;
|
|
while (value > 127) {
|
|
bytes.push((value & 0x7f) | 0x80);
|
|
value >>>= 7;
|
|
}
|
|
bytes.push(value);
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
private decodeVarint(bufferArg: Buffer, offsetArg: number): { value: number; offset: number } {
|
|
let result = 0;
|
|
let shift = 0;
|
|
let offset = offsetArg;
|
|
while (offset < bufferArg.length) {
|
|
const byte = bufferArg[offset++];
|
|
result |= (byte & 0x7f) << shift;
|
|
if ((byte & 0x80) === 0) {
|
|
return { value: result >>> 0, offset };
|
|
}
|
|
shift += 7;
|
|
}
|
|
throw new Error('Invalid Cast protobuf varint.');
|
|
}
|
|
}
|