mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-30 18:06:15 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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/',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user