1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tv.recommendation; 18 19 import android.content.Context; 20 import android.support.annotation.VisibleForTesting; 21 import android.util.Log; 22 import android.util.Pair; 23 24 import com.android.tv.data.Channel; 25 26 import java.util.ArrayList; 27 import java.util.Collection; 28 import java.util.Collections; 29 import java.util.Comparator; 30 import java.util.HashMap; 31 import java.util.List; 32 import java.util.Map; 33 import java.util.concurrent.TimeUnit; 34 35 public class Recommender implements RecommendationDataManager.Listener { 36 private static final String TAG = "Recommender"; 37 38 @VisibleForTesting 39 static final String INVALID_CHANNEL_SORT_KEY = "INVALID"; 40 private static final long MINIMUM_RECOMMENDATION_UPDATE_PERIOD = TimeUnit.MINUTES.toMillis(5); 41 private static final Comparator<Pair<Channel, Double>> mChannelScoreComparator = 42 new Comparator<Pair<Channel, Double>>() { 43 @Override 44 public int compare(Pair<Channel, Double> lhs, Pair<Channel, Double> rhs) { 45 // Sort the scores with descending order. 46 return rhs.second.compareTo(lhs.second); 47 } 48 }; 49 50 private final List<EvaluatorWrapper> mEvaluators = new ArrayList<>(); 51 private final boolean mIncludeRecommendedOnly; 52 private final Listener mListener; 53 54 private final Map<Long, String> mChannelSortKey = new HashMap<>(); 55 private final RecommendationDataManager mDataManager; 56 private List<Channel> mPreviousRecommendedChannels = new ArrayList<>(); 57 private long mLastRecommendationUpdatedTimeUtcMillis; 58 private boolean mChannelRecordLoaded; 59 60 /** 61 * Create a recommender object. 62 * 63 * @param includeRecommendedOnly true to include only recommended results, or false. 64 */ 65 public Recommender(Context context, Listener listener, boolean includeRecommendedOnly) { 66 mListener = listener; 67 mIncludeRecommendedOnly = includeRecommendedOnly; 68 mDataManager = RecommendationDataManager.acquireManager(context, this); 69 } 70 71 @VisibleForTesting 72 Recommender(Listener listener, boolean includeRecommendedOnly, 73 RecommendationDataManager dataManager) { 74 mListener = listener; 75 mIncludeRecommendedOnly = includeRecommendedOnly; 76 mDataManager = dataManager; 77 } 78 79 public boolean isReady() { 80 return mChannelRecordLoaded; 81 } 82 83 public void release() { 84 mDataManager.release(this); 85 } 86 87 public void registerEvaluator(Evaluator evaluator) { 88 registerEvaluator(evaluator, 89 EvaluatorWrapper.DEFAULT_BASE_SCORE, EvaluatorWrapper.DEFAULT_WEIGHT); 90 } 91 92 /** 93 * Register the evaluator used in recommendation. 94 * 95 * The range of evaluated scores by this evaluator will be between {@code baseScore} and 96 * {@code baseScore} + {@code weight} (inclusive). 97 98 * @param evaluator The evaluator to register inside this recommender. 99 * @param baseScore Base(Minimum) score of the score evaluated by {@code evaluator}. 100 * @param weight Weight value to rearrange the score evaluated by {@code evaluator}. 101 */ 102 public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) { 103 mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight)); 104 } 105 106 public List<Channel> recommendChannels() { 107 return recommendChannels(mDataManager.getChannelRecordCount()); 108 } 109 110 /** 111 * Return the channel list of recommendation up to {@code n} or the number of channels. 112 * During the evaluation, this method updates the channel sort key of recommended channels. 113 * 114 * @param size The number of channels that might be recommended. 115 * @return Top {@code size} channels recommended sorted by score in descending order. If 116 * {@code size} is bigger than the number of channels, the number of results could 117 * be less than {@code size}. 118 */ 119 public List<Channel> recommendChannels(int size) { 120 List<Pair<Channel, Double>> records = new ArrayList<>(); 121 Collection<ChannelRecord> channelRecordList = mDataManager.getChannelRecords(); 122 for (ChannelRecord cr : channelRecordList) { 123 double maxScore = Evaluator.NOT_RECOMMENDED; 124 for (EvaluatorWrapper evaluator : mEvaluators) { 125 double score = evaluator.getScaledEvaluatorScore(cr.getChannel().getId()); 126 if (score > maxScore) { 127 maxScore = score; 128 } 129 } 130 if (!mIncludeRecommendedOnly || maxScore != Evaluator.NOT_RECOMMENDED) { 131 records.add(new Pair<>(cr.getChannel(), maxScore)); 132 } 133 } 134 if (size > records.size()) { 135 size = records.size(); 136 } 137 Collections.sort(records, mChannelScoreComparator); 138 139 List<Channel> results = new ArrayList<>(); 140 141 mChannelSortKey.clear(); 142 String sortKeyFormat = "%0" + String.valueOf(size).length() + "d"; 143 for (int i = 0; i < size; ++i) { 144 // Channel with smaller sort key has higher priority. 145 mChannelSortKey.put(records.get(i).first.getId(), String.format(sortKeyFormat, i)); 146 results.add(records.get(i).first); 147 } 148 return results; 149 } 150 151 /** 152 * Returns the {@link Channel} object for a given channel ID from the channel pool that this 153 * recommendation engine has. 154 * 155 * @param channelId The channel ID to retrieve the {@link Channel} object for. 156 * @return the {@link Channel} object for the given channel ID, {@code null} if such a channel 157 * is not found. 158 */ 159 public Channel getChannel(long channelId) { 160 ChannelRecord record = mDataManager.getChannelRecord(channelId); 161 return record == null ? null : record.getChannel(); 162 } 163 164 /** 165 * Returns the {@link ChannelRecord} object for a given channel ID. 166 * 167 * @param channelId The channel ID to receive the {@link ChannelRecord} object for. 168 * @return the {@link ChannelRecord} object for the given channel ID. 169 */ 170 public ChannelRecord getChannelRecord(long channelId) { 171 return mDataManager.getChannelRecord(channelId); 172 } 173 174 /** 175 * Returns the sort key of a given channel Id. Sort key is determined in 176 * {@link #recommendChannels()} and getChannelSortKey must be called after that. 177 * 178 * If getChannelSortKey was called before evaluating the channels or trying to get sort key 179 * of non-recommended channel, it returns {@link #INVALID_CHANNEL_SORT_KEY}. 180 */ 181 public String getChannelSortKey(long channelId) { 182 String key = mChannelSortKey.get(channelId); 183 return key == null ? INVALID_CHANNEL_SORT_KEY : key; 184 } 185 186 @Override 187 public void onChannelRecordLoaded() { 188 mChannelRecordLoaded = true; 189 mListener.onRecommenderReady(); 190 List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords()); 191 for (EvaluatorWrapper evaluator : mEvaluators) { 192 evaluator.onChannelListChanged(Collections.unmodifiableList(channels)); 193 } 194 } 195 196 @Override 197 public void onNewWatchLog(ChannelRecord channelRecord) { 198 for (EvaluatorWrapper evaluator : mEvaluators) { 199 evaluator.onNewWatchLog(channelRecord); 200 } 201 checkRecommendationChanged(); 202 } 203 204 @Override 205 public void onChannelRecordChanged() { 206 if (mChannelRecordLoaded) { 207 List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords()); 208 for (EvaluatorWrapper evaluator : mEvaluators) { 209 evaluator.onChannelListChanged(Collections.unmodifiableList(channels)); 210 } 211 } 212 checkRecommendationChanged(); 213 } 214 215 private void checkRecommendationChanged() { 216 long currentTimeUtcMillis = System.currentTimeMillis(); 217 if (currentTimeUtcMillis - mLastRecommendationUpdatedTimeUtcMillis 218 < MINIMUM_RECOMMENDATION_UPDATE_PERIOD) { 219 return; 220 } 221 mLastRecommendationUpdatedTimeUtcMillis = currentTimeUtcMillis; 222 List<Channel> recommendedChannels = recommendChannels(); 223 if (!recommendedChannels.equals(mPreviousRecommendedChannels)) { 224 mPreviousRecommendedChannels = recommendedChannels; 225 mListener.onRecommendationChanged(); 226 } 227 } 228 229 @VisibleForTesting 230 void setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs) { 231 mLastRecommendationUpdatedTimeUtcMillis = newUpdatedTimeMs; 232 } 233 234 public static abstract class Evaluator { 235 public static final double NOT_RECOMMENDED = -1.0; 236 private Recommender mRecommender; 237 238 protected Evaluator() {} 239 240 protected void onChannelRecordListChanged(List<ChannelRecord> channelRecords) { 241 } 242 243 /** 244 * This will be called when a new watch log comes into WatchedPrograms table. 245 * 246 * @param channelRecord The channel record corresponds to the new watch log. 247 */ 248 protected void onNewWatchLog(ChannelRecord channelRecord) { 249 } 250 251 /** 252 * The implementation should return the recommendation score for the given channel ID. 253 * The return value should be in the range of [0.0, 1.0] or NOT_RECOMMENDED for denoting 254 * that it gives up to calculate the score for the channel. 255 * 256 * @param channelId The channel ID which will be evaluated by this recommender. 257 * @return The recommendation score 258 */ 259 protected abstract double evaluateChannel(final long channelId); 260 261 protected void setRecommender(Recommender recommender) { 262 mRecommender = recommender; 263 } 264 265 protected Recommender getRecommender() { 266 return mRecommender; 267 } 268 } 269 270 private static class EvaluatorWrapper { 271 private static final double DEFAULT_BASE_SCORE = 0.0; 272 private static final double DEFAULT_WEIGHT = 1.0; 273 274 private final Evaluator mEvaluator; 275 // The minimum score of the Recommender unless it gives up to provide the score. 276 private final double mBaseScore; 277 // The weight of the recommender. The return-value of getScore() will be multiplied by 278 // this value. 279 private final double mWeight; 280 281 public EvaluatorWrapper(Recommender recommender, Evaluator evaluator, 282 double baseScore, double weight) { 283 mEvaluator = evaluator; 284 evaluator.setRecommender(recommender); 285 mBaseScore = baseScore; 286 mWeight = weight; 287 } 288 289 /** 290 * This returns the scaled score for the given channel ID based on the returned value 291 * of evaluateChannel(). 292 * 293 * @param channelId The channel ID which will be evaluated by the recommender. 294 * @return Returns the scaled score (mBaseScore + score * mWeight) when evaluateChannel() is 295 * in the range of [0.0, 1.0]. If evaluateChannel() returns NOT_RECOMMENDED or any 296 * negative numbers, it returns NOT_RECOMMENDED. If calculateScore() returns more 297 * than 1.0, it returns (mBaseScore + mWeight). 298 */ 299 private double getScaledEvaluatorScore(long channelId) { 300 double score = mEvaluator.evaluateChannel(channelId); 301 if (score < 0.0) { 302 if (score != Evaluator.NOT_RECOMMENDED) { 303 Log.w(TAG, "Unexpected score (" + score + ") from the recommender" 304 + mEvaluator); 305 } 306 // If the recommender gives up to calculate the score, return 0.0 307 return Evaluator.NOT_RECOMMENDED; 308 } else if (score > 1.0) { 309 Log.w(TAG, "Unexpected score (" + score + ") from the recommender" 310 + mEvaluator); 311 score = 1.0; 312 } 313 return mBaseScore + score * mWeight; 314 } 315 316 public void onNewWatchLog(ChannelRecord channelRecord) { 317 mEvaluator.onNewWatchLog(channelRecord); 318 } 319 320 public void onChannelListChanged(List<ChannelRecord> channelRecords) { 321 mEvaluator.onChannelRecordListChanged(channelRecords); 322 } 323 } 324 325 public interface Listener { 326 /** 327 * Called after channel record map is loaded. 328 */ 329 void onRecommenderReady(); 330 331 /** 332 * Called when the recommendation changes. 333 */ 334 void onRecommendationChanged(); 335 } 336 } 337