Moving your library to plycdn, step by step

This page is for the day you bring what you already have into plycdn: files from another store or a folder, and videos from another platform, in bulk and without watching over it. It covers the copy, carrying on after an interruption, keeping your own ids, checking that nothing is missing and that requests reach plycdn, switching your application over, and making the result searchable. The reference is in S3 and Azure compatibility, Integration, Storage and Video.

You need: an admin API key, your plycdn organization id, and either a bucket (Storage → Storage buckets → New bucket) for files or a video library (Video streaming → Video libraries → New library) for videos. Plan the destination first: bucket and library names are unique across plycdn, so choose names that are yours, and a region close to your viewers.

To keep your site live on the same addresses while the files move, so that nothing is switched until it is ready, see Moving from another cloud.

1How it works

  1. You create the destination: a bucket for files, a video library for videos.
  2. You create a storage credential, a key for S3 and Azure tools, and copy with a tool you already know, or with one call from your server.
  3. Every video that lands under a library's sources/ folder becomes a video and starts processing. Every file that lands in a bucket becomes a stored file.
  4. Running the same copy again sends only what is missing, so an interruption costs you nothing.
  5. You check the counts, point your application at the new addresses, and only then retire the old ones.

2Create a storage credential

In the dashboard: Developers → Storage credentials → create a credential.

  • Give it a Name that says where it is used ("migration, editors' laptop").
  • Choose read and write; add delete if you will run rclone sync. Limit it to the destination bucket or library.
  • Set an Expires date for a migration key so it can't linger.
  • Copy the secret now: it is shown once. A credential is not made from code, and an API key can't make one.

Revoke it when the migration is over (revoking takes effect within 30 seconds). The bucket's page has the hosts and ready-made settings for your tools under its S3 tools and Azure tools tabs.

3Move files in

The commands below use the bucket acme-media in mumbai. Use your bucket's own region host, shown on its S3 tools tab.

rclone, configured once in rclone.conf:

[plycdn]
type = s3
provider = Other
access_key_id = YOUR_ACCESS_KEY_ID
secret_access_key = YOUR_SECRET_ACCESS_KEY
endpoint = https://s3.in-mum.plycdn.net
region = mumbai
rclone copy ./files plycdn:acme-media/docs \
  --transfers 4 --s3-upload-concurrency 4 --s3-chunk-size 64M --checksum --fast-list --progress

From another S3-compatible store or an Azure container, name it as the source: rclone copy other:bucket/path plycdn:acme-media/path .... A bucket in a different region needs its own remote with that region's endpoint.

