• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 import com.android.tv.data.api.Channel;
24 import java.util.ArrayList;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.Comparator;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.concurrent.TimeUnit;
32 
33 public 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      */
Recommender(Context context, Listener listener, boolean includeRecommendedOnly)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
Recommender( Listener listener, boolean includeRecommendedOnly, RecommendationDataManager dataManager)69     Recommender(
70             Listener listener,
71             boolean includeRecommendedOnly,
72             RecommendationDataManager dataManager) {
73         mListener = listener;
74         mIncludeRecommendedOnly = includeRecommendedOnly;
75         mDataManager = dataManager;
76     }
77 
isReady()78     public boolean isReady() {
79         return mChannelRecordLoaded;
80     }
81 
release()82     public void release() {
83         mDataManager.release(this);
84     }
85 
registerEvaluator(Evaluator evaluator)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      */
registerEvaluator(Evaluator evaluator, double baseScore, double weight)101     public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) {
102         mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight));
103     }
104 
recommendChannels()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      */
recommendChannels(int size)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      */
getChannel(long channelId)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      */
getChannelRecord(long channelId)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      */
getChannelSortKey(long channelId)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
onChannelRecordLoaded()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
onNewWatchLog(ChannelRecord channelRecord)196     public void onNewWatchLog(ChannelRecord channelRecord) {
197         for (EvaluatorWrapper evaluator : mEvaluators) {
198             evaluator.onNewWatchLog(channelRecord);
199         }
200         checkRecommendationChanged();
201     }
202 
203     @Override
onChannelRecordChanged()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 
checkRecommendationChanged()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
setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs)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 
Evaluator()237         protected Evaluator() {}
238 
onChannelRecordListChanged(List<ChannelRecord> channelRecords)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          */
onNewWatchLog(ChannelRecord channelRecord)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          */
evaluateChannel(final long channelId)256         protected abstract double evaluateChannel(final long channelId);
257 
setRecommender(Recommender recommender)258         protected void setRecommender(Recommender recommender) {
259             mRecommender = recommender;
260         }
261 
getRecommender()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 
EvaluatorWrapper( Recommender recommender, Evaluator evaluator, double baseScore, double weight)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          */
getScaledEvaluatorScore(long channelId)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 
onNewWatchLog(ChannelRecord channelRecord)313         public void onNewWatchLog(ChannelRecord channelRecord) {
314             mEvaluator.onNewWatchLog(channelRecord);
315         }
316 
onChannelListChanged(List<ChannelRecord> channelRecords)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. */
onRecommenderReady()324         void onRecommenderReady();
325 
326         /** Called when the recommendation changes. */
onRecommendationChanged()327         void onRecommendationChanged();
328     }
329 }
330