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      */
Recommender(Context context, Listener listener, boolean includeRecommendedOnly)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
Recommender(Listener listener, boolean includeRecommendedOnly, RecommendationDataManager dataManager)72     Recommender(Listener listener, boolean includeRecommendedOnly,
73             RecommendationDataManager dataManager) {
74         mListener = listener;
75         mIncludeRecommendedOnly = includeRecommendedOnly;
76         mDataManager = dataManager;
77     }
78 
isReady()79     public boolean isReady() {
80         return mChannelRecordLoaded;
81     }
82 
release()83     public void release() {
84         mDataManager.release(this);
85     }
86 
registerEvaluator(Evaluator evaluator)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      */
registerEvaluator(Evaluator evaluator, double baseScore, double weight)102     public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) {
103         mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight));
104     }
105 
recommendChannels()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      */
recommendChannels(int size)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      */
getChannel(long channelId)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      */
getChannelRecord(long channelId)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      */
getChannelSortKey(long channelId)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
onChannelRecordLoaded()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
onNewWatchLog(ChannelRecord channelRecord)197     public void onNewWatchLog(ChannelRecord channelRecord) {
198         for (EvaluatorWrapper evaluator : mEvaluators) {
199             evaluator.onNewWatchLog(channelRecord);
200         }
201         checkRecommendationChanged();
202     }
203 
204     @Override
onChannelRecordChanged()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 
checkRecommendationChanged()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
setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs)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 
Evaluator()238         protected Evaluator() {}
239 
onChannelRecordListChanged(List<ChannelRecord> channelRecords)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          */
onNewWatchLog(ChannelRecord channelRecord)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          */
evaluateChannel(final long channelId)259         protected abstract double evaluateChannel(final long channelId);
260 
setRecommender(Recommender recommender)261         protected void setRecommender(Recommender recommender) {
262             mRecommender = recommender;
263         }
264 
getRecommender()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 
EvaluatorWrapper(Recommender recommender, Evaluator evaluator, double baseScore, double weight)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          */
getScaledEvaluatorScore(long channelId)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 
onNewWatchLog(ChannelRecord channelRecord)316         public void onNewWatchLog(ChannelRecord channelRecord) {
317             mEvaluator.onNewWatchLog(channelRecord);
318         }
319 
onChannelListChanged(List<ChannelRecord> channelRecords)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          */
onRecommenderReady()329         void onRecommenderReady();
330 
331         /**
332          * Called when the recommendation changes.
333          */
onRecommendationChanged()334         void onRecommendationChanged();
335     }
336 }
337