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