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