Adding search to your application, step by step

For a library of recordings and documents your own customers can search, by meaning and by wording, with answers that point to the exact moment or page. This page takes you from getting your content in, through search, written answers and permissions in your application, to tuning what people find. Every route, field and error code is in the reference: Registering content, Searching and Integration.

You need: the Content Search service on your plan, an API key (Developers → API keys), and your own backend running one of plycdn's server packages (ASP.NET Core, Node.js or Python) or calling the API with your key. For a page in the browser, the React or Angular package.

1How it works

  1. Your files are made searchable. Either they already sit in plycdn (a bucket or a video library: one switch), or they live in storage you run (a connector keeps them in step), or your code registers each one.
  2. plycdn reads each file once: it transcribes recordings, reads text shown on screen, and extracts the text of documents. The file is searchable when it reaches ready.
  3. Your backend searches on behalf of the signed-in person, naming who they are. Who is asking is decided on your server, never in the browser.
  4. Results come back with the moment (playFromSeconds) or page where each match is, so your page can open the file right there.

2Get your content in

Choose the row that matches where your files are.

Your files are Do this
In a plycdn bucket Turn on search for the bucket
In a plycdn video library Turn on search for the library
In S3-compatible storage, an Azure container or Canvas Add a storage connector
Anywhere else, or you want control of each file Register them from code

A plycdn bucket or video library

Nothing to connect: no keys, no addresses, nothing to sync.

In the dashboard: open the bucket (Storage → Storage buckets) or the library (Video streaming → Video libraries) → Settings → Search, switch it on, and save. You can limit it to a folder and choose who can find new files (everyone in your organization, or only the people you grant them to). The bucket's page shows "Making files searchable" with counts, and each file shows its state.

From your code (an admin key):

import { PlycdnStorageApi } from '@plycdn/node/storage';

const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_ADMIN_KEY! });
await storage.updateBucketSearch(orgId, bucketId, { enabled: true, folder: 'lectures/' });
const progress = await storage.getBucketSearch(orgId, bucketId);   // counts: searchable, indexing, waiting, skipped

The same calls are UpdateBucketSearchAsync and GetBucketSearchAsync (ASP.NET Core) and update_bucket_search and get_bucket_search (Python). A video library takes the library's id in place of a bucket's. The reasons a file is skipped or waiting are listed in Make a bucket searchable and Make a video library searchable.

Storage you run: a connector

A connector keeps a library in step with a source that stays authoritative, so nobody uploads twice. In the dashboard: Content search → Storage connectors → add one. You can paste a bucket or container address and the form fills in what it can. Credentials are stored sealed and never shown again. Give each source the least access it needs, read-only:

Source What you create Access it needs
Amazon S3, or any S3-compatible storage (Google Cloud Storage in interoperability mode, MinIO and others) A key for one bucket, optionally a folder (prefix) List the bucket and read its objects. Never write. For Amazon S3: s3:ListBucket on the bucket and s3:GetObject on its objects
Azure Blob A container SAS, made against a stored access policy so you can extend or revoke it in Azure Read and List, nothing else. A token without List makes the container look empty. Not the account key
Canvas An access token (Canvas → Account → Settings → New access token) Whatever its owner sees: sign in as someone who can see the courses to index. Enrolments carry across as permissions

Choose a Schedule (manual or nightly; either way only what changed is read again), and who can find new files. For instant sync, point your storage's create and delete events at the connector's notification address; the dashboard walks you through it. The files' host must be on your storage host list under Content search → Indexing settings, or the run reports source_host_not_allowed. Removing a connector does not remove what it indexed. More in Keeping a source in step automatically.

From code:

import { PlycdnContentApi } from '@plycdn/content-search-node';
const content = new PlycdnContentApi({ apiKey: process.env.PLYCDN_API_KEY! });

const c = await content.createConnector(orgId, { kind: 'azure_blob', name: 'Lecture recordings',
  config: { blobService: 'https://youraccount.blob.core.windows.net', container: 'lectures' },
  credential: process.env.AZURE_SAS!, schedule: 'nightly' });
await content.syncConnector(orgId, c.connectorId);   // sync now, whatever the schedule

