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