/******************************************************************************* * Copyright (c) 2013 Jens Kristian Villadsen. * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html * * Contributors: * Jens Kristian Villadsen - Lead developer, owner and creator ******************************************************************************/ /* TunesRemote+ - http://code.google.com/p/tunesremote-plus/ Copyright (C) 2008 Jeffrey Sharkey, http://jsharkey.org/ Copyright (C) 2010 TunesRemote+, http://code.google.com/p/tunesremote-plus/ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. The Initial Developer of the Original Code is Jeffrey Sharkey. Portions created by Jeffrey Sharkey are Copyright (C) 2008. Jeffrey Sharkey, http://jsharkey.org/ All Rights Reserved. */ package org.dyndns.jkiddo.service.daap.client; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import org.dyndns.jkiddo.dmap.chunks.audio.BaseContainer; import org.dyndns.jkiddo.dmap.chunks.audio.DatabaseContainerns; import org.dyndns.jkiddo.dmap.chunks.audio.ItemsContainer; import org.dyndns.jkiddo.dmap.chunks.audio.ServerDatabases; import org.dyndns.jkiddo.dmcp.chunks.media.audio.DataControlInt; import org.dyndns.jkiddo.dmp.chunks.media.ContentCodesResponse; import org.dyndns.jkiddo.dmp.chunks.media.DatabaseShareType; import org.dyndns.jkiddo.dmp.chunks.media.ItemId; import org.dyndns.jkiddo.dmp.chunks.media.ItemName; import org.dyndns.jkiddo.dmp.chunks.media.ListingItem; import org.dyndns.jkiddo.dmp.chunks.media.LoginResponse; import org.dyndns.jkiddo.dmp.chunks.media.PersistentId; import org.dyndns.jkiddo.dmp.chunks.media.ServerInfoResponse; import org.dyndns.jkiddo.dmp.chunks.media.UpdateResponse; import org.dyndns.jkiddo.dmp.model.Container; import org.dyndns.jkiddo.dmp.model.Database; import org.dyndns.jkiddo.service.dmap.Util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.dd.plist.NSDictionary; import com.dd.plist.NSString; import com.google.common.base.Predicate; import com.google.common.collect.Lists; public class Session { private static final Logger LOGGER = LoggerFactory.getLogger(Session.class); private final String host; private long revision = 1; private final int port; private final int sessionId; protected final Database database, radioDatabase; private final Library library; private final RemoteControl remoteControl; private final String homeSharingGid; private byte[] cert; public Session(final String host, final int port, final String username, final String password) throws Exception { // start a session with the iTunes server this.host = host; this.port = port; getServerInfo(); LOGGER.debug(String.format("trying login for host=%s", host)); final NSDictionary nsDic = Util.requestPList(username, password); homeSharingGid = "&hsgid=" + ((NSString) nsDic.get("sgid")).getContent(); final LoginResponse loginResponse = doLoginWithHomeSharingGid(((NSString) nsDic.get("sgid")).getContent()); sessionId = loginResponse.getSessionId().getValue(); getControlInt(); fp_setup_first(); fp_setup_second(); //final String hspid = ((NSString) nsDic.get("spid")).getContent(); final String hspid = "0"; verifyHomeShare(hspid); // Update revision at once. As the initial call, this does not block but simply updates the revision. // See if adding hasFP=1 and hsgid could resolve the following calls // updateServerRevision(); // getUpdateBlocking(); library = new Library(this); remoteControl = new RemoteControl(this); final ServerDatabases serverDatabases = getServerDatabases(); database = getLocalDatabase(serverDatabases); radioDatabase = getRadioDatabase(serverDatabases); } public Session(final String host, final int port, final String pairingGuid) throws Exception { homeSharingGid = ""; // start a session with the iTunes server this.host = host; this.port = port; getServerInfo(); // http://192.168.254.128:3689/login?pairing-guid=0x0000000000000001 LOGGER.debug(String.format("trying login for host=%s and guid=%s", host, pairingGuid)); final LoginResponse loginResponse = doLogin(pairingGuid); sessionId = loginResponse.getSessionId().getValue(); getControlInt(); // Update revision at once. As the initial call, this does not block but simply updates the revision. // updateServerRevision(); getUpdateBlocking(); library = new Library(this); remoteControl = new RemoteControl(this); final ServerDatabases serverDatabases = getServerDatabases(); database = getLocalDatabase(serverDatabases); radioDatabase = getRadioDatabase(serverDatabases); } public long getRevision() { return revision; } public int getSessionId() { return sessionId; } public Database getDatabase() { return database; } public Database getRadioDatabase() { return radioDatabase; } public Library getLibrary() { return library; } public RemoteControl getRemoteControl() { return remoteControl; } protected Database getRadioDatabase(final ServerDatabases serverDatabases) throws Exception { // Radio database final ListingItem database; try { database = serverDatabases.getListing().getSingleListingItem(new Predicate<ListingItem>() { @Override public boolean apply(final ListingItem input) { return DatabaseShareType.RADIO == input.getSpecificChunk(DatabaseShareType.class).getValue(); } }); } catch(final NoSuchElementException nee) { LOGGER.debug("No radio databases found", nee); return null; } final String databaseName = database.getSpecificChunk(ItemName.class).getValue(); final int itemId = database.getSpecificChunk(ItemId.class).getValue(); final long persistentId = database.getSpecificChunk(PersistentId.class).getUnsignedValue().longValue(); final Database rd = new Database(databaseName, itemId, persistentId); // The following causes iTunes to hang ... TODO why? // DatabaseContainerns allPlaylists = getMasterDatabaseContainerList(itemId); // // Iterable<ListingItem> items = allPlaylists.getListing().getListingItems(); // for(ListingItem item : items) // { // Container playlist = new Container(item.getSpecificChunk(ItemName.class).getValue(), 0, item.getSpecificChunk(ItemId.class).getUnsignedValue(), item.getSpecificChunk(ItemCount.class).getUnsignedValue()); // logger.debug(String.format("found radio genre=%s", playlist.getName())); // rd.addPlaylist(playlist); // } return rd; } protected Database getLocalDatabase(final ServerDatabases serverDatabases) throws Exception { // Local database // For now, the LocalDatabase is sufficient final ListingItem localDatabase = serverDatabases.getListing().getSingleListingItem(new Predicate<ListingItem>() { @Override public boolean apply(final ListingItem input) { return DatabaseShareType.LOCAL == input.getSpecificChunk(DatabaseShareType.class).getValue(); } }); final String databaseName = localDatabase.getSpecificChunk(ItemName.class).getValue(); final int itemId = localDatabase.getSpecificChunk(ItemId.class).getValue(); final long persistentId = localDatabase.getSpecificChunk(PersistentId.class).getUnsignedValue().longValue(); // fetch playlists to find the overall magic "Music" playlist final DatabaseContainerns allPlaylists = getDatabaseContainerList(itemId); for(final ListingItem container : allPlaylists.getListing().getListingItems()) { getContainerDetails(itemId, container.getSpecificChunk(ItemId.class).getValue()); } // For now, the BasePlayList is sufficient final ListingItem item = allPlaylists.getListing().getSingleListingItemContainingClass(BaseContainer.class); final Container playlist = new Container(item.getSpecificChunk(ItemName.class).getValue(), item.getSpecificChunk(PersistentId.class).getUnsignedValue().longValue(), item.getSpecificChunk(ItemId.class).getUnsignedValue(), item.getSpecificChunk(BaseContainer.class).getValue()); return new Database(databaseName, itemId, persistentId, playlist); } private ItemsContainer getContainerDetails(final int databaseId, final int containerId) throws Exception { return RequestHelper.requestParsed(String.format("%s/databases/%d/containers/%d/items?session-id=%s&type=music&meta=dmap.itemkind,dmap.itemid,dmap.containeritemid" + homeSharingGid, this.getRequestBase(), databaseId, containerId, sessionId)); } protected DatabaseContainerns getDatabaseContainerList(final int databaseId) throws Exception { return RequestHelper.requestParsed(String.format("%s/databases/%d/containers?session-id=%s&meta=dmap.itemid,dmap.itemname,dmap.persistentid,dmap.parentcontainerid,com.apple.itunes.is-podcast-playlist,com.apple.itunes.special-playlist,com.apple.itunes.smart-playlist,dmap.haschildcontainers,com.apple.itunes.saved-genius" + homeSharingGid, this.getRequestBase(), databaseId, this.sessionId)); } protected ServerDatabases getServerDatabases() throws Exception { return RequestHelper.requestParsed(String.format("%s/databases?session-id=%s" + homeSharingGid, this.getRequestBase(), this.sessionId)); } private LoginResponse doLoginWithHomeSharingGid(final String gid) throws Exception { return RequestHelper.requestParsed(String.format("%s/login?hasFP=1&hsgid=%s", this.getRequestBase(), gid)); } protected LoginResponse doLogin(final String pairingGuid) throws Exception { return RequestHelper.requestParsed(String.format("%s/login?pairing-guid=0x%s", this.getRequestBase(), pairingGuid)); } protected String getRequestBase() { return String.format("http://%s:%d", host, port); } /** * Logout method disconnects the session on the server. This is being a good DACP citizen that was not happening in previous versions. * * @throws Exception */ public void logout() throws Exception { RequestHelper.dispatch(String.format("%s/logout?session-id=%s", this.getRequestBase(), this.sessionId)); } public ServerInfoResponse getServerInfo() throws Exception { return RequestHelper.requestParsed(String.format("%s/server-info", this.getRequestBase())); } public DataControlInt getControlInt() throws Exception { if("".equals(homeSharingGid)) return RequestHelper.requestParsed(String.format("%s/ctrl-int", this.getRequestBase())); else return RequestHelper.requestParsed(String.format("%s/ctrl-int?" + homeSharingGid.subSequence(1, homeSharingGid.length()), this.getRequestBase())); } // Query the media server about the content codes it handles public ContentCodesResponse getContentCodes() throws Exception { return RequestHelper.requestParsed(String.format("%s/content-codes?session-id=%s" + homeSharingGid, this.getRequestBase(), this.sessionId)); } /** * What is currently known is that pausing a playing number does not release it, but eg. changing to next song does. * * @return * @throws Exception */ public UpdateResponse getUpdateBlocking() throws Exception { // try fetching next revision update using socket keepalive // approach // using the next revision-number will make itunes keepalive // until something happens // GET /update?revision-number=1&daap-no-disconnect=1&session-id=1250589827 final UpdateResponse state = RequestHelper.requestParsed(String.format("%s/update?revision-number=%d&daap-no-disconnect=1&session-id=%s" + homeSharingGid, this.getRequestBase(), revision, sessionId), true); revision = state.getServerRevision().getUnsignedValue(); return state; } public UpdateResponse updateServerRevision() throws Exception { final UpdateResponse state = RequestHelper.requestParsed(String.format("%s/update?session-id=%s&revision-number=%s&delta=0" + homeSharingGid, this.getRequestBase(), sessionId, revision), true); revision = state.getServerRevision().getUnsignedValue(); return state; } private void verifyHomeShare(final String hspid) throws Exception { RequestHelper.requestParsed(String.format("%s/home-share-verify?hspid=" + hspid + "&session-id=%s" + homeSharingGid, this.getRequestBase(), this.sessionId)); } private void fp_setup_first() throws Exception { final byte[] value = new byte[] { 2, 0, 2, (byte) 187 }; final byte[] nr = { 1 }; final ArrayList<byte[]> bytes = Lists.newArrayList("FPLYd".getBytes(StandardCharsets.UTF_8), new byte[] { 1 }, nr, new byte[] { 0, 0, 0, 0 }, new byte[] { (byte) value.length }, value); cert = RequestHelper.requestPost(String.format("%s/fp-setup?session-id=%s" + homeSharingGid, this.getRequestBase(), this.sessionId), concatenateByteArrays(bytes)); System.out.println(Util.toHex(cert)); return; } private void fp_setup_second() throws Exception { RequestHelper.requestPost(String.format("%s/fp-setup?session-id=%s" + homeSharingGid, this.getRequestBase(), this.sessionId), cert); } private static byte[] concatenateByteArrays(final List<byte[]> blocks) { final ByteArrayOutputStream os = new ByteArrayOutputStream(); for(final byte[] b : blocks) { os.write(b, 0, b.length); } return os.toByteArray(); } }