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

Revision 104, 22.3 kB (checked in by chris, 2 years ago)

r7962@ctilden-laptop: chris | 2008-10-19 20:04:57 -0700

r7958@ctilden-laptop: chris | 2008-10-19 19:26:36 -0700
version for working with cached submissions
r7959@ctilden-laptop: chris | 2008-10-19 19:29:05 -0700
merge of Maksim Liauchuk's patch from 7/6/2008
r7960@ctilden-laptop: chris | 2008-10-19 19:30:46 -0700
applied a code-format
r7961@ctilden-laptop: chris | 2008-10-19 19:43:58 -0700
fixes for minor javadoc errors

  • 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 import org.lastpod.util.XmlUtils;
27
28 import org.w3c.dom.Document;
29 import org.w3c.dom.Element;
30 import org.w3c.dom.NodeList;
31
32 import org.xml.sax.SAXException;
33
34 import java.io.BufferedReader;
35 import java.io.File;
36 import java.io.FileOutputStream;
37 import java.io.IOException;
38 import java.io.InputStream;
39 import java.io.InputStreamReader;
40 import java.io.OutputStream;
41 import java.io.OutputStreamWriter;
42 import java.io.Reader;
43 import java.io.UnsupportedEncodingException;
44
45 import java.net.HttpURLConnection;
46 import java.net.MalformedURLException;
47 import java.net.ProtocolException;
48 import java.net.URL;
49 import java.net.URLEncoder;
50
51 import java.security.MessageDigest;
52 import java.security.NoSuchAlgorithmException;
53
54 import java.text.SimpleDateFormat;
55
56 import java.util.Date;
57 import java.util.List;
58 import java.util.TimeZone;
59 import java.util.logging.Level;
60 import java.util.logging.Logger;
61 import java.util.regex.Matcher;
62 import java.util.regex.Pattern;
63
64 import javax.security.auth.login.FailedLoginException;
65
66 import javax.xml.parsers.DocumentBuilder;
67 import javax.xml.parsers.DocumentBuilderFactory;
68 import javax.xml.parsers.ParserConfigurationException;
69 import javax.xml.transform.TransformerConfigurationException;
70 import javax.xml.transform.TransformerException;
71
72 /**
73  * @author muti
74  * @version $Id$
75  */
76 public class Scrobbler {
77     /**
78      * AudioScrobbler suggests a maximum number of tracks per chunk.
79      */
80     private static final int MAX_TRACKS_PER_CHUNK = 10;
81
82     /**
83      * The minimum length (in seconds) of a track that meets Last.fm guidelines.
84      */
85     private static final int MIN_TRACK_SECONDS = 30;
86
87     /**
88      * Last.fm client cache version.
89      */
90     private static final String CACHE_VERSION = "1.2";
91
92     /**
93      * Last.fm client product name.
94      */
95     private static final String PRODUCT_NAME = "Audioscrobbler";
96     private String username;
97     private String encryptedPassword;
98     private String backupUrl;
99     private String challenge;
100     private String submitHost;
101     private Integer submitPort;
102     private String submitUrl;
103     private String submitCachePath;
104
105     /**
106      * Stores the chunks of tracks to be submitted.
107      */
108     private List trackChunks;
109
110     /**
111      * Displays the submission progress as this class updates it.
112      */
113     private ChunkProgress chunkProgress;
114
115     /**
116      * The number of seconds to pause between submissions. Only if the server
117      * asks to do so.
118      */
119     private int interval = 0;
120     private Logger logger;
121
122     public Scrobbler(String username, String encryptedPassword, String backupUrl,
123         String submitCachePath) {
124         this.username = username;
125         this.encryptedPassword = encryptedPassword;
126         this.backupUrl = backupUrl;
127         this.submitCachePath = submitCachePath;
128         logger = Logger.getLogger(getClass().getPackage().getName());
129     }
130
131     /**
132      * Sets the object that displays submission progress.  This object updates
133      * the progress as it is processing.
134      * @param chunkProgress  The object that displays submission progress.
135      */
136     public void setChunkProgress(ChunkProgress chunkProgress) {
137         this.chunkProgress = chunkProgress;
138     }
139
140     /**
141      * Sets the tracks that are submitted.
142      * @param recentPlayed  A list of tracks to submit.
143      */
144     public void setTracksToSubmit(final List recentPlayed) {
145         if (recentPlayed.size() == 0) {
146             throw new RuntimeException("No tracks to submit");
147         }
148
149         /* Converts the recentPlayed List into a List of Chunk objects.  Each
150          * chunk stores at most 10 tracks.  Each chunk will be submitted to
151          * Last.fm individually, per their guidelines.
152          */
153         trackChunks = ChunkUtil.createChunks(recentPlayed, MAX_TRACKS_PER_CHUNK);
154
155         /* Add 1 because the handshake will also be included in the progress. */
156         chunkProgress.setNumberOfChunks(trackChunks.size() + 1);
157     }
158
159     public void handshake()
160             throws UnsupportedEncodingException, MalformedURLException, IOException,
161                 FailedLoginException {
162         if (trackChunks.size() == 0) {
163             throw new RuntimeException("No tracks to submit");
164         }
165
166         String statusMessage = "Beginning Handshake";
167         chunkProgress.setSubmitStatusMessage(statusMessage);
168         logger.log(Level.INFO, statusMessage);
169
170         String args = "?hs=true&p=1.1&c=apd&v=0.1&u=" + URLEncoder.encode(username, "UTF-8");
171         URL url = new URL("http://post.audioscrobbler.com/" + args);
172
173         statusMessage = "Handshaking to URL: " + url.toString();
174         chunkProgress.setSubmitStatusMessage(statusMessage);
175         logger.log(Level.FINE, statusMessage);
176
177         HttpURLConnection c = (HttpURLConnection) url.openConnection();
178
179         c.setDoInput(true);
180         c.setRequestMethod("GET");
181         c.setUseCaches(false);
182         c.setRequestProperty("Connection", "close");
183         c.connect();
184
185         if (c.getResponseCode() != 200) {
186             throw new RuntimeException("Invalid HTTP return code");
187         }
188
189         BufferedReader breader = new BufferedReader(new InputStreamReader(c.getInputStream()));
190
191         String content = null;
192         String buffer = null;
193
194         while ((buffer = breader.readLine()) != null) {
195             if (content != null) {
196                 content += (buffer + "\n");
197             } else {
198                 content = buffer + "\n";
199             }
200         }
201
202         logger.log(Level.FINE, "Received from server:\n" + content);
203
204         if ((content == null) || (content.length() == 0)) {
205             statusMessage = "Invalid response received from AudioScrobbler";
206             chunkProgress.setSubmitStatusMessage(statusMessage);
207             throw new RuntimeException(statusMessage);
208         }
209
210         String[] lines = content.split("\n");
211
212         if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("FAILED")) {
213             statusMessage = lines[0].substring(7);
214             chunkProgress.setSubmitStatusMessage(statusMessage);
215             throw new RuntimeException(statusMessage);
216         }
217
218         if ((lines[0].length() >= 7) && lines[0].substring(0, 7).equals("BADUSER")) {
219             statusMessage = "Invalid Username";
220             chunkProgress.setSubmitStatusMessage(statusMessage);
221             throw new FailedLoginException(statusMessage);
222         }
223
224         if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("UPDATE")) {
225             statusMessage = "Update your client:" + lines[0].substring(7);
226             chunkProgress.setSubmitStatusMessage(statusMessage);
227             throw new RuntimeException(statusMessage);
228         }
229
230         /* Sets the interval, if it is present in the response. */
231         if ((lines.length >= 4) && (lines[3].length() >= 10)) {
232             String wait = lines[3].substring(9);
233             interval = Integer.parseInt(wait);
234         }
235
236         Pattern p = Pattern.compile("http://(.*):(\\d+)(.*)");
237         Matcher m = p.matcher(lines[2]);
238
239         if (m.matches()) {
240             submitHost = m.group(1);
241             submitPort = new Integer(m.group(2));
242             submitUrl = m.group(3);
243             logger.log(Level.FINE, "Set submithost to: " + submitHost);
244             logger.log(Level.FINE, "Set submitport to: " + submitPort);
245             logger.log(Level.FINE, "Set submiturl to: " + submitUrl);
246         } else {
247             throw new RuntimeException("Invalid POST URL returned, unable to continue");
248         }
249
250         challenge = lines[1];
251
252         /* Displays some progress update once the handshake is completed. */
253         chunkProgress.updateCurrentChunk(1);
254
255         statusMessage = "Handshake completed";
256         chunkProgress.setSubmitStatusMessage(statusMessage);
257         logger.log(Level.INFO, statusMessage);
258     }
259
260     public void submitTracks()
261             throws UnsupportedEncodingException, NoSuchAlgorithmException, MalformedURLException,
262                 IOException, FailedLoginException {
263         String statusMessage = "Submitting tracks...";
264         chunkProgress.setSubmitStatusMessage(statusMessage);
265         logger.log(Level.INFO, statusMessage);
266
267         if (trackChunks.size() == 0) {
268             statusMessage = "No tracks to submit";
269             chunkProgress.setSubmitStatusMessage(statusMessage);
270             throw new RuntimeException(statusMessage);
271         }
272
273         MessageDigest md = MessageDigest.getInstance("MD5");
274         String md5pass = encryptedPassword + challenge;
275         String md5chal = MiscUtilities.hexEncode(md.digest(md5pass.getBytes()));
276         String urlEncodedUsername = URLEncoder.encode(username, "UTF-8");
277         String urlEncodedChallange = URLEncoder.encode(md5chal, "UTF-8");
278
279         Chunk chunk = null;
280
281         for (int i = 0; i < trackChunks.size(); i++) {
282             pauseIfRequired();
283
284             chunk = (Chunk) trackChunks.get(i);
285
286             String queryString = "u=" + urlEncodedUsername + "&" + "s=" + urlEncodedChallange;
287
288             int tracknum = 0;
289
290             for (int j = 0; j < chunk.getChunkSize(); j++) {
291                 TrackItem track = (TrackItem) chunk.getContent().get(j);
292
293                 /* Per Last.fm guidelines; do not submit tracks that are less
294                  * than 30 characters in length.
295                  */
296                 if (track.getLength() < MIN_TRACK_SECONDS) {
297                     continue;
298                 }
299
300                 queryString += buildTrackQueryString(track, tracknum);
301
302                 tracknum++;
303             }
304
305             String content = null;
306
307             /* If a backup URL is specified then two submits will take place.  A
308              * backup URL can be used to send your information to another server.
309              */
310             if ((backupUrl != null) && !backupUrl.equals("")) {
311                 content = fetchContent(backupUrl, queryString);
312                 logger.log(Level.FINE, "Received from server:\n" + content);
313             }
314
315             String urlString = "http://" + submitHost + ":" + submitPort + submitUrl;
316             content = fetchContent(urlString, queryString);
317
318             String[] lines = content.split("\n");
319
320             /* Sets the interval, if it is present in the response. */
321             if ((lines.length >= 2) && (lines[1].length() >= 10)) {
322                 String wait = lines[1].substring(9);
323                 interval = Integer.parseInt(wait);
324             }
325
326             if ((lines[0].length() >= 6) && lines[0].substring(0, 6).equals("FAILED")) {
327                 throw new RuntimeException(lines[0].substring(7));
328             }
329
330             if ((lines[0].length() >= 7) && lines[0].substring(0, 7).equals("BADAUTH")) {
331                 throw new FailedLoginException("Invalid username/password");
332             }
333
334             if ((lines[0].length() >= 2) && !lines[0].substring(0, 2).equals("OK")) {
335                 throw new RuntimeException("Unknown error submitting tracks");
336             }
337
338             /* The chunk is successfully written to last.fm. Makes sure the
339              * tracks are marked as inactive.  Writes the history file.
340              * This is done after each chunk because if the next chunk fails
341              * the history file should reflect where the failure occurred.
342              */
343             for (int j = 0; j < chunk.getChunkSize(); j++) {
344                 TrackItem track = (TrackItem) chunk.getContent().get(j);
345                 track.setActive(Boolean.FALSE);
346             }
347
348             addHistories(chunk.getContent());
349
350             /* Add 2 to progress.  1 because chunk progress starts at 1, whereas
351              * this for-loop is zero indexed.  1 because the handshake is also
352              * part of the progress.
353              */
354             chunkProgress.updateCurrentChunk(i + 2);
355         }
356
357         chunkProgress.setSubmitStatusMessage("Done. You may now sync your iPod.");
358         logger.log(Level.INFO, "Tracks submitted");
359         logger.log(Level.INFO,
360             "You may now sync your iPod with your music management software "
361             + "or delete 'Play Counts' from the iTunes folder!");
362
363         chunkProgress.setCompletionStatus(true);
364     }
365
366     public void submitTracksToCache()
367             throws UnsupportedEncodingException, NoSuchAlgorithmException, MalformedURLException,
368                 IOException, FailedLoginException, ParserConfigurationException, SAXException,
369                 TransformerConfigurationException, TransformerException, Exception {
370         String statusMessage = "Submitting tracks to cache...";
371         chunkProgress.setSubmitStatusMessage(statusMessage);
372         logger.log(Level.INFO, statusMessage);
373
374         if (trackChunks.size() == 0) {
375             statusMessage = "No tracks to submit";
376             chunkProgress.setSubmitStatusMessage(statusMessage);
377             throw new RuntimeException(statusMessage);
378         }
379
380         File cacheFile = new File(submitCachePath);
381
382         if (cacheFile.createNewFile()) {
383             FileOutputStream fos = new FileOutputStream(cacheFile);
384             String cacheString = "";
385             cacheString += "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n";
386             cacheString += ("<submissions version=\"" + CACHE_VERSION + "\" product=\""
387             + PRODUCT_NAME + "\">\r\n");
388             cacheString += "</submissions>";
389             fos.write(cacheString.getBytes());
390             fos.close();
391         }
392
393         DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();
394         DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();
395         Document doc = docBuilder.parse(cacheFile);
396
397         NodeList submissions = doc.getElementsByTagName("submissions");
398
399         Chunk chunk = null;
400
401         for (int i = 0; i < trackChunks.size(); i++) {
402             pauseIfRequired();
403             chunk = (Chunk) trackChunks.get(i);
404
405             int tracknum = 0;
406
407             for (int j = 0; j < chunk.getChunkSize(); j++) {
408                 TrackItem track = (TrackItem) chunk.getContent().get(j);
409
410                 if (track.getLength() < MIN_TRACK_SECONDS) {
411                     continue;
412                 }
413
414                 Element item = doc.createElement("item");
415                 XmlUtils.addChild(doc, item, "artist", track.getArtist());
416                 XmlUtils.addChild(doc, item, "album", track.getAlbum());
417                 XmlUtils.addChild(doc, item, "track", track.getTrack());
418                 XmlUtils.addChild(doc, item, "duration", Long.toString(track.getLength()));
419                 XmlUtils.addChild(doc, item, "timestamp", Long.toString(track.getLastplayed()));
420                 XmlUtils.addChild(doc, item, "playcount", "0");
421                 XmlUtils.addChild(doc, item, "source", "1");
422                 XmlUtils.addChild(doc, item, "userActionFlags", "8");
423                 XmlUtils.addChild(doc, item, "playerId", "foo");
424                 submissions.item(0).appendChild(item);
425
426                 tracknum++;
427             }
428
429             for (int j = 0; j < chunk.getChunkSize(); j++) {
430                 TrackItem track = (TrackItem) chunk.getContent().get(j);
431                 track.setActive(Boolean.FALSE);
432             }
433
434             addHistories(chunk.getContent());
435
436             chunkProgress.updateCurrentChunk(i + 2);
437         }
438
439         XmlUtils.xmlToFile(doc, submitCachePath);
440
441         chunkProgress.setSubmitStatusMessage("Done. You may now sync your iPod.");
442         logger.log(Level.INFO, "Tracks submitted");
443         logger.log(Level.INFO,
444             "You may now sync your iPod with your music management software "
445             + "or delete 'Play Counts' from the iTunes folder!");
446
447         chunkProgress.setCompletionStatus(true);
448     }
449
450     /**
451      * Last.fm will informs this client if it needs to pause.  This occurs when
452      * Last.fm is extremely busy.
453      *
454      * This function will pause the required amount of time if it is needed.
455      *
456      */
457     private void pauseIfRequired() {
458         if (interval != 0) {
459             try {
460                 Thread.sleep(interval * 1000);
461             } catch (InterruptedException e) {
462                 /* If interrupted it will simply submit early.  Therefore
463                  * it will not fail if this occurs.
464                  */
465             }
466         }
467     }
468
469     /**
470      * Builds the query string for the given <code>TrackItem</code> and track
471      * number.
472      * @param track  The track to build a query string of.
473      * @param trackNum  The number of the track in the submission.
474      * @return  The complete query string for the given track.
475      * @throws UnsupportedEncodingException  Thrown if errors occur.
476      */
477     private String buildTrackQueryString(TrackItem track, int trackNum)
478             throws UnsupportedEncodingException {
479         StringBuffer trackQueryString = new StringBuffer();
480
481         //TODO: Is all this UTF-8 encoding needed?
482         String artistutf8 = new String(track.getArtist().getBytes("UTF-8"), "UTF-8");
483         String trackutf8 = new String(track.getTrack().getBytes("UTF-8"), "UTF-8");
484         String albumutf8 = new String(track.getAlbum().getBytes("UTF-8"), "UTF-8");
485         String trackString = Long.toString(track.getLength());
486         Date date = new Date(track.getLastplayed() * 1000);
487         SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
488         format.setTimeZone(TimeZone.getTimeZone("GMT:00"));
489
490         String dateString = format.format(date);
491
492         trackQueryString.append("&a[" + trackNum + "]=" + URLEncoder.encode(artistutf8, "UTF-8"));
493         trackQueryString.append("&t[" + trackNum + "]=" + URLEncoder.encode(trackutf8, "UTF-8"));
494         trackQueryString.append("&b[" + trackNum + "]=" + URLEncoder.encode(albumutf8, "UTF-8"));
495         trackQueryString.append("&m[" + trackNum + "]=");
496         trackQueryString.append("&l[" + trackNum + "]=" + URLEncoder.encode(trackString, "UTF-8"));
497         trackQueryString.append("&i[" + trackNum + "]=" + URLEncoder.encode(dateString, "UTF-8"));
498
499         return trackQueryString.toString();
500     }
501
502     /**
503      * Creates the histories and writes them to a file.
504      * @param activeRecentPlayed  The list of active recently played tracks.
505      */
506     public void addHistories(List activeRecentPlayed) {
507         for (int i = 0; i < activeRecentPlayed.size(); i++) {
508             TrackItem track = (TrackItem) activeRecentPlayed.get(i);
509             History.getInstance(null).addhistory(track.getLastplayed());
510         }
511
512         History.getInstance(null).write();
513     }
514
515     /**
516      * Adds inactive recent played tracks to the histories file.  This is done
517      * so they will be preserved in the histories.
518      * @param inactiveRecentPlayed  The list of inactive recently played tracks.
519      */
520     public void addInactiveToHistories(List inactiveRecentPlayed) {
521         for (int i = 0; i < inactiveRecentPlayed.size(); i++) {
522             TrackItem track = (TrackItem) inactiveRecentPlayed.get(i);
523
524             if (History.getInstance(null).isInHistory(track.getLastplayed())) {
525                 History.getInstance(null).addhistory(track.getLastplayed());
526             }
527         }
528     }
529
530     /**
531      * Fetches the HTTP content given a URL String and a query String.
532      * @param urlString  The URL to fetch from.
533      * @param queryString  The query String to submit.
534      * @return  The content returned from the request.
535      * @throws MalformedURLException  Thrown if exceptions occur.
536      * @throws IOException  Thrown if exceptions occur.
537      * @throws ProtocolException  Thrown if exceptions occur.
538      */
539     private String fetchContent(String urlString, String queryString)
540             throws MalformedURLException, IOException, ProtocolException {
541         String content = null;
542         URL url = new URL(urlString);
543         logger.log(Level.FINE, "Submitting tracks to URL: " + url.toString());
544
545         HttpURLConnection c = (HttpURLConnection) url.openConnection();
546         c.setRequestMethod("POST");
547         c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
548         c.setRequestProperty("Content-Length", new Integer(queryString.length()).toString());
549         c.setRequestProperty("Connection", "close");
550         c.setDoInput(true);
551         c.setDoOutput(true);
552         c.setUseCaches(false);
553         c.connect();
554
555         logger.log(Level.FINE, "POST query string:\n" + queryString);
556
557         OutputStream out = null;
558         OutputStreamWriter writer = null;
559         InputStream in = null;
560         Reader reader = null;
561         BufferedReader bufferedReader = null;
562
563         try {
564             out = c.getOutputStream();
565             writer = new OutputStreamWriter(out);
566             writer.write(queryString);
567             writer.flush();
568
569             IoUtils.cleanup(null, writer);
570             IoUtils.cleanup(null, out);
571
572             if (c.getResponseCode() != 200) {
573                 throw new RuntimeException("Invalid HTTP return code");
574             }
575
576             in = c.getInputStream();
577             reader = new InputStreamReader(in);
578             bufferedReader = new BufferedReader(reader);
579
580             String buffer = null;
581
582             while ((buffer = bufferedReader.readLine()) != null) {
583                 if (content != null) {
584                     content += (buffer + "\n");
585                 } else {
586                     content = buffer + "\n";
587                 }
588             }
589         } finally {
590             IoUtils.cleanup(null, writer);
591             IoUtils.cleanup(null, out);
592             IoUtils.cleanup(bufferedReader, null);
593             IoUtils.cleanup(reader, null);
594             IoUtils.cleanup(in, null);
595         }
596
597         logger.log(Level.FINE, "Received from server:\n" + content);
598
599         if ((content == null) || (content.length() == 0)) {
600             throw new RuntimeException("Invalid response received from AudioScrobbler");
601         }
602
603         return content;
604     }
605 }
Note: See TracBrowser for help on using the browser.