package com.sleazyweasel.pandora; import com.google.gson.*; import com.sleazyweasel.applescriptifier.BadPandoraPasswordException; import de.felixbruns.jotify.util.Hex; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; public class JsonPandoraRadio implements PandoraRadio { private static final Logger logger = Logger.getLogger(JsonPandoraRadio.class.getName()); private static final String BLOWFISH_ECB_PKCS5_PADDING = "Blowfish/ECB/PKCS5Padding"; private Long syncTime; private Long clientStartTime; private Integer partnerId; private String partnerAuthToken; private String userAuthToken; private Long userId; private String user; private String password; private PandoraAuthConfiguration authConfiguration = PandoraAuthConfiguration.PANDORAONE_CONFIG; private List<Station> stations; @Override public void connect(String user, String password) throws BadPandoraPasswordException { clientStartTime = System.currentTimeMillis() / 1000L; partnerLogin(); userLogin(user, password); this.user = user; this.password = password; } private void userLogin(String user, String password) { Map<String, Object> userLoginInputs = new HashMap<String, Object>(); userLoginInputs.put("loginType", "user"); userLoginInputs.put("username", user); userLoginInputs.put("password", password); userLoginInputs.put("partnerAuthToken", partnerAuthToken); userLoginInputs.put("syncTime", getPandoraTime()); String userLoginData = new Gson().toJson(userLoginInputs); String encryptedUserLoginData = encrypt(userLoginData); String urlEncodedPartnerAuthToken = urlEncode(partnerAuthToken); String userLoginUrl = String.format(authConfiguration.getBaseUrl() + "method=auth.userLogin&auth_token=%s&partner_id=%d", urlEncodedPartnerAuthToken, partnerId); JsonObject jsonElement = doPost(userLoginUrl, encryptedUserLoginData).getAsJsonObject(); String loginStatus = jsonElement.get("stat").getAsString(); if ("ok".equals(loginStatus)) { JsonObject userLoginResult = jsonElement.get("result").getAsJsonObject(); userAuthToken = userLoginResult.get("userAuthToken").getAsString(); userId = userLoginResult.get("userId").getAsLong(); } else { throw new BadPandoraPasswordException(); } } private String urlEncode(String f) { try { return URLEncoder.encode(f, "ISO-8859-1"); } catch (UnsupportedEncodingException e) { throw new RuntimeException("This better not happen, because ISO-8859-1 is a valid encoding", e); } } private long getPandoraTime() { return syncTime + ((System.currentTimeMillis() / 1000) - clientStartTime); } private void partnerLogin() { JsonElement partnerLoginData = doPartnerLogin(); JsonObject asJsonObject = partnerLoginData.getAsJsonObject(); checkForError(asJsonObject, "Failed at Partner Login"); JsonObject result = asJsonObject.getAsJsonObject("result"); String encryptedSyncTime = result.get("syncTime").getAsString(); partnerAuthToken = result.get("partnerAuthToken").getAsString(); syncTime = Long.valueOf(decrypt(encryptedSyncTime)); partnerId = result.get("partnerId").getAsInt(); } @Override public void sync() { //don't think we need to do this, since it's a part of the core json APIs. } @Override public void disconnect() { syncTime = null; clientStartTime = null; partnerId = null; partnerAuthToken = null; userAuthToken = null; stations = null; } @Override public List<Station> getStations() { JsonObject result = doStandardCall("user.getStationList", new HashMap<String, Object>(), false); checkForError(result, "Failed to get Stations"); JsonArray stationArray = result.get("result").getAsJsonObject().getAsJsonArray("stations"); stations = new ArrayList<Station>(); for (JsonElement jsonStationElement : stationArray) { JsonObject jsonStation = jsonStationElement.getAsJsonObject(); String stationId = jsonStation.get("stationId").getAsString(); String stationIdToken = jsonStation.get("stationToken").getAsString(); boolean isQuickMix = jsonStation.getAsJsonPrimitive("isQuickMix").getAsBoolean(); String stationName = jsonStation.get("stationName").getAsString(); stations.add(new Station(stationId, stationIdToken, false, isQuickMix, stationName)); } return stations; } private JsonObject doStandardCall(String method, Map<String, Object> postData, boolean useSsl) { String url = String.format((useSsl ? authConfiguration.getBaseUrl() : authConfiguration.getNonTlsBaseUrl()) + "method=%s&auth_token=%s&partner_id=%d&user_id=%s", method, urlEncode(userAuthToken), partnerId, userId); logger.fine("url = " + url); postData.put("userAuthToken", userAuthToken); postData.put("syncTime", getPandoraTime()); String jsonData = new Gson().toJson(postData); logger.fine("jsonData = " + jsonData); return doPost(url, encrypt(jsonData)).getAsJsonObject(); } @Override public Station getStationById(long sid) { if (stations == null) { getStations(); } for (Station station : stations) { if (sid == station.getId()) { return station; } } return null; } @Override public void rate(Song song, boolean rating) { String method = "station.addFeedback"; Map<String, Object> data = new HashMap<String, Object>(); data.put("trackToken", song.getTrackToken()); data.put("isPositive", rating); JsonObject ratingResult = doStandardCall(method, data, false); checkForError(ratingResult, "failed to rate song"); } @Override public void tired(Song song) { String method = "user.sleepSong"; Map<String, Object> data = new HashMap<String, Object>(); data.put("trackToken", song.getTrackToken()); JsonObject ratingResult = doStandardCall(method, data, false); checkForError(ratingResult, "failed to sleep song"); } @Override public boolean isAlive() { return userAuthToken != null; } @Override public Song[] getPlaylist(Station station, String format) { Map<String, Object> data = new HashMap<String, Object>(); data.put("stationToken", station.getStationIdToken()); data.put("additionalAudioUrl", "HTTP_192_MP3,HTTP_128_MP3"); JsonObject songResult = doStandardCall("station.getPlaylist", data, true); try { checkForError(songResult, "Failed to get playlist from station"); } catch (RuntimeException e) { String errorCode = songResult.get("code").getAsString(); if ("1003".equals(errorCode) && authConfiguration == PandoraAuthConfiguration.PANDORAONE_CONFIG) { authConfiguration = PandoraAuthConfiguration.ANDROID_CONFIG; reLogin(); return getPlaylist(station, format); } else { throw e; } } JsonArray songsArray = songResult.get("result").getAsJsonObject().get("items").getAsJsonArray(); List<Song> results = new ArrayList<Song>(); for (JsonElement songElement : songsArray) { JsonObject songData = songElement.getAsJsonObject(); //it is completely retarded that pandora leaves this up to the client. Come on, Pandora! Use your brains! if (songData.get("adToken") != null) { continue; } String album = songData.get("albumName").getAsString(); String artist = songData.get("artistName").getAsString(); JsonElement additionalAudioUrlElement = songData.get("additionalAudioUrl"); String additionalAudioUrl = additionalAudioUrlElement != null ? additionalAudioUrlElement.getAsString() : null; JsonObject audioUrlMap = songData.get("audioUrlMap").getAsJsonObject(); JsonObject highQuality = audioUrlMap.get("highQuality").getAsJsonObject(); String audioUrl = highQuality.get("audioUrl").getAsString(); logger.fine("audioUrl = " + audioUrl); logger.fine("additionalAudioUrl = " + additionalAudioUrl); String title = songData.get("songName").getAsString(); String albumDetailUrl = songData.get("albumDetailUrl").getAsString(); String artRadio = songData.get("albumArtUrl").getAsString(); String trackToken = songData.get("trackToken").getAsString(); Integer rating = songData.get("songRating").getAsInt(); if (audioUrl != null && authConfiguration == PandoraAuthConfiguration.PANDORAONE_CONFIG) { results.add(new Song(album, artist, audioUrl, station.getStationId(), title, albumDetailUrl, artRadio, trackToken, rating)); } else if (additionalAudioUrl != null) { results.add(new Song(album, artist, additionalAudioUrl, station.getStationId(), title, albumDetailUrl, artRadio, trackToken, rating)); } } return results.toArray(new Song[results.size()]); } private void reLogin() { partnerLogin(); userLogin(user, password); } private void checkForError(JsonObject songResult, String errorMessage) { String stat = songResult.get("stat").getAsString(); if (!"ok".equals(stat)) { throw new RuntimeException(errorMessage); } } private JsonElement doPartnerLogin() { String partnerLoginUrl = authConfiguration.getBaseUrl() + "method=auth.partnerLogin"; Map<String, Object> data = new HashMap<String, Object>(); data.put("username", authConfiguration.getUserName()); data.put("password", authConfiguration.getPassword()); data.put("deviceModel", authConfiguration.getDeviceModel()); data.put("version", "5"); data.put("includeUrls", true); String stringData = new Gson().toJson(data); return doPost(partnerLoginUrl, stringData); } private static JsonElement doPost(String urlInput, String stringData) { try { URL url = new URL(urlInput); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); urlConnection.setRequestMethod("POST"); urlConnection.setDoOutput(true); urlConnection.setDoInput(true); setRequestHeaders(urlConnection); urlConnection.setRequestProperty("Content-length", String.valueOf(stringData.length())); urlConnection.connect(); DataOutputStream out = new DataOutputStream(urlConnection.getOutputStream()); out.writeBytes(stringData); out.flush(); out.close(); BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), "UTF-8")); String line; while ((line = reader.readLine()) != null) { logger.fine("response = " + line); JsonParser parser = new JsonParser(); return parser.parse(line); } } catch (IOException e) { throw new RuntimeException("Failed to connect to Pandora", e); } throw new RuntimeException("Failed to get a response from Pandora"); } private static void setRequestHeaders(HttpURLConnection conn) { conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"); conn.setRequestProperty("Content-Type", "text/plain"); conn.setRequestProperty("Accept", "*/*"); } private String encrypt(String input) { try { Cipher encryptionCipher = Cipher.getInstance(BLOWFISH_ECB_PKCS5_PADDING); encryptionCipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(authConfiguration.getEncryptionKey().getBytes(), "Blowfish")); byte[] bytes = encryptionCipher.doFinal(input.getBytes()); return Hex.toHex(bytes); } catch (Exception e) { throw new RuntimeException("Failed to properly encrypt data", e); } } private String decrypt(String input) { byte[] result; try { Cipher decryptionCipher = Cipher.getInstance(BLOWFISH_ECB_PKCS5_PADDING); decryptionCipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(authConfiguration.getDecriptionKey().getBytes(), "Blowfish")); result = decryptionCipher.doFinal(Hex.toBytes(input)); } catch (Exception e) { throw new RuntimeException("Failed to properly decrypt data", e); } byte[] chopped = new byte[result.length - 4]; System.arraycopy(result, 4, chopped, 0, chopped.length); return new String(chopped); } }