From 95079ced09531dd8877cd3692f7c705f5b4a375c Mon Sep 17 00:00:00 2001 From: LuanRT Date: Mon, 25 Jul 2022 04:45:55 -0300 Subject: [PATCH] feat: add support for uploading videos (#115) * chore: add video upload url * feat!: add support for uploading videos This is probably complete but I will do a self-review later today. * style: align comments * style: lint code * chore: tidy things up --- package-lock.json | 31 +++++++++- src/core/Session.ts | 1 + src/core/Studio.ts | 133 +++++++++++++++++++++++++++++++++++++++- src/utils/Constants.ts | 1 + src/utils/HTTPClient.ts | 8 ++- 5 files changed, 167 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 216cd9cb..87b56ecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,7 @@ "@protobuf-ts/runtime": "^2.7.0", "flat": "^5.0.2", "undici": "^5.7.0", - "xml-js": "^1.6.11", - "xmlbuilder2": "^3.0.2" }, "devDependencies": { @@ -4585,6 +4583,11 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, "node_modules/semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -5075,6 +5078,17 @@ "node": "^12.13.0 || ^14.15.0 || >=16" } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xmlbuilder2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz", @@ -8497,6 +8511,11 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, "semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -8848,6 +8867,14 @@ "signal-exit": "^3.0.7" } }, + "xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "requires": { + "sax": "^1.2.4" + } + }, "xmlbuilder2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz", diff --git a/src/core/Session.ts b/src/core/Session.ts index ae005db3..a163b226 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -80,6 +80,7 @@ export default class Session extends EventEmitterLike { on(type: 'auth', listener: OAuthAuthEventHandler): void; on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void; on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void; + on(type: 'update-credentials', listener: OAuthAuthEventHandler): void; on(type: string, listener: (...args: any[]) => void): void { super.on(type, listener); diff --git a/src/core/Studio.ts b/src/core/Studio.ts index 4f02140a..4fdf1263 100644 --- a/src/core/Studio.ts +++ b/src/core/Studio.ts @@ -1,7 +1,28 @@ import Proto from '../proto'; import Session from './Session'; import { AxioslikeResponse } from './Actions'; -import { MissingParamError } from '../utils/Utils'; +import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; +import { Constants } from '../utils'; + +export interface UploadResult { + status: string; + scottyResourceId: string; +} + +export interface InitialUploadData { + frontend_upload_id: string; + upload_id: string; + upload_url: string; + scotty_resource_id: string; + chunk_granularity: string; +} + +export interface VideoMetadata { + title?: string; + description?: string; + privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; + is_draft?: boolean; +} class Studio { #session; @@ -31,6 +52,114 @@ class Studio { return response; } + + /** + * Uploads a video to YouTube. + * @example + * ```ts + * const buffer = fs.readFileSync('./my_awesome_video.mp4'); + * const response = await yt.studio.upload(buffer, { title: 'Wow!' }); + * ``` + */ + async upload(buffer: Uint8Array, metadata: VideoMetadata = {}): Promise { + const initial_data = await this.#getInitialUploadData(); + const upload_result = await this.#uploadVideo(initial_data.upload_url, buffer); + + if (upload_result.status !== 'STATUS_SUCCESS') + throw new InnertubeError('Could not process video.'); + + const response = await this.#setVideoMetadata(initial_data, upload_result, metadata); + + return response; + } + + async #getInitialUploadData(): Promise { + const frontend_upload_id = `innertube_android:${uuidv4()}:0:v=3,api=1,cf=3`; + + const payload = { + frontendUploadId: frontend_upload_id, + deviceDisplayName: 'Pixel 6 Pro', + fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${uuidv4()}`, + mp4MoovAtomRelocationStatus: 'UNSUPPORTED', + transcodeResult: 'DISABLED', + connectionType: 'WIFI' + }; + + const response = await this.#session.http.fetch('/upload/youtubei', { + baseURL: Constants.URLS.YT_UPLOAD, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'x-goog-upload-command': 'start', + 'x-goog-upload-protocol': 'resumable' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) + throw new InnertubeError('Could not get initial upload data'); + + return { + frontend_upload_id, + upload_id: response.headers.get('x-guploader-uploadid') as string, + upload_url: response.headers.get('x-goog-upload-url') as string, + scotty_resource_id: response.headers.get('x-goog-upload-header-scotty-resource-id') as string, + chunk_granularity: response.headers.get('x-goog-upload-chunk-granularity') as string + }; + } + + async #uploadVideo(upload_url: string, buffer: Uint8Array): Promise { + const response = await this.#session.http.fetch_function(upload_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'x-goog-upload-command': 'upload, finalize', + 'x-goog-upload-file-name': `file-${Date.now()}`, + 'x-goog-upload-offset': '0' + }, + body: buffer + }); + + if (!response.ok) + throw new InnertubeError('Could not upload video'); + + const data = await response.json(); + + return data; + } + + async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: VideoMetadata) { + const metadata_payload = { + resourceId: { + scottyResourceId: { + id: upload_result.scottyResourceId + } + }, + frontendUploadId: initial_data.frontend_upload_id, + initialMetadata: { + title: { + newTitle: metadata.title || new Date().toDateString() + }, + description: { + newDescription: metadata.description || '', + shouldSegment: true + }, + privacy: { + newPrivacy: metadata.privacy || 'PRIVATE' + }, + draftState: { + isDraft: metadata.is_draft || false + } + } + }; + + const response = await this.#session.actions.execute('/upload/createvideo', { + client: 'ANDROID', + ...metadata_payload + }); + + return response; + } } -export default Studio; +export default Studio; \ No newline at end of file diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index cceadb91..03fbd73e 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -3,6 +3,7 @@ export const URLS = Object.freeze({ YT_BASE: 'https://www.youtube.com', YT_MUSIC_BASE: 'https://music.youtube.com', YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/', + YT_UPLOAD: 'https://upload.youtube.com/', API: Object.freeze({ BASE: 'https://youtubei.googleapis.com', PRODUCTION: 'https://youtubei.googleapis.com/youtubei/', diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index e4e81ffc..e63b4733 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -65,7 +65,9 @@ export default class HTTPClient { let request_body = body; - const is_innertube_req = baseURL === innertube_url; + const is_innertube_req = + baseURL === innertube_url || + baseURL === Constants.URLS.YT_UPLOAD; // Copy context into payload when possible if (content_type === 'application/json' && is_innertube_req && (typeof body === 'string')) { @@ -120,7 +122,7 @@ export default class HTTPClient { // Check if 2xx if (response.ok) { return response; - } throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, await response.json()); + } throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, await response.text()); } #adjustContext(ctx: Context, client: string) { @@ -138,4 +140,4 @@ export default class HTTPClient { break; } } -} \ No newline at end of file +}