Update and improve things

This commit is contained in:
Alex Zinn
2025-08-30 20:17:26 -04:00
parent 68e5b87d8b
commit 96dd978d5d
11 changed files with 112 additions and 159 deletions

View File

@@ -1,34 +1,25 @@
import {Channels, Streams, type ApplicationCredentials, PCastHttpRequests, Reporting} from './pcast';
export class PCastApi {
private readonly _pcastUri: string;
private readonly _applicationCredentials: ApplicationCredentials;
private readonly _pcastHttpRequests: PCastHttpRequests;
private readonly _channels: Channels;
private readonly _streams: Streams;
private readonly _reporting: Reporting;
private constructor(pcastUri: string, applicationCredentials: ApplicationCredentials, channels: Channels) {
const normalized = pcastUri.replace(/\/+$/, '');
this._pcastUri = normalized.endsWith('/pcast') ? normalized : `${normalized}/pcast`;
this._applicationCredentials = applicationCredentials;
this._pcastHttpRequests = new PCastHttpRequests(this._pcastUri, this._applicationCredentials);
private constructor(channels: Channels, streams: Streams, reporting: Reporting) {
this._channels = channels;
this._streams = new Streams(this._pcastHttpRequests);
this._reporting = new Reporting(this._pcastHttpRequests);
this._streams = streams;
this._reporting = reporting;
}
public static async create(pcastUri: string, applicationCredentials: ApplicationCredentials): Promise<PCastApi> {
public static create(pcastUri: string, applicationCredentials: ApplicationCredentials): Promise<PCastApi> {
const pcastHttpRequests = new PCastHttpRequests(pcastUri.replace(/\/+$/, '').endsWith('/pcast') ? pcastUri : `${pcastUri}/pcast`, applicationCredentials);
const channels = await Channels.create(pcastHttpRequests);
return new PCastApi(pcastUri, applicationCredentials, channels);
const channels = new Channels(pcastHttpRequests);
const streams = new Streams(pcastHttpRequests);
const reporting = new Reporting(pcastHttpRequests);
return new PCastApi(channels, streams, reporting);
}
get pcastUri(): string {
return this._pcastUri;
}
get channels(): Channels {
return this._channels;
}

View File

@@ -52,7 +52,7 @@ export class HttpRequests {
try {
const requestPath = `${this._baseUri}${path}`;
const response = await fetch(requestPath, options);
if (!response.ok) {

View File

@@ -20,16 +20,16 @@ export type Member = {
screenName: string;
role: string;
streams: [
{
type: string;
uri: string;
audioState: string;
videoState: string;
}
],
{
type: string;
uri: string;
audioState: string;
videoState: string;
}
];
state: string;
lastUpdate: number;
}
};
type GetChannelParams = {
alias?: string;
@@ -49,31 +49,26 @@ export class ChannelError extends Error {
export class Channels {
private readonly _httpRequests: PCastHttpRequests;
private readonly _channelsByAlias: Map<ChannelAlias, Channel> = new Map();
private _initialized: Promise<void> | boolean = false;
private constructor(pcastHttpRequests: PCastHttpRequests) {
this._httpRequests = pcastHttpRequests;
}
public static async create(pcastHttpRequests: PCastHttpRequests): Promise<Channels> {
const instance = new Channels(pcastHttpRequests);
await instance.initialize();
return instance;
this.initialize();
}
public async createChannel(name: string, description: string, channelOptions: string[] = []): Promise<Channel> {
// Input validation
if (!name || name.trim().length === 0) {
throw new ChannelError('Channel name cannot be empty', 'INVALID_NAME');
}
if (!description || description.trim().length === 0) {
throw new ChannelError('Channel description cannot be empty', 'INVALID_DESCRIPTION');
}
if (!Array.isArray(channelOptions)) {
throw new ChannelError('Channel options must be an array', 'INVALID_OPTIONS');
}
const createChannelRequestBody = {
const createChannel = {
channel: {
name: name.trim(),
alias: name.trim(),
@@ -82,15 +77,14 @@ export class Channels {
}
};
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.PUT, '/channel', {
body: JSON.stringify(createChannelRequestBody)
});
const route = '/channel';
const requestOptions = {body: JSON.stringify(createChannel)};
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.PUT, route, requestOptions);
if (!response.channel) {
throw new ChannelError('Invalid response format - missing channel data', 'INVALID_RESPONSE');
}
// Update cache with new channel
this._channelsByAlias.set(response.channel.alias, response.channel);
return response.channel;
@@ -113,7 +107,8 @@ export class Channels {
}
public async refreshCache(): Promise<void> {
await this.list(); // This clears and repopulates the cache
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ignored = await this.list();
}
public async getChannelInfoByAlias(alias: string): Promise<Channel | undefined> {
@@ -125,41 +120,22 @@ export class Channels {
throw new ChannelError('Either alias or channelId must be provided', 'MISSING_PARAMETER');
}
if (this._initialized === false) {
await this.initialize();
} else if (this._initialized instanceof Promise) {
await this._initialized;
if (alias && this._channelsByAlias.has(alias)) {
return this._channelsByAlias.get(alias);
}
if (alias) {
// Check cache first
if (this._channelsByAlias.has(alias)) {
return this._channelsByAlias.get(alias);
}
const channelList = await this.list();
// Instead of fetching full list, try to fetch single channel if possible
// Assuming API has /channel/{alias}, but based on code, it doesn't; fallback to list
const channelList = await this.list();
// Update cache
return channelList.find(channel => channel.alias === alias);
}
if (channelId) {
// Similar fallback
const channelList = await this.list();
return channelList.find(channel => channel.channelId === channelId);
}
return alias ? channelList.find(channel => channel.alias === alias) : channelList.find(channel => channel.channelId === channelId);
}
public async getChannelPublisherCount(channelId: string): Promise<number> {
public async getPublisherCount(channelId: string): Promise<number> {
const response = await this._httpRequests.request<string>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}/publishers/count`);
return parseInt(response, 10);
}
public async getChannelMembers(channelId: string): Promise<Member[]> {
public async getMembers(channelId: string): Promise<Member[]> {
if (!channelId || channelId.trim().length === 0) {
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
}
@@ -173,29 +149,32 @@ export class Channels {
return response.members;
}
public async getChannelMembersByAlias(alias: string): Promise<Member[]> {
public async getMembersByChannelAlias(alias: string): Promise<Member[]> {
const channel = await this.get({alias});
if (!channel) {
throw new ChannelError(`Channel not found: ${alias}`, 'CHANNEL_NOT_FOUND');
}
return this.getChannelMembers(channel.channelId);
}
public async deleteChannel(channelId: string): Promise<Channel> {
return this.delete(channelId);
}
public async delete(channelId: string): Promise<Channel> {
if (!channelId || channelId.trim().length === 0) {
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
public async delete({channelId, alias}: {channelId?: string; alias?: string}): Promise<Channel> {
if (!channelId && !alias) {
throw new ChannelError('Deleting a channel requires either a channelId or alias', 'INVALID_ARGUMENTS');
}
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.DELETE, `/channel/${encodeURIComponent(channelId)}`);
const channelIdToDelete = alias ? (await this.get({alias}))?.channelId : channelId;
if (!channelIdToDelete) {
throw new ChannelError('Unable to find room to delete', 'NOT_FOUND');
}
const route = `/channel/${encodeURIComponent(channelId)}`;
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.DELETE, route);
if (!response.channel) {
throw new ChannelError(`Invalid response format for deleted channel: ${channelId}`, 'INVALID_RESPONSE');
throw new ChannelError(`Invalid response format for deleted channel [${channelId}]`, 'INVALID_RESPONSE');
}
// Remove from cache if it exists
@@ -207,14 +186,8 @@ export class Channels {
return deletedChannel;
}
// Cache management methods
public isInitialized(): boolean {
return this._initialized !== false && !(this._initialized instanceof Promise);
}
public clearCache(): void {
this._channelsByAlias.clear();
this._initialized = false;
}
public getCacheSize(): number {
@@ -222,25 +195,18 @@ export class Channels {
}
private async initialize(): Promise<void> {
this._initialized = (async () => {
try {
const channelsList = await this.list();
if (!channelsList) {
console.warn('[Channels] Failed to initialize cache - no channels returned');
return;
}
for (const channel of channelsList) {
this._channelsByAlias.set(channel.alias, channel);
}
this._initialized = true;
} catch (error) {
console.error('[Channels] Failed to initialize cache:', error);
this._initialized = true; // Mark as initialized even on error to avoid repeated attempts
try {
const channelsList = await this.list();
if (!channelsList) {
console.warn('[Channels] Failed to initialize cache - no channels returned');
return;
}
})();
await this._initialized;
for (const channel of channelsList) {
this._channelsByAlias.set(channel.alias, channel);
}
} catch (error) {
console.error('[Channels] Failed to initialize cache:', error);
}
}
}

View File

@@ -10,9 +10,7 @@ export class PCastHttpRequests extends HttpRequests {
constructor(baseUri: string, applicationCredentials: ApplicationCredentials, options: {requestTimeoutDuration?: number} = {}) {
const credentials = `${applicationCredentials.id}:${applicationCredentials.secret}`;
const basic = typeof btoa === 'function'
? btoa(credentials)
: Buffer.from(credentials, 'utf-8').toString('base64');
const basic = typeof btoa === 'function' ? btoa(credentials) : Buffer.from(credentials, 'utf-8').toString('base64');
const baseHeaders = new Headers({
'Content-Type': 'application/json',

View File

@@ -2,7 +2,7 @@ import {HttpMethod} from '../net/http/HttpMethod';
import type {PCastHttpRequests} from './PCastRequests';
import assertUnreachable from '../lang/assertUnreachable';
import {ReportKind} from './ReportKind';
import { ViewingReportKind, ViewingReportKindMapping } from './ViewingReportKind';
import {ViewingReportKind, ViewingReportKindMapping} from './ViewingReportKind';
export type PublishingReportOptions = {
applicationIds?: string[];
@@ -16,7 +16,6 @@ export type PublishingReportOptions = {
end: string;
};
export type ViewingReportOptions = {
kind: ViewingReportKind;
applicationIds?: string[];
@@ -78,7 +77,7 @@ export class Reporting {
};
const response = await this._httpRequests.request<string>(HttpMethod.PUT, '/reporting/viewing', requestViewingOptions);
return response;
}
}

View File

@@ -1,37 +1,37 @@
import assertUnreachable from "../lang/assertUnreachable";
import assertUnreachable from '../lang/assertUnreachable';
export enum ViewingReportKind {
RealTime = 0,
HLS = 1,
DASH = 2
export enum ViewingReportKind {
RealTime = 0,
HLS = 1,
DASH = 2
}
export type ViewingReportKindType = 'RealTime' | 'HLS' | 'DASH';
export class ViewingReportKindMapping {
public static convertViewingReportKindTypeToViewingReportKind(viewingReportKindType: ViewingReportKindType): ViewingReportKind {
switch (viewingReportKindType) {
case 'RealTime':
return ViewingReportKind.RealTime;
case 'HLS':
return ViewingReportKind.HLS;
case 'DASH':
return ViewingReportKind.DASH;
default:
assertUnreachable(viewingReportKindType);
}
public static convertViewingReportKindTypeToViewingReportKind(viewingReportKindType: ViewingReportKindType): ViewingReportKind {
switch (viewingReportKindType) {
case 'RealTime':
return ViewingReportKind.RealTime;
case 'HLS':
return ViewingReportKind.HLS;
case 'DASH':
return ViewingReportKind.DASH;
default:
assertUnreachable(viewingReportKindType);
}
}
public static convertViewingReportKindToViewingReportKindType(viewingReportKind: ViewingReportKind): ViewingReportKindType {
switch (viewingReportKind) {
case ViewingReportKind.RealTime:
return 'RealTime';
case ViewingReportKind.HLS:
return 'HLS';
case ViewingReportKind.DASH:
return 'DASH';
default:
assertUnreachable(viewingReportKind);
}
public static convertViewingReportKindToViewingReportKindType(viewingReportKind: ViewingReportKind): ViewingReportKindType {
switch (viewingReportKind) {
case ViewingReportKind.RealTime:
return 'RealTime';
case ViewingReportKind.HLS:
return 'HLS';
case ViewingReportKind.DASH:
return 'DASH';
default:
assertUnreachable(viewingReportKind);
}
}
}
}

View File

@@ -3,4 +3,4 @@ export * from './Reporting';
export * from './PCastRequests';
export * from './Stream';
export * from './ReportKind';
export * from './ViewingReportKind';
export * from './ViewingReportKind';