Skip to content

Build extensions / Concepts

Read and add files

Every hook gets the names and sizes of a download’s files. To do more, an extension asks for it in package.json:

"files" Lets it Users see
"contents" Fingerprint and upload the download’s files “Read the contents of your downloads”
"add" Add files, such as subtitles, next to them “Add files, such as subtitles, next to your downloads”
["contents", "add"] Both Both
package.json
{
"permissions": {
"files": "contents"
}
}

Users see what it asks for on the extension’s page, and before they install it.

Files only exist once a download has finished, so only afterFinish hooks can use them, and afterChecks hooks once the download has finished. A beforeStart hook that asks gets an error: “File 0 isn’t available: it isn’t downloaded yet, or it isn’t part of this download.”

Extensions never read or write files directly, and never see where they’re saved. They ask OrbTorrent, which does it for them, after checking the request.

files.sha256(file) returns the file’s SHA-256 fingerprint, in lowercase hex:

src/scan.ts
import { Download, Result, files } from "@orbtorrent/api";
export default async function scan(download: Download): Promise<Result> {
for (const file of download.files.filter((file) => file.wanted)) {
const sha256 = await files.sha256(file);
// Look the fingerprint up with a service, such as VirusTotal.
}
return { status: "passed", summary: "…" };
}

OrbTorrent computes it natively, so even large files are quick, and the time doesn’t count towards the hook’s CPU limit. Pass a file from download.files, or its index.

files.formData(fields) makes a multipart form to send with fetch. Give it text fields, and files from download.files:

import { fetch, files } from "@orbtorrent/api";
const form = files.formData({ file: download.files[0] });
const response = await fetch("https://www.virustotal.com/api/v3/files", {
method: "POST",
headers: { "x-apikey": apiKey },
body: form,
});

OrbTorrent streams the file from disk as it uploads, so the extension never holds it in memory.

To set a file’s name or type in the form, build it with append:

const form = files.formData({ comment: "From OrbTorrent" });
form.append("file", download.files[2], { filename: "sample.bin", contentType: "application/octet-stream" });

files.save(file, suffix, contents) adds a file next to one of the download’s, in the same folder, named after it with suffix. Players and media servers find subtitles and artwork named this way:

src/subtitles.ts
import { Download, Result, fetch, files } from "@orbtorrent/api";
export default async function subtitles(download: Download): Promise<Result> {
const video = download.files.find((file) => file.wanted && /\.(mkv|mp4)$/i.test(file.path));
if (!video) return { status: "skipped", summary: "No video in this download" };
const response = await fetch("https://subtitles.example.com/find?name=" + encodeURIComponent(video.path));
const saved = await files.save(video, ".en.srt", await response.text());
return { status: "passed", summary: `Added English subtitles: ${saved.path}`, notify: true };
}

Film (2024)/Film.2024.1080p.mkv gets Film (2024)/Film.2024.1080p.en.srt. It needs "files": "add", and returns the new file’s path, relative to the download’s folder like the paths in download.files, and its size.

  • The suffix starts with ., -, _ or a space, and ends with an extension, such as .en.srt, .forced.srt, -poster.jpg or .nfo. It can’t hold a /, so the file always lands next to the one it’s for.
  • The contents are text, saved as UTF-8, or bytes as a Uint8Array, such as an image from response.bytes().
  • Only files that can’t run. Subtitles (.srt .ass .ssa .vtt .sub .idx .sup .smi .lrc), text (.txt .nfo .md .json .xml .csv .cue), images (.jpg .jpeg .png .webp .gif .heic) and checksums (.sfv .md5 .sha1 .sha256). Never a program, a script or an app.
  • It never replaces a file, except one the same hook added to the same download before, such as when the user runs it again. If a file is already there, such as subtitles that came with the download, files.save throws ““Film.2024.1080p.en.srt” already exists. An extension can’t replace a file it didn’t add.” Catch it, and say what was there.
  • Up to 10 MB a file, and 200 files and 50 MB a run.

OrbTorrent marks each file it adds as downloaded from the internet, as browsers do, and makes it readable, never runnable.

The Checks tab lists the files a check added, with Show in Finder. They belong with the download: moving it takes them along, and removing it with its files removes them too. Removing only the download leaves them, like its own files.

Set notify: true in the result, as above, so the user hears about it without opening the Checks tab. Results explains.

  • Skip files the user didn’t want. They’re in download.files with wanted: false, and they weren’t downloaded, so asking for one throws “File 1 isn’t available”.
  • Files can be missing. If the user moved or deleted a file after it finished, asking for it throws “File 3 is missing: it was moved or deleted after downloading.” Catch it, and report what you could check.
  • Check the riskiest first, such as programs before videos, so a hook that’s stopped early has checked what matters.

In a test, give each file a sha256, which files.sha256 returns:

{
"download": {
"name": "Tool 2.0",
"files": [{ "path": "Tool 2.0/setup.exe", "size": 5000000, "sha256": "aaaa…" }]
}
}

Files a hook adds aren’t written anywhere in a test. expect checks them with saved, their paths, and savedIncludes, text in each. download.alsoOnDisk lists files already next to the download’s, which files.save won’t replace:

tests/skips-a-film-with-subtitles.json
{
"download": {
"name": "Film (2024)",
"files": [{ "path": "Film (2024)/Film.2024.1080p.mkv", "size": 4000000000 }],
"alsoOnDisk": ["Film (2024)/Film.2024.1080p.en.srt"]
},
"expect": { "status": "skipped", "summaryIncludes": "already has" }
}

Testing has the rest.