added support for updating play count to server (feedback required)

added warning for possibly hanging sync
This commit is contained in:
memen45
2020-12-15 19:52:01 +01:00
parent 783737cd26
commit 45696e19a7
20 changed files with 403 additions and 80 deletions

View File

@@ -1,9 +1,10 @@
Version [] -
- (tentative) added 'Manage...' option for more playlist options
- (tentative) added alpha podcast implementation (feedback needed)
- (tentative) added support for updating play count to server (scrobble/record_play)
Version [0.0.23] - 2020-12-
Version [0.0.23] - 2020-12-15
- added support for updating play count to server (feedback required)
- added warning for possibly hanging sync
- update last sync in menu
- refactor in menus
- fixes potential ampache crash

View File

@@ -11,7 +11,6 @@ class AmpacheAPI {
private var d_client;
private var d_hash; // password hash, required for every handshake
private var d_server; // ping response
private var d_session; // handshake response
private var d_expire; // Moment of session expire
@@ -43,34 +42,6 @@ class AmpacheAPI {
d_expire = expire;
}
function update(settings) {
System.println("AmpacheAPI::update(settings)");
// update the settings
set(settings);
deleteSession();
}
function deleteSession() {
// reset the session
d_expire = new Time.Moment(0);
Application.Storage.deleteValue("AMPACHE_API_SESSION");
d_session = null;
}
function set(settings) {
d_url = settings.get("api_url") + "/server/json.server.php";
d_usr = settings.get("api_usr");
// hash the password
var hasher = new Cryptography.Hash({:algorithm => Cryptography.HASH_SHA256});
hasher.update(string_to_ba(settings.get("api_key")));
d_hash = ba_to_hexstring(hasher.digest());
System.println("AmpacheAPI::set(url: " + d_url + ", user: " + d_usr + ", pass: " + d_hash + ")");
}
/*
* API call 'handshake'
*/
@@ -130,20 +101,29 @@ class AmpacheAPI {
// auth is optional
// params.put("auth", d_session.get("auth"));
Communications.makeWebRequest(d_url, params, {}, self.method(:onPing));
Communications.makeWebRequest(d_url, params, {}, self.method(:onDictionaryResponse));
}
function onPing(responseCode, data) {
System.println("AmpacheAPI::onPing with responseCode " + responseCode + " payload " + data);
function record_play(callback, params) {
System.println("AmpacheAPI::record_play( params: " + params + ")");
// errors are filtered first
var error = checkDictionaryResponse(responseCode, data);
if (error) {
d_fallback.invoke(error);
return;
}
d_callback = callback;
params.put("action", "record_play");
params.put("auth", d_session.get("auth"));
d_callback.invoke(data);
Communications.makeWebRequest(d_url, params, {}, self.method(:onDictionaryResponse));
}
function scrobble(callback, params) {
System.println("AmpacheAPI::scrobble( params: " + params + ")");
d_callback = callback;
params.put("action", "scrobble");
params.put("auth", d_session.get("auth"));
Communications.makeWebRequest(d_url, params, {}, self.method(:onDictionaryResponse));
}
// returns array of playlist objects
@@ -151,10 +131,6 @@ class AmpacheAPI {
System.println("AmpacheAPI::playlists( params: " + params + ")");
d_callback = callback;
if (params == null) {
params = {};
}
params.put("action", "playlists");
params.put("auth", d_session.get("auth"));
@@ -227,6 +203,11 @@ class AmpacheAPI {
return now.lessThan(d_expire);
}
/*
* onArrayResponse
*
* default handler for actions that return an array
*/
function onArrayResponse(responseCode, data) {
System.println("AmpacheAPI::onArrayResponse with responseCode: " + responseCode + ", payload " + data);
@@ -248,6 +229,24 @@ class AmpacheAPI {
return null;
}
/*
* onDictionaryResponse
*
* Default handler for actions that return a dictionary
*/
function onDictionaryResponse(responseCode, data) {
System.println("AmpacheAPI::onDictionaryResponse with responseCode " + responseCode + " payload " + data);
// errors are filtered first
var error = checkDictionaryResponse(responseCode, data);
if (error) {
d_fallback.invoke(error);
return;
}
d_callback.invoke(data);
}
function checkDictionaryResponse(responseCode, data) {
var error = checkResponse(responseCode, data);
if (error) { return error; }
@@ -257,6 +256,11 @@ class AmpacheAPI {
return null;
}
/*
* checkResponse
*
* returns response / api errors if found
*/
function checkResponse(responseCode, data) {
var error = SubMusic.HttpError.is(responseCode);
if (error) { return error; }
@@ -342,4 +346,36 @@ class AmpacheAPI {
};
return StringUtil.convertEncodedString(ba, options);
}
function update(settings) {
System.println("AmpacheAPI::update(settings)");
// update the settings
set(settings);
deleteSession();
}
function set(settings) {
d_url = settings.get("api_url") + "/server/json.server.php";
d_usr = settings.get("api_usr");
// hash the password
var hasher = new Cryptography.Hash({:algorithm => Cryptography.HASH_SHA256});
hasher.update(string_to_ba(settings.get("api_key")));
d_hash = ba_to_hexstring(hasher.digest());
System.println("AmpacheAPI::set(url: " + d_url + ", user: " + d_usr + ", pass: " + d_hash + ")");
}
function deleteSession() {
// reset the session
d_expire = new Time.Moment(0);
Application.Storage.deleteValue("AMPACHE_API_SESSION");
d_session = null;
}
function client() {
return d_client;
}
}

View File

@@ -16,6 +16,7 @@ class AmpacheProvider {
enum {
AMPACHE_ACTION_PING,
AMPACHE_ACTION_RECORD_PLAY,
AMPACHE_ACTION_PLAYLIST,
AMPACHE_ACTION_PLAYLISTS,
AMPACHE_ACTION_PLAYLIST_SONGS,
@@ -34,6 +35,7 @@ class AmpacheProvider {
// functions:
// - ping returns an object with server version
// - recordPlay submit a play
// - getAllPlaylists returns array of all playlists available for Ampache user
// - getPlaylist returns an array of one playlist object with id
// - getPlaylistSongs returns an array of songs on the playlist with id
@@ -54,6 +56,19 @@ class AmpacheProvider {
do_();
}
function recordPlay(id, time, callback) {
d_callback = callback;
d_params = {
"id" => id,
"client" => d_api.client(),
"date" => time,
};
d_action = AMPACHE_ACTION_RECORD_PLAY;
do_();
}
/**
* getAllPlaylists
*
@@ -144,6 +159,13 @@ class AmpacheProvider {
d_callback.invoke(response);
}
function on_do_record_play(response) {
System.println("AmpacheProvider::on_do_record_play( response = " + response + ")");
d_action = null;
d_callback.invoke(response["success"]); // expected success string
}
function on_do_playlist(response) {
// append the standard playlist objects to the array
for (var idx = 0; idx < response.size(); ++idx) {
@@ -239,6 +261,10 @@ class AmpacheProvider {
d_api.handshake(self.method(:do_));
return;
}
if (d_action == AMPACHE_ACTION_RECORD_PLAY) {
d_api.record_play(self.method(:on_do_record_play), d_params);
return;
}
if (d_action == AMPACHE_ACTION_PLAYLIST) {
d_api.playlist(self.method(:on_do_playlist), d_params);
return;

View File

@@ -10,7 +10,7 @@ class ArrayStore extends Store {
// returns a connected item
function get(idx) {
System.println("Store::get( idx : " + idx + " )");
// System.println("ArrayStore::get( idx : " + idx + " )");
var items = Store.value();
if ((idx == null)
@@ -31,7 +31,7 @@ class ArrayStore extends Store {
}
function add(item) {
System.println("Store::save( item : " + item.toStorage() + " )");
System.println("ArrayStore::add( item : " + item.toStorage() + " )");
// return false if failed save
if (item == null) {
@@ -45,12 +45,19 @@ class ArrayStore extends Store {
// returns true if item id entry removed from storage or is not in storage
function remove(item) {
System.println("Store::remove( item : " + item + " )");
System.println("ArrayStore::remove( item : " + item + " )");
if (item == null) {
return true;
}
Store.value().remove(item);
Store.value().remove(item.toStorage());
return Store.update();
}
function removeAll() {
System.println("ArrayStore::removeAll()");
setValue([]);
return Store.update();
}
}

View File

@@ -11,7 +11,7 @@ class ObjectStore extends Store {
// returns a connected item
function get(id) {
System.println("ObjectStore::get( id : " + id + " )");
// System.println("ObjectStore::get( id : " + id + " )");
var items = Store.value();
if (id == null) {
@@ -21,7 +21,7 @@ class ObjectStore extends Store {
}
function getIds() {
System.println("ObjectStore::getIds()");
// System.println("ObjectStore::getIds()");
return Store.value().keys();
}

View File

@@ -1,6 +1,3 @@
using Toybox.System;
using Toybox.Application;
module PlaylistStore {
var d_store = new ObjectStore(Storage.PLAYLISTS);

View File

@@ -141,7 +141,8 @@ class PlaylistSync extends Deferrable {
}
// update playlist info if not found
if (error.type() == SubMusic.ApiError.NOTFOUND) {
if ((error instanceof SubMusic.ApiError)
&& (error.type() == SubMusic.ApiError.NOTFOUND)) {
d_playlist.setRemote(false);
}

54
source/ScrobbleStore.mc Normal file
View File

@@ -0,0 +1,54 @@
class Scrobble {
private var d_id;
private var d_time;
function initialize(storage) {
d_id = storage["id"];
d_time = storage["time"];
if (d_time == null) {
d_time = Time.now().value().format("%i");
}
}
function id() {
return d_id;
}
function time() {
return d_time;
}
function toStorage() {
return {
"id" => d_id,
"time" => d_time,
};
}
}
module ScrobbleStore {
var d_store = new ArrayStore(Storage.PLAY_RECORDS);
function add(item) {
d_store.add(item);
}
function get(idx) {
return d_store.get(idx);
}
function size() {
return d_store.size();
}
function remove(item) {
return d_store.remove(item);
}
function removeAll() {
return d_store.removeAll();
}
}

91
source/ScrobbleSync.mc Normal file
View File

@@ -0,0 +1,91 @@
class ScrobbleSync extends Deferrable {
private var d_provider;
private var f_progress;
private var d_scrobble = null;
private var d_idx = 0;
private var d_todo = ScrobbleStore.size();
private var d_todo_total = ScrobbleStore.size();
function initialize(provider, progress) {
Deferrable.initialize(method(:sync)); // make sync the deferred task
f_progress = progress;
d_provider = provider;
d_provider.setFallback(method(:onError));
}
function sync() {
if (d_todo == 0) {
return Deferrable.complete();
}
// update progress
f_progress.invoke(progress());
// record first play on the list
var storage = ScrobbleStore.get(d_idx);
d_scrobble = new Scrobble(storage);
d_provider.recordPlay(d_scrobble.id(), d_scrobble.time(), method(:onRecordPlay));
return Deferrable.defer();
}
function onRecordPlay(response) {
System.println("ScrobbleSync::onRecordPlay( response : " + response + ")");
// decrement todos
d_todo -= 1;
// remove front as it is done now
ScrobbleStore.remove(d_scrobble);
// sync next
sync();
}
function onError(error) {
System.println("ScrobbleSync::onError( " + error.shortString() + " " + error.toString() + ")");
// some problem with the network - we can skip this and try again later
if ((error instanceof SubMusic.HttpError)
|| (error instanceof SubMusic.GarminSdkError)) {
// decrement todos
d_todo -= 1;
// increment idx as first is stored, but not reported yet
d_idx += 1;
// sync next
sync();
return;
}
// Nextcloud has 'missing method' and reports
//
// if ((error instanceof AmpacheError)
// && (error.code() == AmpacheError.METHOD_MISSING))
//
// if ((error instanceof SubsonicError)
// && (error.code() == SubsonicError.NOT_FOUND))
//
// perform default action
// default action is to clear the list
// remove all records, since it is not possible to store them anyways
ScrobbleStore.removeAll();
// mark complete
Deferrable.complete();
}
function progress() {
var done = d_todo_total - d_todo;
var progress = (100 * done) / d_todo_total.toFloat();
return progress;
}
}

View File

@@ -7,8 +7,6 @@ class Store {
private var d_value = null; // variable that is reflected in the Application.Storage
function initialize(key, value) {
System.println("Store::initialize()");
d_key = key;
var stored = Application.Storage.getValue(d_key);
@@ -22,9 +20,18 @@ class Store {
function value() {
return d_value;
}
function setValue(value) {
d_value = value;
}
function update() {
Application.Storage.setValue(d_key, d_value);
return true; // true if successful
var success = true;
try {
Application.Storage.setValue(d_key, d_value);
} catch (exception instanceof Toybox.Lang.StorageFullException) {
success = false;
}
return success;
}
}

View File

@@ -6,6 +6,8 @@ using Toybox.Media;
class SubMusicContentDelegate extends Media.ContentDelegate {
private var d_iterator;
enum { START, SKIP_NEXT, SKIP_PREVIOUS, PLAYBACK_NOTIFY, COMPLETE, STOP, PAUSE, RESUME, }
private var d_events = ["Start", "Skip Next", "Skip Previous", "Playback Notify", "Complete", "Stop", "Pause", "Resume"];
function initialize() {
@@ -57,6 +59,7 @@ class SubMusicContentDelegate extends Media.ContentDelegate {
function onMore() {
System.println("onMore is called");
}
function onLibrary() {
System.println("onLibrary is called");
}
@@ -65,8 +68,28 @@ class SubMusicContentDelegate extends Media.ContentDelegate {
// been triggered for the given song
function onSong(contentRefId, songEvent, playbackPosition) {
System.println("onSong Event (" + d_events[songEvent] + "): " + getSongName(contentRefId) + " at position " + playbackPosition);
if (songEvent == PLAYBACK_NOTIFY) {
var id = findIdByRefId(contentRefId);
if (id == null) { return; }
ScrobbleStore.add(new Scrobble({
"id" => id,
}));
return;
}
}
function findIdByRefId(refId) {
var ids = SongStore.getIds();
for (var idx = 0; idx < ids.size(); ++idx) {
var isong = new ISong(ids[idx]);
if (refId == isong.refId()) {
return ids[idx];
}
}
return null;
}
function getSongName(refId) {
return Media.getCachedContentObj(new Media.ContentRef(refId, Media.CONTENT_TYPE_AUDIO)).getMetadata().title;
}

View File

@@ -71,8 +71,8 @@ class SubMusicContentIterator extends Media.ContentIterator {
PLAYBACK_CONTROL_SKIP_FORWARD,
PLAYBACK_CONTROL_SKIP_BACKWARD,
];
profile.playbackNotificationThreshold = 1;
profile.requirePlaybackNotification = false;
profile.playbackNotificationThreshold = 30;
profile.requirePlaybackNotification = true; // notify played
profile.skipPreviousThreshold = 5;
profile.supportsPlaylistPreview = true;
return profile;
@@ -167,7 +167,12 @@ class SubMusicContentIterator extends Media.ContentIterator {
// Retrieve the cached object from Media
function getObj(idx) {
return Media.getCachedContentObj(new Media.ContentRef(d_songs[idx], Media.CONTENT_TYPE_AUDIO));
var contentRef = new Media.ContentRef(d_songs[idx], Media.CONTENT_TYPE_AUDIO);
var content = Media.getCachedContentObj(contentRef);
var metadata = content.getMetadata();
var playbackStartPos = 0; // current playback position is 0, TODO load from storage if podcast
return new Media.ActiveContent(contentRef, metadata, playbackStartPos);
}
// reorder the playlist randomly

View File

@@ -21,10 +21,12 @@ module Storage {
SONGS, // dictionary where song id is key
SONGS_DELETE, // array of song ids of todelete songs (refCount == 0)
PLAYLISTS, // dictionary where playlist id is key
LAST_SYNC, // dictionary with details on last sync
PLAY_RECORDS, // array with play_record objects
VERSION = 200, // version string of store
}

View File

@@ -26,16 +26,25 @@ class SubMusicSyncDelegate extends Communications.SyncDelegate {
// show progress
Communications.notifySyncProgress(0);
// starting sync
// first sync is on Scrobbles
startScrobbleSync();
}
function startPlaylistSync() {
// starting sync
d_todo = PlaylistStore.getIds();
d_todo_total = d_todo.size();
// start async loop, provide callback to onLoopCompleted
d_loop = new DeferredFor(0, d_todo.size(), self.method(:step), self.method(:onComplete));
d_loop = new DeferredFor(0, d_todo.size(), self.method(:stepPlaylist), self.method(:onPlaylistsDone));
d_loop.run();
}
function onComplete() {
function stepPlaylist(idx) {
return new PlaylistSync(d_provider, d_todo[idx], method(:onPlaylistProgress));
}
function onPlaylistsDone() {
// finalize removals (deletes are deferred, to prevent redownloading)
var todelete = SongStore.getDeletes();
for (var idx = 0; idx < todelete.size(); ++idx) {
@@ -51,20 +60,31 @@ class SubMusicSyncDelegate extends Communications.SyncDelegate {
Communications.notifySyncComplete(null);
Application.Storage.setValue(Storage.LAST_SYNC, { "time" => Time.now().value(), });
}
function startScrobbleSync() {
var deferrable = new ScrobbleSync(d_provider, method(:onScrobbleProgress));
deferrable.setCallback(method(:startPlaylistSync));
if (deferrable.run()) {
startPlaylistSync(); // continue with playlist sync afterwards
}
// not completed, so wait for callback
}
function onProgress(progress) {
function onPlaylistProgress(progress) {
System.println("Sync Progress: list " + (d_loop.idx() + 1) + " of " + d_loop.end() + " is on " + progress + " %");
progress += (100 * d_loop.idx());
progress += (100 * d_loop.idx()); // half of 100% for playlist progress
progress /= d_loop.end().toFloat();
System.println(progress.toNumber());
Communications.notifySyncProgress(progress.toNumber());
}
function step(idx) {
return new PlaylistSync(d_provider, d_todo[idx], method(:onProgress));
}
function onScrobbleProgress(progress) {
System.println("Sync Progress: scrobble is on " + progress + " %");
}
// Sync always needed to verify new songs on the server
function isSyncNeeded() {

View File

@@ -7,6 +7,7 @@ using SubMusic;
class SubsonicAPI {
private var d_base_url;
private var d_client;
private var d_params = {};
@@ -16,8 +17,8 @@ class SubsonicAPI {
function initialize(settings, fallback) {
set(settings);
var client = (WatchUi.loadResource(Rez.Strings.AppName) + " v" + (new SubMusicVersion(null).toString()));
d_params.put("c", client);
d_client = (WatchUi.loadResource(Rez.Strings.AppName) + " v" + (new SubMusicVersion(null).toString()));
d_params.put("c", d_client);
d_params.put("v", "1.10.2"); // subsonic api version
d_params.put("f", "json"); // request format
@@ -44,7 +45,35 @@ class SubsonicAPI {
}
d_callback.invoke(data["subsonic-response"]);
}
function scrobble(callback, params) {
System.println("SubsonicAPI::scrobble(params: " + params + ")");
d_callback = callback;
var url = d_base_url + "scrobble";
// construct parameters
var id = params["id"];
var time = params["time"];
params = d_params;
params["id"] = id; // set id for scrobble
params["time"] = time; // set time for scrobble
Communications.makeWebRequest(url, params, {}, self.method(:onGetPlaylist));
}
function onScrobble(responseCode, data) {
System.println("SubsonicAPI::onScrobble( responseCode: " + responseCode + ", data: " + data + ")");
// check if request was successful and response is ok
var error = checkResponse(responseCode, data);
if (error) {
d_fallback.invoke(error); // add function name and variables available ?
return;
}
d_callback.invoke(data["subsonic-response"]); // empty response on success
}
/**
* getPlaylists
@@ -159,8 +188,10 @@ class SubsonicAPI {
function update(settings) {
System.println("SubsonicAPI::update(settings)");
// no persistent session info, so only update variables for future requests
// update the settings
set(settings);
// no persistent session info, so only update variables for future requests
}
function set(settings) {
@@ -170,4 +201,8 @@ class SubsonicAPI {
System.println("SubsonicAPI::set(url: " + d_base_url + " user: " + d_params.get("u") + ", pass: " + d_params.get("p") + ")");
}
function client() {
return d_client;
}
}

View File

@@ -52,6 +52,10 @@ class SubsonicError extends SubMusic.ApiError {
return d_msg;
}
function code() {
return d_code;
}
static function is(responseCode, data) {
// subsonic API errors have http code 200, status failed and an element 'error'

View File

@@ -17,6 +17,7 @@ class SubsonicProvider {
// functions:
// - ping returns an object with server version
// - recordPlay submit a play
// - getAllPlaylists returns array of all playlists available for Subsonic user
// - getPlaylistSongs returns an array of songs on the playlist with id
// - getRefId returns a refId for a song by id (this downloads the song)
@@ -34,6 +35,16 @@ class SubsonicProvider {
d_api.ping(self.method(:onPing));
}
function recordPlay(id, time, callback) {
d_callback = callback;
var params = {
"id" => id,
"time" => time,
};
d_api.scrobble(self.method(:onRecordPlay), params); // scrobble is only way to submit a play
}
/**
* getAllPlaylists
@@ -104,6 +115,12 @@ class SubsonicProvider {
d_callback.invoke(response);
}
function onRecordPlay(response) {
System.println("SubsonicProvider::onRecordPlay( response = " + response + ")");
d_callback.invoke(response); // expected empty element
}
function onGetAllPlaylists(response) {
System.println("SubsonicProvider::onGetAllPlaylists( response = " + response + ")");

View File

@@ -79,6 +79,7 @@ module SubMusic {
function onBack() {
if (d_callback) { d_callback.invoke(); }
else { Menu2InputDelegate.onBack(); }
}
}

View File

@@ -54,7 +54,7 @@ module SubMusic {
}
function onMoreInfo() {
WatchUi.pushView(new SubMusic.Menu.MoreView(), new SubMusic.Menu.Delegate(), WatchUi.SLIDE_IMMEDIATE);
WatchUi.pushView(new SubMusic.Menu.MoreView(), new SubMusic.Menu.Delegate(null), WatchUi.SLIDE_IMMEDIATE);
}
}

View File

@@ -15,8 +15,4 @@ class TapDelegate extends WatchUi.BehaviorDelegate {
d_callback.invoke();
}
}
function onBack() {
WatchUi.popView(WatchUi.SLIDE_IMMEDIATE);
}
}