root/trunk/src/main/org/lastpod/Scrobbler.java

Revision 85, 18.1 kB (checked in by chris, 3 years ago)

Completes #20: Saves the history file and clears submitted chunks
properly. These actions are now taking place after each chunk is
submitted.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Id
Line 
1 /*
2  * LastPod is an application used to publish one's iPod play counts to Last.fm.
3  * Copyright (C) 2007  Chris Tilden
4  *
5  * This program is free software; you can redistribute it and/or
6  * modify it under the terms of the GNU General Public License
7  * as published by the Free Software Foundation; either version 2
8  * of the License, or (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License
16  * along with this program; if not, write to the Free Software
17  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18  */
19 package org.lastpod;
20
21 import org.lastpod.chunk.Chunk;
22 import org.lastpod.chunk.ChunkUtil;
23
24 import org.lastpod.util.IoUtils;
25 import org.lastpod.util.MiscUtilities;
26
27 import java.io.BufferedReader;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.InputStreamReader;
31 import java.io.OutputStream;
32 import java.io.OutputStreamWriter;
33 import java.io.Reader;
34 import java.io.UnsupportedEncodingException;
35
36 import java.net.HttpURLConnection;
37 import java.net.MalformedURLException;
38 import java.net.ProtocolException;
39 import java.net.URL;
40 import java.net.URLEncoder;
41
42 import java.security.MessageDigest;
43 import java.security.NoSuchAlgorithmException;
44
45 import java.text.SimpleDateFormat;
46
47 import java.util.Date;
48 import java.util.List;
49 import java.util.TimeZone;
50 import java.util.logging.Level;
51 import java.util.logging.Logger;
52 import java.util.regex.Matcher;
53 import java.util.regex.Pattern;
54
55 import javax.security.auth.login.FailedLoginException;
56
57 /**
58  * @author muti
59  * @version $Id$
60  */
61 public class Scrobbler {
62     /**
63      * AudioScrobbler suggests a maximum number of tracks per chunk.
64      */
65     private static final int MAX_TRACKS_PER_CHUNK = 10;
66
67     /**
68      * The minimum length (in seconds) of a track that meets Last.fm guidelines.
69      */
70     private static final int MIN_TRACK_SECONDS = 30;
71     private String username;
72     private String encryptedPassword;
73     private String backupUrl;
74     private String challenge;
75     private String submitHost;
76     private Integer submitPort;
77     private String submitUrl;
78
79     /**
80      * Stores the chunks of tracks to be submitted.
81      */
82     private List trackChunks;
83
84     /**
85      * Displays the submission progress as this class updates it.
86      */
87     private ChunkProgress chunkProgress;
88
89     /**
90      * The number of seconds to pause between submissions. Only if the server
91      * asks to do so.
92      */
93     private int interval = 0;
94     private Logger logger;
95
96     public Scrobbler(String username, String encryptedPassword, String backupUrl) {
97         this.username = username;
98         this.encryptedPassword = encryptedPassword;
99         this.backupUrl = backupUrl;
100         logger = Logger.getLogger(getClass().getPackage().getName());
101     }
102
103     /**
104      * Sets the object that displays submission progress.  This object updates
105      * the progress as it is processing.
106      * @param chunkProgress  The object that displays submission progress.
107      */
108     public void setChunkProgress(ChunkProgress chunkProgress) {
109         this.chunkProgress = chunkProgress;
110     }
111
112     /**
113      * Sets the tracks that are submitted.
114      * @param recentPlayed  A list of tracks to submit.
115      */
116     public void setTracksToSubmit(final List recentPlayed) {
117         if (recentPlayed.size() == 0) {
118             throw new RuntimeException("No tracks to submit");
119         }
120
121         /* Converts the recentPlayed List into a List of Chunk objects.  Each
122          * chunk stores at most 10 tracks.  Each chunk will be submitted to
123          * Last.fm individually, per their guidelines.
124          */
125         trackChunks = ChunkUtil.createChunks(recentPlayed, MAX_TRACKS_PER_CHUNK);
126
127         /* Add 1 because the handshake will also be included in the progress. */
128         chunkProgress.setNumberOfChunks(trackChunks.size() + 1);
129     }
130
131     public void handshake()
132             throws UnsupportedEncodingException, MalformedURLException, IOException,
133                 FailedLoginException {
134         if (trackChunks.size() == 0) {
135             throw new RuntimeException("No tracks to submit");
136         }
137
138         String statusMessage = "Beginning Handshake";
139         chunkProgress.setSubmitStatusMessage(statusMessage);
140         logger.log(Level.INFO, statusMessage);
141
142         String args = "?hs=true&p=1.1&c=apd&v=0.1&u=" + URLEncoder.encode(username, "UTF-8");
143         URL url = new URL("http://post.audioscrobbler.com/" + args);
144
145         statusMessage = "Handshaking to URL: " + url.toString();
146         chunkProgress.setSubmitStatusMessage(statusMessage);
147         logger.log(Level.FINE, statusMessage);
148
149         HttpURLConnection c = (HttpURLConnection) url.openConnection();
150
151         c.setDoInput(true);
152         c.setRequestMethod("GET");
153         c.setUseCaches(false);
154         c.setRequestProperty("Connection", "close");
155         c.connect();
156
157         if (c.getResponseCode() != 200) {
158             throw new RuntimeException("Invalid HTTP return code");
159         }
160
161         BufferedReader breader = new BufferedReader(new InputStreamReader(c.getInputStream()));
162
163         String content = null;
164         String buffer = null;
165
166         while ((buffer = breader.readLine()) != null) {
167             if (content != null) {
168                 content += (buffer + "\n");
169             } else {
170                 content = buffer + "\n";
171             }
172         }
173
174         logger.log(Level.FINE, "Received from server:\n" + content);
175
176         if ((content == null) || (content.length() == 0)) {
177             statusMessage = "Invalid response received from AudioScrobbler";
178             chunkProgress.setSubmitStatusMessage(statusMessage);
179             throw new RuntimeException(statusMessage);
180         }
181
182         String[] lines = content.split("\n");
183
184         if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("FAILED")) {
185             statusMessage = lines[0].substring(7);
186             chunkProgress.setSubmitStatusMessage(statusMessage);
187             throw new RuntimeException(statusMessage);
188         }
189
190         if ((lines[0].length() >= 7) && lines[0].substring(0, 7).equals("BADUSER")) {
191             statusMessage = "Invalid Username";
192             chunkProgress.setSubmitStatusMessage(statusMessage);
193             throw new FailedLoginException(statusMessage);
194         }
195
196         if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("UPDATE")) {
197             statusMessage = "Update your client:" + lines[0].substring(7);
198             chunkProgress.setSubmitStatusMessage(statusMessage);
199             throw new RuntimeException(statusMessage);
200         }
201
202         /* Sets the interval, if it is present in the response. */
203         if ((lines.length >= 4) && (lines[3].length() >= 10)) {
204             String wait = lines[3].substring(9);
205             interval = Integer.parseInt(wait);
206         }
207
208         Pattern p = Pattern.compile("http://(.*):(\\d+)(.*)");
209         Matcher m = p.matcher(lines[2]);
210
211         if (m.matches()) {
212             submitHost = m.group(1);
213             submitPort = new Integer(m.group(2));
214             submitUrl = m.group(3);
215             logger.log(Level.FINE, "Set submithost to: " + submitHost);
216             logger.log(Level.FINE, "Set submitport to: " + submitPort);
217             logger.log(Level.FINE, "Set submiturl to: " + submitUrl);
218         } else {
219             throw new RuntimeException("Invalid POST URL returned, unable to continue");
220         }
221
222         challenge = lines[1];
223
224         /* Displays some progress update once the handshake is completed. */
225         chunkProgress.updateCurrentChunk(1);
226
227         statusMessage = "Handshake completed";
228         chunkProgress.setSubmitStatusMessage(statusMessage);
229         logger.log(Level.INFO, statusMessage);
230     }
231
232     public void submitTracks()
233             throws UnsupportedEncodingException, NoSuchAlgorithmException, MalformedURLException,
234                 IOException, FailedLoginException {
235         String statusMessage = "Submitting tracks...";
236         chunkProgress.setSubmitStatusMessage(statusMessage);
237         logger.log(Level.INFO, statusMessage);
238
239         if (trackChunks.size() == 0) {
240             statusMessage = "No tracks to submit";
241             chunkProgress.setSubmitStatusMessage(statusMessage);
242             throw new RuntimeException(statusMessage);
243         }
244
245         MessageDigest md = MessageDigest.getInstance("MD5");
246         String md5pass = encryptedPassword + challenge;
247         String md5chal = MiscUtilities.hexEncode(md.digest(md5pass.getBytes()));
248         String urlEncodedUsername = URLEncoder.encode(username, "UTF-8");
249         String urlEncodedChallange = URLEncoder.encode(md5chal, "UTF-8");
250
251         Chunk chunk = null;
252
253         for (int i = 0; i < trackChunks.size(); i++) {
254             pauseIfRequired();
255
256             chunk = (Chunk) trackChunks.get(i);
257
258             String queryString = "u=" + urlEncodedUsername + "&" + "s=" + urlEncodedChallange;
259
260             int tracknum = 0;
261
262             for (int j = 0; j < chunk.getChunkSize(); j++) {
263                 TrackItem track = (TrackItem) chunk.getContent().get(j);
264
265                 /* Per Last.fm guidelines; do not submit tracks that are less
266                  * than 30 characters in length.
267                  */
268                 if (track.getLength() < MIN_TRACK_SECONDS) {
269                     continue;
270                 }
271
272                 queryString += buildTrackQueryString(track, tracknum);
273
274                 tracknum++;
275             }
276
277             String content = null;
278
279             /* If a backup URL is specified then two submits will take place.  A
280              * backup URL can be used to send your information to another server.
281              */
282             if ((backupUrl != null) && !backupUrl.equals("")) {
283                 content = fetchContent(backupUrl, queryString);
284                 logger.log(Level.FINE, "Received from server:\n" + content);
285             }
286
287             String urlString = "http://" + submitHost + ":" + submitPort + submitUrl;
288             content = fetchContent(urlString, queryString);
289
290             String[] lines = content.split("\n");
291
292             /* Sets the interval, if it is present in the response. */
293             if ((lines.length >= 2) && (lines[1].length() >= 10)) {
294                 String wait = lines[1].substring(9);
295                 interval = Integer.parseInt(wait);
296             }
297
298             if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("FAILED")) {
299                 throw new RuntimeException(lines[0].substring(7));
300             }
301
302             if ((lines[0].length() >= 7) && lines[0].substring(0, 7).equals("BADAUTH")) {
303                 throw new FailedLoginException("Invalid username/password");
304             }
305
306             if ((lines[0].length() >= 2) && !lines[0].substring(0, 2).equals("OK")) {
307                 throw new RuntimeException("Unknown error submitting tracks");
308             }
309
310             /* The chunk is successfully written to last.fm. Makes sure the
311              * tracks are marked as inactive.  Writes the history file.
312              * This is done after each chunk because if the next chunk fails
313              * the history file should reflect where the failure occurred.
314              */
315             for (int j = 0; j < chunk.getChunkSize(); j++) {
316                 TrackItem track = (TrackItem) chunk.getContent().get(j);
317                 track.setActive(Boolean.FALSE);
318             }
319
320             addHistories(chunk.getContent());
321
322             /* Add 2 to progress.  1 because chunk progress starts at 1, whereas
323              * this for-loop is zero indexed.  1 because the handshake is also
324              * part of the progress.
325              */
326             chunkProgress.updateCurrentChunk(i + 2);
327         }
328
329         chunkProgress.setSubmitStatusMessage("Done. You may now sync your iPod.");
330         logger.log(Level.INFO, "Tracks submitted");
331         logger.log(Level.INFO,
332             "You may now sync your iPod with your music management software "
333             + "or delete 'Play Counts' from the iTunes folder!");
334
335         chunkProgress.setCompletionStatus(true);
336     }
337
338     /**
339      * Last.fm will informs this client if it needs to pause.  This occurs when
340      * Last.fm is extremely busy.
341      *
342      * This function will pause the required amount of time if it is needed.
343      *
344      */
345     private void pauseIfRequired() {
346         if (interval != 0) {
347             try {
348                 Thread.sleep(interval * 1000);
349             } catch (InterruptedException e) {
350                 /* If interrupted it will simply submit early.  Therefore
351                  * it will not fail if this occurs.
352                  */
353             }
354         }
355     }
356
357     /**
358      * Builds the query string for the given <code>TrackItem</code> and track
359      * number.
360      * @param track  The track to build a query string of.
361      * @param trackNum  The number of the track in the submission.
362      * @return  The complete query string for the given track.
363      * @throws UnsupportedEncodingException  Thrown if errors occur.
364      */
365     private String buildTrackQueryString(TrackItem track, int trackNum)
366             throws UnsupportedEncodingException {
367         StringBuffer trackQueryString = new StringBuffer();
368
369         //TODO: Is all this UTF-8 encoding needed?
370         String artistutf8 = new String(track.getArtist().getBytes("UTF-8"), "UTF-8");
371         String trackutf8 = new String(track.getTrack().getBytes("UTF-8"), "UTF-8");
372         String albumutf8 = new String(track.getAlbum().getBytes("UTF-8"), "UTF-8");
373         String trackString = Long.toString(track.getLength());
374         Date date = new Date(track.getLastplayed() * 1000);
375         SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
376         format.setTimeZone(TimeZone.getTimeZone("GMT:00"));
377
378         String dateString = format.format(date);
379
380         trackQueryString.append("&a[" + trackNum + "]=" + URLEncoder.encode(artistutf8, "UTF-8"));
381         trackQueryString.append("&t[" + trackNum + "]=" + URLEncoder.encode(trackutf8, "UTF-8"));
382         trackQueryString.append("&b[" + trackNum + "]=" + URLEncoder.encode(albumutf8, "UTF-8"));
383         trackQueryString.append("&m[" + trackNum + "]=");
384         trackQueryString.append("&l[" + trackNum + "]=" + URLEncoder.encode(trackString, "UTF-8"));
385         trackQueryString.append("&i[" + trackNum + "]=" + URLEncoder.encode(dateString, "UTF-8"));
386
387         return trackQueryString.toString();
388     }
389
390     /**
391      * Creates the histories and writes them to a file.
392      * @param activeRecentPlayed  The list of active recently played tracks.
393      */
394     public void addHistories(List activeRecentPlayed) {
395         for (int i = 0; i < activeRecentPlayed.size(); i++) {
396             TrackItem track = (TrackItem) activeRecentPlayed.get(i);
397             History.getInstance(null).addhistory(track.getLastplayed());
398         }
399
400         History.getInstance(null).write();
401     }
402
403     /**
404      * Adds inactive recent played tracks to the histories file.  This is done
405      * so they will be preserved in the histories.
406      * @param inactiveRecentPlayed  The list of inactive recently played tracks.
407      */
408     public void addInactiveToHistories(List inactiveRecentPlayed) {
409         for (int i = 0; i < inactiveRecentPlayed.size(); i++) {
410             TrackItem track = (TrackItem) inactiveRecentPlayed.get(i);
411
412             if (History.getInstance(null).isInHistory(track.getLastplayed())) {
413                 History.getInstance(null).addhistory(track.getLastplayed());
414             }
415         }
416     }
417
418     /**
419      * Fetches the HTTP content given a URL String and a query String.
420      * @param urlString  The URL to fetch from.
421      * @param queryString  The query String to submit.
422      * @return  The content returned from the request.
423      * @throws MalformedURLException  Thrown if exceptions occur.
424      * @throws IOException  Thrown if exceptions occur.
425      * @throws ProtocolException  Thrown if exceptions occur.
426      */
427     private String fetchContent(String urlString, String queryString)
428             throws MalformedURLException, IOException, ProtocolException {
429         String content = null;
430         URL url = new URL(urlString);
431         logger.log(Level.FINE, "Submitting tracks to URL: " + url.toString());
432
433         HttpURLConnection c = (HttpURLConnection) url.openConnection();
434         c.setRequestMethod("POST");
435         c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
436         c.setRequestProperty("Content-Length", new Integer(queryString.length()).toString());
437         c.setRequestProperty("Connection", "close");
438         c.setDoInput(true);
439         c.setDoOutput(true);
440         c.setUseCaches(false);
441         c.connect();
442
443         logger.log(Level.FINE, "POST query string:\n" + queryString);
444
445         OutputStream out = null;
446         OutputStreamWriter writer = null;
447         InputStream in = null;
448         Reader reader = null;
449         BufferedReader bufferedReader = null;
450
451         try {
452             out = c.getOutputStream();
453             writer = new OutputStreamWriter(out);
454             writer.write(queryString);
455             writer.flush();
456
457             IoUtils.cleanup(null, writer);
458             IoUtils.cleanup(null, out);
459
460             if (c.getResponseCode() != 200) {
461                 throw new RuntimeException("Invalid HTTP return code");
462             }
463
464             in = c.getInputStream();
465             reader = new InputStreamReader(in);
466             bufferedReader = new BufferedReader(reader);
467
468             String buffer = null;
469
470             while ((buffer = bufferedReader.readLine()) != null) {
471                 if (content != null) {
472                     content += (buffer + "\n");
473                 } else {
474                     content = buffer + "\n";
475                 }
476             }
477         } finally {
478             IoUtils.cleanup(null, writer);
479             IoUtils.cleanup(null, out);
480             IoUtils.cleanup(bufferedReader, null);
481             IoUtils.cleanup(reader, null);
482             IoUtils.cleanup(in, null);
483         }
484
485         logger.log(Level.FINE, "Received from server:\n" + content);
486
487         if ((content == null) || (content.length() == 0)) {
488             throw new RuntimeException("Invalid response received from AudioScrobbler");
489         }
490
491         return content;
492     }
493 }
Note: See TracBrowser for help on using the browser.