Watch time per viewer, step by step

For courses, compliance training, onboarding, member content, patient education and anything else where you need to know how long each person really watched. Name the viewer on your server and plycdn keeps their watch time in every video library, then gives you a report per viewer and per video: in the dashboard, from your server, as a CSV, and in a table inside your own application. The rules and fields are in the reference: Watch time and Playback sessions and progress.

You need: a video library, 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. Watch time is in every plan.

1How it works

  1. Your server tells plycdn who is watching, with an id of your own for that viewer: the resumeId. In the dashboard it is called the viewer id.
  2. plycdn starts a playback session for that viewer. The player adds the seconds it has really been playing to the position it already saves, every two minutes (every minute in a library with serial playback), and when the viewer pauses, reaches the end or leaves the page. It sends nothing extra.
  3. plycdn adds it up per viewer, per video and per day.
  4. You read the report whenever you need it.

Viewers your server doesn't name are not tracked: they send nothing and nothing is kept for them.

2What counts

  • Watch time is the time the video was actually playing for that viewer. Pauses don't count. Watching again counts again, and an hour at double speed counts as an hour.
  • Watched to (watchedSeconds) is different: the furthest point the viewer has reached.
  • Two tabs playing at once both count, each only for the time that really passed.
  • Resetting a viewer's progress for a retake starts their position over. Their watch time is history and stays.
  • Watch time arrives in batches, so a report can be a few minutes behind a viewer who is still watching.

3Choose your viewer id

Your own id for whoever is watching: a learner, an employee, a member, a patient, a customer or a seat. Use the same id on every visit and every device so the report follows the person.

  • 1 to 128 characters of A-Z a-z 0-9 . _ : @ -.
  • Never an email address or a name: plycdn doesn't need to know who the person is, and the report and its CSV carry the id you chose.
  • One id per person per course (such as learner-42:course-7) if you want separate totals for the same video in two courses.

4Name the viewer on your server

With plycdn's server packages this is the only code you add. The players already ask your backend's sign route for a link; once the route knows the viewer's id, it starts a session for them by itself, in every library.

ASP.NET Core

builder.Services.AddPlycdnVideo(builder.Configuration);
builder.Services.AddPlycdnStorage(o =>
    o.PlaybackResumeIdFor = (http, library, video) => http.User.FindFirst("member")?.Value);

Node.js

app.use('/api/plycdn/v1', createPlycdnRouter({
  storage,
  signer: new PlycdnLinkSigner(storage),
  video: new PlycdnVideoApi(storage),
  resolveOrganization: (req) => req.user?.plycdnOrganizationId ?? null,
  authorize: (req, operation, bucketId, key) => true,   // your own rules
  playbackResumeIdFor: (req, libraryId, videoId) => req.user?.memberId ?? null,
}));

Python

def playback_resume_id_for(request, library_id, video_id):
    user = getattr(request.state, "user", None)
    return user.member_id if user else None

app.include_router(plycdn_router(
    storage=storage, signer=AsyncPlycdnLinkSigner(storage), video=AsyncPlycdnVideoApi(storage),
    resolve_organization=resolve_organization, authorize=authorize,
    playback_resume_id_for=playback_resume_id_for,
), prefix="/api/plycdn/v1")

Return nothing (null or None) for a visitor you can't name.

5Play the video

Nothing changes in your frontend. plycdn's players count the playing time by themselves, with no prop and no code.

React

import { PlycdnProvider, PlycdnPlayer } from '@plycdn/react/video';

<PlycdnProvider config={{ baseUrl: '/api/plycdn/v1' }}>
  <PlycdnPlayer libraryId={libraryId} videoId={videoId} />
</PlycdnProvider>

Angular

<plycdn-player [libraryId]="libraryId" [videoId]="videoId"></plycdn-player>

The players' onProgress (React) and (progress) (Angular) events also carry played, the seconds this session has been playing. They come from the browser and are fine for a progress bar of your own; read the report from your server for anything you enforce or show to others.

With a player of your own

Start the session from your server (StartPlaybackAsync, startPlayback, start_playback) and give its url to your player:

POST /api/content/v1/playback-sessions   {"bucketId": "<library id>", "videoId": "<video id>", "resumeId": "member-42"}

With a resumeId the answer in any library carries pass, progressUrl, progressIntervalSeconds, watchedSeconds, durationSeconds, completed and watchTimeSeconds. Post to progressUrl as text/plain, every progressIntervalSeconds and on pause, at the end and when the page is hidden:

{"pass": "<pass>", "position": <seconds>, "played": <seconds this session has been playing>}

played is the total for the session so far, never the part since the last save, so a save that is lost or sent twice changes nothing. plycdn never counts more than the time that has really passed since the session started. When you start a new session before expiresAt, played starts again from 0.

6Read the report

