{"_id":"@bozonx/social-posting-youtube","name":"@bozonx/social-posting-youtube","dist-tags":{"latest":"0.8.0"},"versions":{"0.8.0":{"name":"@bozonx/social-posting-youtube","version":"0.8.0","description":"YouTube platform for @bozonx/social-posting","keywords":["social-media","posting","youtube"],"type":"module","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","workerd":"./dist/index.js","import":"./dist/index.js","default":"./dist/index.js"}},"dependencies":{},"peerDependencies":{"@bozonx/social-posting":"^0.8.0"},"devDependencies":{"@bozonx/social-posting":"0.8.0","@bozonx/social-posting-conformance":"0.8.0"},"publishConfig":{"access":"public","provenance":true},"sideEffects":false,"author":"Ivan K","license":"MIT","engines":{"node":">=24.0.0"},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_nodeVersion":"24.19.0","_id":"@bozonx/social-posting-youtube@0.8.0","dist":{"integrity":"sha512-lLWBUAWfHDkkBzlp1eLaw2ik7vJ0HlVOHGdnAHjHnmo0q/nDVmom77rDUfzBIVpgYULy1z5A0IIm9T+uRRuUwg==","shasum":"dacbcaa5c04f2d792c1c508ffb3fcab38c3d88ca","tarball":"https://registry.npmjs.org/@bozonx/social-posting-youtube/-/social-posting-youtube-0.8.0.tgz","fileCount":21,"unpackedSize":109907,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIF5809eRwsINsP8UUJwY1D5UTR+0nUFdcsqn/pltsqT4AiAU0BXDtPL6FK64RFyEia0m9Wf7kJqzvEs5p5BcHrQuMw=="}]},"_npmUser":{"name":"bozonx","email":"ipkozyrin@gmail.com"},"directories":{},"maintainers":[{"name":"bozonx","email":"ipkozyrin@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/social-posting-youtube_0.8.0_1788181104540_0.9665778609547331"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-31T12:58:23.874Z","0.8.0":"2026-08-31T12:58:24.676Z","modified":"2026-08-31T12:58:25.408Z"},"maintainers":[{"name":"bozonx","email":"ipkozyrin@gmail.com"}],"description":"YouTube platform for @bozonx/social-posting","keywords":["social-media","posting","youtube"],"author":"Ivan K","license":"MIT","readme":"# @bozonx/social-posting-youtube\n\nYouTube support for [`@bozonx/social-posting`](https://www.npmjs.com/package/@bozonx/social-posting).\nZero runtime dependencies, Web APIs only — it runs on Node, Bun, Deno and Cloudflare Workers.\n\n```bash\npnpm add @bozonx/social-posting @bozonx/social-posting-youtube\n```\n\n```ts\nimport { createPostingClient } from '@bozonx/social-posting';\nimport { youtube } from '@bozonx/social-posting-youtube';\n\nconst client = createPostingClient({\n  platforms: [youtube],\n  credentialProvider, // see \"Credentials\" below — not optional in practice\n  accounts: {\n    main: {\n      platform: 'youtube',\n      auth: { accessToken: '…', refreshToken: '…', expiresAt: '…' },\n      oauthClient: {\n        clientId: process.env.GOOGLE_CLIENT_ID!,\n        clientSecret: process.env.GOOGLE_CLIENT_SECRET,\n      },\n    },\n  },\n});\n\nconst result = await client.post({\n  platform: 'youtube',\n  account: 'main',\n  type: 'video',\n  title: 'Release notes, August',\n  body: 'Everything that shipped this month.',\n  visibility: 'private',\n  media: [{ type: 'video', source: { kind: 'stream', open: openFile, sizeBytes: 812_000_000 } }],\n});\n\n// result.data.status === 'processing' — see below.\n```\n\n## An uploaded video is not a published video\n\n`publish()` **never** returns `published`. `videos.insert` answers with a video id the moment the\nlast byte lands, and YouTube then transcodes for anywhere between a few seconds and several hours.\nA host that treats the id as proof of publication announces a video its audience gets a spinner\nfor.\n\nSo the flow is always two-phase:\n\n1. `post()` returns `status: 'processing'` with a `handle` and a `checkAfterMs`.\n2. The host calls `checkStatus(request, handle)` until it answers `published` or `failed`.\n\n`checkStatus()` reads `processingDetails.processingStatus`, not `status.uploadStatus` — the latter\nreads `uploaded` for a file still being transcoded.\n\n**Budget the wait from the descriptor, not from a constant.** `capabilities.asyncProcessing`\nstates `maxWaitSecs` (6 hours here) and `pollIntervalSecs`. A host with one global 15-minute\ntimeout marks successful uploads of long videos as failures.\n\n## Credentials\n\nA Google access token lives about an hour, which is less than a large upload takes. This adapter\ntherefore refreshes **before** the upload starts rather than reacting to a 401 halfway through.\n\nFor that it needs two things:\n\n- a `credentialProvider` on the client, whose `onCredentialsRefreshed()` **persists the result**;\n- `oauthClient.clientId` (and secret, for a confidential client) on the account.\n\nGoogle rotates refresh tokens. A rotated token that is not persisted locks the channel out\npermanently — no retry recovers it, and the user must go through consent again. If your provider\nhas no `onCredentialsRefreshed`, this account works until the current access token expires and then\nstops for good.\n\nScopes: `youtube.upload` is enough to insert a video. `youtube` is additionally needed to read\nprocessing status back and to set a custom thumbnail.\n\n## Quota is units, not posts\n\nOne `videos.insert` costs **1600 quota units** against the Google Cloud project's daily budget,\nwhich defaults to 10 000 — roughly six uploads a day, for every channel the project serves\ncombined. An exhausted budget surfaces as `QUOTA_EXCEEDED` and resets at midnight Pacific time,\nwhich no `retry-after` header states.\n\nThis is a different failure from Vimeo's, which is storage. Both are `QUOTA_EXCEEDED`;\n`capabilities.rateLimits.quotaCost.unit` is what tells them apart (`quotaUnits` against `bytes`),\nand it is what a host should branch on to say \"try tomorrow\" rather than \"free up space\".\n\n## Shorts\n\n`shortVideo` and `video` are the same `videos.insert` call with the same limits. There is no Shorts\nendpoint: YouTube classifies a Short from the finished file's aspect ratio and duration, after the\nupload, by rules that belong to the product and change without an API version.\n\nSubmitting a landscape video as `shortVideo` logs a warning and uploads it anyway — refusing would\nreject uploads YouTube itself would have accepted.\n\n## Resumable upload\n\nLarge uploads run over Google's resumable protocol: a session is opened, chunks are `PUT` by byte\noffset, and the last one returns the video.\n\nOn failure the error carries a `resumeHandle`. Passing it back to `publish()` continues the upload\ninstead of starting over:\n\n```ts\nconst result = await client.post(request, { resume: previousError.resumeHandle });\n```\n\nTwo details worth knowing:\n\n- The handle carries the session's opaque `upload_id`, never the signed session URL, so it is safe\n  in a host's database. `findResumeHandleSecrets()` on it returns nothing.\n- Before resuming, the adapter asks YouTube where the file actually got to. The stored offset is\n  only what was true before the process died; resuming from a guessed byte produces a corrupt file\n  rather than a visible error.\n\n`chunkSizeBytes` on the account tunes the writes. It must be a multiple of 256 KiB — Google's own\nrequirement — and an invalid value is refused before a session is opened rather than rounded.\n\n## Drafts and scheduling\n\nThere is **no draft**. A `private` video is uploaded, stored and has already cost its 1600 units;\n`mode: 'draft'` is refused rather than quietly mapped onto privacy.\n\n`scheduledAt` maps to `status.publishAt`, which YouTube honours **only while the video is private**.\nSetting it on a public video is refused, because YouTube would otherwise ignore it silently — the\nworst of the three possible outcomes.\n\n## What it publishes\n\n| Field                     | Maps to                                              |\n| ------------------------- | ---------------------------------------------------- |\n| `title`                   | `snippet.title` (required, ≤ 100)                    |\n| `body` / `description`    | `snippet.description` (≤ 5000)                       |\n| `tags`                    | `snippet.tags` (≤ 500 characters joined)             |\n| `language`                | `snippet.defaultLanguage` and `defaultAudioLanguage` |\n| `visibility`              | `status.privacyStatus` (`private` by default)        |\n| `thumbnail`               | a separate `thumbnails.set` call after the insert    |\n| `extra.categoryId`        | `snippet.categoryId` (account default, then `22`)    |\n| `extra.madeForKids`       | `status.selfDeclaredMadeForKids`                     |\n| `extra.notifySubscribers` | the `notifySubscribers` query parameter              |\n\nA thumbnail that YouTube refuses is logged and does not fail the publication: the video exists and\nits quota is spent, and failing here would make the host re-upload a video it already has.\n\n## Known limits\n\n- An unverified channel is capped at 15 minutes of video regardless of file size. That is a\n  property of the channel, not of the API, so the descriptor does not state it.\n- A project whose OAuth consent screen has not been verified can only upload `private` videos.\n- `delete()` is not implemented in this iteration, and `supportsDeletion` says so.\n","readmeFilename":"","_rev":"1-c78a0204fc0932dcd2939c227cb09278"}