Add new PlaylistObserver which deprecates PlaylistBridge
This commit is contained in:
parent
92c4273a5e
commit
b957b49e72
@ -1,95 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.ContentResolver;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.provider.MediaStore.Audio;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
class PlaylistBridge {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queries all native playlists and imports them
|
|
||||||
*
|
|
||||||
* @param context the context to use
|
|
||||||
*/
|
|
||||||
static void importAndroidPlaylists(Context context) {
|
|
||||||
ContentResolver resolver = context.getContentResolver();
|
|
||||||
Cursor cursor = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
cursor = resolver.query(Audio.Playlists.EXTERNAL_CONTENT_URI, new String[]{Audio.Playlists._ID, Audio.Playlists.NAME}, null, null, null);
|
|
||||||
} catch (SecurityException e) {
|
|
||||||
Log.v("VanillaMusic", "Unable to query existing playlists, exception: "+e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor != null) {
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
long playlistId = cursor.getLong(0);
|
|
||||||
String playlistName = cursor.getString(1);
|
|
||||||
importAndroidPlaylist(context, playlistName, playlistId);
|
|
||||||
}
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports a single native playlist into our own media library
|
|
||||||
*
|
|
||||||
* @param context the context to use
|
|
||||||
* @param targetName the name of the playlist in our media store
|
|
||||||
* @param playlistId the native playlist id to import
|
|
||||||
*/
|
|
||||||
static void importAndroidPlaylist(Context context, String targetName, long playlistId) {
|
|
||||||
ArrayList<Long> bulkIds = new ArrayList<>();
|
|
||||||
ContentResolver resolver = context.getContentResolver();
|
|
||||||
Uri uri = Audio.Playlists.Members.getContentUri("external", playlistId);
|
|
||||||
Cursor cursor = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
cursor = resolver.query(uri, new String[]{Audio.Media.DATA}, null, null, Audio.Playlists.Members.DEFAULT_SORT_ORDER);
|
|
||||||
} catch (SecurityException e) {
|
|
||||||
Log.v("VanillaMusic", "Failed to query playlist: "+e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor != null) {
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
String path = cursor.getString(0);
|
|
||||||
// We do not need to do a lookup by path as we can calculate the id used
|
|
||||||
// by the mediastore using the path
|
|
||||||
bulkIds.add(MediaLibrary.hash63(path));
|
|
||||||
}
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bulkIds.size() == 0)
|
|
||||||
return; // do not import empty playlists
|
|
||||||
|
|
||||||
long targetPlaylistId = MediaLibrary.createPlaylist(context, targetName);
|
|
||||||
if (targetPlaylistId == -1)
|
|
||||||
return; // already exists, won't touch
|
|
||||||
|
|
||||||
MediaLibrary.addToPlaylist(context, targetPlaylistId, bulkIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -506,7 +506,8 @@ public final class PlaybackService extends Service
|
|||||||
mRemoteControlClient = new RemoteControl().getClient(this);
|
mRemoteControlClient = new RemoteControl().getClient(this);
|
||||||
mRemoteControlClient.initializeRemote();
|
mRemoteControlClient.initializeRemote();
|
||||||
|
|
||||||
mPlaylistObserver = new PlaylistObserver(this);
|
int syncMode = Integer.parseInt(settings.getString(PrefKeys.PLAYLIST_SYNC_MODE, PrefDefaults.PLAYLIST_SYNC_MODE));
|
||||||
|
mPlaylistObserver = new PlaylistObserver(this, syncMode);
|
||||||
|
|
||||||
mLooper = thread.getLooper();
|
mLooper = thread.getLooper();
|
||||||
mHandler = new Handler(mLooper, this);
|
mHandler = new Handler(mLooper, this);
|
||||||
@ -940,6 +941,9 @@ public final class PlaybackService extends Service
|
|||||||
mReadaheadEnabled = settings.getBoolean(PrefKeys.ENABLE_READAHEAD, PrefDefaults.ENABLE_READAHEAD);
|
mReadaheadEnabled = settings.getBoolean(PrefKeys.ENABLE_READAHEAD, PrefDefaults.ENABLE_READAHEAD);
|
||||||
} else if (PrefKeys.AUTOPLAYLIST_PLAYCOUNTS.equals(key)) {
|
} else if (PrefKeys.AUTOPLAYLIST_PLAYCOUNTS.equals(key)) {
|
||||||
mAutoPlPlaycounts = settings.getInt(PrefKeys.AUTOPLAYLIST_PLAYCOUNTS, PrefDefaults.AUTOPLAYLIST_PLAYCOUNTS);
|
mAutoPlPlaycounts = settings.getInt(PrefKeys.AUTOPLAYLIST_PLAYCOUNTS, PrefDefaults.AUTOPLAYLIST_PLAYCOUNTS);
|
||||||
|
} else if (PrefKeys.PLAYLIST_SYNC_MODE.equals(key)) {
|
||||||
|
int syncMode = Integer.parseInt(settings.getString(PrefKeys.PLAYLIST_SYNC_MODE, PrefDefaults.PLAYLIST_SYNC_MODE));
|
||||||
|
mPlaylistObserver.setSyncMode(syncMode);
|
||||||
} else if (PrefKeys.SELECTED_THEME.equals(key) || PrefKeys.DISPLAY_MODE.equals(key)) {
|
} else if (PrefKeys.SELECTED_THEME.equals(key) || PrefKeys.DISPLAY_MODE.equals(key)) {
|
||||||
// Theme changed: trigger a restart of all registered activites
|
// Theme changed: trigger a restart of all registered activites
|
||||||
ArrayList<TimelineCallback> list = sCallbacks;
|
ArrayList<TimelineCallback> list = sCallbacks;
|
||||||
|
@ -26,23 +26,36 @@ import android.database.Cursor;
|
|||||||
import android.database.sqlite.SQLiteDatabase;
|
import android.database.sqlite.SQLiteDatabase;
|
||||||
import android.database.sqlite.SQLiteOpenHelper;
|
import android.database.sqlite.SQLiteOpenHelper;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
|
import android.os.FileObserver;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.HandlerThread;
|
import android.os.HandlerThread;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.os.Process;
|
import android.os.Process;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.FileReader;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
|
||||||
public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callback {
|
public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callback {
|
||||||
/**
|
/**
|
||||||
* Timeout to coalesce duplicate messages.
|
* Bits for mSyncMode
|
||||||
*/
|
*/
|
||||||
private final static int COALESCE_EVENTS_DELAY_MS = 280;
|
public final static int SYNC_MODE_IMPORT = (1 << 0);
|
||||||
|
public final static int SYNC_MODE_EXPORT = (1 << 1);
|
||||||
|
public final static int SYNC_MODE_PURGE = (1 << 2);
|
||||||
|
/**
|
||||||
|
* Timeout to coalesce duplicate messages, ~2.3 sec because no real reason.
|
||||||
|
*/
|
||||||
|
private final static int COALESCE_EVENTS_DELAY_MS = 2345;
|
||||||
/**
|
/**
|
||||||
* Extension to use for M3U files
|
* Extension to use for M3U files
|
||||||
*/
|
*/
|
||||||
@ -63,30 +76,42 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
* Directory which holds observed playlists.
|
* Directory which holds observed playlists.
|
||||||
*/
|
*/
|
||||||
private File mPlaylists = new File(Environment.getExternalStorageDirectory(), "Playlists");
|
private File mPlaylists = new File(Environment.getExternalStorageDirectory(), "Playlists");
|
||||||
|
/**
|
||||||
static class Database {
|
* What kind of synching to perform, bitmask of PlaylistObserver.SYNC_MODE_*
|
||||||
|
*/
|
||||||
|
private int mSyncMode;
|
||||||
|
/**
|
||||||
|
* Database fields
|
||||||
|
*/
|
||||||
|
private static class Database {
|
||||||
final static String TABLE_NAME = "playlist_metadata";
|
final static String TABLE_NAME = "playlist_metadata";
|
||||||
final static String _ID = "_id";
|
final static String _ID = "_id";
|
||||||
final static String NAME = "name";
|
final static String NAME = "name";
|
||||||
final static String MTIME = "mtime";
|
final static String HASH = "hash";
|
||||||
final static String[] FILLED_PROJECTION = {
|
final static String[] FILLED_PROJECTION = {
|
||||||
_ID,
|
_ID,
|
||||||
NAME,
|
NAME,
|
||||||
MTIME,
|
HASH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlaylistObserver(Context context) {
|
|
||||||
super(context, "playlistobserver.db", null, 1 /* version */);
|
|
||||||
|
|
||||||
|
public PlaylistObserver(Context context, int mode) {
|
||||||
|
super(context, "playlist_observer.db", null, 1 /* version */);
|
||||||
mContext = context;
|
mContext = context;
|
||||||
|
setSyncMode(mode);
|
||||||
|
|
||||||
// Launch new thread for background execution
|
// Launch new thread for background execution
|
||||||
mHandlerThread= new HandlerThread("PlaylistWriter", Process.THREAD_PRIORITY_LOWEST);
|
mHandlerThread= new HandlerThread("PlaylisObserverHandler", Process.THREAD_PRIORITY_LOWEST);
|
||||||
mHandlerThread.start();
|
mHandlerThread.start();
|
||||||
mHandler = new Handler(mHandlerThread.getLooper(), this);
|
mHandler = new Handler(mHandlerThread.getLooper(), this);
|
||||||
|
|
||||||
// Register to receive media library events.
|
// Register to receive media library events.
|
||||||
MediaLibrary.registerLibraryObserver(mObserver);
|
MediaLibrary.registerLibraryObserver(mLibraryObserver);
|
||||||
|
mFileObserver.startWatching();
|
||||||
|
|
||||||
|
XT("Object created, trigger FULL_SYNC_SCAN");
|
||||||
|
sendUniqueMessage(MSG_FULL_SYNC_SCAN, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,28 +119,81 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
* after this function was called.
|
* after this function was called.
|
||||||
*/
|
*/
|
||||||
public void unregister() {
|
public void unregister() {
|
||||||
MediaLibrary.unregisterLibraryObserver(mObserver);
|
MediaLibrary.unregisterLibraryObserver(mLibraryObserver);
|
||||||
|
mFileObserver.stopWatching();
|
||||||
|
|
||||||
mHandlerThread.quitSafely();
|
mHandlerThread.quitSafely();
|
||||||
mHandlerThread = null;
|
mHandlerThread = null;
|
||||||
mHandler = null;
|
mHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the sync mode of a created instance
|
||||||
|
*
|
||||||
|
* @param mode the new mode
|
||||||
|
*/
|
||||||
|
public void setSyncMode(int mode) {
|
||||||
|
mSyncMode = mode;
|
||||||
|
XT("Sync mode is now "+mSyncMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLiteHelper onCreate
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onCreate(SQLiteDatabase dbh) {
|
||||||
|
dbh.execSQL("CREATE TABLE "+Database.TABLE_NAME+" ( "
|
||||||
|
+ Database._ID + " INTEGER PRIMARY KEY, "
|
||||||
|
+ Database.HASH + " INTEGER NOT NULL, "
|
||||||
|
+ Database.NAME + " TEXT NOT NULL )"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLiteHelper onUpgrade
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onUpgrade(SQLiteDatabase dbh, int oldVersion, int newVersion) {
|
||||||
|
// No updates so far.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message handler, used to dedupe messages and perform
|
||||||
|
* background work.
|
||||||
|
*/
|
||||||
private final static int MSG_DUMP_M3U = 1;
|
private final static int MSG_DUMP_M3U = 1;
|
||||||
private final static int MSG_DUMP_ALL_M3U = 2;
|
private final static int MSG_DUMP_ALL_M3U = 2;
|
||||||
|
private final static int MSG_IMPORT_M3U = 3;
|
||||||
|
private final static int MSG_FORCE_M3U_IMPORT = 4;
|
||||||
|
private final static int MSG_FULL_SYNC_SCAN = 5;
|
||||||
|
ArrayList<Integer> msgDedupe = new ArrayList<>();
|
||||||
@Override
|
@Override
|
||||||
public boolean handleMessage(Message message) {
|
public boolean handleMessage(Message message) {
|
||||||
|
msgDedupe.remove(0);
|
||||||
|
|
||||||
switch (message.what) {
|
switch (message.what) {
|
||||||
case MSG_DUMP_M3U:
|
case MSG_DUMP_M3U:
|
||||||
Long id = (Long)message.obj;
|
Long id = (Long)message.obj;
|
||||||
if (!dumpM3uPlaylist(id)) {
|
if (Playlist.getPlaylist(mContext, id) != null) {
|
||||||
// Dump of 'id' failed, so this playlist was likely deleted.
|
XT("DUMP_M3U: source of id "+id+" exists, dumping");
|
||||||
cleanupOrphanedM3u();
|
dumpAsM3uPlaylist(id);
|
||||||
|
} else {
|
||||||
|
XT("DUMP_M3U: source of id "+id+" vanished, scanning all");
|
||||||
|
sendUniqueMessage(MSG_FULL_SYNC_SCAN, 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case MSG_DUMP_ALL_M3U:
|
case MSG_DUMP_ALL_M3U:
|
||||||
dumpM3uPlaylists();
|
dumpAllAsM3uPlaylist();
|
||||||
cleanupOrphanedM3u();
|
break;
|
||||||
|
case MSG_IMPORT_M3U:
|
||||||
|
File f = (File)(message.obj);
|
||||||
|
importM3uPlaylist(f);
|
||||||
|
break;
|
||||||
|
case MSG_FORCE_M3U_IMPORT:
|
||||||
|
forceM3uImport();
|
||||||
|
break;
|
||||||
|
case MSG_FULL_SYNC_SCAN:
|
||||||
|
fullSyncScan();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new IllegalArgumentException("Invalid message type received");
|
throw new IllegalArgumentException("Invalid message type received");
|
||||||
@ -123,32 +201,141 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forcefully re-imports all M3U files, even if we think that
|
||||||
|
* our information is up-to-date.
|
||||||
|
*/
|
||||||
|
private void forceM3uImport() {
|
||||||
|
Cursor cursor = queryDatabase(null);
|
||||||
|
if (cursor != null) {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
deletePlaylistMetadata(cursor.getLong(0));
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
// run this ASAP to ensure that no other message re-populates
|
||||||
|
// metadata.
|
||||||
|
XT("forceM3uImport: metadata cleared, calling fullSyncScan");
|
||||||
|
fullSyncScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dumps all playlist to stable storage.
|
||||||
|
*/
|
||||||
|
private void dumpAllAsM3uPlaylist() {
|
||||||
|
XT("dumpAllAsM3uPlaylist: called");
|
||||||
|
Cursor cursor = Playlist.queryPlaylists(mContext);
|
||||||
|
if (cursor != null) {
|
||||||
|
while(cursor.moveToNext()) {
|
||||||
|
final long id = cursor.getLong(0);
|
||||||
|
XT("dumpAllAsM3uPlaylist: Dumping ID "+id);
|
||||||
|
sendUniqueMessage(MSG_DUMP_M3U, id);
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new message to the queue. Ignores call if a duplicate
|
||||||
|
* message is already pending.
|
||||||
|
*
|
||||||
|
* @param type the type of the message
|
||||||
|
* @param obj object payload of this message.
|
||||||
|
*/
|
||||||
|
private void sendUniqueMessage(int type, Object obj) {
|
||||||
|
int fprint = type << 10 + obj.hashCode();
|
||||||
|
if (!msgDedupe.contains(fprint)) {
|
||||||
|
msgDedupe.add(fprint);
|
||||||
|
mHandler.sendMessageDelayed(mHandler.obtainMessage(type, obj), COALESCE_EVENTS_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports an M3U formatted file into our native media library.
|
||||||
|
*
|
||||||
|
* @param m3u the file to import
|
||||||
|
*/
|
||||||
|
private void importM3uPlaylist(File m3u) {;
|
||||||
|
XT("importM3uPlaylist("+m3u+")");
|
||||||
|
|
||||||
|
if ((mSyncMode & SYNC_MODE_IMPORT) == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!m3u.exists())
|
||||||
|
return;
|
||||||
|
|
||||||
|
final long hash = getHash(m3u);
|
||||||
|
if (hash == -1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
boolean must_import = true;
|
||||||
|
String import_as = fromM3u(m3u.getName());
|
||||||
|
Cursor cursor = queryDatabase(null);
|
||||||
|
if (cursor != null) {
|
||||||
|
// Try to find an existing playlist where the constructed path
|
||||||
|
// would match given input file.
|
||||||
|
while(cursor.moveToNext()) {
|
||||||
|
File tmp = getFileForName(mPlaylists, asM3u(cursor.getString(1)));
|
||||||
|
if (m3u.equals(tmp)) {
|
||||||
|
// Found a matching playlist: this will be our import target
|
||||||
|
// if the hash indicates that our version is outdated.
|
||||||
|
import_as = cursor.getString(1);
|
||||||
|
must_import = (hash != cursor.getLong(2));
|
||||||
|
XT("importM3uPlaylist(): hash="+hash+", import="+must_import+", import_as="+import_as);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (must_import) {
|
||||||
|
MediaLibrary.unregisterLibraryObserver(mLibraryObserver);
|
||||||
|
long import_id = Playlist.createPlaylist(mContext, import_as);
|
||||||
|
try (BufferedReader br = new BufferedReader(new FileReader(m3u))) {
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
if (line.matches("^/.+")) {
|
||||||
|
Playlist.addToPlaylist(mContext, import_id, MediaUtils.buildFileQuery(line, Song.FILLED_PROJECTION));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatePlaylistMetadata(import_id, import_as, hash);
|
||||||
|
} catch(IOException e) {
|
||||||
|
Log.e("VanillaMusic", "Error while parsing m3u: "+e);
|
||||||
|
}
|
||||||
|
MediaLibrary.registerLibraryObserver(mLibraryObserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports a single playlist ad M3U(8).
|
* Exports a single playlist ad M3U(8).
|
||||||
*
|
*
|
||||||
* @param id the playlist id to export.
|
* @param id the playlist id to export.
|
||||||
* @return true if the playlist was dumped.
|
* @return the newly written playlist, null if nothing was done.
|
||||||
*/
|
*/
|
||||||
private boolean dumpM3uPlaylist(long id) {
|
private File dumpAsM3uPlaylist(long id) {
|
||||||
final String name = Playlist.getPlaylist(mContext, id);
|
XT("dumpM3uPlaylist("+id+")");
|
||||||
|
|
||||||
if (id < 0)
|
if (id < 0)
|
||||||
throw new IllegalArgumentException("Called with negative id!");
|
throw new IllegalArgumentException("Called with negative id!");
|
||||||
|
|
||||||
|
if ((mSyncMode & SYNC_MODE_EXPORT) == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
final String name = Playlist.getPlaylist(mContext, id);
|
||||||
if (name == null)
|
if (name == null)
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
|
final File m3u = getFileForName(mPlaylists, asM3u(name));
|
||||||
|
|
||||||
if (!mPlaylists.isDirectory())
|
if (!mPlaylists.isDirectory())
|
||||||
mPlaylists.mkdir();
|
mPlaylists.mkdir();
|
||||||
|
|
||||||
Log.v("VanillaMusic", "Dumping "+getFileForName(mPlaylists, name));
|
|
||||||
|
|
||||||
PrintWriter pw = null;
|
PrintWriter pw = null;
|
||||||
QueryTask query = MediaUtils.buildPlaylistQuery(id, Song.FILLED_PLAYLIST_PROJECTION);
|
QueryTask query = MediaUtils.buildPlaylistQuery(id, Song.FILLED_PLAYLIST_PROJECTION);
|
||||||
Cursor cursor = query.runQuery(mContext);
|
Cursor cursor = query.runQuery(mContext);
|
||||||
try {
|
try {
|
||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
pw = new PrintWriter(getFileForName(mPlaylists, name + M3U_EXT));
|
pw = new PrintWriter(m3u);
|
||||||
pw.println("#EXTM3U");
|
pw.println("#EXTM3U");
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
final String path = cursor.getString(1);
|
final String path = cursor.getString(1);
|
||||||
@ -159,7 +346,9 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
pw.printf("#EXTINF:%d,%s - %s%n", (duration/1000), artist, title);
|
pw.printf("#EXTINF:%d,%s - %s%n", (duration/1000), artist, title);
|
||||||
pw.println(path);
|
pw.println(path);
|
||||||
}
|
}
|
||||||
updatePlaylistMetadata(id, name);
|
pw.flush();
|
||||||
|
long hash_new = getHash(m3u);
|
||||||
|
updatePlaylistMetadata(id, name, hash_new);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.v("VanillaMusic", "IOException while writing:", e);
|
Log.v("VanillaMusic", "IOException while writing:", e);
|
||||||
@ -167,50 +356,64 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
if (cursor != null) cursor.close();
|
if (cursor != null) cursor.close();
|
||||||
if (pw != null) pw.close();
|
if (pw != null) pw.close();
|
||||||
}
|
}
|
||||||
return true;
|
return m3u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dumps all playlist to stable storage.
|
* Identify (and remove) playlist items which do not exist anymore
|
||||||
|
* and pick up any new M3U files.
|
||||||
*/
|
*/
|
||||||
private void dumpM3uPlaylists() {
|
private void fullSyncScan() {
|
||||||
Cursor cursor = Playlist.queryPlaylists(mContext);
|
XT("fullSyncScan() running...");
|
||||||
if (cursor != null) {
|
ArrayList<File> knownM3u = new ArrayList<>();
|
||||||
while(cursor.moveToNext()) {
|
|
||||||
final long id = cursor.getLong(0);
|
|
||||||
sendUniqueMessage(MSG_DUMP_M3U, id);
|
|
||||||
}
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// First step is to check all known playlist metadata entries
|
||||||
* Checks our playlists directory for files which reference
|
// and check whether their native or M3U copy was purged.
|
||||||
* non-existing playlists and removes them.
|
final boolean do_purge = (mSyncMode & SYNC_MODE_PURGE) != 0;
|
||||||
*/
|
Cursor cursor = queryDatabase(null);
|
||||||
private void cleanupOrphanedM3u() {
|
|
||||||
SQLiteDatabase dbh = getReadableDatabase();
|
|
||||||
Cursor cursor = dbh.query(Database.TABLE_NAME, Database.FILLED_PROJECTION, null, null, null, null, null);
|
|
||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
final long id = cursor.getLong(0);
|
final long id = cursor.getLong(0);
|
||||||
final String name = cursor.getString(1);
|
final String name = cursor.getString(1);
|
||||||
final File src_m3u = getFileForName(mPlaylists, name + M3U_EXT);
|
// generates possible names of this playlist as M3U.
|
||||||
|
final File src_m3u = getFileForName(mPlaylists, asM3u(name));
|
||||||
|
final File bak_m3u = getFileForName(mPlaylists, name + ".backup");
|
||||||
|
|
||||||
if (Playlist.getPlaylist(mContext, id) == null) {
|
if (Playlist.getPlaylist(mContext, id) == null) {
|
||||||
// Native version of this playlist is gone, rename M3U variant:
|
// Native version of this playlist is gone, rename M3U variant:
|
||||||
File dst_m3u = getFileForName(mPlaylists, name + ".bak");
|
if (do_purge) {
|
||||||
src_m3u.renameTo(dst_m3u);
|
src_m3u.renameTo(bak_m3u);
|
||||||
|
}
|
||||||
deletePlaylistMetadata(id);
|
deletePlaylistMetadata(id);
|
||||||
Log.v("VanillaMusic", name+": Renamed old m3u");
|
XT("fullSyncScan(): renamed old M3U -> "+bak_m3u);
|
||||||
} else if (!src_m3u.exists()) {
|
} else if (do_purge && !src_m3u.exists()) {
|
||||||
Playlist.deletePlaylist(mContext, id); // Fixme: do we really want this?
|
// Source vanished, write one last dump and remove it.
|
||||||
|
File dump = dumpAsM3uPlaylist(id);
|
||||||
|
if (dump != null) {
|
||||||
|
dump.renameTo(bak_m3u);
|
||||||
|
}
|
||||||
|
Playlist.deletePlaylist(mContext, id);
|
||||||
deletePlaylistMetadata(id);
|
deletePlaylistMetadata(id);
|
||||||
Log.v("VanillaMusic", name+": Killed native playlist");
|
XT("fullSyncScan(): killed native playlist with id "+id);
|
||||||
|
}
|
||||||
|
// If this M3U exists, record it so that we don't try to re-import.
|
||||||
|
if (src_m3u.exists()) {
|
||||||
|
knownM3u.add(src_m3u);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor.close();
|
cursor.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now list all M3U files in the playlists dir and import newly seen files.
|
||||||
|
File[] files = mPlaylists.listFiles();
|
||||||
|
if (files != null) {
|
||||||
|
for (File f : files) {
|
||||||
|
if (isM3uFilename(f.getName()) && !knownM3u.contains(f)) {
|
||||||
|
XT("fullSyncScan(): new M3U discovered, must import "+f);
|
||||||
|
sendUniqueMessage(MSG_IMPORT_M3U, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -220,7 +423,6 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
* @return file object for given name
|
* @return file object for given name
|
||||||
*/
|
*/
|
||||||
private File getFileForName(File parent, String name) {
|
private File getFileForName(File parent, String name) {
|
||||||
//Fixme: check for m3u8 and remove invalid chars.
|
|
||||||
name = name.replaceAll("/", "_");
|
name = name.replaceAll("/", "_");
|
||||||
File f = new File(parent, name);
|
File f = new File(parent, name);
|
||||||
return f;
|
return f;
|
||||||
@ -232,12 +434,16 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
* @param id the id to update.
|
* @param id the id to update.
|
||||||
* @param name the name to register for this id.
|
* @param name the name to register for this id.
|
||||||
*/
|
*/
|
||||||
private void updatePlaylistMetadata(long id, String name) {
|
private void updatePlaylistMetadata(long id, String name, long hash) {
|
||||||
|
if (hash < 0)
|
||||||
|
throw new IllegalArgumentException("hash can not be negative");
|
||||||
|
|
||||||
|
XT("updatePlaylistMetadata of "+name+" to hash "+hash);
|
||||||
SQLiteDatabase dbh = getWritableDatabase();
|
SQLiteDatabase dbh = getWritableDatabase();
|
||||||
ContentValues values = new ContentValues();
|
ContentValues values = new ContentValues();
|
||||||
values.put(Database._ID, id);
|
values.put(Database._ID, id);
|
||||||
values.put(Database.NAME, name);
|
values.put(Database.NAME, name);
|
||||||
values.put(Database.MTIME, System.currentTimeMillis());
|
values.put(Database.HASH, hash);
|
||||||
|
|
||||||
deletePlaylistMetadata(id);
|
deletePlaylistMetadata(id);
|
||||||
dbh.insert(Database.TABLE_NAME, null, values);
|
dbh.insert(Database.TABLE_NAME, null, values);
|
||||||
@ -250,7 +456,7 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
*/
|
*/
|
||||||
private void deletePlaylistMetadata(long id) {
|
private void deletePlaylistMetadata(long id) {
|
||||||
SQLiteDatabase dbh = getWritableDatabase();
|
SQLiteDatabase dbh = getWritableDatabase();
|
||||||
dbh.delete(Database.TABLE_NAME, Database._ID+"=?", new String[] { new Long(id).toString() });
|
dbh.delete(Database.TABLE_NAME, Database._ID+"=?", new String[] { Long.valueOf(id).toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -259,7 +465,7 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
* @param name the name to check
|
* @param name the name to check
|
||||||
* @return true if file appears to be an M3U
|
* @return true if file appears to be an M3U
|
||||||
*/
|
*/
|
||||||
private boolean FIXME_isM3uFilename(String name) {
|
private boolean isM3uFilename(String name) {
|
||||||
if (name.length() < M3U_EXT.length())
|
if (name.length() < M3U_EXT.length())
|
||||||
return false;
|
return false;
|
||||||
final int offset = name.length() - M3U_EXT.length();
|
final int offset = name.length() - M3U_EXT.length();
|
||||||
@ -267,48 +473,120 @@ public class PlaylistObserver extends SQLiteOpenHelper implements Handler.Callba
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new message to the queue. Pending duplicate messages
|
* Returns the m3u-filename of name
|
||||||
* will be pruged.
|
|
||||||
*
|
*
|
||||||
* @param type the type of the message
|
* @param name the name to use
|
||||||
* @param obj object payload of this message.
|
* @return the m3u name
|
||||||
*/
|
*/
|
||||||
private void sendUniqueMessage(int type, Long obj) {
|
private String asM3u(String name) {
|
||||||
mHandler.removeMessages(type, obj);
|
return name + M3U_EXT;
|
||||||
mHandler.sendMessageDelayed(mHandler.obtainMessage(type, obj), COALESCE_EVENTS_DELAY_MS);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of an m3u file
|
||||||
|
*
|
||||||
|
* @param name the m3u filename
|
||||||
|
* @return the non-m3u name
|
||||||
|
*/
|
||||||
|
private String fromM3u(String name) {
|
||||||
|
if (!isM3uFilename(name))
|
||||||
|
throw new IllegalArgumentException("Not an M3U filename: "+name);
|
||||||
|
return name.substring(0, name.length() - M3U_EXT.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes the contents of given file
|
||||||
|
*
|
||||||
|
* @param f the file to hash
|
||||||
|
* @return the calculated hash, -1 on error.
|
||||||
|
*/
|
||||||
|
private long getHash(File f) {
|
||||||
|
long hash = -1;
|
||||||
|
byte[] buff = new byte[4096];
|
||||||
|
try(FileInputStream fis = new FileInputStream(f)) {
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
while(fis.read(buff) != -1) {
|
||||||
|
crc.update(buff);
|
||||||
|
}
|
||||||
|
hash = crc.getValue();
|
||||||
|
if (hash < 0)
|
||||||
|
hash = hash * -1;
|
||||||
|
} catch(IOException e) {
|
||||||
|
// hash will be -1 which signals failure.
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain a cursor to our metadaata database.
|
||||||
|
*
|
||||||
|
* @param selection selection for query
|
||||||
|
* @return cursor with results.
|
||||||
|
*/
|
||||||
|
private Cursor queryDatabase(String selection) {
|
||||||
|
return getReadableDatabase().query(Database.TABLE_NAME, Database.FILLED_PROJECTION, selection, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library observer callback which notifies us about media library
|
* Library observer callback which notifies us about media library
|
||||||
* events.
|
* events.
|
||||||
|
*
|
||||||
|
* @param type the event type
|
||||||
|
* @param id the id of given type which had a change
|
||||||
|
* @param ongoing whether or not to expect more of these events
|
||||||
*/
|
*/
|
||||||
private final LibraryObserver mObserver = new LibraryObserver() {
|
private final LibraryObserver mLibraryObserver = new LibraryObserver() {
|
||||||
@Override
|
@Override
|
||||||
public void onChange(LibraryObserver.Type type, long id, boolean ongoing) {
|
public void onChange(LibraryObserver.Type type, long id, boolean ongoing) {
|
||||||
if (type != LibraryObserver.Type.PLAYLIST || ongoing)
|
if (type != LibraryObserver.Type.PLAYLIST || ongoing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Dispatch this event but use different type if id was -1 as
|
int msg = MSG_DUMP_M3U; // Default: export this playlist ID.
|
||||||
// this indicates that multiple (unknown) playlists may have changed.
|
if (id == LibraryObserver.Value.UNKNOWN) {
|
||||||
final int msg = (id < 0 ? MSG_DUMP_ALL_M3U : MSG_DUMP_M3U);
|
// An unknown (all?) playlist was modified: dump all to M3U
|
||||||
|
msg = MSG_DUMP_ALL_M3U;
|
||||||
|
}
|
||||||
|
if (id == LibraryObserver.Value.OUTDATED) {
|
||||||
|
// Our data is wrong, reimport all M3Us
|
||||||
|
msg = MSG_FORCE_M3U_IMPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
XT("LibraryObserver::onChange id="+id+", msg="+msg);
|
||||||
sendUniqueMessage(msg, id);
|
sendUniqueMessage(msg, id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void onCreate(SQLiteDatabase dbh) {
|
* Observer which monitors the playlists directory.
|
||||||
dbh.execSQL("CREATE TABLE "+Database.TABLE_NAME+" ( "
|
*
|
||||||
+ Database._ID + " INTEGER PRIMARY KEY, "
|
* @param event the event type
|
||||||
+ Database.MTIME + " INTEGER NOT NULL, "
|
* @param dirent the filename which triggered the event.
|
||||||
+ Database.NAME + " TEXT NOT NULL )"
|
*/
|
||||||
);
|
private final static int mask = FileObserver.CLOSE_WRITE | FileObserver.MOVED_FROM | FileObserver.MOVED_TO | FileObserver.DELETE;
|
||||||
}
|
private final FileObserver mFileObserver = new FileObserver(mPlaylists.getAbsolutePath(), mask) {
|
||||||
|
@Override
|
||||||
|
public void onEvent(int event, String dirent) {
|
||||||
|
if (!isM3uFilename(dirent))
|
||||||
|
return;
|
||||||
|
|
||||||
@Override
|
if ((event & (FileObserver.MOVED_FROM | FileObserver.DELETE)) != 0) {
|
||||||
public void onUpgrade(SQLiteDatabase dbh, int oldVersion, int newVersion) {
|
// A M3U vanished, do a full scan.
|
||||||
// No updates so far.
|
XT("FileObserver::onEvent DELETE of "+dirent+" triggers FULL_SYNC_SCAN");
|
||||||
|
sendUniqueMessage(MSG_FULL_SYNC_SCAN, 0);
|
||||||
|
}
|
||||||
|
if ((event & (FileObserver.MOVED_TO | FileObserver.CLOSE_WRITE)) != 0) {
|
||||||
|
// Single file was created, import it.
|
||||||
|
XT("FileObserver::onEvent WRITE of "+dirent+" triggers IMPORT_M3U");
|
||||||
|
sendUniqueMessage(MSG_IMPORT_M3U, new File(mPlaylists, dirent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void XT(String s) {
|
||||||
|
try(PrintWriter pw = new PrintWriter(new FileOutputStream(new File("/sdcard/playlist-observer.txt"), true))) {
|
||||||
|
pw.println(System.currentTimeMillis()/1000+": "+s);
|
||||||
|
Log.v("VanillaMusic", "XTRACE: "+s);
|
||||||
|
} catch(Exception e) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO:
|
|
||||||
// Use FileObserver to track playlist changes?
|
|
||||||
// how do we check modifications? write a shadow-dir in private app storage with same mtimes?
|
|
||||||
}
|
}
|
||||||
|
@ -70,5 +70,5 @@ public class PrefDefaults {
|
|||||||
public static final boolean IGNORE_AUDIOFOCUS_LOSS = false;
|
public static final boolean IGNORE_AUDIOFOCUS_LOSS = false;
|
||||||
public static final boolean ENABLE_SCROLL_TO_SONG = false;
|
public static final boolean ENABLE_SCROLL_TO_SONG = false;
|
||||||
public static final boolean KEEP_SCREEN_ON = false;
|
public static final boolean KEEP_SCREEN_ON = false;
|
||||||
public static final int PLAYLIST_SYNC_MODE = 0xFF;
|
public static final String PLAYLIST_SYNC_MODE = "255";
|
||||||
}
|
}
|
||||||
|
@ -330,6 +330,12 @@ THE SOFTWARE.
|
|||||||
<string name="autoplaylist_playcounts_disabled">Do not create an automatic playlist</string>
|
<string name="autoplaylist_playcounts_disabled">Do not create an automatic playlist</string>
|
||||||
<string name="autoplaylist_playcounts_name" formatted="false">Top %d</string>
|
<string name="autoplaylist_playcounts_name" formatted="false">Top %d</string>
|
||||||
|
|
||||||
|
<string name="playlist_sync_mode_title">Playlist synchronization</string>
|
||||||
|
<string name="playlist_sync_all">Synchronize \'Playlists\' folder</string>
|
||||||
|
<string name="playlist_sync_only_import">Import M3U, never export</string>
|
||||||
|
<string name="playlist_sync_only_export">Write M3U, never import</string>
|
||||||
|
<string name="playlist_sync_disabled">Disabled</string>
|
||||||
|
|
||||||
<string name="permission_request_summary">Vanilla Music needs read permission to display your music library</string>
|
<string name="permission_request_summary">Vanilla Music needs read permission to display your music library</string>
|
||||||
<string name="reverse_sort">Reverse sort</string>
|
<string name="reverse_sort">Reverse sort</string>
|
||||||
|
|
||||||
|
@ -127,6 +127,21 @@ THE SOFTWARE.
|
|||||||
</string-array>
|
</string-array>
|
||||||
<!-- END default action entries definition -->
|
<!-- END default action entries definition -->
|
||||||
|
|
||||||
|
<!-- START playlist sync mode entries definition -->
|
||||||
|
<string-array name="playlist_sync_mode_entries">
|
||||||
|
<item>@string/playlist_sync_all</item>
|
||||||
|
<item>@string/playlist_sync_only_export</item>
|
||||||
|
<item>@string/playlist_sync_only_import</item>
|
||||||
|
<item>@string/playlist_sync_disabled</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="playlist_sync_mode_values">
|
||||||
|
<item>255</item> <!-- default, sync all -->
|
||||||
|
<item>2</item> <!-- SYNC_MODE_EXPORT -->
|
||||||
|
<item>1</item> <!-- SYNC_MODE_IMPORT -->
|
||||||
|
<item>0</item> <!-- DISABLE -->
|
||||||
|
</string-array>
|
||||||
|
<!-- END playlist sync mode entries definition -->
|
||||||
|
|
||||||
<string-array name="notification_visibility_entries">
|
<string-array name="notification_visibility_entries">
|
||||||
<item>@string/show_when_playing</item>
|
<item>@string/show_when_playing</item>
|
||||||
<item>@string/always_show</item>
|
<item>@string/always_show</item>
|
||||||
|
@ -60,6 +60,12 @@ THE SOFTWARE.
|
|||||||
vanilla:sbpSummaryText="@string/autoplaylist_playcounts_summary"
|
vanilla:sbpSummaryText="@string/autoplaylist_playcounts_summary"
|
||||||
vanilla:sbpSummaryFormat="@string/autoplaylist_playcounts_fmt"
|
vanilla:sbpSummaryFormat="@string/autoplaylist_playcounts_fmt"
|
||||||
vanilla:sbpSummaryZeroText="@string/autoplaylist_playcounts_disabled"/>
|
vanilla:sbpSummaryZeroText="@string/autoplaylist_playcounts_disabled"/>
|
||||||
|
<ch.blinkenlights.android.vanilla.ListPreferenceSummary
|
||||||
|
android:key="playlist_sync_mode"
|
||||||
|
android:title="@string/playlist_sync_mode_title"
|
||||||
|
android:entries="@array/playlist_sync_mode_entries"
|
||||||
|
android:entryValues="@array/playlist_sync_mode_values"
|
||||||
|
android:defaultValue="255" />
|
||||||
<CheckBoxPreference
|
<CheckBoxPreference
|
||||||
android:key="scrobble"
|
android:key="scrobble"
|
||||||
android:title="@string/scrobble_title"
|
android:title="@string/scrobble_title"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user