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,9 +1,9 @@
import js from "@eslint/js"; import js from '@eslint/js';
import globals from "globals"; import globals from 'globals';
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint';
import { defineConfig } from "eslint/config"; import {defineConfig} from 'eslint/config';
export default defineConfig([ export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } }, {files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], plugins: {js}, extends: ['js/recommended'], languageOptions: {globals: {...globals.browser, ...globals.node}}},
tseslint.configs.recommended, tseslint.configs.recommended
]); ]);

View File

@@ -1,12 +1,15 @@
import PCastApi from "../../src/index"; import PCastApi from '../../src/index';
const pacstUri = "https://pcast-stg.phenixrts.com" const pcastUri = 'https://pcast-stg.phenixrts.com';
const application = { const application = {
id: "phenixrts.com-alex.zinn", id: 'phenixrts.com-alex.zinn',
secret: "AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg" secret: 'AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'
} };
const pcastApi = new PCastApi(pacstUri, application); const pcastApi = PCastApi.create(pcastUri, application);
console.log('pcastApi [%o]', pcastApi);
console.log();
console.log('ChannelsApi [%o]', pcastApi.channels);
const channelsList = await pcastApi.channels.list(); const channelsList = await pcastApi.channels.list();
// const start = hrtime.bigint(); // const start = hrtime.bigint();
@@ -19,7 +22,6 @@ const channelsList = await pcastApi.channels.list();
// console.log(publishingReportCsv); // console.log(publishingReportCsv);
// console.log(`Time taken: ${Number(endPublishing - start) / 1_000_000_000} seconds`); // console.log(`Time taken: ${Number(endPublishing - start) / 1_000_000_000} seconds`);
// const viewingReportCsv = await pcastApi.reporting.generateReport(ReportKind.Viewing, { // const viewingReportCsv = await pcastApi.reporting.generateReport(ReportKind.Viewing, {
// kind: ViewingReportKind.HLS, // kind: ViewingReportKind.HLS,
// start: moment().subtract(1, 'day').toISOString(), // start: moment().subtract(1, 'day').toISOString(),

View File