The same calls exist as CreateConnectorAsync and SyncConnectorAsync (ASP.NET Core) and create_connector and sync_connector (Python). A connector needs the connectors feature in your plan.

Registering files from code

You put the file in your storage, then tell plycdn where it is. Keep this on your server: it decides which address plycdn reads.

const registration = await content.registerAsset(orgId,
  { externalId: 'lecture-2026-09-02', filename: 'Cardiology week 3.mp4', kind: 'video',
    sourceUrl: 'https://files.example.com/lecture.mp4', sourceVersion: '"0x8DF1151249B1D1B"' },
  { url: 'https://files.example.com/lecture.mp4?token=...', expiresAt: new Date(Date.now() + 3600_000).toISOString() },
  { collections: ['week-1'], principals: ['group:students'] });
// registration.assetId and registration.jobId: keep both against your own record
registration = content.register_asset(
    org_id,
    {"externalId": "lecture-2026-09-02", "filename": "Cardiology week 3.mp4", "kind": "video",
     "sourceUrl": "https://files.example.com/lecture.mp4", "sourceVersion": '"0x8DF1151249B1D1B"'},
    {"url": "https://files.example.com/lecture.mp4?token=..."},
    collections=["week-1"], principals=["group:students"])

In ASP.NET Core the call is RegisterAssetAsync(org, source, access), with a complete controller in the worked example. Three rules save time: sourceUrl is unsigned (a signed address is source_url_must_be_unsigned), sourceVersion is the storage ETag with its quotes (a made-up value is source_version_changed), and registering the same externalId and sourceVersion again is 409 source_version_already_registered. For a first load of an existing library, import registers up to 100 at a time. Put principals here if a file must never be visible to everyone: a follow-up call leaves a window where it is indexed and open.

Follow progress

A file is searchable only when its job reaches ready. Poll the job every few seconds:

const job = await content.getJob(orgId, registration.jobId);   // job.state, job.progress, job.errorCode

getJob is GetJobAsync and get_job. The states run queued, extracting, transcribing, embedding, ready; failed and cancelled are final, and retry_scheduled means a temporary problem that retries by itself. Treat a state you don't recognise as still in progress. For a list, call getActivity once instead of asking about each file. In the dashboard: Content search → Library lists every file with its state and updates by itself; open one and choose Processing to see each run and why it failed.

3Search from your backend

Your frontend never calls plycdn. It calls routes on your backend, which decide who is asking.

ASP.NET Core. AddPlycdnContentSearch mounts the routes at /api/plycdn-content/v1. Register how a signed-in user maps to a plycdn organization (IOrganizationContextResolver) and to principals:

builder.Services.AddPlycdnContentSearch(builder.Configuration);
builder.Services.AddScoped<IContentPrincipalResolver, ClaimsContentPrincipalResolver>();   // user id and roles

Node.js.

import { createContentSearchRouter } from '@plycdn/content-search-node/express';

app.use('/api/plycdn-content/v1', createContentSearchRouter({
  api: content,
  resolveOrganization: (req) => req.user?.plycdnOrganizationId ?? null,
  authorize: (req, access) => access === 'read' ? req.user?.roles.includes('reader') === true : req.user?.roles.includes('manager') === true,
  resolvePrincipals: (req) => (req.user?.roles ?? []).map((r: string) => `role:${r}`),
}));   // mount before express.json()

Python.

from plycdn_content_search.fastapi import content_search_router

app.include_router(content_search_router(
    api=api, resolve_organization=resolve_organization, authorize=authorize,
    resolve_principals=resolve_principals), prefix="/api/plycdn-content/v1")

Every route is refused until authorize returns exactly true. The organization and the principals come only from your resolvers: a browser that sends its own is ignored. Full option lists: Node.js and Python. To search from your own server code, with one result per file:

const found = await content.search(orgId, { query: 'what should a student bring to the viva', group: 'file',
  principals: ['user:alice', 'group:cs101'], filter: { collections: ['CS101'], language: 'hi' } });

4Show results and play from the moment

