aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv/recommendation/Recommender.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/tv/recommendation/Recommender.java')
-rw-r--r--src/com/android/tv/recommendation/Recommender.java336
1 files changed, 336 insertions, 0 deletions
diff --git a/src/com/android/tv/recommendation/Recommender.java b/src/com/android/tv/recommendation/Recommender.java
new file mode 100644
index 00000000..0561449e
--- /dev/null
+++ b/src/com/android/tv/recommendation/Recommender.java
@@ -0,0 +1,336 @@
+/*
+ * 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.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.
+ *
+ * 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(new Pair<>(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 Collections.unmodifiableList(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.
+ *
+ * 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 static abstract 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();
+ }
+}