Files
YouTube.js/src/core/clients/Studio.ts
Daniel Wykerd 87ed3960ff refactor!: replace unnecessary classes with pure functions (#468)
* deps: update linkedom

* refactor!: remove YTNodeGenerator in favour of namespaced pure functions

BREAKING CHANGES:
- Removes `YTNodeGenerator` from `import('youtubei.js').Generator` and exposes its functions directly in `import('youtubei.js').Generator`

* refactor!: replace Parser class with pure functions

- Remove Parser class in favour of pure functions
- Merge duplicate classes `AppendContinuationItemsAction` into a single class
- Move continuation parsers into a seperate file
- Add better custom logging support to parser methods as per issue #460

* refactor!: replace Proto class with pure functions

* chore: update package-lock.json

* refactor!: replace FormatUtils with pure functions and JSX components

- Replace linkedom DASH manifest generation with a dependency free JSX implementation
- Remove FormatUtils class in favour of pure functions
- Remove DOMParser requirement
- Remove duplicate types

* refactor: implement changes from #462

* chore: lint

* fix: deno support

* fix: render valid xml document

* fix: wrong function call in DashUtils

* fix: typo in parser

Co-authored-by: LuanRT <luan.lrt4@gmail.com>

* refactor!: move streaming info logic into seperate function

This allows users to access the same data available in the dash manifest while also simplifying the manifest generation

* chore: lint

* refactor: readability improvements & fixes

Remove redundant getAudioTrackGroups
General readability improvements in StreamingInfo.ts
Share response object between `getBitrate` and `getMimeType` as to not make duplicate requests

* build: remove unnecessary step in deno build

Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>

* refactor: move types to `types` directory

* docs: add back comments lost during refactor

* chore: lint

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2023-08-18 06:49:58 -03:00

192 lines
6.0 KiB
TypeScript

import * as Proto from '../../proto/index.js';
import * as Constants from '../../utils/Constants.js';
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js';
import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Clients.js';
import type { ApiResponse } from '../Actions.js';
import type Session from '../Session.js';
import { CreateVideoEndpoint } from '../endpoints/upload/index.js';
interface UploadResult {
status: string;
scottyResourceId: string;
}
interface InitialUploadData {
frontend_upload_id: string;
upload_id: string;
upload_url: string;
scotty_resource_id: string;
chunk_granularity: string;
}
export default class Studio {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
/**
* Uploads a custom thumbnail and sets it for a video.
* @example
* ```ts
* const buffer = fs.readFileSync('./my_awesome_thumbnail.jpg');
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
if (!video_id || !buffer)
throw new MissingParamError('One or more parameters are missing.');
const payload = Proto.encodeCustomThumbnailPayload(video_id, buffer);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Updates a given video's metadata.
* @example
* ```ts
* const response = await yt.studio.updateVideoMetadata('videoid', {
* tags: [ 'astronomy', 'NASA', 'APOD' ],
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* license: 'creative_commons'
* // ...
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Uploads a video to YouTube.
* @example
* ```ts
* const file = fs.readFileSync('./my_awesome_video.mp4');
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: UploadedVideoMetadataOptions = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const initial_data = await this.#getInitialUploadData();
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
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:${Platform.shim.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/${Platform.shim.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, file: BodyInit): 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: file
});
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: UploadedVideoMetadataOptions) {
const response = await this.#session.actions.execute(
CreateVideoEndpoint.PATH, CreateVideoEndpoint.build({
resource_id: {
scotty_resource_id: {
id: upload_result.scottyResourceId
}
},
frontend_upload_id: initial_data.frontend_upload_id,
initial_metadata: {
title: {
new_title: metadata.title || new Date().toDateString()
},
description: {
new_description: metadata.description || '',
should_segment: true
},
privacy: {
new_privacy: metadata.privacy || 'PRIVATE'
},
draft_state: {
is_draft: metadata.is_draft
}
},
client: 'ANDROID'
})
);
return response;
}
}