Each result has a snippet with highlights, a relevance from 0 to 1 and a confidence (high, moderate, low). Where it matched, it carries a location: playFromSeconds for a recording, page for a document. With one result per file, moments lists every place that file matched (up to twenty). When items is empty and withheldAsWeak is above zero, say that nothing answers the question closely enough.

React. The package gives hooks and helpers, so the interface is yours:

import { ContentSearchProvider, useSearch, markSpans } from '@plycdn/content-search-react';
import { PlycdnProvider, PlycdnPlayer } from '@plycdn/react/video';
import { librarySource } from '@plycdn/content-search-react';

<ContentSearchProvider config={{ apiBaseUrl: '/api/plycdn-content/v1' }}>
  <Results />
</ContentSearchProvider>

function Results() {
  const { results, search } = useSearch({ group: 'file' });
  // search('viva questions') after the person submits: a search is billed, so never on render
  return results?.items.map(file => (
    <article key={file.assetId}>
      <h3>{file.filename}</h3>
      <p>{markSpans(file.snippet, file.highlights).map((run, i) => run.mark ? <mark key={i}>{run.text}</mark> : run.text)}</p>
      {/* a library video plays at the matched second */}
      <PlycdnPlayer {...librarySource(file)} startSeconds={file.moments?.[0]?.location.playFromSeconds} />
    </article>));
}

Render the marked runs as text, never as HTML. apiBaseUrl is your backend's route, never plycdn's. The player is a plycdn video component: wrap it in PlycdnProvider as in Play video. For files outside your plycdn libraries, open the file at location.playFromSeconds or location.page in your own player.

Angular. Add PlycdnContentSearchModule.forRoot() once, then one element:

<plycdn-content-search (resultSelected)="onResultSelected($event)"></plycdn-content-search>

