Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ const playlistUrl = await sdk.files.getHlsStreamUrl(fileId, {
maxSubtitleCount: 1,
});

const vlcPlaylistUrl = await sdk.files.getXspfPlaylistUrl(fileId);

const upload = await sdk.files.upload({
file: new File(["hello"], "hello.txt"),
parentId: 0,
Expand All @@ -322,6 +324,13 @@ const upload = await sdk.files.upload({

Upload targets `upload.put.io` internally because `api.put.io/v2/files/upload` is only a redirect shim.

## Endpoint Coverage

The TypeScript SDK mirrors the supported public put.io API surface. The current audit outcome,
scope, exclusions, and refresh contract are documented in [API Coverage](docs/API-COVERAGE.md).
The portable route matrix is public evidence for selected decisions, not a published backend
inventory.
Comment thread
Copilot marked this conversation as resolved.

## File Lookup and Mutations

Named-child lookup avoids listing an entire folder and preserves the same query-conditioned fields as
Expand Down
54 changes: 54 additions & 0 deletions docs/API-COVERAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# API Coverage

`@putdotio/sdk` is the canonical full put.io API client. Endpoint completeness means every
supported, user-facing public API capability is represented by a typed SDK operation, an explicit
canonical equivalent, or a direct-route URL helper.

## Current Audit Outcome

The maintained public backend surface and first-party consumers were compared with the canonical
SDK operation tree for issue [#172](https://github.com/putdotio/putio-sdk-typescript/issues/172).
The comparison found one unresolved supported capability: the XSPF playlist route used for VLC
handoff. `files.getXspfPlaylistUrl(...)` closes that gap. No supported public endpoint gaps remain
from that audit.
Comment thread
Copilot marked this conversation as resolved.

The audit enumerated current public route registrations, compared method and path contracts with
SDK requests and direct-route helpers, inspected backend authentication and response behavior for
every mismatch, and checked current first-party web and CLI usage before deciding whether a route
was supported, equivalent, legacy, or private. Earlier request, response, and typed-error contract
alignment is recorded in [#108](https://github.com/putdotio/putio-sdk-typescript/issues/108).

Comment thread
Copilot marked this conversation as resolved.
This is a point-in-time completeness result, not a claim that future backend changes are detected
automatically. Response-branch and mutation-depth confidence remains a separate live-testing
concern.

## Scope

The completeness contract includes authenticated JSON operations, public registration and OAuth
operations intended for API consumers, and direct content routes that consumers access through
SDK-generated URLs.

It excludes:

- admin, private, restricted, secret-key, and service-to-service routes
- browser page, callback, and form-post routes that are not API-client operations
- legacy aliases when the SDK exposes the canonical replacement
- transport aliases when one safer method reaches the same handler and contract

## Evidence Boundary

The backend-wide inventory, extractor, and private route classifications stay in the private
owning environment. [`api-route-matrix.json`](api-route-matrix.json) contains portable public
evidence for selected contract decisions and validates each named operation on both the Effect and
Promise clients. It is deliberately not the complete backend route inventory.

## Refresh Contract

Repeat the private comparison when a public backend route changes, a first-party consumer needs an
unmodeled capability, or a contract-drift report is confirmed. For every supported gap:

1. update the request, response, and typed error boundary together
2. keep the Effect and Promise client surfaces aligned through the canonical operation tree
3. add deterministic request and parsing coverage
4. add safe live proof when local fixtures cannot establish the behavior
5. add only public-safe evidence to this repository
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ This split is the stable default unless a domain grows large enough to earn its
The `files` namespace owns both:

- JSON operations like `files.get(...)`, `files.list(...)`, `files.extract(...)`
- direct route helpers like `files.getApiDownloadUrl(...)`, `files.getApiContentUrl(...)`, `files.getHlsStreamUrl(...)`
- direct route helpers like `files.getApiDownloadUrl(...)`, `files.getApiContentUrl(...)`, `files.getHlsStreamUrl(...)`, and `files.getXspfPlaylistUrl(...)`
- upload helpers like `files.createUploadRequest(...)` and `files.upload(...)`

That split is deliberate:
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Those stay source-backed or sandbox-only until we have a sacrificial account spe
| `account` | account info, settings, confirmations |
| `config` | app-owned JSON config storage |
| `files` | core file listing, search, and mutations |
| `file-direct` | direct file URLs and upload |
| `file-direct` | direct file URLs, XSPF playlists, and upload |
| `file-tasks` | extractions, watch status, MP4 tasks |
| `transfers` | transfer orchestration |
| `events` | event history |
Expand Down
8 changes: 8 additions & 0 deletions docs/api-route-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@
"rationale": "The files client exposes named child lookup directly.",
"source": "putio/api2/files.py:786"
},
{
"classification": "sdk",
"method": "GET",
"operation": "files.getXspfPlaylistUrl",
"path": "/v2/files/:fileId/xspf",
"rationale": "The files client exposes a tokenized URL for the backend XSPF playlist used by first-party VLC handoff.",
"source": "putio/api2/download.py:84"
},
{
"classification": "sdk",
"method": "POST",
Expand Down
6 changes: 6 additions & 0 deletions scripts/test-compat-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ const compilePromiseAuthorizationCodeExchange = async () => {
void compilePromiseAuthorizationCodeExchange;
const promiseAuthHost = new URL(promiseAuthUrl).host;
promiseClient.setAccessToken("test-auth-token");
const xspfPlaylistUrl = await promiseClient.files.getXspfPlaylistUrl(7);
if (new URL(xspfPlaylistUrl).searchParams.get("oauth_token") !== "test-auth-token") {
throw new Error("Promise access-token replacement did not reach the XSPF playlist URL");
}
const uploadRequest = await promiseClient.files.createUploadRequest({
file: new Blob(["hello from node"]),
fileName: "node.txt",
Expand All @@ -257,6 +261,7 @@ void effectClient.auth.exchangeAuthorizationCode(exchangeInput);
void effectClient.files.getChild({ name: "source.txt", parentId: 0 });
void effectClient.files.copy(copyInput);
void effectClient.files.canWrite(7);
void effectClient.files.getXspfPlaylistUrl(7);
void effectClient.files.touch(touchInput);
void effectClient.transfers.getTorrent(7);
void effectClient.transfers.addTrackers(trackersInput);
Expand Down Expand Up @@ -285,6 +290,7 @@ console.log(
uploadTokenUpdated: true,
utility: toHumanFileSize(1_572_864),
unknownAppSpecificPasswordError,
xspfTokenUpdated: true,
}),
);
`,
Expand Down
2 changes: 2 additions & 0 deletions src/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ exports[`sdk root entry > exports the expected top-level public surface 1`] = `
"buildFileApiDownloadUrl",
"buildFileApiMp4DownloadUrl",
"buildFileHlsStreamUrl",
"buildFileXspfPlaylistUrl",
"buildOAuthAppIconUrl",
"buildOAuthAuthorizeUrl",
"buildPutioUrl",
Expand Down Expand Up @@ -395,6 +396,7 @@ exports[`sdk root entry > exports the expected top-level public surface 1`] = `
"getTransferInfo",
"getTransferTorrent",
"getVoucher",
"getXspfPlaylistUrl",
"getZip",
"grants",
"isPutioApiError",
Expand Down
6 changes: 6 additions & 0 deletions src/core/client.promise.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ vi.mock("../domains/files.js", async () => {
getHlsStreamUrl: vi.fn((fileId) =>
Effect.succeed(`https://api.put.io/files/${fileId}/hls/media.m3u8`),
),
getXspfPlaylistUrl: vi.fn((fileId) =>
Comment thread
altaywtf marked this conversation as resolved.
Effect.succeed(`https://api.put.io/v2/files/${fileId}/xspf?oauth_token=token-123`),
),
getMp4Status: vi.fn((fileId) =>
Effect.succeed({ id: fileId, status: "COMPLETED", percent_done: 100, size: 100 }),
),
Expand Down Expand Up @@ -665,6 +668,9 @@ describe("sdk promise client adapters", () => {
"https://api.put.io/files/4/mp4/download",
);
expect(await client.files.getHlsStreamUrl(4)).toBe("https://api.put.io/files/4/hls/media.m3u8");
expect(await client.files.getXspfPlaylistUrl(4)).toBe(
"https://api.put.io/v2/files/4/xspf?oauth_token=token-123",
);
expect(await client.files.getStartFrom(4)).toBe(4);
expect(await client.files.setStartFrom({ file_id: 4, time: 95 })).toEqual({
start_from: 95,
Expand Down
4 changes: 4 additions & 0 deletions src/core/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe("sdk client factories", () => {
expect(client.files.copy).toBeTypeOf("function");
expect(client.files.createUploadFormData).toBeTypeOf("function");
expect(client.files.getApiDownloadUrl).toBeTypeOf("function");
expect(client.files.getXspfPlaylistUrl).toBeTypeOf("function");
expect(client.files.setSort).toBeTypeOf("function");
expect(client.payment.changePlan.preview).toBeTypeOf("function");
expect(client.podcast.getLinks).toBeTypeOf("function");
Expand Down Expand Up @@ -231,6 +232,9 @@ describe("sdk client factories", () => {
expect(await client.files.getHlsStreamUrl(42)).toBe(
"https://api.put.io/v2/files/42/hls/media.m3u8?oauth_token=token-123",
);
expect(await client.files.getXspfPlaylistUrl(42)).toBe(
"https://api.put.io/v2/files/42/xspf?oauth_token=token-123",
);
expect(
await client.files.createUploadRequest({
file: new Blob(["hello"], { type: "text/plain" }),
Expand Down
2 changes: 2 additions & 0 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
getDownloadUrl,
getFileChild,
getHlsStreamUrl,
getXspfPlaylistUrl,
getFile,
getMp4Status,
getStartFrom,
Expand Down Expand Up @@ -549,6 +550,7 @@ const sharedOperationTree = {
getChild: validated(getFileChild),
getDownloadUrl: validated(getDownloadUrl),
getHlsStreamUrl: validated(getHlsStreamUrl),
getXspfPlaylistUrl: validated(getXspfPlaylistUrl),
getMp4Status: validated(getMp4Status),
getStartFrom: validated(getStartFrom),
list: validated(queryFiles),
Expand Down
17 changes: 17 additions & 0 deletions src/domains/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ describe("files domain", () => {
"https://api.put.io/v2/files/42/hls/media.m3u8?max_subtitle_count=2&oauth_token=token-123&original=0&subtitle_languages=en%2Ctr",
);

expect(
files.buildFileXspfPlaylistUrl("https://api.put.io", 42, {
oauthToken: "token-123",
}),
).toBe("https://api.put.io/v2/files/42/xspf?oauth_token=token-123");

expect(
await runConfigEffect(files.getApiDownloadUrl(42), {
accessToken: "token-123",
Expand Down Expand Up @@ -275,6 +281,13 @@ describe("files domain", () => {
}),
).toBe("https://api.put.io/v2/files/42/hls/media.m3u8?oauth_token=token-123");

expect(
await runConfigEffect(files.getXspfPlaylistUrl(42), {
accessToken: "token-123",
baseUrl: "https://api.put.io",
}),
).toBe("https://api.put.io/v2/files/42/xspf?oauth_token=token-123");

const uploadRequest = await runConfigEffect(
files.createFileUploadRequest({
file: new Blob(["hello"], { type: "text/plain" }),
Expand Down Expand Up @@ -332,6 +345,10 @@ describe("files domain", () => {
runConfigExit(files.getHlsStreamUrl(42, { oauthToken: "" }), {
accessToken: "token-123",
}),
runConfigExit(files.getXspfPlaylistUrl(0), { accessToken: "token-123" }),
runConfigExit(files.getXspfPlaylistUrl(42, { oauthToken: "" }), {
accessToken: "token-123",
}),
]);
expect(invalidDirectAccess.map(expectFailure)).toEqual(
invalidDirectAccess.map(() => expect.any(PutioValidationError)),
Expand Down
33 changes: 33 additions & 0 deletions src/domains/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,9 @@ const FileHlsStreamUrlOptionsSchema = Schema.Struct({
Schema.Array(NonEmptyStringSchema).check(Schema.isMinLength(1)),
),
});
const FileXspfPlaylistUrlOptionsSchema = Schema.Struct({
oauthToken: Schema.optional(NonEmptyStringSchema),
});
const FilesNextFileSchema = Schema.Struct({
id: Schema.Int,
name: Schema.String,
Expand Down Expand Up @@ -507,6 +510,9 @@ export type FileHlsStreamUrlOptions = {
readonly playOriginal?: boolean;
readonly subtitleLanguages?: ReadonlyArray<string>;
};
export type FileXspfPlaylistUrlOptions = {
readonly oauthToken?: string;
};
export type FileUploadInput = {
readonly file: Blob;
readonly fileName?: string;
Expand Down Expand Up @@ -929,6 +935,14 @@ export const buildFileHlsStreamUrl = (
typeof options.playOriginal === "boolean" ? (options.playOriginal ? 1 : 0) : undefined,
subtitle_languages: joinCsv(options.subtitleLanguages),
});
export const buildFileXspfPlaylistUrl = (
baseUrl: string | URL,
fileId: number,
options: FileXspfPlaylistUrlOptions = {},
): string =>
buildPutioUrl(baseUrl, `/v2/files/${encodePathSegment(fileId)}/xspf`, {
oauth_token: options.oauthToken,
});
export const createFileUploadFormData = (input: FileUploadInput): FormData => {
const formData = new FormData();
formData.append("file", input.file);
Expand Down Expand Up @@ -1402,6 +1416,25 @@ export const getHlsStreamUrl = (
),
),
);
export const getXspfPlaylistUrl = (
fileId: number,
options: FileXspfPlaylistUrlOptions = {},
): Effect.Effect<string, PutioSdkError, PutioSdkConfig> =>
decodeAndRun(
Schema.Struct({
fileId: PositiveFileIdSchema,
options: FileXspfPlaylistUrlOptionsSchema,
}),
{ fileId, options },
(decoded) =>
resolveRouteContext(decoded.options.oauthToken).pipe(
Effect.map(({ config, oauthToken }) =>
buildFileXspfPlaylistUrl(config.baseUrl ?? "https://api.put.io", decoded.fileId, {
oauthToken,
}),
),
),
);
export const listFileSubtitles = (
fileId: number,
options: {
Expand Down
1 change: 1 addition & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("sdk root entry", () => {
getCode: expect.any(Function),
getConfigKey: expect.any(Function),
getFile: expect.any(Function),
getXspfPlaylistUrl: expect.any(Function),
getPaymentInfo: expect.any(Function),
getTransfer: expect.any(Function),
isPutioOperationError: expect.any(Function),
Expand Down
19 changes: 19 additions & 0 deletions test/live/domains/file-direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ await run("files hls url is tokenized", async () => {
};
});

await run("files XSPF playlist is fetchable for owned video", async () => {
const video = await requireOwnedVideoFixture(client);
const url = await client.files.getXspfPlaylistUrl(video.id);
const response = await fetch(url);
const body = await response.text();

assert(response.ok, "expected XSPF playlist route to be fetchable");
assert(
response.headers.get("content-type")?.includes("application/xspf+xml") === true,
"expected XSPF content type",
);
assert(body.includes("<playlist"), "expected XSPF playlist body");

return {
status: response.status,
video_id: video.id,
};
});

await run("files upload works through upload.put.io", async () => {
const name = `codex_sdk_upload_probe_${Date.now()}.txt`;
const upload = await client.files.upload({
Expand Down