diff --git a/.flowconfig b/.flowconfig index 64516a4..bcc0450 100644 --- a/.flowconfig +++ b/.flowconfig @@ -12,4 +12,5 @@ module.name_mapper='^redux\(.*\)$' -> '<PROJECT_ROOT>/src/redux\1' module.name_mapper='^util\(.*\)$' -> '<PROJECT_ROOT>/src/util\1' module.name_mapper='^constants\(.*\)$' -> '<PROJECT_ROOT>/src/constants\1' module.name_mapper='^lbry\(.*\)$' -> '<PROJECT_ROOT>/src/lbry\1' +module.name_mapper='^lbry-first\(.*\)$' -> '<PROJECT_ROOT>/src/lbry-first\1' module.name_mapper='^lbryURI\(.*\)$' -> '<PROJECT_ROOT>/src/lbryURI\1' diff --git a/dist/bundle.es.js b/dist/bundle.es.js index 742564d..bf8be70 100644 --- a/dist/bundle.es.js +++ b/dist/bundle.es.js @@ -1217,6 +1217,174 @@ const lbryProxy = new Proxy(Lbry, { // +const CHECK_LBRYFIRST_STARTED_TRY_NUMBER = 200; +// +// Basic LBRYFIRST connection config +// Offers a proxy to call LBRYFIRST methods + +// +const LbryFirst = { + isConnected: false, + connectPromise: null, + lbryFirstConnectionString: 'http://localhost:1337/rpc', + apiRequestHeaders: { 'Content-Type': 'application/json' }, + + // Allow overriding lbryFirst connection string (e.g. to `/api/proxy` for lbryweb) + setLbryFirstConnectionString: value => { + LbryFirst.lbryFirstConnectionString = value; + }, + + setApiHeader: (key, value) => { + LbryFirst.apiRequestHeaders = Object.assign(LbryFirst.apiRequestHeaders, { [key]: value }); + }, + + unsetApiHeader: key => { + Object.keys(LbryFirst.apiRequestHeaders).includes(key) && delete LbryFirst.apiRequestHeaders['key']; + }, + // Allow overriding Lbry methods + overrides: {}, + setOverride: (methodName, newMethod) => { + LbryFirst.overrides[methodName] = newMethod; + }, + getApiRequestHeaders: () => LbryFirst.apiRequestHeaders, + + // + // LbryFirst Methods + // + status: (params = {}) => lbryFirstCallWithResult('server.Status', params), + stop: () => lbryFirstCallWithResult('stop', {}), + version: () => lbryFirstCallWithResult('version', {}), + + // Upload to youtube + upload: (params = {}) => { + // Only upload when originally publishing for now + if (!params.file_path) { + return {}; + } + const uploadParams = {}; + uploadParams.Title = params.title; + uploadParams.Description = params.description; + uploadParams.FilePath = params.file_path; + uploadParams.Category = ''; + uploadParams.ThumbnailURL = params.thumbnail_url; + uploadParams.Keywords = params.tags.join(','); + // Needs to be configurable? + uploadParams.PublishType = 'private'; + uploadParams.PublishAt = ''; // Premiere Date + uploadParams.MonetizationOff = false; + uploadParams.ClaimName = params.name; + uploadParams.URI = params.permanent_url; + return lbryFirstCallWithResult('youtube.Upload', uploadParams); + }, + + hasYTAuth: token => { + const hasYTAuthParams = {}; + hasYTAuthParams.AuthToken = token; + return lbryFirstCallWithResult('youtube.HasAuth', hasYTAuthParams); + }, + + ytSignup: () => { + const emptyParams = {}; + return lbryFirstCallWithResult('youtube.Signup', emptyParams); + }, + + remove: () => { + const emptyParams = {}; + return lbryFirstCallWithResult('youtube.Remove', emptyParams); + }, + + // Connect to lbry-first + connect: () => { + if (LbryFirst.connectPromise === null) { + LbryFirst.connectPromise = new Promise((resolve, reject) => { + let tryNum = 0; + // Check every half second to see if the lbryFirst is accepting connections + function checkLbryFirstStarted() { + tryNum += 1; + LbryFirst.status().then(resolve).catch(() => { + if (tryNum <= CHECK_LBRYFIRST_STARTED_TRY_NUMBER) { + setTimeout(checkLbryFirstStarted, tryNum < 50 ? 400 : 1000); + } else { + reject(new Error('Unable to connect to LBRY')); + } + }); + } + + checkLbryFirstStarted(); + }); + } + + // Flow thinks this could be empty, but it will always return a promise + // $FlowFixMe + return LbryFirst.connectPromise; + } +}; + +function checkAndParse$1(response) { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } + return response.json().then(json => { + let error; + if (json.error) { + const errorMessage = typeof json.error === 'object' ? json.error.message : json.error; + error = new Error(errorMessage); + } else { + error = new Error('Protocol error with unknown response signature'); + } + return Promise.reject(error); + }); +} + +function apiCall$1(method, params, resolve, reject) { + const counter = new Date().getTime(); + params = [params]; + const options = { + method: 'POST', + headers: LbryFirst.apiRequestHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + method, + params, + id: counter + }) + }; + + return fetch(LbryFirst.lbryFirstConnectionString, options).then(checkAndParse$1).then(response => { + const error = response.error || response.result && response.result.error; + + if (error) { + return reject(error); + } + return resolve(response.result); + }).catch(reject); +} + +function lbryFirstCallWithResult(name, params = {}) { + console.log(`LbryFirst: calling ${name}`); + return new Promise((resolve, reject) => { + apiCall$1(name, params, result => { + resolve(result); + }, reject); + }); +} + +// This is only for a fallback +// If there is a LbryFirst method that is being called by an app, it should be added to /flow-typed/LbryFirst.js +const lbryFirstProxy = new Proxy(LbryFirst, { + get(target, name) { + if (name in target) { + return target[name]; + } + + return (params = {}) => new Promise((resolve, reject) => { + apiCall$1(name, params, resolve, reject); + }); + } +}); + +// + const DEFAULT_SEARCH_RESULT_FROM = 0; const DEFAULT_SEARCH_SIZE = 20; @@ -4462,6 +4630,7 @@ const doPublish = (success, fail) => (dispatch, getState) => { language, license, licenseUrl, + useLBRYUploader, licenseType, otherLicenseDescription, thumbnail, @@ -4547,8 +4716,14 @@ const doPublish = (success, fail) => (dispatch, getState) => { // Only pass file on new uploads, not metadata only edits. // The sdk will figure it out if (filePath) publishPayload.file_path = filePath; - - return lbryProxy.publish(publishPayload).then(success, fail); + // if (useLBRYUploader) return LbryFirst.upload(publishPayload); + return lbryProxy.publish(publishPayload).then(response => { + if (!useLBRYUploader) { + return success(response); + } + publishPayload.permanent_url = response.outputs[0].permanent_url; + return lbryFirstProxy.upload(publishPayload).then(success(response), fail); + }, fail); }; // Calls file_list until any reflecting files are done @@ -6215,7 +6390,8 @@ const defaultState$5 = { publishing: false, publishSuccess: false, publishError: undefined, - optimize: false + optimize: false, + useLBRYUploader: false }; const publishReducer = handleActions({ @@ -6938,6 +7114,7 @@ exports.DEFAULT_FOLLOWED_TAGS = DEFAULT_FOLLOWED_TAGS; exports.DEFAULT_KNOWN_TAGS = DEFAULT_KNOWN_TAGS; exports.LICENSES = licenses; exports.Lbry = lbryProxy; +exports.LbryFirst = lbryFirstProxy; exports.MATURE_TAGS = MATURE_TAGS; exports.PAGES = pages; exports.SEARCH_OPTIONS = SEARCH_OPTIONS; diff --git a/dist/flow-typed/LbryFirst.js b/dist/flow-typed/LbryFirst.js new file mode 100644 index 0000000..4f3e01b --- /dev/null +++ b/dist/flow-typed/LbryFirst.js @@ -0,0 +1,100 @@ +// @flow +declare type StatusResponse = { + Version: string, + Message: string, + Running: boolean, + Commit: string, +}; + +declare type VersionResponse = { + build: string, + lbrynet_version: string, + os_release: string, + os_system: string, + platform: string, + processor: string, + python_version: string, +}; +/* SAMPLE UPLOAD RESPONSE (FULL) +"Video": { + "etag": "\"Dn5xIderbhAnUk5TAW0qkFFir0M/xlGLrlTox7VFTRcR8F77RbKtaU4\"", + "id": "8InjtdvVmwE", + "kind": "youtube#video", + "snippet": { + "categoryId": "22", + "channelId": "UCXiVsGTU88fJjheB2rqF0rA", + "channelTitle": "Mark Beamer", + "liveBroadcastContent": "none", + "localized": { + "title": "my title" + }, + "publishedAt": "2020-05-05T04:17:53.000Z", + "thumbnails": { + "default": { + "height": 90, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/default.jpg?sqp=CMTQw_UF&rs=AOn4CLB6dlhZMSMrazDlWRsitPgCsn8fVw", + "width": 120 + }, + "high": { + "height": 360, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/hqdefault.jpg?sqp=CMTQw_UF&rs=AOn4CLB-Je_7l6qvASRAR_bSGWZHaXaJWQ", + "width": 480 + }, + "medium": { + "height": 180, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/mqdefault.jpg?sqp=CMTQw_UF&rs=AOn4CLCvSnDLqVznRNMKuvJ_0misY_chPQ", + "width": 320 + } + }, + "title": "my title" + }, + "status": { + "embeddable": true, + "license": "youtube", + "privacyStatus": "private", + "publicStatsViewable": true, + "uploadStatus": "uploaded" + } + } + */ +declare type UploadResponse = { + Video: { + id: string, + snippet: { + channelId: string, + }, + status: { + uploadStatus: string, + }, + }, +}; + +declare type HasYTAuthResponse = { + HashAuth: boolean, +} + +declare type YTSignupResponse = {} + +// +// Types used in the generic LbryFirst object that is exported +// +declare type LbryFirstTypes = { + isConnected: boolean, + connectPromise: ?Promise<any>, + connect: () => void, + lbryFirstConnectionString: string, + apiRequestHeaders: { [key: string]: string }, + setApiHeader: (string, string) => void, + unsetApiHeader: string => void, + overrides: { [string]: ?Function }, + setOverride: (string, Function) => void, + + // LbryFirst Methods + stop: () => Promise<string>, + status: () => Promise<StatusResponse>, + version: () => Promise<VersionResponse>, + upload: (params: {}) => Promise<UploadResponse>, + hasYTAuth: () =>Promise<HasYTAuthResponse>, + ytSignup: () =>Promise<YTSignupResponse>, + remove: () =>void, +}; diff --git a/flow-typed/LbryFirst.js b/flow-typed/LbryFirst.js new file mode 100644 index 0000000..4f3e01b --- /dev/null +++ b/flow-typed/LbryFirst.js @@ -0,0 +1,100 @@ +// @flow +declare type StatusResponse = { + Version: string, + Message: string, + Running: boolean, + Commit: string, +}; + +declare type VersionResponse = { + build: string, + lbrynet_version: string, + os_release: string, + os_system: string, + platform: string, + processor: string, + python_version: string, +}; +/* SAMPLE UPLOAD RESPONSE (FULL) +"Video": { + "etag": "\"Dn5xIderbhAnUk5TAW0qkFFir0M/xlGLrlTox7VFTRcR8F77RbKtaU4\"", + "id": "8InjtdvVmwE", + "kind": "youtube#video", + "snippet": { + "categoryId": "22", + "channelId": "UCXiVsGTU88fJjheB2rqF0rA", + "channelTitle": "Mark Beamer", + "liveBroadcastContent": "none", + "localized": { + "title": "my title" + }, + "publishedAt": "2020-05-05T04:17:53.000Z", + "thumbnails": { + "default": { + "height": 90, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/default.jpg?sqp=CMTQw_UF&rs=AOn4CLB6dlhZMSMrazDlWRsitPgCsn8fVw", + "width": 120 + }, + "high": { + "height": 360, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/hqdefault.jpg?sqp=CMTQw_UF&rs=AOn4CLB-Je_7l6qvASRAR_bSGWZHaXaJWQ", + "width": 480 + }, + "medium": { + "height": 180, + "url": "https://i9.ytimg.com/vi/8InjtdvVmwE/mqdefault.jpg?sqp=CMTQw_UF&rs=AOn4CLCvSnDLqVznRNMKuvJ_0misY_chPQ", + "width": 320 + } + }, + "title": "my title" + }, + "status": { + "embeddable": true, + "license": "youtube", + "privacyStatus": "private", + "publicStatsViewable": true, + "uploadStatus": "uploaded" + } + } + */ +declare type UploadResponse = { + Video: { + id: string, + snippet: { + channelId: string, + }, + status: { + uploadStatus: string, + }, + }, +}; + +declare type HasYTAuthResponse = { + HashAuth: boolean, +} + +declare type YTSignupResponse = {} + +// +// Types used in the generic LbryFirst object that is exported +// +declare type LbryFirstTypes = { + isConnected: boolean, + connectPromise: ?Promise<any>, + connect: () => void, + lbryFirstConnectionString: string, + apiRequestHeaders: { [key: string]: string }, + setApiHeader: (string, string) => void, + unsetApiHeader: string => void, + overrides: { [string]: ?Function }, + setOverride: (string, Function) => void, + + // LbryFirst Methods + stop: () => Promise<string>, + status: () => Promise<StatusResponse>, + version: () => Promise<VersionResponse>, + upload: (params: {}) => Promise<UploadResponse>, + hasYTAuth: () =>Promise<HasYTAuthResponse>, + ytSignup: () =>Promise<YTSignupResponse>, + remove: () =>void, +}; diff --git a/src/index.js b/src/index.js index dcec873..ed34c1f 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ import * as SHARED_PREFERENCES from 'constants/shared_preferences'; import { SEARCH_TYPES, SEARCH_OPTIONS } from 'constants/search'; import { DEFAULT_KNOWN_TAGS, DEFAULT_FOLLOWED_TAGS, MATURE_TAGS } from 'constants/tags'; import Lbry, { apiCall } from 'lbry'; +import LbryFirst from 'lbry-first'; import { selectState as selectSearchState } from 'redux/selectors/search'; // constants @@ -42,6 +43,7 @@ export { // common export { Lbry, apiCall }; +export { LbryFirst }; export { regexInvalidURI, regexAddress, diff --git a/src/lbry-first.js b/src/lbry-first.js new file mode 100644 index 0000000..cf9251c --- /dev/null +++ b/src/lbry-first.js @@ -0,0 +1,181 @@ +// @flow +import 'proxy-polyfill'; + +const CHECK_LBRYFIRST_STARTED_TRY_NUMBER = 200; +// +// Basic LBRYFIRST connection config +// Offers a proxy to call LBRYFIRST methods + +// +const LbryFirst: LbryFirstTypes = { + isConnected: false, + connectPromise: null, + lbryFirstConnectionString: 'http://localhost:1337/rpc', + apiRequestHeaders: { 'Content-Type': 'application/json' }, + + // Allow overriding lbryFirst connection string (e.g. to `/api/proxy` for lbryweb) + setLbryFirstConnectionString: (value: string) => { + LbryFirst.lbryFirstConnectionString = value; + }, + + setApiHeader: (key: string, value: string) => { + LbryFirst.apiRequestHeaders = Object.assign(LbryFirst.apiRequestHeaders, { [key]: value }); + }, + + unsetApiHeader: key => { + Object.keys(LbryFirst.apiRequestHeaders).includes(key) && delete LbryFirst.apiRequestHeaders['key']; + }, + // Allow overriding Lbry methods + overrides: {}, + setOverride: (methodName, newMethod) => { + LbryFirst.overrides[methodName] = newMethod; + }, + getApiRequestHeaders: () => LbryFirst.apiRequestHeaders, + + // + // LbryFirst Methods + // + status: (params = {}) => lbryFirstCallWithResult('server.Status', params), + stop: () => lbryFirstCallWithResult('stop', {}), + version: () => lbryFirstCallWithResult('version', {}), + + // Upload to youtube + upload: (params = {}) => { + // Only upload when originally publishing for now + if (!params.file_path) { + return {}; + } + const uploadParams = {}; + uploadParams.Title = params.title; + uploadParams.Description = params.description; + uploadParams.FilePath = params.file_path; + uploadParams.Category = ''; + uploadParams.ThumbnailURL = params.thumbnail_url; + uploadParams.Keywords = params.tags.join(','); + // Needs to be configurable? + uploadParams.PublishType = 'private'; + uploadParams.PublishAt = ''; // Premiere Date + uploadParams.MonetizationOff = false; + uploadParams.ClaimName = params.name; + uploadParams.URI = params.permanent_url; + return lbryFirstCallWithResult('youtube.Upload', uploadParams); + }, + + hasYTAuth: (token: string) => { + const hasYTAuthParams = {}; + hasYTAuthParams.AuthToken = token; + return lbryFirstCallWithResult('youtube.HasAuth', hasYTAuthParams); + }, + + ytSignup: () => { + const emptyParams = {}; + return lbryFirstCallWithResult('youtube.Signup', emptyParams); + }, + + remove: () => { + const emptyParams = {}; + return lbryFirstCallWithResult('youtube.Remove', emptyParams); + }, + + // Connect to lbry-first + connect: () => { + if (LbryFirst.connectPromise === null) { + LbryFirst.connectPromise = new Promise((resolve, reject) => { + let tryNum = 0; + // Check every half second to see if the lbryFirst is accepting connections + function checkLbryFirstStarted() { + tryNum += 1; + LbryFirst.status() + .then(resolve) + .catch(() => { + if (tryNum <= CHECK_LBRYFIRST_STARTED_TRY_NUMBER) { + setTimeout(checkLbryFirstStarted, tryNum < 50 ? 400 : 1000); + } else { + reject(new Error('Unable to connect to LBRY')); + } + }); + } + + checkLbryFirstStarted(); + }); + } + + // Flow thinks this could be empty, but it will always return a promise + // $FlowFixMe + return LbryFirst.connectPromise; + }, +}; + +function checkAndParse(response) { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } + return response.json().then(json => { + let error; + if (json.error) { + const errorMessage = typeof json.error === 'object' ? json.error.message : json.error; + error = new Error(errorMessage); + } else { + error = new Error('Protocol error with unknown response signature'); + } + return Promise.reject(error); + }); +} + +export function apiCall(method: string, params: ?{}, resolve: Function, reject: Function) { + const counter = new Date().getTime(); + params = [params]; + const options = { + method: 'POST', + headers: LbryFirst.apiRequestHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + method, + params, + id: counter, + }), + }; + + return fetch(LbryFirst.lbryFirstConnectionString, options) + .then(checkAndParse) + .then(response => { + const error = response.error || (response.result && response.result.error); + + if (error) { + return reject(error); + } + return resolve(response.result); + }) + .catch(reject); +} + +function lbryFirstCallWithResult(name: string, params: ?{} = {}) { + console.log(`LbryFirst: calling ${name}`); + return new Promise((resolve, reject) => { + apiCall( + name, + params, + result => { + resolve(result); + }, + reject + ); + }); +} + +// This is only for a fallback +// If there is a LbryFirst method that is being called by an app, it should be added to /flow-typed/LbryFirst.js +const lbryFirstProxy = new Proxy(LbryFirst, { + get(target: LbryFirstTypes, name: string) { + if (name in target) { + return target[name]; + } + + return (params = {}) => + new Promise((resolve, reject) => { + apiCall(name, params, resolve, reject); + }); + }, +}); + +export default lbryFirstProxy; diff --git a/src/redux/actions/publish.js b/src/redux/actions/publish.js index c977640..45b685a 100644 --- a/src/redux/actions/publish.js +++ b/src/redux/actions/publish.js @@ -4,6 +4,7 @@ import { SPEECH_STATUS, SPEECH_PUBLISH } from 'constants/speech_urls'; import * as ACTIONS from 'constants/action_types'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; import Lbry from 'lbry'; +import LbryFirst from 'lbry-first'; import { batchActions } from 'util/batch-actions'; import { creditsToString } from 'util/format-credits'; import { doError } from 'redux/actions/notifications'; @@ -245,6 +246,7 @@ export const doPublish = (success: Function, fail: Function) => ( language, license, licenseUrl, + useLBRYUploader, licenseType, otherLicenseDescription, thumbnail, @@ -350,8 +352,15 @@ export const doPublish = (success: Function, fail: Function) => ( // Only pass file on new uploads, not metadata only edits. // The sdk will figure it out if (filePath) publishPayload.file_path = filePath; - - return Lbry.publish(publishPayload).then(success, fail); + // if (useLBRYUploader) return LbryFirst.upload(publishPayload); + return Lbry.publish(publishPayload) + .then((response) => { + if (!useLBRYUploader) { + return success(response); + } + publishPayload.permanent_url = response.outputs[0].permanent_url; + return LbryFirst.upload(publishPayload).then(success(response), fail); + }, fail); }; // Calls file_list until any reflecting files are done diff --git a/src/redux/reducers/publish.js b/src/redux/reducers/publish.js index 4c94168..2104675 100644 --- a/src/redux/reducers/publish.js +++ b/src/redux/reducers/publish.js @@ -33,6 +33,7 @@ type PublishState = { licenseUrl: string, tags: Array<string>, optimize: boolean, + useLBRYUploader: boolean, }; const defaultState: PublishState = { @@ -68,6 +69,7 @@ const defaultState: PublishState = { publishSuccess: false, publishError: undefined, optimize: false, + useLBRYUploader: false, }; export const publishReducer = handleActions(