diff --git a/.env.defaults b/.env.defaults index 71004f69e..ac65b6a58 100644 --- a/.env.defaults +++ b/.env.defaults @@ -8,6 +8,7 @@ WEBPACK_ELECTRON_PORT=9091 WEB_SERVER_PORT=1337 LBRY_WEB_API=https://api.lbry.tv LBRY_WEB_STREAMING_API=https://cdn.lbryplayer.xyz +LBRY_WEB_BUFFER_API=https://collector-service.api.lbry.tv/api/v1/events/video WELCOME_VERSION=1.0 # Custom Site info diff --git a/config.js b/config.js index 517b7af08..b35cda7f8 100644 --- a/config.js +++ b/config.js @@ -9,6 +9,7 @@ const config = { WEB_SERVER_PORT: process.env.WEB_SERVER_PORT, LBRY_WEB_API: process.env.LBRY_WEB_API, //api.lbry.tv', LBRY_WEB_STREAMING_API: process.env.LBRY_WEB_STREAMING_API, //cdn.lbryplayer.xyz', + LBRY_WEB_BUFFER_API: process.env.LBRY_WEB_BUFFER_API, WELCOME_VERSION: process.env.WELCOME_VERSION, DOMAIN: process.env.DOMAIN, URL: process.env.URL, diff --git a/ui/analytics.js b/ui/analytics.js index f5c9630ae..a9bea13fd 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -9,7 +9,7 @@ import Native from 'native'; import ElectronCookies from '@exponent/electron-cookies'; import { generateInitialUrl } from 'util/url'; // @endif -import { MATOMO_ID, MATOMO_URL } from 'config'; +import { MATOMO_ID, MATOMO_URL, LBRY_WEB_BUFFER_API } from 'config'; const isProduction = process.env.NODE_ENV === 'production'; const devInternalApis = process.env.LBRY_API_URL; @@ -36,7 +36,10 @@ type Analytics = { apiSyncTags: ({}) => void, tagFollowEvent: (string, boolean, ?string) => void, videoStartEvent: (string, number) => void, - videoBufferEvent: (string, number) => void, + videoBufferEvent: ( + StreamClaim, + { timeAtBuffer: number, bufferDuration: number, bitRate: number, duration: number, userIdHash: string } + ) => void, emailProvidedEvent: () => void, emailVerifiedEvent: () => void, rewardEligibleEvent: () => void, @@ -182,9 +185,32 @@ const analytics: Analytics = { sendPromMetric('time_to_start', duration); sendMatomoEvent('Media', 'TimeToStart', claimId, duration); }, - videoBufferEvent: (claimId, currentTime) => { + videoBufferEvent: (claim, data) => { + // @if TARGET='web' sendPromMetric('buffer'); - sendMatomoEvent('Media', 'BufferTimestamp', claimId, currentTime * 1000); + // @endif + + sendMatomoEvent('Media', 'BufferTimestamp', claim.claim_id, data.timeAtBuffer); + + fetch(LBRY_WEB_BUFFER_API, { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + device: 'web', + type: 'buffering', + client: data.userIdHash, + data: { + url: claim.canonical_url, + position: data.timeAtBuffer, + duration: data.bufferDuration, + stream_duration: data.duration, + stream_bitrate: data.bitRate, + }, + }), + }); }, tagFollowEvent: (tag, following) => { sendMatomoEvent('Tag', following ? 'Tag-Follow' : 'Tag-Unfollow', tag); diff --git a/ui/component/fileDetails/view.jsx b/ui/component/fileDetails/view.jsx index 0fd86cab3..ec0f00722 100644 --- a/ui/component/fileDetails/view.jsx +++ b/ui/component/fileDetails/view.jsx @@ -3,6 +3,7 @@ import React, { Fragment, PureComponent } from 'react'; import Button from 'component/button'; import path from 'path'; import Card from 'component/common/card'; +import { formatBytes } from 'util/format-bytes'; type Props = { claim: StreamClaim, @@ -102,17 +103,5 @@ class FileDetails extends PureComponent { ); } } -// move this with other helper functions when we re-use it -function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return __('0 Bytes'); - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = [__('Bytes'), __('KB'), __('MB'), __('GB'), __('TB'), __('PB'), __('EB'), __('ZB'), __('YB')]; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} export default FileDetails; diff --git a/ui/component/viewers/videoViewer/index.js b/ui/component/viewers/videoViewer/index.js index 69718d9a9..d1bcb8c75 100644 --- a/ui/component/viewers/videoViewer/index.js +++ b/ui/component/viewers/videoViewer/index.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { makeSelectClaimForUri, makeSelectFileInfoForUri, makeSelectThumbnailForUri, SETTINGS } from 'lbry-redux'; -import { doChangeVolume, doChangeMute, doAnalyticsView } from 'redux/actions/app'; +import { doChangeVolume, doChangeMute, doAnalyticsView, doAnalyticsBuffer } from 'redux/actions/app'; import { selectVolume, selectMute } from 'redux/selectors/app'; import { savePosition, clearPosition } from 'redux/actions/content'; import { makeSelectContentPositionForUri } from 'redux/selectors/content'; @@ -33,6 +33,7 @@ const perform = dispatch => ({ clearPosition: uri => dispatch(clearPosition(uri)), changeMute: muted => dispatch(doChangeMute(muted)), doAnalyticsView: (uri, timeToStart) => dispatch(doAnalyticsView(uri, timeToStart)), + doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)), claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()), }); diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 10a172eff..5a3c4b15d 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -23,7 +23,7 @@ type Props = { source: string, contentType: string, thumbnail: string, - claim: Claim, + claim: StreamClaim, muted: boolean, volume: number, uri: string, @@ -31,6 +31,7 @@ type Props = { autoplayIfEmbedded: boolean, desktopPlayStartTime?: number, doAnalyticsView: (string, number) => Promise, + doAnalyticsBuffer: (string, any) => void, claimRewards: () => void, savePosition: (string, number) => void, clearPosition: string => void, @@ -56,6 +57,7 @@ function VideoViewer(props: Props) { autoplaySetting, autoplayIfEmbedded, doAnalyticsView, + doAnalyticsBuffer, claimRewards, savePosition, clearPosition, @@ -85,7 +87,7 @@ function VideoViewer(props: Props) { }, [uri, previousUri]); function doTrackingBuffered(e: Event, data: any) { - analytics.videoBufferEvent(claimId, data.currentTime); + doAnalyticsBuffer(uri, data); } function doTrackingFirstPlay(e: Event, data: any) { diff --git a/ui/redux/actions/app.js b/ui/redux/actions/app.js index 9941ae406..84aaf0cbf 100644 --- a/ui/redux/actions/app.js +++ b/ui/redux/actions/app.js @@ -53,6 +53,8 @@ import analytics, { SHARE_INTERNAL } from 'analytics'; import { doSignOutCleanup, deleteSavedPassword, getSavedPassword } from 'util/saved-passwords'; import { doSocketConnect } from 'redux/actions/websocket'; import { stringifyServerParam, shouldSetSetting } from 'util/sync-settings'; +import sha256 from 'crypto-js/sha256'; +import Base64 from 'crypto-js/enc-base64'; // @if TARGET='app' const { autoUpdater } = remote.require('electron-updater'); @@ -467,6 +469,32 @@ export function doAnalyticsView(uri, timeToStart) { }; } +export function doAnalyticsBuffer(uri, bufferData) { + return (dispatch, getState) => { + const state = getState(); + const claim = makeSelectClaimForUri(uri)(state); + const user = selectUser(state); + const { + value: { video, audio, source }, + } = claim; + const timeAtBuffer = bufferData.currentTime * 1000; + const bufferDuration = bufferData.secondsToLoad * 1000; + const fileDurationInSeconds = (video && video.duration) || (audio && audio.duration); + const fileSize = source.size; // size in bytes + const fileSizeInBits = fileSize * 8; + const bitRate = parseInt(fileSizeInBits / fileDurationInSeconds); + const userIdHash = Base64.stringify(sha256(user.id)); + + analytics.videoBufferEvent(claim, { + timeAtBuffer, + bufferDuration, + bitRate, + userIdHash, + duration: fileDurationInSeconds, + }); + }; +} + export function doAnalyticsTagSync() { return (dispatch, getState) => { const state = getState(); @@ -560,11 +588,12 @@ export function doGetAndPopulatePreferences() { return (dispatch, getState) => { const state = getState(); + let preferenceKey; // @if TARGET='app' - const preferenceKey = state.user && state.user.user && state.user.user.has_verified_email ? 'shared' : 'anon'; + preferenceKey = state.user && state.user.user && state.user.user.has_verified_email ? 'shared' : 'anon'; // @endif // @if TARGET='web' - const preferenceKey = 'shared'; + preferenceKey = 'shared'; // @endif function successCb(savedPreferences) { diff --git a/ui/util/format-bytes.js b/ui/util/format-bytes.js new file mode 100644 index 000000000..247d8f424 --- /dev/null +++ b/ui/util/format-bytes.js @@ -0,0 +1,12 @@ +// @flow +export function formatBytes(bytes: number, decimals?: number = 2) { + if (bytes === 0) return __('0 Bytes'); + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = [__('Bytes'), __('KB'), __('MB'), __('GB'), __('TB'), __('PB'), __('EB'), __('ZB'), __('YB')]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}