diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 4064171..b24ba68 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/packages/apps/backend/src/api-server/ApiServer.ts b/packages/apps/backend/src/api-server/ApiServer.ts index 744241c..aafdbf1 100644 --- a/packages/apps/backend/src/api-server/ApiServer.ts +++ b/packages/apps/backend/src/api-server/ApiServer.ts @@ -1,4 +1,4 @@ -import { feathers } from '@feathersjs/feathers' +import { RealTimeConnection, feathers } from '@feathersjs/feathers' import { koa, rest, bodyParser, errorHandler, serveStatic, cors } from '@feathersjs/koa' import socketio from '@feathersjs/socketio' import { EventEmitter } from 'eventemitter3' @@ -6,8 +6,12 @@ import { ServiceTypes } from '@sofie-prompter-editor/shared-model' import { LoggerInstance } from '../lib/logger.js' import { PublishChannels } from './PublishChannels.js' import { PlaylistFeathersService, PlaylistService } from './services/PlaylistService.js' +import { RundownFeathersService, RundownService } from './services/RundownService.js' +import { SegmentFeathersService, SegmentService } from './services/SegmentService.js' +import { PartFeathersService, PartService } from './services/PartService.js' import { ExampleFeathersService, ExampleService } from './services/ExampleService.js' import { Store } from '../data-stores/Store.js' +import { SofieCoreConnection } from '../sofie-core-connection/SofieCoreConnection.js' export type ApiServerEvents = { connection: [] @@ -17,10 +21,18 @@ export class ApiServer extends EventEmitter { public initialized: Promise public readonly playlist: PlaylistFeathersService + public readonly rundown: RundownFeathersService + public readonly segment: SegmentFeathersService + public readonly part: PartFeathersService public readonly example: ExampleFeathersService private log: LoggerInstance - constructor(log: LoggerInstance, port: number, private store: Store) { + constructor( + log: LoggerInstance, + port: number, + private store: Store, + private coreConnection: SofieCoreConnection | undefined + ) { super() this.log = log.category('ApiServer') @@ -37,19 +49,25 @@ export class ApiServer extends EventEmitter { this.app.configure(rest()) this.app.configure(socketio({ cors: { origin: '*' } })) // TODO: cors - this.playlist = PlaylistService.setupService(this.app, this.store, this.log) + this.playlist = PlaylistService.setupService(this.log, this.app, this.store) + this.rundown = RundownService.setupService(this.log, this.app, this.store, this.coreConnection) + this.segment = SegmentService.setupService(this.log, this.app, this.store) + this.part = PartService.setupService(this.log, this.app, this.store) + this.example = ExampleService.setupService(this.app) - this.app.on('connection', (connection) => { + this.app.on('connection', (connection: RealTimeConnection) => { // A new client connection has been made this.emit('connection') // Add the connection to the Anything channel: this.app.channel(PublishChannels.Everyone()).join(connection) }) - this.app.on('disconnect', (_connection) => { + this.app.on('disconnect', (connection: RealTimeConnection) => { // A client disconnected. // Note: A disconnected client will leave all channels automatically. + + this.coreConnection?.unsubscribe(connection) }) this.playlist.on('tmpPong', (payload: string) => { diff --git a/packages/apps/backend/src/api-server/PublishChannels.ts b/packages/apps/backend/src/api-server/PublishChannels.ts index bf14b89..10651c8 100644 --- a/packages/apps/backend/src/api-server/PublishChannels.ts +++ b/packages/apps/backend/src/api-server/PublishChannels.ts @@ -1,3 +1,5 @@ +import { RundownPlaylistId } from '@sofie-prompter-editor/shared-model' + /** Definitions of published channels */ export const PublishChannels = { Everyone: (): string => { @@ -11,7 +13,12 @@ export const PublishChannels = { return `playlists` }, - OneSpecificPlaylist: (playlistId: string): string => { - return `playlists/${playlistId}` + /** All info inside one playlist */ + Playlist: (playlistId: RundownPlaylistId): string => { + return `playlist/${playlistId}` + }, + + RundownsInPlaylist: (playlistId: RundownPlaylistId): string => { + return `playlist/${playlistId}/rundowns` }, } diff --git a/packages/apps/backend/src/api-server/services/PartService.ts b/packages/apps/backend/src/api-server/services/PartService.ts new file mode 100644 index 0000000..b0f0d95 --- /dev/null +++ b/packages/apps/backend/src/api-server/services/PartService.ts @@ -0,0 +1,104 @@ +import EventEmitter from 'eventemitter3' +import { Application, PaginationParams, Params } from '@feathersjs/feathers' +import { ServiceTypes, Services, PartServiceDefinition as Definition } from '@sofie-prompter-editor/shared-model' +export { PlaylistServiceDefinition } from '@sofie-prompter-editor/shared-model' +import { PublishChannels } from '../PublishChannels.js' +import { CustomFeathersService } from './lib.js' +import { Store } from '../../data-stores/Store.js' +import { Lambda, observe } from 'mobx' +import { LoggerInstance } from '../../lib/logger.js' +import { NotFound, NotImplemented } from '@feathersjs/errors' + +export type PartFeathersService = CustomFeathersService + +/** The methods exposed by this class are exposed in the API */ +export class PartService extends EventEmitter implements Definition.Service { + static setupService(log: LoggerInstance, app: Application, store: Store): PartFeathersService { + app.use(Services.Part, new PartService(log.category('PartService'), store), { + methods: Definition.ALL_METHODS, + serviceEvents: Definition.ALL_EVENTS, + }) + const service = app.service(Services.Part) as PartFeathersService + this.setupPublications(app, service) + return service + } + private static setupPublications(app: Application, service: PartFeathersService) { + service.publish('created', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + service.publish('updated', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + // service.publish('patched', (data, _context) => { + // return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + // }) + service.publish('removed', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + } + + private observers: Lambda[] = [] + constructor( + private log: LoggerInstance, + + private store: Store + ) { + super() + + this.observers.push( + observe(this.store.parts.parts, (change) => { + this.log.debug('observed change', change) + + if (change.type === 'add') { + this.emit('created', change.newValue) + } else if (change.type === 'update') { + // const patch = diff(change.oldValue, change.newValue) + // if (patch) this.emit('patched', patch) + this.emit('updated', change.newValue) + } else if (change.type === 'delete') { + this.emit('removed', { + _id: change.oldValue._id, + playlistId: change.oldValue.playlistId, + rundownId: change.oldValue.rundownId, + segmentId: change.oldValue.segmentId, + }) + } + }) + ) + } + destroy() { + // dispose of observers: + for (const obs of this.observers) { + obs() + } + } + + public async find(_params?: Params & { paginate?: PaginationParams }): Promise { + return Array.from(this.store.parts.parts.values()) + } + public async get(id: Id, _params?: Params): Promise { + const data = this.store.parts.parts.get(id) + if (!data) throw new NotFound(`Rundown "${id}" not found`) + return data + } + /** @deprecated not supported */ + public async create(_data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + public async update(_id: NullId, _data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + // public async patch(_id: NullId, _data: PatchData, _params?: Params): Promise { + // throw new NotImplemented(`Not supported`) + // } + /** @deprecated not supported */ + public async remove(_id: NullId, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } +} +type Result = Definition.Result +type Id = Definition.Id +type NullId = Definition.NullId +type Data = Definition.Data diff --git a/packages/apps/backend/src/api-server/services/PlaylistService.ts b/packages/apps/backend/src/api-server/services/PlaylistService.ts index 84cbec0..a2aaac6 100644 --- a/packages/apps/backend/src/api-server/services/PlaylistService.ts +++ b/packages/apps/backend/src/api-server/services/PlaylistService.ts @@ -1,25 +1,20 @@ import EventEmitter from 'eventemitter3' import { Application, PaginationParams, Params } from '@feathersjs/feathers' -import { - ServiceTypes, - Services, - PlaylistServiceDefinition as Definition, - diff, -} from '@sofie-prompter-editor/shared-model' +import { ServiceTypes, Services, PlaylistServiceDefinition as Definition } from '@sofie-prompter-editor/shared-model' export { PlaylistServiceDefinition } from '@sofie-prompter-editor/shared-model' import { PublishChannels } from '../PublishChannels.js' import { CustomFeathersService } from './lib.js' import { Store } from '../../data-stores/Store.js' import { Lambda, observe } from 'mobx' import { LoggerInstance } from '../../lib/logger.js' -import { Forbidden, NotFound } from '@feathersjs/errors' +import { NotImplemented, NotFound } from '@feathersjs/errors' export type PlaylistFeathersService = CustomFeathersService /** The methods exposed by this class are exposed in the API */ export class PlaylistService extends EventEmitter implements Definition.Service { - static setupService(app: Application, store: Store, log: LoggerInstance): PlaylistFeathersService { - app.use(Services.Playlist, new PlaylistService(app, store, log.category('PlaylistService')), { + static setupService(log: LoggerInstance, app: Application, store: Store): PlaylistFeathersService { + app.use(Services.Playlist, new PlaylistService(log.category('PlaylistService'), app, store), { methods: Definition.ALL_METHODS, serviceEvents: Definition.ALL_EVENTS, }) @@ -39,28 +34,32 @@ export class PlaylistService extends EventEmitter implements service.publish('updated', (_data, _context) => { return app.channel(PublishChannels.AllPlaylists()) }) - service.publish('patched', (_data, _context) => { - return app.channel(PublishChannels.AllPlaylists()) - }) + // service.publish('patched', (_data, _context) => { + // return app.channel(PublishChannels.AllPlaylists()) + // }) service.publish('removed', (_data, _context) => { return app.channel(PublishChannels.AllPlaylists()) }) + + service.publish('created', (data, _context) => { + return app.channel(PublishChannels.Playlist(data._id)) + }) } private observers: Lambda[] = [] - constructor(private app: Application, private store: Store, private log: LoggerInstance) { + constructor(private log: LoggerInstance, private app: Application, private store: Store) { super() this.observers.push( observe(this.store.playlists.playlists, (change) => { - this.log.info('observed change', change) + this.log.debug('observed change', change) if (change.type === 'add') { this.emit('created', change.newValue) } else if (change.type === 'update') { - const patch = diff(change.oldValue, change.newValue) - if (patch) this.emit('patched', patch) - // this.emit('updated', change.newValue) + // const patch = diff(change.oldValue, change.newValue) + // if (patch) this.emit('patched', patch) + this.emit('updated', change.newValue) } else if (change.type === 'delete') { this.emit('removed', change.oldValue._id) } @@ -82,32 +81,36 @@ export class PlaylistService extends EventEmitter implements if (!data) throw new NotFound(`Playlist "${id}" not found`) return data } + /** @deprecated not supported */ public async create(_data: Data, _params?: Params): Promise { - throw new Forbidden(`Not supported`) + throw new NotImplemented(`Not supported`) // this.store.playlists.create(data) // return this.get(data._id) } + /** @deprecated not supported */ public async update(_id: NullId, _data: Data, _params?: Params): Promise { - throw new Forbidden(`Not supported`) + throw new NotImplemented(`Not supported`) // if (id === null) throw new BadRequest(`id must not be null`) // if (id !== data._id) throw new BadRequest(`Cannot change id of playlist`) // this.store.playlists.update(data) // return this.get(data._id) } - public async patch(_id: NullId, _data: PatchData, _params?: Params): Promise { - throw new Forbidden(`Not supported`) - // if (id === null) throw new BadRequest(`id must not be null`) - // const existing = await this.get(id) - // const newData: RundownPlaylist = { - // ...existing, - // ...data, - // } - // this.store.playlists.update(newData) - // return newData - } + /** @deprecated not supported */ + // public async patch(_id: NullId, _data: PatchData, _params?: Params): Promise { + // throw new NotImplemented(`Not supported`) + // // if (id === null) throw new BadRequest(`id must not be null`) + // // const existing = await this.get(id) + // // const newData: RundownPlaylist = { + // // ...existing, + // // ...data, + // // } + // // this.store.playlists.update(newData) + // // return newData + // } + /** @deprecated not supported */ public async remove(_id: NullId, _params?: Params): Promise { - throw new Forbidden(`Not supported`) + throw new NotImplemented(`Not supported`) // if (id === null) throw new BadRequest(`id must not be null`) // const existing = await this.get(id) // this.store.playlists.remove(id) @@ -122,8 +125,10 @@ export class PlaylistService extends EventEmitter implements public async subscribeToPlaylists(_: unknown, params: Params): Promise { if (!params.connection) throw new Error('No connection!') + this.app.channel(PublishChannels.AllPlaylists()).join(params.connection) } + public async tmpPing(_payload: string): Promise { console.log('got a ping!') setTimeout(() => { @@ -138,4 +143,3 @@ type Result = Definition.Result type Id = Definition.Id type NullId = Definition.NullId type Data = Definition.Data -type PatchData = Definition.PatchData diff --git a/packages/apps/backend/src/api-server/services/RundownService.ts b/packages/apps/backend/src/api-server/services/RundownService.ts new file mode 100644 index 0000000..94e9de4 --- /dev/null +++ b/packages/apps/backend/src/api-server/services/RundownService.ts @@ -0,0 +1,122 @@ +import EventEmitter from 'eventemitter3' +import { Application, PaginationParams, Params } from '@feathersjs/feathers' +import { + ServiceTypes, + Services, + RundownServiceDefinition as Definition, + RundownPlaylistId, +} from '@sofie-prompter-editor/shared-model' +export { PlaylistServiceDefinition } from '@sofie-prompter-editor/shared-model' +import { PublishChannels } from '../PublishChannels.js' +import { CustomFeathersService } from './lib.js' +import { Store } from '../../data-stores/Store.js' +import { Lambda, observe } from 'mobx' +import { LoggerInstance } from '../../lib/logger.js' +import { NotFound, NotImplemented } from '@feathersjs/errors' +import { SofieCoreConnection } from '../../sofie-core-connection/SofieCoreConnection.js' + +export type RundownFeathersService = CustomFeathersService + +/** The methods exposed by this class are exposed in the API */ +export class RundownService extends EventEmitter implements Definition.Service { + static setupService( + log: LoggerInstance, + app: Application, + store: Store, + coreConnection: SofieCoreConnection | undefined + ): RundownFeathersService { + app.use(Services.Rundown, new RundownService(log.category('RundownService'), app, store, coreConnection), { + methods: Definition.ALL_METHODS, + serviceEvents: Definition.ALL_EVENTS, + }) + const service = app.service(Services.Rundown) as RundownFeathersService + this.setupPublications(app, service) + return service + } + private static setupPublications(app: Application, service: RundownFeathersService) { + service.publish('created', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + service.publish('updated', (rundown, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(rundown.playlistId)) + }) + // service.publish('patched', (data, _context) => { + // return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + // }) + service.publish('removed', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + } + + private observers: Lambda[] = [] + constructor( + private log: LoggerInstance, + private app: Application, + private store: Store, + private coreConnection: SofieCoreConnection | undefined + ) { + super() + + this.observers.push( + observe(this.store.rundowns.rundowns, (change) => { + this.log.debug('observed change', change) + + if (change.type === 'add') { + this.emit('created', change.newValue) + } else if (change.type === 'update') { + // const patch = diff(change.oldValue, change.newValue) + // if (patch) this.emit('patched', patch) + this.emit('updated', change.newValue) + } else if (change.type === 'delete') { + this.emit('removed', { _id: change.oldValue._id, playlistId: change.oldValue.playlistId }) + } + }) + ) + } + destroy() { + // dispose of observers: + for (const obs of this.observers) { + obs() + } + } + + public async find(_params?: Params & { paginate?: PaginationParams }): Promise { + return Array.from(this.store.rundowns.rundowns.values()) + } + public async get(id: Id, _params?: Params): Promise { + const data = this.store.rundowns.rundowns.get(id) + if (!data) throw new NotFound(`Rundown "${id}" not found`) + return data + } + /** @deprecated not supported */ + public async create(_data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + public async update(_id: NullId, _data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + // public async patch(_id: NullId, _data: PatchData, _params?: Params): Promise { + // throw new NotImplemented(`Not supported`) + // } + /** @deprecated not supported */ + public async remove(_id: NullId, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + + public async subscribeToRundownsInPlaylist(playlistId: RundownPlaylistId, params: Params): Promise { + if (!params.connection) throw new Error('No connection!') + + this.app.channel(PublishChannels.RundownsInPlaylist(playlistId)).join(params.connection) + + this.coreConnection?.subscribeToPlaylist(params.connection, playlistId) + + // this.app.channel(PublishChannels.SegmentsInRundown(rId)).join(params.connection) + // this.app.channel(PublishChannels.PartsInRundown(rId)).join(params.connection) + } +} +type Result = Definition.Result +type Id = Definition.Id +type NullId = Definition.NullId +type Data = Definition.Data diff --git a/packages/apps/backend/src/api-server/services/SegmentService.ts b/packages/apps/backend/src/api-server/services/SegmentService.ts new file mode 100644 index 0000000..091c1dc --- /dev/null +++ b/packages/apps/backend/src/api-server/services/SegmentService.ts @@ -0,0 +1,99 @@ +import EventEmitter from 'eventemitter3' +import { Application, PaginationParams, Params } from '@feathersjs/feathers' +import { ServiceTypes, Services, SegmentServiceDefinition as Definition } from '@sofie-prompter-editor/shared-model' +export { PlaylistServiceDefinition } from '@sofie-prompter-editor/shared-model' +import { PublishChannels } from '../PublishChannels.js' +import { CustomFeathersService } from './lib.js' +import { Store } from '../../data-stores/Store.js' +import { Lambda, observe } from 'mobx' +import { LoggerInstance } from '../../lib/logger.js' +import { NotFound, NotImplemented } from '@feathersjs/errors' + +export type SegmentFeathersService = CustomFeathersService + +/** The methods exposed by this class are exposed in the API */ +export class SegmentService extends EventEmitter implements Definition.Service { + static setupService(log: LoggerInstance, app: Application, store: Store): SegmentFeathersService { + app.use(Services.Segment, new SegmentService(log.category('SegmentService'), store), { + methods: Definition.ALL_METHODS, + serviceEvents: Definition.ALL_EVENTS, + }) + const service = app.service(Services.Segment) as SegmentFeathersService + this.setupPublications(app, service) + return service + } + private static setupPublications(app: Application, service: SegmentFeathersService) { + service.publish('created', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + service.publish('updated', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + // service.publish('patched', (data, _context) => { + // return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + // }) + service.publish('removed', (data, _context) => { + return app.channel(PublishChannels.RundownsInPlaylist(data.playlistId)) + }) + } + + private observers: Lambda[] = [] + constructor(private log: LoggerInstance, private store: Store) { + super() + + this.observers.push( + observe(this.store.segments.segments, (change) => { + this.log.debug('observed change', change) + + if (change.type === 'add') { + this.emit('created', change.newValue) + } else if (change.type === 'update') { + // const patch = diff(change.oldValue, change.newValue) + // if (patch) this.emit('patched', patch) + this.emit('updated', change.newValue) + } else if (change.type === 'delete') { + this.emit('removed', { + _id: change.oldValue._id, + playlistId: change.oldValue.playlistId, + rundownId: change.oldValue.rundownId, + }) + } + }) + ) + } + destroy() { + // dispose of observers: + for (const obs of this.observers) { + obs() + } + } + + public async find(_params?: Params & { paginate?: PaginationParams }): Promise { + return Array.from(this.store.segments.segments.values()) + } + public async get(id: Id, _params?: Params): Promise { + const data = this.store.segments.segments.get(id) + if (!data) throw new NotFound(`Rundown "${id}" not found`) + return data + } + /** @deprecated not supported */ + public async create(_data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + public async update(_id: NullId, _data: Data, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } + /** @deprecated not supported */ + // public async patch(_id: NullId, _data: PatchData, _params?: Params): Promise { + // throw new NotImplemented(`Not supported`) + // } + /** @deprecated not supported */ + public async remove(_id: NullId, _params?: Params): Promise { + throw new NotImplemented(`Not supported`) + } +} +type Result = Definition.Result +type Id = Definition.Id +type NullId = Definition.NullId +type Data = Definition.Data diff --git a/packages/apps/backend/src/data-stores/PartStore.ts b/packages/apps/backend/src/data-stores/PartStore.ts new file mode 100644 index 0000000..93da42e --- /dev/null +++ b/packages/apps/backend/src/data-stores/PartStore.ts @@ -0,0 +1,37 @@ +import { action, makeAutoObservable, observable } from 'mobx' +import isEqual from 'lodash.isequal' +import { Part, PartId } from 'packages/shared/model/dist' + +export class PartStore { + ready: boolean = false + public readonly parts = observable.map() + + constructor() { + makeAutoObservable(this, { + create: action, + update: action, + remove: action, + }) + } + + create(part: Part) { + this._updateIfChanged(part) + } + update(part: Part) { + this._updateIfChanged(part) + } + remove(partId: PartId) { + this._deleteIfChanged(partId) + } + + private _updateIfChanged(part: Part) { + if (!isEqual(this.parts.get(part._id), part)) { + this.parts.set(part._id, part) + } + } + private _deleteIfChanged(partId: PartId) { + if (this.parts.has(partId)) { + this.parts.delete(partId) + } + } +} diff --git a/packages/apps/backend/src/data-stores/PlaylistStore.ts b/packages/apps/backend/src/data-stores/PlaylistStore.ts index 8b1bd95..fc45df0 100644 --- a/packages/apps/backend/src/data-stores/PlaylistStore.ts +++ b/packages/apps/backend/src/data-stores/PlaylistStore.ts @@ -21,7 +21,6 @@ export class PlaylistStore { this._updateIfChanged(playlist) } remove(playlistId: RundownPlaylistId) { - console.log('a') this._deleteIfChanged(playlistId) } diff --git a/packages/apps/backend/src/data-stores/RundownStore.ts b/packages/apps/backend/src/data-stores/RundownStore.ts new file mode 100644 index 0000000..2ce2b9a --- /dev/null +++ b/packages/apps/backend/src/data-stores/RundownStore.ts @@ -0,0 +1,37 @@ +import { action, makeAutoObservable, observable } from 'mobx' +import isEqual from 'lodash.isequal' +import { Rundown, RundownId } from 'packages/shared/model/dist' + +export class RundownStore { + ready: boolean = false + public readonly rundowns = observable.map() + + constructor() { + makeAutoObservable(this, { + create: action, + update: action, + remove: action, + }) + } + + create(rundown: Rundown) { + this._updateIfChanged(rundown) + } + update(rundown: Rundown) { + this._updateIfChanged(rundown) + } + remove(rundownId: RundownId) { + this._deleteIfChanged(rundownId) + } + + private _updateIfChanged(rundown: Rundown) { + if (!isEqual(this.rundowns.get(rundown._id), rundown)) { + this.rundowns.set(rundown._id, rundown) + } + } + private _deleteIfChanged(rundownId: RundownId) { + if (this.rundowns.has(rundownId)) { + this.rundowns.delete(rundownId) + } + } +} diff --git a/packages/apps/backend/src/data-stores/SegmentStore.ts b/packages/apps/backend/src/data-stores/SegmentStore.ts new file mode 100644 index 0000000..296c87c --- /dev/null +++ b/packages/apps/backend/src/data-stores/SegmentStore.ts @@ -0,0 +1,37 @@ +import { action, makeAutoObservable, observable } from 'mobx' +import isEqual from 'lodash.isequal' +import { Segment, SegmentId } from 'packages/shared/model/dist' + +export class SegmentStore { + ready: boolean = false + public readonly segments = observable.map() + + constructor() { + makeAutoObservable(this, { + create: action, + update: action, + remove: action, + }) + } + + create(segment: Segment) { + this._updateIfChanged(segment) + } + update(segment: Segment) { + this._updateIfChanged(segment) + } + remove(segmentId: SegmentId) { + this._deleteIfChanged(segmentId) + } + + private _updateIfChanged(segment: Segment) { + if (!isEqual(this.segments.get(segment._id), segment)) { + this.segments.set(segment._id, segment) + } + } + private _deleteIfChanged(segmentId: SegmentId) { + if (this.segments.has(segmentId)) { + this.segments.delete(segmentId) + } + } +} diff --git a/packages/apps/backend/src/data-stores/Store.ts b/packages/apps/backend/src/data-stores/Store.ts index 8269d72..8be9599 100644 --- a/packages/apps/backend/src/data-stores/Store.ts +++ b/packages/apps/backend/src/data-stores/Store.ts @@ -1,5 +1,11 @@ +import { PartStore } from './PartStore.js' import { PlaylistStore } from './PlaylistStore.js' +import { RundownStore } from './RundownStore.js' +import { SegmentStore } from './SegmentStore.js' export class Store { public playlists = new PlaylistStore() + public rundowns = new RundownStore() + public segments = new SegmentStore() + public parts = new PartStore() } diff --git a/packages/apps/backend/src/index.ts b/packages/apps/backend/src/index.ts index bb9264f..034cadc 100644 --- a/packages/apps/backend/src/index.ts +++ b/packages/apps/backend/src/index.ts @@ -25,16 +25,9 @@ async function init() { const store = new Store() - const httpAPI = new ApiServer(log, DEFAULT_DEV_API_PORT, store) - - httpAPI.on('connection', () => { - log.info('new connection!') - }) - - await httpAPI.initialized - + let coreConnection: SofieCoreConnection | undefined = undefined if (!options.noCore) { - const coreConnection = new SofieCoreConnection(log, options, processHandler, store) + coreConnection = new SofieCoreConnection(log, options, processHandler, store) coreConnection.on('connected', () => { log.info('Connected to Core') }) @@ -46,6 +39,14 @@ async function init() { log.info('NOT connecting to Core (noCore=true)') } + const httpAPI = new ApiServer(log, DEFAULT_DEV_API_PORT, store, coreConnection) + + httpAPI.on('connection', () => { + log.info('new connection!') + }) + + await httpAPI.initialized + log.info('Backend initialized') } init().catch(log.error) diff --git a/packages/apps/backend/src/sofie-core-connection/SofieCoreConnection.ts b/packages/apps/backend/src/sofie-core-connection/SofieCoreConnection.ts index f1e10db..da56e6a 100644 --- a/packages/apps/backend/src/sofie-core-connection/SofieCoreConnection.ts +++ b/packages/apps/backend/src/sofie-core-connection/SofieCoreConnection.ts @@ -6,6 +6,8 @@ import { JSONBlobStringify, StatusCode, } from '@sofie-automation/server-core-integration' +import { RealTimeConnection } from '@feathersjs/feathers' +import { RundownPlaylistId } from '@sofie-prompter-editor/shared-model' import { PeripheralDeviceCategory, PeripheralDeviceType, @@ -18,6 +20,11 @@ import { Store } from '../data-stores/Store.js' import { DataHandler } from './dataHandlers/DataHandler.js' import { SettingsHandler } from './dataHandlers/SettingsHandler.js' import { RundownPlaylistHandler } from './dataHandlers/RundownPlaylistHandler.js' +import { SubscriberManager } from './SubscriberManager.js' +import { observe } from 'mobx' +import { RundownHandler } from './dataHandlers/RundownHandler.js' +import { SegmentHandler } from './dataHandlers/SegmentHandler.js' +import { PartHandler } from './dataHandlers/PartHandler.js' interface SofieCoreConnectionEvents { connected: [] @@ -34,6 +41,8 @@ export class SofieCoreConnection extends EventEmitter private coreDataHandlers: DataHandler[] = [] + private subscriberManager = new SubscriberManager() + constructor(log: LoggerInstance, options: ConfigOptions, processHandler: ProcessHandler, private store: Store) { super() this.log = log.category('SofieCoreConnection') @@ -78,6 +87,7 @@ export class SofieCoreConnection extends EventEmitter await this.core.init(ddpConfig) await this.setupDataHandlers() + await this.setupCoreSubscriptions() const peripheralDevice = await this.core.getPeripheralDevice() if (!peripheralDevice.studioId) { @@ -106,7 +116,15 @@ export class SofieCoreConnection extends EventEmitter await this.updateCoreStatus() await this.core.destroy() } - setStatus(id: string, status: StatusCode, message: string): void { + public subscribeToPlaylist(connection: RealTimeConnection, playlistId: RundownPlaylistId) { + // Add connection as a subscriber to the playlist: + this.subscriberManager.subscribeToPlaylist(connection, playlistId) + } + public unsubscribe(connection: RealTimeConnection) { + // Remove connection from all subscriptions + this.subscriberManager.unsubscribeFromPlaylists(connection) + } + private setStatus(id: string, status: StatusCode, message: string): void { this.statuses.set(id, { status, message }) this.updateCoreStatus().catch((err) => this.log.error(err)) } @@ -138,9 +156,29 @@ export class SofieCoreConnection extends EventEmitter this.coreDataHandlers.push(new SettingsHandler(this.log, this.core, this.store)) this.coreDataHandlers.push(new RundownPlaylistHandler(this.log, this.core, this.store)) + this.coreDataHandlers.push(new RundownHandler(this.log, this.core, this.store)) + this.coreDataHandlers.push(new SegmentHandler(this.log, this.core, this.store)) + this.coreDataHandlers.push(new PartHandler(this.log, this.core, this.store)) // Wait for all DataHandlers to be initialized: await Promise.all(this.coreDataHandlers.map((handler) => handler.initialized)) + } + /* Maps hash -> Array*/ + private subscriptions: Map[]> = new Map() + private addSubscription(hash: string, subId: Promise): void { + let subs = this.subscriptions.get(hash) + if (!subs) { + subs = [] + this.subscriptions.set(hash, subs) + } + subs.push(subId) + } + + // private activeCoreSubscriptions: Map = new Set() + + private async setupCoreSubscriptions(): Promise { + // We always subscribe to these: + await this.core.autoSubscribe('rundownPlaylists', {}) // this.core.autoSubscribe('peripheralDeviceCommands', this.core.deviceId), @@ -150,6 +188,86 @@ export class SofieCoreConnection extends EventEmitter // this.core.autoSubscribe('rundownPlaylists', {}), + // Subscribe to rundowns in playlists: + observe(this.subscriberManager.playlists, (change) => { + const playlistId = change.name + const subHash = `playlist_${playlistId}` + + if (change.type === 'add') { + this.log.info('Subscribing to playlist ' + playlistId) + + this.addSubscription( + subHash, + this.core.autoSubscribe('rundownPlaylists', { + _id: playlistId, + }) + ) + this.addSubscription(subHash, this.core.autoSubscribe('rundowns', [playlistId], null)) + } else if (change.type === 'update') { + console.log('update ', change.newValue) + // this.emit('updated', change.newValue) + } else if (change.type === 'delete') { + console.log('removed', change.oldValue) + + const subs = this.subscriptions.get(subHash) || [] + + // No one is subscribed to this anymore, so unsubscribe: + subs.forEach((sub) => { + sub.then((subscriptionId) => { + this.core.unsubscribe(subscriptionId) + }) + }) + this.subscriptions.delete(subHash) + } + }) + + observe(this.store.rundowns.rundowns, (change) => { + if (change.type === 'add') { + this.subscriberManager.subscribeToRundown(change.name) + } else if (change.type === 'delete') { + this.subscriberManager.unsubscribeFromRundown(change.name) + } + }) + // Subscribe to all data in rundowns: + observe(this.subscriberManager.rundowns, (change) => { + if (change.type === 'add') { + const rundownId = change.newValue + const subHash = `rundown_${rundownId}` + + this.log.info('Subscribing to rundown ' + rundownId) + + this.addSubscription( + subHash, + this.core.autoSubscribe('segments', { + rundownId: rundownId, + }) + ) + this.addSubscription(subHash, this.core.autoSubscribe('parts', [rundownId])) + this.addSubscription( + subHash, + this.core.autoSubscribe('pieces', { + startRundownId: rundownId, + }) + ) + // } else if (change.type === 'update') { + // console.log('update ', change.newValue) + // this.emit('updated', change.newValue) + } else if (change.type === 'delete') { + const subHash = `rundown_${change.oldValue}` + console.log('removed', change.oldValue) + + const subs = this.subscriptions.get(subHash) || [] + + // No one is subscribed to this anymore, so unsubscribe: + subs.forEach((sub) => { + sub.then((subscriptionId) => { + this.core.unsubscribe(subscriptionId) + }) + }) + this.subscriptions.delete(subHash) + } + }) + this.log.info('Core: Subscriptions are set up!') } } diff --git a/packages/apps/backend/src/sofie-core-connection/SubscriberManager.ts b/packages/apps/backend/src/sofie-core-connection/SubscriberManager.ts new file mode 100644 index 0000000..164ffeb --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/SubscriberManager.ts @@ -0,0 +1,55 @@ +import { action, makeAutoObservable, observable } from 'mobx' +import { RundownId, RundownPlaylistId } from 'packages/shared/model/dist' +import { RealTimeConnection } from '@feathersjs/feathers' + +export class SubscriberManager { + public readonly playlists = observable.map< + RundownPlaylistId, + { + connections: RealTimeConnection[] + } + >() + public readonly rundowns = observable.set() + + constructor() { + makeAutoObservable(this, { + subscribeToPlaylist: action, + unsubscribeFromPlaylists: action, + subscribeToRundown: action, + unsubscribeFromRundown: action, + }) + } + + public subscribeToPlaylist(connection: RealTimeConnection, playlistId: RundownPlaylistId) { + // Add connection to a subscription + let sub = this.playlists.get(playlistId) || { connections: [] } + sub.connections.push(connection) + this.playlists.set(playlistId, sub) + } + public unsubscribeFromPlaylists(connection: RealTimeConnection) { + // Remove connection from all subscriptions + + for (const [playlistId, sub] of this.playlists.entries()) { + let changed = false + const i = sub.connections.indexOf(connection) + if (i !== -1) { + sub.connections.splice(i, 1) + changed = true + } + + if (changed) { + if (sub.connections.length === 0) { + this.playlists.delete(playlistId) + } else { + this.playlists.set(playlistId, sub) + } + } + } + } + public subscribeToRundown(rundownId: RundownId) { + this.rundowns.add(rundownId) + } + public unsubscribeFromRundown(rundownId: RundownId) { + this.rundowns.delete(rundownId) + } +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/PartHandler.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/PartHandler.ts new file mode 100644 index 0000000..c9d3bf9 --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/PartHandler.ts @@ -0,0 +1,95 @@ +import { Collection, CoreConnection, protectString } from '@sofie-automation/server-core-integration' +import { + AnyProtectedString, + RundownId, + Part, + PartId, + SegmentId, + PartDisplayType, +} from '@sofie-prompter-editor/shared-model' +import { LoggerInstance } from '../../lib/logger.js' +import { Store } from '../../data-stores/Store.js' +import * as Core from './tmpCoreDataTypes/index.js' +import { DataHandler } from './DataHandler.js' + +export class PartHandler extends DataHandler { + public initialized: Promise + constructor(log: LoggerInstance, core: CoreConnection, store: Store) { + super(log.category('PartHandler'), core, store) + + this.initialized = Promise.resolve().then(async () => { + const observer = this.core.observe('parts') + observer.added = (id: string) => this.onAdded(protectString(id)) + observer.changed = (id: string) => this.onChanged(protectString(id)) + observer.removed = (id: string) => this.onRemoved(protectString(id)) + this.observers.push(observer) + }) + } + private onAdded(id: Core.PartId): void { + this.log.info('onAdded ' + id) + const part = this.collection.findOne(id) + + if (!part) { + this.store.parts.remove(this.convertId(id)) + } else { + const s = this.convert(part) + if (s.part) this.store.parts.create(s.part) + else this.store.parts.remove(s._id) + } + } + private onChanged(id: Core.PartId): void { + this.log.info('onChanged ' + id) + const part = this.collection.findOne(id) + + if (!part) { + this.store.parts.remove(this.convertId(id)) + } else { + const s = this.convert(part) + if (s.part) this.store.parts.update(s.part) + else this.store.parts.remove(s._id) + } + } + private onRemoved(id: Core.PartId): void { + this.log.info('onRemoved ' + id) + this.store.parts.remove(this.convertId(id)) + } + + private convert(corePart: Core.DBPart): { _id: PartId; part: Part | null } { + const partId = this.convertId(corePart._id) + const rundownId = this.convertId(corePart.rundownId) + const rundown = this.store.rundowns.rundowns.get(rundownId) + if (!rundown) return { _id: partId, part: null } + + return { + _id: partId, + part: { + _id: partId, + + playlistId: rundown?.playlistId, + rundownId: rundownId, + segmentId: this.convertId(corePart.segmentId), + label: corePart.title, + rank: corePart._rank, + + isOnAir: false, // TODO + isNext: false, // TODO + display: { + label: '', // TODO + type: PartDisplayType.FULL, // TODO + }, + }, + } + } + private convertId, B extends AnyProtectedString>(id: A): B { + return id as any + } + + private get collection(): Collection { + const collection = this.core.getCollection('parts') + if (!collection) { + this.log.error('collection "parts" not found!') + throw new Error('collection "parts" not found!') + } + return collection + } +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownHandler.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownHandler.ts new file mode 100644 index 0000000..2c18005 --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownHandler.ts @@ -0,0 +1,67 @@ +import { Collection, CoreConnection, protectString } from '@sofie-automation/server-core-integration' +import { AnyProtectedString, Rundown, RundownId, RundownPlaylistId } from '@sofie-prompter-editor/shared-model' +import { LoggerInstance } from '../../lib/logger.js' +import { Store } from '../../data-stores/Store.js' +import * as Core from './tmpCoreDataTypes/index.js' +import { DataHandler } from './DataHandler.js' + +export class RundownHandler extends DataHandler { + public initialized: Promise + constructor(log: LoggerInstance, core: CoreConnection, store: Store) { + super(log.category('RundownHandler'), core, store) + + this.initialized = Promise.resolve().then(async () => { + const observer = this.core.observe('rundowns') + observer.added = (id: string) => this.onAdded(protectString(id)) + observer.changed = (id: string) => this.onChanged(protectString(id)) + observer.removed = (id: string) => this.onRemoved(protectString(id)) + this.observers.push(observer) + }) + } + private onAdded(id: Core.RundownId): void { + this.log.info('onAdded ' + id) + const rundown = this.collection.findOne(id) + + if (!rundown) { + this.store.rundowns.remove(this.convertId(id)) + } else { + this.store.rundowns.create(this.convert(rundown)) + } + } + private onChanged(id: Core.RundownId): void { + this.log.info('onChanged ' + id) + const rundown = this.collection.findOne(id) + + if (!rundown) { + this.store.rundowns.remove(this.convertId(id)) + } else { + this.store.rundowns.update(this.convert(rundown)) + } + } + private onRemoved(id: Core.RundownId): void { + this.log.info('onRemoved ' + id) + this.store.rundowns.remove(this.convertId(id)) + } + + private convert(coreRundown: Core.DBRundown): Rundown { + return { + _id: this.convertId(coreRundown._id), + + playlistId: this.convertId(coreRundown.playlistId), + label: coreRundown.name, + rank: 0, // todo + } + } + private convertId, B extends AnyProtectedString>(id: A): B { + return id as any + } + + private get collection(): Collection { + const collection = this.core.getCollection('rundowns') + if (!collection) { + this.log.error('collection "rundowns" not found!') + throw new Error('collection "rundowns" not found!') + } + return collection + } +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownPlaylistHandler.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownPlaylistHandler.ts index fba5c1a..5514e84 100644 --- a/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownPlaylistHandler.ts +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/RundownPlaylistHandler.ts @@ -16,8 +16,6 @@ export class RundownPlaylistHandler extends DataHandler { observer.changed = (id: string) => this.onChanged(protectString(id)) observer.removed = (id: string) => this.onRemoved(protectString(id)) this.observers.push(observer) - - await this.core.autoSubscribe('rundownPlaylists', {}) }) } private onAdded(id: Core.RundownPlaylistId): void { @@ -54,6 +52,7 @@ export class RundownPlaylistHandler extends DataHandler { isActive: Boolean(corePlaylist.activationId), rehearsal: Boolean(corePlaylist.rehearsal), startedPlayback: corePlaylist.startedPlayback, + loaded: true, // todo } } private convertId(id: Core.RundownPlaylistId): RundownPlaylistId { @@ -63,8 +62,8 @@ export class RundownPlaylistHandler extends DataHandler { private get collection(): Collection { const collection = this.core.getCollection('rundownPlaylists') if (!collection) { - this.log.error('collection "peripheralDevices" not found!') - throw new Error('collection "peripheralDevices" not found!') + this.log.error('collection "rundownPlaylists" not found!') + throw new Error('collection "rundownPlaylists" not found!') } return collection } diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/SegmentHandler.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/SegmentHandler.ts new file mode 100644 index 0000000..fd6d9aa --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/SegmentHandler.ts @@ -0,0 +1,81 @@ +import { Collection, CoreConnection, protectString } from '@sofie-automation/server-core-integration' +import { AnyProtectedString, RundownId, Segment, SegmentId } from '@sofie-prompter-editor/shared-model' +import { LoggerInstance } from '../../lib/logger.js' +import { Store } from '../../data-stores/Store.js' +import * as Core from './tmpCoreDataTypes/index.js' +import { DataHandler } from './DataHandler.js' + +export class SegmentHandler extends DataHandler { + public initialized: Promise + constructor(log: LoggerInstance, core: CoreConnection, store: Store) { + super(log.category('SegmentHandler'), core, store) + + this.initialized = Promise.resolve().then(async () => { + const observer = this.core.observe('segments') + observer.added = (id: string) => this.onAdded(protectString(id)) + observer.changed = (id: string) => this.onChanged(protectString(id)) + observer.removed = (id: string) => this.onRemoved(protectString(id)) + this.observers.push(observer) + }) + } + private onAdded(id: Core.SegmentId): void { + this.log.info('onAdded ' + id) + const segment = this.collection.findOne(id) + + if (!segment) { + this.store.segments.remove(this.convertId(id)) + } else { + const s = this.convert(segment) + if (s.segment) this.store.segments.create(s.segment) + else this.store.segments.remove(s._id) + } + } + private onChanged(id: Core.SegmentId): void { + this.log.info('onChanged ' + id) + const segment = this.collection.findOne(id) + + if (!segment) { + this.store.segments.remove(this.convertId(id)) + } else { + const s = this.convert(segment) + if (s.segment) this.store.segments.update(s.segment) + else this.store.segments.remove(s._id) + } + } + private onRemoved(id: Core.SegmentId): void { + this.log.info('onRemoved ' + id) + this.store.segments.remove(this.convertId(id)) + } + + private convert(coreSegment: Core.DBSegment): { _id: SegmentId; segment: Segment | null } { + const segmentId = this.convertId(coreSegment._id) + const rundownId = this.convertId(coreSegment.rundownId) + const rundown = this.store.rundowns.rundowns.get(rundownId) + if (!rundown) return { _id: segmentId, segment: null } + + return { + _id: segmentId, + segment: { + _id: segmentId, + + playlistId: rundown?.playlistId, + rundownId: rundownId, + label: coreSegment.name, + rank: coreSegment._rank, + isHidden: coreSegment.isHidden, + }, + } + } + private convertId, B extends AnyProtectedString>(id: A): B { + return id as any + } + + private get collection(): Collection { + const collection = this.core.getCollection('segments') + if (!collection) { + this.log.error('collection "segments" not found!') + throw new Error('collection "segments" not found!') + } + return collection + } +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Notes.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Notes.ts new file mode 100644 index 0000000..240598e --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Notes.ts @@ -0,0 +1,43 @@ +import { NoteSeverity } from '@sofie-automation/blueprints-integration' +import { ITranslatableMessage } from './TranslatableMessage' +import { RundownId, SegmentId, PartId, PieceId } from './Ids' + +export interface INoteBase { + type: NoteSeverity + message: ITranslatableMessage +} + +export interface TrackedNote extends GenericNote { + rank: number + origin: { + name: string + segmentName?: string + rundownId?: RundownId + segmentId?: SegmentId + partId?: PartId + pieceId?: PieceId + } +} + +export interface GenericNote extends INoteBase { + origin: { + name: string + } +} +export interface RundownNote extends INoteBase { + origin: { + name: string + } +} +export interface SegmentNote extends RundownNote { + origin: { + name: string + } +} + +export interface PartNote extends SegmentNote { + origin: { + name: string + pieceId?: PieceId + } +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Part.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Part.ts new file mode 100644 index 0000000..25140e9 --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Part.ts @@ -0,0 +1,38 @@ +import { IBlueprintPartDB, NoteSeverity } from '@sofie-automation/blueprints-integration' +import { ITranslatableMessage } from './TranslatableMessage' +import { ProtectedStringProperties } from '@sofie-automation/server-core-integration' +import { PartId, RundownId, SegmentId } from './Ids' +import { PartNote } from './Notes' + +export interface PartInvalidReason { + message: ITranslatableMessage + severity?: NoteSeverity + color?: string +} + +/** A "Line" in NRK Lingo. */ +export interface DBPart extends ProtectedStringProperties { + _id: PartId + /** Position inside the segment */ + _rank: number + + /** The rundown this line belongs to */ + rundownId: RundownId + segmentId: SegmentId + + /** Holds notes (warnings / errors) thrown by the blueprints during creation */ + notes?: Array + + /** Holds the user-facing explanation for why the part is invalid */ + invalidReason?: PartInvalidReason + + /** Human readable unqiue identifier of the part */ + identifier?: string + + /** A modified expectedDuration with the planned preroll and other timings factored in */ + expectedDurationWithPreroll: number | undefined +} + +export function isPartPlayable(part: DBPart): boolean { + return !part.invalid && !part.floated +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Rundown.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Rundown.ts new file mode 100644 index 0000000..7e4ba40 --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Rundown.ts @@ -0,0 +1,71 @@ +import { IBlueprintRundownDB, Time } from '@sofie-automation/blueprints-integration' +import { ProtectedStringProperties } from '@sofie-automation/server-core-integration' +import { + RundownId, + OrganizationId, + StudioId, + ShowStyleBaseId, + PeripheralDeviceId, + RundownPlaylistId, + ShowStyleVariantId, +} from './Ids' +import { RundownNote } from './Notes' + +export interface RundownImportVersions { + studio: string + showStyleBase: string + showStyleVariant: string + blueprint: string + + core: string +} + +/** This is a very uncomplete mock-up of the Rundown object */ +export interface Rundown + extends ProtectedStringProperties { + _id: RundownId + /** ID of the organization that owns the rundown */ + organizationId: OrganizationId | null + /** The id of the Studio this rundown is in */ + studioId: StudioId + + /** The ShowStyleBase this Rundown uses (its the parent of the showStyleVariant) */ + showStyleBaseId: ShowStyleBaseId + showStyleVariantId: ShowStyleVariantId + /** The peripheral device the rundown originates from */ + peripheralDeviceId?: PeripheralDeviceId + restoredFromSnapshotId?: RundownId + created: Time + modified: Time + + /** Revisions/Versions of various docs that when changed require the user to reimport the rundown */ + importVersions: RundownImportVersions + + status?: string + // There should be something like a Owner user here somewhere? + + /** Is the rundown in an unsynced (has been unpublished from ENPS) state? */ + orphaned?: 'deleted' | 'from-snapshot' | 'manual' + + /** Last sent storyStatus to ingestDevice (MOS) */ + notifiedCurrentPlayingPartExternalId?: string + + /** Holds notes (warnings / errors) thrown by the blueprints during creation, or appended after */ + notes?: Array + + /** External id of the Rundown Playlist to put this rundown in */ + playlistExternalId?: string + /** Whether the end of the rundown marks a commercial break */ + endOfRundownIsShowBreak?: boolean + /** Name (user-facing) of the external NCS this rundown came from */ + externalNRCSName: string + /** The id of the Rundown Playlist this rundown is in */ + playlistId: RundownPlaylistId + /** If the playlistId has ben set manually by a user in Sofie */ + playlistIdIsSetInSofie?: boolean + /** Whenever the baseline (RundownBaselineObjs, RundownBaselineAdLibItems, RundownBaselineAdLibActions) changes, this is changed too */ + baselineModifyHash?: string +} + +/** Note: Use Rundown instead */ +export type DBRundown = Rundown diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Segment.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Segment.ts new file mode 100644 index 0000000..41278eb --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/Segment.ts @@ -0,0 +1,33 @@ +import { IBlueprintSegmentDB } from '@sofie-automation/blueprints-integration' +import { ProtectedStringProperties } from '@sofie-automation/server-core-integration' +import { SegmentId, RundownId } from './Ids' +import { SegmentNote } from './Notes' + +export enum SegmentOrphanedReason { + /** Segment is deleted from the NRCS but we still need it */ + DELETED = 'deleted', + /** Segment should be hidden, but it is still playing */ + HIDDEN = 'hidden', +} + +// TV 2 uses this for the not-yet-contributed MiniShelf +// export const orphanedHiddenSegmentPropertiesToPreserve: MongoFieldSpecifierOnes = {} + +/** A "Title" in NRK Lingo / "Stories" in ENPS Lingo. */ +export interface DBSegment extends ProtectedStringProperties { + _id: SegmentId + /** Position inside rundown */ + _rank: number + /** ID of the source object in the gateway */ + externalId: string + /** Timestamp when the externalData was last modified */ + externalModified: number + /** The rundown this segment belongs to */ + rundownId: RundownId + + /** Is the segment in an unsynced state? */ + orphaned?: SegmentOrphanedReason + + /** Holds notes (warnings / errors) thrown by the blueprints during creation */ + notes?: Array +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/TranslatableMessage.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/TranslatableMessage.ts new file mode 100644 index 0000000..7ea5e46 --- /dev/null +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/TranslatableMessage.ts @@ -0,0 +1,9 @@ +import { ITranslatableMessage as IBlueprintTranslatableMessage } from '@sofie-automation/blueprints-integration' + +/** + * @enum - A translatable message (i18next) + */ +export interface ITranslatableMessage extends IBlueprintTranslatableMessage { + /** namespace used */ + namespaces?: Array +} diff --git a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/index.ts b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/index.ts index c7c68b5..93d3d6e 100644 --- a/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/index.ts +++ b/packages/apps/backend/src/sofie-core-connection/dataHandlers/tmpCoreDataTypes/index.ts @@ -4,4 +4,9 @@ */ export * from './RundownPlaylist' +export * from './Rundown' +export * from './Segment' +export * from './Part' export * from './Ids' + +export { ProtectedString } from '@sofie-automation/server-core-integration' diff --git a/packages/apps/client/src/App.tsx b/packages/apps/client/src/App.tsx index be16cb3..544917f 100644 --- a/packages/apps/client/src/App.tsx +++ b/packages/apps/client/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React from 'react' import './App.css' import { Helmet } from 'react-helmet-async' diff --git a/packages/apps/client/src/TestPlaylist.tsx b/packages/apps/client/src/TestPlaylist.tsx new file mode 100644 index 0000000..fd9bd65 --- /dev/null +++ b/packages/apps/client/src/TestPlaylist.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react' +import { APIConnection } from './api/ApiConnection.ts' +import { Rundown, RundownId, RundownPlaylist } from '@sofie-prompter-editor/shared-model' +import { TestRundown } from './TestRundown.tsx' +import { useApiConnection } from './TestUtil.tsx' + +export const TestPlaylist: React.FC<{ api: APIConnection; playlist: RundownPlaylist }> = ({ api, playlist }) => { + const [ready, setReady] = React.useState(false) + const [rundowns, setRundowns] = React.useState>({}) + + const updateRundowns = React.useCallback((id: RundownId, data: (prev: Rundown | undefined) => Rundown | null) => { + setRundowns((prev) => { + const newData = data(prev[id]) + if (newData === null) { + const d = { ...prev } + delete d[id] + return d + } else { + return { + ...prev, + [id]: newData, + } + } + }) + }, []) + + useApiConnection( + (connected) => { + if (!connected) { + // setConnected(false) + return + } + + // setConnected(true) + + api.rundown + .subscribeToRundownsInPlaylist(playlist._id) + .then(() => { + setReady(true) + }) + .catch(console.error) + + api.rundown.on('created', (data) => { + updateRundowns(data._id, () => data) + }) + api.rundown.on('updated', (data) => { + updateRundowns(data._id, () => data) + }) + api.rundown.on('removed', (data) => { + updateRundowns(data._id, () => null) + }) + + // Also fetch initial list: + api.rundown + .find() + .then((list) => { + console.log('list rundowns', list) + list.forEach((rundown) => updateRundowns(rundown._id, () => rundown)) + }) + .catch(console.error) + }, + api, + [playlist._id] + ) + + return ( +
+

Playlist {playlist.label}

+
Subscription status: {ready ? Ready : Not ready}
+
+ Rundowns: +
+ {Object.values(rundowns).map((rundown) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/apps/client/src/TestPlaylists.tsx b/packages/apps/client/src/TestPlaylists.tsx index 03b951b..79d299f 100644 --- a/packages/apps/client/src/TestPlaylists.tsx +++ b/packages/apps/client/src/TestPlaylists.tsx @@ -1,12 +1,16 @@ import React, { useEffect } from 'react' import { APIConnection } from './api/ApiConnection.ts' import { RundownPlaylist, RundownPlaylistId, patch } from '@sofie-prompter-editor/shared-model' +import { TestPlaylist } from './TestPlaylist.tsx' +import { useApiConnection } from './TestUtil.tsx' export const TestPlaylists: React.FC<{ api: APIConnection }> = ({ api }) => { const [ready, setReady] = React.useState(false) const [connected, setConnected] = React.useState(false) const [playlists, setPlaylists] = React.useState>({}) + const [selectedPlaylist, setSelectedPlaylist] = React.useState(null) + const updatePlaylists = React.useCallback( (id: RundownPlaylistId, data: (prev: RundownPlaylist | undefined) => RundownPlaylist | null) => { setPlaylists((prev) => { @@ -48,23 +52,23 @@ export const TestPlaylists: React.FC<{ api: APIConnection }> = ({ api }) => { api.playlist.on('updated', (data) => { updatePlaylists(data._id, () => data) }) - api.playlist.on('patched', (data) => { - updatePlaylists(data._id, (prev) => { - if (!prev) { - // We need to do a resync: - api.playlist - .get(data._id) - .then((playlist) => { - updatePlaylists(playlist._id, () => playlist) - }) - .catch(console.error) + // api.playlist.on('patched', (data) => { + // updatePlaylists(data._id, (prev) => { + // if (!prev) { + // // We need to do a resync: + // api.playlist + // .get(data._id) + // .then((playlist) => { + // updatePlaylists(playlist._id, () => playlist) + // }) + // .catch(console.error) - return patch({} as any, data) - } else { - return patch(prev, data) - } - }) - }) + // return patch({} as any, data) + // } else { + // return patch(prev, data) + // } + // }) + // }) api.playlist.on('removed', (id) => { updatePlaylists(id, () => null) }) @@ -73,7 +77,7 @@ export const TestPlaylists: React.FC<{ api: APIConnection }> = ({ api }) => { api.playlist .find() .then((list) => { - console.log('list', list) + console.log('list playlists', list) list.forEach((playlist) => updatePlaylists(playlist._id, () => playlist)) }) .catch(console.error) @@ -84,9 +88,9 @@ export const TestPlaylists: React.FC<{ api: APIConnection }> = ({ api }) => { return (
+
Connection status: {connected ? Connected : Not connected}
+
Subscription status: {ready ? Ready : Not ready}

Rundown playlists

-
Connection status: {connected ?
Connected
:
Not connected
}
-
Subscription status: {ready ?
Ready
:
Not ready
}
@@ -104,38 +108,15 @@ export const TestPlaylists: React.FC<{ api: APIConnection }> = ({ api }) => { + ))}
{playlist.modified} {playlist.isActive ? 'yes' : 'no'} {playlist.rehearsal ? 'yes' : 'no'} + +
+
{selectedPlaylist && }
) } - -function useApiConnection( - effect: (connected: boolean) => void, - api: APIConnection, - deps?: React.DependencyList | undefined -): void { - const [connected, setConnected] = React.useState(api.connected) - - useEffect(() => { - const onConnected = () => { - setConnected(true) - } - const onDisconnected = () => { - setConnected(false) - } - api.on('connected', onConnected) - api.on('disconnected', onDisconnected) - return () => { - api.off('connected', onConnected) - api.off('disconnected', onDisconnected) - } - }, []) - - useEffect(() => { - effect(connected) - }, [connected, ...(deps || [])]) -} diff --git a/packages/apps/client/src/TestRundown.tsx b/packages/apps/client/src/TestRundown.tsx new file mode 100644 index 0000000..63cac35 --- /dev/null +++ b/packages/apps/client/src/TestRundown.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { APIConnection } from './api/ApiConnection.ts' +import { Rundown, Segment, SegmentId } from '@sofie-prompter-editor/shared-model' +import { useApiConnection } from './TestUtil.tsx' +import { TestSegment } from './TestSegment.tsx' + +export const TestRundown: React.FC<{ api: APIConnection; rundown: Rundown }> = ({ api, rundown }) => { + const [segments, setSegments] = React.useState>({}) + + const updateSegments = React.useCallback((id: SegmentId, data: (prev: Segment | undefined) => Segment | null) => { + setSegments((prev) => { + const newData = data(prev[id]) + if (newData === null) { + const d = { ...prev } + delete d[id] + return d + } else { + return { + ...prev, + [id]: newData, + } + } + }) + }, []) + + useApiConnection( + (connected) => { + if (!connected) return + + api.segment.on('created', (data) => { + if (data.rundownId !== rundown._id) return + updateSegments(data._id, () => data) + }) + api.segment.on('updated', (data) => { + if (data.rundownId !== rundown._id) return + updateSegments(data._id, () => data) + }) + api.segment.on('removed', (data) => { + if (data.rundownId !== rundown._id) return + updateSegments(data._id, () => null) + }) + + // Also fetch initial list: + api.segment + .find({ + query: { + rundownId: rundown._id, + }, + }) + .then((list) => { + console.log('list segment', list) + list + .filter((segment) => segment.rundownId === rundown._id) + .forEach((segment) => updateSegments(segment._id, () => segment)) + }) + .catch(console.error) + }, + api, + [rundown._id] + ) + + const sortedSegments = React.useMemo(() => { + return Object.values(segments).sort((a, b) => a.rank - b.rank) + }, [segments]) + + return ( +
+

Rundown "{rundown.label}"

+
+ Segments: +
+ {sortedSegments.map((segment) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/apps/client/src/TestSegment.tsx b/packages/apps/client/src/TestSegment.tsx new file mode 100644 index 0000000..d8b4a4c --- /dev/null +++ b/packages/apps/client/src/TestSegment.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { APIConnection } from './api/ApiConnection.ts' +import { Segment, Part, PartId } from '@sofie-prompter-editor/shared-model' +import { useApiConnection } from './TestUtil.tsx' + +export const TestSegment: React.FC<{ api: APIConnection; segment: Segment }> = ({ api, segment }) => { + const [parts, setParts] = React.useState>({}) + + const updateParts = React.useCallback((id: PartId, data: (prev: Part | undefined) => Part | null) => { + setParts((prev) => { + const newData = data(prev[id]) + if (newData === null) { + const d = { ...prev } + delete d[id] + return d + } else { + return { + ...prev, + [id]: newData, + } + } + }) + }, []) + + useApiConnection( + (connected) => { + if (!connected) return + + api.part.on('created', (data) => { + if (data.segmentId !== segment._id) return + updateParts(data._id, () => data) + }) + api.part.on('updated', (data) => { + if (data.segmentId !== segment._id) return + updateParts(data._id, () => data) + }) + api.part.on('removed', (data) => { + if (data.segmentId !== segment._id) return + updateParts(data._id, () => null) + }) + + // Also fetch initial list: + api.part + .find({ + query: { + segmentId: segment._id, + }, + }) + .then((list) => { + console.log('list part', list) + list.filter((part) => part.segmentId === segment._id).forEach((part) => updateParts(part._id, () => part)) + }) + .catch(console.error) + }, + api, + [segment._id] + ) + + const sortedParts = React.useMemo(() => { + return Object.values(parts).sort((a, b) => a.rank - b.rank) + }, [parts]) + + return ( +
+

Segment "{segment.label}"

+
+ Parts: +
+ {sortedParts.map((part) => ( +
Part "{part.label}"
+ // + ))} +
+
+
+ ) +} diff --git a/packages/apps/client/src/TestUtil.tsx b/packages/apps/client/src/TestUtil.tsx new file mode 100644 index 0000000..90f4635 --- /dev/null +++ b/packages/apps/client/src/TestUtil.tsx @@ -0,0 +1,29 @@ +import React, { useEffect } from 'react' +import { APIConnection } from './api/ApiConnection.ts' + +export function useApiConnection( + effect: (connected: boolean) => void, + api: APIConnection, + deps?: React.DependencyList | undefined +): void { + const [connected, setConnected] = React.useState(api.connected) + + useEffect(() => { + const onConnected = () => { + setConnected(true) + } + const onDisconnected = () => { + setConnected(false) + } + api.on('connected', onConnected) + api.on('disconnected', onDisconnected) + return () => { + api.off('connected', onConnected) + api.off('disconnected', onDisconnected) + } + }, []) + + useEffect(() => { + effect(connected) + }, [connected, ...(deps || [])]) +} diff --git a/packages/apps/client/src/api/ApiConnection.ts b/packages/apps/client/src/api/ApiConnection.ts index 8255804..11f5285 100644 --- a/packages/apps/client/src/api/ApiConnection.ts +++ b/packages/apps/client/src/api/ApiConnection.ts @@ -5,6 +5,9 @@ import socketio, { SocketService } from '@feathersjs/socketio-client' import { ExampleServiceDefinition, PlaylistServiceDefinition, + RundownServiceDefinition, + SegmentServiceDefinition, + PartServiceDefinition, ServiceTypes, Services, } from '@sofie-prompter-editor/shared-model' @@ -17,11 +20,15 @@ interface APIConnectionEvents { } export class APIConnection extends EventEmitter { - private app: Application, unknown> - public readonly playlist: FeathersTypedService + public readonly rundown: FeathersTypedService + public readonly segment: FeathersTypedService + public readonly part: FeathersTypedService public readonly example: FeathersTypedService public connected = false + + private app: Application, unknown> + constructor() { super() console.log('setupAPIConnection') @@ -51,6 +58,33 @@ export class APIConnection extends EventEmitter { ) this.playlist = this.app.service(Services.Playlist) as FeathersTypedService } + { + this.app.use( + Services.Rundown, + socketClient.service(Services.Rundown) as SocketService & ServiceTypes[Services.Rundown], + { + methods: RundownServiceDefinition.ALL_METHODS, + } + ) + this.rundown = this.app.service(Services.Rundown) as FeathersTypedService + } + { + this.app.use( + Services.Segment, + socketClient.service(Services.Segment) as SocketService & ServiceTypes[Services.Segment], + { + methods: SegmentServiceDefinition.ALL_METHODS, + } + ) + this.segment = this.app.service(Services.Segment) as FeathersTypedService + } + { + this.app.use(Services.Part, socketClient.service(Services.Part) as SocketService & ServiceTypes[Services.Part], { + methods: PartServiceDefinition.ALL_METHODS, + }) + this.part = this.app.service(Services.Part) as FeathersTypedService + } + { this.app.use( Services.Example, diff --git a/packages/apps/client/src/components/SplitPanel/SplitPanel.tsx b/packages/apps/client/src/components/SplitPanel/SplitPanel.tsx index 16ef111..66316aa 100644 --- a/packages/apps/client/src/components/SplitPanel/SplitPanel.tsx +++ b/packages/apps/client/src/components/SplitPanel/SplitPanel.tsx @@ -44,7 +44,7 @@ export function SplitPanel({ if (!beginCoords.current || !contRect.current) return const diffX = (e.clientX - beginCoords.current.x) / contRect.current.width - const diffY = (e.clientY - beginCoords.current.y) / contRect.current.height + // const diffY = (e.clientY - beginCoords.current.y) / contRect.current.height const newValue = Math.max(0, Math.min(1, initialPos.current + diffX)) @@ -55,7 +55,7 @@ export function SplitPanel({ e.preventDefault() } - function onMouseUp(e: MouseEvent) { + function onMouseUp(_e: MouseEvent) { setIsResizing(false) } diff --git a/packages/apps/client/src/mocks/mockConnection.ts b/packages/apps/client/src/mocks/mockConnection.ts index e6655a3..9f9d01c 100644 --- a/packages/apps/client/src/mocks/mockConnection.ts +++ b/packages/apps/client/src/mocks/mockConnection.ts @@ -46,6 +46,7 @@ export class MockConnection extends EventEmitter { modified: START_TIME, rehearsal: false, startedPlayback: START_TIME, + loaded: true, }, { _id: PLAYLIST_ID_1, @@ -55,6 +56,7 @@ export class MockConnection extends EventEmitter { modified: START_TIME, rehearsal: false, startedPlayback: undefined, + loaded: true, }, ] @@ -80,6 +82,7 @@ export class MockConnection extends EventEmitter { _id: RUNDOWN_ID_0_0, playlistId: PLAYLIST_ID_0, rank: 0, + label: 'Rundown 0', }, ] diff --git a/packages/apps/client/src/model/UIRundown.ts b/packages/apps/client/src/model/UIRundown.ts index 0a768ab..986f5b6 100644 --- a/packages/apps/client/src/model/UIRundown.ts +++ b/packages/apps/client/src/model/UIRundown.ts @@ -81,9 +81,9 @@ export class UIRundown { }) // we track rundown created, changed and removed, because we own Rundowns - this.store.connection.rundown.on('created', (json: Rundown) => {}) + this.store.connection.rundown.on('created', (_json: Rundown) => {}) - this.store.connection.rundown.on('changed', (json: Rundown) => {}) + this.store.connection.rundown.on('changed', (_json: Rundown) => {}) this.store.connection.rundown.on('removed', (id: RundownId) => { this.rundowns.delete(id) diff --git a/packages/apps/client/vite.config.ts b/packages/apps/client/vite.config.ts index eea7d47..e69de29 100644 --- a/packages/apps/client/vite.config.ts +++ b/packages/apps/client/vite.config.ts @@ -1,14 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -// import path from 'path' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - resolve: { - preserveSymlinks: true, - }, - optimizeDeps: { - include: ['packages/shared/*/dist/*'], - }, -}) diff --git a/packages/shared/model/src/ProtectedString.ts b/packages/shared/model/src/ProtectedString.ts index 3e17d2a..8cba68d 100644 --- a/packages/shared/model/src/ProtectedString.ts +++ b/packages/shared/model/src/ProtectedString.ts @@ -71,3 +71,7 @@ export function mapToRecord(map: Map): Re } return obj } + +export type ProtectedStringProperties = { + [P in keyof T]: P extends K ? AnyProtectedString : T[P] +} diff --git a/packages/shared/model/src/client-server-api/PartService.ts b/packages/shared/model/src/client-server-api/PartService.ts new file mode 100644 index 0000000..ac7b4de --- /dev/null +++ b/packages/shared/model/src/client-server-api/PartService.ts @@ -0,0 +1,83 @@ +import { + PaginationParams, + Params, + ServiceMethods, + EventEmitter, + assertConstIsValid, + assertConstIncludesAllMethods, +} from './lib.js' +import { Part } from '../model.js' +import { Diff } from '../patch.js' + +/** List of all method names */ +export const ALL_METHODS = [ + 'find', + 'get', + 'create', + 'update', + // 'patch', + 'remove', + // +] as const +/** The methods exposed by this class are exposed in the API */ +interface Methods extends Omit { + find(params?: Params & { paginate?: PaginationParams }): Promise + get(id: Id, params?: Params): Promise + /** @deprecated not supported */ + create(data: Data, params?: Params): Promise + /** @deprecated not supported */ + update(id: NullId, data: Data, params?: Params): Promise + /** @deprecated not supported */ + // patch(id: NullId, data: PatchData, params?: Params): Promise + /** @deprecated not supported */ + remove(id: NullId, params?: Params): Promise +} +export interface Service extends Methods, EventEmitter {} + +/** List of all event names */ +export const ALL_EVENTS = [ + 'created', + 'updated', + // 'patched', + 'removed', + // +] as const + +/** Definitions of all events */ +export interface Events { + created: [data: Data] + updated: [data: Data] + // patched: [data: PatchData] + removed: [data: RemovedData] + // +} + +// Helper types for the default service methods: +export type Data = Part +export type PatchData = Omit, 'playlistId'> & Pick +export type RemovedData = { + _id: Id + playlistId: Data['playlistId'] + rundownId: Data['rundownId'] + segmentId: Data['segmentId'] +} +export type Result = Pick +export type Id = Data['_id'] +export type NullId = Id | null + +// ============================================================================ +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckMethods(methods: keyof Omit) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_METHODS) + assertConstIncludesAllMethods(methods) +} +typeCheckMethods('' as any) + +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckEvents(events: keyof Events) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_EVENTS) + assertConstIncludesAllMethods(events) +} +typeCheckEvents('' as any) diff --git a/packages/shared/model/src/client-server-api/PlaylistService.ts b/packages/shared/model/src/client-server-api/PlaylistService.ts index da33847..6c4e9ed 100644 --- a/packages/shared/model/src/client-server-api/PlaylistService.ts +++ b/packages/shared/model/src/client-server-api/PlaylistService.ts @@ -15,23 +15,30 @@ export const ALL_METHODS = [ 'get', 'create', 'update', - 'patch', + // 'patch', 'remove', // 'subscribeToPlaylists', + // 'tmpPing', ] as const /** The methods exposed by this class are exposed in the API */ -interface Methods extends ServiceMethods { +interface Methods extends Omit { find(params?: Params & { paginate?: PaginationParams }): Promise get(id: Id, params?: Params): Promise + /** @deprecated not supported */ create(data: Data, params?: Params): Promise + /** @deprecated not supported */ update(id: NullId, data: Data, params?: Params): Promise - patch(id: NullId, data: PatchData, params?: Params): Promise + /** @deprecated not supported */ + // patch(id: NullId, data: PatchData, params?: Params): Promise + /** @deprecated not supported */ remove(id: NullId, params?: Params): Promise - // + + /** Subscribe to a list of all playlists */ subscribeToPlaylists(_?: unknown, params?: Params): Promise + // tmpPing(payload: string): Promise } @@ -41,10 +48,9 @@ export interface Service extends Methods, EventEmitter {} export const ALL_EVENTS = [ 'created', 'updated', - 'patched', + // 'patched', 'removed', // - 'updated', 'tmpPong', ] as const @@ -52,7 +58,7 @@ export const ALL_EVENTS = [ export interface Events { created: [data: Data] updated: [data: Data] - patched: [data: PatchData] + // patched: [data: PatchData] removed: [id: Id] // tmpPong: [payload: string] diff --git a/packages/shared/model/src/client-server-api/RundownService.ts b/packages/shared/model/src/client-server-api/RundownService.ts new file mode 100644 index 0000000..02c432d --- /dev/null +++ b/packages/shared/model/src/client-server-api/RundownService.ts @@ -0,0 +1,82 @@ +import { + PaginationParams, + Params, + ServiceMethods, + EventEmitter, + assertConstIsValid, + assertConstIncludesAllMethods, +} from './lib.js' +import { Rundown, RundownPlaylistId } from '../model.js' +import { Diff } from '../patch.js' + +/** List of all method names */ +export const ALL_METHODS = [ + 'find', + 'get', + 'create', + 'update', + // 'patch', + 'remove', + // + 'subscribeToRundownsInPlaylist', +] as const +/** The methods exposed by this class are exposed in the API */ +interface Methods extends Omit { + find(params?: Params & { paginate?: PaginationParams }): Promise + get(id: Id, params?: Params): Promise + /** @deprecated not supported */ + create(data: Data, params?: Params): Promise + /** @deprecated not supported */ + update(id: NullId, data: Data, params?: Params): Promise + /** @deprecated not supported */ + // patch(id: NullId, data: PatchData, params?: Params): Promise + /** @deprecated not supported */ + remove(id: NullId, params?: Params): Promise + + /** Subscribe to all info within a specific playlist */ + subscribeToRundownsInPlaylist(playlistId: RundownPlaylistId, params?: Params): Promise +} +export interface Service extends Methods, EventEmitter {} + +/** List of all event names */ +export const ALL_EVENTS = [ + 'created', + 'updated', + // 'patched', + 'removed', + // +] as const + +/** Definitions of all events */ +export interface Events { + created: [data: Data] + updated: [data: Data] + // patched: [data: PatchData] + removed: [data: RemovedData] + // +} + +// Helper types for the default service methods: +export type Data = Rundown +export type PatchData = Omit, 'playlistId'> & Pick +export type RemovedData = { _id: Id; playlistId: Data['playlistId'] } +export type Result = Pick +export type Id = Data['_id'] +export type NullId = Id | null + +// ============================================================================ +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckMethods(methods: keyof Omit) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_METHODS) + assertConstIncludesAllMethods(methods) +} +typeCheckMethods('' as any) + +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckEvents(events: keyof Events) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_EVENTS) + assertConstIncludesAllMethods(events) +} +typeCheckEvents('' as any) diff --git a/packages/shared/model/src/client-server-api/SegmentService.ts b/packages/shared/model/src/client-server-api/SegmentService.ts new file mode 100644 index 0000000..85b7300 --- /dev/null +++ b/packages/shared/model/src/client-server-api/SegmentService.ts @@ -0,0 +1,78 @@ +import { + PaginationParams, + Params, + ServiceMethods, + EventEmitter, + assertConstIsValid, + assertConstIncludesAllMethods, +} from './lib.js' +import { Segment } from '../model.js' +import { Diff } from '../patch.js' + +/** List of all method names */ +export const ALL_METHODS = [ + 'find', + 'get', + 'create', + 'update', + // 'patch', + 'remove', + // +] as const +/** The methods exposed by this class are exposed in the API */ +interface Methods extends Omit { + find(params?: Params & { paginate?: PaginationParams }): Promise + get(id: Id, params?: Params): Promise + /** @deprecated not supported */ + create(data: Data, params?: Params): Promise + /** @deprecated not supported */ + update(id: NullId, data: Data, params?: Params): Promise + /** @deprecated not supported */ + // patch(id: NullId, data: PatchData, params?: Params): Promise + /** @deprecated not supported */ + remove(id: NullId, params?: Params): Promise +} +export interface Service extends Methods, EventEmitter {} + +/** List of all event names */ +export const ALL_EVENTS = [ + 'created', + 'updated', + // 'patched', + 'removed', + // +] as const + +/** Definitions of all events */ +export interface Events { + created: [data: Data] + updated: [data: Data] + // patched: [data: PatchData] + removed: [data: RemovedData] + // +} + +// Helper types for the default service methods: +export type Data = Segment +export type PatchData = Omit, 'playlistId'> & Pick +export type RemovedData = { _id: Id; playlistId: Data['playlistId']; rundownId: Data['rundownId'] } +export type Result = Pick +export type Id = Data['_id'] +export type NullId = Id | null + +// ============================================================================ +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckMethods(methods: keyof Omit) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_METHODS) + assertConstIncludesAllMethods(methods) +} +typeCheckMethods('' as any) + +// Type check: ensure that Methods and ALL_METHODS are in sync: +function typeCheckEvents(events: keyof Events) { + // This does nothing, but is a type check: + assertConstIsValid(ALL_EVENTS) + assertConstIncludesAllMethods(events) +} +typeCheckEvents('' as any) diff --git a/packages/shared/model/src/client-server-api/index.ts b/packages/shared/model/src/client-server-api/index.ts index feeb31f..9f6b775 100644 --- a/packages/shared/model/src/client-server-api/index.ts +++ b/packages/shared/model/src/client-server-api/index.ts @@ -1,13 +1,28 @@ import * as PlaylistServiceDefinition from './PlaylistService.js' +import * as RundownServiceDefinition from './RundownService.js' +import * as SegmentServiceDefinition from './SegmentService.js' +import * as PartServiceDefinition from './PartService.js' import * as ExampleServiceDefinition from './ExampleService.js' -export { PlaylistServiceDefinition, ExampleServiceDefinition } +export { + PlaylistServiceDefinition, + ExampleServiceDefinition, + RundownServiceDefinition, + SegmentServiceDefinition, + PartServiceDefinition, +} export enum Services { Example = 'example', Playlist = 'playlist', + Rundown = 'rundown', + Segment = 'segment', + Part = 'part', } export type ServiceTypes = { [Services.Example]: ExampleServiceDefinition.Service [Services.Playlist]: PlaylistServiceDefinition.Service + [Services.Rundown]: RundownServiceDefinition.Service + [Services.Segment]: SegmentServiceDefinition.Service + [Services.Part]: PartServiceDefinition.Service } diff --git a/packages/shared/model/src/model.ts b/packages/shared/model/src/model.ts index 0997c42..ef0a72a 100644 --- a/packages/shared/model/src/model.ts +++ b/packages/shared/model/src/model.ts @@ -17,6 +17,9 @@ export interface RundownPlaylist extends DataObject { created: number modified: number + /** Is false while data is being loaded from Core */ + loaded: boolean + /** If the playlist is active or not */ isActive: boolean /** Is the playlist in rehearsal mode (can be used, when active: true) */ @@ -38,6 +41,10 @@ export interface Rundown extends DataObject { _id: RundownId playlistId: RundownPlaylistId + + /** User-presentable name (Slug) for the Title */ + label: string + /** The position of the Rundown within its Playlist */ rank: number @@ -53,7 +60,9 @@ export interface Rundown extends DataObject { export type SegmentId = ProtectedString<'SegmentId', string> export interface Segment extends DataObject { _id: SegmentId + playlistId: RundownPlaylistId rundownId: RundownId + /** The position of the Segment within its Rundown */ rank: number @@ -77,6 +86,7 @@ export interface Segment extends DataObject { export type PartId = ProtectedString<'PartId', string> export interface Part extends DataObject { _id: PartId + playlistId: RundownPlaylistId rundownId: RundownId segmentId: SegmentId /** The position of the Part within its Segment */