2 * Copyright (C) 2014 The CyanogenMod Project
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package org.lineageos.eleven.provider;
19 import android.content.ContentValues;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.database.sqlite.SQLiteDatabase;
23 import android.view.animation.AccelerateInterpolator;
24 import android.view.animation.Interpolator;
26 import java.util.HashSet;
27 import java.util.Iterator;
30 * This database tracks the number of play counts for an individual song. This is used to drive
31 * the top played tracks as well as the playlist images
33 public class SongPlayCount {
34 private static SongPlayCount sInstance = null;
36 private MusicDB mMusicDatabase = null;
38 // interpolator curve applied for measuring the curve
39 private static Interpolator sInterpolator = new AccelerateInterpolator(1.5f);
41 // how many weeks worth of playback to track
42 private static final int NUM_WEEKS = 52;
44 // how high to multiply the interpolation curve
45 private static int INTERPOLATOR_HEIGHT = 50;
47 // how high the base value is. The ratio of the Height to Base is what really matters
48 private static int INTERPOLATOR_BASE = 25;
50 private static int ONE_WEEK_IN_MS = 1000 * 60 * 60 * 24 * 7;
52 private static String WHERE_ID_EQUALS = SongPlayCountColumns.ID + "=?";
54 // number of weeks since epoch time
55 private int mNumberOfWeeksSinceEpoch;
57 // used to track if we've walkd through the db and updated all the rows
58 private boolean mDatabaseUpdated;
61 * Constructor of <code>RecentStore</code>
63 * @param context The {@link android.content.Context} to use
65 public SongPlayCount(final Context context) {
66 mMusicDatabase = MusicDB.getInstance(context);
68 long msSinceEpoch = System.currentTimeMillis();
69 mNumberOfWeeksSinceEpoch = (int)(msSinceEpoch / ONE_WEEK_IN_MS);
71 mDatabaseUpdated = false;
74 public void onCreate(final SQLiteDatabase db) {
75 // create the play count table
76 // WARNING: If you change the order of these columns
77 // please update getColumnIndexForWeek
78 StringBuilder builder = new StringBuilder();
79 builder.append("CREATE TABLE IF NOT EXISTS ");
80 builder.append(SongPlayCountColumns.NAME);
82 builder.append(SongPlayCountColumns.ID);
83 builder.append(" INT UNIQUE,");
85 for (int i = 0; i < NUM_WEEKS; i++) {
86 builder.append(getColumnNameForWeek(i));
87 builder.append(" INT DEFAULT 0,");
90 builder.append(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX);
91 builder.append(" INT NOT NULL,");
93 builder.append(SongPlayCountColumns.PLAYCOUNTSCORE);
94 builder.append(" REAL DEFAULT 0);");
96 db.execSQL(builder.toString());
99 public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
100 // No upgrade path needed yet
103 public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
104 // If we ever have downgrade, drop the table to be safe
105 db.execSQL("DROP TABLE IF EXISTS " + SongPlayCountColumns.NAME);
110 * @param context The {@link android.content.Context} to use
111 * @return A new instance of this class.
113 public static final synchronized SongPlayCount getInstance(final Context context) {
114 if (sInstance == null) {
115 sInstance = new SongPlayCount(context.getApplicationContext());
121 * Increases the play count of a song by 1
122 * @param songId The song id to increase the play count
124 public void bumpSongCount(final long songId) {
129 final SQLiteDatabase database = mMusicDatabase.getWritableDatabase();
130 updateExistingRow(database, songId, true);
134 * This creates a new entry that indicates a song has been played once as well as its score
135 * @param database a writeable database
136 * @param songId the id of the track
138 private void createNewPlayedEntry(final SQLiteDatabase database, final long songId) {
139 // no row exists, create a new one
140 float newScore = getScoreMultiplierForWeek(0);
141 int newPlayCount = 1;
143 final ContentValues values = new ContentValues(3);
144 values.put(SongPlayCountColumns.ID, songId);
145 values.put(SongPlayCountColumns.PLAYCOUNTSCORE, newScore);
146 values.put(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX, mNumberOfWeeksSinceEpoch);
147 values.put(getColumnNameForWeek(0), newPlayCount);
149 database.insert(SongPlayCountColumns.NAME, null, values);
153 * This function will take a song entry and update it to the latest week and increase the count
154 * for the current week by 1 if necessary
155 * @param database a writeable database
156 * @param id the id of the track to bump
157 * @param bumpCount whether to bump the current's week play count by 1 and adjust the score
159 private void updateExistingRow(final SQLiteDatabase database, final long id, boolean bumpCount) {
160 String stringId = String.valueOf(id);
162 // begin the transaction
163 database.beginTransaction();
165 // get the cursor of this content inside the transaction
166 final Cursor cursor = database.query(SongPlayCountColumns.NAME, null, WHERE_ID_EQUALS,
167 new String[] { stringId }, null, null, null);
169 // if we have a result
170 if (cursor != null && cursor.moveToFirst()) {
171 // figure how many weeks since we last updated
172 int lastUpdatedIndex = cursor.getColumnIndex(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX);
173 int lastUpdatedWeek = cursor.getInt(lastUpdatedIndex);
174 int weekDiff = mNumberOfWeeksSinceEpoch - lastUpdatedWeek;
176 // if it's more than the number of weeks we track, delete it and create a new entry
177 if (Math.abs(weekDiff) >= NUM_WEEKS) {
178 // this entry needs to be dropped since it is too outdated
179 deleteEntry(database, stringId);
181 createNewPlayedEntry(database, id);
183 } else if (weekDiff != 0) {
184 // else, shift the weeks
185 int[] playCounts = new int[NUM_WEEKS];
188 // time is shifted forwards
189 for (int i = 0; i < NUM_WEEKS - weekDiff; i++) {
190 playCounts[i + weekDiff] = cursor.getInt(getColumnIndexForWeek(i));
192 } else if (weekDiff < 0) {
193 // time is shifted backwards (by user) - nor typical behavior but we
194 // will still handle it
196 // since weekDiff is -ve, NUM_WEEKS + weekDiff is the real # of weeks we have to
197 // transfer. Then we transfer the old week i - weekDiff to week i
198 // for example if the user shifted back 2 weeks, ie -2, then for 0 to
199 // NUM_WEEKS + (-2) we set the new week i = old week i - (-2) or i+2
200 for (int i = 0; i < NUM_WEEKS + weekDiff; i++) {
201 playCounts[i] = cursor.getInt(getColumnIndexForWeek(i - weekDiff));
210 float score = calculateScore(playCounts);
212 // if the score is non-existant, then delete it
214 deleteEntry(database, stringId);
216 // create the content values
217 ContentValues values = new ContentValues(NUM_WEEKS + 2);
218 values.put(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX, mNumberOfWeeksSinceEpoch);
219 values.put(SongPlayCountColumns.PLAYCOUNTSCORE, score);
221 for (int i = 0; i < NUM_WEEKS; i++) {
222 values.put(getColumnNameForWeek(i), playCounts[i]);
226 database.update(SongPlayCountColumns.NAME, values, WHERE_ID_EQUALS,
227 new String[]{stringId});
229 } else if (bumpCount) {
230 // else no shifting, just update the scores
231 ContentValues values = new ContentValues(2);
233 // increase the score by a single score amount
234 int scoreIndex = cursor.getColumnIndex(SongPlayCountColumns.PLAYCOUNTSCORE);
235 float score = cursor.getFloat(scoreIndex) + getScoreMultiplierForWeek(0);
236 values.put(SongPlayCountColumns.PLAYCOUNTSCORE, score);
238 // increase the play count by 1
239 values.put(getColumnNameForWeek(0), cursor.getInt(getColumnIndexForWeek(0)) + 1);
242 database.update(SongPlayCountColumns.NAME, values, WHERE_ID_EQUALS,
243 new String[]{stringId});
247 } else if (bumpCount) {
248 // if we have no existing results, create a new one
249 createNewPlayedEntry(database, id);
252 database.setTransactionSuccessful();
253 database.endTransaction();
256 public void deleteAll() {
257 final SQLiteDatabase database = mMusicDatabase.getWritableDatabase();
258 database.delete(SongPlayCountColumns.NAME, null, null);
262 * Gets a cursor containing the top songs played. Note this only returns songs that have been
263 * played at least once in the past NUM_WEEKS
264 * @param numResults number of results to limit by. If <= 0 it returns all results
265 * @return the top tracks
267 public Cursor getTopPlayedResults(int numResults) {
270 final SQLiteDatabase database = mMusicDatabase.getReadableDatabase();
271 return database.query(SongPlayCountColumns.NAME, new String[] { SongPlayCountColumns.ID },
272 null, null, null, null, SongPlayCountColumns.PLAYCOUNTSCORE + " DESC",
273 (numResults <= 0 ? null : String.valueOf(numResults)));
277 * Given a list of ids, it sorts the results based on the most played results
279 * @return sorted list - this may be smaller than the list passed in for performance reasons
281 public long[] getTopPlayedResultsForList(long[] ids) {
282 final int MAX_NUMBER_SONGS_TO_ANALYZE = 250;
284 if (ids == null || ids.length == 0) {
288 HashSet<Long> uniqueIds = new HashSet<>(ids.length);
290 // create the list of ids to select against
291 StringBuilder selection = new StringBuilder();
292 selection.append(SongPlayCountColumns.ID);
293 selection.append(" IN (");
295 // add the first element to handle the separator case for the first element
296 uniqueIds.add(ids[0]);
297 selection.append(ids[0]);
299 for (int i = 1; i < ids.length; i++) {
300 // if the new id doesn't exist
301 if (uniqueIds.add(ids[i])) {
302 // append a separator
303 selection.append(",");
306 selection.append(ids[i]);
308 // for performance reasons, only look at a certain number of songs
309 // in case their playlist is ridiculously large
310 if (uniqueIds.size() >= MAX_NUMBER_SONGS_TO_ANALYZE) {
316 // close out the selection
317 selection.append(")");
319 long[] sortedList = new long[uniqueIds.size()];
321 // now query for the songs
322 final SQLiteDatabase database = mMusicDatabase.getReadableDatabase();
323 Cursor topSongsCursor = null;
327 topSongsCursor = database.query(SongPlayCountColumns.NAME,
328 new String[]{ SongPlayCountColumns.ID }, selection.toString(), null, null,
329 null, SongPlayCountColumns.PLAYCOUNTSCORE + " DESC");
331 if (topSongsCursor != null && topSongsCursor.moveToFirst()) {
333 // for each id found, add it to the list and remove it from the unique ids
334 long id = topSongsCursor.getLong(0);
335 sortedList[idx++] = id;
336 uniqueIds.remove(id);
337 } while (topSongsCursor.moveToNext());
340 if (topSongsCursor != null) {
341 topSongsCursor.close();
342 topSongsCursor = null;
346 // append the remaining items - these are songs that haven't been played recently
347 for (final Long uniqueId : uniqueIds) {
348 sortedList[idx++] = uniqueId;
355 * This updates all the results for the getTopPlayedResults so that we can get an
356 * accurate list of the top played results
358 private synchronized void updateResults() {
359 if (mDatabaseUpdated) {
363 final SQLiteDatabase database = mMusicDatabase.getWritableDatabase();
365 database.beginTransaction();
367 int oldestWeekWeCareAbout = mNumberOfWeeksSinceEpoch - NUM_WEEKS + 1;
368 // delete rows we don't care about anymore
369 database.delete(SongPlayCountColumns.NAME, SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX
370 + " < " + oldestWeekWeCareAbout, null);
372 // get the remaining rows
373 Cursor cursor = database.query(SongPlayCountColumns.NAME,
374 new String[] { SongPlayCountColumns.ID },
375 null, null, null, null, null);
377 if (cursor != null && cursor.moveToFirst()) {
378 // for each row, update it
380 updateExistingRow(database, cursor.getLong(0), false);
381 } while (cursor.moveToNext());
387 mDatabaseUpdated = true;
388 database.setTransactionSuccessful();
389 database.endTransaction();
393 * @param songId The song Id to remove.
395 public void removeItem(final long songId) {
396 final SQLiteDatabase database = mMusicDatabase.getWritableDatabase();
397 deleteEntry(database, String.valueOf(songId));
402 * @param database database to use
403 * @param stringId id to delete
405 private void deleteEntry(final SQLiteDatabase database, final String stringId) {
406 database.delete(SongPlayCountColumns.NAME, WHERE_ID_EQUALS, new String[]{stringId});
410 * Calculates the score of the song given the play counts
411 * @param playCounts an array of the # of times a song has been played for each week
412 * where playCounts[N] is the # of times it was played N weeks ago
415 private static float calculateScore(final int[] playCounts) {
416 if (playCounts == null) {
421 for (int i = 0; i < Math.min(playCounts.length, NUM_WEEKS); i++) {
422 score += playCounts[i] * getScoreMultiplierForWeek(i);
429 * Gets the column name for each week #
431 * @return the column name
433 private static String getColumnNameForWeek(final int week) {
434 return SongPlayCountColumns.WEEK_PLAY_COUNT + String.valueOf(week);
438 * Gets the score multiplier for each week
440 * @return the multiplier to apply
442 private static float getScoreMultiplierForWeek(final int week) {
443 return sInterpolator.getInterpolation(1 - (week / (float)NUM_WEEKS)) * INTERPOLATOR_HEIGHT
448 * For some performance gain, return a static value for the column index for a week
449 * WARNIGN: This function assumes you have selected all columns for it to work
451 * @return column index of that week
453 private static int getColumnIndexForWeek(final int week) {
454 // ID, followed by the weeks columns
458 public interface SongPlayCountColumns {
461 public static final String NAME = "songplaycount";
463 /* Song IDs column */
464 public static final String ID = "songid";
466 /* Week Play Count */
467 public static final String WEEK_PLAY_COUNT = "week";
469 /* Weeks since Epoch */
470 public static final String LAST_UPDATED_WEEK_INDEX = "weekindex";
473 public static final String PLAYCOUNTSCORE = "playcountscore";