AWS CLI, with a profile that points at the plycdn endpoint (endpoint_url = https://s3.in-mum.plycdn.net, region = mumbai):

aws --profile plycdn s3 sync ./files s3://acme-media/docs/

AzCopy, with a SAS that has read and write:

azcopy copy "./files/*" "https://ACCOUNT.blob.REGION_HOST/acme-media/docs/?SAS" --recursive --overwrite=false

From your own code, with our .NET, Node.js or Python package (Developers → SDKs): pass the region and its host label. Both are on the bucket's S3 tools and Azure tools tabs, with ready code under plycdn .NET, plycdn Node.js and plycdn Python. For mumbai the label is in-mum:

var service = PlycdnBlob.CreateServiceClient("ACCOUNT", accountKey, "mumbai", hostLabel: "in-mum");
await service.GetBlobContainerClient("acme-media").GetBlobClient("docs/intro.pdf").UploadAsync("intro.pdf", overwrite: true);
const service = PlycdnBlob.createServiceClient('ACCOUNT', accountKey, { region: 'mumbai', hostLabel: 'in-mum' });
service = PlycdnBlob.create_service_client("ACCOUNT", account_key, region="mumbai", host_label="in-mum")

The S3 clients take the same hostLabel / host_label. The full list is in Region host label.

Things worth knowing:

  • Point tools at the plycdn endpoint only. It checks your credential, applies the bucket's rules and records each file.
  • At most 16 transfers at once per machine (4 × 4 above). To go faster, run more machines.
  • Names are the keys. Keep your folder structure; a key is 1 to 1,024 bytes with no // and no leading or trailing /. A \ in a file name is treated as a folder separator (/), so Windows paths work as they are.
  • A bucket's upload rules apply (allowed types, largest file, replacing files). A file that breaks a rule is refused for that file only.
  • Set each file's content type on upload: it decides how browsers treat the file.
  • The copy is counted as uploads only; reading through these tools is not delivery, so show files to viewers with signed links or a public bucket, not with tool links.

4Move videos in

Every file under a library's sources/ becomes one video, titled after its file name. Folders inside sources/ are fine. Videos are processed as soon as they land.

In the dashboard: open the library → Videos → Bulk upload. It shows the exact settings for this library in three tabs, S3 tools, Azure tools and Server packages. For a few files, Upload videos or dragging them onto the page is enough.

With your tools:

rclone copy ./videos plycdn:acme-lectures/sources --ignore-existing --progress --transfers 4
aws --profile plycdn s3 sync ./videos s3://acme-lectures/sources/

With your server packages (an admin key; the folder is on the server). One call uploads every video file of a folder, four at a time, resuming each file and skipping the ones already added:

ASP.NET Core

var results = await uploader.UploadVideosAsync(orgId, libraryId, "/data/videos");
foreach (var r in results.Where(r => r.Error is not null)) logger.LogWarning("{File}: {Error}", r.File, r.Error);

Node.js

const uploads = new PlycdnVideoUploader(new PlycdnVideoApi(storage), new PlycdnUploader(storage));
const results = await uploads.uploadVideos(orgId, libraryId, '/data/videos');   // each: { file, key, videoId } | skipped | error

Python

results = uploads.upload_videos(org_id, library_id, "/data/videos")              # each: {file, key, videoId}, with skipped or error

Only video files are taken; names starting with a dot and symlinked files are left out.

Carrying on after an interruption

Run the same command again. A key a video already came from is skipped, and nothing is processed twice:

  • rclone with --ignore-existing, aws s3 sync and AzCopy with --overwrite=false skip what is there (for AzCopy make the SAS with read as well as write). A video's source stays listed in sources/, read-only, so these tools see it.
  • A tool that sends it anyway gets video_source_exists for that file only and carries on.
  • The server packages report a skipped file as skipped (Skipped in .NET), and a file that failed as error (Error) with the others unaffected. Run the call again to retry only the failures.
  • A video that never became ready (video_source_missing, upload_abandoned) is replaced by uploading it again to the same name.

Videos from another platform

Download each video first (most platforms offer a download or an export), then push the folder in with either way above. Keep the downloaded folder until every video shows as ready.

5Keep your own ids

Your application has its own ids for these videos. Keep the mapping to plycdn's video ids in one of two ways.

Name the files with your ids. Save lesson-1042.mp4 as is, copy as above, then ask for the video each file became. A video's sourceKey is the key you uploaded:

ASP.NET Core

var v = await videos.GetVideoBySourceKeyAsync(orgId, libraryId, "sources/lesson-1042.mp4");
await videos.UpdateVideoAsync(orgId, v!.Id, new UpdateVideoRequest(Title: "Lesson 1042", Metadata: new Dictionary<string, object?> { ["oldId"] = 1042 }));

Node.js

const v = await video.getVideoBySourceKey(orgId, libraryId, 'sources/lesson-1042.mp4');
if (v) await video.updateVideo(orgId, v.id, { title: 'Lesson 1042', metadata: { oldId: 1042 } });

Python

v = video.get_video_by_source_key(org_id, library_id, "sources/lesson-1042.mp4")
if v:
    video.update_video(org_id, v["id"], {"title": "Lesson 1042", "metadata": {"oldId": 1042}})

metadata is up to 50 keys of strings, numbers or booleans (8 KiB as JSON), and a new metadata replaces the old, so send every key you want to keep. title is 1 to 200 characters. To set a title during the copy instead, add it as file metadata (--metadata-set title="Lesson 1042" with rclone, --metadata title="Lesson 1042" with the AWS CLI).

Create each video first, with your id in it. When you already hold a record for each video, create the video with its metadata, keep the returned id in your database, then send the file:

Node.js

const created = await video.createVideo(orgId, libraryId, {
  title: 'Lesson 1042', size, contentType: 'video/mp4', fileName: 'lesson-1042.mp4', metadata: { oldId: 1042 },
});
await db.setPlycdnId(1042, created.id);                                   // your own record
await uploader.continueUpload(orgId, created.upload, createReadStream(path));

ASP.NET Core uses CreateVideoAsync with Metadata: and ContinueUploadAsync; Python uses create_video and continue_upload. See Adding a video.

GET /libraries/{libraryId}/videos?sourceKey=… finds a video by its source, and the list is in the dashboard under the library's Videos tab.

6Check the counts, and that nothing is missing

Files. Compare both sides after the copy:

rclone check ./files plycdn:acme-media/docs --fast-list --one-way

0 differences found is the answer to look for. --one-way checks that everything in the source arrived. In the dashboard, the bucket shows its file count and size; compare them with your source.

Videos.

  1. The library's Videos tab title shows the number of videos (Videos (412)); compare with your source count. From code the library's videoCount.
  2. Filter the tab by status failed: each failed video says why. From code: ListVideosAsync(orgId, libraryId, status: "failed"), listVideos(..., { status: 'failed' }), list_videos(..., status="failed"); the reason is errorCode (Statuses).
  3. Wait until nothing is queued, encoding or playable. Or subscribe to video.ready and video.failed under Developers → Webhooks and count them (Webhooks, step by step).
  4. Play three or four of the longest and the oldest in the dashboard (open the video).

For each failure fix the file and upload it again to the same name (for a video that never became ready) or under a new one: unreadable files (video_unreadable) are usually fixed by exporting them as MP4. Failed videos aren't charged.

7Check that requests reach plycdn

When a copy fails, or an application seems to ignore the new address, look at what plycdn received. Every request made to your storage endpoints is in the Request log, including the ones plycdn refused, so you can tell in a minute whether the problem is in your tool, your credential or the address.

In the dashboard: Storage → Request log. It also opens from a bucket's Requests action (in Storage buckets and on the bucket's page), from a video library's Requests action, from a credential's View requests action under Developers → Storage credentials, and from the S3 tools and Azure tools tabs. Choose a time range, then narrow it by bucket, credential or access key, operation, result, request ID, file name prefix or client IP. Each active filter shows as a chip you can clear. The filters are kept in the page address, so you can send the view to a colleague. The screen shows how many days are kept.

  1. Run one command from your tool, for example rclone lsf plycdn:acme-media --max-depth 1.
  2. Open Storage → Request log, pick Last hour, and type the credential's access key ID in Access key ID (or use View requests on the credential, which does the same). Refused requests are tied to the credential their access key ID belongs to, a wrong secret included, so Credential finds them too. One kind isn't tied to a credential: an Azure request refused while your storage account has more than one key (the request doesn't say which key signed it). Find it by leaving Credential empty and choosing a Result; the screen reminds you of this under the Credential filter. If nothing shows at all, check the access key ID your tool uses first: a request signed with a key ID that isn't one of yours (a typo, or a key from your old provider) isn't kept in your log.
  3. Find the row for your command. Read Operation and Result: the result is in plain words, such as Succeeded or Wrong secret, with our error code underneath. Select the row to see every detail, including Code and Request ID, both of which you can copy.
  4. To see where requests come from, read the Client IP column, or type an address in Client IP to see only the requests from it. Over the API, pass clientIp.
What you see (code underneath) What it means What to do
Succeeded (status 200 or 204) The request reached plycdn and worked The problem is after the copy: check the counts and your own addresses
Wrong secret (signature_invalid, S3 SignatureDoesNotMatch) The secret is wrong, or something changed the request after it was signed Copy the secret again from where you saved it; check for a proxy that rewrites headers, and that the endpoint host is the one in S3 tools
Unknown access key (credential_invalid) The credential is revoked or expired Create a new credential
Not allowed (permission_denied) The credential lacks the permission or does not reach this bucket Create a credential with the permission, limited to this bucket
No such bucket (bucket_not_found) No bucket of that name in your organization Check the bucket name and the organization the credential belongs to
Clock is off (clock_skew) Your machine's clock is more than 15 minutes off Synchronize it
No row at all The access key ID (or Azure account name) isn't one of yours, so the request isn't kept in your log; or the request never reached plycdn First compare the access key ID in your tool with the one under Developers → Storage credentials: a mistyped key ID leaves no row. Then check the endpoint and the host label in your tool (s3.in-mum.plycdn.net for Mumbai), and that your network lets the request out

A request refused before plycdn could tell what it was shows the operation Not recognised (the row's details show the value the API returns, Unknown, beside it). Refused requests are listed under the access key ID or account name they were signed with, when it is one of yours; a mistyped key ID leaves no row. Each row has a request ID: the same ID is in the error your tool printed (RequestId for S3, x-ms-request-id for Azure), and you can paste it into the Request ID filter. The log never holds your secret, signatures, SAS tokens, query strings or file contents. The full list of codes is in S3 and Azure compatibility.

Over the API. The same filters are query parameters. An admin or standard key works, and for a sub-organization add the X-Organization-Id header:

GET /api/content/v1/storage/requests?from=2026-10-27T09:00:00Z&accessKey=PLYXXXXXXXXXXXX&status=error&limit=50

ASP.NET Core

var page = await storage.GetStorageRequestsAsync(orgId, new StorageRequestQuery {
    From = DateTimeOffset.UtcNow.AddHours(-1), AccessKey = "PLYXXXXXXXXXXXX", Status = "error" });
foreach (var r in page.Items) Console.WriteLine(quot;{r.Time:u} {r.Operation} {r.Bucket}/{r.Key} {r.Status} {r.Code} {r.RequestId}");
// await foreach (var r in storage.ListStorageRequestsAsync(orgId, query)) follows every page

Node.js

const page = await storage.getStorageRequests(orgId, {
  from: new Date(Date.now() - 3600_000), accessKey: 'PLYXXXXXXXXXXXX', status: 'error',
});
for (const r of page.items) console.log(r.time, r.operation, `${r.bucket}/${r.key}`, r.status, r.code, r.requestId);
// for await (const r of storage.listStorageRequests(orgId, query)) follows every page

Python

page = storage.get_storage_requests(org_id, from_=datetime.now(timezone.utc) - timedelta(hours=1),
                                    access_key="PLYXXXXXXXXXXXX", status="error")
for r in page["items"]:
    print(r["time"], r["operation"], f'{r["bucket"]}/{r["key"]}', r["status"], r["code"], r["requestId"])
# for r in storage.iter_storage_requests(org_id, access_key="..."): follows every page

Newest first. status takes a class (2xx, 4xx, 5xx), error for anything from 400, or an exact number; code matches our error code exactly. When nextCursor is not null, pass it as cursor for the next page, with the same filters: the cursor only marks where the next page starts, so different filters give you a different list. retentionDays in every answer is how far back you can look: 30 days unless plycdn has extended it for your account, for example during a migration.

If the search fails:

Error Why What to do
validation_error (422) A time without a zone (use Z or an offset), to before from, a limit outside 1 to 500, or a malformed cursor Fix the value; pass nextCursor exactly as it came, or start again without cursor
sub_organization_not_found (404) The organization filter names an organization that is not yours Use an id from your own organizations
query_timeout, query_too_expensive (400) The range and filters match too much Narrow the time range, or add a bucket or access key
request_log_unavailable (503) The log can't answer just now. Retryable Try again in a minute

8Switch your application over

  1. Add the routes your backend needs so your pages can play and download: Playing video for libraries and Delivering files and video for files.
  2. Store the new ids or keys next to the old ones in your database (the mapping above), and have your pages read the new ones when they exist.
  3. Switch one place first, a staging copy or one course, then the rest.
  4. Use your own domain if your old links lived on one: add it under Delivery → Custom domains before the switch, so the addresses people see stay yours.
  5. Keep the old addresses working for as long as old links are out in the world: have your server answer each one with your own page (or player) for that file, with a freshly signed link for the new location in it. Answer the page itself, not a redirect to the file.
  6. Keep the old store until every video shows as ready and you have watched real traffic on the new one for a while; then revoke the storage credential you made for the move.

9Make it searchable afterwards

If you want to search the videos and documents by what is said and shown in them, switch search on after the copy, so nothing is read until you decide. For a video library: open it → Settings → Search. A video is read from its kept original or its MP4 download, so keep one of them. Videos you uploaded before are included, and a video identical to one already searchable in your account is made searchable from that work. Searching, step by step covers asking questions of the result, and Make a video library searchable the rules.

10Check it works

  1. Copy five files or videos with the commands above.
  2. Run the same command again: it sends nothing.
  3. The counts on the bucket or library match, and no video is failed.
  4. Your page plays a video and opens a file through your own route.
  5. Revoke the credential and confirm the tool is refused.
  6. In Storage → Request log, find your five requests (Check that requests reach plycdn).

11Questions and problems

AccessDenied from a tool. The credential lacks a permission the operation needs (write to upload, read to look, delete for sync), or it doesn't reach this bucket, or it has expired. Create a new one.

SlowDown or ServerBusy. You passed a rate or transfer limit. Leave the tool's retries on, lower --transfers, or run the copy from more machines.

the host label of region 'mumbai' is not known. Your code gave our package a region without its host label, so it stopped before sending anything. Add hostLabel: "in-mum" (.NET and Node.js) or host_label="in-mum" (Python), copied from the bucket's S3 tools or Azure tools tab, or leave the region out of an Azure client to use the global host. See Region host label.

RequestTimeTooSkewed. Your machine's clock is more than 15 minutes off. Synchronize it.

A file copied but is not a video. A library takes video/* only, and only under sources/. Files elsewhere in a library, or of another type, are refused with validation_error or file_type_not_allowed. Put other files in a bucket.

video_source_exists. A video was already made from that key. Delete that video first to replace it, or upload under another name.

plan_limit_reached. You reached your plan's bucket, storage or open-upload limit (1,000 at once). Let uploads complete, or ask about a higher plan.

file_too_large. A file passed the bucket's or your plan's largest size. Check Largest file in the bucket's settings.

A video is failed with video_too_long. It is longer than your plan allows: trim it, or ask us about a plan with longer videos.

Titles are the file names. Set a title with UpdateVideoAsync, updateVideo or update_video, or with file metadata title during the copy.

Everything is slow. Large copies are limited by your own line first. Run rclone close to the source, and keep four transfers per machine.