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
This commit is contained in:
LuanRT
2022-07-25 04:45:55 -03:00
committed by GitHub
parent 616b1405c3
commit 95079ced09
5 changed files with 167 additions and 7 deletions

View File

@@ -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);

View File

@@ -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<AxioslikeResponse> {
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<InitialUploadData> {
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<UploadResult> {
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;

View File

@@ -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/',

View File

@@ -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;
}
}
}
}