588 lines
16 KiB
Java
588 lines
16 KiB
Java
/*
|
|
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package ch.blinkenlights.android.medialibrary;
|
|
|
|
import android.content.Context;
|
|
import android.content.ContentValues;
|
|
import android.database.Cursor;
|
|
import android.database.ContentObserver;
|
|
import android.provider.MediaStore;
|
|
import android.os.Environment;
|
|
|
|
import java.util.ArrayList;
|
|
import java.io.File;
|
|
|
|
public class MediaLibrary {
|
|
|
|
public static final String TABLE_SONGS = "songs";
|
|
public static final String TABLE_ALBUMS = "albums";
|
|
public static final String TABLE_CONTRIBUTORS = "contributors";
|
|
public static final String TABLE_CONTRIBUTORS_SONGS = "contributors_songs";
|
|
public static final String TABLE_GENRES = "genres";
|
|
public static final String TABLE_GENRES_SONGS = "genres_songs";
|
|
public static final String TABLE_PLAYLISTS = "playlists";
|
|
public static final String TABLE_PLAYLISTS_SONGS = "playlists_songs";
|
|
public static final String VIEW_ARTISTS = "_artists";
|
|
public static final String VIEW_ALBUMS_ARTISTS = "_albums_artists";
|
|
public static final String VIEW_SONGS_ALBUMS_ARTISTS = "_songs_albums_artists";
|
|
public static final String VIEW_PLAYLIST_SONGS = "_playlists_songs";
|
|
|
|
public static final int ROLE_ARTIST = 0;
|
|
public static final int ROLE_COMPOSER = 1;
|
|
|
|
/**
|
|
* Our static backend instance
|
|
*/
|
|
private static MediaLibraryBackend sBackend;
|
|
/**
|
|
* An instance to the created scanner thread during our own creation
|
|
*/
|
|
private static MediaScanner sScanner;
|
|
/**
|
|
* The observer to call-back during database changes
|
|
*/
|
|
private static ContentObserver sContentObserver;
|
|
/**
|
|
* The lock we are using during object creation
|
|
*/
|
|
private static final Object[] sWait = new Object[0];
|
|
|
|
private static MediaLibraryBackend getBackend(Context context) {
|
|
if (sBackend == null) {
|
|
// -> unlikely
|
|
synchronized(sWait) {
|
|
if (sBackend == null) {
|
|
sBackend = new MediaLibraryBackend(context);
|
|
sScanner = new MediaScanner(context, sBackend);
|
|
sScanner.startQuickScan(50);
|
|
}
|
|
}
|
|
}
|
|
return sBackend;
|
|
}
|
|
|
|
/**
|
|
* Triggers a rescan of the library
|
|
*
|
|
* @param context the context to use
|
|
* @param forceFull starts a full / slow scan if true
|
|
* @param drop drop the existing library if true
|
|
*/
|
|
public static void scanLibrary(Context context, boolean forceFull, boolean drop) {
|
|
MediaLibraryBackend backend = getBackend(context); // also initialized sScanner
|
|
if (drop) {
|
|
sScanner.flushDatabase();
|
|
// fixme: should clean orphaned AFTER scan finished
|
|
}
|
|
|
|
if (forceFull) {
|
|
sScanner.startFullScan();
|
|
} else {
|
|
sScanner.startNormalScan();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whacky function to get the current scan progress
|
|
*
|
|
* @param context the context to use
|
|
* @return a description of the progress, null if no scan is running
|
|
*/
|
|
public static String describeScanProgress(Context context) {
|
|
MediaLibraryBackend backend = getBackend(context); // also initialized sScanner
|
|
MediaScanner.MediaScanPlan.Statistics stats = sScanner.getScanStatistics();
|
|
String msg = null;
|
|
if (stats.lastFile != null)
|
|
msg = stats.lastFile+" ("+stats.changed+" / "+stats.seen+")";
|
|
return msg;
|
|
}
|
|
|
|
|
|
/**
|
|
* Registers a new content observer for the media library
|
|
*
|
|
* @param observer the content observer we are going to call on changes
|
|
*/
|
|
public static void registerContentObserver(ContentObserver observer) {
|
|
if (sContentObserver == null) {
|
|
sContentObserver = observer;
|
|
} else {
|
|
throw new IllegalStateException("ContentObserver was already registered");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Broadcasts a change to the observer, which will queue and dispatch
|
|
* the event to any registered observer
|
|
*/
|
|
static void notifyObserver() {
|
|
if (sContentObserver != null)
|
|
sContentObserver.onChange(true);
|
|
}
|
|
|
|
/**
|
|
* Perform a media query on the database, returns a cursor
|
|
*
|
|
* @param context the context to use
|
|
* @param table the table to query, one of MediaLibrary.TABLE_*
|
|
* @param projection the columns to returns in this query
|
|
* @param selection the selection (WHERE) to use
|
|
* @param selectionArgs arguments for the selection
|
|
* @param orderBy how the result should be sorted
|
|
*/
|
|
public static Cursor queryLibrary(Context context, String table, String[] projection, String selection, String[] selectionArgs, String orderBy) {
|
|
return getBackend(context).query(false, table, projection, selection, selectionArgs, null, null, orderBy, null);
|
|
}
|
|
|
|
/**
|
|
* Removes a single song from the database
|
|
*
|
|
* @param context the context to use
|
|
* @param id the song id to delete
|
|
* @return the number of affected rows
|
|
*/
|
|
public static int removeSong(Context context, long id) {
|
|
int rows = getBackend(context).delete(TABLE_SONGS, SongColumns._ID+"="+id, null);
|
|
|
|
if (rows > 0) {
|
|
getBackend(context).cleanOrphanedEntries(true);
|
|
notifyObserver();
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Updates the play or skipcount of a song
|
|
*
|
|
* @param context the context to use
|
|
* @param id the song id to update
|
|
*/
|
|
public static void updateSongPlayCounts(Context context, long id, boolean played) {
|
|
final String column = played ? MediaLibrary.SongColumns.PLAYCOUNT : MediaLibrary.SongColumns.SKIPCOUNT;
|
|
String selection = MediaLibrary.SongColumns._ID+"="+id;
|
|
getBackend(context).execSQL("UPDATE "+MediaLibrary.TABLE_SONGS+" SET "+column+"="+column+"+1 WHERE "+selection);
|
|
}
|
|
|
|
/**
|
|
* Creates a new empty playlist
|
|
*
|
|
* @param context the context to use
|
|
* @param name the name of the new playlist
|
|
* @return long the id of the created playlist, -1 on error
|
|
*/
|
|
public static long createPlaylist(Context context, String name) {
|
|
ContentValues v = new ContentValues();
|
|
v.put(MediaLibrary.PlaylistColumns._ID, hash63(name));
|
|
v.put(MediaLibrary.PlaylistColumns.NAME, name);
|
|
long id = getBackend(context).insert(MediaLibrary.TABLE_PLAYLISTS, null, v);
|
|
|
|
if (id != -1)
|
|
notifyObserver();
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Deletes a playlist and all of its child elements
|
|
*
|
|
* @param context the context to use
|
|
* @param id the playlist id to delete
|
|
* @return boolean true if the playlist was deleted
|
|
*/
|
|
public static boolean removePlaylist(Context context, long id) {
|
|
// first, wipe all songs
|
|
removeFromPlaylist(context, MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+id, null);
|
|
int rows = getBackend(context).delete(MediaLibrary.TABLE_PLAYLISTS, MediaLibrary.PlaylistColumns._ID+"="+id, null);
|
|
boolean removed = (rows > 0);
|
|
|
|
if (removed)
|
|
notifyObserver();
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Adds a batch of songs to a playlist
|
|
*
|
|
* @param context the context to use
|
|
* @param playlistId the id of the playlist parent
|
|
* @param ids an array list with the song ids to insert
|
|
* @return the number of added items
|
|
*/
|
|
public static int addToPlaylist(Context context, long playlistId, ArrayList<Long> ids) {
|
|
long pos = 0;
|
|
// First we need to get the position of the last item
|
|
String[] projection = { MediaLibrary.PlaylistSongColumns.POSITION };
|
|
String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId;
|
|
String order = MediaLibrary.PlaylistSongColumns.POSITION+" DESC";
|
|
Cursor cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection, null, order);
|
|
if (cursor.moveToFirst())
|
|
pos = cursor.getLong(0) + 1;
|
|
cursor.close();
|
|
|
|
ArrayList<ContentValues> bulk = new ArrayList<>();
|
|
for (Long id : ids) {
|
|
if (getBackend(context).getSongMtime(id) == 0)
|
|
continue;
|
|
|
|
ContentValues v = new ContentValues();
|
|
v.put(MediaLibrary.PlaylistSongColumns.PLAYLIST_ID, playlistId);
|
|
v.put(MediaLibrary.PlaylistSongColumns.SONG_ID, id);
|
|
v.put(MediaLibrary.PlaylistSongColumns.POSITION, pos);
|
|
bulk.add(v);
|
|
pos++;
|
|
}
|
|
int rows = getBackend(context).bulkInsert(MediaLibrary.TABLE_PLAYLISTS_SONGS, null, bulk);
|
|
|
|
if (rows > 0)
|
|
notifyObserver();
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Removes a set of items from a playlist
|
|
*
|
|
* @param context the context to use
|
|
* @param selection the selection for the items to drop
|
|
* @param selectionArgs arguments for `selection'
|
|
* @return the number of deleted rows, -1 on error
|
|
*/
|
|
public static int removeFromPlaylist(Context context, String selection, String[] selectionArgs) {
|
|
int rows = getBackend(context).delete(MediaLibrary.TABLE_PLAYLISTS_SONGS, selection, selectionArgs);
|
|
|
|
if (rows > 0)
|
|
notifyObserver();
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Renames an existing playlist
|
|
*
|
|
* @param context the context to use
|
|
* @param playlistId the id of the playlist to rename
|
|
* @param newName the new name of the playlist
|
|
* @return the id of the new playlist, -1 on error
|
|
*/
|
|
public static long renamePlaylist(Context context, long playlistId, String newName) {
|
|
long newId = createPlaylist(context, newName);
|
|
if (newId >= 0) {
|
|
String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId;
|
|
ContentValues v = new ContentValues();
|
|
v.put(MediaLibrary.PlaylistSongColumns.PLAYLIST_ID, newId);
|
|
getBackend(context).update(MediaLibrary.TABLE_PLAYLISTS_SONGS, v, selection, null);
|
|
removePlaylist(context, playlistId);
|
|
}
|
|
|
|
if (newId != -1)
|
|
notifyObserver();
|
|
return newId;
|
|
}
|
|
|
|
/**
|
|
* Moves an item in a playlist. Note: both items should be in the
|
|
* same playlist - 'fun things' will happen otherwise.
|
|
*
|
|
* @param context the context to use
|
|
* @param from the _id of the 'dragged' element
|
|
* @param to the _id of the 'repressed' element
|
|
*/
|
|
public static void movePlaylistItem(Context context, long from, long to) {
|
|
long fromPos, toPos, playlistId;
|
|
|
|
String[] projection = { MediaLibrary.PlaylistSongColumns.POSITION, MediaLibrary.PlaylistSongColumns.PLAYLIST_ID };
|
|
String selection = MediaLibrary.PlaylistSongColumns._ID+"=";
|
|
|
|
// Get playlist id and position of the 'from' item
|
|
Cursor cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection+Long.toString(from), null, null);
|
|
cursor.moveToFirst();
|
|
fromPos = cursor.getLong(0);
|
|
playlistId = cursor.getLong(1);
|
|
cursor.close();
|
|
|
|
// Get position of the target item
|
|
cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection+Long.toString(to), null, null);
|
|
cursor.moveToFirst();
|
|
toPos = cursor.getLong(0);
|
|
cursor.close();
|
|
|
|
// Moving down -> We actually want to be below the target
|
|
if (toPos > fromPos)
|
|
toPos++;
|
|
|
|
// shift all rows +1
|
|
String setArg = MediaLibrary.PlaylistSongColumns.POSITION+"="+MediaLibrary.PlaylistSongColumns.POSITION+"+1";
|
|
selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId+" AND "+MediaLibrary.PlaylistSongColumns.POSITION+" >= "+toPos;
|
|
getBackend(context).execSQL("UPDATE "+MediaLibrary.TABLE_PLAYLISTS_SONGS+" SET "+setArg+" WHERE "+selection);
|
|
|
|
ContentValues v = new ContentValues();
|
|
v.put(MediaLibrary.PlaylistSongColumns.POSITION, toPos);
|
|
selection = MediaLibrary.PlaylistSongColumns._ID+"="+from;
|
|
getBackend(context).update(MediaLibrary.TABLE_PLAYLISTS_SONGS, v, selection, null);
|
|
|
|
notifyObserver();
|
|
}
|
|
|
|
/**
|
|
* Returns the number of songs in the music library
|
|
*
|
|
* @param context the context to use
|
|
* @return the number of songs
|
|
*/
|
|
public static int getLibrarySize(Context context) {
|
|
int count = 0;
|
|
Cursor cursor = queryLibrary(context, TABLE_SONGS, new String[]{"count(*)"}, null, null, null);
|
|
if (cursor.moveToFirst())
|
|
count = cursor.getInt(0);
|
|
cursor.close();
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Returns the 'key' of given string used for sorting and searching
|
|
*
|
|
* @param name the string to convert
|
|
* @return the the key of given name
|
|
*/
|
|
public static String keyFor(String name) {
|
|
return MediaStore.Audio.keyFor(name);
|
|
}
|
|
|
|
/**
|
|
* Simple 63 bit hash function for strings
|
|
*
|
|
* @param str the string to hash
|
|
* @return a positive long
|
|
*/
|
|
public static long hash63(String str) {
|
|
if (str == null)
|
|
return 0;
|
|
|
|
long hash = 0;
|
|
int len = str.length();
|
|
for (int i = 0; i < len ; i++) {
|
|
hash = 31*hash + str.charAt(i);
|
|
}
|
|
return (hash < 0 ? hash*-1 : hash);
|
|
}
|
|
|
|
/**
|
|
* Returns the guessed media paths for this device
|
|
*
|
|
* @return array with guessed directories
|
|
*/
|
|
public static File[] discoverMediaPaths() {
|
|
ArrayList<File> scanTargets = new ArrayList<>();
|
|
|
|
// this should always exist
|
|
scanTargets.add(Environment.getExternalStorageDirectory());
|
|
|
|
// this *may* exist
|
|
File sdCard = new File("/storage/sdcard1");
|
|
if (sdCard.isDirectory())
|
|
scanTargets.add(sdCard);
|
|
|
|
return scanTargets.toArray(new File[scanTargets.size()]);
|
|
}
|
|
|
|
// Columns of Song entries
|
|
public interface SongColumns {
|
|
/**
|
|
* The id of this song in the database
|
|
*/
|
|
String _ID = "_id";
|
|
/**
|
|
* The title of this song
|
|
*/
|
|
String TITLE = "title";
|
|
/**
|
|
* The sortable title of this song
|
|
*/
|
|
String TITLE_SORT = "title_sort";
|
|
/**
|
|
* The position in the album of this song
|
|
*/
|
|
String SONG_NUMBER = "song_num";
|
|
/**
|
|
* The album where this song belongs to
|
|
*/
|
|
String ALBUM_ID = "album_id";
|
|
/**
|
|
* The year of this song
|
|
*/
|
|
String YEAR = "year";
|
|
/**
|
|
* How often the song was played
|
|
*/
|
|
String PLAYCOUNT = "playcount";
|
|
/**
|
|
* How often the song was skipped
|
|
*/
|
|
String SKIPCOUNT = "skipcount";
|
|
/**
|
|
* The duration of this song
|
|
*/
|
|
String DURATION = "duration";
|
|
/**
|
|
* The path to the music file
|
|
*/
|
|
String PATH = "path";
|
|
/**
|
|
* The mtime of this item
|
|
*/
|
|
String MTIME = "mtime";
|
|
}
|
|
|
|
// Columns of Album entries
|
|
public interface AlbumColumns {
|
|
/**
|
|
* The id of this album in the database
|
|
*/
|
|
String _ID = SongColumns._ID;
|
|
/**
|
|
* The title of this album
|
|
*/
|
|
String ALBUM = "album";
|
|
/**
|
|
* The sortable title of this album
|
|
*/
|
|
String ALBUM_SORT = "album_sort";
|
|
/**
|
|
* The disc number of this album
|
|
*/
|
|
String DISC_NUMBER = "disc_num";
|
|
/**
|
|
* The primary contributor / artist reference for this album
|
|
*/
|
|
String PRIMARY_ARTIST_ID = "primary_artist_id";
|
|
/**
|
|
* The year of this album
|
|
*/
|
|
String PRIMARY_ALBUM_YEAR = "primary_album_year";
|
|
/**
|
|
* The mtime of this item
|
|
*/
|
|
String MTIME = "mtime";
|
|
}
|
|
|
|
// Columns of Contributors entries
|
|
public interface ContributorColumns {
|
|
/**
|
|
* The id of this contributor
|
|
*/
|
|
String _ID = SongColumns._ID;
|
|
/**
|
|
* The name of this contributor
|
|
*/
|
|
String _CONTRIBUTOR = "_contributor";
|
|
/**
|
|
* The sortable title of this contributor
|
|
*/
|
|
String _CONTRIBUTOR_SORT = "_contributor_sort";
|
|
/**
|
|
* The mtime of this item
|
|
*/
|
|
String MTIME = "mtime";
|
|
/**
|
|
* ONLY IN VIEWS - the artist
|
|
*/
|
|
String ARTIST = "artist";
|
|
/**
|
|
* ONLY IN VIEWS - the artist_sort key
|
|
*/
|
|
String ARTIST_SORT = "artist_sort";
|
|
/**
|
|
* ONLY IN VIEWS - the artist id
|
|
*/
|
|
String ARTIST_ID = "artist_id";
|
|
}
|
|
|
|
// Songs <-> Contributor mapping
|
|
public interface ContributorSongColumns {
|
|
/**
|
|
* The role of this entry
|
|
*/
|
|
String ROLE = "role";
|
|
/**
|
|
* the contirbutor id this maps to
|
|
*/
|
|
String _CONTRIBUTOR_ID = "_contributor_id";
|
|
/**
|
|
* the song this maps to
|
|
*/
|
|
String SONG_ID = "song_id";
|
|
}
|
|
|
|
// Columns of Genres entries
|
|
public interface GenreColumns {
|
|
/**
|
|
* The id of this genre
|
|
*/
|
|
String _ID = SongColumns._ID;
|
|
/**
|
|
* The name of this genre
|
|
*/
|
|
String _GENRE = "_genre";
|
|
/**
|
|
* The sortable title of this genre
|
|
*/
|
|
String _GENRE_SORT = "_genre_sort";
|
|
}
|
|
|
|
// Songs <-> Contributor mapping
|
|
public interface GenreSongColumns {
|
|
/**
|
|
* the genre id this maps to
|
|
*/
|
|
String _GENRE_ID = "_genre_id";
|
|
/**
|
|
* the song this maps to
|
|
*/
|
|
String SONG_ID = "song_id";
|
|
}
|
|
|
|
// Playlists
|
|
public interface PlaylistColumns {
|
|
/**
|
|
* The id of this playlist
|
|
*/
|
|
String _ID = SongColumns._ID;
|
|
/**
|
|
* The name of this playlist
|
|
*/
|
|
String NAME = "name";
|
|
}
|
|
|
|
// Song <-> Playlist mapping
|
|
public interface PlaylistSongColumns {
|
|
/**
|
|
* The ID of this entry
|
|
*/
|
|
String _ID = SongColumns._ID;
|
|
/**
|
|
* The playlist this entry belongs to
|
|
*/
|
|
String PLAYLIST_ID = "playlist_id";
|
|
/**
|
|
* The song this entry references to
|
|
*/
|
|
String SONG_ID = "song_id";
|
|
/**
|
|
* The order attribute
|
|
*/
|
|
String POSITION = "position";
|
|
}
|
|
}
|