[on hold recsys work] Recommended For You (#782)

* Factor out lighthouse-result processing code for FYP re-use.

The FYP results will be in the same format as LH.

* Recsys: add ability to pass in specific uuid to use

For FYP, we want to pass the UUID as a param when searching for recommendations. The search comes before the recsys entry creation, so we need to generate the UUID first when searching, and then tell recsys to use that specific ID.

* Redux: fetch and store FYP

Note that the gid cannot be used as "hash" for the uri list -- it doesn't necessarily change when the list changes, so we can't use it to optimize redux.  For now, just always update/render when re-fetched.

* UI for FYP

* Mark rendered FYPs

* Pass the FYP ID down the same way as Collection ID

Not ideal, but at least it's in the same pattern as existing code for now. The whole prop-drilling problem with the claim components will be fixed together later.

* Include 'gid' and 'uuid' in recommendation search

* Allow users to mark recommendations that they dislike

* Pass auth-token to all FYP requests + remove beacon use

beacons are unreliable and often blocked

* Only show FYP for members

* FYP readme page

* small fixes

* fyp

Co-authored-by: Thomas Zarebczan <thomas.zarebczan@gmail.com>
This commit is contained in:
infinite-persistence 2022-03-15 12:07:31 -07:00 committed by GitHub
parent 5445a95c9a
commit 1e67a5cc7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 533 additions and 65 deletions

View file

@ -16,6 +16,7 @@ SEARCH_SERVER_API_ALT=https://recsys.odysee.tv/search
SEARCH_SERVER_API=https://lighthouse.odysee.tv/search
SOCKETY_SERVER_API=wss://sockety.odysee.com/ws
RECSYS_ENDPOINT=https://recsys.odysee.tv/v1/lvv
RECSYS_FYP_ENDPOINT=https://recsys.odysee.tv/v1/u
THUMBNAIL_CDN_URL=https://thumbnails.odycdn.com/optimize/
THUMBNAIL_CARDS_CDN_URL=https://cards.odycdn.com/
LOCALE_API=https://api.odysee.com/locale/get

View file

@ -22,6 +22,7 @@ const config = {
SHARE_DOMAIN_URL: process.env.SHARE_DOMAIN_URL,
URL: process.env.URL,
RECSYS_ENDPOINT: process.env.RECSYS_ENDPOINT,
RECSYS_FYP_ENDPOINT: process.env.RECSYS_FYP_ENDPOINT,
THUMBNAIL_CDN_URL: process.env.THUMBNAIL_CDN_URL,
THUMBNAIL_CARDS_CDN_URL: process.env.THUMBNAIL_CARDS_CDN_URL,
THUMBNAIL_HEIGHT: process.env.THUMBNAIL_HEIGHT,

View file

@ -76,11 +76,11 @@ const recsys = {
* Page was loaded. Get or Create entry and populate it with default data, plus recommended content, recsysId, etc.
* Called from recommendedContent component
*/
onRecsLoaded: function (claimId, uris) {
onRecsLoaded: function (claimId, uris, uuid = '') {
if (window && window.store) {
const state = window.store.getState();
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId);
recsys.createRecsysEntry(claimId, null, uuid);
}
const claimIds = getClaimIdsFromUris(uris);
recsys.entries[claimId]['recsysId'] = makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId;
@ -94,8 +94,9 @@ const recsys = {
* Creates an Entry with optional parentUuid
* @param: claimId: string
* @param: parentUuid: string (optional)
* @param: uuid: string Specific uuid to use.
*/
createRecsysEntry: function (claimId, parentUuid) {
createRecsysEntry: function (claimId, parentUuid, uuid = '') {
if (window && window.store && claimId) {
const state = window.store.getState();
const user = selectUser(state);
@ -103,7 +104,7 @@ const recsys = {
if (parentUuid) {
// Make a stub entry that will be filled out on page load
recsys.entries[claimId] = {
uuid: Uuidv4(),
uuid: uuid || Uuidv4(),
parentUuid: parentUuid,
uid: userId || null, // selectUser
claimId: claimId,
@ -113,7 +114,7 @@ const recsys = {
};
} else {
recsys.entries[claimId] = {
uuid: Uuidv4(),
uuid: uuid || Uuidv4(),
uid: userId, // selectUser
claimId: claimId,
pageLoadedAt: Date.now(),

View file

@ -33,6 +33,7 @@ declare type SearchState = {
hasReachedMaxResultsLength: {},
searching: boolean,
mentionQuery: string,
personalRecommendations: { gid: string, uris: Array<string> },
};
declare type SearchSuccess = {
@ -51,3 +52,8 @@ declare type UpdateSearchOptions = {
type: ACTIONS.UPDATE_SEARCH_OPTIONS,
data: SearchOptions,
};
declare type FypParam = {
gid: string,
uuid: string,
};

View file

@ -2168,6 +2168,8 @@
"Streamer": "Streamer",
"Pinned": "Pinned",
"Remove all unavailable claims": "Remove all unavailable claims",
"Recommended For You": "Recommended For You",
"Recommendation removed. Thanks for the feedback!": "Recommendation removed. Thanks for the feedback!",
"Hide Chat": "Hide Chat",
"Popout Chat": "Popout Chat",
"Chat Hidden": "Chat Hidden",
@ -2201,5 +2203,6 @@
"Choose Your Preference": "Choose Your Preference",
"Recently Active": "Recently Active",
"All Channels": "All Channels",
"# Recommended Videos (Alpha)\n ## What is this\n\n LBRY's content catalog is growing fast. Each day, there are more creators using the platform. This is great news! But it also makes finding things you would like to watch harder. Odysee's recommended videos tries to make it easier.\n\n ## How does it work?\n\n Based on your video viewing history, Odysee tries to find other channels you might like. Then, we recommend videos from those channels. At least, that's how it works right now. But expect a lot of rapid change.\n\n ## Does Odysee manipulate the results?\n\n No. The current algorithm itself has a tendency to favor channels with more viewers. But this is just because we have more evidence for those ones. Otherwise, we aren't making editorial decisions or picking favorites.\n\n ## My results suck, help?\n\n The more videos you watch, the better it should be. But this system is brand new and it will take a bit of time to tune it. Please have patience. Or, if you want to complain or suggest something, please email: mailto:hello@odysee.com": "# Recommended Videos (Alpha)\n ## What is this\n\n LBRY's content catalog is growing fast. Each day, there are more creators using the platform. This is great news! But it also makes finding things you would like to watch harder. Odysee's recommended videos tries to make it easier.\n\n ## How does it work?\n\n Based on your video viewing history, Odysee tries to find other channels you might like. Then, we recommend videos from those channels. At least, that's how it works right now. But expect a lot of rapid change.\n\n ## Does Odysee manipulate the results?\n\n No. The current algorithm itself has a tendency to favor channels with more viewers. But this is just because we have more evidence for those ones. Otherwise, we aren't making editorial decisions or picking favorites.\n\n ## My results suck, help?\n\n The more videos you watch, the better it should be. But this system is brand new and it will take a bit of time to tune it. Please have patience. Or, if you want to complain or suggest something, please email: mailto:hello@odysee.com",
"--end--": "--end--"
}

View file

@ -49,6 +49,7 @@ type Props = {
claimSearchByQuery: { [string]: Array<string> },
claimsByUri: { [string]: any },
collectionId?: string,
fypId?: string,
showNoSourceClaims?: boolean,
onClick?: (e: any, claim?: ?Claim, index?: number) => void,
maxClaimRender?: number,
@ -88,6 +89,7 @@ export default function ClaimList(props: Props) {
searchInLanguage,
hideMenu,
collectionId,
fypId,
showNoSourceClaims,
onClick,
maxClaimRender,
@ -221,6 +223,7 @@ export default function ClaimList(props: Props) {
showHiddenByUser={showHiddenByUser}
properties={renderProperties}
collectionId={collectionId}
fypId={fypId}
showNoSourceClaims={showNoSourceClaims}
swipeLayout={swipeLayout}
/>

View file

@ -16,10 +16,12 @@ import { formatClaimPreviewTitle } from 'util/formatAriaLabel';
import { parseURI } from 'util/lbryURI';
import PreviewOverlayProperties from 'component/previewOverlayProperties';
import FileDownloadLink from 'component/fileDownloadLink';
import FileHideRecommendation from 'component/fileHideRecommendation';
import FileWatchLaterLink from 'component/fileWatchLaterLink';
import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
import { FYP_ID } from 'constants/urlParams';
// $FlowFixMe cannot resolve ...
import PlaceholderTx from 'static/img/placeholderTx.gif';
@ -42,6 +44,7 @@ type Props = {
showHiddenByUser?: boolean,
properties?: (Claim) => void,
collectionId?: string,
fypId?: string,
showNoSourceClaims?: boolean,
isLivestream: boolean,
viewCount: string,
@ -72,6 +75,7 @@ function ClaimPreviewTile(props: Props) {
isLivestream,
isLivestreamActive,
collectionId,
fypId,
mediaDuration,
viewCount,
swipeLayout = false,
@ -95,7 +99,9 @@ function ClaimPreviewTile(props: Props) {
const repostedContentUri = claim && (claim.reposted_claim ? claim.reposted_claim.permanent_url : claim.permanent_url);
const listId = collectionId || collectionClaimId;
const navigateUrl =
formatLbryUrlForWeb(canonicalUrl || uri || '/') + (listId ? generateListSearchUrlParams(listId) : '');
formatLbryUrlForWeb(canonicalUrl || uri || '/') +
(listId ? generateListSearchUrlParams(listId) : '') +
(fypId ? `?${FYP_ID}=${fypId}` : ''); // sigh...
const navLinkProps = {
to: navigateUrl,
onClick: (e) => e.stopPropagation(),
@ -200,6 +206,11 @@ function ClaimPreviewTile(props: Props) {
<div className="claim-preview__hover-actions">
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />}
</div>
{fypId && (
<div className="claim-preview__hover-actions">
{isStream && <FileHideRecommendation focusable={false} uri={repostedContentUri} />}
</div>
)}
{/* @if TARGET='app' */}
<div className="claim-preview__hover-actions">
{isStream && <FileDownloadLink focusable={false} uri={canonicalUrl} hideOpenButton />}

View file

@ -7,21 +7,23 @@ type Props = {
href?: string,
navigate?: string,
icon?: string,
iconSize?: number,
description?: string,
};
export default function HelpLink(props: Props) {
const { href, navigate, icon, description } = props;
const { href, navigate, icon, iconSize, description } = props;
return (
<Button
onClick={e => {
onClick={(e) => {
if (href) {
e.stopPropagation();
}
}}
className="icon--help"
icon={icon || ICONS.HELP}
iconSize={14}
iconSize={iconSize || 14}
title={description || __('Help')}
description={description || __('Help')}
href={href}
navigate={navigate}

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doRemovePersonalRecommendation } from 'redux/actions/search';
import FileHideRecommendation from './view';
const perform = {
doRemovePersonalRecommendation,
};
export default connect(null, perform)(FileHideRecommendation);

View file

@ -0,0 +1,37 @@
// @flow
import React from 'react';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
type Props = {
uri: string,
buttonType: ?string,
showLabel: ?boolean,
focusable: boolean,
// --- redux ---
doRemovePersonalRecommendation: (uri: string) => void,
};
export default function FileHideRecommendation(props: Props) {
const { uri, buttonType, showLabel = false, focusable = true, doRemovePersonalRecommendation } = props;
function handleClick(e) {
doRemovePersonalRecommendation(uri);
e.preventDefault();
}
const label = __('I dislike this');
return (
<Button
button={buttonType}
className={buttonType ? undefined : 'button--file-action'}
title={label}
icon={ICONS.REMOVE}
label={showLabel ? label : null}
onClick={handleClick}
aria-hidden={!focusable}
tabIndex={focusable ? 0 : -1}
/>
);
}

View file

@ -1,4 +1,5 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { selectClaimForUri } from 'redux/selectors/claims';
import { doFetchRecommendedContent } from 'redux/actions/search';
import { selectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search';
@ -18,4 +19,4 @@ const select = (state, props) => {
};
};
export default connect(select, { doFetchRecommendedContent })(RecommendedContent);
export default withRouter(connect(select, { doFetchRecommendedContent })(RecommendedContent));

View file

@ -1,4 +1,5 @@
// @flow
import { v4 as Uuidv4 } from 'uuid';
import { SHOW_ADS, AD_KEYWORD_BLOCKLIST, AD_KEYWORD_BLOCKLIST_CHECK_DESCRIPTION } from 'config';
import React from 'react';
import ClaimList from 'component/claimList';
@ -8,6 +9,7 @@ import Ads from 'web/component/ads';
import Card from 'component/common/card';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import Button from 'component/button';
import { FYP_ID } from 'constants/urlParams';
import classnames from 'classnames';
import RecSys from 'recsys';
import { getClaimMetadata } from 'util/claim';
@ -22,10 +24,11 @@ type Props = {
recommendedContentUris: Array<string>,
nextRecommendedUri: string,
isSearching: boolean,
doFetchRecommendedContent: (string) => void,
doFetchRecommendedContent: (string, ?FypParam) => void,
claim: ?StreamClaim,
claimId: string,
metadata: any,
location: UrlLocation,
userHasPremiumPlus: boolean,
};
@ -37,6 +40,7 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
nextRecommendedUri,
isSearching,
claim,
location,
userHasPremiumPlus,
} = props;
@ -80,9 +84,19 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
}
: null;
// Assume this component always resides in a page where the `uri` matches
// e.g. never in a floating popup. With that, we can grab the FYP ID from
// the search param directly. Otherwise, the parent component would need to
// pass it.
const { search } = location;
const urlParams = new URLSearchParams(search);
const fypId = urlParams.get(FYP_ID);
const [uuid] = React.useState(fypId ? Uuidv4() : '');
React.useEffect(() => {
doFetchRecommendedContent(uri);
}, [uri, doFetchRecommendedContent]);
const fypParam = fypId && uuid ? { gid: fypId, uuid } : null;
doFetchRecommendedContent(uri, fypParam);
}, [uri, doFetchRecommendedContent, fypId, uuid]);
React.useEffect(() => {
// Right now we only want to record the recs if they actually saw them.
@ -93,9 +107,9 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
nextRecommendedUri &&
viewMode === VIEW_ALL_RELATED
) {
onRecommendationsLoaded(claimId, recommendedContentUris);
onRecommendationsLoaded(claimId, recommendedContentUris, uuid);
}
}, [recommendedContentUris, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode]);
}, [recommendedContentUris, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode, uuid]);
function handleRecommendationClicked(e, clickedClaim) {
if (claim) {

View file

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import { doFetchPersonalRecommendations } from 'redux/actions/search';
import { selectPersonalRecommendations } from 'redux/selectors/search';
import { selectHasOdyseeMembership, selectUser } from 'redux/selectors/user';
import RecommendedPersonal from './view';
const select = (state) => {
const user = selectUser(state);
return {
userId: user && user.id,
personalRecommendations: selectPersonalRecommendations(state),
hasMembership: selectHasOdyseeMembership(state),
};
};
const perform = {
doFetchPersonalRecommendations,
};
export default connect(select, perform)(RecommendedPersonal);

View file

@ -0,0 +1,135 @@
// @flow
import React from 'react';
import Button from 'component/button';
import HelpLink from 'component/common/help-link';
import Icon from 'component/common/icon';
import ClaimList from 'component/claimList';
import { URL, SHARE_DOMAIN_URL } from 'config';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import { useIsLargeScreen, useIsMediumScreen } from 'effects/use-screensize';
// TODO: recsysFyp will be moved into 'RecSys', so the redux import in a jsx
// violation is just temporary.
import { recsysFyp } from 'redux/actions/search';
// ****************************************************************************
// SectionHeader (TODO: DRY)
// ****************************************************************************
type SectionHeaderProps = {
title: string,
navigate?: string,
icon?: string,
help?: string,
};
const SectionHeader = ({ title, icon = '', help }: SectionHeaderProps) => {
const SHARE_DOMAIN = SHARE_DOMAIN_URL || URL;
return (
<h1 className="claim-grid__header">
<Icon className="claim-grid__header-icon" sectionIcon icon={icon} size={20} />
<span className="claim-grid__title">{title}</span>
{help}
<HelpLink href={`${SHARE_DOMAIN}/$/${PAGES.FYP}`} iconSize={24} description={__('Learn more')} />
</h1>
);
};
// ****************************************************************************
// RecommendedPersonal
// ****************************************************************************
const VIEW = { ALL_VISIBLE: 0, COLLAPSED: 1, EXPANDED: 2 };
function getSuitablePageSizeForScreen(defaultSize, isLargeScreen, isMediumScreen) {
return isMediumScreen ? 6 : isLargeScreen ? Math.ceil(defaultSize * (3 / 2)) : defaultSize;
}
type Props = {
onLoad: (displayed: boolean) => void,
// --- redux ---
userId: ?string,
personalRecommendations: { gid: string, uris: Array<string> },
hasMembership: boolean,
doFetchPersonalRecommendations: () => void,
};
export default function RecommendedPersonal(props: Props) {
const { onLoad, userId, personalRecommendations, hasMembership, doFetchPersonalRecommendations } = props;
const [markedGid, setMarkedGid] = React.useState('');
const [view, setView] = React.useState(VIEW.ALL_VISIBLE);
const isLargeScreen = useIsLargeScreen();
const isMediumScreen = useIsMediumScreen();
const count = personalRecommendations.uris.length;
const countCollapsed = getSuitablePageSizeForScreen(8, isLargeScreen, isMediumScreen);
const finalCount = view === VIEW.ALL_VISIBLE ? count : view === VIEW.COLLAPSED ? countCollapsed : count;
React.useEffect(() => {
onLoad(count > 0);
}, [count, onLoad]);
// Resolve the view state:
React.useEffect(() => {
let newView;
if (count <= countCollapsed) {
newView = VIEW.ALL_VISIBLE;
} else {
if (view === VIEW.ALL_VISIBLE) {
newView = VIEW.COLLAPSED;
}
}
if (newView && newView !== view) {
setView(newView);
}
}, [count, countCollapsed, view, setView]);
// Mark recommendations when rendered:
React.useEffect(() => {
if (userId && markedGid !== personalRecommendations.gid) {
setMarkedGid(personalRecommendations.gid);
recsysFyp.markPersonalRecommendations(userId, personalRecommendations.gid);
}
}, [userId, markedGid, personalRecommendations.gid]);
// Fetch on mount:
React.useEffect(() => {
doFetchPersonalRecommendations();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if ((!hasMembership && !process.env.ENABLE_WIP_FEATURES) || count < 1) {
return null;
}
return (
<>
<SectionHeader title={__('Recommended For You')} icon={ICONS.WEB} />
<ClaimList
tileLayout
uris={personalRecommendations.uris.slice(0, finalCount)}
fypId={personalRecommendations.gid}
/>
{view !== VIEW.ALL_VISIBLE && (
<div className="livestream-list--view-more">
<Button
label={view === VIEW.COLLAPSED ? __('Show more') : __('Show less')}
button="link"
iconRight={view === VIEW.COLLAPSED ? ICONS.DOWN : ICONS.UP}
className="claim-grid__title--secondary"
onClick={() => {
if (view === VIEW.COLLAPSED) {
setView(VIEW.EXPANDED);
} else {
setView(VIEW.COLLAPSED);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
/>
</div>
)}
</>
);
}

View file

@ -23,6 +23,7 @@ const BackupPage = lazyImport(() => import('page/backup' /* webpackChunkName: "b
const Code2257Page = lazyImport(() => import('web/page/code2257' /* webpackChunkName: "code2257" */));
const PrivacyPolicyPage = lazyImport(() => import('web/page/privacypolicy' /* webpackChunkName: "privacypolicy" */));
const TOSPage = lazyImport(() => import('web/page/tos' /* webpackChunkName: "tos" */));
const FypPage = lazyImport(() => import('web/page/fyp' /* webpackChunkName: "fyp" */));
const YouTubeTOSPage = lazyImport(() => import('web/page/youtubetos' /* webpackChunkName: "youtubetos" */));
// @endif
@ -307,6 +308,7 @@ function AppRouter(props: Props) {
<Route path={`/$/${PAGES.CODE_2257}`} exact component={Code2257Page} />
<Route path={`/$/${PAGES.PRIVACY_POLICY}`} exact component={PrivacyPolicyPage} />
<Route path={`/$/${PAGES.TOS}`} exact component={TOSPage} />
<Route path={`/$/${PAGES.FYP}`} exact component={FypPage} />
<Route path={`/$/${PAGES.YOUTUBE_TOS}`} exact component={YouTubeTOSPage} />
{/* @endif */}
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />

View file

@ -233,6 +233,9 @@ export const SEARCH_FAIL = 'SEARCH_FAIL';
export const UPDATE_SEARCH_OPTIONS = 'UPDATE_SEARCH_OPTIONS';
export const UPDATE_SEARCH_SUGGESTIONS = 'UPDATE_SEARCH_SUGGESTIONS';
export const SET_MENTION_SEARCH_RESULTS = 'SET_MENTION_SEARCH_RESULTS';
export const FYP_FETCH_SUCCESS = 'FYP_FETCH_SUCCESS';
export const FYP_FETCH_FAILED = 'FYP_FETCH_FAILED';
export const FYP_HIDE_URI = 'FYP_HIDE_URI';
// Settings
export const DAEMON_SETTINGS_RECEIVED = 'DAEMON_SETTINGS_RECEIVED';

View file

@ -74,6 +74,7 @@ exports.CHECKOUT = 'checkout';
exports.CODE_2257 = '2257';
exports.PRIVACY_POLICY = 'privacypolicy';
exports.TOS = 'tos';
exports.FYP = 'fyp';
exports.YOUTUBE_TOS = 'youtubetos';
exports.BUY = 'buy';
exports.RECEIVE = 'receive';

View file

@ -0,0 +1 @@
export const FYP_ID = 'fypId';

View file

@ -9,6 +9,7 @@ import ClaimTilesDiscover from 'component/claimTilesDiscover';
import ClaimPreviewTile from 'component/claimPreviewTile';
import Icon from 'component/common/icon';
import WaitUntilOnPage from 'component/common/wait-until-on-page';
import RecommendedPersonal from 'component/recommendedPersonal';
import { useIsLargeScreen } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage';
import { getLivestreamUris } from 'util/livestream';
@ -45,11 +46,11 @@ function HomePage(props: Props) {
fetchingActiveLivestreams,
hideScheduledLivestreams,
} = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
const showIndividualTags = showPersonalizedTags && followedTags.length < 5;
const isLargeScreen = useIsLargeScreen();
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
const rowData: Array<RowDataItem> = GetLinksData(
@ -71,6 +72,7 @@ function HomePage(props: Props) {
icon?: string,
help?: string,
};
const SectionHeader = ({ title, navigate = '/', icon = '', help }: SectionHeaderProps) => {
return (
<h1 className="claim-grid__header">
@ -145,6 +147,7 @@ function HomePage(props: Props) {
doFetchActiveLivestreams();
}, []);
const [hasPersonalRecommendations, setHasPersonalRecommendations] = useState(false);
const [hasScheduledStreams, setHasScheduledStreams] = useState(false);
const scheduledStreamsLoaded = (total) => setHasScheduledStreams(total > 0);
@ -169,6 +172,8 @@ function HomePage(props: Props) {
{SIMPLE_SITE && <Meme />}
{/* @endif */}
<RecommendedPersonal onLoad={(displayed) => setHasPersonalRecommendations(displayed)} />
{!fetchingActiveLivestreams && (
<>
{authenticated && channelIds.length > 0 && !hideScheduledLivestreams && (
@ -181,7 +186,7 @@ function HomePage(props: Props) {
/>
)}
{authenticated && hasScheduledStreams && !hideScheduledLivestreams && (
{authenticated && ((hasScheduledStreams && !hideScheduledLivestreams) || hasPersonalRecommendations) && (
<SectionHeader title={__('Following')} navigate={`/$/${PAGES.CHANNELS_FOLLOWING}`} icon={ICONS.SUBSCRIBE} />
)}
</>

View file

@ -426,7 +426,7 @@ const OdyseeMembershipPage = (props: Props) => {
<ul>
<li>
{__(
`Early access and exclusive features include: livestreaming and the ability to post odysee hyperlinks and images in comments + blogs. Account is also automatically eligible for Rewards. More to come later.`
`Exclusive and early access features include: recommended content on homepage, livestreaming, and the ability to post odysee hyperlinks + images in comments. Account is also automatically eligible for Rewards. More to come later.`
)}
</li>
<li>

View file

@ -1,16 +1,67 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { doToast } from 'redux/actions/notifications';
import { selectShowMatureContent } from 'redux/selectors/settings';
import { selectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
import { selectClaimForUri, selectClaimIdForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
import { doResolveUris } from 'redux/actions/claims';
import { buildURI, isURIValid } from 'util/lbryURI';
import { batchActions } from 'util/batch-actions';
import { makeSelectSearchUrisForQuery, selectSearchValue } from 'redux/selectors/search';
import { makeSelectSearchUrisForQuery, selectPersonalRecommendations, selectSearchValue } from 'redux/selectors/search';
import { selectUser } from 'redux/selectors/user';
import handleFetchResponse from 'util/handle-fetch';
import { getSearchQueryString } from 'util/query-params';
import { getRecommendationSearchOptions } from 'util/search';
import { SEARCH_SERVER_API, SEARCH_SERVER_API_ALT } from 'config';
import { SEARCH_SERVER_API, SEARCH_SERVER_API_ALT, RECSYS_FYP_ENDPOINT } from 'config';
import { SEARCH_OPTIONS } from 'constants/search';
import { X_LBRY_AUTH_TOKEN } from 'constants/token';
import { getAuthToken } from 'util/saved-passwords';
// ****************************************************************************
// FYP
// ****************************************************************************
// TODO: This should be part of `extras/recsys/recsys`, but due to the circular
// dependency problem with `extras`, I'm temporarily placing it. The recsys
// object should be moved into `ui`, but that change will require more testing.
console.assert(RECSYS_FYP_ENDPOINT, 'RECSYS_FYP_ENDPOINT not defined!');
const recsysFyp = {
fetchPersonalRecommendations: (userId: string) => {
return fetch(`${RECSYS_FYP_ENDPOINT}/${userId}/fyp`, { headers: { [X_LBRY_AUTH_TOKEN]: getAuthToken() } })
.then((response) => response.json())
.then((result) => result)
.catch((error) => {
console.log('FYP: fetch', { error, userId });
return {};
});
},
markPersonalRecommendations: (userId: string, gid: string) => {
return fetch(`${RECSYS_FYP_ENDPOINT}/${userId}/fyp/${gid}/mark`, {
method: 'POST',
headers: { [X_LBRY_AUTH_TOKEN]: getAuthToken() },
}).catch((error) => {
console.log('FYP: mark', { error, userId, gid });
return {};
});
},
ignoreRecommendation: (userId: string, gid: string, claimId: string) => {
return fetch(`${RECSYS_FYP_ENDPOINT}/${userId}/fyp/${gid}/c/${claimId}/ignore`, {
method: 'POST',
headers: { [X_LBRY_AUTH_TOKEN]: getAuthToken() },
})
.then((response) => response.json())
.then((result) => result)
.catch((error) => {
console.log('FYP: ignore', { error, userId, gid, claimId });
return {};
});
},
};
// ****************************************************************************
// ****************************************************************************
type Dispatch = (action: any) => any;
type GetState = () => { claims: any, search: SearchState, user: User };
@ -21,6 +72,8 @@ type SearchOptions = {
related_to?: string,
nsfw?: boolean,
isBackgroundSearch?: boolean,
gid?: string, // for fyp only
uuid?: string, // for fyp only
};
let lighthouse = {
@ -46,6 +99,36 @@ export const setSearchUserId = (userId: ?string) => {
lighthouse.user_id = userId ? `&user_id=${userId}` : '';
};
/**
* Processes a lighthouse-formatted search result to an array of uris.
* @param results
*/
const processLighthouseResults = (results: Array<any>) => {
const uris = [];
results.forEach((item) => {
if (item) {
const { name, claimId } = item;
const urlObj: LbryUrlObj = {};
if (name.startsWith('@')) {
urlObj.channelName = name;
urlObj.channelClaimId = claimId;
} else {
urlObj.streamName = name;
urlObj.streamClaimId = claimId;
}
const url = buildURI(urlObj);
if (isURIValid(url)) {
uris.push(url);
}
}
});
return uris;
};
export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
dispatch: Dispatch,
getState: GetState
@ -85,31 +168,10 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
cmd(queryWithOptions)
.then((data: { body: Array<{ name: string, claimId: string }>, poweredBy: string }) => {
const { body: result, poweredBy } = data;
const uris = [];
const uris = processLighthouseResults(result);
const actions = [];
result.forEach((item) => {
if (item) {
const { name, claimId } = item;
const urlObj: LbryUrlObj = {};
if (name.startsWith('@')) {
urlObj.channelName = name;
urlObj.channelClaimId = claimId;
} else {
urlObj.streamName = name;
urlObj.streamClaimId = claimId;
}
const url = buildURI(urlObj);
if (isURIValid(url)) {
uris.push(url);
}
}
});
actions.push(doResolveUris(uris));
actions.push({
type: ACTIONS.SEARCH_SUCCESS,
data: {
@ -120,6 +182,7 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
recsys: poweredBy,
},
});
dispatch(batchActions(...actions));
})
.catch(() => {
@ -154,7 +217,10 @@ export const doSetMentionSearchResults = (query: string, uris: Array<string>) =>
});
};
export const doFetchRecommendedContent = (uri: string) => (dispatch: Dispatch, getState: GetState) => {
export const doFetchRecommendedContent = (uri: string, fyp: ?FypParam = null) => (
dispatch: Dispatch,
getState: GetState
) => {
const state = getState();
const claim = selectClaimForUri(state, uri);
const matureEnabled = selectShowMatureContent(state);
@ -162,6 +228,12 @@ export const doFetchRecommendedContent = (uri: string) => (dispatch: Dispatch, g
if (claim && claim.value && claim.claim_id) {
const options: SearchOptions = getRecommendationSearchOptions(matureEnabled, claimIsMature, claim.claim_id);
if (fyp) {
options['gid'] = fyp.gid;
options['uuid'] = fyp.uuid;
}
const { title } = claim.value;
if (title && options) {
@ -170,4 +242,55 @@ export const doFetchRecommendedContent = (uri: string) => (dispatch: Dispatch, g
}
};
export { lighthouse };
export const doFetchPersonalRecommendations = () => (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const user = selectUser(state);
if (!user || !user.id) {
dispatch({ type: ACTIONS.FYP_FETCH_FAILED });
return;
}
recsysFyp
.fetchPersonalRecommendations(user.id)
.then((data) => {
const { gid, recs } = data;
if (gid && recs) {
dispatch({
type: ACTIONS.FYP_FETCH_SUCCESS,
data: {
gid,
uris: processLighthouseResults(recs),
},
});
} else {
dispatch({ type: ACTIONS.FYP_FETCH_FAILED });
}
})
.catch(() => {
dispatch({ type: ACTIONS.FYP_FETCH_FAILED });
});
};
export const doRemovePersonalRecommendation = (uri: string) => (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const user = selectUser(state);
const personalRecommendations = selectPersonalRecommendations(state);
const claimId = selectClaimIdForUri(state, uri);
if (!user || !user.id || !personalRecommendations.gid || !claimId) {
return;
}
recsysFyp
.ignoreRecommendation(user.id, personalRecommendations.gid, claimId)
.then((res) => {
dispatch({ type: ACTIONS.FYP_HIDE_URI, data: { uri } });
dispatch(doToast({ message: __('Recommendation removed. Thanks for the feedback!') }));
})
.catch((err) => {
console.log('doRemovePersonalRecommendation:', err);
});
};
export { lighthouse, recsysFyp };

View file

@ -22,6 +22,7 @@ const defaultState: SearchState = {
searching: false,
results: [],
mentionQuery: '',
personalRecommendations: { gid: '', uris: [] },
};
export default handleActions(
@ -74,6 +75,39 @@ export default handleActions(
results: action.data.uris,
mentionQuery: action.data.query,
}),
[ACTIONS.FYP_FETCH_SUCCESS]: (state: SearchState, action: any): SearchState => {
return {
...state,
personalRecommendations: {
gid: action.data.gid,
uris: action.data.uris,
},
};
},
[ACTIONS.FYP_FETCH_FAILED]: (state: SearchState, action: any): SearchState => ({
...state,
personalRecommendations: defaultState.personalRecommendations,
}),
[ACTIONS.FYP_HIDE_URI]: (state: SearchState, action: any): SearchState => {
const { uri } = action.data;
const uris = state.personalRecommendations.uris.slice();
const index = uris.findIndex((x) => x === uri);
if (index !== -1) {
uris.splice(index, 1);
return {
...state,
personalRecommendations: {
gid: state.personalRecommendations.gid,
uris,
},
};
}
return state;
},
},
defaultState
);

View file

@ -34,6 +34,7 @@ export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Ar
selectState(state).hasReachedMaxResultsLength;
export const selectMentionSearchResults: (state: State) => Array<string> = (state) => selectState(state).results;
export const selectMentionQuery: (state: State) => string = (state) => selectState(state).mentionQuery;
export const selectPersonalRecommendations = (state: State) => selectState(state).personalRecommendations;
export const makeSelectSearchUrisForQuery = (query: string): ((state: State) => Array<string>) =>
createSelector(selectSearchResultByQuery, (byQuery) => {

View file

@ -112,6 +112,11 @@ export const selectOdyseeMembershipIsPremiumPlus = (state) => {
return selectState(state).odyseeMembershipName === 'Premium+';
};
export const selectHasOdyseeMembership = (state) => {
const membershipName = selectOdyseeMembershipName(state);
return Boolean(membershipName);
};
export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => {
const total = state.youtubeChannelImportTotal;
const complete = state.youtubeChannelImportComplete || 0;

View file

@ -77,13 +77,24 @@ export const getSearchQueryString = (query: string, options: any = {}) => {
}
const additionalOptions = {};
const { related_to } = options;
const { nsfw } = options;
const { free_only } = options;
const { related_to, nsfw, free_only, gid, uuid } = options;
if (related_to) additionalOptions[SEARCH_OPTIONS.RELATED_TO] = related_to;
if (free_only) additionalOptions[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true;
if (nsfw === false) additionalOptions[SEARCH_OPTIONS.INCLUDE_MATURE] = false;
if (related_to) {
additionalOptions[SEARCH_OPTIONS.RELATED_TO] = related_to;
if (gid && uuid) {
additionalOptions['gid'] = gid;
additionalOptions['uuid'] = uuid;
}
}
if (free_only) {
additionalOptions[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true;
}
if (nsfw === false) {
additionalOptions[SEARCH_OPTIONS.INCLUDE_MATURE] = false;
}
if (additionalOptions) {
Object.keys(additionalOptions).forEach((key) => {

View file

@ -5,20 +5,28 @@ import { URL as SITE_URL, URL_LOCAL, URL_DEV, SIMPLE_SITE } from 'config';
import { SEARCH_OPTIONS } from 'constants/search';
export function createNormalizedSearchKey(query: string) {
const FROM = '&from=';
// Ignore the "page" (`from`) because we don't care what the last page
// searched was, we want everything.
let normalizedQuery = query;
if (normalizedQuery.includes(FROM)) {
const a = normalizedQuery.indexOf(FROM);
const b = normalizedQuery.indexOf('&', a + FROM.length);
const removeParam = (query: string, param: string) => {
// TODO: find a standard way to do this.
if (query.includes(param)) {
const a = query.indexOf(param);
const b = query.indexOf('&', a + param.length);
if (b > a) {
normalizedQuery = normalizedQuery.substring(0, a) + normalizedQuery.substring(b);
query = query.substring(0, a) + query.substring(b);
} else {
normalizedQuery = normalizedQuery.substring(0, a);
query = query.substring(0, a);
}
}
return query;
};
let normalizedQuery = query;
// Ignore the "page" (`from`) because we don't care what the last page searched was, we want everything:
normalizedQuery = removeParam(normalizedQuery, '&from=');
// Remove FYP additional info:
normalizedQuery = removeParam(normalizedQuery, '&gid=');
normalizedQuery = removeParam(normalizedQuery, '&uuid=');
return normalizedQuery;
}

2
web/page/fyp/index.js Normal file
View file

@ -0,0 +1,2 @@
import PageFyp from './view';
export default PageFyp;

27
web/page/fyp/view.jsx Normal file
View file

@ -0,0 +1,27 @@
// @flow
import React from 'react';
import Page from 'component/page';
import MarkdownPreview from 'component/common/markdown-preview';
export default function PageFyp() {
const content = `# Recommended Videos - Alpha version available to Odysee Premium users
## What is this?
Our content catalog is growing fast. Each day, there are more creators using the platform. This is great news! But it also makes finding things you would like to watch harder. Odysee's recommended videos tries to make it easier.
## How does it work?
Based on your video viewing history, Odysee tries to find other channels you might like. Then, we recommend videos from those channels. At least, that's how it works right now. But expect a lot of rapid change.
## Does Odysee manipulate the results?
No. The current algorithm itself has a tendency to favor channels with more viewers. But this is just because we have more evidence for those ones. Otherwise, we aren't making editorial decisions or picking favorites.
## My results suck, wtf?
The more videos you watch, the better it should be. But this system is brand new and it will take a bit of time to tune it. Please have patience. Or, if you want to complain or suggest something, please email: recommendations@odysee.com
## Why don't I have any recommendations?
Right now, it's a premium feature. But you also might not be using Odysee enough. It's hard to make recommendations without knowing much about you. Otherwise, if you use uBlock Origin or Brave, make sure they are disabled on Odysee, as they interfere with us learning what you like.`;
return (
<Page>
<MarkdownPreview content={__(content)} simpleLinks />
</Page>
);
}