/**
* MobScrobblerImpl.java
*
* This program is distributed under the terms of the GNU General Public
* License
* Copyright 2008 NJ Pearman
*
* This file is part of MobScrob.
*
* MobScrob 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.
*
* MobScrob 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 MobScrob. If not, see <http://www.gnu.org/licenses/>.
*/
package mobscrob.scrobbler;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.security.DigestException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.Vector;
import javax.microedition.io.Connector;
import javax.microedition.io.HttpConnection;
import mobscrob.alert.AlertType;
import mobscrob.alert.Alertable;
import mobscrob.alert.Alerter;
import mobscrob.id3.TrackMetadata;
import mobscrob.logging.Log;
import mobscrob.logging.LogFactory;
import mobscrob.properties.MobScrobProperties;
import mobscrob.util.ByteUtil;
import mobscrob.util.microedition.HTTPUtil;
/**
* @author Neill
*
*/
public class MobScrobblerImpl implements Alerter, MobScrobbler {
private static final Log log = LogFactory.getLogger(MobScrobblerImpl.class);
private static final String AUDIOSCROBBLER_URL = "http://post.audioscrobbler.com/";
private static final String TEST_CLIENT_ID = "tst";
private static final String TEST_CLIENT_VERSION = "1.0";
private static final String DEFAULT_HOST = "post.audioscrobbler.com";
private static final String DEFAULT_USER_AGENT = "MobScrobblerImpl (MIDP2.0)";
private static final String DEFAULT_POST_CONTENT_TYPE = "application/x-www-form-urlencoded";
private static final String SCROBBLE_OK = "OK";
private static final String SCROBBLE_BAD_SESSION = "BADSESSION";
private static final String SCROBBLE_FAILED = "FAILED";
private static final String HEADER_ACCEPT = "Accept";
private static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language";
private static final String HEADER_CONTENT_LENGTH = "Content-Length";
private static final String HEADER_CONTENT_TYPE = "Content-Type";
private static final String HEADER_USER_AGENT = "User-Agent";
// these param names are pre-encoded for submission.
private static final String ENC_OPEN_BRACKET = "%5b";
private static final String ENC_CLOSE_BRACKET = "%5d";
private static final String ENC_EQUALS = "=";
private static final String PARAM_SUBMIT_ALBUM = "b";
private static final String PARAM_SUBMIT_ARTIST = "a";
private static final String PARAM_SUBMIT_LENGTH = "l";
private static final String PARAM_SUBMIT_MUSICBRAINZ = "m";
private static final String PARAM_SUBMIT_NUMBER = "n";
private static final String PARAM_SUBMIT_RATING = "r";
private static final String PARAM_SUBMIT_SESSION = "s";
private static final String PARAM_SUBMIT_SOURCE = "o";
private static final String PARAM_SUBMIT_TIME = "i";
private static final String PARAM_SUBMIT_TRACK = "t";
private int failureCount;
private MobScrobProperties props;
private Alertable alertable;
private Session session;
private boolean processing, shutdown;
private TrackMetadata holdingTrack;
public MobScrobblerImpl(MobScrobProperties props) {
this.props = props;
this.failureCount = 0;
}
/* (non-Javadoc)
* @see mobscrob.scrobbler.MobScrobbler#getHoldingTrack()
*/
public TrackMetadata getHoldingTrack() {
return this.holdingTrack;
}
/* (non-Javadoc)
* @see mobscrob.scrobbler.MobScrobbler#handshake()
*/
public boolean handshake() throws IOException, NoSuchAlgorithmException,
DigestException {
final String methodName = "3";
long timestamp = (long) ((new Date().getTime()) / 1000);
// create handshake URL
StringBuffer urlBuf = new StringBuffer(AUDIOSCROBBLER_URL);
urlBuf.append("?hs=true&").append("p=1.2.1&").append("c=").append(
TEST_CLIENT_ID).append("&v=").append(TEST_CLIENT_VERSION)
.append("&u=").append(props.getUsername()).append("&t=")
.append(timestamp).append("&a=").append(
MD5Util.md5Hash(props.getHashedPassword() + timestamp));
String url = urlBuf.toString();
log.info(methodName, "Attempting to connect to " + url);
// create HTTPConnection to URL
byte[] body = HTTPUtil.getUrl(url, DEFAULT_HOST);
Vector lines = ByteUtil.readLines(body);
// check is OK
String ok = (String) lines.elementAt(0);
if (ok == null) {
log.error(methodName, "Handshake response should not be null");
} else if (!"OK".equals(ok)) {
log.error(methodName, "Handshake response not OK: " + ok);
}
// get lines
String sessionID = (String) lines.elementAt(1);
String nowPlayingUrl = (String) lines.elementAt(2);
String submitUrl = (String) lines.elementAt(3);
session = new Session(sessionID, nowPlayingUrl, submitUrl);
log.info(methodName, "Got session: " + session);
try {
session.validateSessionInfo();
} catch (ScrobbleException ex) {
log.error(methodName, "Invalid scrobble session: " + ex.getMessage(), ex);
}
return true;
}
/* (non-Javadoc)
* @see mobscrob.scrobbler.MobScrobbler#scrobble(mobscrob.id3.TrackMetadata)
*/
public void scrobble(TrackMetadata track) throws RequeueException, ScrobbleOfflineException {
final String methodName = "4";
holdingTrack = track;
HttpConnection conn = null;
OutputStream os = null;
// first check if scrobbling offline
if(props.scrobbleOffline()) {
throw new ScrobbleOfflineException();
}
if (track == null) {
log.error(methodName, "Can't scrobble null track");
return;
} else if (track.isInvalidID3Tag()) {
log.error(methodName, "Can't scrobble track with invalid ID3 tag");
}
try {
if (!validSession()) {
throw new RequeueException("Unable to establish handshake");
}
processing = true;
holdingTrack = null;
byte[] paramBytes = createSubmissionParams(track);
// get host from submit URL
String submitUrl = session.submitUrl;
String host = submitUrl.substring(7, submitUrl.lastIndexOf(':'));
log.info(methodName, "Host: " + host);
// set up HTTP connection
conn = (HttpConnection) Connector.open(session.submitUrl);
conn.setRequestMethod(HttpConnection.POST);
conn.setRequestProperty(HTTPUtil.HEADER_HOST, host);
conn.setRequestProperty(HEADER_USER_AGENT, DEFAULT_USER_AGENT);
conn.setRequestProperty(HEADER_CONTENT_TYPE, DEFAULT_POST_CONTENT_TYPE);
conn.setRequestProperty(HEADER_CONTENT_LENGTH, String.valueOf(paramBytes.length));
conn.setRequestProperty(HEADER_ACCEPT, "*/*");
conn.setRequestProperty(HEADER_ACCEPT_LANGUAGE, "en");
log.info(methodName, "Set up connection details");
// try opening output stream first
os = conn.openOutputStream();
// try writing byte by byte
for (int i = 0; i < paramBytes.length; i++) {
os.write((int) paramBytes[i]);
}
log.info(methodName, "Written to stream");
byte[] response = HTTPUtil.readHttpResponse(conn);
String responseStatus = new String(response);
log.info(methodName, "Scrobble response: " + responseStatus);
failureCount = 0;
if (responseStatus.indexOf(SCROBBLE_OK) >= 0) {
log.info(methodName, "Success submitting track - check last.fm!!");
} else if (responseStatus.indexOf(SCROBBLE_BAD_SESSION) >= 0) {
log.error(methodName, "Invalid session, requeueing track and invalidating session");
session.invalidate();
throw new RequeueException("BADSESSION returned from audioscrobbler");
} else if (responseStatus.indexOf(SCROBBLE_FAILED) >= 0) {
log.error(methodName, "Submission failed: " + responseStatus);
throw new RequeueException("Failure submitting to audioscrobbler");
} else {
String msg = "Unexpected response from audioscrobbler: " + responseStatus;
log.error(methodName, msg);
throw new RequeueException(msg);
}
} catch (IOException ex) {
failureCount++;
String msg = "Error scrobbling track: " + ex.getMessage();
log.error(methodName, msg, ex);
if (failureCount > 2) {
session.invalidate();
failureCount = 0;
}
throw new RequeueException(msg);
} finally {
track.attemptedSubmit();
if (os != null) {
try { os.close(); }
catch (Exception e) { log.error(methodName, "Unable to close HTTP output stream: " + e.getMessage(), e); }
}
HTTPUtil.closeHttpConnection(conn);
processing = false;
}
}
private byte[] createSubmissionParams(TrackMetadata track)
throws UnsupportedEncodingException {
final String methodName = "5";
StringBuffer paramsBuf = new StringBuffer();
paramsBuf.append(PARAM_SUBMIT_SESSION).append('=').append(
HTTPUtil.encodeParam(session.sessionID)).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_ALBUM, 0).append(
HTTPUtil.encodeParam(track.getAlbumTitle())).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_ARTIST, 0).append(
HTTPUtil.encodeParam(track.getArtist())).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_LENGTH, 0).append(
String.valueOf(track.getTrackLengthInSeconds())).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_MUSICBRAINZ, 0)
.append("").append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_NUMBER, 0).append(
String.valueOf(track.getTrackNumber())).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_RATING, 0).append("")
.append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_SOURCE, 0).append("P")
.append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_TIME, 0).append(
String.valueOf(track.getStartTimestampInSeconds())).append('&');
appendSubmissionParam(paramsBuf, PARAM_SUBMIT_TRACK, 0).append(
HTTPUtil.encodeParam(track.getTrackTitle()));
String params = paramsBuf.toString();
byte[] paramBytes = params.getBytes();
// need to encode URL
log.info(methodName, "Generated request params: " + params);
return paramBytes;
}
private StringBuffer appendSubmissionParam(StringBuffer buf, String paramName, int count) {
buf.append(paramName).append(ENC_OPEN_BRACKET).append(count).append(ENC_CLOSE_BRACKET).append(ENC_EQUALS);
return buf;
}
public void alert(AlertType type, String msg) {
if (alertable != null) {
alertable.alert(type, msg);
}
}
public void setAlertable(Alertable alertable) {
this.alertable = alertable;
}
private boolean validSession() {
final String methodName = "7";
if (session == null || session.invalid()) {
log.error(methodName, "Session is invalid, attempting handshake");
while ((session == null || session.invalid()) && !shutdown) {
int retryTime = session == null ? 0 : session.getRetryWaitTime();
log.info(methodName, "Waiting for next handshake attempt in " + retryTime + "ms");
// wait five seconds if unsuccessful
try { Thread.sleep(session.getRetryWaitTime()); }
catch (Exception e) {}
try {
if (handshake()) {
session.resetWaitTime();
break;
}
} catch (Exception ex) {
log.error(methodName, "Handshake error: " + ex.getMessage(), ex);
if (session == null) {
log.info(methodName, "Session null, creating invalid session");
session = new Session();
}
session.increaseWaitTime();
}
}
if (session == null || session.invalid()) {
String msg = "Handshake failed, unable to scrobble";
log.info(methodName, msg);
return false;
}
}
return true;
}
/* (non-Javadoc)
* @see mobscrob.scrobbler.MobScrobbler#shutdown()
*/
public void shutdown() {
final String methodName = "8";
shutdown = true;
try {
while (processing) {
log.info(methodName, "Can't shutdown, still scrobbling");
Thread.sleep(5000);
}
} catch (InterruptedException e) {
log.error(methodName, "Interrupted while waiting to finish processing.", e);
}
}
/**
* Represents an AudioScrobbler session
*
* @author Neill
*
*/
private static class Session {
private static final int MAX_RETRY = 120 * 60 * 1000;
private static final int ONE_MINUTE = 60 * 1000;
private final String sessionID;
private final String nowPlayingUrl;
private final String submitUrl;
private int retryWaitTime;
private boolean invalid;
/**
* Creates an invalid Session, with null values for
*/
public Session() {
this(null, null, null);
invalid = true;
}
public Session(String sessionID, String nowPlayingUrl, String submitUrl) {
this.sessionID = sessionID;
this.nowPlayingUrl = nowPlayingUrl;
this.submitUrl = submitUrl;
this.retryWaitTime = 0;
}
/**
* Validates the session information. Method call isn't compulsory but
* is recommended before attempts to scrobble are made.
*/
public void validateSessionInfo() throws ScrobbleException {
if (invalid) {
return;
}
if (sessionID == null) {
// error
invalid = true;
throw new ScrobbleException("Session ID is null");
} else if (nowPlayingUrl == null) {
// error
invalid = true;
throw new ScrobbleException("Now playing URL is null");
} else if (submitUrl == null) {
// error
invalid = true;
throw new ScrobbleException("Submission URL is null");
}
invalid = false;
}
public boolean invalid() {
return invalid;
}
public void invalidate() {
invalid = true;
}
public int getRetryWaitTime() {
return retryWaitTime;
}
public void increaseWaitTime() {
if (retryWaitTime == 0) {
retryWaitTime = ONE_MINUTE;
} else if (retryWaitTime < MAX_RETRY) {
retryWaitTime *= 2;
}
}
public void resetWaitTime() {
retryWaitTime = 0;
}
public String toString() {
StringBuffer buf = new StringBuffer(
"[MobScrobblerImpl.Session [Session ID: ");
buf.append(sessionID).append("] [Now playing URL: ").append(
nowPlayingUrl).append("] [Submit URL: ").append(submitUrl)
.append("] [Valid? ").append(!invalid).append("]]");
return buf.toString();
}
}
private static class ScrobbleException extends Exception {
ScrobbleException(String msg) {
super(msg);
}
}
}