Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bluebillywig/bb-sapi-node-sdk",
"version": "1.0.0",
"version": "1.1.0",
"description": "Blue Billywig Server API SDK for Node.js",
"type": "module",
"main": "./dist/index.js",
Expand Down
44 changes: 44 additions & 0 deletions src/entities/media-clip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs';
import { basename, extname } from 'node:path';
import { Entity } from '../entity.js';
import { SapiResponse } from '../response.js';
import { FilterSet } from '../search/filter-set.js';
import type { Listable } from '../contracts/listable.js';
import type { Gettable } from '../contracts/gettable.js';
import type { Creatable } from '../contracts/creatable.js';
Expand Down Expand Up @@ -62,6 +63,49 @@ function getMimeType(filePath: string): string {
}

export class MediaClip extends Entity implements Listable, Gettable, Creatable<MediaClipProps>, Updatable<MediaClipProps>, Deletable {
/**
* Search media clips using a filterset.
*
* The filtered counterpart to {@link list}, which can only page and sort. A
* filterset is the same structure the OVP builds in its filter UI, so a search
* moves between the OVP, the API and this SDK unchanged.
*
* const filterSet = FilterSet.create()
* .where('status', 'is', 'published')
* .where('title', 'contains', 'koert');
*
* await sdk.mediaclip.search(filterSet);
*
* The filterset goes over the wire as JSON and SAPI compiles it, exactly as
* the OVP does. It is deliberately not compiled here: that would be a second
* implementation of semantics the server owns, and a filter SAPI cannot read
* is ignored silently — HTTP 200, with neither `numfound` nor `items`.
*
* @param filterSet Groups are AND-ed, filters within a group OR-ed.
* @param filterQueries Raw Solr filters, for the rare thing a filterset cannot
* express. NOTE the encoding: these go out as `fq[0]=`; SAPI ignores a
* repeated `fq=` and a nested `fq[][0]=`, in both cases without an error.
*/
async search(
filterSet: FilterSet,
limit: number = 15,
offset: number = 0,
sort: string = 'createddate desc',
query: string = '*',
filterQueries: string[] = [],
): Promise<SapiResponse> {
const params: Record<string, string> = buildQuery({ q: query, limit, offset, sort });

if (!filterSet.isEmpty()) {
params.filterset = filterSet.toString();
}
filterQueries.forEach((filterQuery, index) => {
params[`fq[${index}]`] = filterQuery;
});

return this.sdk.sendRequest('GET', '/sapi/mediaclip', { query: params });
}

async list(
limit: number = 15,
offset: number = 0,
Expand Down
34 changes: 34 additions & 0 deletions src/entities/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,38 @@ export class Thumbnail extends Entity {
relativeImagePath = relativeImagePath.replace(/^\//, '');
return `${this.sdk.baseUri}/image/${width}/${height}/${relativeImagePath}`;
}

/**
* Absolute URL of a media clip's poster image.
*
* Use this rather than building a URL from the clip payload. A clip's `src` is
* its SOURCE MEDIA file, so `defaultMediaAssetPath + clip.src` yields a link
* to a .mov — the service says as much, replying
* "Invalid src mime type: video/quicktime". That mistake shows up as a grid
* full of broken images.
*
* `'default'` is accepted for either dimension and lets the service choose.
*
* A draft (unpublished) clip's poster is not public. Pass an RPC token minted
* from the READ-ONLY key — never the write key, because this URL ends up in
* page source — to see those.
*/
getMediaClipPosterPath(
mediaClipId: number | string,
width: number | 'default' = 'default',
height: number | 'default' = 'default',
rpcToken?: string,
): string {
const dimension = (value: number | 'default'): string =>
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 100000
? String(value)
: 'default';

const url =
`${this.sdk.baseUri}/mediaclip/${encodeURIComponent(String(mediaClipId))}` +
`/spthumbnail/${dimension(width)}/${dimension(height)}.webp`;

return rpcToken ? `${url}?useSession=true&rpctoken=${encodeURIComponent(rpcToken)}` : url;
}

}
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ export { Playout } from './entities/playout.js';
export { Subtitle } from './entities/subtitle.js';
export { Thumbnail } from './entities/thumbnail.js';

// Search
export { FilterSet } from './search/filter-set.js';
export type {
Filter,
FilterGroup,
FilterOperator,
FilterScalar,
FilterSetData,
FilterValue,
SearchRequestEnvelope,
} from './search/filter-set.js';

// Exceptions
export { HTTPRequestException } from './exceptions/http-request-exception.js';
export { HTTPClientErrorException } from './exceptions/http-client-error-exception.js';
Expand Down
182 changes: 182 additions & 0 deletions src/search/filter-set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* The filterset: the structure the OVP builds in its filter UI, and the shape
* SAPI's `filterset` parameter takes.
*
* Deliberately NOT compiled to a Solr query here. SAPI compiles filtersets
* itself, using the same SearchRequestHelper that serves the OVP, so compiling
* client-side would be a second implementation of semantics the server owns —
* free to drift, with a failure mode that is invisible: a filter SAPI cannot
* read is ignored, and the response is HTTP 200 with neither `numfound` nor
* `items`, which reads exactly like an empty library.
*
* Mirrors `app/services/filter-set.types.ts` in OVP6, so a filterset moves
* between the UI, the API and any SDK unchanged.
*
* Server-side quirks a caller inherits (the compiler is formatengine's):
* - A filter whose value is the string '0' is dropped by the backend's
* empty-value guard, so "views is 0" cannot be expressed as a filterset.
* - In values, '+' becomes a space and '"' is stripped before compilation.
* - An unknown FIELD is not an error: it queries a non-existent index field
* and returns numfound=0 — a typo'd field name looks like an empty library.
*/

/** Operators SAPI understands. */
export type FilterOperator =
| 'is'
| 'isNot'
| 'isAnyOf'
| 'isNotAnyOf'
| 'isEmpty'
| 'isNotEmpty'
| 'contains'
| 'containsAnyOf'
| 'containsAllOf'
| 'doesNotContain'
| 'doesNotContainAnyOf'
| 'isBefore'
| 'isAfter'
| 'isSmallerThan'
| 'isGreaterThan'
| 'isInTheLast'
| 'isNotInTheLast';

/** Operators that test presence, so they are meaningful without a value. */
const VALUELESS_OPERATORS: ReadonlySet<string> = new Set(['isEmpty', 'isNotEmpty']);

/**
* One value in a filter. Numbers and booleans are accepted and normalised to
* strings on the wire: the backend's compiler mangles a JSON `true` into "1"
* (which matches nothing, silently) and its empty-value guard drops `false`
* outright, while numbers work but only ever appear as strings in what OVP6
* sends. Normalising here keeps an ingested OVP/Automations filterset working.
*/
export type FilterScalar = string | number | boolean;
export type FilterValue = FilterScalar | FilterScalar[];

export interface Filter {
field: string;
operator: FilterOperator;
value?: FilterValue;
/** Constrain to an entity type: mediaclip, project, search. */
type?: string;
}

export interface FilterGroup {
filters: Filter[];
}

/** A filterset is groups of filters: groups are AND-ed, filters within one OR-ed. */
export type FilterSetData = FilterGroup[];

/** The envelope OVP6 sends. */
export interface SearchRequestEnvelope {
type: 'SearchRequest';
filterSet: FilterSetData;
}

function hasValue(filter: Filter): boolean {
if (VALUELESS_OPERATORS.has(filter.operator)) {
return true;
}
const values = Array.isArray(filter.value) ? filter.value : [filter.value];

return values.some(
(value) =>
typeof value === 'number' ||
typeof value === 'boolean' ||
(typeof value === 'string' && value.trim() !== ''),
);
}

function isScalar(value: unknown): value is FilterScalar {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
}

function normalizeScalar(value: FilterScalar): string {
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
}

/**
* Build a filterset.
*
* const filterSet = FilterSet.create()
* .where('status', 'is', 'published')
* .where('title', 'contains', 'koert');
*
* await sdk.mediaclip.search(filterSet);
*/
export class FilterSet {
private constructor(private readonly groups: FilterSetData = []) {}

static create(): FilterSet {
return new FilterSet();
}

/**
* Accepts either a bare list of groups or the `SearchRequest` envelope OVP6
* sends.
*/
static from(filterSet: FilterSetData | SearchRequestEnvelope): FilterSet {
const groups = Array.isArray(filterSet) ? filterSet : filterSet.filterSet;

return new FilterSet(groups ?? []);
}

/** Add a condition as its own group, so it is AND-ed with the rest. */
where(field: string, operator: FilterOperator, value?: string | string[], type?: string): FilterSet {
return this.andGroup({ field, operator, value, type });
}

/** Add several conditions as one group, so they are OR-ed with each other. */
andGroup(...filters: Filter[]): FilterSet {
return new FilterSet([...this.groups, { filters }]);
}

/** The wire format: what SAPI's `filterset` parameter expects. */
toArray(): FilterSetData {
return this.groups
// from() ingests external data; a group without a filters array is junk,
// not a crash.
.map((group) => ({
filters: (Array.isArray(group?.filters) ? group.filters : []).filter(hasValue).map(strip),
}))
.filter((group) => group.filters.length > 0);
}

toJSON(): FilterSetData {
return this.toArray();
}

toString(): string {
return JSON.stringify(this.toArray());
}

isEmpty(): boolean {
return this.toArray().length === 0;
}
}

/** Normalise to the wire shape: what the OVP sends and the backend can read. */
function strip(filter: Filter): Filter {
const stripped: Filter = { field: filter.field, operator: filter.operator };
if (VALUELESS_OPERATORS.has(filter.operator)) {
// The backend's compiler skips ANY filter whose value is empty — presence
// tests included — so isEmpty/isNotEmpty must carry a placeholder or they
// silently never fire (verified live: a bare isEmpty returned the full
// unfiltered publication). '*' is what OVP6 sends ("backend needs a value
// to work"), and it overrides whatever the caller supplied.
stripped.value = '*';
} else if (filter.value !== undefined) {
stripped.value = Array.isArray(filter.value)
? filter.value.filter(isScalar).map(normalizeScalar)
: normalizeScalar(filter.value);
}
if (filter.type) {
stripped.type = filter.type;
}

return stripped;
}
26 changes: 26 additions & 0 deletions tests/entities/thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,30 @@ describe('Thumbnail', () => {
'Given height is lower than 0.',
);
});

it('builds a clip poster URL from the OVP thumbnail route', () => {
const sdk = new Sdk('my-publication', new EmptyAuthenticator());

expect(sdk.thumbnail.getMediaClipPosterPath(1234, 320, 180)).toBe(
'https://my-publication.bbvms.com/mediaclip/1234/spthumbnail/320/180.webp',
);
});

it('lets the service choose the dimensions by default', () => {
const sdk = new Sdk('my-publication', new EmptyAuthenticator());

expect(sdk.thumbnail.getMediaClipPosterPath(1234)).toBe(
'https://my-publication.bbvms.com/mediaclip/1234/spthumbnail/default/default.webp',
);
});

it('carries an RPC token so draft clips resolve', () => {
const sdk = new Sdk('my-publication', new EmptyAuthenticator());

expect(sdk.thumbnail.getMediaClipPosterPath(1234, 'default', 'default', '12-345678')).toBe(
'https://my-publication.bbvms.com/mediaclip/1234/spthumbnail/default/default.webp' +
'?useSession=true&rpctoken=12-345678',
);
});

});
Loading
Loading