| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tv.recommendation; |
| |
| import android.content.Context; |
| import android.support.annotation.VisibleForTesting; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import com.android.tv.data.api.Channel; |
| |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.concurrent.TimeUnit; |
| |
| public class Recommender implements RecommendationDataManager.Listener { |
| private static final String TAG = "Recommender"; |
| |
| @VisibleForTesting static final String INVALID_CHANNEL_SORT_KEY = "INVALID"; |
| private static final long MINIMUM_RECOMMENDATION_UPDATE_PERIOD = TimeUnit.MINUTES.toMillis(5); |
| private static final Comparator<Pair<Channel, Double>> mChannelScoreComparator = |
| new Comparator<Pair<Channel, Double>>() { |
| @Override |
| public int compare(Pair<Channel, Double> lhs, Pair<Channel, Double> rhs) { |
| // Sort the scores with descending order. |
| return rhs.second.compareTo(lhs.second); |
| } |
| }; |
| |
| private final List<EvaluatorWrapper> mEvaluators = new ArrayList<>(); |
| private final boolean mIncludeRecommendedOnly; |
| private final Listener mListener; |
| |
| private final Map<Long, String> mChannelSortKey = new HashMap<>(); |
| private final RecommendationDataManager mDataManager; |
| private List<Channel> mPreviousRecommendedChannels = new ArrayList<>(); |
| private long mLastRecommendationUpdatedTimeUtcMillis; |
| private boolean mChannelRecordLoaded; |
| |
| /** |
| * Create a recommender object. |
| * |
| * @param includeRecommendedOnly true to include only recommended results, or false. |
| */ |
| public Recommender(Context context, Listener listener, boolean includeRecommendedOnly) { |
| mListener = listener; |
| mIncludeRecommendedOnly = includeRecommendedOnly; |
| mDataManager = RecommendationDataManager.acquireManager(context, this); |
| } |
| |
| @VisibleForTesting |
| Recommender( |
| Listener listener, |
| boolean includeRecommendedOnly, |
| RecommendationDataManager dataManager) { |
| mListener = listener; |
| mIncludeRecommendedOnly = includeRecommendedOnly; |
| mDataManager = dataManager; |
| } |
| |
| public boolean isReady() { |
| return mChannelRecordLoaded; |
| } |
| |
| public void release() { |
| mDataManager.release(this); |
| } |
| |
| public void registerEvaluator(Evaluator evaluator) { |
| registerEvaluator( |
| evaluator, EvaluatorWrapper.DEFAULT_BASE_SCORE, EvaluatorWrapper.DEFAULT_WEIGHT); |
| } |
| |
| /** |
| * Register the evaluator used in recommendation. |
| * |
| * <p>The range of evaluated scores by this evaluator will be between {@code baseScore} and |
| * {@code baseScore} + {@code weight} (inclusive). |
| * |
| * @param evaluator The evaluator to register inside this recommender. |
| * @param baseScore Base(Minimum) score of the score evaluated by {@code evaluator}. |
| * @param weight Weight value to rearrange the score evaluated by {@code evaluator}. |
| */ |
| public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) { |
| mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight)); |
| } |
| |
| public List<Channel> recommendChannels() { |
| return recommendChannels(mDataManager.getChannelRecordCount()); |
| } |
| |
| /** |
| * Return the channel list of recommendation up to {@code n} or the number of channels. During |
| * the evaluation, this method updates the channel sort key of recommended channels. |
| * |
| * @param size The number of channels that might be recommended. |
| * @return Top {@code size} channels recommended sorted by score in descending order. If {@code |
| * size} is bigger than the number of channels, the number of results could be less than |
| * {@code size}. |
| */ |
| public List<Channel> recommendChannels(int size) { |
| List<Pair<Channel, Double>> records = new ArrayList<>(); |
| Collection<ChannelRecord> channelRecordList = mDataManager.getChannelRecords(); |
| for (ChannelRecord cr : channelRecordList) { |
| double maxScore = Evaluator.NOT_RECOMMENDED; |
| for (EvaluatorWrapper evaluator : mEvaluators) { |
| double score = evaluator.getScaledEvaluatorScore(cr.getChannel().getId()); |
| if (score > maxScore) { |
| maxScore = score; |
| } |
| } |
| if (!mIncludeRecommendedOnly || maxScore != Evaluator.NOT_RECOMMENDED) { |
| records.add(Pair.create(cr.getChannel(), maxScore)); |
| } |
| } |
| if (size > records.size()) { |
| size = records.size(); |
| } |
| Collections.sort(records, mChannelScoreComparator); |
| |
| List<Channel> results = new ArrayList<>(); |
| |
| mChannelSortKey.clear(); |
| String sortKeyFormat = "%0" + String.valueOf(size).length() + "d"; |
| for (int i = 0; i < size; ++i) { |
| // Channel with smaller sort key has higher priority. |
| mChannelSortKey.put(records.get(i).first.getId(), String.format(sortKeyFormat, i)); |
| results.add(records.get(i).first); |
| } |
| return results; |
| } |
| |
| /** |
| * Returns the {@link Channel} object for a given channel ID from the channel pool that this |
| * recommendation engine has. |
| * |
| * @param channelId The channel ID to retrieve the {@link Channel} object for. |
| * @return the {@link Channel} object for the given channel ID, {@code null} if such a channel |
| * is not found. |
| */ |
| public Channel getChannel(long channelId) { |
| ChannelRecord record = mDataManager.getChannelRecord(channelId); |
| return record == null ? null : record.getChannel(); |
| } |
| |
| /** |
| * Returns the {@link ChannelRecord} object for a given channel ID. |
| * |
| * @param channelId The channel ID to receive the {@link ChannelRecord} object for. |
| * @return the {@link ChannelRecord} object for the given channel ID. |
| */ |
| public ChannelRecord getChannelRecord(long channelId) { |
| return mDataManager.getChannelRecord(channelId); |
| } |
| |
| /** |
| * Returns the sort key of a given channel Id. Sort key is determined in {@link |
| * #recommendChannels()} and getChannelSortKey must be called after that. |
| * |
| * <p>If getChannelSortKey was called before evaluating the channels or trying to get sort key |
| * of non-recommended channel, it returns {@link #INVALID_CHANNEL_SORT_KEY}. |
| */ |
| public String getChannelSortKey(long channelId) { |
| String key = mChannelSortKey.get(channelId); |
| return key == null ? INVALID_CHANNEL_SORT_KEY : key; |
| } |
| |
| @Override |
| public void onChannelRecordLoaded() { |
| mChannelRecordLoaded = true; |
| mListener.onRecommenderReady(); |
| List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords()); |
| for (EvaluatorWrapper evaluator : mEvaluators) { |
| evaluator.onChannelListChanged(Collections.unmodifiableList(channels)); |
| } |
| } |
| |
| @Override |
| public void onNewWatchLog(ChannelRecord channelRecord) { |
| for (EvaluatorWrapper evaluator : mEvaluators) { |
| evaluator.onNewWatchLog(channelRecord); |
| } |
| checkRecommendationChanged(); |
| } |
| |
| @Override |
| public void onChannelRecordChanged() { |
| if (mChannelRecordLoaded) { |
| List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords()); |
| for (EvaluatorWrapper evaluator : mEvaluators) { |
| evaluator.onChannelListChanged(Collections.unmodifiableList(channels)); |
| } |
| } |
| checkRecommendationChanged(); |
| } |
| |
| private void checkRecommendationChanged() { |
| long currentTimeUtcMillis = System.currentTimeMillis(); |
| if (currentTimeUtcMillis - mLastRecommendationUpdatedTimeUtcMillis |
| < MINIMUM_RECOMMENDATION_UPDATE_PERIOD) { |
| return; |
| } |
| mLastRecommendationUpdatedTimeUtcMillis = currentTimeUtcMillis; |
| List<Channel> recommendedChannels = recommendChannels(); |
| if (!recommendedChannels.equals(mPreviousRecommendedChannels)) { |
| mPreviousRecommendedChannels = recommendedChannels; |
| mListener.onRecommendationChanged(); |
| } |
| } |
| |
| @VisibleForTesting |
| void setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs) { |
| mLastRecommendationUpdatedTimeUtcMillis = newUpdatedTimeMs; |
| } |
| |
| public abstract static class Evaluator { |
| public static final double NOT_RECOMMENDED = -1.0; |
| private Recommender mRecommender; |
| |
| protected Evaluator() {} |
| |
| protected void onChannelRecordListChanged(List<ChannelRecord> channelRecords) {} |
| |
| /** |
| * This will be called when a new watch log comes into WatchedPrograms table. |
| * |
| * @param channelRecord The channel record corresponds to the new watch log. |
| */ |
| protected void onNewWatchLog(ChannelRecord channelRecord) {} |
| |
| /** |
| * The implementation should return the recommendation score for the given channel ID. The |
| * return value should be in the range of [0.0, 1.0] or NOT_RECOMMENDED for denoting that it |
| * gives up to calculate the score for the channel. |
| * |
| * @param channelId The channel ID which will be evaluated by this recommender. |
| * @return The recommendation score |
| */ |
| protected abstract double evaluateChannel(final long channelId); |
| |
| protected void setRecommender(Recommender recommender) { |
| mRecommender = recommender; |
| } |
| |
| protected Recommender getRecommender() { |
| return mRecommender; |
| } |
| } |
| |
| private static class EvaluatorWrapper { |
| private static final double DEFAULT_BASE_SCORE = 0.0; |
| private static final double DEFAULT_WEIGHT = 1.0; |
| |
| private final Evaluator mEvaluator; |
| // The minimum score of the Recommender unless it gives up to provide the score. |
| private final double mBaseScore; |
| // The weight of the recommender. The return-value of getScore() will be multiplied by |
| // this value. |
| private final double mWeight; |
| |
| public EvaluatorWrapper( |
| Recommender recommender, Evaluator evaluator, double baseScore, double weight) { |
| mEvaluator = evaluator; |
| evaluator.setRecommender(recommender); |
| mBaseScore = baseScore; |
| mWeight = weight; |
| } |
| |
| /** |
| * This returns the scaled score for the given channel ID based on the returned value of |
| * evaluateChannel(). |
| * |
| * @param channelId The channel ID which will be evaluated by the recommender. |
| * @return Returns the scaled score (mBaseScore + score * mWeight) when evaluateChannel() is |
| * in the range of [0.0, 1.0]. If evaluateChannel() returns NOT_RECOMMENDED or any |
| * negative numbers, it returns NOT_RECOMMENDED. If calculateScore() returns more than |
| * 1.0, it returns (mBaseScore + mWeight). |
| */ |
| private double getScaledEvaluatorScore(long channelId) { |
| double score = mEvaluator.evaluateChannel(channelId); |
| if (score < 0.0) { |
| if (score != Evaluator.NOT_RECOMMENDED) { |
| Log.w( |
| TAG, |
| "Unexpected score (" + score + ") from the recommender" + mEvaluator); |
| } |
| // If the recommender gives up to calculate the score, return 0.0 |
| return Evaluator.NOT_RECOMMENDED; |
| } else if (score > 1.0) { |
| Log.w(TAG, "Unexpected score (" + score + ") from the recommender" + mEvaluator); |
| score = 1.0; |
| } |
| return mBaseScore + score * mWeight; |
| } |
| |
| public void onNewWatchLog(ChannelRecord channelRecord) { |
| mEvaluator.onNewWatchLog(channelRecord); |
| } |
| |
| public void onChannelListChanged(List<ChannelRecord> channelRecords) { |
| mEvaluator.onChannelRecordListChanged(channelRecords); |
| } |
| } |
| |
| public interface Listener { |
| /** Called after channel record map is loaded. */ |
| void onRecommenderReady(); |
| |
| /** Called when the recommendation changes. */ |
| void onRecommendationChanged(); |
| } |
| } |