diff --git a/packages/nodes-base/credentials/YouTubeOAuth2Api.credentials.ts b/packages/nodes-base/credentials/YouTubeOAuth2Api.credentials.ts new file mode 100644 index 0000000000..46171f5114 --- /dev/null +++ b/packages/nodes-base/credentials/YouTubeOAuth2Api.credentials.ts @@ -0,0 +1,28 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +//https://developers.google.com/youtube/v3/guides/auth/client-side-web-apps#identify-access-scopes +const scopes = [ + 'https://www.googleapis.com/auth/youtube', + 'https://www.googleapis.com/auth/youtubepartner', + 'https://www.googleapis.com/auth/youtube.force-ssl', + 'https://www.googleapis.com/auth/youtube.upload', +]; + +export class YouTubeOAuth2Api implements ICredentialType { + name = 'youTubeOAuth2Api'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google OAuth2 API'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/YouTube/ChannelDescription.ts b/packages/nodes-base/nodes/Google/YouTube/ChannelDescription.ts new file mode 100644 index 0000000000..80cb5b8127 --- /dev/null +++ b/packages/nodes-base/nodes/Google/YouTube/ChannelDescription.ts @@ -0,0 +1,486 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const channelOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all channels', + }, + { + name: 'Update', + value: 'update', + description: 'Update a channel', + }, + { + name: 'Upload Banner', + value: 'uploadBanner', + description: 'Upload a channel banner', + } + ], + default: 'getAll', + description: 'The operation to perform.' + } +] as INodeProperties[]; + +export const channelFields = [ + /* -------------------------------------------------------------------------- */ + /* channel:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Part', + name: 'part', + type: 'multiOptions', + options: [ + { + name: 'Audit Details', + value: 'auditDetails', + }, + { + name: 'Branding Settings', + value: 'brandingSettings', + }, + { + name: 'Content Details', + value: 'contentDetails', + }, + { + name: 'Content Owner Details', + value: 'contentOwnerDetails', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'Localizations', + value: 'localizations', + }, + { + name: 'Snippet', + value: 'snippet', + }, + { + name: 'Statistics', + value: 'statistics', + }, + { + name: 'Status', + value: 'status', + }, + { + name: 'Topic Details', + value: 'topicDetails', + }, + ], + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'channel', + ], + }, + }, + default: '' + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'channel', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'channel', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'channel', + ], + }, + }, + options: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'string', + default: '', + description: 'The categoryId parameter specifies a YouTube guide category, thereby requesting YouTube channels associated with that category.', + }, + { + displayName: 'For Username', + name: 'forUsername', + type: 'string', + default: '', + description: `The forUsername parameter specifies a YouTube username, thereby requesting the channel associated with that username.`, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + description: `The id parameter specifies a comma-separated list of the YouTube channel ID(s) for the resource(s) that are being retrieved. In a channel resource, the id property specifies the channel's YouTube channel ID.`, + }, + { + displayName: 'Managed By Me', + name: 'managedByMe', + type: 'boolean', + default: false, + description: `Set this parameter's value to true to instruct the API to only return channels managed by the content owner that the onBehalfOfContentOwner parameter specifies`, + }, + { + displayName: 'Mine', + name: 'mine', + type: 'boolean', + default: false, + description: `This parameter can only be used in a properly authorized request. Set this parameter's value to true to instruct the API to only return channels owned by the authenticated user.`, + }, + { + displayName: 'Language Code', + name: 'h1', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLanguages' + }, + default: '', + description: `The hl parameter instructs the API to retrieve localized resource metadata for a specific application language that the YouTube website supports.`, + }, + { + displayName: 'On Behalf Of Content Owner', + name: 'onBehalfOfContentOwner', + type: 'string', + default: '', + description: `The onBehalfOfContentOwner parameter indicates that the request's authorization credentials identify a YouTube CMS user who is acting on behalf of the content owner specified in the parameter value`, + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* channel:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'channel', + ], + }, + }, + default: '', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'channel', + ], + }, + }, + options: [ + { + displayName: 'Branding Settings', + name: 'brandingSettingsUi', + type: 'fixedCollection', + default: {}, + description: 'Encapsulates information about the branding of the channel.', + placeholder: 'Add Branding Settings', + typeOptions: { + multipleValues: false, + }, + options: [ + { + name: 'channelSettingsValues', + displayName: 'Channel Settings', + values: [ + { + displayName: 'Channel', + name: 'channel', + type: 'collection', + default: '', + placeholder: 'Add Channel Settings', + typeOptions: { + multipleValues: false, + }, + options: [ + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'The country with which the channel is associated. Update this property to set the value of the snippet.country property.', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: `The channel description, which appears in the channel information box on your channel page. The property's value has a maximum length of 1000 characters.`, + }, + { + displayName: 'Default Language', + name: 'defaultLanguage', + type: 'string', + default: '', + description: 'The content tab that users should display by default when viewers arrive at your channel page.', + }, + { + displayName: 'Default Tab', + name: 'defaultTab', + type: 'string', + default: 'The content tab that users should display by default when viewers arrive at your channel page.', + }, + { + displayName: 'Featured Channels Title', + name: 'featuredChannelsTitle', + type: 'string', + default: '', + description: 'The title that displays above the featured channels module. The title has a maximum length of 30 characters.', + }, + { + displayName: 'Featured Channels Urls', + name: 'featuredChannelsUrls', + type: 'string', + typeOptions: { + multipleValues: true, + }, + description: 'A list of up to 100 channels that you would like to link to from the featured channels module. The property value is a list of YouTube channel ID values, each of which uniquely identifies a channel.', + default: [], + }, + { + displayName: 'Keywords', + name: 'keywords', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: 'tech,news', + description: 'Keywords associated with your channel. The value is a space-separated list of strings.', + default: '', + }, + { + displayName: 'Moderate Comments', + name: 'moderateComments', + type: 'boolean', + description: 'This setting determines whether user-submitted comments left on the channel page need to be approved by the channel owner to be publicly visible.', + default: false, + }, + { + displayName: 'Profile Color', + name: 'profileColor', + type: 'string', + default: '', + description: `A prominent color that complements the channel's content.`, + }, + { + displayName: 'Show Related Channels', + name: 'showRelatedChannels', + type: 'boolean', + description: 'This setting indicates whether YouTube should show an algorithmically generated list of related channels on your channel page.', + default: false, + }, + { + displayName: 'Show Browse View', + name: 'showBrowseView', + type: 'boolean', + description: 'This setting indicates whether the channel page should display content in a browse or feed view.', + default: false, + }, + { + displayName: 'Tracking Analytics AccountId', + name: 'trackingAnalyticsAccountId', + type: 'string', + description: 'The ID for a Google Analytics account that you want to use to track and measure traffic to your channel.', + default: '', + }, + { + displayName: 'Unsubscribed Trailer', + name: 'unsubscribedTrailer', + type: 'string', + description: `The video that should play in the featured video module in the channel page's browse view for unsubscribed viewers.`, + default: '', + }, + ], + }, + ], + description: 'The channel object encapsulates branding properties of the channel page', + }, + { + name: 'imageSettingsValues', + displayName: 'Image Settings', + values: [ + { + displayName: 'Image', + name: 'image', + type: 'collection', + default: '', + placeholder: 'Add Channel Settings', + description: `The image object encapsulates information about images that display on the channel's channel page or video watch pages.`, + typeOptions: { + multipleValues: false, + }, + options: [ + { + displayName: 'Banner External Url', + name: 'bannerExternalUrl', + type: 'string', + default: '', + }, + { + displayName: 'Tracking Image Url', + name: 'trackingImageUrl', + type: 'string', + default: '', + }, + { + displayName: 'watch Icon Image Url', + name: 'watchIconImageUrl', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + name: 'statusValue', + displayName: 'Status', + values: [ + { + displayName: 'Status', + name: 'status', + type: 'collection', + default: '', + placeholder: 'Add Status', + typeOptions: { + multipleValues: false, + }, + options: [ + { + displayName: 'Self Declared Made For Kids', + name: 'selfDeclaredMadeForKids', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'On Behalf Of Content Owner', + name: 'onBehalfOfContentOwner', + type: 'string', + default: '', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* channel:uploadBanner */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'uploadBanner', + ], + resource: [ + 'channel', + ], + }, + }, + default: '', + }, + { + displayName: 'Binary Property', + name: 'binaryProperty', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'uploadBanner', + ], + resource: [ + 'channel', + ], + }, + }, + default: 'data', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/YouTube/GenericFunctions.ts b/packages/nodes-base/nodes/Google/YouTube/GenericFunctions.ts new file mode 100644 index 0000000000..174f5755d5 --- /dev/null +++ b/packages/nodes-base/nodes/Google/YouTube/GenericFunctions.ts @@ -0,0 +1,66 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://www.googleapis.com${resource}`, + json: true + }; + try { + options = Object.assign({}, options, option); + + if (Object.keys(body).length === 0) { + delete options.body; + } + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'youTubeOAuth2Api', options); + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + let errors = error.response.body.error.errors; + + errors = errors.map((e: IDataObject) => e.message); + // Try to return the error prettier + throw new Error( + `YouTube error response [${error.statusCode}]: ${errors.join('|')}` + ); + } + throw error; + } +} + +export async function googleApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string ,method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.maxResults = 100; + + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query); + query.pageToken = responseData['nextPageToken']; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '' + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts b/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts new file mode 100644 index 0000000000..4621c06d5b --- /dev/null +++ b/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts @@ -0,0 +1,309 @@ +import { + IExecuteFunctions, BINARY_ENCODING, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeTypeDescription, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + googleApiRequest, + googleApiRequestAllItems, +} from './GenericFunctions'; + +import { + channelOperations, + channelFields, +} from './ChannelDescription'; + +export class YouTube implements INodeType { + description: INodeTypeDescription = { + displayName: 'Youtube', + name: 'youTube', + icon: 'file:youTube.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume YouTube API.', + defaults: { + name: 'YouTube', + color: '#FF0000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'youTubeOAuth2Api', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Channel', + value: 'channel', + }, + ], + default: 'channel', + description: 'The resource to operate on.' + }, + ...channelOperations, + ...channelFields, + ], + }; + + methods = { + loadOptions: { + // Get all the languages to display them to user so that he can + // select them easily + async getLanguages( + this: ILoadOptionsFunctions + ): Promise { + const returnData: INodePropertyOptions[] = []; + const languages = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + '/youtube/v3/i18nLanguages', + ); + for (const language of languages) { + const languageName = language.id.toUpperCase(); + const languageId = language.id; + returnData.push({ + name: languageName, + value: languageId + }); + } + return returnData; + }, + } + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'channel') { + //https://developers.google.com/youtube/v3/docs/channels/list + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const part = this.getNodeParameter('part', i) as string[]; + const options = this.getNodeParameter('options', i) as IDataObject; + + qs.part = part.join(','); + + if (options.categoryId) { + qs.categoryId = options.categoryId as string; + } + if (options.forUsername) { + qs.forUsername = options.forUsername as string; + } + if (options.id) { + qs.id = options.id as string; + } + if (options.managedByMe) { + qs.managedByMe = options.managedByMe as boolean; + } + if (options.mine) { + qs.mine = options.mine as boolean; + } + if (options.h1) { + qs.h1 = options.h1 as string; + } + if (options.onBehalfOfContentOwner) { + qs.onBehalfOfContentOwner = options.onBehalfOfContentOwner as string; + } + if (returnAll) { + responseData = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + `/youtube/v3/channels`, + {}, + qs + ); + } else { + qs.maxResults = this.getNodeParameter('limit', i) as number; + responseData = await googleApiRequest.call( + this, + 'GET', + `/youtube/v3/channels`, + {}, + qs + ); + responseData = responseData.items; + } + } + //https://developers.google.com/youtube/v3/docs/channels/update + if (operation === 'update') { + const channelId = this.getNodeParameter('channelId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + const body: IDataObject = { + id: channelId, + brandingSettings: { + channel: {}, + image: {}, + }, + }; + + qs.part = 'brandingSettings'; + + if (updateFields.onBehalfOfContentOwner) { + qs.onBehalfOfContentOwner = updateFields.onBehalfOfContentOwner as string; + } + + if (updateFields.brandingSettingsUi) { + const channelSettingsValues = (updateFields.brandingSettingsUi as IDataObject).channelSettingsValues as IDataObject | undefined; + const channelSettings: IDataObject = {}; + if (channelSettingsValues?.channel) { + const channelSettingsOptions = channelSettingsValues.channel as IDataObject; + if (channelSettingsOptions.country) { + channelSettings.country = channelSettingsOptions.country; + } + if (channelSettingsOptions.description) { + channelSettings.description = channelSettingsOptions.description; + } + if (channelSettingsOptions.defaultLanguage) { + channelSettings.defaultLanguage = channelSettingsOptions.defaultLanguage; + } + if (channelSettingsOptions.defaultTab) { + channelSettings.defaultTab = channelSettingsOptions.defaultTab; + } + if (channelSettingsOptions.featuredChannelsTitle) { + channelSettings.featuredChannelsTitle = channelSettingsOptions.featuredChannelsTitle; + } + if (channelSettingsOptions.featuredChannelsUrls) { + channelSettings.featuredChannelsUrls = channelSettingsOptions.featuredChannelsUrls; + } + if (channelSettingsOptions.keywords) { + channelSettings.keywords = channelSettingsOptions.keywords; + } + if (channelSettingsOptions.moderateComments) { + channelSettings.moderateComments = channelSettingsOptions.moderateComments as boolean; + } + if (channelSettingsOptions.profileColor) { + channelSettings.profileColor = channelSettingsOptions.profileColor as string; + } + if (channelSettingsOptions.profileColor) { + channelSettings.profileColor = channelSettingsOptions.profileColor as string; + } + if (channelSettingsOptions.showRelatedChannels) { + channelSettings.showRelatedChannels = channelSettingsOptions.showRelatedChannels as boolean; + } + if (channelSettingsOptions.showBrowseView) { + channelSettings.showBrowseView = channelSettingsOptions.showBrowseView as boolean; + } + if (channelSettingsOptions.trackingAnalyticsAccountId) { + channelSettings.trackingAnalyticsAccountId = channelSettingsOptions.trackingAnalyticsAccountId as string; + } + if (channelSettingsOptions.unsubscribedTrailer) { + channelSettings.unsubscribedTrailer = channelSettingsOptions.unsubscribedTrailer as string; + } + } + + const imageSettingsValues = (updateFields.brandingSettingsUi as IDataObject).imageSettingsValues as IDataObject | undefined; + const imageSettings: IDataObject = {}; + if (imageSettingsValues?.image) { + const imageSettingsOptions = imageSettings.image as IDataObject; + if (imageSettingsOptions.bannerExternalUrl) { + imageSettings.bannerExternalUrl = imageSettingsOptions.bannerExternalUrl as string + } + if (imageSettingsOptions.trackingImageUrl) { + imageSettings.trackingImageUrl = imageSettingsOptions.trackingImageUrl as string + } + if (imageSettingsOptions.watchIconImageUrl) { + imageSettings.watchIconImageUrl = imageSettingsOptions.watchIconImageUrl as string + } + } + + //@ts-ignore + body.brandingSettings.channel = channelSettings; + //@ts-ignore + body.brandingSettings.image = imageSettings; + } + + responseData = await googleApiRequest.call( + this, + 'PUT', + '/youtube/v3/channels', + body, + qs + ); + } + //https://developers.google.com/youtube/v3/docs/channelBanners/insert + if (operation === 'uploadBanner') { + const channelId = this.getNodeParameter('channelId', i) as string; + const binaryProperty = this.getNodeParameter('binaryProperty', i) as string; + + let mimeType; + + // Is binary file to upload + const item = items[i]; + + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + if (item.binary[binaryProperty] === undefined) { + throw new Error(`No binary data property "${binaryProperty}" does not exists on item!`); + } + + if (item.binary[binaryProperty].mimeType) { + mimeType = item.binary[binaryProperty].mimeType; + } + + const body = Buffer.from(item.binary[binaryProperty].data, BINARY_ENCODING); + + const requestOptions = { + headers: { + 'Content-Type': mimeType, + }, + json: false, + }; + + let response = await googleApiRequest.call(this, 'POST', '/upload/youtube/v3/channelBanners/insert', body, qs, undefined, requestOptions); + + const { url } = JSON.parse(response); + + qs.part = 'brandingSettings' + + responseData = await googleApiRequest.call( + this, + 'PUT', + `/youtube/v3/channels`, + { + id: channelId, + brandingSettings: { + image: { + bannerExternalUrl: url, + }, + }, + }, + qs, + ); + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Google/YouTube/youTube.png b/packages/nodes-base/nodes/Google/YouTube/youTube.png new file mode 100644 index 0000000000..efbe868910 Binary files /dev/null and b/packages/nodes-base/nodes/Google/YouTube/youTube.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c19d8e2349..56c7813657 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -64,6 +64,7 @@ "dist/credentials/GoogleOAuth2Api.credentials.js", "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", "dist/credentials/GoogleTasksOAuth2Api.credentials.js", + "dist/credentials/YouTubeOAuth2Api.credentials.js", "dist/credentials/GumroadApi.credentials.js", "dist/credentials/HarvestApi.credentials.js", "dist/credentials/HelpScoutOAuth2Api.credentials.js", @@ -202,7 +203,8 @@ "dist/nodes/Google/Calendar/GoogleCalendar.node.js", "dist/nodes/Google/Drive/GoogleDrive.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", - "dist/nodes/Google/Task/GoogleTasks.node.js", + "dist/nodes/Google/Task/GoogleTasks.node.js", + "dist/nodes/Google/YouTube/YouTube.node.js", "dist/nodes/GraphQL/GraphQL.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", "dist/nodes/Harvest/Harvest.node.js",