From 2cef1bacc928884a8dc3e2749f8dc801f8fe2eea Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 22:58:54 +0300 Subject: [PATCH 1/3] feat(files): expose XSPF playlist URLs --- README.md | 9 +++++ docs/API-COVERAGE.md | 54 ++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 2 +- docs/TESTING.md | 2 +- docs/api-route-matrix.json | 8 +++++ scripts/test-compat-node.ts | 6 ++++ src/__snapshots__/index.spec.ts.snap | 2 ++ src/core/client.promise.spec.ts | 4 +++ src/core/client.spec.ts | 4 +++ src/core/client.ts | 2 ++ src/domains/files.spec.ts | 17 +++++++++ src/domains/files.ts | 33 +++++++++++++++++ src/index.spec.ts | 1 + test/live/domains/file-direct.ts | 19 ++++++++++ 14 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 docs/API-COVERAGE.md diff --git a/README.md b/README.md index 3f37a81..b189608 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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. + ## File Lookup and Mutations Named-child lookup avoids listing an entire folder and preserves the same query-conditioned fields as diff --git a/docs/API-COVERAGE.md b/docs/API-COVERAGE.md new file mode 100644 index 0000000..5cf6afb --- /dev/null +++ b/docs/API-COVERAGE.md @@ -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. + +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) + +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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efd9a4d..347bf2a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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: diff --git a/docs/TESTING.md b/docs/TESTING.md index 047f894..833175f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 | diff --git a/docs/api-route-matrix.json b/docs/api-route-matrix.json index d84f89e..7b1b787 100644 --- a/docs/api-route-matrix.json +++ b/docs/api-route-matrix.json @@ -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", diff --git a/scripts/test-compat-node.ts b/scripts/test-compat-node.ts index f47e890..195b506 100644 --- a/scripts/test-compat-node.ts +++ b/scripts/test-compat-node.ts @@ -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", @@ -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); @@ -285,6 +290,7 @@ console.log( uploadTokenUpdated: true, utility: toHumanFileSize(1_572_864), unknownAppSpecificPasswordError, + xspfTokenUpdated: true, }), ); `, diff --git a/src/__snapshots__/index.spec.ts.snap b/src/__snapshots__/index.spec.ts.snap index 9640602..1aa3288 100644 --- a/src/__snapshots__/index.spec.ts.snap +++ b/src/__snapshots__/index.spec.ts.snap @@ -290,6 +290,7 @@ exports[`sdk root entry > exports the expected top-level public surface 1`] = ` "buildFileApiDownloadUrl", "buildFileApiMp4DownloadUrl", "buildFileHlsStreamUrl", + "buildFileXspfPlaylistUrl", "buildOAuthAppIconUrl", "buildOAuthAuthorizeUrl", "buildPutioUrl", @@ -395,6 +396,7 @@ exports[`sdk root entry > exports the expected top-level public surface 1`] = ` "getTransferInfo", "getTransferTorrent", "getVoucher", + "getXspfPlaylistUrl", "getZip", "grants", "isPutioApiError", diff --git a/src/core/client.promise.spec.ts b/src/core/client.promise.spec.ts index 8163d29..2975de9 100644 --- a/src/core/client.promise.spec.ts +++ b/src/core/client.promise.spec.ts @@ -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) => + Effect.succeed(`https://api.put.io/files/${fileId}/xspf`), + ), getMp4Status: vi.fn((fileId) => Effect.succeed({ id: fileId, status: "COMPLETED", percent_done: 100, size: 100 }), ), @@ -665,6 +668,7 @@ 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/files/4/xspf"); expect(await client.files.getStartFrom(4)).toBe(4); expect(await client.files.setStartFrom({ file_id: 4, time: 95 })).toEqual({ start_from: 95, diff --git a/src/core/client.spec.ts b/src/core/client.spec.ts index 45f394c..9d63840 100644 --- a/src/core/client.spec.ts +++ b/src/core/client.spec.ts @@ -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"); @@ -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" }), diff --git a/src/core/client.ts b/src/core/client.ts index 9179987..7edba9c 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -91,6 +91,7 @@ import { getDownloadUrl, getFileChild, getHlsStreamUrl, + getXspfPlaylistUrl, getFile, getMp4Status, getStartFrom, @@ -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), diff --git a/src/domains/files.spec.ts b/src/domains/files.spec.ts index 0896668..73c0228 100644 --- a/src/domains/files.spec.ts +++ b/src/domains/files.spec.ts @@ -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", @@ -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" }), @@ -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)), diff --git a/src/domains/files.ts b/src/domains/files.ts index cb1ca57..08bfede 100644 --- a/src/domains/files.ts +++ b/src/domains/files.ts @@ -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, @@ -507,6 +510,9 @@ export type FileHlsStreamUrlOptions = { readonly playOriginal?: boolean; readonly subtitleLanguages?: ReadonlyArray; }; +export type FileXspfPlaylistUrlOptions = { + readonly oauthToken?: string; +}; export type FileUploadInput = { readonly file: Blob; readonly fileName?: string; @@ -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); @@ -1402,6 +1416,25 @@ export const getHlsStreamUrl = ( ), ), ); +export const getXspfPlaylistUrl = ( + fileId: number, + options: FileXspfPlaylistUrlOptions = {}, +): Effect.Effect => + 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: { diff --git a/src/index.spec.ts b/src/index.spec.ts index 2a10fd9..4461626 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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), diff --git a/test/live/domains/file-direct.ts b/test/live/domains/file-direct.ts index ba3517b..49e2f12 100644 --- a/test/live/domains/file-direct.ts +++ b/test/live/domains/file-direct.ts @@ -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(" { const name = `codex_sdk_upload_probe_${Date.now()}.txt`; const upload = await client.files.upload({ From d5f3bd08b366b175222e53a6ab9e4ef44f6ea5b6 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 23:04:36 +0300 Subject: [PATCH 2/3] docs: clarify endpoint coverage prose --- README.md | 2 +- docs/API-COVERAGE.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b189608..ec96c06 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ Upload targets `upload.put.io` internally because `api.put.io/v2/files/upload` i ## 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) +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. diff --git a/docs/API-COVERAGE.md b/docs/API-COVERAGE.md index 5cf6afb..54db865 100644 --- a/docs/API-COVERAGE.md +++ b/docs/API-COVERAGE.md @@ -7,7 +7,7 @@ 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) +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. @@ -16,7 +16,7 @@ The audit enumerated current public route registrations, compared method and pat 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) +alignment is recorded in [#108](https://github.com/putdotio/putio-sdk-typescript/issues/108). 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 From 8689c62cba7b40376987090eca5d0956e9d0925e Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 11 Aug 2026 07:39:14 +0300 Subject: [PATCH 3/3] test(files): align XSPF adapter fixture --- src/core/client.promise.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/client.promise.spec.ts b/src/core/client.promise.spec.ts index 2975de9..9e200a9 100644 --- a/src/core/client.promise.spec.ts +++ b/src/core/client.promise.spec.ts @@ -201,7 +201,7 @@ vi.mock("../domains/files.js", async () => { Effect.succeed(`https://api.put.io/files/${fileId}/hls/media.m3u8`), ), getXspfPlaylistUrl: vi.fn((fileId) => - Effect.succeed(`https://api.put.io/files/${fileId}/xspf`), + 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 }), @@ -668,7 +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/files/4/xspf"); + 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,