diff --git a/CHANGELOG.md b/CHANGELOG.md index ab176c3..bb236d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.3.1] (2026-09-02) + +Parity fixes aligning `Video` legacy search and analyzer output with `videodb-python`, plus fixes for Search v2 result shapes and `ask()` argument handling. + +### Added + +- **Legacy scene-index targeting** — `Video.legacySearch()` now accepts `sceneIndexId`, `indexId` (an alias for `sceneIndexId`, matching `videodb-python`'s `index_id` → `scene_index_id` aliasing), and `algorithm`, forwarding them to the wire. `Video.search()` forwards these when it routes to legacy. `sceneIndexId` and `algorithm` were added to the `SearchBase` type and are now emitted by the legacy search request builders (`scene_index_id`, `algorithm`) + +### Changed + +- **`ask()` accepts an options object** — `Video.ask()` and `Collection.ask()` now take an `AskOptions` (`topK`, `mode`, `includeSources`) as their second argument. The parameter was previously `topK: number`, so an options object was serialised into `top_k` and `mode` / `includeSources` were silently dropped. The positional form (`ask(question, 20, 'default', true)`) still works, and `AskOptions` is exported from `@/types/search` +- **`Shot` metadata widened for Search v2** — `videoLength` and `videoTitle` are now optional and `streamUrl` / `playerUrl` nullable on both `Shot` and `ShotBase`, since Search v2 omits length and title and sends explicit nulls for stream keys. `getEmbedCode()`'s error no longer suggests `generateStream()` or `autoGenerate`, neither of which helps when the server returns no player URL + +### Fixed + +- **`search({ indexId })` no longer throws** — the singular `indexId` was incorrectly listed as an unsupported selector, so passing it raised "Cannot mix legacy search params" instead of routing to `legacySearch()`. It is now treated as a legacy selector, mirroring `videodb-python` whose `unsupported_params` holds `index_ids` but not `index_id` +- **Legacy `SemanticSearch` payload parity** — `dynamic_score_percentage` (defaulting `null`) and `filter` (defaulting `[]`) are now always emitted, matching `videodb-python`'s `SemanticSearch` request payload; previously they were omitted when unset, which changed server-side ranking/results +- **`Understanding.getAnalyzerOutput()` preserves raw keys** — the call now skips camelCase conversion (`convert: false`), so the server's snake_case keys (e.g. `scene_id`) survive. Camelcasing renamed `scene_id` → `sceneId`, which the index endpoint does not recognize, breaking the round-trip of analyzer output back into `Video.index()` as a `source` +- **`getEmbedCode({ autoGenerate: true })` on search shots** — `generateStream()` short-circuited when `streamUrl` alone was set, so shots returned by search never fetched their `playerUrl` and `getEmbedCode()` threw despite auto-generation. The request is now skipped only when both URLs are already known, and sends `length: null` for shots that carry no video length rather than letting `JSON.stringify` coerce `NaN` +- **`videoLength` is no longer `NaN`** — `parseFloat(result.length)` produced `NaN` when Search v2 sent `length: null`. Values now pass through a finite-number coercion that yields `undefined` for null, empty, or non-numeric input +- **`streamUrl` no longer dropped from serialised shots** — `doc.streamLink ?? doc.streamUrl` fell through a real `null` onto a key the server never emits, leaving `streamUrl` as `undefined` so `JSON.stringify()` omitted it entirely. It now uses `||` and normalises absent streams to `null`, matching `videodb-python` + ## [0.3.0] (2026-07-25) Indexing v2 — a new retrieval architecture plus a generation/compute stack, ported from `videodb-python`'s `feat/add-indexing-v2`. diff --git a/jest.config.js b/jest.config.js index 04ad6ea..b2916cf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,10 @@ module.exports = { testEnvironment: 'node', testMatch: ['**/test/**/*.spec.ts'], + // Mirrors the `@/*` paths in tsconfig.json so specs can import from src. + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, collectCoverageFrom: [ '/src/**/*.ts', '!/src/types/**/*.ts', diff --git a/package-lock.json b/package-lock.json index eead51a..81ab2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "videodb", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "videodb", - "version": "0.3.0", + "version": "0.3.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e1ea320..f8af38f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "videodb", - "version": "0.3.0", + "version": "0.3.1", "description": "A NodeJS wrapper for VideoDB's API written in TypeScript", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -96,5 +96,8 @@ "tar": "^7.4.3", "uuid": "^9.0.1", "ws": "^8.19.0" + }, + "browser": { + "./dist/utils/openBrowser.js": "./dist/utils/openBrowser.browser.js" } } diff --git a/src/core/collection.ts b/src/core/collection.ts index 2c28db1..1fdf1a9 100644 --- a/src/core/collection.ts +++ b/src/core/collection.ts @@ -10,7 +10,7 @@ import type { RecordMeetingConfig, CaptureSessionBase, } from '@/interfaces/core'; -import { IndexType, SearchType } from '@/types/search'; +import { IndexType, SearchType, type AskOptions } from '@/types/search'; import type { FileUploadConfig, URLUploadConfig } from '@/types/collection'; import type { CreateCaptureSessionConfig, @@ -443,16 +443,29 @@ export class Collection implements ICollection { /** * Ask a question and get an answer generated from retrieved collection context. + * @param question - The question to answer + * @param optionsOrTopK - Options object, or `topK` for the legacy positional form + * @param mode - Retrieval/answer mode, positional form only (default `"default"`) + * @param includeSources - Whether to include source shots, positional form only (default false) */ public ask = async ( question: string, - topK: number = 15, - mode: string = 'default', - includeSources: boolean = false + optionsOrTopK: AskOptions | number = {}, + mode?: string, + includeSources?: boolean ): Promise => { + const opts: AskOptions = + typeof optionsOrTopK === 'number' + ? { topK: optionsOrTopK, mode, includeSources } + : optionsOrTopK; const res = await this.#vhttp.post, object>( [collection, this.id, ask], - { question, top_k: topK, mode, include_sources: includeSources } + { + question, + top_k: opts.topK ?? 15, + mode: opts.mode ?? 'default', + include_sources: opts.includeSources ?? false, + } ); return new AskResponse(this.#vhttp, res.data as AskResponseData); }; diff --git a/src/core/search/index.ts b/src/core/search/index.ts index c213d0b..7885f6d 100644 --- a/src/core/search/index.ts +++ b/src/core/search/index.ts @@ -42,6 +42,12 @@ class SceneSearch implements Search { if (data.filter !== undefined) { reqData.filter = data.filter; } + if (data.sceneIndexId !== undefined) { + reqData.scene_index_id = data.sceneIndexId; + } + if (data.algorithm !== undefined) { + reqData.algorithm = data.algorithm; + } return reqData; }; @@ -83,16 +89,21 @@ class SemanticSearch data.scoreThreshold ?? SemanticSearchDefaultValues.scoreThreshold, result_threshold: data.resultThreshold ?? SemanticSearchDefaultValues.resultThreshold, + // Always emitted, matching videodb-python's SemanticSearch payload where + // `dynamic_score_percentage` is always present (null when unset) and + // legacy_search passes `filter=[]` by default. + dynamic_score_percentage: data.dynamicScorePercentage ?? null, + filter: data.filter ?? [], }; - if (data.dynamicScorePercentage !== undefined) { - reqData.dynamic_score_percentage = data.dynamicScorePercentage; - } - if (data.filter !== undefined) { - reqData.filter = data.filter; - } if (data.sortDocsOn !== undefined) { reqData.sort_docs_on = data.sortDocsOn; } + if (data.sceneIndexId !== undefined) { + reqData.scene_index_id = data.sceneIndexId; + } + if (data.algorithm !== undefined) { + reqData.algorithm = data.algorithm; + } return reqData; }; @@ -141,6 +152,12 @@ class KeywordSearch if (data.filter !== undefined) { reqData.filter = data.filter; } + if (data.sceneIndexId !== undefined) { + reqData.scene_index_id = data.sceneIndexId; + } + if (data.algorithm !== undefined) { + reqData.algorithm = data.algorithm; + } return reqData; }; @@ -189,6 +206,12 @@ class LLMSearch if (data.filter !== undefined) { reqData.filter = data.filter; } + if (data.sceneIndexId !== undefined) { + reqData.scene_index_id = data.sceneIndexId; + } + if (data.algorithm !== undefined) { + reqData.algorithm = data.algorithm; + } return reqData; }; diff --git a/src/core/search/searchResult.ts b/src/core/search/searchResult.ts index 6c65a8d..29a08fa 100644 --- a/src/core/search/searchResult.ts +++ b/src/core/search/searchResult.ts @@ -8,6 +8,13 @@ import { HttpClient } from '@/utils/httpClient'; /** Camelcase version of SearchResponse for internal use */ type SearchResponseCamel = SnakeKeysToCamelCase; +/** Search V2 sends `length: null`; never turn a missing value into NaN. */ +const toFiniteNumber = (value: unknown): number | undefined => { + if (value === null || value === undefined || value === '') return undefined; + const num = Number(value); + return Number.isFinite(num) ? num : undefined; +}; + export class SearchResult implements Iterable { #vhttp: HttpClient; #searchResponse: SearchResponseCamel; @@ -34,13 +41,16 @@ export class SearchResult implements Iterable { text: doc.text, searchScore: doc.score, videoId: result.videoId, - videoTitle: result.title, - videoLength: parseFloat(result.length), + videoTitle: result.title ?? undefined, + videoLength: toFiniteNumber(result.length), sceneIndexId: doc.sceneIndexId, sceneIndexName: doc.sceneIndexName, metadata: doc.metadata, - streamUrl: doc.streamLink ?? doc.streamUrl, - playerUrl: doc.playerUrl, + // `||`, not `??` — mirrors videodb-python's + // `doc.get("stream_link") or doc.get("stream_url")`, and normalises + // the absent-key `undefined` to `null` so serialised shots match. + streamUrl: doc.streamLink || doc.streamUrl || null, + playerUrl: doc.playerUrl ?? null, }) ); } diff --git a/src/core/shot.ts b/src/core/shot.ts index 6f0a68b..310aa75 100644 --- a/src/core/shot.ts +++ b/src/core/shot.ts @@ -13,8 +13,8 @@ const { video, stream } = ApiPath; */ export class Shot implements IShot { public readonly videoId: string; - public readonly videoLength: number; - public readonly videoTitle: string; + public readonly videoLength?: number; + public readonly videoTitle?: string; public readonly start: number; public readonly end: number; public readonly text?: string; @@ -22,8 +22,8 @@ export class Shot implements IShot { public readonly sceneIndexId?: string; public readonly sceneIndexName?: string; public readonly metadata?: Record; - public streamUrl?: string; - public playerUrl?: string; + public streamUrl?: string | null; + public playerUrl?: string | null; #vhttp: HttpClient; constructor(http: HttpClient, data: ShotBase) { @@ -43,16 +43,20 @@ export class Shot implements IShot { } /** - * Get the streaming URL for the shot + * Get the streaming URL for the shot. + * Shots produced by search arrive with a `streamUrl` but no `playerUrl`, so + * the request is only skipped when both are already known. * @returns A streaming URL for the shot */ generateStream = async () => { - if (this.streamUrl) { + if (this.streamUrl && this.playerUrl) { return this.streamUrl; } const body = { - length: this.videoLength, + // Search V2 shots have no length; send null like videodb-python rather + // than letting JSON.stringify turn NaN into null implicitly. + length: this.videoLength ?? null, timeline: [[this.start, this.end]] as Timeline, }; @@ -96,9 +100,7 @@ export class Shot implements IShot { await this.generateStream(); } if (!this.playerUrl) { - throw new VideodbError( - 'player_url not available. Call generateStream() first or set autoGenerate=true.' - ); + throw new VideodbError('player_url not available for this shot.'); } return buildIframeEmbedCode( this.playerUrl, diff --git a/src/core/understanding.ts b/src/core/understanding.ts index 9e98517..f41bac4 100644 --- a/src/core/understanding.ts +++ b/src/core/understanding.ts @@ -381,17 +381,29 @@ export class Understanding { throw new Error(`Analyzer not found: ${nameOrId}`); }; - /** Return output for an analyzer by name or id. */ + /** + * Return output for an analyzer by name or id. + * + * Returned with the server's raw snake_case keys (e.g. `scene_id`) — this + * output is meant to be round-tripped back into `Video.index` as a `source`, + * and the server keys off `scene_id`. Camelcasing it (the default response + * conversion) would rename `scene_id` to `sceneId`, which the index endpoint + * no longer recognizes, so the round-trip must skip conversion. + */ public getAnalyzerOutput = async (nameOrId: string): Promise => { - const res = await this.#vhttp.get([ - video, - this.videoId, - understand, - this.id ?? '', - 'analyzers', - nameOrId, - 'output', - ]); + const res = await this.#vhttp.get( + [ + video, + this.videoId, + understand, + this.id ?? '', + 'analyzers', + nameOrId, + 'output', + ], + undefined, + { convert: false } + ); return res.data; }; diff --git a/src/core/video.ts b/src/core/video.ts index cf8901e..d09d233 100644 --- a/src/core/video.ts +++ b/src/core/video.ts @@ -62,7 +62,7 @@ import { IndexSceneConfig, SubtitleStyleProps, } from '@/types/config'; -import { SearchType, IndexType } from '@/types/search'; +import { SearchType, IndexType, type AskOptions } from '@/types/search'; import { SceneIndexRecords, SceneIndexes } from '@/types'; import { Shot } from './shot'; import { VideodbError } from '@/utils/error'; @@ -117,6 +117,9 @@ export interface VideoSearchOptions { indexId?: string; algorithm?: string; namespace?: string; + stitch?: boolean; + rerank?: boolean; + rerankParams?: Record; // new (v2) topK?: number; mode?: string; @@ -186,8 +189,50 @@ export class Video implements IVideo { */ public search = async ( query: string, - options: VideoSearchOptions = {} + optionsOrSearchType: VideoSearchOptions | SearchType = {}, + indexType?: IndexType, + resultThreshold?: number, + scoreThreshold?: number, + dynamicScorePercentage?: number, + filter?: Array>, + sortDocsOn?: string ): Promise => { + // Back-compat: the pre-v2 signature was fully positional — + // search(query, searchType, indexType, resultThreshold, scoreThreshold, + // dynamicScorePercentage, filter, sortDocsOn) + // Detect it (2nd arg is a SearchType string, or any trailing positional arg + // is present) and fold it into an options object, forcing legacy routing — + // mirroring videodb-python's `has_old = bool(args)`. + const positionalLegacy = + typeof optionsOrSearchType === 'string' || + indexType !== undefined || + resultThreshold !== undefined || + scoreThreshold !== undefined || + dynamicScorePercentage !== undefined || + filter !== undefined || + sortDocsOn !== undefined; + + const options: VideoSearchOptions = positionalLegacy + ? { + searchType: + typeof optionsOrSearchType === 'string' + ? (optionsOrSearchType as SearchType) + : undefined, + indexType, + resultThreshold, + scoreThreshold, + dynamicScorePercentage, + filter, + sortDocsOn, + } + : (optionsOrSearchType as VideoSearchOptions); + + // Note: scoreThreshold is intentionally NOT a legacy trigger. It is shared + // by legacy and Search V2, so on its own it must not force legacy routing — + // it routes to new search unless a genuinely legacy param is present. A + // positional scoreThreshold still routes to legacy via `positionalLegacy`, + // mirroring videodb-python (score_threshold is positional-only, absent from + // its `old_params`). const oldParams: (keyof VideoSearchOptions)[] = [ 'searchType', 'indexType', @@ -198,6 +243,9 @@ export class Video implements IVideo { 'algorithm', 'sortDocsOn', 'namespace', + 'stitch', + 'rerank', + 'rerankParams', ]; const newParams: (keyof VideoSearchOptions)[] = [ 'topK', @@ -207,16 +255,19 @@ export class Video implements IVideo { 'sessionId', 'config', ]; + // Only the multi-index (plural) selectors are unsupported by search(). + // The singular `indexId` is a legacy selector (in `oldParams`) that must + // route to legacySearch, mirroring videodb-python whose `unsupported_params` + // holds `index_ids` but not `index_id`. const unsupported: (keyof VideoSearchOptions)[] = [ 'indexName', 'indexNames', - 'indexId', 'indexIds', ]; - const hasOld = oldParams.some( - k => options[k] !== undefined && options[k] !== null - ); + const hasOld = + positionalLegacy || + oldParams.some(k => options[k] !== undefined && options[k] !== null); const hasNew = newParams.some( k => options[k] !== undefined && options[k] !== null ); @@ -237,7 +288,7 @@ export class Video implements IVideo { } if (hasUnsupported) { throw new VideodbError( - 'indexName/indexNames/indexId/indexIds are not supported in search(). ' + + 'indexName/indexNames/indexIds are not supported in search(). ' + 'Use semanticSearch(), query(), or aggregate() for index-specific calls.' ); } @@ -252,7 +303,10 @@ export class Video implements IVideo { options.scoreThreshold, options.dynamicScorePercentage, options.filter, - options.sortDocsOn + options.sortDocsOn, + options.sceneIndexId, + options.indexId, + options.algorithm ); } @@ -271,6 +325,10 @@ export class Video implements IVideo { if (options.includeClip != null) payload.include_clip = options.includeClip; if (options.sessionId != null) payload.session_id = options.sessionId; if (options.config != null) payload.config = options.config; + // Shared with legacy; forwarded to Search V2 like videodb-python's + // `_new_search(**kwargs)` passthrough of a `score_threshold` kwarg. + if (options.scoreThreshold != null) + payload.score_threshold = options.scoreThreshold; const res = await this.#vhttp.post, typeof payload>( [video, this.id, searchPath, 'v2'], payload @@ -281,19 +339,28 @@ export class Video implements IVideo { /** * Ask a question and get an answer generated from retrieved video context. * @param question - The question to answer - * @param topK - Number of context chunks to retrieve (default 15) - * @param mode - Retrieval/answer mode (default `"default"`) - * @param includeSources - Whether to include source shots (default false) + * @param optionsOrTopK - Options object, or `topK` for the legacy positional form + * @param mode - Retrieval/answer mode, positional form only (default `"default"`) + * @param includeSources - Whether to include source shots, positional form only (default false) */ public ask = async ( question: string, - topK: number = 15, - mode: string = 'default', - includeSources: boolean = false + optionsOrTopK: AskOptions | number = {}, + mode?: string, + includeSources?: boolean ): Promise => { + const opts: AskOptions = + typeof optionsOrTopK === 'number' + ? { topK: optionsOrTopK, mode, includeSources } + : optionsOrTopK; const res = await this.#vhttp.post, object>( [video, this.id, ask], - { question, top_k: topK, mode, include_sources: includeSources } + { + question, + top_k: opts.topK ?? 15, + mode: opts.mode ?? 'default', + include_sources: opts.includeSources ?? false, + } ); return new AskResponse(this.#vhttp, res.data as AskResponseData); }; @@ -399,6 +466,10 @@ export class Video implements IVideo { * @param dynamicScorePercentage - [optional] Percentage of dynamic score to consider * @param filter - [optional] Additional metadata filters * @param sortDocsOn - [optional] Sort docs within each video by "score" or "start" + * @param sceneIndexId - [optional] Target a specific legacy scene index by id + * @param indexId - [optional] Alias for `sceneIndexId` (mirrors videodb-python's + * `index_id` → `scene_index_id` aliasing) + * @param algorithm - [optional] Legacy ranking algorithm selector */ public legacySearch = async ( query: string, @@ -408,8 +479,14 @@ export class Video implements IVideo { scoreThreshold?: number, dynamicScorePercentage?: number, filter?: Array>, - sortDocsOn?: string + sortDocsOn?: string, + sceneIndexId?: string, + indexId?: string, + algorithm?: string ): Promise => { + // `indexId` is accepted as an alias for `sceneIndexId`; an explicit + // `sceneIndexId` wins if both are provided. + const resolvedSceneIndexId = sceneIndexId ?? indexId; const s = new SearchFactory(this.#vhttp); const searchFunc = s.getSearch(searchType ?? DefaultSearchType); const results = await searchFunc.searchInsideVideo({ @@ -422,6 +499,8 @@ export class Video implements IVideo { dynamicScorePercentage: dynamicScorePercentage, filter: filter, sortDocsOn: sortDocsOn, + sceneIndexId: resolvedSceneIndexId, + algorithm: algorithm, }); return results; }; diff --git a/src/interfaces/core.ts b/src/interfaces/core.ts index 802107e..2b500eb 100644 --- a/src/interfaces/core.ts +++ b/src/interfaces/core.ts @@ -126,14 +126,14 @@ export interface IImage extends ImageBase {} */ export interface ShotBase { videoId: string; - videoLength: number; - videoTitle: string; + videoLength?: number; + videoTitle?: string; start: number; end: number; text?: string; searchScore?: number; - streamUrl?: StreamableURL; - playerUrl?: StreamableURL; + streamUrl?: StreamableURL | null; + playerUrl?: StreamableURL | null; sceneIndexId?: string; sceneIndexName?: string; metadata?: Record; diff --git a/src/types/response.ts b/src/types/response.ts index f3a3f8a..a57ef35 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -143,20 +143,20 @@ export type SearchResponse = { end: number; score: number; start: number; - stream_link?: string; - stream_url?: string; - player_url?: string; + stream_link?: string | null; + stream_url?: string | null; + player_url?: string | null; text: string; scene_index_id?: string; scene_index_name?: string; metadata?: Record; }[]; - length: string; + length: string | number | null; max_score: number; platform: string; stream_url: StreamableURL; thumbnail: string; - title: string; + title: string | null; video_id: string; }[]; }; diff --git a/src/types/search.ts b/src/types/search.ts index bf2e0a4..7fae97d 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -14,6 +14,10 @@ export type SearchBase = { dynamicScorePercentage?: number; filter?: Array>; sortDocsOn?: string; + /** Target a specific legacy scene index by id. */ + sceneIndexId?: string; + /** Legacy ranking algorithm selector. */ + algorithm?: string; }; export type SemanticSearchBase = SearchBase; @@ -45,3 +49,10 @@ export type SceneVideoSearch = { export type SceneCollectionSearch = { collectionId: string; } & SceneSearchBase; + +/** Options for `Video.ask` / `Collection.ask`. */ +export type AskOptions = { + topK?: number; + mode?: string; + includeSources?: boolean; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4be3fa5..7e24a53 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ import { PLAYER_URL } from '@/constants'; +import { openBrowser } from '@/utils/openBrowser'; import _ from 'lodash'; import { AudioBase, VideoBase } from '@/interfaces/core'; @@ -118,7 +119,17 @@ export const fromCamelToSnake = ( .value() as CamelKeysToSnakeCase; }; -export const playStream = (url: string) => `${PLAYER_URL}?url=${url}`; +/** + * Build the player URL for a stream and open it in the browser, matching + * videodb-python's `play_stream`. Stays synchronous and returns the same + * string, so existing callers are unaffected. Set `VIDEODB_NO_BROWSER` to + * suppress the launch. + */ +export const playStream = (url: string) => { + const player = `${PLAYER_URL}?url=${url}`; + openBrowser(player); + return player; +}; /** * Sleep for the given number of milliseconds. Used by the client-side diff --git a/src/utils/openBrowser.browser.ts b/src/utils/openBrowser.browser.ts new file mode 100644 index 0000000..50ae9c9 --- /dev/null +++ b/src/utils/openBrowser.browser.ts @@ -0,0 +1,7 @@ +/** Browser counterpart of `openBrowser`, selected via package.json's `browser` field. */ +export const openBrowser = (url: string): boolean => { + if (typeof window === 'undefined' || typeof window.open !== 'function') { + return false; + } + return window.open(url, '_blank', 'noopener,noreferrer') !== null; +}; diff --git a/src/utils/openBrowser.ts b/src/utils/openBrowser.ts new file mode 100644 index 0000000..b348174 --- /dev/null +++ b/src/utils/openBrowser.ts @@ -0,0 +1,44 @@ +/** + * Opens a URL in the user's default browser. Mirrors `webbrowser.open` in + * videodb-python, which every `.play()` calls. + * + * The browser build is swapped in by package.json's `browser` field, so + * `node:child_process` never enters a bundle's module graph; the lazy require + * and `isNode()` guard cover bundlers that ignore that field. + */ +const isNode = (): boolean => + typeof process !== 'undefined' && + process.versions != null && + process.versions.node != null; + +export const openBrowser = (url: string): boolean => { + if (!isNode()) return false; + // Escape hatch for CI and headless servers. + if (process.env.VIDEODB_NO_BROWSER) return false; + try { + // Lazy require, not import: keeps child_process out of browser bundles. + const { spawn } = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('node:child_process') as typeof import('node:child_process'); + let cmd: string; + let args: string[]; + if (process.platform === 'darwin') { + cmd = 'open'; + args = [url]; + } else if (process.platform === 'win32') { + cmd = 'cmd'; + // `start` treats & as a command separator, so it must be escaped. + args = ['/c', 'start', '""', url.replace(/&/g, '^&')]; + } else { + cmd = 'xdg-open'; + args = [url]; + } + const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }); + // A missing opener must never crash the host process. + child.on('error', () => {}); + child.unref(); + return true; + } catch { + return false; + } +}; diff --git a/test/regressions.spec.ts b/test/regressions.spec.ts new file mode 100644 index 0000000..b7b6fe0 --- /dev/null +++ b/test/regressions.spec.ts @@ -0,0 +1,231 @@ +/** + * Regression tests for the Node-SDK bugs tracked in `issues/`. + * Each block names the Linear issue it locks down. + */ +import { Shot } from '@/core/shot'; +import { SearchResult } from '@/core/search/searchResult'; +import { Video } from '@/core/video'; +import { Collection } from '@/core/collection'; +import { Understanding } from '@/core/understanding'; +import { playStream } from '@/utils'; +import type { HttpClient } from '@/utils/httpClient'; + +/** Records every request the SDK makes so the wire body can be asserted. */ +const makeHttp = (responses: Record = {}) => { + const calls: { + method: string; + path: string[]; + body?: unknown; + opts?: unknown; + }[] = []; + const respond = (path: string[]) => ({ + data: responses[path.join('/')] ?? responses['*'] ?? {}, + }); + return { + calls, + client: { + post: (path: string[], body?: unknown) => { + calls.push({ method: 'post', path, body }); + return Promise.resolve(respond(path)); + }, + get: (path: string[], params?: unknown, opts?: unknown) => { + calls.push({ method: 'get', path, opts }); + return Promise.resolve(respond(path)); + }, + } as unknown as HttpClient, + }; +}; + +describe('ENG-1524 — ask() accepts an options object', () => { + it('reads topK/includeSources from an options object', async () => { + const { calls, client } = makeHttp(); + await new Video(client, { id: 'v1', collectionId: 'c1' } as never).ask( + 'q', + { + topK: 15, + includeSources: true, + } + ); + expect(calls[0].body).toEqual({ + question: 'q', + top_k: 15, + mode: 'default', + include_sources: true, + }); + }); + + it('still honours the legacy positional form', async () => { + const { calls, client } = makeHttp(); + await new Video(client, { id: 'v1', collectionId: 'c1' } as never).ask( + 'q', + 3, + 'default', + true + ); + expect(calls[0].body).toEqual({ + question: 'q', + top_k: 3, + mode: 'default', + include_sources: true, + }); + }); + + it('applies the same defaults on Collection.ask', async () => { + const { calls, client } = makeHttp(); + await new Collection(client, 'c1', 'n', 'd').ask('q'); + expect(calls[0].body).toEqual({ + question: 'q', + top_k: 15, + mode: 'default', + include_sources: false, + }); + }); +}); + +describe('ENG-1518 / ENG-1512 — SearchResult field coercion', () => { + const build = (result: Record) => + new SearchResult( + {} as HttpClient, + { + results: [ + { + videoId: 'v1', + collectionId: 'c1', + docs: [ + { + start: 0, + end: 1, + score: 0.5, + text: 't', + ...(result.doc as object), + }, + ], + ...result, + }, + ], + } as never + ); + + it('leaves videoLength undefined rather than NaN when length is null', () => { + const shot = build({ length: null, title: null }).shots[0]; + expect(shot.videoLength).toBeUndefined(); + expect(shot.videoTitle).toBeUndefined(); + }); + + it('still parses the legacy numeric-string length', () => { + const shot = build({ length: '665.460680', title: 'Clip' }).shots[0]; + expect(shot.videoLength).toBeCloseTo(665.46068); + expect(shot.videoTitle).toBe('Clip'); + }); + + it("falls through an empty streamLink onto streamUrl, like Python's `or`", () => { + const shot = build({ + length: null, + title: null, + doc: { streamLink: '', streamUrl: 'https://s/x.m3u8' }, + }).shots[0]; + expect(shot.streamUrl).toBe('https://s/x.m3u8'); + }); + + it('normalises a missing stream to null, not undefined', () => { + const shot = build({ length: null, title: null }).shots[0]; + expect(shot.streamUrl).toBeNull(); + expect(shot.playerUrl).toBeNull(); + expect(JSON.parse(JSON.stringify(shot))).toHaveProperty('streamUrl', null); + }); +}); + +describe('ENG-1522 — getEmbedCode auto-generates the player URL', () => { + it('fetches playerUrl for a search shot that only has streamUrl', async () => { + const { calls, client } = makeHttp({ + '*': { + streamUrl: 'https://s/new.m3u8', + playerUrl: 'https://p/watch?v=abc', + }, + }); + const shot = new Shot(client, { + videoId: 'v1', + start: 0, + end: 1, + streamUrl: 'https://s/search.m3u8', + } as never); + + const embed = await shot.getEmbedCode(); + expect(calls).toHaveLength(1); + expect(embed).toContain('https://p/embed?v=abc'); + }); + + it('sends length null instead of NaN when the shot has no length', async () => { + const { calls, client } = makeHttp({ + '*': { streamUrl: 's', playerUrl: 'p' }, + }); + await new Shot(client, { + videoId: 'v1', + start: 0, + end: 1, + } as never).generateStream(); + expect((calls[0].body as { length: unknown }).length).toBeNull(); + }); + + it('skips the request once both URLs are known', async () => { + const { calls, client } = makeHttp(); + await new Shot(client, { + videoId: 'v1', + start: 0, + end: 1, + streamUrl: 'https://s/x.m3u8', + playerUrl: 'https://p/watch?v=abc', + } as never).generateStream(); + expect(calls).toHaveLength(0); + }); +}); + +describe('ENG-1516 — playStream opens the browser', () => { + const original = process.env.VIDEODB_NO_BROWSER; + afterEach(() => { + if (original === undefined) delete process.env.VIDEODB_NO_BROWSER; + else process.env.VIDEODB_NO_BROWSER = original; + jest.resetModules(); + }); + + it('returns the player URL unchanged', () => { + process.env.VIDEODB_NO_BROWSER = '1'; + expect(playStream('https://s/x.m3u8')).toContain('?url=https://s/x.m3u8'); + }); + + it('spawns an opener, and honours VIDEODB_NO_BROWSER', () => { + jest.isolateModules(() => { + const cp = require('node:child_process') as { spawn: unknown }; + const spawn = jest + .spyOn(cp as never, 'spawn') + .mockReturnValue({ on: () => {}, unref: () => {} } as never); + + delete process.env.VIDEODB_NO_BROWSER; + const { playStream: play } = + require('@/utils') as typeof import('@/utils'); + play('https://s/x.m3u8'); + expect(spawn).toHaveBeenCalledTimes(1); + + process.env.VIDEODB_NO_BROWSER = '1'; + play('https://s/y.m3u8'); + expect(spawn).toHaveBeenCalledTimes(1); + spawn.mockRestore(); + }); + }); +}); + +describe('ENG-1517 — analyzer output keeps the server snake_case keys', () => { + it('requests the output with conversion disabled', async () => { + const { calls, client } = makeHttp({ + '*': { scenes: [{ scene_id: 's1', data: {} }] }, + }); + const u = new Understanding(client, { id: 'u1', videoId: 'v1' } as never); + const out = (await u.getAnalyzerOutput('transcript')) as { + scenes: Record[]; + }; + + expect(calls[0].opts).toEqual({ convert: false }); + expect(out.scenes[0]).toHaveProperty('scene_id'); + expect(out.scenes[0]).not.toHaveProperty('sceneId'); + }); +});