@@ -1,14 +1,15 @@
{ {
"name": "@techniker-me/pcast-api", "name": "@techniker-me/pcast-api",
"version": "2025.0.10", "version": "2025.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"ci-build": "bun run build", "ci-build": "bun run build",
"format": "prettier --write ./",
"test": "bun test", "test": "bun test",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"prelint": "bun install", "preformat": "bun install",
"format": "prettier --write ./",
"prelint": "bun format",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix", "lint:fix": "eslint src/**/*.ts --fix",
"build:node": "bun build src/index.ts --outdir dist/node --target node --format esm --production", "build:node": "bun build src/index.ts --outdir dist/node --target node --format esm --production",
@@ -21,7 +22,6 @@
"build:dev": "bun run build:node:dev && bun run build:browser:dev && bun run build:types:dev", "build:dev": "bun run build:node:dev && bun run build:browser:dev && bun run build:types:dev",
"prebuild": "bun run clean", "prebuild": "bun run clean",
"build": "bun run build:node && bun run build:browser && bun run build:types", "build": "bun run build:node && bun run build:browser && bun run build:types",
"postbuild": "bash scripts/prepare-package-json.sh",
"postclean": "bun run lint", "postclean": "bun run lint",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"prepublish": "bash scripts/pre-publish.sh" "prepublish": "bash scripts/pre-publish.sh"

View File

@@ -4,10 +4,7 @@ set -o errexit
set -o nounset set -o nounset
set -o pipefail set -o pipefail
echo "Preparing package.json for distributions" echo "Preparing package.json for distribution"
bash scripts/prepare-package-json.sh bash scripts/prepare-package-json.sh
echo "Copying .npmrc to dist/"
cp .npmrc dist/
echo "Pre-publish done" echo "Pre-publish done"

View File

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

View File

@@ -20,16 +20,16 @@ export type Member = {
screenName: string; screenName: string;
role: string; role: string;
streams: [ streams: [
{ {
type: string; type: string;
uri: string; uri: string;
audioState: string; audioState: string;
videoState: string; videoState: string;
} }
], ];
state: string; state: string;
lastUpdate: number; lastUpdate: number;
} };
type GetChannelParams = { type GetChannelParams = {
alias?: string; alias?: string;
@@ -49,31 +49,26 @@ export class ChannelError extends Error {
export class Channels { export class Channels {
private readonly _httpRequests: PCastHttpRequests; private readonly _httpRequests: PCastHttpRequests;
private readonly _channelsByAlias: Map<ChannelAlias, Channel> = new Map(); private readonly _channelsByAlias: Map<ChannelAlias, Channel> = new Map();
private _initialized: Promise<void> | boolean = false;
private constructor(pcastHttpRequests: PCastHttpRequests) { private constructor(pcastHttpRequests: PCastHttpRequests) {
this._httpRequests = pcastHttpRequests; this._httpRequests = pcastHttpRequests;
} this.initialize();
public static async create(pcastHttpRequests: PCastHttpRequests): Promise<Channels> {
const instance = new Channels(pcastHttpRequests);
await instance.initialize();
return instance;
} }
public async createChannel(name: string, description: string, channelOptions: string[] = []): Promise<Channel> { public async createChannel(name: string, description: string, channelOptions: string[] = []): Promise<Channel> {
// Input validation
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new ChannelError('Channel name cannot be empty', 'INVALID_NAME'); throw new ChannelError('Channel name cannot be empty', 'INVALID_NAME');
} }
if (!description || description.trim().length === 0) { if (!description || description.trim().length === 0) {
throw new ChannelError('Channel description cannot be empty', 'INVALID_DESCRIPTION'); throw new ChannelError('Channel description cannot be empty', 'INVALID_DESCRIPTION');
} }
if (!Array.isArray(channelOptions)) { if (!Array.isArray(channelOptions)) {
throw new ChannelError('Channel options must be an array', 'INVALID_OPTIONS'); throw new ChannelError('Channel options must be an array', 'INVALID_OPTIONS');
} }
const createChannelRequestBody = { const createChannel = {
channel: { channel: {
name: name.trim(), name: name.trim(),
alias: name.trim(), alias: name.trim(),
@@ -82,15 +77,14 @@ export class Channels {
} }
}; };
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.PUT, '/channel', { const route = '/channel';
body: JSON.stringify(createChannelRequestBody) const requestOptions = {body: JSON.stringify(createChannel)};
}); const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.PUT, route, requestOptions);
if (!response.channel) { if (!response.channel) {
throw new ChannelError('Invalid response format - missing channel data', 'INVALID_RESPONSE'); throw new ChannelError('Invalid response format - missing channel data', 'INVALID_RESPONSE');
} }
// Update cache with new channel
this._channelsByAlias.set(response.channel.alias, response.channel); this._channelsByAlias.set(response.channel.alias, response.channel);
return response.channel; return response.channel;
@@ -113,7 +107,8 @@ export class Channels {
} }
public async refreshCache(): Promise<void> { 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> { 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'); throw new ChannelError('Either alias or channelId must be provided', 'MISSING_PARAMETER');
} }
if (this._initialized === false) { if (alias && this._channelsByAlias.has(alias)) {
await this.initialize(); return this._channelsByAlias.get(alias);
} else if (this._initialized instanceof Promise) {
await this._initialized;
} }
if (alias) { const channelList = await this.list();
// Check cache first
if (this._channelsByAlias.has(alias)) {
return this._channelsByAlias.get(alias);
}
// Instead of fetching full list, try to fetch single channel if possible return alias ? channelList.find(channel => channel.alias === alias) : channelList.find(channel => channel.channelId === channelId);
// 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);
}
} }
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`); const response = await this._httpRequests.request<string>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}/publishers/count`);
return parseInt(response, 10); return parseInt(response, 10);
} }
public async getChannelMembers(channelId: string): Promise<Member[]> { public async getMembers(channelId: string): Promise<Member[]> {
if (!channelId || channelId.trim().length === 0) { if (!channelId || channelId.trim().length === 0) {
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID'); throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
} }
@@ -173,7 +149,7 @@ export class Channels {
return response.members; return response.members;
} }
public async getChannelMembersByAlias(alias: string): Promise<Member[]> { public async getMembersByChannelAlias(alias: string): Promise<Member[]> {
const channel = await this.get({alias}); const channel = await this.get({alias});
if (!channel) { if (!channel) {
@@ -183,19 +159,22 @@ export class Channels {
return this.getChannelMembers(channel.channelId); return this.getChannelMembers(channel.channelId);
} }
public async deleteChannel(channelId: string): Promise<Channel> { public async delete({channelId, alias}: {channelId?: string; alias?: string}): Promise<Channel> {
return this.delete(channelId); if (!channelId && !alias) {
} throw new ChannelError('Deleting a channel requires either a channelId or alias', 'INVALID_ARGUMENTS');
public async delete(channelId: string): Promise<Channel> {
if (!channelId || channelId.trim().length === 0) {
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
} }
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) { 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 // Remove from cache if it exists
@@ -207,14 +186,8 @@ export class Channels {
return deletedChannel; return deletedChannel;
} }
// Cache management methods
public isInitialized(): boolean {
return this._initialized !== false && !(this._initialized instanceof Promise);
}
public clearCache(): void { public clearCache(): void {
this._channelsByAlias.clear(); this._channelsByAlias.clear();
this._initialized = false;
} }
public getCacheSize(): number { public getCacheSize(): number {
@@ -222,25 +195,18 @@ export class Channels {
} }
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
this._initialized = (async () => { try {
try { const channelsList = await this.list();
const channelsList = await this.list(); if (!channelsList) {
if (!channelsList) { console.warn('[Channels] Failed to initialize cache - no channels returned');
console.warn('[Channels] Failed to initialize cache - no channels returned'); return;
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
} }
})();
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} = {}) { constructor(baseUri: string, applicationCredentials: ApplicationCredentials, options: {requestTimeoutDuration?: number} = {}) {
const credentials = `${applicationCredentials.id}:${applicationCredentials.secret}`; const credentials = `${applicationCredentials.id}:${applicationCredentials.secret}`;
const basic = typeof btoa === 'function' const basic = typeof btoa === 'function' ? btoa(credentials) : Buffer.from(credentials, 'utf-8').toString('base64');
? btoa(credentials)
: Buffer.from(credentials, 'utf-8').toString('base64');
const baseHeaders = new Headers({ const baseHeaders = new Headers({
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -2,7 +2,7 @@ import {HttpMethod} from '../net/http/HttpMethod';
import type {PCastHttpRequests} from './PCastRequests'; import type {PCastHttpRequests} from './PCastRequests';
import assertUnreachable from '../lang/assertUnreachable'; import assertUnreachable from '../lang/assertUnreachable';
import {ReportKind} from './ReportKind'; import {ReportKind} from './ReportKind';
import { ViewingReportKind, ViewingReportKindMapping } from './ViewingReportKind'; import {ViewingReportKind, ViewingReportKindMapping} from './ViewingReportKind';
export type PublishingReportOptions = { export type PublishingReportOptions = {
applicationIds?: string[]; applicationIds?: string[];
@@ -16,7 +16,6 @@ export type PublishingReportOptions = {
end: string; end: string;
}; };
export type ViewingReportOptions = { export type ViewingReportOptions = {
kind: ViewingReportKind; kind: ViewingReportKind;
applicationIds?: string[]; applicationIds?: string[];

View File

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