In the dashboard: Video streaming → Watch time, and choose the library (the same report is on each library's Watch time tab). Choose the last 7, 30 or 90 days, or your own dates. You see the totals (watch time, viewers and videos) and a table by viewer: viewer id, watch time, videos watched, completed and last watched. Switch to By video for the same figures per video.

Narrow it with the filters above the table:

  • Live stream: a live stream's recordings only (watch time of a live stream is counted on its recording).
  • Video: one video, or one recording, and who watched it. In a long library, type in Find a video by title.
  • Viewer id: one viewer and every video they watched. Enter the id your application gives them and choose Show.

Selecting a viewer or a video in the table narrows to it too. Each filter shows above the totals and can be removed on its own, or all at once with Clear filters. The filters are kept in the page's address, so you can bookmark or share a view with your team. Download CSV saves every row of what you see, with watch time in seconds.

From your server, with your API key (standard or admin):

GET /api/content/v1/libraries/{libraryId}/watch-time?from=2026-10-01&to=2026-10-31&groupBy=viewer
What you want Query ASP.NET Core Node.js Python
Every viewer, most watch time first groupBy=viewer (the default) GetWatchTimeReportAsync getWatchTimeReport get_watch_time_report
Every video, most watched first groupBy=video GetWatchTimeReportAsync getWatchTimeReport get_watch_time_report
One viewer's videos resumeId=member-42 GetWatchTimeReportAsync getWatchTimeReport get_watch_time_report
One video's viewers videoId=… GetWatchTimeReportAsync getWatchTimeReport get_watch_time_report
Everything as a file format=csv DownloadWatchTimeCsvAsync downloadWatchTimeCsv download_watch_time_csv

Dates are YYYY-MM-DD in UTC and inclusive. Without them the report covers the last 30 days; a range can be at most 366 days. The answer carries totals and items, and nextCursor while there are more: send it back as cursor, up to limit (1 to 500, 100 when omitted) at a time. Each items entry for a viewer is:

{ "resumeId": "member-42", "watchTimeSeconds": 11520, "videosWatched": 9, "videosCompleted": 6, "lastWatchedAt": "2026-10-23T09:12:44Z" }

Node.js

const report = await video.getWatchTimeReport(orgId, libraryId, { from: '2026-10-01', to: '2026-10-31', groupBy: 'viewer', limit: 100 });
for (const row of report.items) console.log(row.resumeId, row.watchTimeSeconds);

Python

report = video.get_watch_time_report(org_id, library_id, {"from": "2026-10-01", "to": "2026-10-31", "groupBy": "viewer"})
for row in report["items"]:
    print(row["resumeId"], row["watchTimeSeconds"])

ASP.NET Core

var report = await video.GetWatchTimeReportAsync(orgId, libraryId, new WatchTimeQuery { From = new DateOnly(2026, 10, 1), To = new DateOnly(2026, 10, 31) });
await using var csv = await video.DownloadWatchTimeCsvAsync(orgId, libraryId, new WatchTimeQuery { From = new DateOnly(2026, 10, 1), To = new DateOnly(2026, 10, 31) });

A single viewer's progress now also carries watchTimeSeconds and firstWatchedAt, from the same calls as before (GetPlaybackProgressAsync, getPlaybackProgress, get_playback_progress and their list versions). In the dashboard the same appears under the video's Viewer progress.

7Show it in your own application

The report needs your API key (standard or admin), so your server fetches it and your page shows it. Your server route should check that the person asking may see it, such as a manager looking at their own team.

app.get('/api/team/time-spent', requireManager, async (req, res) => {
  const report = await video.getWatchTimeReport(orgId, libraryId, { groupBy: 'viewer', limit: 100 });
  res.json(report.items.filter(r => req.team.has(r.resumeId)));
});
function TimeSpent({ rows }: { rows: { resumeId: string; watchTimeSeconds: number; videosCompleted: number }[] }) {
  const hm = (s: number) => `${Math.floor(s / 3600)} h ${Math.round((s % 3600) / 60)} min`;
  return (
    <table>
      <thead><tr><th>Member</th><th>Time spent</th><th>Completed</th></tr></thead>
      <tbody>{rows.map(r => (
        <tr key={r.resumeId}><td>{nameFor(r.resumeId)}</td><td>{hm(r.watchTimeSeconds)}</td><td>{r.videosCompleted}</td></tr>
      ))}</tbody>
    </table>
  );
}

nameFor is your own lookup from the viewer id to a name: plycdn keeps the id, you keep who it is.

8Check it works

  1. Take a short test video in a library and sign in to your application as a test viewer.
  2. Play it for about three minutes, then pause.
  3. In the dashboard open Video streaming → Watch time and choose the library (choose the last 7 days). Your test viewer's id is in the table with about three minutes.
  4. Open the video → Viewer progress, look up the same id: Watch time and First watched are filled in.
  5. Download the CSV and open it.

9Questions and problems

The report is empty. Your server isn't naming the viewer, so nothing is tracked. Check that the viewer is signed in when the player asks for its link and that your hook returns an id. The dashboard's empty state says the same.

A viewer I watched with is missing. Watch time is saved when the player saves: every two minutes while playing, and on pause, at the end and when the page is hidden. Pause or close the tab, then reload the report.

Watch time is lower than the video's length although the viewer finished. Watch time counts playing time, and position is a separate figure: someone who skipped through a video has completed true and little watch time.

It is higher than the video's length. The viewer watched parts again. Watch time is real time spent playing, so a viewer who played at a higher speed shows less than the video's length.

404 library_not_found. The library id is wrong, or belongs to another organization.

422 validation_error. A date isn't YYYY-MM-DD, the range is longer than 366 days, or limit is outside 1 to 500.

What about live streams? Live viewing has its own figures: see Watching a live stream.

Can I delete a viewer's watch time? Resetting progress doesn't. The report keeps the history of the range.