Merge branch 'master' of git://github.com/ksuther/MenuTunes
[MenuTunes.git] / AudioscrobblerController.m
1 #import "AudioscrobblerController.h"
2 #import "PreferencesController.h"
3 #import <openssl/evp.h>
4 #import <ITFoundation/ITDebug.h>
5
6 #define AUDIOSCROBBLER_ID @"mtu"
7 #define AUDIOSCROBBLER_VERSION [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleVersion"]
8
9 static AudioscrobblerController *_sharedController = nil;
10
11 @implementation AudioscrobblerController
12
13 + (AudioscrobblerController *)sharedController
14 {
15         if (!_sharedController) {
16                 _sharedController = [[AudioscrobblerController alloc] init];
17         }
18         return _sharedController;
19 }
20
21 - (id)init
22 {
23         if ( (self = [super init]) ) {
24                 _handshakeCompleted = NO;
25                 _md5Challenge = nil;
26                 _postURL = nil;
27                 
28                 /*_handshakeCompleted = YES;
29                 _md5Challenge = @"rawr";
30                 _postURL = [NSURL URLWithString:@"http://audioscrobbler.com/"];*/
31                 
32                 _delayDate = [[NSDate date] retain];
33                 _responseData = [[NSMutableData alloc] init];
34                 _tracks = [[NSMutableArray alloc] init];
35                 _submitTracks = [[NSMutableArray alloc] init];
36                 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioscrobblerNotification:) name:@"AudioscrobblerHandshakeComplete" object:self];
37         }
38         return self;
39 }
40
41 - (void)dealloc
42 {
43         [_lastStatus release];
44         [_md5Challenge release];
45         [_postURL release];
46         [_responseData release];
47         [_submitTracks release];
48         [_tracks release];
49         [_delayDate release];
50         [super dealloc];
51 }
52
53 - (NSString *)lastStatus
54 {
55         return _lastStatus;
56 }
57
58 - (void)attemptHandshake
59 {
60         [self attemptHandshake:NO];
61 }
62
63 - (void)attemptHandshake:(BOOL)force
64 {
65         if (_handshakeCompleted && !force) {
66                 return;
67         }
68         
69         //If we've already tried to handshake three times in a row unsuccessfully, set the attempt count to -3
70         if (_handshakeAttempts > 3) {
71                 ITDebugLog(@"Audioscrobbler: Maximum handshake limit reached (3). Retrying when handshake attempts reach zero.");
72                 _handshakeAttempts = -3;
73                 
74                 //Remove any tracks we were trying to submit, just to be safe
75                 [_submitTracks removeAllObjects];
76                 
77                 return;
78         }
79         
80         //Increment the number of times we've tried to handshake
81         _handshakeAttempts++;
82         
83         //We're still on our self-imposed cooldown time.
84         if (_handshakeAttempts < 0) {
85                 ITDebugLog(@"Audioscrobbler: Handshake timeout. Retrying when handshake attempts reach zero.");
86                 return;
87         }
88         
89         //Delay if we haven't met the interval time limit
90         NSTimeInterval interval = [_delayDate timeIntervalSinceNow];
91         if (interval > 0) {
92                 ITDebugLog(@"Audioscrobbler: Delaying handshake attempt for %i seconds", interval);
93                 [self performSelector:@selector(attemptHandshake) withObject:nil afterDelay:interval + 1];
94                 return;
95         }
96         
97         NSString *user = [[NSUserDefaults standardUserDefaults] stringForKey:@"audioscrobblerUser"];
98         if (!_handshakeCompleted && user) {
99                 NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"http://post.audioscrobbler.com/?hs=true&p=1.1&c=%@&v=%@&u=%@", AUDIOSCROBBLER_ID, AUDIOSCROBBLER_VERSION, user]];
100                 
101                 [_lastStatus release];
102                 _lastStatus = [NSLocalizedString(@"audioscrobbler_handshaking", @"Attempting to handshake with server") retain];
103                 [[NSNotificationCenter defaultCenter] postNotificationName:@"AudioscrobblerStatusChanged" object:nil userInfo:[NSDictionary dictionaryWithObject:_lastStatus forKey:@"StatusString"]];
104                 
105                 _currentStatus = AudioscrobblerRequestingHandshakeStatus;
106                 //_responseData = [[NSMutableData alloc] init];
107                 [NSURLConnection connectionWithRequest:[NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:15] delegate:self];
108         }
109 }
110
111 - (BOOL)handshakeCompleted
112 {
113         return _handshakeCompleted;
114 }
115
116 - (void)submitTrack:(NSString *)title artist:(NSString *)artist album:(NSString *)album length:(int)length
117 {
118         ITDebugLog(@"Audioscrobbler: Adding a new track to the submission queue.");
119         NSDictionary *newTrack = [NSDictionary dictionaryWithObjectsAndKeys:title,
120                                                                                                                                                 @"title",
121                                                                                                                                                 artist,
122                                                                                                                                                 @"artist",
123                                                                                                                                                 (album == nil) ? @"" : album,
124                                                                                                                                                 @"album",
125                                                                                                                                                 [NSString stringWithFormat:@"%i", length],
126                                                                                                                                                 @"length",
127                                                                                                                                                 [[NSDate date] descriptionWithCalendarFormat:@"%Y-%m-%d %H:%M:%S" timeZone:nil locale:nil],
128                                                                                                                                                 @"time",
129                                                                                                                                                 nil, nil];
130         [_tracks addObject:newTrack];
131         [self submitTracks];
132 }
133
134 - (void)submitTracks
135 {
136         if (!_handshakeCompleted) {
137                 [self attemptHandshake:NO];
138                 return;
139         }
140         
141         ITDebugLog(@"Audioscrobbler: Submitting queued tracks");
142         
143         if ([_tracks count] == 0) {
144                 ITDebugLog(@"Audioscrobbler: No queued tracks to submit.");
145                 return;
146         }
147         
148         NSString *user = [[NSUserDefaults standardUserDefaults] stringForKey:@"audioscrobblerUser"], *passString = [PreferencesController getKeychainItemPasswordForUser:user];
149         char *pass = (char *)[passString UTF8String];
150         
151         if (passString == nil) {
152                 ITDebugLog(@"Audioscrobbler: Access denied to user password");
153                 return;
154         }
155         
156         NSTimeInterval interval = [_delayDate timeIntervalSinceNow];
157         if (interval > 0) {
158                 ITDebugLog(@"Audioscrobbler: Delaying track submission for %f seconds", interval);
159                 [self performSelector:@selector(submitTracks) withObject:nil afterDelay:interval + 1];
160                 return;
161         }
162         
163         int i;
164         NSMutableString *requestString;
165         NSString *authString, *responseHash = @"";
166         unsigned char *buffer;
167         EVP_MD_CTX ctx;
168         
169         //Build the MD5 response string we send along with the request
170         buffer = malloc(EVP_MD_size(EVP_md5()));
171         EVP_DigestInit(&ctx, EVP_md5());
172         EVP_DigestUpdate(&ctx, pass, strlen(pass));
173         EVP_DigestFinal(&ctx, buffer, NULL);
174         
175         for (i = 0; i < 16; i++) {
176                 responseHash = [responseHash stringByAppendingFormat:@"%0.2x", buffer[i]];
177         }
178         
179         free(buffer);
180         buffer = malloc(EVP_MD_size(EVP_md5()));
181         char *cat = (char *)[[responseHash stringByAppendingString:_md5Challenge] UTF8String];
182         EVP_DigestInit(&ctx, EVP_md5());
183         EVP_DigestUpdate(&ctx, cat, strlen(cat));
184         EVP_DigestFinal(&ctx, buffer, NULL);
185         
186         responseHash = @"";
187         for (i = 0; i < 16; i++) {
188                 responseHash = [responseHash stringByAppendingFormat:@"%0.2x", buffer[i]];
189         }
190         free(buffer);
191         
192         authString = (NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)[NSString stringWithFormat:@"u=%@&s=%@", user, responseHash], NULL, NULL, kCFStringEncodingUTF8);
193         requestString = [[NSMutableString alloc] initWithString:authString];
194         [authString release];
195         
196         //We can only submit ten tracks at a time
197         for (i = 0; (i < [_tracks count]) && (i < 10); i++) {
198                 NSDictionary *nextTrack = [_tracks objectAtIndex:i];
199                 NSString *artistEscaped, *titleEscaped, *albumEscaped, *timeEscaped, *ampersand = @"&";
200                 
201                 //Escape each of the individual parameters we're sending
202                 artistEscaped = (NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)[nextTrack objectForKey:@"artist"], NULL, (CFStringRef)ampersand, kCFStringEncodingUTF8);
203                 titleEscaped = (NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)[nextTrack objectForKey:@"title"], NULL, (CFStringRef)ampersand, kCFStringEncodingUTF8);
204                 albumEscaped = (NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)[nextTrack objectForKey:@"album"], NULL, (CFStringRef)ampersand, kCFStringEncodingUTF8);
205                 timeEscaped = (NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)[nextTrack objectForKey:@"time"], NULL, (CFStringRef)ampersand, kCFStringEncodingUTF8);
206                 
207                 [requestString appendString:[NSString stringWithFormat:@"&a[%i]=%@&t[%i]=%@&b[%i]=%@&m[%i]=&l[%i]=%@&i[%i]=%@", i, artistEscaped,
208                                                                                                                                                                                                                                                 i, titleEscaped,
209                                                                                                                                                                                                                                                 i, albumEscaped,
210                                                                                                                                                                                                                                                 i,
211                                                                                                                                                                                                                                                 i, [nextTrack objectForKey:@"length"],
212                                                                                                                                                                                                                                                 i, timeEscaped]];
213                 
214                 //Release the escaped strings
215                 [artistEscaped release];
216                 [titleEscaped release];
217                 [albumEscaped release];
218                 [timeEscaped release];
219                 
220                 [_submitTracks addObject:nextTrack];
221         }
222         
223         ITDebugLog(@"Audioscrobbler: Sending track submission request");
224         [_lastStatus release];
225         _lastStatus = [NSLocalizedString(@"audioscrobbler_submitting", @"Submitting tracks to server") retain];
226         [[NSNotificationCenter defaultCenter] postNotificationName:@"AudioscrobblerStatusChanged" object:nil userInfo:[NSDictionary dictionaryWithObject:_lastStatus forKey:@"StatusString"]];
227         
228         //Create and send the request
229         NSMutableURLRequest *request = [[NSURLRequest requestWithURL:_postURL cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:15] mutableCopy];
230         [request setHTTPMethod:@"POST"];
231         [request setHTTPBody:[requestString dataUsingEncoding:NSUTF8StringEncoding]];
232         _currentStatus = AudioscrobblerSubmittingTracksStatus;
233         //_responseData = [[NSMutableData alloc] init];
234         [_responseData setData:nil];
235         [NSURLConnection connectionWithRequest:request delegate:self];
236         [requestString release];
237         [request release];
238         
239         //For now we're not going to cache results, as it is less of a headache
240         //[_tracks removeObjectsInArray:_submitTracks];
241         [_tracks removeAllObjects];
242         //[_submitTracks removeAllObjects];
243         
244         //If we have tracks left, submit again after the interval seconds
245 }
246
247 - (void)handleAudioscrobblerNotification:(NSNotification *)note
248 {
249         if ([_tracks count] > 0) {
250                 [self performSelector:@selector(submitTracks) withObject:nil afterDelay:2];
251         }
252 }
253
254 #pragma mark -
255
256 - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
257 {
258         [_responseData setData:nil];
259         [_lastStatus release];
260         _lastStatus = [[NSString stringWithFormat:NSLocalizedString(@"audioscrobbler_error", @"Error - %@"), [error localizedDescription]] retain];
261         [[NSNotificationCenter defaultCenter] postNotificationName:@"AudioscrobblerStatusChanged" object:self userInfo:[NSDictionary dictionaryWithObject:_lastStatus forKey:@"StatusString"]];
262         ITDebugLog(@"Audioscrobbler: Connection error \"%@\"", error);
263 }
264
265 - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
266 {
267         [_responseData appendData:data];
268 }
269
270 - (void)connectionDidFinishLoading:(NSURLConnection *)connection
271 {
272         NSString *string = [[NSString alloc] initWithData:_responseData encoding:NSASCIIStringEncoding];
273         NSArray *lines = [string componentsSeparatedByString:@"\n"];
274         NSString *responseAction = nil, *key = nil, *comment = nil;
275         
276         if ([lines count] > 0) {
277                 responseAction = [lines objectAtIndex:0];
278         }
279         ITDebugLog(@"Audioscrobbler: Response %@", string);
280         if (_currentStatus == AudioscrobblerRequestingHandshakeStatus) {
281                 if ([lines count] < 2) {
282                         //We have a protocol error
283                 }
284                 if ([responseAction isEqualToString:@"UPTODATE"] || (([responseAction length] > 5) && [[responseAction substringToIndex:5] isEqualToString:@"UPDATE"])) {
285                         if ([lines count] >= 4) {
286                                 _md5Challenge = [[lines objectAtIndex:1] retain];
287                                 _postURL = [[NSURL alloc] initWithString:[lines objectAtIndex:2]];
288                                 _handshakeCompleted = YES;
289                                 [[NSNotificationCenter defaultCenter] postNotificationName:@"AudioscrobblerHandshakeComplete" object:self];
290                                 key = @"audioscrobbler_handshake_complete";
291                                 comment = @"Handshake complete";
292                                 _handshakeAttempts = 0;
293                         } else {
294                                 //We have a protocol error
295                         }
296                 } else if (([responseAction length] > 5) && [[responseAction substringToIndex:5] isEqualToString:@"FAILED"]) {
297                         ITDebugLog(@"Audioscrobbler: Handshake failed (%@)", [responseAction substringFromIndex:6]);
298                         key = @"audioscrobbler_handshake_failed";
299                         comment = @"Handshake failed";
300                         //We have a error
301                 } else if ([responseAction isEqualToString:@"BADUSER"]) {
302                         ITDebugLog(@"Audioscrobbler: Bad user name");
303                         key = @"audioscrobbler_bad_user";
304                         comment = @"Handshake failed - invalid user name";
305                         //We have a bad user
306                         
307                         //Don't count this as a bad handshake attempt
308                         _handshakeAttempts = 0;
309                 } else {
310                         ITDebugLog(@"Audioscrobbler: Handshake failed, protocol error");
311                         key = @"audioscrobbler_protocol_error";
312                         comment = @"Internal protocol error";
313                         //We have a protocol error
314                 }
315         } else if (_currentStatus == AudioscrobblerSubmittingTracksStatus) {
316                 if ([responseAction isEqualToString:@"OK"]) {
317                         ITDebugLog(@"Audioscrobbler: Submission successful, clearing queue.");
318                         /*[_tracks removeObjectsInArray:_submitTracks];
319                         [_submitTracks removeAllObjects];*/
320                         [_submitTracks removeAllObjects];
321                         if ([_tracks count] > 0) {
322                                 ITDebugLog(@"Audioscrobbler: Tracks remaining in queue, submitting remaining tracks");
323                                 [self performSelector:@selector(submitTracks) withObject:nil afterDelay:2];
324                         }
325                         key = @"audioscrobbler_submission_ok";
326                         comment = @"Last track submission successful";
327                 } else if ([responseAction isEqualToString:@"BADAUTH"]) {
328                         ITDebugLog(@"Audioscrobbler: Bad password");
329                         key = @"audioscrobbler_bad_password";
330                         comment = @"Last track submission failed - invalid password";
331                         //Bad auth
332                         
333                         //Add the tracks we were trying to submit back into the submission queue
334                         [_tracks addObjectsFromArray:_submitTracks];
335                         
336                         _handshakeCompleted = NO;
337                         
338                         //If we were previously valid with the same login name, try reauthenticating and sending again
339                         [self attemptHandshake:YES];
340                 } else if (([responseAction length] > 5) && [[responseAction substringToIndex:5] isEqualToString:@"FAILED"]) {
341                         ITDebugLog(@"Audioscrobbler: Submission failed (%@)", [responseAction substringFromIndex:6]);
342                         key = @"audioscrobbler_submission_failed";
343                         comment = @"Last track submission failed - see console for error";
344                         //Failed
345                         
346                         //We got an unknown error. To be safe we're going to remove the tracks we tried to submit
347                         [_submitTracks removeAllObjects];
348                         
349                         _handshakeCompleted = NO;
350                 }
351         }
352         
353         //Handle the final INTERVAL response
354         if (([[lines objectAtIndex:[lines count] - 2] length] > 9) && [[[lines objectAtIndex:[lines count] - 2] substringToIndex:8] isEqualToString:@"INTERVAL"]) {
355                 int seconds = [[[lines objectAtIndex:[lines count] - 2] substringFromIndex:9] intValue];
356                 ITDebugLog(@"Audioscrobbler: INTERVAL %i", seconds);
357                 [_delayDate release];
358                 _delayDate = [[NSDate dateWithTimeIntervalSinceNow:seconds] retain];
359         } else {
360                 ITDebugLog(@"No interval response.");
361                 //We have a protocol error
362         }
363         [_lastStatus release];
364         _lastStatus = [NSLocalizedString(key, comment) retain];
365         [[NSNotificationCenter defaultCenter] postNotificationName:@"AudioscrobblerStatusChanged" object:nil userInfo:[NSDictionary dictionaryWithObject:_lastStatus forKey:@"StatusString"]];
366         [string release];
367         [_responseData setData:nil];
368 }
369
370 -(NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse
371 {
372         //Don't cache any Audioscrobbler communication
373         return nil;
374 }
375
376 @end