diff --git a/src/de/felixbruns/jotify/Jotify.java b/src/de/felixbruns/jotify/Jotify.java index da4f4f6..8636770 100644 --- a/src/de/felixbruns/jotify/Jotify.java +++ b/src/de/felixbruns/jotify/Jotify.java @@ -168,6 +168,28 @@ public interface Jotify extends Runnable, Player { */ public List browseTracks(List ids) throws TimeoutException; + /** + * Request a replacement track. + * + * @param track The track to search the replacement for. + * + * @return A {@link Track} object. + * + * @see Track + */ + public Track replacement(Track track) throws TimeoutException; + + /** + * Request multiple replacement track. + * + * @param tracks The tracks to search the replacements for. + * + * @return A list of {@link Track} objects. + * + * @see Track + */ + public List replacement(List tracks) throws TimeoutException; + /** * Get stored user playlists. * diff --git a/src/de/felixbruns/jotify/JotifyConnection.java b/src/de/felixbruns/jotify/JotifyConnection.java index 837d177..fd61dd7 100644 --- a/src/de/felixbruns/jotify/JotifyConnection.java +++ b/src/de/felixbruns/jotify/JotifyConnection.java @@ -22,33 +22,37 @@ import de.felixbruns.jotify.util.*; public class JotifyConnection implements Jotify, CommandListener { - private Session session; - private Protocol protocol; - private boolean running; - private User user; - private Semaphore userSemaphore; - private SpotifyOggPlayer oggPlayer; - private Cache cache; - private float volume; - private long timeout; - private TimeUnit unit; + /* + * Values for browsing media. + */ + private static final int BROWSE_ARTIST = 1; + private static final int BROWSE_ALBUM = 2; + private static final int BROWSE_TRACK = 3; - /** - * Enum for browsing media. + /* + * Session and protocol associated with this connection. */ - private enum BrowseType { - ARTIST(1), ALBUM(2), TRACK(3); - - private int value; - - private BrowseType(int value){ - this.value = value; - } - - public int getValue(){ - return this.value; - } - } + protected Session session; + protected Protocol protocol; + + /* + * User information. + */ + private User user; + private Semaphore userSemaphore; + + /* + * Player and cache. + */ + private Player player; + private Cache cache; + + /* + * Status and timeout. + */ + private boolean running; + private long timeout; + private TimeUnit unit; /** * Create a new Jotify instance using the default {@link Cache} @@ -76,9 +80,8 @@ public JotifyConnection(Cache cache, long timeout, TimeUnit unit){ this.running = false; this.user = null; this.userSemaphore = new Semaphore(2); - this.oggPlayer = null; + this.player = null; this.cache = cache; - this.volume = 1.0f; this.timeout = timeout; this.unit = unit; @@ -97,6 +100,16 @@ public void setTimeout(long timeout, TimeUnit unit){ this.unit = unit; } + /** + * Set timeout for requests. + * + * @param seconds Timeout in seconds to use. + */ + public void setTimeout(long seconds){ + this.timeout = seconds; + this.unit = TimeUnit.SECONDS; + } + /** * Login to Spotify using the specified username and password. * @@ -327,7 +340,7 @@ public Image image(String id) throws TimeoutException { * * @see BrowseType */ - private Object browse(BrowseType type, String id) throws TimeoutException { + private Object browse(int type, String id) throws TimeoutException { /* * Check if id is a 32-character hex string, * if not try to parse it as a Spotify URI. @@ -336,9 +349,9 @@ private Object browse(BrowseType type, String id) throws TimeoutException { try{ Link link = Link.create(id); - if((type.equals(BrowseType.ARTIST) && !link.isArtistLink()) || - (type.equals(BrowseType.ALBUM) && !link.isAlbumLink()) || - (type.equals(BrowseType.TRACK) && !link.isTrackLink())){ + if((type == BROWSE_ARTIST && !link.isArtistLink()) || + (type == BROWSE_ALBUM && !link.isAlbumLink()) || + (type == BROWSE_TRACK && !link.isTrackLink())){ throw new IllegalArgumentException( "Browse type doesn't match given Spotify URI." ); @@ -359,7 +372,7 @@ private Object browse(BrowseType type, String id) throws TimeoutException { /* Send browse request. */ try{ - this.protocol.sendBrowseRequest(callback, type.getValue(), id); + this.protocol.sendBrowseRequest(callback, type, id); } catch(ProtocolException e){ return null; @@ -383,7 +396,7 @@ private Object browse(BrowseType type, String id) throws TimeoutException { */ public Artist browseArtist(String id) throws TimeoutException { /* Browse. */ - Object artist = this.browse(BrowseType.ARTIST, id); + Object artist = this.browse(BROWSE_ARTIST, id); if(artist instanceof Artist){ return (Artist)artist; @@ -418,7 +431,7 @@ public Artist browse(Artist artist) throws TimeoutException { */ public Album browseAlbum(String id) throws TimeoutException { /* Browse. */ - Object album = this.browse(BrowseType.ALBUM, id); + Object album = this.browse(BROWSE_ALBUM, id); if(album instanceof Album){ return (Album)album; @@ -452,7 +465,7 @@ public Album browse(Album album) throws TimeoutException { */ public Track browseTrack(String id) throws TimeoutException { /* Browse. */ - Object object = this.browse(BrowseType.TRACK, id); + Object object = this.browse(BROWSE_TRACK, id); if(object instanceof Result){ Result result = (Result)object; @@ -542,7 +555,7 @@ public List browseTracks(List ids) throws TimeoutException { /* Send browse request. */ try{ - this.protocol.sendBrowseRequest(callback, BrowseType.TRACK.getValue(), ids); + this.protocol.sendBrowseRequest(callback, BROWSE_TRACK, ids); } catch(ProtocolException e){ return null; @@ -583,6 +596,48 @@ public List browse(List tracks) throws TimeoutException { return this.browseTracks(ids); } + /** + * Request a replacement track. + * + * @param track The track to search the replacement for. + * + * @return A {@link Track} object. + * + * @see Track + */ + public Track replacement(Track track) throws TimeoutException { + return this.replacement(Arrays.asList(track)).get(0); + } + + /** + * Request multiple replacement track. + * + * @param tracks The tracks to search the replacements for. + * + * @return A list of {@link Track} objects. + * @throws TimeoutException + * + * @see Track + */ + public List replacement(List tracks) throws TimeoutException { + /* Create channel callback */ + ChannelCallback callback = new ChannelCallback(); + + /* Send browse request. */ + try{ + this.protocol.sendReplacementRequest(callback, tracks); + } + catch(ProtocolException e){ + return null; + } + + /* Get data. */ + byte[] data = callback.get(this.timeout, this.unit); + + /* Create result from XML. */ + return XMLMediaParser.parseResult(data, "UTF-8").getTracks(); + } + /** * Get stored user playlists. * @@ -1477,10 +1532,10 @@ public boolean playlistSetInformation(Playlist playlist, String description, Str */ public void play(Track track, int bitrate, PlaybackListener listener) throws TimeoutException, IOException, LineUnavailableException { /* Stop previous ogg player. */ - if(this.oggPlayer != null){ - this.oggPlayer.stop(); + if(this.player != null){ + this.player.stop(); - this.oggPlayer = null; + this.player = null; } try{ @@ -1488,13 +1543,10 @@ public void play(Track track, int bitrate, PlaybackListener listener) throws Tim this.protocol.sendPlayRequest(); /* Create a new ogg player. */ - this.oggPlayer = new SpotifyOggPlayer(this.protocol); + this.player = new SpotifyOggPlayer(this.protocol); /* Play track. */ - this.oggPlayer.play(track, bitrate, listener); - - /* Set volume. */ - this.oggPlayer.volume(this.volume); + this.player.play(track, bitrate, listener); } catch(Exception e){ e.printStackTrace(); @@ -1505,8 +1557,8 @@ public void play(Track track, int bitrate, PlaybackListener listener) throws Tim * Start playing or resume current track. */ public void play(){ - if(this.oggPlayer != null){ - this.oggPlayer.play(); + if(this.player != null){ + this.player.play(); } } @@ -1514,8 +1566,8 @@ public void play(){ * Pause playback of current track. */ public void pause(){ - if(this.oggPlayer != null){ - this.oggPlayer.pause(); + if(this.player != null){ + this.player.pause(); } } @@ -1523,10 +1575,10 @@ public void pause(){ * Stop playback of current track. */ public void stop(){ - if(this.oggPlayer != null){ - this.oggPlayer.stop(); + if(this.player != null){ + this.player.stop(); - this.oggPlayer = null; + this.player = null; } } @@ -1536,8 +1588,8 @@ public void stop(){ * @return Length in milliseconds or -1 if not available. */ public int length(){ - if(this.oggPlayer != null){ - return this.oggPlayer.length(); + if(this.player != null){ + return this.player.length(); } return -1; @@ -1549,8 +1601,8 @@ public int length(){ * @return Playback position in milliseconds or -1 if not available. */ public int position(){ - if(this.oggPlayer != null){ - return this.oggPlayer.position(); + if(this.player != null){ + return this.player.position(); } return -1; @@ -1564,8 +1616,8 @@ public int position(){ * @throws IOException If an I/O error occurs while seeking. */ public void seek(int ms) throws IOException { - if(this.oggPlayer != null){ - this.oggPlayer.seek(ms); + if(this.player != null){ + this.player.seek(ms); } } @@ -1575,8 +1627,8 @@ public void seek(int ms) throws IOException { * @return A value from 0.0 to 1.0. */ public float volume(){ - if(this.oggPlayer != null){ - return this.oggPlayer.volume(); + if(this.player != null){ + return this.player.volume(); } return -1.0f; @@ -1588,10 +1640,8 @@ public float volume(){ * @param volume A value from 0.0 to 1.0. */ public void volume(float volume){ - this.volume = volume; - - if(this.oggPlayer != null){ - this.oggPlayer.volume(this.volume); + if(this.player != null){ + this.player.volume(volume); } } diff --git a/src/de/felixbruns/jotify/JotifyPool.java b/src/de/felixbruns/jotify/JotifyPool.java index dbe4b51..09e2335 100644 --- a/src/de/felixbruns/jotify/JotifyPool.java +++ b/src/de/felixbruns/jotify/JotifyPool.java @@ -266,6 +266,26 @@ public List browseTracks(List ids) throws TimeoutException { return result; } + public Track replacement(Track track) throws TimeoutException { + Jotify connection = this.getConnection(); + + Track result = connection.replacement(track); + + this.releaseConnection(connection); + + return result; + } + + public List replacement(List tracks) throws TimeoutException { + Jotify connection = this.getConnection(); + + List result = connection.replacement(tracks); + + this.releaseConnection(connection); + + return result; + } + public PlaylistContainer playlistContainer() throws TimeoutException { Jotify connection = this.getConnection(); diff --git a/src/de/felixbruns/jotify/protocol/Command.java b/src/de/felixbruns/jotify/protocol/Command.java index df16339..9830d89 100644 --- a/src/de/felixbruns/jotify/protocol/Command.java +++ b/src/de/felixbruns/jotify/protocol/Command.java @@ -24,11 +24,12 @@ public class Command { /* Search and metadata. */ public static final int COMMAND_BROWSE = 0x30; - public static final int COMMAND_SEARCH = 0x31; + public static final int COMMAND_SEARCH_OLD = 0x31; public static final int COMMAND_PLAYLISTCHANGED = 0x34; public static final int COMMAND_GETPLAYLIST = 0x35; public static final int COMMAND_CHANGEPLAYLIST = 0x36; public static final int COMMAND_GETTOPLIST = 0x38; + public static final int COMMAND_SEARCH = 0x39; /* Session management. */ public static final int COMMAND_NOTIFY = 0x42; @@ -40,6 +41,6 @@ public class Command { public static final int COMMAND_REQUESTPLAY = 0x4f; /* Internal. */ - public static final int COMMAND_PRODINFO = 0x50; - public static final int COMMAND_WELCOME = 0x69; + public static final int COMMAND_PRODINFO = 0x50; + public static final int COMMAND_WELCOME = 0x69; } diff --git a/src/de/felixbruns/jotify/protocol/Protocol.java b/src/de/felixbruns/jotify/protocol/Protocol.java index 6119e1f..f4d513b 100644 --- a/src/de/felixbruns/jotify/protocol/Protocol.java +++ b/src/de/felixbruns/jotify/protocol/Protocol.java @@ -111,7 +111,7 @@ public void sendInitialPacket() throws ProtocolException { buffer.putInt(this.session.clientOs); buffer.putInt(0x00000000); /* Unknown */ buffer.putInt(this.session.clientRevision); - buffer.putInt(0x00000000); /* Windows: 0x1541ECD0, Mac OSX: 0x00000000 */ + buffer.putInt(0x1541ECD0); /* Windows: 0x1541ECD0, Mac OSX: 0x00000000 */ buffer.putInt(0x01000000); /* Windows: 0x01000000, Mac OSX: 0x01040000 */ buffer.putInt(this.session.clientId); /* 4 bytes, Windows: 0x010B0029, Mac OSX: 0x026A0200 */ buffer.putInt(0x00000001); /* Unknown */ @@ -123,7 +123,7 @@ public void sendInitialPacket() throws ProtocolException { buffer.putShort((short)0x0100); /* Unknown */ /* Random bytes here... */ buffer.put(this.session.username); - buffer.put((byte)0x40); /* Unknown (probably flags), 0x5F */ + buffer.put((byte)0x5F);/* Minor protocol version. */ /* Update length byte. */ buffer.putShort(2, (short)buffer.position()); @@ -344,7 +344,7 @@ public void receiveInitialPacket() throws ProtocolException { /* Send authentication packet (puzzle solution, HMAC). */ public void sendAuthenticationPacket() throws ProtocolException { - ByteBuffer buffer = ByteBuffer.allocate(20 + 1 + 1 + 4 + 2 + 15 + 8); + ByteBuffer buffer = ByteBuffer.allocate(20 + 1 + 1 + 4 + 2 + 15 + this.session.puzzleSolution.length); /* Append fields to buffer. */ buffer.put(this.session.authHmac); /* 20 bytes */ @@ -372,7 +372,7 @@ public void receiveAuthenticationPacket() throws ProtocolException { /* Check status. */ if(buffer[0] != 0x00){ - throw new ProtocolException("Authentication failed! (Error " + buffer[1] + ")"); + throw new ProtocolException("Authentication failed!"); } /* Check payload length. AND with 0x00FF so we don't get a negative integer. */ @@ -504,7 +504,7 @@ public void sendAdRequest(ChannelListener listener, int type) throws ProtocolExc /* Append channel id and ad type. */ buffer.putShort((short)channel.getId()); - buffer.put((byte)type); /* 0: audio, 1: banner, 2: fullscreen-banner. */ + buffer.put((byte)type); /* 0: audio, 1: banner, 2: fullscreen-banner, 3: unknown. */ buffer.flip(); /* Register channel. */ @@ -544,8 +544,7 @@ public void sendToplistRequest(ChannelListener listener, Map par /* Append channel id, some values, query length and query. */ buffer.putShort((short)channel.getId()); - buffer.putShort((short)0x0000); - buffer.putShort((short)0x0000); + buffer.putInt(0x00000000); for(Entry parameter : parameters.entrySet()){ byte[] key = parameter.getKey(); @@ -570,7 +569,7 @@ public void sendToplistRequest(ChannelListener listener, Map par public void sendImageRequest(ChannelListener listener, String id) throws ProtocolException { /* Create channel and buffer. */ Channel channel = new Channel("Image-Channel", Channel.Type.TYPE_IMAGE, listener); - ByteBuffer buffer = ByteBuffer.allocate(2 + 20); + ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 20); /* Check length of id. */ if(id.length() != 40){ @@ -579,6 +578,7 @@ public void sendImageRequest(ChannelListener listener, String id) throws Protoco /* Append channel id and image hash. */ buffer.putShort((short)channel.getId()); + buffer.putShort((short)0x0000); buffer.put(Hex.toBytes(id)); buffer.flip(); @@ -594,7 +594,7 @@ public void sendSearchQuery(ChannelListener listener, String query, int offset, /* Create channel and buffer. */ byte[] queryBytes = query.getBytes(Charset.forName("UTF-8")); Channel channel = new Channel("Search-Channel", Channel.Type.TYPE_SEARCH, listener); - ByteBuffer buffer = ByteBuffer.allocate(2 + 4 + 4 + 2 + 1 + queryBytes.length); + ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 6 * 4 + 2 + 1 + queryBytes.length); /* Check offset and limit. */ if(offset < 0){ @@ -604,11 +604,16 @@ else if((limit < 0 && limit != -1) || limit == 0){ throw new IllegalArgumentException("Limit needs to be either -1 for no limit or > 0"); } - /* Append channel id, some values, query length and query. */ + /* Append channel id, some unknown values, query length and query. */ buffer.putShort((short)channel.getId()); + buffer.putShort((short)0x0000); /* Unknown. */ buffer.putInt(offset); /* Result offset. */ buffer.putInt(limit); /* Reply limit. */ - buffer.putShort((short)0x0000); + buffer.putInt(0x00000000); /* Unknown. */ + buffer.putInt(0xFFFFFFFF); /* Unknown. */ + buffer.putInt(0x00000000); /* Unknown. */ + buffer.putInt(0xFFFFFFFF); /* Unknown. */ + buffer.putShort((short)0x0000); /* Unknown. */ buffer.put((byte)queryBytes.length); buffer.put(queryBytes); buffer.flip(); @@ -629,13 +634,14 @@ public void sendSearchQuery(ChannelListener listener, String query) throws Proto public void sendAesKeyRequest(ChannelListener listener, Track track, File file) throws ProtocolException { /* Create channel and buffer. */ Channel channel = new Channel("AES-Key-Channel", Channel.Type.TYPE_AESKEY, listener); - ByteBuffer buffer = ByteBuffer.allocate(20 + 16 + 2 + 2); + ByteBuffer buffer = ByteBuffer.allocate(20 + 16 + 2 + 2 + 2); /* Request the AES key for this file by sending the file id and track id. */ buffer.put(Hex.toBytes(file.getId())); /* 20 bytes */ buffer.put(Hex.toBytes(track.getId())); /* 16 bytes */ buffer.putShort((short)0x0000); buffer.putShort((short)channel.getId()); + buffer.putShort((short)0x0000); buffer.flip(); /* Register channel. */ @@ -668,7 +674,7 @@ public void sendPlayRequest() throws ProtocolException { public void sendSubstreamRequest(ChannelListener listener, Track track, File file, int offset, int length) throws ProtocolException { /* Create channel and buffer. */ Channel channel = new Channel("Substream-Channel", Channel.Type.TYPE_SUBSTREAM, listener); - ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 2 + 2 + 2 + 2 + 4 + 20 + 4 + 4); + ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 2 + 2 + 2 + 2 + 2 + 4 + 20 + 4 + 4); /* Append channel id. */ buffer.putShort((short)channel.getId()); @@ -678,6 +684,7 @@ public void sendSubstreamRequest(ChannelListener listener, Track track, File fil buffer.putShort((short)0x0000); buffer.putShort((short)0x0000); buffer.putShort((short)0x0000); + buffer.putShort((short)0x0000); buffer.putShort((short)0x4e20); /* Unknown (static value) */ @@ -724,7 +731,7 @@ public void sendChannelAbort(int id) throws ProtocolException { public void sendBrowseRequest(ChannelListener listener, int type, Collection ids) throws ProtocolException { /* Create channel and buffer. */ Channel channel = new Channel("Browse-Channel", Channel.Type.TYPE_BROWSE, listener); - ByteBuffer buffer = ByteBuffer.allocate(2 + 1 + ids.size() * 16 + ((type == 1 || type == 2)?4:0)); + ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 1 + ids.size() * 16 + ((type == 1 || type == 2)?4:0)); /* Check arguments. */ if(type != 1 && type != 2 && type != 3){ @@ -736,6 +743,7 @@ else if((type == 1 && type == 2) && ids.size() != 1){ /* Append channel id and type. */ buffer.putShort((short)channel.getId()); + buffer.putShort((short)0x0000); /* Unknown. */ buffer.put((byte)type); /* Append (16 byte binary, 32 byte hex string) ids. */ @@ -750,7 +758,7 @@ else if((type == 1 && type == 2) && ids.size() != 1){ /* Append zero. */ if(type == 1 || type == 2){ - buffer.putInt(0); + buffer.putInt(0); /* Timestamp of cached version? */ } buffer.flip(); @@ -771,6 +779,76 @@ public void sendBrowseRequest(ChannelListener listener, int type, String id) thr this.sendBrowseRequest(listener, type, list); } + /* Request replacements for a list of tracks. The response comes as compressed XML. */ + public void sendReplacementRequest(ChannelListener listener, Collection tracks) throws ProtocolException { + /* Calculate data length. */ + int dataLength = 0; + + for(Track track : tracks){ + if(track.getArtist() != null && track.getArtist().getName() != null){ + dataLength += track.getArtist().getName().getBytes().length; + } + + if(track.getAlbum() != null && track.getAlbum().getName() != null){ + dataLength += track.getAlbum().getName().getBytes().length; + } + + if(track.getTitle() != null){ + dataLength += track.getTitle().getBytes().length; + } + + if(track.getLength() != -1){ + dataLength += Integer.toString(track.getLength() / 1000).getBytes().length; + } + + dataLength += 4; /* Separators */ + } + + /* Create channel and buffer. */ + Channel channel = new Channel("Browse-Channel", Channel.Type.TYPE_BROWSE, listener); + ByteBuffer buffer = ByteBuffer.allocate(2 + 2 + 1 + dataLength); + + /* Append channel id and type. */ + buffer.putShort((short)channel.getId()); + buffer.putShort((short)0x0000); /* Unknown. */ + buffer.put((byte)0x06); + + /* Append track info. */ + for(Track track : tracks){ + if(track.getArtist() != null && track.getArtist().getName() != null){ + buffer.put(track.getArtist().getName().getBytes()); + } + + buffer.put((byte)0x01); /* Separator. */ + + if(track.getAlbum() != null && track.getAlbum().getName() != null){ + buffer.put(track.getAlbum().getName().getBytes()); + } + + buffer.put((byte)0x01); /* Separator. */ + + if(track.getTitle() != null){ + buffer.put(track.getTitle().getBytes()); + } + + buffer.put((byte)0x01); /* Separator. */ + + if(track.getLength() != -1){ + buffer.put(Integer.toString(track.getLength() / 1000).getBytes()); + } + + buffer.put((byte)0x00); /* Separator. */ + } + + buffer.flip(); + + /* Register channel. */ + Channel.register(channel); + + /* Send packet. */ + this.sendPacket(Command.COMMAND_BROWSE, buffer); + } + /* Request playlist details. The response comes as plain XML. */ public void sendPlaylistRequest(ChannelListener listener, String id) throws ProtocolException { /* Create channel and buffer. */ @@ -793,10 +871,13 @@ public void sendPlaylistRequest(ChannelListener listener, String id) throws Prot /* Normal playlist. */ else{ buffer.put(Hex.toBytes(id)); /* 16 bytes */ - buffer.put((byte)0x02); /* Playlist identifier. TODO: 0x03 spotted. */ + buffer.put((byte)0x02); /* Playlist identifier. */ } + /* + * TODO: Other playlist identifiers (e.g. 0x03, starred tracks? inbox?). + */ - /* TODO */ + /* TODO: Use those fields to request only the information needed. */ buffer.putInt(-1); /* Revision. -1: no cached data. */ buffer.putInt(0); /* Number of entries. */ buffer.putInt(1); /* Checksum. */ diff --git a/src/de/felixbruns/jotify/protocol/Session.java b/src/de/felixbruns/jotify/protocol/Session.java index f329782..be2f95e 100644 --- a/src/de/felixbruns/jotify/protocol/Session.java +++ b/src/de/felixbruns/jotify/protocol/Session.java @@ -299,7 +299,7 @@ private void generateAuthHmac(){ buffer.put((byte)0); /* Unknown */ buffer.putShort((short)this.puzzleSolution.length); buffer.putInt(0x0000000); /* Unknown */ - //buffer.put(randomBytes); /* Zero random bytes :-) */ + /* Random bytes here... */ buffer.put(this.puzzleSolution); /* 8 bytes */ this.authHmac = Hash.hmacSha1(buffer.array(), this.keyHmac);