parent
a2b9db5f58
commit
a04f78e7d8
|
@ -23,6 +23,14 @@
|
||||||
#import "AudioScrobblerClient.h"
|
#import "AudioScrobblerClient.h"
|
||||||
#import "PlaylistEntry.h"
|
#import "PlaylistEntry.h"
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Symbolic Constants
|
||||||
|
// ========================================
|
||||||
|
NSString * const AudioScrobblerRunLoopMode = @"org.sbooth.Play.AudioScrobbler.RunLoopMode";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helpers
|
||||||
|
// ========================================
|
||||||
static NSString *
|
static NSString *
|
||||||
escapeForLastFM(NSString *string)
|
escapeForLastFM(NSString *string)
|
||||||
{
|
{
|
||||||
|
@ -61,11 +69,10 @@ escapeForLastFM(NSString *string)
|
||||||
{
|
{
|
||||||
if((self = [super init])) {
|
if((self = [super init])) {
|
||||||
|
|
||||||
_pluginID = @"cog";
|
_pluginID = @"pla";
|
||||||
|
|
||||||
if([[NSUserDefaults standardUserDefaults] boolForKey:@"automaticallyLaunchLastFM"]) {
|
if([[NSUserDefaults standardUserDefaults] boolForKey:@"automaticallyLaunchLastFM"])
|
||||||
[[NSWorkspace sharedWorkspace] launchApplication:@"Last.fm.app"];
|
[[NSWorkspace sharedWorkspace] launchApplication:@"Last.fm.app"];
|
||||||
}
|
|
||||||
|
|
||||||
_keepProcessingAudioScrobblerCommands = YES;
|
_keepProcessingAudioScrobblerCommands = YES;
|
||||||
|
|
||||||
|
@ -85,9 +92,8 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
- (void) dealloc
|
- (void) dealloc
|
||||||
{
|
{
|
||||||
if([self keepProcessingAudioScrobblerCommands] || NO == [self audioScrobblerThreadCompleted]) {
|
if([self keepProcessingAudioScrobblerCommands] || NO == [self audioScrobblerThreadCompleted])
|
||||||
[self shutdown];
|
[self shutdown];
|
||||||
}
|
|
||||||
|
|
||||||
[_queue release], _queue = nil;
|
[_queue release], _queue = nil;
|
||||||
|
|
||||||
|
@ -98,15 +104,15 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
- (void) start:(PlaylistEntry *)pe
|
- (void) start:(PlaylistEntry *)pe
|
||||||
{
|
{
|
||||||
[self sendCommand:[NSString stringWithFormat:@"START c=%@&a=%@&t=%@&b=%@&m=%@&l=%i&p=%@\n",
|
[self sendCommand:[NSString stringWithFormat:@"START c=%@&a=%@&t=%@&b=%@&m=%@&l=%i&p=%@\n",
|
||||||
[self pluginID],
|
[self pluginID],
|
||||||
escapeForLastFM([pe artist]),
|
escapeForLastFM([pe artist]),
|
||||||
escapeForLastFM([pe title]),
|
escapeForLastFM([pe title]),
|
||||||
escapeForLastFM([pe album]),
|
escapeForLastFM([pe album]),
|
||||||
@"", // TODO: MusicBrainz support
|
@"", // TODO: MusicBrainz support
|
||||||
(int)([[pe length] doubleValue]/1000.0),
|
(int)([[pe length] doubleValue]/1000.0),
|
||||||
escapeForLastFM([[pe url] path])
|
escapeForLastFM([[pe url] path])
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void) stop
|
- (void) stop
|
||||||
|
@ -130,9 +136,8 @@ escapeForLastFM(NSString *string)
|
||||||
semaphore_signal([self semaphore]);
|
semaphore_signal([self semaphore]);
|
||||||
|
|
||||||
// Wait for the thread to terminate
|
// Wait for the thread to terminate
|
||||||
while(NO == [self audioScrobblerThreadCompleted]) {
|
while(NO == [self audioScrobblerThreadCompleted])
|
||||||
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.5]];
|
[[NSRunLoop currentRunLoop] runMode:AudioScrobblerRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.2]];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
@ -141,9 +146,8 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
- (NSMutableArray *) queue
|
- (NSMutableArray *) queue
|
||||||
{
|
{
|
||||||
if(nil == _queue) {
|
if(nil == _queue)
|
||||||
_queue = [[NSMutableArray alloc] init];
|
_queue = [[NSMutableArray alloc] init];
|
||||||
}
|
|
||||||
|
|
||||||
return _queue;
|
return _queue;
|
||||||
}
|
}
|
||||||
|
@ -155,7 +159,6 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
- (void) sendCommand:(NSString *)command
|
- (void) sendCommand:(NSString *)command
|
||||||
{
|
{
|
||||||
NSLog(@"Command: %@", command);
|
|
||||||
@synchronized([self queue]) {
|
@synchronized([self queue]) {
|
||||||
[[self queue] addObject:command];
|
[[self queue] addObject:command];
|
||||||
}
|
}
|
||||||
|
@ -201,7 +204,6 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
// Get the first command to be sent
|
// Get the first command to be sent
|
||||||
@synchronized([myself queue]) {
|
@synchronized([myself queue]) {
|
||||||
|
|
||||||
enumerator = [[myself queue] objectEnumerator];
|
enumerator = [[myself queue] objectEnumerator];
|
||||||
command = [[enumerator nextObject] retain];
|
command = [[enumerator nextObject] retain];
|
||||||
|
|
||||||
|
@ -210,17 +212,17 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
if(nil != command) {
|
if(nil != command) {
|
||||||
@try {
|
@try {
|
||||||
port = [client connectToHost:@"localhost" port:port];
|
if([client connectToHost:@"localhost" port:port]) {
|
||||||
[client send:command];
|
port = [client connectedPort];
|
||||||
|
[client send:command];
|
||||||
[command release];
|
[command release];
|
||||||
|
|
||||||
response = [client receive];
|
response = [client receive];
|
||||||
if(2 > [response length] || NSOrderedSame != [response compare:@"OK" options:NSLiteralSearch range:NSMakeRange(0,2)]) {
|
if(2 > [response length] || NSOrderedSame != [response compare:@"OK" options:NSLiteralSearch range:NSMakeRange(0,2)])
|
||||||
NSLog(@"AudioScrobbler error: %@", response);
|
NSLog(@"AudioScrobbler error: %@", response);
|
||||||
|
|
||||||
|
[client shutdown];
|
||||||
}
|
}
|
||||||
|
|
||||||
[client shutdown];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@catch(NSException *exception) {
|
@catch(NSException *exception) {
|
||||||
|
@ -235,15 +237,15 @@ escapeForLastFM(NSString *string)
|
||||||
|
|
||||||
// Send a final stop command to cleanup
|
// Send a final stop command to cleanup
|
||||||
@try {
|
@try {
|
||||||
port = [client connectToHost:@"localhost" port:port];
|
if([client connectToHost:@"localhost" port:port]) {
|
||||||
[client send:[NSString stringWithFormat:@"STOP c=%@\n", [myself pluginID]]];
|
[client send:[NSString stringWithFormat:@"STOP c=%@\n", [myself pluginID]]];
|
||||||
|
|
||||||
response = [client receive];
|
response = [client receive];
|
||||||
if(2 > [response length] || NSOrderedSame != [response compare:@"OK" options:NSLiteralSearch range:NSMakeRange(0,2)]) {
|
if(2 > [response length] || NSOrderedSame != [response compare:@"OK" options:NSLiteralSearch range:NSMakeRange(0,2)])
|
||||||
NSLog(@"AudioScrobbler error: %@", response);
|
NSLog(@"AudioScrobbler error: %@", response);
|
||||||
|
|
||||||
|
[client shutdown];
|
||||||
}
|
}
|
||||||
|
|
||||||
[client shutdown];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@catch(NSException *exception) {
|
@catch(NSException *exception) {
|
||||||
|
@ -251,9 +253,9 @@ escapeForLastFM(NSString *string)
|
||||||
}
|
}
|
||||||
|
|
||||||
[client release];
|
[client release];
|
||||||
[pool release];
|
|
||||||
|
|
||||||
[myself setAudioScrobblerThreadCompleted:YES];
|
[myself setAudioScrobblerThreadCompleted:YES];
|
||||||
|
|
||||||
|
[pool release];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* $Id: AudioScrobblerClient.h 241 2007-01-26 23:02:09Z stephen_booth $
|
* $Id: AudioScrobblerClient.h 666 2007-04-26 16:35:18Z stephen_booth $
|
||||||
*
|
*
|
||||||
* Copyright (C) 2006 - 2007 Stephen F. Booth <me@sbooth.org>
|
* Copyright (C) 2006 - 2007 Stephen F. Booth <me@sbooth.org>
|
||||||
*
|
*
|
||||||
|
@ -26,10 +26,13 @@
|
||||||
{
|
{
|
||||||
int _socket;
|
int _socket;
|
||||||
BOOL _doPortStepping;
|
BOOL _doPortStepping;
|
||||||
in_port_t _lastPort;
|
in_port_t _port;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (in_port_t) connectToHost:(NSString *)hostname port:(in_port_t)port;
|
- (BOOL) connectToHost:(NSString *)hostname port:(in_port_t)port;
|
||||||
|
|
||||||
|
- (BOOL) isConnected;
|
||||||
|
- (in_port_t) connectedPort;
|
||||||
|
|
||||||
- (void) send:(NSString *)data;
|
- (void) send:(NSString *)data;
|
||||||
- (NSString *) receive;
|
- (NSString *) receive;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* $Id: AudioScrobblerClient.m 362 2007-02-13 05:30:49Z stephen_booth $
|
* $Id: AudioScrobblerClient.m 869 2007-06-18 15:51:33Z stephen_booth $
|
||||||
*
|
*
|
||||||
* Copyright (C) 2006 - 2007 Stephen F. Booth <me@sbooth.org>
|
* Copyright (C) 2006 - 2007 Stephen F. Booth <me@sbooth.org>
|
||||||
*
|
*
|
||||||
|
@ -41,7 +41,7 @@ addressForHost(NSString *hostname)
|
||||||
if(INADDR_NONE == address) {
|
if(INADDR_NONE == address) {
|
||||||
hostinfo = gethostbyname([hostname cStringUsingEncoding:NSASCIIStringEncoding]);
|
hostinfo = gethostbyname([hostname cStringUsingEncoding:NSASCIIStringEncoding]);
|
||||||
if(NULL == hostinfo) {
|
if(NULL == hostinfo) {
|
||||||
NSLog(@"Unable to resolve address for \"%@\".", hostname);
|
NSLog(@"AudioScrobblerClient error: Unable to resolve address for \"%@\".", hostname);
|
||||||
return INADDR_NONE;
|
return INADDR_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ addressForHost(NSString *hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
@interface AudioScrobblerClient (Private)
|
@interface AudioScrobblerClient (Private)
|
||||||
- (void) connectToSocket:(in_addr_t)remoteAddress port:(in_port_t)port;
|
- (BOOL) connectToSocket:(in_addr_t)remoteAddress port:(in_port_t)port;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation AudioScrobblerClient
|
@implementation AudioScrobblerClient
|
||||||
|
@ -61,35 +61,51 @@ addressForHost(NSString *hostname)
|
||||||
{
|
{
|
||||||
if((self = [super init])) {
|
if((self = [super init])) {
|
||||||
_socket = -1;
|
_socket = -1;
|
||||||
_lastPort = -1;
|
|
||||||
_doPortStepping = YES;
|
_doPortStepping = YES;
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (in_port_t) connectToHost:(NSString *)hostname port:(in_port_t)port
|
- (BOOL) connectToHost:(NSString *)hostname port:(in_port_t)port
|
||||||
{
|
{
|
||||||
in_addr_t remoteAddress = addressForHost(hostname);
|
NSParameterAssert(nil != hostname);
|
||||||
|
|
||||||
[self connectToSocket:remoteAddress port:port];
|
in_addr_t remoteAddress = addressForHost(hostname);
|
||||||
|
|
||||||
return _lastPort;
|
if(INADDR_NONE != remoteAddress)
|
||||||
|
return [self connectToSocket:remoteAddress port:port];
|
||||||
|
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL) isConnected
|
||||||
|
{
|
||||||
|
return (-1 != _socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (in_port_t) connectedPort
|
||||||
|
{
|
||||||
|
return _port;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void) send:(NSString *)data
|
- (void) send:(NSString *)data
|
||||||
{
|
{
|
||||||
const char *utf8data = [data UTF8String];
|
const char *utf8data = [data UTF8String];
|
||||||
unsigned len = strlen(utf8data);
|
unsigned len = strlen(utf8data);
|
||||||
unsigned bytesToSend = len;
|
unsigned bytesToSend = len;
|
||||||
unsigned totalBytesSent = 0;
|
unsigned totalBytesSent = 0;
|
||||||
ssize_t bytesSent = 0;
|
ssize_t bytesSent = 0;
|
||||||
|
|
||||||
|
if(NO == [self isConnected]) {
|
||||||
|
NSLog(@"AudioScrobblerClient error: Can't send data, client not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
while(totalBytesSent < bytesToSend && -1 != bytesSent) {
|
while(totalBytesSent < bytesToSend && -1 != bytesSent) {
|
||||||
bytesSent = send(_socket, utf8data + totalBytesSent, bytesToSend - totalBytesSent, 0);
|
bytesSent = send(_socket, utf8data + totalBytesSent, bytesToSend - totalBytesSent, 0);
|
||||||
|
|
||||||
if(-1 == bytesSent || 0 == bytesSent) {
|
if(-1 == bytesSent || 0 == bytesSent)
|
||||||
NSLog(@"Unable to send data through socket");
|
NSLog(@"AudioScrobblerClient error: Unable to send data through socket: %s", strerror(errno));
|
||||||
}
|
|
||||||
|
|
||||||
totalBytesSent += bytesSent;
|
totalBytesSent += bytesSent;
|
||||||
}
|
}
|
||||||
|
@ -102,10 +118,18 @@ addressForHost(NSString *hostname)
|
||||||
ssize_t bytesRead = 0;
|
ssize_t bytesRead = 0;
|
||||||
BOOL keepGoing = YES;
|
BOOL keepGoing = YES;
|
||||||
NSString *result = nil;
|
NSString *result = nil;
|
||||||
|
|
||||||
|
if(NO == [self isConnected]) {
|
||||||
|
NSLog(@"AudioScrobblerClient error: Can't receive data, client not connected");
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
bytesRead = recv(_socket, buffer, readSize, 0);
|
bytesRead = recv(_socket, buffer, readSize, 0);
|
||||||
NSAssert1(-1 != bytesRead && 0 < bytesRead, @"Unable to receive data through socket (%s).", strerror(errno));
|
if(-1 == bytesRead || 0 == bytesRead) {
|
||||||
|
NSLog(@"AudioScrobblerClient error: Unable to receive data through socket: %s", strerror(errno));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if('\n' == buffer[bytesRead - 1]) {
|
if('\n' == buffer[bytesRead - 1]) {
|
||||||
--bytesRead;
|
--bytesRead;
|
||||||
|
@ -126,64 +150,80 @@ addressForHost(NSString *hostname)
|
||||||
char buffer [ kBufferSize ];
|
char buffer [ kBufferSize ];
|
||||||
ssize_t bytesRead;
|
ssize_t bytesRead;
|
||||||
|
|
||||||
if(-1 == _socket) {
|
if(NO == [self isConnected]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = shutdown(_socket, SHUT_WR);
|
result = shutdown(_socket, SHUT_WR);
|
||||||
NSAssert1(-1 != result, @"Socket shutdown failed (%s).", strerror(errno));
|
if(-1 == result)
|
||||||
|
NSLog(@"AudioScrobblerClient error: Socket shutdown failed: %s", strerror(errno));
|
||||||
|
|
||||||
for(;;) {
|
for(;;) {
|
||||||
bytesRead = recv(_socket, buffer, kBufferSize, 0);
|
bytesRead = recv(_socket, buffer, kBufferSize, 0);
|
||||||
|
if(-1 == bytesRead)
|
||||||
NSAssert1(-1 != bytesRead, @"Waiting for shutdown confirmation failed (%s).", strerror(errno));
|
NSLog(@"AudioScrobblerClient error: Waiting for shutdown confirmation failed: %s", strerror(errno));
|
||||||
|
|
||||||
if(0 != bytesRead) {
|
if(0 != bytesRead) {
|
||||||
NSLog(@"Received unexpected bytes during shutdown: %@.", [[[NSString alloc] initWithBytes:buffer length:bytesRead encoding:NSUTF8StringEncoding] autorelease]);
|
NSString *received = [[NSString alloc] initWithBytes:buffer length:bytesRead encoding:NSUTF8StringEncoding];
|
||||||
|
NSLog(@"Received unexpected bytes during shutdown: %@", received);
|
||||||
|
[received release];
|
||||||
}
|
}
|
||||||
else {
|
else
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = close(_socket);
|
result = close(_socket);
|
||||||
NSAssert1(-1 != result, @"Couldn't close socket (%s).", strerror(errno));
|
if(-1 == result)
|
||||||
|
NSLog(@"Couldn't close socket (%s)", strerror(errno));
|
||||||
|
|
||||||
_socket = -1;
|
_socket = -1;
|
||||||
|
_port = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation AudioScrobblerClient (Private)
|
@implementation AudioScrobblerClient (Private)
|
||||||
|
|
||||||
- (void) connectToSocket:(in_addr_t)remoteAddress port:(in_port_t)port
|
- (BOOL) connectToSocket:(in_addr_t)remoteAddress port:(in_port_t)port
|
||||||
{
|
{
|
||||||
|
NSParameterAssert(INADDR_NONE != remoteAddress);
|
||||||
|
|
||||||
struct sockaddr_in socketAddress;
|
struct sockaddr_in socketAddress;
|
||||||
int result;
|
int result;
|
||||||
|
|
||||||
_socket = socket(AF_INET, SOCK_STREAM, 0);
|
_socket = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
NSAssert1(-1 != _socket, @"Unable to create socket (%s).", strerror(errno));
|
if(-1 == _socket) {
|
||||||
|
NSLog(@"Unable to create socket (%s)", strerror(errno));
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
_lastPort = port;
|
_port = port;
|
||||||
socketAddress.sin_family = AF_INET;
|
socketAddress.sin_family = AF_INET;
|
||||||
socketAddress.sin_addr.s_addr = remoteAddress;
|
socketAddress.sin_addr.s_addr = remoteAddress;
|
||||||
socketAddress.sin_port = htons(_lastPort);
|
socketAddress.sin_port = htons(_port);
|
||||||
|
|
||||||
result = connect(_socket, (const struct sockaddr *)&socketAddress, sizeof(struct sockaddr_in));
|
result = connect(_socket, (const struct sockaddr *)&socketAddress, sizeof(struct sockaddr_in));
|
||||||
|
|
||||||
|
// Don't check result yet
|
||||||
if(_doPortStepping) {
|
if(_doPortStepping) {
|
||||||
while(-1 == result && _lastPort <= (port + kPortsToStep)) {
|
while(-1 == result && _port <= (port + kPortsToStep)) {
|
||||||
socketAddress.sin_port = htons(++_lastPort);
|
socketAddress.sin_port = htons(++_port);
|
||||||
result = connect(_socket, (const struct sockaddr *)&socketAddress, sizeof(struct sockaddr_in));
|
result = connect(_socket, (const struct sockaddr *)&socketAddress, sizeof(struct sockaddr_in));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't log failures, because the client may not be running
|
||||||
if(-1 == result) {
|
if(-1 == result) {
|
||||||
_doPortStepping = NO;
|
|
||||||
close(_socket);
|
close(_socket);
|
||||||
_socket = -1;
|
|
||||||
// NSAssert1(-1 != result, @"Couldn't connect to server (%s).", strerror(errno));
|
_socket = -1;
|
||||||
|
_port = 0;
|
||||||
|
_doPortStepping = NO;
|
||||||
|
|
||||||
|
return NO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
|
@ -28,6 +28,8 @@ int randint(int low, int high)
|
||||||
|
|
||||||
+ (NSMutableArray *)shuffleList:(NSArray *)l
|
+ (NSMutableArray *)shuffleList:(NSArray *)l
|
||||||
{
|
{
|
||||||
|
srandom(time(NULL));
|
||||||
|
|
||||||
NSMutableArray *a = [l mutableCopy];
|
NSMutableArray *a = [l mutableCopy];
|
||||||
NSMutableArray *b = [[NSMutableArray alloc] init];
|
NSMutableArray *b = [[NSMutableArray alloc] init];
|
||||||
|
|
||||||
|
|
6
TODO
6
TODO
|
@ -5,8 +5,8 @@ Incubate.
|
||||||
|
|
||||||
BUGS:
|
BUGS:
|
||||||
Files still in use (File handle leak?): http://sbooth.org/forums/viewtopic.php?t=1619
|
Files still in use (File handle leak?): http://sbooth.org/forums/viewtopic.php?t=1619
|
||||||
Window order not preserved (incorrect use of makeKeyAndOrderFront in nsapp dock click delegate method?): http://sbooth.org/forums/viewtopic.php?t=1615
|
#Window order not preserved (incorrect use of makeKeyAndOrderFront in nsapp dock click delegate method?): http://sbooth.org/forums/viewtopic.php?t=1615
|
||||||
Slider enabled on launch: http://sbooth.org/forums/viewtopic.php?t=1471
|
#Slider enabled on launch: http://sbooth.org/forums/viewtopic.php?t=1471
|
||||||
Drag slider to the end: http://sbooth.org/forums/viewtopic.php?t=1537
|
#Drag slider to the end: http://sbooth.org/forums/viewtopic.php?t=1537
|
||||||
Shuffle is not random: http://sbooth.org/forums/viewtopic.php?p=5225
|
Shuffle is not random: http://sbooth.org/forums/viewtopic.php?p=5225
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue