/* * Copyright (C) 2014 The CyanogenMod Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.wm.remusic.recent; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.view.animation.AccelerateInterpolator; import android.view.animation.Interpolator; import com.wm.remusic.provider.MusicDB; import java.util.HashSet; import java.util.Iterator; /** * This database tracks the number of play counts for an individual song. This is used to drive * the top played tracks as well as the playlist images */ public class SongPlayCount { // how many weeks worth of playback to track private static final int NUM_WEEKS = 52; private static SongPlayCount sInstance = null; // interpolator curve applied for measuring the curve private static Interpolator sInterpolator = new AccelerateInterpolator(1.5f); // how high to multiply the interpolation curve private static int INTERPOLATOR_HEIGHT = 50; // how high the base value is. The ratio of the Height to Base is what really matters private static int INTERPOLATOR_BASE = 25; private static int ONE_WEEK_IN_MS = 1000 * 60 * 60 * 24 * 7; private static String WHERE_ID_EQUALS = SongPlayCountColumns.ID + "=?"; private MusicDB mMusicDatabase = null; // number of weeks since epoch time private int mNumberOfWeeksSinceEpoch; // used to track if we've walkd through the db and updated all the rows private boolean mDatabaseUpdated; /** * Constructor of <code>RecentStore</code> * * @param context The {@link Context} to use */ public SongPlayCount(final Context context) { mMusicDatabase = MusicDB.getInstance(context); long msSinceEpoch = System.currentTimeMillis(); mNumberOfWeeksSinceEpoch = (int) (msSinceEpoch / ONE_WEEK_IN_MS); mDatabaseUpdated = false; } /** * @param context The {@link Context} to use * @return A new instance of this class. */ public static final synchronized SongPlayCount getInstance(final Context context) { if (sInstance == null) { sInstance = new SongPlayCount(context.getApplicationContext()); } return sInstance; } /** * Calculates the score of the song given the play counts * * @param playCounts an array of the # of times a song has been played for each week * where playCounts[N] is the # of times it was played N weeks ago * @return the score */ private static float calculateScore(final int[] playCounts) { if (playCounts == null) { return 0; } float score = 0; for (int i = 0; i < Math.min(playCounts.length, NUM_WEEKS); i++) { score += playCounts[i] * getScoreMultiplierForWeek(i); } return score; } /** * Gets the column name for each week # * * @param week number * @return the column name */ private static String getColumnNameForWeek(final int week) { return SongPlayCountColumns.WEEK_PLAY_COUNT + String.valueOf(week); } /** * Gets the score multiplier for each week * * @param week number * @return the multiplier to apply */ private static float getScoreMultiplierForWeek(final int week) { return sInterpolator.getInterpolation(1 - (week / (float) NUM_WEEKS)) * INTERPOLATOR_HEIGHT + INTERPOLATOR_BASE; } /** * For some performance gain, return a static value for the column index for a week * WARNIGN: This function assumes you have selected all columns for it to work * * @param week number * @return column index of that week */ private static int getColumnIndexForWeek(final int week) { // ID, followed by the weeks columns return 1 + week; } public void onCreate(final SQLiteDatabase db) { // create the play count table // WARNING: If you change the order of these columns // please update getColumnIndexForWeek StringBuilder builder = new StringBuilder(); builder.append("CREATE TABLE IF NOT EXISTS "); builder.append(SongPlayCountColumns.NAME); builder.append("("); builder.append(SongPlayCountColumns.ID); builder.append(" INT UNIQUE,"); for (int i = 0; i < NUM_WEEKS; i++) { builder.append(getColumnNameForWeek(i)); builder.append(" INT DEFAULT 0,"); } builder.append(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX); builder.append(" INT NOT NULL,"); builder.append(SongPlayCountColumns.PLAYCOUNTSCORE); builder.append(" REAL DEFAULT 0);"); db.execSQL(builder.toString()); } public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { // No upgrade path needed yet } public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { // If we ever have downgrade, drop the table to be safe db.execSQL("DROP TABLE IF EXISTS " + SongPlayCountColumns.NAME); onCreate(db); } /** * Increases the play count of a song by 1 * * @param songId The song id to increase the play count */ public void bumpSongCount(final long songId) { if (songId < 0) { return; } final SQLiteDatabase database = mMusicDatabase.getWritableDatabase(); updateExistingRow(database, songId, true); } /** * This creates a new entry that indicates a song has been played once as well as its score * * @param database a writeable database * @param songId the id of the track */ private void createNewPlayedEntry(final SQLiteDatabase database, final long songId) { // no row exists, create a new one float newScore = getScoreMultiplierForWeek(0); int newPlayCount = 1; final ContentValues values = new ContentValues(3); values.put(SongPlayCountColumns.ID, songId); values.put(SongPlayCountColumns.PLAYCOUNTSCORE, newScore); values.put(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX, mNumberOfWeeksSinceEpoch); values.put(getColumnNameForWeek(0), newPlayCount); database.insert(SongPlayCountColumns.NAME, null, values); } /** * This function will take a song entry and update it to the latest week and increase the count * for the current week by 1 if necessary * * @param database a writeable database * @param id the id of the track to bump * @param bumpCount whether to bump the current's week play count by 1 and adjust the score */ private void updateExistingRow(final SQLiteDatabase database, final long id, boolean bumpCount) { String stringId = String.valueOf(id); // begin the transaction database.beginTransaction(); // get the cursor of this content inside the transaction final Cursor cursor = database.query(SongPlayCountColumns.NAME, null, WHERE_ID_EQUALS, new String[]{stringId}, null, null, null); // if we have a result if (cursor != null && cursor.moveToFirst()) { // figure how many weeks since we last updated int lastUpdatedIndex = cursor.getColumnIndex(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX); int lastUpdatedWeek = cursor.getInt(lastUpdatedIndex); int weekDiff = mNumberOfWeeksSinceEpoch - lastUpdatedWeek; // if it's more than the number of weeks we track, delete it and create a new entry if (Math.abs(weekDiff) >= NUM_WEEKS) { // this entry needs to be dropped since it is too outdated deleteEntry(database, stringId); if (bumpCount) { createNewPlayedEntry(database, id); } } else if (weekDiff != 0) { // else, shift the weeks int[] playCounts = new int[NUM_WEEKS]; if (weekDiff > 0) { // time is shifted forwards for (int i = 0; i < NUM_WEEKS - weekDiff; i++) { playCounts[i + weekDiff] = cursor.getInt(getColumnIndexForWeek(i)); } } else if (weekDiff < 0) { // time is shifted backwards (by user) - nor typical behavior but we // will still handle it // since weekDiff is -ve, NUM_WEEKS + weekDiff is the real # of weeks we have to // transfer. Then we transfer the old week i - weekDiff to week i // for example if the user shifted back 2 weeks, ie -2, then for 0 to // NUM_WEEKS + (-2) we set the new week i = old week i - (-2) or i+2 for (int i = 0; i < NUM_WEEKS + weekDiff; i++) { playCounts[i] = cursor.getInt(getColumnIndexForWeek(i - weekDiff)); } } // bump the count if (bumpCount) { playCounts[0]++; } float score = calculateScore(playCounts); // if the score is non-existant, then delete it if (score < .01f) { deleteEntry(database, stringId); } else { // create the content values ContentValues values = new ContentValues(NUM_WEEKS + 2); values.put(SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX, mNumberOfWeeksSinceEpoch); values.put(SongPlayCountColumns.PLAYCOUNTSCORE, score); for (int i = 0; i < NUM_WEEKS; i++) { values.put(getColumnNameForWeek(i), playCounts[i]); } // update the entry database.update(SongPlayCountColumns.NAME, values, WHERE_ID_EQUALS, new String[]{stringId}); } } else if (bumpCount) { // else no shifting, just update the scores ContentValues values = new ContentValues(2); // increase the score by a single score amount int scoreIndex = cursor.getColumnIndex(SongPlayCountColumns.PLAYCOUNTSCORE); float score = cursor.getFloat(scoreIndex) + getScoreMultiplierForWeek(0); values.put(SongPlayCountColumns.PLAYCOUNTSCORE, score); // increase the play count by 1 values.put(getColumnNameForWeek(0), cursor.getInt(getColumnIndexForWeek(0)) + 1); // update the entry database.update(SongPlayCountColumns.NAME, values, WHERE_ID_EQUALS, new String[]{stringId}); } cursor.close(); } else if (bumpCount) { // if we have no existing results, create a new one createNewPlayedEntry(database, id); } database.setTransactionSuccessful(); database.endTransaction(); } public void deleteAll() { final SQLiteDatabase database = mMusicDatabase.getWritableDatabase(); database.delete(SongPlayCountColumns.NAME, null, null); } /** * Gets a cursor containing the top songs played. Note this only returns songs that have been * played at least once in the past NUM_WEEKS * * @param numResults number of results to limit by. If <= 0 it returns all results * @return the top tracks */ public Cursor getTopPlayedResults(int numResults) { updateResults(); final SQLiteDatabase database = mMusicDatabase.getReadableDatabase(); return database.query(SongPlayCountColumns.NAME, new String[]{SongPlayCountColumns.ID}, null, null, null, null, SongPlayCountColumns.PLAYCOUNTSCORE + " DESC", (numResults <= 0 ? null : String.valueOf(numResults))); } /** * Given a list of ids, it sorts the results based on the most played results * * @param ids list * @return sorted list - this may be smaller than the list passed in for performance reasons */ public long[] getTopPlayedResultsForList(long[] ids) { final int MAX_NUMBER_SONGS_TO_ANALYZE = 250; if (ids == null || ids.length == 0) { return null; } HashSet<Long> uniqueIds = new HashSet<Long>(ids.length); // create the list of ids to select against StringBuilder selection = new StringBuilder(); selection.append(SongPlayCountColumns.ID); selection.append(" IN ("); // add the first element to handle the separator case for the first element uniqueIds.add(ids[0]); selection.append(ids[0]); for (int i = 1; i < ids.length; i++) { // if the new id doesn't exist if (uniqueIds.add(ids[i])) { // append a separator selection.append(","); // append the id selection.append(ids[i]); // for performance reasons, only look at a certain number of songs // in case their playlist is ridiculously large if (uniqueIds.size() >= MAX_NUMBER_SONGS_TO_ANALYZE) { break; } } } // close out the selection selection.append(")"); long[] sortedList = new long[uniqueIds.size()]; // now query for the songs final SQLiteDatabase database = mMusicDatabase.getReadableDatabase(); Cursor topSongsCursor = null; int idx = 0; try { topSongsCursor = database.query(SongPlayCountColumns.NAME, new String[]{SongPlayCountColumns.ID}, selection.toString(), null, null, null, SongPlayCountColumns.PLAYCOUNTSCORE + " DESC"); if (topSongsCursor != null && topSongsCursor.moveToFirst()) { do { // for each id found, add it to the list and remove it from the unique ids long id = topSongsCursor.getLong(0); sortedList[idx++] = id; uniqueIds.remove(id); } while (topSongsCursor.moveToNext()); } } finally { if (topSongsCursor != null) { topSongsCursor.close(); topSongsCursor = null; } } // append the remaining items - these are songs that haven't been played recently Iterator<Long> iter = uniqueIds.iterator(); while (iter.hasNext()) { sortedList[idx++] = iter.next(); } return sortedList; } /** * This updates all the results for the getTopPlayedResults so that we can get an * accurate list of the top played results */ private synchronized void updateResults() { if (mDatabaseUpdated) { return; } final SQLiteDatabase database = mMusicDatabase.getWritableDatabase(); database.beginTransaction(); int oldestWeekWeCareAbout = mNumberOfWeeksSinceEpoch - NUM_WEEKS + 1; // delete rows we don't care about anymore database.delete(SongPlayCountColumns.NAME, SongPlayCountColumns.LAST_UPDATED_WEEK_INDEX + " < " + oldestWeekWeCareAbout, null); // get the remaining rows Cursor cursor = database.query(SongPlayCountColumns.NAME, new String[]{SongPlayCountColumns.ID}, null, null, null, null, null); if (cursor != null && cursor.moveToFirst()) { // for each row, update it do { updateExistingRow(database, cursor.getLong(0), false); } while (cursor.moveToNext()); cursor.close(); cursor = null; } mDatabaseUpdated = true; database.setTransactionSuccessful(); database.endTransaction(); } /** * @param songId The song Id to remove. */ public void removeItem(final long songId) { final SQLiteDatabase database = mMusicDatabase.getWritableDatabase(); deleteEntry(database, String.valueOf(songId)); } /** * Deletes the entry * * @param database database to use * @param stringId id to delete */ private void deleteEntry(final SQLiteDatabase database, final String stringId) { database.delete(SongPlayCountColumns.NAME, WHERE_ID_EQUALS, new String[]{stringId}); } public interface SongPlayCountColumns { /* Table name */ String NAME = "songplaycount"; /* Song IDs column */ String ID = "songid"; /* Week Play Count */ String WEEK_PLAY_COUNT = "week"; /* Weeks since Epoch */ String LAST_UPDATED_WEEK_INDEX = "weekindex"; /* Play count */ String PLAYCOUNTSCORE = "playcountscore"; } }