diff --git a/app/i18n/en-US/app.json b/app/i18n/en-US/app.json index b4cea99ad04c..dd7e0f645d9a 100644 --- a/app/i18n/en-US/app.json +++ b/app/i18n/en-US/app.json @@ -21,5 +21,6 @@ "Authentication Error": "Authentication Error", "Diagnostic Report Uploaded Successfully": "Diagnostic Report Uploaded Successfully", "Copy Report ID": "Copy Report ID", - "The diagnostic report was securely uploaded, and the Report ID below has been copied to your clipboard. Please provide the Report ID to the Streamlabs Streamer Success Team.": "The diagnostic report was securely uploaded, and the Report ID below has been copied to your clipboard. Please provide the Report ID to the Streamlabs Streamer Success Team." + "The diagnostic report was securely uploaded, and the Report ID below has been copied to your clipboard. Please provide the Report ID to the Streamlabs Streamer Success Team.": "The diagnostic report was securely uploaded, and the Report ID below has been copied to your clipboard. Please provide the Report ID to the Streamlabs Streamer Success Team.", + "Unknown %{platform} error": "Unknown %{platform} error" } diff --git a/app/i18n/en-US/youtube.json b/app/i18n/en-US/youtube.json index c447adbdb0f2..80672d917d06 100644 --- a/app/i18n/en-US/youtube.json +++ b/app/i18n/en-US/youtube.json @@ -42,5 +42,142 @@ "Stream Shift Error: YouTube is not live": "Stream Shift Error: YouTube is not live", "Error starting vertical stream in YouTube dual streaming.": "Error starting vertical stream in YouTube dual streaming.", "YouTube service is temporarily unavailable": "YouTube service is temporarily unavailable", - "YouTube backend error return from API call. Disable YouTube to allow the user to stream. Refer user to YouTube support": "YouTube backend error return from API call. Disable YouTube to allow the user to stream. Refer user to YouTube support" + "YouTube backend error return from API call. Disable YouTube to allow the user to stream. Refer user to YouTube support": "YouTube backend error return from API call. Disable YouTube to allow the user to stream. Refer user to YouTube support", + "This channel is not in the YouTube Partner Program, so monetization settings cannot be changed.": "This channel is not in the YouTube Partner Program, so monetization settings cannot be changed.", + "You have too many live or scheduled broadcasts on YouTube. Remove some in YouTube Studio and try again.": "You have too many live or scheduled broadcasts on YouTube. Remove some in YouTube Studio and try again.", + "Streamlabs has reached YouTube's daily API limit. It resets at midnight Pacific time.": "Streamlabs has reached YouTube's daily API limit. It resets at midnight Pacific time.", + "Too many requests to YouTube in a short period. Wait a few minutes and try again.": "Too many requests to YouTube in a short period. Wait a few minutes and try again.", + "Your YouTube account has not granted Streamlabs the permissions needed to stream. Re-log or re-merge the account.": "Your YouTube account has not granted Streamlabs the permissions needed to stream. Re-log or re-merge the account.", + "Failed to upload thumbnail.": "Failed to upload thumbnail.", + "YouTube token expired, please re-merge your account": "YouTube token expired, please re-merge your account", + "YouTube API service unavailable": "YouTube API service unavailable", + "YouTube permission denied by API": "YouTube permission denied by API", + "Account Delegation Forbidden": "Account Delegation Forbidden", + "Authenticated User Account Closed": "Authenticated User Account Closed", + "Authenticated User Account Suspended": "Authenticated User Account Suspended", + "Authenticated User Not Channel": "Authenticated User Not Channel", + "Authorization Required": "Authorization Required", + "Backend Error": "Backend Error", + "Broadcast Stream Delay (ms) Required": "Broadcast Stream Delay (ms) Required", + "Category Not Found": "Category Not Found", + "CDN Required": "CDN Required", + "Channel Closed": "Channel Closed", + "Channel Forbidden": "Channel Forbidden", + "Channel Not Found": "Channel Not Found", + "Channel Suspended": "Channel Suspended", + "Closed Captions Type Modification Not Allowed": "Closed Captions Type Modification Not Allowed", + "CMS User Account Not Found": "CMS User Account Not Found", + "Concurrent Broadcasts Exceed Limit": "Concurrent Broadcasts Exceed Limit", + "Content Owner Account Not Found": "Content Owner Account Not Found", + "Default Language Not Set": "Default Language Not Set", + "Enable Auto Start Modification Not Allowed": "Enable Auto Start Modification Not Allowed", + "Enable Closed Captions Modification Not Allowed": "Enable Closed Captions Modification Not Allowed", + "Enable DVR Modification Not Allowed": "Enable DVR Modification Not Allowed", + "Enable Monitor Stream Modification Not Allowed": "Enable Monitor Stream Modification Not Allowed", + "Enable Monitor Stream Required": "Enable Monitor Stream Required", + "Error Executing Transition": "Error Executing Transition", + "Error Stream Inactive": "Error Stream Inactive", + "Forbidden": "Forbidden", + "Forbidden Embed Setting": "Forbidden Embed Setting", + "Forbidden License Setting": "Forbidden License Setting", + "Forbidden Privacy Setting": "Forbidden Privacy Setting", + "Frame Rate Required": "Frame Rate Required", + "ID Required": "ID Required", + "Incompatible Parameters": "Incompatible Parameters", + "Ingestion Type Required": "Ingestion Type Required", + "Insufficient Capabilities": "Insufficient Capabilities", + "Insufficient Live Permissions": "Insufficient Live Permissions", + "Insufficient Permissions": "Insufficient Permissions", + "Internal Error": "Internal Error", + "Invalid Auto Start": "Invalid Auto Start", + "Invalid Auto Stop": "Invalid Auto Stop", + "Invalid Category ID": "Invalid Category ID", + "Invalid Creator Cuepoint Config": "Invalid Creator Cuepoint Config", + "Invalid Criteria": "Invalid Criteria", + "Invalid Cuepoint Schedule": "Invalid Cuepoint Schedule", + "Invalid Default Broadcast Privacy Setting": "Invalid Default Broadcast Privacy Setting", + "Invalid Description": "Invalid Description", + "Invalid Eligible For Ads Monetization": "Invalid Eligible For Ads Monetization", + "Invalid Embed Setting": "Invalid Embed Setting", + "Invalid Enable Closed Captions": "Invalid Enable Closed Captions", + "Invalid Enabled": "Invalid Enabled", + "Invalid Filename": "Invalid Filename", + "Invalid Filters": "Invalid Filters", + "Invalid Format": "Invalid Format", + "Invalid Frame Rate": "Invalid Frame Rate", + "Invalid Image": "Invalid Image", + "Invalid Ingestion Type": "Invalid Ingestion Type", + "Invalid Language": "Invalid Language", + "Invalid Latency Preference Options": "Invalid Latency Preference Options", + "Invalid Mine": "Invalid Mine", + "Invalid Page Token": "Invalid Page Token", + "Invalid Part": "Invalid Part", + "Invalid Pause Ads Until": "Invalid Pause Ads Until", + "Invalid Privacy Status": "Invalid Privacy Status", + "Invalid Projection": "Invalid Projection", + "Invalid Publish At": "Invalid Publish At", + "Invalid Recording Details": "Invalid Recording Details", + "Invalid Region Code": "Invalid Region Code", + "Invalid Repeat Interval (secs)": "Invalid Repeat Interval (secs)", + "Invalid Resolution": "Invalid Resolution", + "Invalid Scheduled End Time": "Invalid Scheduled End Time", + "Invalid Scheduled Start Time": "Invalid Scheduled Start Time", + "Invalid Tags": "Invalid Tags", + "Invalid Title": "Invalid Title", + "Invalid Transition": "Invalid Transition", + "Invalid Video Game Rating": "Invalid Video Game Rating", + "Invalid Video Metadata": "Invalid Video Metadata", + "Live Broadcast Binding Not Allowed": "Live Broadcast Binding Not Allowed", + "Live Broadcast Deletion Not Allowed": "Live Broadcast Deletion Not Allowed", + "Live Broadcast Not Found": "Live Broadcast Not Found", + "Live Permission Blocked": "Live Permission Blocked", + "Live Stream Not Found": "Live Stream Not Found", + "Live Streaming Not Enabled": "Live Streaming Not Enabled", + "Media Body Required": "Media Body Required", + "Missing Ads Monetization Status": "Missing Ads Monetization Status", + "Missing Required Parameter": "Missing Required Parameter", + "Monetization Details Modification Not Allowed": "Monetization Details Modification Not Allowed", + "Pause Ads Until Modification Not Allowed": "Pause Ads Until Modification Not Allowed", + "Privacy Status Required": "Privacy Status Required", + "Quota Exceeded": "Quota Exceeded", + "Record From Start Modification Not Allowed": "Record From Start Modification Not Allowed", + "Redundant Transition": "Redundant Transition", + "Resolution Required": "Resolution Required", + "Scheduled End Time Required": "Scheduled End Time Required", + "Scheduled Start Time Required": "Scheduled Start Time Required", + "Shared Ingestion Broadcasts Exceed Limit": "Shared Ingestion Broadcasts Exceed Limit", + "Status Required": "Status Required", + "Title Required": "Title Required", + "Unexpected Parameter": "Unexpected Parameter", + "Unexpected Part": "Unexpected Part", + "Unknown Part": "Unknown Part", + "Unsupported Language Code": "Unsupported Language Code", + "Unsupported Region Code": "Unsupported Region Code", + "Upload Limit Exceeded": "Upload Limit Exceeded", + "Upload Rate Limit Exceeded": "Upload Rate Limit Exceeded", + "User Broadcasts Exceed Limit": "User Broadcasts Exceed Limit", + "User Requests Exceed Rate Limit": "User Requests Exceed Rate Limit", + "Video Category Not Found": "Video Category Not Found", + "Video Chart Not Found": "Video Chart Not Found", + "Video Not Eligible For Ads Monetization": "Video Not Eligible For Ads Monetization", + "Video Not Found": "Video Not Found", + "YouTube Signup Required": "YouTube Signup Required", + "Failed to create the YouTube broadcast": "Failed to create the YouTube broadcast", + "Failed to update the YouTube broadcast": "Failed to update the YouTube broadcast", + "Failed to delete the YouTube broadcast": "Failed to delete the YouTube broadcast", + "Failed to bind the stream to the YouTube broadcast": "Failed to bind the stream to the YouTube broadcast", + "Failed to change the status of the YouTube broadcast": "Failed to change the status of the YouTube broadcast", + "Failed to fetch your YouTube broadcasts": "Failed to fetch your YouTube broadcasts", + "Failed to create the YouTube stream": "Failed to create the YouTube stream", + "Failed to fetch the YouTube stream": "Failed to fetch the YouTube stream", + "Failed to update the YouTube video settings": "Failed to update the YouTube video settings", + "Failed to fetch the YouTube video": "Failed to fetch the YouTube video", + "Failed to upload the video to YouTube": "Failed to upload the video to YouTube", + "Failed to fetch YouTube categories": "Failed to fetch YouTube categories", + "Failed to fetch your YouTube channel": "Failed to fetch your YouTube channel", + "confirm the account is enabled for live streaming": "confirm the account is enabled for live streaming", + "confirm the broadcast is currently live or that it exists in YouTube Studio": "confirm the broadcast is currently live or that it exists in YouTube Studio", + "confirm the channel is under its broadcast limit": "confirm the channel is under its broadcast limit", + "confirm their current and upcoming broadcasts in YouTube Studio": "confirm their current and upcoming broadcasts in YouTube Studio", + "confirm their videos and current and upcoming broadcasts in YouTube Studio": "confirm their videos and current and upcoming broadcasts in YouTube Studio" } diff --git a/app/services/platforms/base-platform.ts b/app/services/platforms/base-platform.ts index 4efc3d6e8149..94957e6a044d 100644 --- a/app/services/platforms/base-platform.ts +++ b/app/services/platforms/base-platform.ts @@ -19,7 +19,8 @@ import * as remote from '@electron/remote'; import { VideoSettingsService } from 'services/settings-v2/video'; import { ENotificationType, NotificationsService } from 'services/notifications'; import { IJsonRpcRequest } from 'services/api/jsonrpc'; -import { throwStreamError, TStreamErrorType } from 'services/streaming/stream-error'; +import { StreamError, throwStreamError, TStreamErrorType } from 'services/streaming/stream-error'; +import { $t } from 'services/i18n/i18n'; const VIEWER_COUNT_UPDATE_INTERVAL = 60 * 1000; @@ -152,19 +153,23 @@ export abstract class BasePlatformService extends Stat }); } - formatError( - e: any, + throwPlatformError( platform: TPlatform, + e: StreamError | any, errorType: TStreamErrorType = 'PLATFORM_REQUEST_FAILED', ): never { console.error(`${platformLabels(platform)} Error: `, e); + if (e instanceof StreamError) { + throwStreamError(e.type, e, e.message); + } + const message = e.result?.data?.message || e.result?.message || e.statusText || e.message || - `Unknown error starting ${platformLabels(platform)} stream`; + $t('Unknown %{platform} error', { platform: platformLabels(platform) }); throwStreamError( errorType, diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 3e518f3a6b2c..4903fe9d866e 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -11,7 +11,7 @@ import { TDisplayType } from 'services/settings-v2'; import { $t } from 'services/i18n'; import { KickService, IKickStartStreamOptions } from './kick'; import { PatreonService, IPatreonStartStreamOptions } from './patreon'; -import type { TStreamErrorType } from 'services/streaming/stream-error'; +import type { StreamError, TStreamErrorType } from 'services/streaming/stream-error'; export type Tag = string; export interface IGame { @@ -176,6 +176,12 @@ export interface IPlatformState { isPrepopulated: boolean; } +export interface IPlatformErrorCallbackProps { + e: any; + reqInfo: IPlatformRequest | string; + errorType?: TStreamErrorType; +} + // All platform services should implement this interface. export interface IPlatformService { capabilities: Set; @@ -216,7 +222,12 @@ export interface IPlatformService { postNotification?: (message: string) => void; - formatError?: (e: any, platform: TPlatform, errorType?: TStreamErrorType) => never; + createPlatformError?: ( + e: any, + reqInfo: IPlatformRequest | string, + errorType?: TStreamErrorType, + fn?: (p: IPlatformErrorCallbackProps) => void, + ) => StreamError; fetchNewToken: () => Promise; diff --git a/app/services/platforms/patreon.ts b/app/services/platforms/patreon.ts index df7bf183e828..367a68a3869b 100644 --- a/app/services/platforms/patreon.ts +++ b/app/services/platforms/patreon.ts @@ -291,7 +291,7 @@ export class PatreonService return res; }) .catch(e => { - return this.formatError(e, 'patreon'); + return this.throwPlatformError('patreon', e); }); } @@ -312,7 +312,7 @@ export class PatreonService return res; }) .catch(e => { - return this.formatError(e, 'patreon'); + return this.throwPlatformError('patreon', e); }); } diff --git a/app/services/platforms/youtube.ts b/app/services/platforms/youtube.ts index 7353dc6980c7..421f50c0d493 100644 --- a/app/services/platforms/youtube.ts +++ b/app/services/platforms/youtube.ts @@ -6,6 +6,7 @@ import { IPlatformRequest, IPlatformState, TLiveDockFeature, + IPlatformErrorCallbackProps, } from '.'; import { Inject } from 'services/core/injector'; import { authorizedHeaders, jfetch } from 'util/requests'; @@ -13,12 +14,19 @@ import { platformAuthorizedRequest } from './utils'; import { CustomizationService } from 'services/customization'; import { IGoLiveSettings, TDisplayOutput } from 'services/streaming'; import { $t, I18nService } from 'services/i18n'; -import { StreamError, throwStreamError, TStreamErrorType } from 'services/streaming/stream-error'; +import { + createStreamError, + StreamError, + throwStreamError, + TStreamErrorType, +} from 'services/streaming/stream-error'; import { BasePlatformService } from './base-platform'; import { TDisplayType } from 'services/settings-v2/video'; import { assertIsDefined, getDefined } from 'util/properties-type-guards'; import Utils from '../utils'; import { YoutubeUploader } from './youtube/uploader'; +import { formatErrorRejectedRequest, getYoutubeErrorType } from './youtube/errors'; +import { EYoutubeErrorReason } from './youtube/api'; import { lazyModule } from 'util/lazy-module'; import * as remote from '@electron/remote'; import { IVideo } from 'obs-studio-node'; @@ -313,86 +321,64 @@ export class YoutubeService try { return await platformAuthorizedRequest('youtube', reqInfo); } catch (e: unknown) { - console.error(`Failed ${this.displayName} API Request:`, reqInfo); - - const error = e as any; - const details = error?.message ?? $t('Connection Failed'); - - // If the error doesn't have a result or an error object, it's not a YouTube API error response - if (!error?.result || !error.result?.error) { - return throwStreamError( - 'PLATFORM_REQUEST_FAILED', - { ...error, platform: 'youtube' }, - details, - ); + const error = this.createPlatformError(e, reqInfo); + // Handle the live streaming not enabled error first because none of the others will occur if the user + // is not enabled for live streaming + if (error.reason === 'liveStreamingNotEnabled' && repeatRequestIfRateLimitExceed) { + await Utils.sleep(3000); + return await this.requestYoutube(reqInfo, false); } - const json = error.result.error; - - // If there are no errors in the array, check the status code - if (!json?.errors || !json.errors.length) { - if (!json.status) { - console.error('No status or reason in YouTube API error response', json); - return throwStreamError('PLATFORM_REQUEST_FAILED', { - ...error, - platform: 'youtube', - details, - }); - } - - if (json.status === 423) { - console.error('Error 423: YouTube token expired, need to refresh', json); - return throwStreamError('YOUTUBE_TOKEN_EXPIRED', { - ...error, - platform: 'youtube', - details, - }); - } - - if (json.status === 503) { - console.error('Error 503: YouTube service unavailable', json); - return throwStreamError('YOUTUBE_UNAVAILABLE', { - ...error, - platform: 'youtube', - details, - }); - } + this.throwPlatformError('youtube', error); + } + } - if (json.status !== 403) { - console.error('Error ', json.status, ': Non-generic error', json); - return throwStreamError('PLATFORM_REQUEST_FAILED', { - ...error, - platform: 'youtube', - details, - }); - } - } + /** + * ERROR HANDLING + */ - // If there are errors in the array, check the first one for the reason - const firstError = json.errors[0]; - if (firstError.reason) { - // Log the reason and the full error response for debugging - console.log('YouTube API error Reason:', firstError.reason, json); + /** + * Unified error handling for all YouTube API requests + * @remark This function logs the error to the console and creates a StreamError object + * with the appropriate error type and details. By unifying error handling, ensure YouTube errors + * are handled consistently, improve debugging through a single point of entry, and make updating + * error handling easier in the future. + * @param e - `any`, The error object thrown by the failed request + * @param reqInfo - `IPlatformRequest | string`, The request information from the request that failed + * @param errorType - `TStreamErrorType`, Optional parameter to specify the type of `StreamError` to create. + * @param fn - A function to override the default error handling behavior. If provided, this function + * will be called instead of throwing a `StreamError`. This allows for custom error handling in specific cases + * and skipping throwing the error if the desired functionality is to continue execution without interruption. + * Takes in an object with the error, request info, and error type as parameters in case the function needs them. + * @returns a `StreamError` object + */ + createPlatformError( + e: any, + reqInfo: IPlatformRequest | string, + reqErrorType?: TStreamErrorType, + fn?: (p: IPlatformErrorCallbackProps) => void, + ): StreamError { + // Always log the error to the console for debugging purposes + const consoleError = + typeof reqInfo !== 'string' + ? `Failed ${this.displayName} API Request` + : `Failed ${this.displayName} Request`; + console.error(consoleError, '\nRequest Info:', reqInfo, '\nError:', e); + + // If a function is provided, skip the default handling + if (fn) { + fn({ e, reqInfo, errorType: reqErrorType }); + return; + } - if (firstError.reason === 'liveStreamingNotEnabled') { - if (repeatRequestIfRateLimitExceed) { - await Utils.sleep(3000); - return await this.requestYoutube(reqInfo, false); - } - return throwStreamError('YOUTUBE_STREAMING_DISABLED', { - ...error, - platform: 'youtube', - details, - }); - } - } + // YouTube API requests should return a reason, but errors within the app will not have a reason + // `undefined` reasons will be handled in the below functions to create a consistent error message + const reason: EYoutubeErrorReason | undefined = e?.result?.error?.errors?.[0]?.reason; + const errorType = reqErrorType || getYoutubeErrorType(e, reqInfo); + const rejectedRequest = formatErrorRejectedRequest(e, errorType, reason); - return throwStreamError('PLATFORM_REQUEST_FAILED', { - ...error, - platform: 'youtube', - details, - }); - } + // The diagnostic report should also record the error message shown to the user + return createStreamError(errorType, rejectedRequest, rejectedRequest.statusText); } @mutation() @@ -473,16 +459,21 @@ export class YoutubeService thumbnail: broadcast.snippet.thumbnails?.high?.url || 'default', }); } catch (e: unknown) { - console.error('Error fetching broadcasts', e); - - // If fetching the YouTube settings fails, populate just the Stream Shift settings - if (settings) { - this.UPDATE_STREAM_SETTINGS({ - title: settings.stream_title, - broadcastId: settings.broadcast_id, - }); - } - return; + // Skip throwing the error here because we want to allow the user to use stream shift even if YouTube API call fails + this.createPlatformError( + e, + 'Error setting up stream shift stream', + 'PLATFORM_REQUEST_FAILED', + () => { + // If fetching the YouTube settings fails, populate just the Stream Shift settings + if (settings) { + this.UPDATE_STREAM_SETTINGS({ + title: settings.stream_title, + broadcastId: settings.broadcast_id, + }); + } + }, + ); } this.setPlatformContext('youtube'); @@ -637,11 +628,18 @@ export class YoutubeService }, 1000); }); } catch (e: unknown) { - console.error('Error setting up YouTube dual stream', e); - - // Catch error to prevent blocking the horizontal stream starting if there is an issue - // setting up the vertical stream - this.postNotification('Error setting up YouTube dual stream. Vertical stream not started.'); + this.createPlatformError( + e, + 'Error setting up YouTube dual stream', + 'PLATFORM_REQUEST_FAILED', + () => { + // Catch and don't throw error to prevent blocking the horizontal stream starting + // if there is an issue setting up the vertical stream + this.postNotification( + 'Error setting up YouTube dual stream. Vertical stream not started.', + ); + }, + ); } } @@ -704,22 +702,23 @@ export class YoutubeService * check that user has enabled live-streaming on their account */ async validatePlatform(): Promise { + const endpoint = 'liveStreams?part=id,snippet&mine=true'; + const url = `${this.apiBase}/${endpoint}`; + try { - const endpoint = 'liveStreams?part=id,snippet&mine=true'; - const url = `${this.apiBase}/${endpoint}`; await this.requestYoutube(url, false); this.SET_ENABLED_STATUS(true); return EPlatformCallResult.Success; } catch (e: unknown) { - if ((e as any)?.result?.error?.errors?.length) { - console.log('Youtube API Error Reason: ', (e as any).result.error.errors[0].reason); - } - console.error('Error validating YouTube platform', e); - this.SET_ENABLED_STATUS(false); + + // `requestYoutube` already created the stream error and logged it so just return the result if (e instanceof StreamError) { - if (e.type === 'YOUTUBE_TOKEN_EXPIRED') return EPlatformCallResult.TokenExpired; - if (e.type === 'YOUTUBE_STREAMING_DISABLED') { + if (e.status === 423 || e.reason === EYoutubeErrorReason.Forbidden) { + return EPlatformCallResult.TokenExpired; + } + + if (e.reason === EYoutubeErrorReason.LiveStreamingNotEnabled) { return EPlatformCallResult.YoutubeStreamingDisabled; } } @@ -934,18 +933,23 @@ export class YoutubeService await this.uploadThumbnail(params.thumbnail, broadcast.id); this.UPDATE_STREAM_SETTINGS({ thumbnail: params.thumbnail }); } catch (e: unknown) { - // Note: we already logged this error to the console in the `uploadThumbnail` method - console.debug('Error uploading thumbnail:', e); - - let message = $t('Please upload thumbnail manually on YouTube.'); - if (e instanceof StreamError) { - message = [$t('Please upload thumbnail manually on YouTube.'), e.details].join(' '); - } - - this.notificationsService.actions.push({ - message, - type: ENotificationType.WARNING, - }); + // Note: we already logged and handled the error in the `uploadThumbnail` method + this.createPlatformError( + e, + 'Error uploading thumbnail', + 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED', + () => { + const message = + e instanceof StreamError + ? [$t('Please upload thumbnail manually on YouTube.'), e.details].join(' ') + : $t('Please upload thumbnail manually on YouTube.'); + + this.notificationsService.actions.push({ + message, + type: ENotificationType.WARNING, + }); + }, + ); } } @@ -1034,11 +1038,36 @@ export class YoutubeService if (params.eligibleForMonetization) body.monetizationDetails = monetizationDetails; - broadcast = await this.requestYoutube({ - body: JSON.stringify(body), - method: 'PUT', - url: `${this.apiBase}/${endpoint}`, - }); + try { + broadcast = await this.requestYoutube({ + body: JSON.stringify(body), + method: 'PUT', + url: `${this.apiBase}/${endpoint}`, + }); + } catch (e: unknown) { + this.createPlatformError( + e, + 'Error updating broadcast', + 'PLATFORM_REQUEST_FAILED', + async () => { + // YouTube reports a broadcast as ads-eligible via eligibleForAdsMonetization and + // then refuses the write if the channel is outside the Partner Program, so the + // flag cannot be trusted as permission. Ad settings are not worth blocking go + // live over — drop them and send the rest. + const isMonetizationRefused = + e instanceof StreamError && e.reason === 'monetizationDetailsModificationNotAllowed'; + if (!isMonetizationRefused || !body.monetizationDetails) throw e; + + delete body.monetizationDetails; + const retryFields = fields.filter(field => field !== 'monetizationDetails'); + broadcast = await this.requestYoutube({ + body: JSON.stringify(body), + method: 'PUT', + url: `${this.apiBase}/liveBroadcasts?part=${retryFields.join(',')}&id=${id}`, + }); + }, + ); + } if (!isMidStreamMode) { await this.updateCategory(broadcast.id, params.categoryId!); @@ -1267,9 +1296,9 @@ export class YoutubeService } async uploadThumbnail(base64url: string | 'default', videoId: string) { - // if `default` passed as url then upload default url - // otherwise convert the passed base64url to blob - const url = + // if `default` passed as the `base64url` then upload from the default image url + // otherwise convert the passed `base64url` to blob + const imageUrl = base64url !== 'default' ? base64url : `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; if (base64url.startsWith('http')) { @@ -1278,48 +1307,18 @@ export class YoutubeService return; } - const body = await fetch(url).then(res => res.blob()); + const body = await fetch(imageUrl).then(res => res.blob()); - await jfetch(`https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${videoId}`, { + const url = `https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${videoId}`; + + await jfetch(url, { method: 'POST', body, headers: { Authorization: `Bearer ${this.oauthToken}` }, }).catch(e => { - console.error('Failed to upload thumbnail', e); - const errorType = 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED'; - const error = e as any; - - let details = 'Failed to upload thumbnail.'; - const code = error?.code || error?.status; - - if (code) { - const hasReason = error?.errors && error?.errors.length && error?.errors[0].reason; - switch (code) { - case 400: - if (hasReason && error.errors[0].reason === 'invalidImage') { - details = $t('Thumbnail image content is invalid.'); - } else if (hasReason && error.errors[0].reason === 'mediaBodyRequired') { - details = $t('Thumbnail file does not include image content.'); - } - break; - case 403: - details = $t('Permission missing to upload thumbnails.'); - break; - case 413: - details = $t('YouTube thumbnail image is too large. Maximum size is 2MB.'); - break; - case 404: - details = $t('Video does not exist. Thumbnail upload failed.'); - break; - case 429: - details = $t('Exceeded thumbnail upload quota. Please try again later.'); - break; - default: - details = error?.message || details; - } - } - - return throwStreamError(errorType, { ...error, platform: 'youtube' }, details); + console.log(`Failed to upload thumbnail. Endpoint: ${url}, Image URL: ${imageUrl}`, e); + const error = this.createPlatformError(e, { url }, 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED'); + this.throwPlatformError('youtube', error); }); } diff --git a/app/services/platforms/youtube/api.ts b/app/services/platforms/youtube/api.ts new file mode 100644 index 000000000000..30d34c1895e1 --- /dev/null +++ b/app/services/platforms/youtube/api.ts @@ -0,0 +1,322 @@ +import { $t } from 'services/i18n'; +import { TStreamErrorType } from 'services/streaming/stream-error'; + +export enum EYoutubeErrorReason { + AccountDelegationForbidden = 'accountDelegationForbidden', + AuthenticatedUserAccountClosed = 'authenticatedUserAccountClosed', + AuthenticatedUserAccountSuspended = 'authenticatedUserAccountSuspended', + AuthenticatedUserNotChannel = 'authenticatedUserNotChannel', + AuthorizationRequired = 'authorizationRequired', + BackendError = 'backendError', + BroadcastStreamDelayMsRequired = 'broadcastStreamDelayMsRequired', + CategoryNotFound = 'categoryNotFound', + CdnRequired = 'cdnRequired', + ChannelClosed = 'channelClosed', + ChannelForbidden = 'channelForbidden', + ChannelNotFound = 'channelNotFound', + ChannelSuspended = 'channelSuspended', + ClosedCaptionsTypeModificationNotAllowed = 'closedCaptionsTypeModificationNotAllowed', + CmsUserAccountNotFound = 'cmsUserAccountNotFound', + ConcurrentBroadcastsExceedLimit = 'concurrentBroadcastsExceedLimit', + ContentOwnerAccountNotFound = 'contentOwnerAccountNotFound', + DefaultLanguageNotSet = 'defaultLanguageNotSet', + EnableAutoStartModificationNotAllowed = 'enableAutoStartModificationNotAllowed', + EnableClosedCaptionsModificationNotAllowed = 'enableClosedCaptionsModificationNotAllowed', + EnableDvrModificationNotAllowed = 'enableDvrModificationNotAllowed', + EnableMonitorStreamModificationNotAllowed = 'enableMonitorStreamModificationNotAllowed', + EnableMonitorStreamRequired = 'enableMonitorStreamRequired', + ErrorExecutingTransition = 'errorExecutingTransition', + ErrorStreamInactive = 'errorStreamInactive', + Forbidden = 'forbidden', + ForbiddenEmbedSetting = 'forbiddenEmbedSetting', + ForbiddenLicenseSetting = 'forbiddenLicenseSetting', + ForbiddenPrivacySetting = 'forbiddenPrivacySetting', + FrameRateRequired = 'frameRateRequired', + IdRequired = 'idRequired', + IncompatibleParameters = 'incompatibleParameters', + IngestionTypeRequired = 'ingestionTypeRequired', + InsufficientCapabilities = 'insufficientCapabilities', + InsufficientLivePermissions = 'insufficientLivePermissions', + InsufficientPermissions = 'insufficientPermissions', + InternalError = 'internalError', + InvalidAutoStart = 'invalidAutoStart', + InvalidAutoStop = 'invalidAutoStop', + InvalidCategoryId = 'invalidCategoryId', + InvalidCreatorCuepointConfig = 'invalidCreatorCuepointConfig', + InvalidCriteria = 'invalidCriteria', + InvalidCuepointSchedule = 'invalidCuepointSchedule', + InvalidDefaultBroadcastPrivacySetting = 'invalidDefaultBroadcastPrivacySetting', + InvalidDescription = 'invalidDescription', + InvalidEligibleForAdsMonetization = 'invalidEligibleForAdsMonetization', + InvalidEmbedSetting = 'invalidEmbedSetting', + InvalidEnableClosedCaptions = 'invalidEnableClosedCaptions', + InvalidEnabled = 'invalidEnabled', + InvalidFilename = 'invalidFilename', + InvalidFilters = 'invalidFilters', + InvalidFormat = 'invalidFormat', + InvalidFrameRate = 'invalidFrameRate', + InvalidImage = 'invalidImage', + InvalidIngestionType = 'invalidIngestionType', + InvalidLanguage = 'invalidLanguage', + InvalidLatencyPreferenceOptions = 'invalidLatencyPreferenceOptions', + InvalidMine = 'invalidMine', + InvalidPageToken = 'invalidPageToken', + InvalidPart = 'invalidPart', + InvalidPauseAdsUntil = 'invalidPauseAdsUntil', + InvalidPrivacyStatus = 'invalidPrivacyStatus', + InvalidProjection = 'invalidProjection', + InvalidPublishAt = 'invalidPublishAt', + InvalidRecordingDetails = 'invalidRecordingDetails', + InvalidRegionCode = 'invalidRegionCode', + InvalidRepeatIntervalSecs = 'invalidRepeatIntervalSecs', + InvalidResolution = 'invalidResolution', + InvalidScheduledEndTime = 'invalidScheduledEndTime', + InvalidScheduledStartTime = 'invalidScheduledStartTime', + InvalidTags = 'invalidTags', + InvalidTitle = 'invalidTitle', + InvalidTransition = 'invalidTransition', + InvalidVideoGameRating = 'invalidVideoGameRating', + InvalidVideoMetadata = 'invalidVideoMetadata', + LiveBroadcastBindingNotAllowed = 'liveBroadcastBindingNotAllowed', + LiveBroadcastDeletionNotAllowed = 'liveBroadcastDeletionNotAllowed', + LiveBroadcastNotFound = 'liveBroadcastNotFound', + LivePermissionBlocked = 'livePermissionBlocked', + LiveStreamNotFound = 'liveStreamNotFound', + LiveStreamingNotEnabled = 'liveStreamingNotEnabled', + MediaBodyRequired = 'mediaBodyRequired', + MissingAdsMonetizationStatus = 'missingAdsMonetizationStatus', + MissingRequiredParameter = 'missingRequiredParameter', + MonetizationDetailsModificationNotAllowed = 'monetizationDetailsModificationNotAllowed', + PauseAdsUntilModificationNotAllowed = 'pauseAdsUntilModificationNotAllowed', + PrivacyStatusRequired = 'privacyStatusRequired', + QuotaExceeded = 'quotaExceeded', + RecordFromStartModificationNotAllowed = 'recordFromStartModificationNotAllowed', + RedundantTransition = 'redundantTransition', + ResolutionRequired = 'resolutionRequired', + ScheduledEndTimeRequired = 'scheduledEndTimeRequired', + ScheduledStartTimeRequired = 'scheduledStartTimeRequired', + SharedIngestionBroadcastsExceedLimit = 'sharedIngestionBroadcastsExceedLimit', + StatusRequired = 'statusRequired', + TitleRequired = 'titleRequired', + UnexpectedParameter = 'unexpectedParameter', + UnexpectedPart = 'unexpectedPart', + UnknownPart = 'unknownPart', + UnsupportedLanguageCode = 'unsupportedLanguageCode', + UnsupportedRegionCode = 'unsupportedRegionCode', + UploadLimitExceeded = 'uploadLimitExceeded', + UploadRateLimitExceeded = 'uploadRateLimitExceeded', + UserBroadcastsExceedLimit = 'userBroadcastsExceedLimit', + UserRequestsExceedRateLimit = 'userRequestsExceedRateLimit', + VideoCategoryNotFound = 'videoCategoryNotFound', + VideoChartNotFound = 'videoChartNotFound', + VideoNotEligibleForAdsMonetization = 'videoNotEligibleForAdsMonetization', + VideoNotFound = 'videoNotFound', + YoutubeSignupRequired = 'youtubeSignupRequired', +} + +export enum EYoutubeErrorEndpoint { + LiveBroadcastInsert = 'liveBroadcasts/insert', + LiveBroadcastUpdate = 'liveBroadcasts/update', + LiveBroadcastDelete = 'liveBroadcasts/delete', + LiveBroadcastBind = 'liveBroadcasts/bind', + LiveBroadcastTransition = 'liveBroadcasts/transition', + LiveBroadcastList = 'liveBroadcasts/list', + LiveStreamsInsert = 'liveStreams/insert', + LiveStreamsList = 'liveStreams/list', + VideosInsert = 'videos/insert', + VideosUpdate = 'videos/update', + VideosList = 'videos/list', + VideoCategoriesList = 'videoCategories/list', + ChannelsList = 'channels/list', + ThumbnailsSet = 'thumbnails/set', +} + +/** + * Display YouTube API Error Details in a readable format + * @remark Any changes to the error details in the YouTube API will not automatically update this list. + * In that case, `formatErrorDetails` will format the error details separated by camel case. Not + * ideal but better than showing nothing at all. + */ +export const reasonLabels = (reason: EYoutubeErrorReason) => + ({ + [EYoutubeErrorReason.AccountDelegationForbidden]: $t('Account Delegation Forbidden'), + [EYoutubeErrorReason.AuthenticatedUserAccountClosed]: $t('Authenticated User Account Closed'), + [EYoutubeErrorReason.AuthenticatedUserAccountSuspended]: $t( + 'Authenticated User Account Suspended', + ), + [EYoutubeErrorReason.AuthenticatedUserNotChannel]: $t('Authenticated User Not Channel'), + [EYoutubeErrorReason.AuthorizationRequired]: $t('Authorization Required'), + [EYoutubeErrorReason.BackendError]: $t('Backend Error'), + [EYoutubeErrorReason.BroadcastStreamDelayMsRequired]: $t( + 'Broadcast Stream Delay (ms) Required', + ), + [EYoutubeErrorReason.CategoryNotFound]: $t('Category Not Found'), + [EYoutubeErrorReason.CdnRequired]: $t('CDN Required'), + [EYoutubeErrorReason.ChannelClosed]: $t('Channel Closed'), + [EYoutubeErrorReason.ChannelForbidden]: $t('Channel Forbidden'), + [EYoutubeErrorReason.ChannelNotFound]: $t('Channel Not Found'), + [EYoutubeErrorReason.ChannelSuspended]: $t('Channel Suspended'), + [EYoutubeErrorReason.ClosedCaptionsTypeModificationNotAllowed]: $t( + 'Closed Captions Type Modification Not Allowed', + ), + [EYoutubeErrorReason.CmsUserAccountNotFound]: $t('CMS User Account Not Found'), + [EYoutubeErrorReason.ConcurrentBroadcastsExceedLimit]: $t('Concurrent Broadcasts Exceed Limit'), + [EYoutubeErrorReason.ContentOwnerAccountNotFound]: $t('Content Owner Account Not Found'), + [EYoutubeErrorReason.DefaultLanguageNotSet]: $t('Default Language Not Set'), + [EYoutubeErrorReason.EnableAutoStartModificationNotAllowed]: $t( + 'Enable Auto Start Modification Not Allowed', + ), + [EYoutubeErrorReason.EnableClosedCaptionsModificationNotAllowed]: $t( + 'Enable Closed Captions Modification Not Allowed', + ), + [EYoutubeErrorReason.EnableDvrModificationNotAllowed]: $t( + 'Enable DVR Modification Not Allowed', + ), + [EYoutubeErrorReason.EnableMonitorStreamModificationNotAllowed]: $t( + 'Enable Monitor Stream Modification Not Allowed', + ), + [EYoutubeErrorReason.EnableMonitorStreamRequired]: $t('Enable Monitor Stream Required'), + [EYoutubeErrorReason.ErrorExecutingTransition]: $t('Error Executing Transition'), + [EYoutubeErrorReason.ErrorStreamInactive]: $t('Error Stream Inactive'), + [EYoutubeErrorReason.Forbidden]: $t('Forbidden'), + [EYoutubeErrorReason.ForbiddenEmbedSetting]: $t('Forbidden Embed Setting'), + [EYoutubeErrorReason.ForbiddenLicenseSetting]: $t('Forbidden License Setting'), + [EYoutubeErrorReason.ForbiddenPrivacySetting]: $t('Forbidden Privacy Setting'), + [EYoutubeErrorReason.FrameRateRequired]: $t('Frame Rate Required'), + [EYoutubeErrorReason.IdRequired]: $t('ID Required'), + [EYoutubeErrorReason.IncompatibleParameters]: $t('Incompatible Parameters'), + [EYoutubeErrorReason.IngestionTypeRequired]: $t('Ingestion Type Required'), + [EYoutubeErrorReason.InsufficientCapabilities]: $t('Insufficient Capabilities'), + [EYoutubeErrorReason.InsufficientLivePermissions]: $t('Insufficient Live Permissions'), + [EYoutubeErrorReason.InsufficientPermissions]: $t('Insufficient Permissions'), + [EYoutubeErrorReason.InternalError]: $t('Internal Error'), + [EYoutubeErrorReason.InvalidAutoStart]: $t('Invalid Auto Start'), + [EYoutubeErrorReason.InvalidAutoStop]: $t('Invalid Auto Stop'), + [EYoutubeErrorReason.InvalidCategoryId]: $t('Invalid Category ID'), + [EYoutubeErrorReason.InvalidCreatorCuepointConfig]: $t('Invalid Creator Cuepoint Config'), + [EYoutubeErrorReason.InvalidCriteria]: $t('Invalid Criteria'), + [EYoutubeErrorReason.InvalidCuepointSchedule]: $t('Invalid Cuepoint Schedule'), + [EYoutubeErrorReason.InvalidDefaultBroadcastPrivacySetting]: $t( + 'Invalid Default Broadcast Privacy Setting', + ), + [EYoutubeErrorReason.InvalidDescription]: $t('Invalid Description'), + [EYoutubeErrorReason.InvalidEligibleForAdsMonetization]: $t( + 'Invalid Eligible For Ads Monetization', + ), + [EYoutubeErrorReason.InvalidEmbedSetting]: $t('Invalid Embed Setting'), + [EYoutubeErrorReason.InvalidEnableClosedCaptions]: $t('Invalid Enable Closed Captions'), + [EYoutubeErrorReason.InvalidEnabled]: $t('Invalid Enabled'), + [EYoutubeErrorReason.InvalidFilename]: $t('Invalid Filename'), + [EYoutubeErrorReason.InvalidFilters]: $t('Invalid Filters'), + [EYoutubeErrorReason.InvalidFormat]: $t('Invalid Format'), + [EYoutubeErrorReason.InvalidFrameRate]: $t('Invalid Frame Rate'), + [EYoutubeErrorReason.InvalidImage]: $t('Invalid Image'), + [EYoutubeErrorReason.InvalidIngestionType]: $t('Invalid Ingestion Type'), + [EYoutubeErrorReason.InvalidLanguage]: $t('Invalid Language'), + [EYoutubeErrorReason.InvalidLatencyPreferenceOptions]: $t('Invalid Latency Preference Options'), + [EYoutubeErrorReason.InvalidMine]: $t('Invalid Mine'), + [EYoutubeErrorReason.InvalidPageToken]: $t('Invalid Page Token'), + [EYoutubeErrorReason.InvalidPart]: $t('Invalid Part'), + [EYoutubeErrorReason.InvalidPauseAdsUntil]: $t('Invalid Pause Ads Until'), + [EYoutubeErrorReason.InvalidPrivacyStatus]: $t('Invalid Privacy Status'), + [EYoutubeErrorReason.InvalidProjection]: $t('Invalid Projection'), + [EYoutubeErrorReason.InvalidPublishAt]: $t('Invalid Publish At'), + [EYoutubeErrorReason.InvalidRecordingDetails]: $t('Invalid Recording Details'), + [EYoutubeErrorReason.InvalidRegionCode]: $t('Invalid Region Code'), + [EYoutubeErrorReason.InvalidRepeatIntervalSecs]: $t('Invalid Repeat Interval (secs)'), + [EYoutubeErrorReason.InvalidResolution]: $t('Invalid Resolution'), + [EYoutubeErrorReason.InvalidScheduledEndTime]: $t('Invalid Scheduled End Time'), + [EYoutubeErrorReason.InvalidScheduledStartTime]: $t('Invalid Scheduled Start Time'), + [EYoutubeErrorReason.InvalidTags]: $t('Invalid Tags'), + [EYoutubeErrorReason.InvalidTitle]: $t('Invalid Title'), + [EYoutubeErrorReason.InvalidTransition]: $t('Invalid Transition'), + [EYoutubeErrorReason.InvalidVideoGameRating]: $t('Invalid Video Game Rating'), + [EYoutubeErrorReason.InvalidVideoMetadata]: $t('Invalid Video Metadata'), + [EYoutubeErrorReason.LiveBroadcastBindingNotAllowed]: $t('Live Broadcast Binding Not Allowed'), + [EYoutubeErrorReason.LiveBroadcastDeletionNotAllowed]: $t( + 'Live Broadcast Deletion Not Allowed', + ), + [EYoutubeErrorReason.LiveBroadcastNotFound]: $t('Live Broadcast Not Found'), + [EYoutubeErrorReason.LivePermissionBlocked]: $t('Live Permission Blocked'), + [EYoutubeErrorReason.LiveStreamNotFound]: $t('Live Stream Not Found'), + [EYoutubeErrorReason.LiveStreamingNotEnabled]: $t('Live Streaming Not Enabled'), + [EYoutubeErrorReason.MediaBodyRequired]: $t('Media Body Required'), + [EYoutubeErrorReason.MissingAdsMonetizationStatus]: $t('Missing Ads Monetization Status'), + [EYoutubeErrorReason.MissingRequiredParameter]: $t('Missing Required Parameter'), + [EYoutubeErrorReason.MonetizationDetailsModificationNotAllowed]: $t( + 'Monetization Details Modification Not Allowed', + ), + [EYoutubeErrorReason.PauseAdsUntilModificationNotAllowed]: $t( + 'Pause Ads Until Modification Not Allowed', + ), + [EYoutubeErrorReason.PrivacyStatusRequired]: $t('Privacy Status Required'), + [EYoutubeErrorReason.QuotaExceeded]: $t('Quota Exceeded'), + [EYoutubeErrorReason.RecordFromStartModificationNotAllowed]: $t( + 'Record From Start Modification Not Allowed', + ), + [EYoutubeErrorReason.RedundantTransition]: $t('Redundant Transition'), + [EYoutubeErrorReason.ResolutionRequired]: $t('Resolution Required'), + [EYoutubeErrorReason.ScheduledEndTimeRequired]: $t('Scheduled End Time Required'), + [EYoutubeErrorReason.ScheduledStartTimeRequired]: $t('Scheduled Start Time Required'), + [EYoutubeErrorReason.SharedIngestionBroadcastsExceedLimit]: $t( + 'Shared Ingestion Broadcasts Exceed Limit', + ), + [EYoutubeErrorReason.StatusRequired]: $t('Status Required'), + [EYoutubeErrorReason.TitleRequired]: $t('Title Required'), + [EYoutubeErrorReason.UnexpectedParameter]: $t('Unexpected Parameter'), + [EYoutubeErrorReason.UnexpectedPart]: $t('Unexpected Part'), + [EYoutubeErrorReason.UnknownPart]: $t('Unknown Part'), + [EYoutubeErrorReason.UnsupportedLanguageCode]: $t('Unsupported Language Code'), + [EYoutubeErrorReason.UnsupportedRegionCode]: $t('Unsupported Region Code'), + [EYoutubeErrorReason.UploadLimitExceeded]: $t('Upload Limit Exceeded'), + [EYoutubeErrorReason.UploadRateLimitExceeded]: $t('Upload Rate Limit Exceeded'), + [EYoutubeErrorReason.UserBroadcastsExceedLimit]: $t('User Broadcasts Exceed Limit'), + [EYoutubeErrorReason.UserRequestsExceedRateLimit]: $t('User Requests Exceed Rate Limit'), + [EYoutubeErrorReason.VideoCategoryNotFound]: $t('Video Category Not Found'), + [EYoutubeErrorReason.VideoChartNotFound]: $t('Video Chart Not Found'), + [EYoutubeErrorReason.VideoNotEligibleForAdsMonetization]: $t( + 'Video Not Eligible For Ads Monetization', + ), + [EYoutubeErrorReason.VideoNotFound]: $t('Video Not Found'), + [EYoutubeErrorReason.YoutubeSignupRequired]: $t('YouTube Signup Required'), + }[reason]); + +/** + * Map YouTube API endpoints to a corresponding `TStreamErrorType` + * @remark Each endpoint has its own documented set of error reasons and its own `TStreamErrorType`, + * so errors can be identified with the call that produced it instead of collapsed into `PLATFORM_REQUEST_FAILED` + */ +export const endpointErrorTypes: Record = { + [EYoutubeErrorEndpoint.LiveBroadcastInsert]: 'YOUTUBE_CREATE_BROADCAST_FAILED', + [EYoutubeErrorEndpoint.LiveBroadcastUpdate]: 'YOUTUBE_UPDATE_BROADCAST_FAILED', + [EYoutubeErrorEndpoint.LiveBroadcastDelete]: 'YOUTUBE_DELETE_BROADCAST_FAILED', + [EYoutubeErrorEndpoint.LiveBroadcastBind]: 'YOUTUBE_BIND_STREAM_FAILED', + [EYoutubeErrorEndpoint.LiveBroadcastTransition]: 'YOUTUBE_TRANSITION_BROADCAST_FAILED', + [EYoutubeErrorEndpoint.LiveBroadcastList]: 'YOUTUBE_FETCH_BROADCASTS_FAILED', + [EYoutubeErrorEndpoint.LiveStreamsInsert]: 'YOUTUBE_CREATE_STREAM_FAILED', + [EYoutubeErrorEndpoint.LiveStreamsList]: 'YOUTUBE_FETCH_STREAM_FAILED', + [EYoutubeErrorEndpoint.VideosInsert]: 'YOUTUBE_UPLOAD_VIDEO_FAILED', + [EYoutubeErrorEndpoint.VideosUpdate]: 'YOUTUBE_UPDATE_VIDEO_FAILED', + [EYoutubeErrorEndpoint.VideosList]: 'YOUTUBE_FETCH_VIDEO_FAILED', + [EYoutubeErrorEndpoint.VideoCategoriesList]: 'YOUTUBE_FETCH_CATEGORIES_FAILED', + [EYoutubeErrorEndpoint.ChannelsList]: 'YOUTUBE_FETCH_CHANNEL_FAILED', + [EYoutubeErrorEndpoint.ThumbnailsSet]: 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED', +}; + +export interface IYouTubeErrorResponse { + result: { + error: { + code: number; + errors: IYouTubeError[]; + message: string; + }; + }; +} + +export interface IYouTubeError { + message: string; + domain: string; + location: string; + locationType: string; + reason: EYoutubeErrorReason; +} diff --git a/app/services/platforms/youtube/errors.ts b/app/services/platforms/youtube/errors.ts new file mode 100644 index 000000000000..6669c46bbf7e --- /dev/null +++ b/app/services/platforms/youtube/errors.ts @@ -0,0 +1,153 @@ +import { endpointErrorTypes, reasonLabels, EYoutubeErrorReason } from './api'; +import { IRejectedRequest, TStreamErrorType } from 'services/streaming/stream-error'; +import { IPlatformRequest } from '../index'; +import { $t } from 'services/i18n'; + +/** + * Build the reject request error object. + * @remark The reason is looked up against the endpoint's own table of documented + * YouTube reasons first, then against the reasons documented for every endpoint. + * Anything unrecognised falls back to YouTube's own message so nothing is swallowed. + */ +export function formatErrorRejectedRequest( + e: any, + errorType: TStreamErrorType = 'PLATFORM_REQUEST_FAILED', + reason?: EYoutubeErrorReason, +) { + const details = + errorType === 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED' + ? formatThumbnailUploadError(e) + : formatYoutubeReasonDetail(e, reason); + + const message = e?.result?.error?.errors?.[0]?.message; + + // The error message returned from the YouTube API should also be shown to the user. + // Because it is being returned directly from the API, this may or may not be translated, + // but it is the most specific message available so is the most helpful for debugging. + const statusText = message ? `${details} (${message})` : details; + + const rejectedRequest: IRejectedRequest = { + ...e, + platform: 'youtube', + reason, + statusText, + }; + // Updated the rejected request status if it is returned from the YouTube API + const json = e?.result?.error; + if (json) { + return { + ...rejectedRequest, + status: json.status, + }; + } + + // Otherwise, return the default rejected request + return rejectedRequest; +} + +/** + * Build the user-facing detail line for a failed request. + * + * @remark The reason is looked up against the endpoint's own table of documented + * YouTube reasons first, then against the reasons documented for every endpoint. + * Anything unrecognised falls back to YouTube's own message so nothing is swallowed. + * @param e - the error thrown by the failed request + * @param errorTypeOrReason - an explicit error type from the caller, otherwise + * `error.errors[0].reason` from the YouTube response + * @param endpoint - the endpoint that failed, when it could be resolved from the URL + */ +export function formatErrorDetails( + e: any, + errorType: TStreamErrorType = 'PLATFORM_REQUEST_FAILED', + reason?: EYoutubeErrorReason, +): string | undefined { + // Show custom thumbnail upload errors but for all other api requests, show the error detail + // returned from the api + return errorType === 'YOUTUBE_THUMBNAIL_UPLOAD_FAILED' + ? formatThumbnailUploadError(e) + : formatYoutubeReasonDetail(e, reason); +} + +/** + * The display label for a YouTube reason code. + * + * @param reason - `error.errors[0].reason` from the YouTube response + * @returns the translated label, or a camelCase split of the reason when it is not in + * the dictionary + */ +export function formatYoutubeReasonDetail(e: any, reason?: EYoutubeErrorReason): string { + if (!reason) { + // If no reason was returned from the YouTube API, attempt to handle by status code + const status = e?.result?.error?.status; + switch (status) { + case 423: + console.error('YouTube API Error 423: YouTube token expired, need to refresh', e); + return $t('YouTube token expired, please re-merge your account'); + case 503: + console.error('YouTube API Error 503: YouTube service unavailable', e); + return $t('YouTube API service unavailable'); + case 403: + console.error('YouTube API Error 403: Permission denied', e); + return $t('YouTube permission denied by API'); + default: + console.error('YouTube API Error ', status, ': Non-generic error', e); + return $t('Connection Failed'); + } + } + + // Return the translated label for the reason, or a camelCase split of the reason when it is not in the dictionary + return ( + reasonLabels(reason) ?? + reason + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/^./, c => c.toUpperCase()) + ); +} + +export function formatThumbnailUploadError(e: any): string { + const code = e?.code || e?.status; + + switch (code) { + case 400: { + const hasReason = e?.errors && e?.errors.length && e?.errors[0].reason; + if (hasReason && e.errors[0].reason === 'invalidImage') { + return $t('Thumbnail image content is invalid.'); + } else if (hasReason && e.errors[0].reason === 'mediaBodyRequired') { + return $t('Thumbnail file does not include image content.'); + } else { + return $t('Failed to upload thumbnail.'); + } + } + case 403: + return $t('Permission missing to upload thumbnails.'); + case 413: + return $t('YouTube thumbnail image is too large. Maximum size is 2MB.'); + case 404: + return $t('Video does not exist. Thumbnail upload failed.'); + case 429: + return $t('Exceeded thumbnail upload quota. Please try again later.'); + default: + return e?.message || $t('Failed to upload thumbnail.'); + } +} + +/** + * The error type for a given endpoint. + * @param endpoint - the endpoint that failed, or `undefined` if it could not be resolved + * @returns the endpoint's error type, falling back to PLATFORM_REQUEST_FAILED + */ +export function getYoutubeErrorType(e: any, reqInfo?: IPlatformRequest | string): TStreamErrorType { + // If the error doesn't have a result or an error object, it's not a YouTube API error response + // If the url is not a valid YouTube API url, it's not a YouTube API error response + const url = typeof reqInfo === 'string' ? reqInfo : reqInfo?.url; + if (!e?.result || !e.result?.error || !url || !url?.includes('/youtube/v3/')) { + return 'PLATFORM_REQUEST_FAILED'; + } + + // Everything after `/youtube/v3/` and before the query string identifies the endpoint + const path = url.match(/\/youtube\/v3\/([^?]*)/)?.[1]; + + // Map the endpoint to a corresponding error type + return path ? endpointErrorTypes[path] : 'PLATFORM_REQUEST_FAILED'; +} diff --git a/app/services/platforms/youtube/index.ts b/app/services/platforms/youtube/index.ts new file mode 100644 index 000000000000..e9888777957a --- /dev/null +++ b/app/services/platforms/youtube/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './uploader'; +export * from './api'; diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts index 520d51219452..ca23567f32ac 100644 --- a/app/services/streaming/stream-error.ts +++ b/app/services/streaming/stream-error.ts @@ -172,6 +172,110 @@ export const errorTypes = { ); }, }, + YOUTUBE_CREATE_BROADCAST_FAILED: { + get message() { + return $t('Failed to create the YouTube broadcast'); + }, + get action() { + return $t('confirm the channel is under its broadcast limit'); + }, + }, + YOUTUBE_UPDATE_BROADCAST_FAILED: { + get message() { + return $t('Failed to update the YouTube broadcast'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_DELETE_BROADCAST_FAILED: { + get message() { + return $t('Failed to delete the YouTube broadcast'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_BIND_STREAM_FAILED: { + get message() { + return $t('Failed to bind the stream to the YouTube broadcast'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_TRANSITION_BROADCAST_FAILED: { + get message() { + return $t('Failed to change the status of the YouTube broadcast'); + }, + get action() { + return $t('confirm the broadcast is currently live or that it exists in YouTube Studio'); + }, + }, + YOUTUBE_FETCH_BROADCASTS_FAILED: { + get message() { + return $t('Failed to fetch your YouTube broadcasts'); + }, + get action() { + return $t('confirm the account is enabled for live streaming'); + }, + }, + YOUTUBE_CREATE_STREAM_FAILED: { + get message() { + return $t('Failed to create the YouTube stream'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_FETCH_STREAM_FAILED: { + get message() { + return $t('Failed to fetch the YouTube stream'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_UPDATE_VIDEO_FAILED: { + get message() { + return $t('Failed to update the YouTube video settings'); + }, + get action() { + return $t('confirm their current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_FETCH_VIDEO_FAILED: { + get message() { + return $t('Failed to fetch the YouTube video'); + }, + get action() { + return $t('confirm their videos and current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_UPLOAD_VIDEO_FAILED: { + get message() { + return $t('Failed to upload the video to YouTube'); + }, + get action() { + return $t('confirm their videos and current and upcoming broadcasts in YouTube Studio'); + }, + }, + YOUTUBE_FETCH_CATEGORIES_FAILED: { + get message() { + return $t('Failed to fetch YouTube categories'); + }, + get action() { + return $t('confirm the account is enabled for live streaming'); + }, + }, + YOUTUBE_FETCH_CHANNEL_FAILED: { + get message() { + return $t('Failed to fetch your YouTube channel'); + }, + get action() { + return $t('confirm the account is enabled for live streaming'); + }, + }, FACEBOOK_STREAMING_DISABLED: { get message() { return $t( @@ -347,6 +451,11 @@ export interface IRejectedRequest { status?: number; statusText?: string; platform?: TPlatform; + /** + * Machine-readable reason from the platform, e.g. YouTube's `error.errors[0].reason`. + * Callers use it to react to a specific failure without re-parsing the response. + */ + reason?: string; } export interface IStreamError extends IRejectedRequest { @@ -370,6 +479,7 @@ export class StreamError extends Error implements IRejectedRequest { public status?: number; public statusText?: string; public platform?: TPlatform; + public reason?: string; /** * returns serializable representation of the error @@ -380,6 +490,7 @@ export class StreamError extends Error implements IRejectedRequest { message: this.message, details: this.details, platform: this.platform, + reason: this.reason, }; }; @@ -397,11 +508,12 @@ export class StreamError extends Error implements IRejectedRequest { this.url = rejectedRequest?.url; this.status = rejectedRequest?.status; this.statusText = rejectedRequest?.statusText; + this.reason = rejectedRequest?.reason; this.platform = rejectedRequest?.platform ?? getPlatform(this?.url); - // TODO: remove sensitive data from YT requests + // Remove sensitive query params from the URL for YouTube errors if (this.platform === 'youtube') { - this.url = ''; + this.url = this.url?.replace(/\?.*$/, ''); } // don't allow to call 'new' outside this file