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.support.annotation.Nullable;
20 import android.support.annotation.VisibleForTesting;
21 import android.text.TextUtils;
22 
23 import com.android.tv.data.api.Program;
24 
25 import java.text.BreakIterator;
26 import java.util.ArrayList;
27 import java.util.Calendar;
28 import java.util.List;
29 import java.util.concurrent.TimeUnit;
30 
31 public class RoutineWatchEvaluator extends Recommender.Evaluator {
32     // TODO: test and refine constant values in WatchedProgramRecommender in order to
33     // improve the performance of this recommender.
34     private static final double REQUIRED_MIN_SCORE = 0.15;
35     @VisibleForTesting static final double MULTIPLIER_FOR_UNMATCHED_DAY_OF_WEEK = 0.7;
36     private static final double TITLE_MATCH_WEIGHT = 0.5;
37     private static final double TIME_MATCH_WEIGHT = 1 - TITLE_MATCH_WEIGHT;
38     private static final long DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM = TimeUnit.DAYS.toMillis(14);
39     private static final long MAX_DIFF_MS_FOR_OLD_PROGRAM = TimeUnit.DAYS.toMillis(56);
40 
41     @Override
evaluateChannel(long channelId)42     public double evaluateChannel(long channelId) {
43         ChannelRecord cr = getRecommender().getChannelRecord(channelId);
44         if (cr == null) {
45             return NOT_RECOMMENDED;
46         }
47 
48         Program currentProgram = cr.getCurrentProgram();
49         if (currentProgram == null) {
50             return NOT_RECOMMENDED;
51         }
52 
53         WatchedProgram[] watchHistory = cr.getWatchHistory();
54         if (watchHistory.length < 1) {
55             return NOT_RECOMMENDED;
56         }
57 
58         Program watchedProgram = watchHistory[watchHistory.length - 1].getProgram();
59         long startTimeDiffMsWithCurrentProgram =
60                 currentProgram.getStartTimeUtcMillis() - watchedProgram.getStartTimeUtcMillis();
61         if (startTimeDiffMsWithCurrentProgram >= MAX_DIFF_MS_FOR_OLD_PROGRAM) {
62             return NOT_RECOMMENDED;
63         }
64 
65         double maxScore = NOT_RECOMMENDED;
66         long watchedDurationMs = watchHistory[watchHistory.length - 1].getWatchedDurationMs();
67         for (int i = watchHistory.length - 2; i >= 0; --i) {
68             if (watchedProgram.getStartTimeUtcMillis()
69                     == watchHistory[i].getProgram().getStartTimeUtcMillis()) {
70                 watchedDurationMs += watchHistory[i].getWatchedDurationMs();
71             } else {
72                 double score =
73                         calculateRoutineWatchScore(
74                                 currentProgram, watchedProgram, watchedDurationMs);
75                 if (score >= REQUIRED_MIN_SCORE && score > maxScore) {
76                     maxScore = score;
77                 }
78                 watchedProgram = watchHistory[i].getProgram();
79                 watchedDurationMs = watchHistory[i].getWatchedDurationMs();
80                 startTimeDiffMsWithCurrentProgram =
81                         currentProgram.getStartTimeUtcMillis()
82                                 - watchedProgram.getStartTimeUtcMillis();
83                 if (startTimeDiffMsWithCurrentProgram >= MAX_DIFF_MS_FOR_OLD_PROGRAM) {
84                     return maxScore;
85                 }
86             }
87         }
88         double score =
89                 calculateRoutineWatchScore(currentProgram, watchedProgram, watchedDurationMs);
90         if (score >= REQUIRED_MIN_SCORE && score > maxScore) {
91             maxScore = score;
92         }
93         return maxScore;
94     }
95 
calculateRoutineWatchScore( Program currentProgram, Program watchedProgram, long watchedDurationMs)96     private static double calculateRoutineWatchScore(
97             Program currentProgram, Program watchedProgram, long watchedDurationMs) {
98         double timeMatchScore = calculateTimeMatchScore(currentProgram, watchedProgram);
99         double titleMatchScore =
100                 calculateTitleMatchScore(currentProgram.getTitle(), watchedProgram.getTitle());
101         double watchDurationScore = calculateWatchDurationScore(watchedProgram, watchedDurationMs);
102         long diffMs =
103                 currentProgram.getStartTimeUtcMillis() - watchedProgram.getStartTimeUtcMillis();
104         double multiplierForOldProgram =
105                 (diffMs < MAX_DIFF_MS_FOR_OLD_PROGRAM)
106                         ? 1.0
107                                 - (double) Math.max(diffMs - DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM, 0)
108                                         / (MAX_DIFF_MS_FOR_OLD_PROGRAM
109                                                 - DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM)
110                         : 0.0;
111         return (titleMatchScore * TITLE_MATCH_WEIGHT + timeMatchScore * TIME_MATCH_WEIGHT)
112                 * watchDurationScore
113                 * multiplierForOldProgram;
114     }
115 
116     @VisibleForTesting
calculateTitleMatchScore(@ullable String title1, @Nullable String title2)117     static double calculateTitleMatchScore(@Nullable String title1, @Nullable String title2) {
118         if (TextUtils.isEmpty(title1) || TextUtils.isEmpty(title2)) {
119             return 0;
120         }
121         List<String> wordList1 = splitTextToWords(title1);
122         List<String> wordList2 = splitTextToWords(title2);
123         if (wordList1.isEmpty() || wordList2.isEmpty()) {
124             return 0;
125         }
126         int maxMatchedWordSeqLen = calculateMaximumMatchedWordSequenceLength(wordList1, wordList2);
127 
128         // F-measure score
129         double precision = (double) maxMatchedWordSeqLen / wordList1.size();
130         double recall = (double) maxMatchedWordSeqLen / wordList2.size();
131         return 2.0 * precision * recall / (precision + recall);
132     }
133 
134     @VisibleForTesting
calculateMaximumMatchedWordSequenceLength( List<String> toSearchWords, List<String> toMatchWords)135     static int calculateMaximumMatchedWordSequenceLength(
136             List<String> toSearchWords, List<String> toMatchWords) {
137         int[] matchedWordSeqLen = new int[toMatchWords.size()];
138         int maxMatchedWordSeqLen = 0;
139         for (String word : toSearchWords) {
140             for (int j = toMatchWords.size() - 1; j >= 0; --j) {
141                 if (word.equals(toMatchWords.get(j))) {
142                     matchedWordSeqLen[j] = j > 0 ? matchedWordSeqLen[j - 1] + 1 : 1;
143                 } else {
144                     maxMatchedWordSeqLen = Math.max(maxMatchedWordSeqLen, matchedWordSeqLen[j]);
145                     matchedWordSeqLen[j] = 0;
146                 }
147             }
148         }
149         for (int len : matchedWordSeqLen) {
150             maxMatchedWordSeqLen = Math.max(maxMatchedWordSeqLen, len);
151         }
152 
153         return maxMatchedWordSeqLen;
154     }
155 
calculateTimeMatchScore(Program p1, Program p2)156     private static double calculateTimeMatchScore(Program p1, Program p2) {
157         ProgramTime t1 = ProgramTime.createFromProgram(p1);
158         ProgramTime t2 = ProgramTime.createFromProgram(p2);
159 
160         double dupTimeScore = calculateOverlappedIntervalScore(t1, t2);
161 
162         // F-measure score
163         double precision = dupTimeScore / (t1.endTimeOfDayInSec - t1.startTimeOfDayInSec);
164         double recall = dupTimeScore / (t2.endTimeOfDayInSec - t2.startTimeOfDayInSec);
165         return 2.0 * precision * recall / (precision + recall);
166     }
167 
168     @VisibleForTesting
calculateOverlappedIntervalScore(ProgramTime t1, ProgramTime t2)169     static double calculateOverlappedIntervalScore(ProgramTime t1, ProgramTime t2) {
170         if (t1.dayChanged && !t2.dayChanged) {
171             // Swap two values.
172             return calculateOverlappedIntervalScore(t2, t1);
173         }
174 
175         boolean sameDay = false;
176         // Handle cases like (00:00 - 02:00) - (01:00 - 03:00) or (22:00 - 25:00) - (23:00 - 26:00).
177         double score =
178                 Math.max(
179                         0,
180                         Math.min(t1.endTimeOfDayInSec, t2.endTimeOfDayInSec)
181                                 - Math.max(t1.startTimeOfDayInSec, t2.startTimeOfDayInSec));
182         if (score > 0) {
183             sameDay = (t1.weekDay == t2.weekDay);
184         } else if (t1.dayChanged != t2.dayChanged) {
185             // To handle cases like t1 : (00:00 - 01:00) and t2 : (23:00 - 25:00).
186             score =
187                     Math.max(
188                             0,
189                             Math.min(t1.endTimeOfDayInSec, t2.endTimeOfDayInSec - 24 * 60 * 60)
190                                     - t1.startTimeOfDayInSec);
191             // Same day if next day of t2's start day equals to t1's start day. (1 <= weekDay <= 7)
192             sameDay = (t1.weekDay == ((t2.weekDay % 7) + 1));
193         }
194 
195         if (!sameDay) {
196             score *= MULTIPLIER_FOR_UNMATCHED_DAY_OF_WEEK;
197         }
198         return score;
199     }
200 
calculateWatchDurationScore(Program program, long durationMs)201     private static double calculateWatchDurationScore(Program program, long durationMs) {
202         return (double) durationMs
203                 / (program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis());
204     }
205 
206     @VisibleForTesting
getTimeOfDayInSec(Calendar time)207     static int getTimeOfDayInSec(Calendar time) {
208         return time.get(Calendar.HOUR_OF_DAY) * 60 * 60
209                 + time.get(Calendar.MINUTE) * 60
210                 + time.get(Calendar.SECOND);
211     }
212 
213     @VisibleForTesting
splitTextToWords(String text)214     static List<String> splitTextToWords(String text) {
215         List<String> wordList = new ArrayList<>();
216         BreakIterator boundary = BreakIterator.getWordInstance();
217         boundary.setText(text);
218         int start = boundary.first();
219         for (int end = boundary.next();
220                 end != BreakIterator.DONE;
221                 start = end, end = boundary.next()) {
222             String word = text.substring(start, end);
223             if (Character.isLetterOrDigit(word.charAt(0))) {
224                 wordList.add(word);
225             }
226         }
227         return wordList;
228     }
229 
230     @VisibleForTesting
231     static class ProgramTime {
232         final int startTimeOfDayInSec;
233         final int endTimeOfDayInSec;
234         final int weekDay;
235         final boolean dayChanged;
236 
createFromProgram(Program p)237         public static ProgramTime createFromProgram(Program p) {
238             Calendar time = Calendar.getInstance();
239 
240             time.setTimeInMillis(p.getStartTimeUtcMillis());
241             int weekDay = time.get(Calendar.DAY_OF_WEEK);
242             int startTimeOfDayInSec = getTimeOfDayInSec(time);
243 
244             time.setTimeInMillis(p.getEndTimeUtcMillis());
245             boolean dayChanged = (weekDay != time.get(Calendar.DAY_OF_WEEK));
246             // Set maximum program duration time to 12 hours.
247             int endTimeOfDayInSec =
248                     startTimeOfDayInSec
249                             + (int)
250                                             Math.min(
251                                                     p.getEndTimeUtcMillis()
252                                                             - p.getStartTimeUtcMillis(),
253                                                     TimeUnit.HOURS.toMillis(12))
254                                     / 1000;
255 
256             return new ProgramTime(startTimeOfDayInSec, endTimeOfDayInSec, weekDay, dayChanged);
257         }
258 
ProgramTime( int startTimeOfDayInSec, int endTimeOfDayInSec, int weekDay, boolean dayChanged)259         private ProgramTime(
260                 int startTimeOfDayInSec, int endTimeOfDayInSec, int weekDay, boolean dayChanged) {
261             this.startTimeOfDayInSec = startTimeOfDayInSec;
262             this.endTimeOfDayInSec = endTimeOfDayInSec;
263             this.weekDay = weekDay;
264             this.dayChanged = dayChanged;
265         }
266     }
267 }
268