Skip to content

Uploads and Offline Tasks

NihilDigit edited this page Sep 26, 2026 · 1 revision

Uploads and Offline Tasks

Uploading

client.upload(parentId, sourcePath)

The content hash goes up first. If PikPak recognises it the upload is over — UploadResult.instantUpload is true and no bytes moved. Otherwise the bytes go to Aliyun OSS as a signed multipart upload, one part at a time; the part size is ceil(size / 10000) with a 256 KiB floor, as the Go reference computes it.

Hashing less

Hashing reads the whole file. For content PikPak may already hold, 60 KB is enough: the Xunlei CID samples three 20 KB windows, and gcidByCid looks it up in PikPak's index, which covers content the account has never held.

val cid = XunleiCid.of(size) { offset, length -> readAt(offset, length) }
val gcid = client.gcidByCid(cid, size)
    ?: PikPakHash.fromSource(openSource(), size) { hashed -> showHashing(hashed) }
client.upload(parentId, name, size, gcid, open = ::openSource) { sent -> showSending(sent) }

Fact (2026-09-25). PikPak requires a gcid at upload but does not check it: an upload under a wrong 40-hex hash completed and kept the wrong value, and instant uploads of that value would then be served these bytes. A CID miss therefore means hashing the file, not skipping the hash. Content just uploaded is in the CID index at once, and uploading it again is instant.

Content that is not a file-system Path — an Android content: URI — goes through the same overload, with open yielding it from its first byte.

Uploads that survive the process

when (val start = client.startUpload(parentId, name, size, gcid)) {
    is UploadStart.Instant -> done(start.fileId)
    is UploadStart.Pending -> save(start.session)                   // serializable
}
// later, possibly in another process
client.continueUpload(session, open = { offset -> openSourceAt(offset) }) { sent -> showSending(sent) }
client.cancelUpload(session)                                         // to give up instead

Facts (2026-09-25).

  • startUpload leaves a visible PHASE_TYPE_PENDING file and an upload task in the drive. Neither goes away by itself, and starting again does not pick them up: the new file is named "name(1)" beside the old one. Finish it or cancel it. A pending file handed to the archive service answers file not complete.
  • The session's OSS credentials expire 12 hours after the start. Past that the upload can only be cancelled; OSS refuses with 403, which reaches the caller as UrlExpiredException.
  • continueUpload asks OSS which parts arrived and sends the rest. A session saved after one part was finished by another JVM, and the file read back byte for byte.

The session holds live OSS credentials: keep it where the account's session is kept, not in logs.

When the answer is lost

A request that completes the multipart upload is not replayed after it was sent (see Errors and Retries): if OSS completed it and the answer was lost, a replay finds the upload gone and reports failure. For the same reason a later continueUpload of that session finds no parts. So a 404 from OSS is checked against the drive file, and a file already COMPLETE counts as done; and cancelUpload — which upload() also calls on any failure — leaves a COMPLETE file alone. Before this, the cleanup path permanently deleted a finished upload that merely looked failed.

A 403 from OSS always reads as expiry, including one caused by a skewed clock in the Date header; OSS names the difference only in the body's <Code>, which is not parsed.

Offline downloads

when (val r = client.createUrlFile(parentId, "magnet:?xt=...")) {
    is CreateUrlResult.Queued          -> r.task.id
    is CreateUrlResult.InstantComplete -> r.file
}
var task = client.getTask(taskId)
while (task.phase !in TaskPhase.TERMINAL) { delay(3.seconds); task = client.getTask(taskId) }
task.fileId                                                    // set once COMPLETE
client.pruneOfflineOutput(task, keep = setOf("01.mkv", "Extras/NCOP.mkv"))

An offline download takes five to ten seconds on content PikPak already holds and minutes on content it must fetch from the swarm, where resolveMagnet plus instantCreate answers in about a second (Magnets and Instant Create). It is still the cheaper path for more than a file or two: it is charged its size against the monthly offline allowance, where an instant copy is charged 15 % against the much smaller upload allowance — about six times more per byte. It lands under a folder named after the torrent.

The SDK owns no polling loop; when a task counts as done is the caller's decision.

  • getTask(id) reads one task of any type — offline, restore, decompress, pack, copy share the shape (DriveTask) and the endpoint. Poll with it rather than with the listing, which pulls the whole table each time.
  • listOfflineTasks() is the raw listing. Its default filter, running and errored, is the "in progress or broken" view: it leaves out PENDING, where a new task starts, and COMPLETE, so it cannot follow one task to its end. The server has no id filter here (400).
  • Fact. The listing and getTask can disagree. The listing joins in the produced file (with=reference_resource), so a task whose file was later deleted reads as ERROR / "File deleted" there while getTask still says COMPLETE / "Saved". Ask getTask about the transfer, the listing about the file.
  • deleteOfflineTasks(ids, deleteFiles). Fact (2026-09-23). With deleteFiles = false a finished task's file stays; an unfinished task's placeholder goes with the task regardless.
  • clearOfflineTasks(phases) is the web client's "clear completed" / "clear unfinished". Its request shape comes from the web client and was never run against a real account, since it cannot be aimed at test data.
  • Retrying a failed task is submitting its URL again: createUrlFile(parentId, task.params["url"]!!). The server's own RETRY accepted every task tried on 2026-09-23 and sent each back to ERROR "Save failed, retry please" two seconds later.

Keeping part of a torrent

A magnet always downloads whole; there is no file-selection field. pruneOfflineOutput(task, keep) takes a completed task down to the files named and deletes the rest permanently. It is not a cleanup heuristic: the caller names what to keep, and it only maps those names onto the output.

Fact (2026-09-24). The output is laid out as resolveMagnet reports the paths: a torrent with one root folder becomes a folder of that name holding the same relative paths, subfolders included; a single-file torrent becomes the file itself. A torrent with several top-level entries was not observed. PikPak drops some files on its own — an AV release that resolved to five files, two of them advertising .txt and .html, landed as three; images were kept — so a kept path with no file comes back in missing, not as an error.

A folder with nothing to keep is deleted whole without being listed, so pruning a pack to one episode costs one listing per level on the kept path. A single-file torrent's file is kept whenever keep is not empty: it is the only path, and PikPak may have renamed it to "name(1)" beside an existing file, which a name match would have deleted.

Clone this wiki locally