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