It searches, shows results with the matched words marked and jump-to links for each moment, and plays in place: a recording seeks to the moment, a document opens at the page. Step through matches with Previous and Next, or ] and [ (Moving between matches). To play on another page, give each result a real link with contentPlaybackLink and render <plycdn-content-playback [assetId]="assetId" [startSeconds]="startSeconds"> there (A real link to your playback page).

To let people narrow as they go ("tax", then "audit" within those results) and search selected documents, use <plycdn-scoped-search> in Angular or <PlycdnScopedSearch /> in React (details).

5Written answers with citations

A written answer is one search plus one answer. Every claim is cited by number and each citation carries its moment or page.

const reply = await content.answer(orgId, { query: 'what should a student bring to the viva', principals });
if (reply.answer === null) { /* show "nothing here answers that": a result, not an error */ }
else showAnswer(reply.answer, reply.citations.filter(c => c.cited));

Show the citations with cited: true and link each to location. Send quotes: true and each claim quotes its passage word for word, with verified telling you whether it is really there. To show the answer as it is written, use streamAnswer (Python stream_answer, ASP.NET Core StreamAnswerAsync); stopping early is not charged. A follow-up ("and for next year?") sends the conversation so far as conversation. In the browser, the ready-made assistant does all of this:

<plycdn-assistant [suggestions]="['What does the attendance policy say?']"></plycdn-assistant>
<PlycdnAssistant suggestions={['What does the attendance policy say?']} onCitation={(source) => openInYourPlayer(source)} />

Call clear() when someone signs out. Details: Answering a question and Add the assistant to your app.

6Suggestions as someone types, and related content

Suggestions complete a partly typed query from searches that found something. A query is offered only after several searches have used it, so one person's search is never shown to a colleague. They are not billed. Needs the suggestions feature.

const { data } = useSuggestions(typed);   // React; data.items[].query

The same in code: content.suggest(orgId, typed), suggest(org, prefix, limit) in ASP.NET Core and content.suggest(org_id, typed) in Python. A prefix under two characters answers an empty list.

Related content is a "you might also want" panel on a file's page: useRelated(assetId) in React, content.related(assetId) in Angular, getRelated(orgId, assetId, { principals }) on your server. Pass the reader's principals here too. It counts as one search and needs the related feature.

7Permission-aware search

By default anyone who can search your organization finds any file. To restrict, mark a file restricted and list the principals who may find it: a user, a group, a role or a course, as opaque strings your system understands.

1. Restrict a file (Enterprise plans: the permissions feature). In the dashboard: Library → open the file → Access → Only listed identities, add user:alice and the groups, Save access. From code:

await content.setPermissions(orgId, assetId, 'restricted', { principals: ['group:cs101-enrolled', 'role:faculty'] });

SetPermissionsAsync in ASP.NET Core, set_permissions in Python. It replaces the whole list, so mirror your own system rather than adding one by one. For everything you register, requireAcl: true in your settings makes new files restricted until you say otherwise.

2. Say who is asking on every search. In the routes above, that is resolvePrincipals (or IContentPrincipalResolver). Send every principal the person holds, expanded on your side. A restricted file is found when any one matches, and is removed before ranking: it is never retrieved, scored or counted.

3. Check that a restricted user sees nothing. Search as someone with no grant and confirm the file is absent, then as a granted one and confirm it appears:

const q = { query: 'viva questions', group: 'file' as const };
const nobody = await content.search(orgId, { ...q, principals: ['user:nobody'] });
const alice = await content.search(orgId, { ...q, principals: ['user:alice', 'group:cs101-enrolled'] });
console.assert(!nobody.items.some(i => i.assetId === assetId) && alice.items.some(i => i.assetId === assetId));

In the dashboard: Content search → Search → Filters → Search as, type user:alice, group:cs101-enrolled and search: it shows only what they may see. The account audit records each of these as dashboard.search_as (the count of principals, never who or what you asked).

Leaving principals out is not "search everything": only open files are returned. Collections and tags narrow a search but never grant access.

8Group, tag and narrow

Collections are named groups addressed by an id you choose (a course code): create them in Content search → Collections, or at registration with collections. Tags are your own metadata on a file (Library → file → Tags & collections); changing them costs nothing and never re-indexes. Narrow a search with filter: collections, metadata, language, speaker, createdAfter and the rest. Registering against a collection that doesn't exist is 400 collection_not_found.

9Tune what people find

  1. Find the gaps. Content search → Search analytics: Searched for, not found lists queries that returned nothing. Each is either missing content or a different word for something you hold. From code: getSearchAnalytics. Needs the analytics feature.
  2. Teach and rank. Content search → Search relevance has three tabs: Synonyms (your word for a thing, such as "viva" also matching "oral examination"), Boosts (promote or demote files by a tag or kind, Gentle to Strong) and Pinned results (Pin files to an exact query, up to 10, in order).
await content.setSynonyms(orgId, [{ term: 'viva', expansions: ['oral examination'] }]);   // replaces the dictionary
await content.setBoost(orgId, { name: 'current-term', match: { term: '2026-autumn' }, factor: 3 });
await content.setPins(orgId, 'exam timetable', [assetId]);
  1. Search again and check. Tuning changes the order only: relevance stays comparable between queries, a boost can't hide a result, and a pin promotes a file only if it answers the query well enough to be found. Needs the tuning feature. See Tuning relevance.

10Fix a transcript, and what processing again costs

A misheard name or term can be corrected. In the dashboard: Library → open the recording → Transcript, click a line, correct it, Save corrections. Search updates within a minute or two and the audio is not processed again. From code, read the transcript, then send the lines you changed with its revision:

const t = await content.getTranscript(orgId, assetId);
await content.correctTranscript(orgId, assetId, t.revision, [{ id: t.segments[0].id, text: 'hello class' }]);

If the file was indexed again since you read it, the answer is 412 revision_conflict: read it again. A document has no transcript (409 transcript_not_ready for one still processing).

You never pay for the same work twice:

  • A transcript correction is not charged. Neither is Re-index (the file's page, or reindex), which reuses what was already extracted. Re-tagging, permissions and collections never re-index.
  • A failed run, a file with no speech, and a cancelled run are not charged.
  • A new sourceVersion is charged once it finishes, as new content.
  • A file whose bytes are identical to one already charged anywhere in your account is made searchable from that work and charged nothing, as is a renamed file in a connected source.

Details: Indexing: charged only on success and duplicate content.

11Keep an eye on it: Library health

Content search → Library health shows files that need you. Needs attention are files that failed or wait on you; Gone from storage are files no longer where they were registered (hidden from search, not deleted, restored on their own when the file returns); Nothing to find indexed successfully but hold nothing searchable, usually a document with no readable text or a recording with no speech. From code: getLibraryHealth. Nothing retries a failed file by itself: fix the cause, then re-index.

To narrow the list, choose a Kind of file (video, audio or document) and, on Needs attention, What is wrong (failed, waiting for access, or re-index failed). Each filter shows as a chip above the list with a way to clear it, and stays in the page's address, so a filtered view can be shared. The filters narrow the files the page has loaded (up to 100 at a time); the counts on the tabs are always the totals.

12See what people search for

Content search → Search analytics shows, for the last 7, 30 or 90 days (Period): how many searches there were, the share that Found nothing, results per search and the typical response time; Searches per day; Most searched; and Searched for, not found, the list worth acting on: content your library is missing, or wording worth a synonym under Search relevance. Choose a query to run it again in Search. The period stays in the page's address, and a chip shows when it is not the default 30 days. It needs search analytics in your plan.

From code (admin key):

GET /api/content/v1/analytics/searches?days=30&limit=25

ASP.NET Core

ContentSearchAnalytics analytics = await content.GetSearchAnalyticsAsync(orgId, days: 30, limit: 25);

Node.js

const analytics = await content.getSearchAnalytics(orgId, { days: 30, limit: 25 });

Python

analytics = api.get_search_analytics(org_id, days=30, limit=25)

The answer carries summary, topQueries, daily and withoutResults (the searches that found nothing). days is 1 to 365 and limit 1 to 200. Query text is kept 90 days by default (searchLogDays, 0 keeps none); recordingSince says from when, and searches before it are counted but their words were not kept. See Seeing what people search for.

Check it works: search your library for a phrase that is in it, and once for one that isn't. Open Search analytics: both are in Most searched, and the second is under Searched for, not found. Choose it to run it again in Search.

Problems:

  • 403 not_included_in_plan, or the page says search analytics aren't part of your plan: your plan doesn't include search analytics; see Account → Usage → Plan.
  • 403 admin_key_required: the call needs an admin key, on your server.
  • The figures look lower than your usage statement: they cover searches since recordingSince only, or searchLogDays is 0.
  • 422 validation_error: days or limit out of range.

13Check it works

  1. Make a small test bucket or library searchable, or register one short recording.
  2. Open Content search → Library: the file reaches ready.
  3. In Content search → Search, type something said in it. The result shows the moment; open it and it plays there.
  4. Choose Write an answer with Check quotes on: the answer cites the same moment.
  5. Restrict the file to user:alice, then use Search as with user:nobody: no result. With user:alice: the result.
  6. From your application, sign in as a test user and repeat the search through your own route.

14Questions and problems

Every call from the browser answers 403 forbidden. No authorize is registered, or it returns something other than the boolean true.

A file I uploaded never shows up. Open it in the Library. awaiting_source_access means the access link expired: supply a fresh one (Access tab). source_host_not_allowed means the file's host isn't on your storage host list in Indexing settings. A file ready but with nothing to find appears in Library health.

A bucket's file says "Skipped". The reason is shown on the file: protected delivery, residency, unsupported kind, too large, no readable copy or unreadable. Skipped files are checked again by themselves when the cause goes away.

A restricted file is invisible to my signed-in users. Your resolver returned no principals, or none of them is on the file's list. With no principals, only open files are found.

not_included_in_plan. The feature (permissions, tuning, answers, analytics, suggestions, related, connectors) isn't in your plan. GET /plan says what is.

quota_exceeded (429). A monthly limit is reached; it is not retryable until the month resets or the limit is raised (Usage and quotas).

The answer is null. Nothing in the library answers the question closely enough. That is a result, not a failure.

Connector stops with connector_credential_cannot_list or connector_credential_expired. The Azure token lacks List, or has expired: create a new one against a stored access policy and save it on the connector.

The preview doesn't play a moment. Allow your plycdn delivery address in media-src, connect-src and frame-src if your site sets a content security policy (Network requirements).