<NME> FileDownloader.java
<BEF> /*
 * Copyright 2015 The SageTV Authors. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package sage;

import sage.io.BufferedSageFile;
import sage.io.LocalSageFile;
import java.io.IOException;
import java.net.URI;

public class FileDownloader extends SystemTask
{
  private static final int MP4_RESEEK_PREROLL_TIME = 1000;
  private static final boolean CIRC_DOWNLOAD_DEBUG = false;
  private static java.util.Map uiXferMap = new java.util.WeakHashMap();
  private static java.util.Map fileMap = new java.util.HashMap();
  // This stores the status results from file download operations....in a way this is a memory leak since it'll grow without bound;
  // but that growth should be very minimal and not cause any overall memory issue (similar to MetaImage caching all URL images forever)
  private static java.util.Map resultMap = new java.util.HashMap();
  public static FileDownloader getFileDownloader(UIManager uiMgr)
  {
    synchronized (uiXferMap)
    {
      if (uiXferMap.containsKey(uiMgr))
        return (FileDownloader) uiXferMap.get(uiMgr);
      FileDownloader rv = new FileDownloader(uiMgr);
      uiXferMap.put(uiMgr, rv);
      return rv;
    }
  }

  public FileDownloader(UIManager inUIMgr)
  {
    if (inUIMgr != null)
      uiMgrWeak = new java.lang.ref.WeakReference(inUIMgr);
  }

  public static FileDownloader getFileDownloader(java.io.File testFile)
  {
    synchronized (fileMap)
    {
      FileDownloader fd = (FileDownloader) fileMap.get(testFile);
      if (fd != null && !fd.isComplete())
        return fd;
    }
    return null;
  }

  public static Object getBGDownloadResult(java.io.File testFile)
  {
    return resultMap.get(testFile);
  }

  public static boolean isDownloading(java.io.File testFile)
  {
    return isDownloading(null, testFile);
  }
  public static boolean isDownloading(UIManager uiMgr, java.io.File testFile)
  {
    if (uiMgr == null)
    {
      return getFileDownloader(testFile) != null;
    }
    synchronized (uiXferMap)
    {
      FileDownloader fd = getFileDownloader(uiMgr);
      return (fd.myDestFile != null && fd.myDestFile.equals(testFile) && !fd.isComplete());
    }
  }

  public void cancel()
  {
    abort = true;
    if (taskThread != null)
    {
      if (Sage.DBG) System.out.println("Waiting for download thread to terminate...");
      try
      {
        // Forcibly close the HTTP connection if it's there so we don't worry about timeout issues
        if (urlinStream != null)
          urlinStream.close();
      }
      catch (Exception e){}
      try
      {
        taskThread.join(30000);
      }
      catch (InterruptedException e)
      {}
      if (Sage.DBG) System.out.println("Download thread has terminated.");
    }
  }

  public synchronized Object downloadCircularFile(String serverName, String srcFile, java.io.File destFile)
  {
    return downloadFile(serverName, srcFile, destFile, true);
  }
  // serverName can be null which means use the preferredServer, srcFile must be a file, it's downloaded to destFile which must also be a file
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile)
  {
    return downloadFile(serverName, srcFile, destFile, false);
  }
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular)
  {
    return downloadFile(serverName, srcFile, destFile, circular, null);
  }
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps)
  {
    return downloadFile(serverName, srcFile, destFile, circular, requestProps, false);
  }
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous)
  {
    return downloadFile(serverName, srcFile, destFile, circular, requestProps, inSynchronous, false);
  }
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous,
      boolean captureMode)
  {
    return downloadFile(serverName, srcFile, destFile, circular, requestProps, inSynchronous, captureMode, null);
  }
  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous,
      boolean captureMode, DecryptInfo cryptoSpec)
  {
    this.captureMode = captureMode;
    this.cryptoInfo = cryptoSpec;
    synchronous = inSynchronous;
    UIManager uiMgr = (uiMgrWeak != null) ? ((UIManager) uiMgrWeak.get()) : null;
    success = false;
    if (circular && !Sage.client)
      circSize = Sage.getLong("downloader/circular_file_size", 8*1024*1024);
    else
      circSize = 0;
    indexAtEndOfMP4 = fullDownloadRequired = httpRangeSupported= false;
    gotSMBAccess = gotMSAccess = false;
    downloadedBytes = lastDownloadOffset = 0;
    lastPlayerRequestTimestamp = lastSeekNotifyTimestamp = 0;
    lastNotifyReadOffset = 0;
    lastDownloadTimestamp = 0;
    isMP4Stream = false;
    statusMessage = "";
    if (Sage.DBG) System.out.println("Download requested for files server=" + serverName + " src=" + srcFile + " dest=" + destFile);
    if (Sage.client && serverName == null)
      serverName = Sage.preferredServer;
    remoteUIXfer = uiMgr != null && uiMgr.getUIClientType() == UIClient.REMOTE_UI && uiMgr.hasRemoteFSSupport();
    if (serverName != null && (serverName.startsWith("http:") || serverName.startsWith("https:") || serverName.startsWith("ftp:") || serverName.startsWith("file:")))
      remoteUIXfer = false;
    if (serverName == null && !remoteUIXfer)
      return Boolean.FALSE;

    myServerName = serverName;
    if (myServerName != null && myServerName.startsWith("smb://"))
    {
      srcFile = FSManager.getInstance().requestLocalSMBAccess(myServerName);
      if (srcFile == null)
        return Boolean.FALSE;
      gotSMBAccess = true;
    }
    if (myServerName!=null && myServerName.startsWith("file:"))
      mySrcFile = new java.io.File(URI.create(myServerName));
    else
      mySrcFile = (srcFile == null) ? null : new java.io.File(srcFile);

    myDestFile = destFile;
    if (!remoteUIXfer)
    {
      java.io.File destParent = myDestFile.getParentFile();
      if (destParent != null)
        IOUtils.safemkdirs(destParent);
    }

    if (myServerName != null && (myServerName.startsWith("http:") || myServerName.startsWith("https:") || myServerName.startsWith("ftp:")))
    {
          myURLConn = myURL.openConnection();
          if (httpRangeSupported)
            myURLConn.setRequestProperty("Range", "bytes=0-");
          if (requestProps != null)
          {
            java.util.Iterator walker = requestProps.entrySet().iterator();
        {
          myURLConn = myURL.openConnection();
          if (httpRangeSupported)
            myURLConn.setRequestProperty("Range", "bytes=0-");
          if (Sage.getBoolean("use_sagetv_user_agent", false))
            myURLConn.setRequestProperty("User-Agent", "SageTV/" + sage.Version.MAJOR_VERSION + "." + sage.Version.MINOR_VERSION + "." + sage.Version.MICRO_VERSION);
          if (!"".equals(Sage.get("custom_user_agent", "")))
            myURLConn.setRequestProperty("User-Agent", Sage.get("custom_user_agent", ""));
          if (requestProps != null)
          {
            java.util.Iterator walker = requestProps.entrySet().iterator();
            while (walker.hasNext())
            {
              java.util.Map.Entry currEnt = (java.util.Map.Entry) walker.next();
              if (currEnt.getKey() != null && currEnt.getValue() != null)
                myURLConn.setRequestProperty(currEnt.getKey().toString(), currEnt.getValue().toString());
            }
          }
          try
          {
            myURLConn.setConnectTimeout(30000);
            myURLConn.setReadTimeout(30000);
          }catch (Throwable tr){} // in case its a bad JRE version w/ out these calls
          try
          {
            urlinStream = myURLConn.getInputStream();
          }
          catch (Exception e1)
          {
            // Check to see if HTTP Range requests are supported from this server
            if (myURLConn instanceof java.net.HttpURLConnection && httpRangeSupported)
            {
              java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;
              // 416 is the proper response for denying range requests; but some servers just send
              // a generic 400 response (like last.fm)
              if (httpConn.getResponseCode() / 100 == 4)
              {
                if (Sage.DBG) System.out.println("HTTP Range requests are NOT supported by this server. errcode=" + httpConn.getResponseCode());
                httpRangeSupported = false;
                continue;
              }
            }
            throw e1;
          }
          remoteSize = myURLConn.getContentLength();
          if (Sage.DBG) System.out.println("Download remoteSize=" + remoteSize);
          if (myURLConn instanceof java.net.HttpURLConnection)
          {
            java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;
            if (httpConn.getResponseCode() / 100 == 3)
            {
              if (Sage.DBG) System.out.println("Internally processing HTTP redirect...");
              urlinStream.close();
              myURL = new java.net.URL(httpConn.getHeaderField("Location"));
              continue;
            }
          }
          break;
        }
        fileOut = new BufferedSageFile(new LocalSageFile(myDestFile, false));
        if (captureMode)
          fileOut.seek(fileOut.length());
        else
          fileOut.setLength(0);
      }
      catch (Exception e)
      {
        System.out.println("ERROR with file download of:" + e);
        try{
          if (urlinStream != null)
            urlinStream.close();
        }catch (Exception e3){}
        try{
          if (fileOut != null)
            fileOut.close();
        }catch (Exception e4){}
        cleanup();
        return Boolean.FALSE;
      }
    }
    else if (remoteUIXfer)
    {
      if (captureMode)
      {
        // capture mode not supported for remote UI xfers
        return Boolean.FALSE;
      }
      if (!mySrcFile.isFile())
      {
        cleanup();
        return Boolean.FALSE;
      }

      MiniClientSageRenderer mcsr = (MiniClientSageRenderer) uiMgr.getRootPanel().getRenderEngine();
      remoteSize = mySrcFile.length();
      MiniClientSageRenderer.RemoteFSXfer fsXferOp = mcsr.fsDownloadFile(mySrcFile, destFile.toString());
      if (fsXferOp.error != 0)
      {
        cleanup();
        return Boolean.FALSE;
      }
      fsXferOpWeak = new java.lang.ref.WeakReference(fsXferOp);
    }
    else if (myServerName!=null && mySrcFile!=null && mySrcFile.isFile() && myServerName.startsWith("file:"))
    {
      if (Sage.DBG) System.out.println("FileDownloader: Local File Copy: From: " + mySrcFile + " to " + destFile);
      // file url passed as the url to download
      if (!mySrcFile.exists())
      {
        cleanup();
        return Boolean.FALSE;
      }
    }
    else
    {
      try
      {
        sock = new java.net.Socket();
        sock.connect(new java.net.InetSocketAddress(Sage.preferredServer, 7818), 5000);
        sock.setSoTimeout(30000);
        //sock.setTcpNoDelay(true);
        outStream = new java.io.DataOutputStream(new java.io.BufferedOutputStream(sock.getOutputStream()));
        inStream = new java.io.DataInputStream(new java.io.BufferedInputStream(sock.getInputStream()));
        outStream.write("OPENW ".getBytes(Sage.BYTE_CHARSET));
        outStream.write(mySrcFile.toString().getBytes("UTF-16BE"));
        outStream.write("\r\n".getBytes(Sage.BYTE_CHARSET));
        outStream.flush();
        String str = Sage.readLineBytes(inStream);
        if ("NON_MEDIA".equals(str))
        {
          // We may need to get special access to read this file; so try that first
          if (NetworkClient.getSN().requestMediaServerAccess(mySrcFile, true))
          {
            gotMSAccess = true;
            outStream.write("OPENW ".getBytes(Sage.BYTE_CHARSET));
            outStream.write(mySrcFile.toString().getBytes("UTF-16BE"));
            outStream.write("\r\n".getBytes(Sage.BYTE_CHARSET));
            outStream.flush();
            str = Sage.readLineBytes(inStream);
          }
        }
        if (!"OK".equals(str))
          throw new java.io.IOException("Error opening remote file of:" + str);
        // get the size
        outStream.write("SIZE\r\n".getBytes(Sage.BYTE_CHARSET));
        outStream.flush();
        str = Sage.readLineBytes(inStream);
        remoteSize = Long.parseLong(str.substring(0, str.indexOf(' ')));
        fileOut = new BufferedSageFile(new LocalSageFile(myDestFile, false));
        if (captureMode)
          fileOut.seek(fileOut.length());
        else
          fileOut.setLength(0);
      }
      catch (Exception e)
      {
        System.out.println("ERROR with file download of:" + e);
        try{
          if (sock != null)
            sock.close();
        }catch (Exception e1){}
        try{
          if (outStream != null)
            outStream.close();
        }catch (Exception e2){}
        try{
          if (inStream != null)
            inStream.close();
        }catch (Exception e3){}
        try{
          if (fileOut != null)
            fileOut.close();
        }catch (Exception e4){}
        cleanup();
        return Boolean.FALSE;
      }
    }
    // Don't allow others to look at our files if this was done in appendMode because it won't give proper information
    if (!captureMode)
    {
      synchronized (fileMap)
      {
        fileMap.put(myDestFile, this);
      }
    }
    startTaskThread("FileDownload", synchronous);

    return (synchronous && !success) ? Boolean.FALSE : Boolean.TRUE;
  }

  public void taskRun()
  {
    try
    {
      if (myServerName != null && (myServerName.startsWith("http:") || myServerName.startsWith("https:") || myServerName.startsWith("ftp:")))
      {
        httpTaskRun();
      }
      else if (remoteUIXfer)
      {
        remoteUITaskRun();
      }
      else if (myServerName!=null && myServerName.startsWith("file:"))
      {
        fileTaskRun();
      }
      else
      {
        stvTaskRun();
      }
      if (Sage.DBG) System.out.println("Download completed for files server=" + myServerName + " src=" + mySrcFile + " dest=" + myDestFile + " abort=" + abort);
    }
    finally
    {
      cleanup();
      if (uiMgrWeak == null)
      {
        resultMap.put(myDestFile, wasSuccessful() ? (Object)Boolean.TRUE : (Object)("Error: " + getStatusMessage()));
      }
      synchronized (fileMap)
      {
        fileMap.remove(myDestFile);
      }
    }
  }

  private void fileTaskRun() {
    try {
      downloadedBytes=myDestFile.length();
      statusMessage="100%";

      if (mySrcFile.equals(myDestFile)) {
        succeeded();
        return;
      }

      IOUtils.copyFile(mySrcFile, myDestFile);
      succeeded();
    }
    catch (IOException e)
    {
      if (Sage.DBG) System.out.println("ERROR during file download/copy of:" + e);
      Sage.printStackTrace(e);
      statusMessage = "Error:" + e.toString();
    }
  }

  private void cleanup()
  {
    if (gotMSAccess)
    {
      NetworkClient.getSN().requestMediaServerAccess(mySrcFile, false);
      gotMSAccess = false;
    }
    if (gotSMBAccess)
    {
      FSManager.getInstance().releaseLocalSMBAccess(myServerName);
      gotSMBAccess = false;
    }

  }

  // NOTE: Not all servers support Range download requests!!!!
  private void restartHttpDownload(long startOffset) throws java.io.IOException
  {
    if (startOffset > 0 && !httpRangeSupported)
      throw new java.io.IOException("Cannot restart HTTP download because we already know this server doesn't support Range! offset=" + startOffset);
    // Close current connection first
    try{
      if (urlinStream != null)
        urlinStream.close();
    }catch (Exception e3){}
    myURLConn = myURL.openConnection();
    if (httpRangeSupported)
      myURLConn.setRequestProperty("Range", "bytes=" + startOffset + "-");
    try
    {
      myURLConn.setConnectTimeout(30000);
      myURLConn.setReadTimeout(30000);
    }catch (Throwable tr){} // in case its a bad JRE version w/ out these calls
    urlinStream = myURLConn.getInputStream();
    if (myURLConn instanceof java.net.HttpURLConnection)
    {
      java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;
      if (httpConn.getResponseCode() / 100 == 3)
      {
        if (Sage.DBG) System.out.println("Internally processing HTTP redirect to: " + httpConn.getHeaderField("Location") +
            " responseCode=" + httpConn.getResponseCode());
        urlinStream.close();
        myURL = new java.net.URL(httpConn.getHeaderField("Location"));
        if (abort) return;
        restartHttpDownload(startOffset);
        return;
      }
    }
    lastDownloadOffset = downloadedBytes = startOffset;
    //		lastSeekNotifyTimestamp = 0; // reset this indicator
    if (circSize > 0)
    {
      synchronized (notifyRead)
      {
        // Reposition this file
        if (fileOut.length() < (startOffset % circSize))
          fileOut.setLength(startOffset % circSize);
        fileOut.seek(startOffset % circSize);
        notifyRead.notify();
      }
    }
    if (Sage.DBG) System.out.println("HTTP Download has been restarted at offset=" + startOffset);
  }

  // This'll reconnect to the server if it needs to
  private int safeHttpRead(byte[] buffer) throws java.io.IOException
  {
    try
    {
      int rv = urlinStream.read(buffer);
      // For encrypted files, due to partial decryption we can't trust the download size check here
      if (rv <= 0 && remoteSize > 0 && downloadedBytes < remoteSize && httpRangeSupported && cryptoInfo == null)
      {
        if (Sage.DBG) System.out.println("Read " + rv + " bytes from the URL stream; but we're not done with the file yet...restart the download...");
        restartHttpDownload(downloadedBytes);
        return urlinStream.read(buffer);
      }
      return rv;
    }
    catch (java.io.IOException e)
    {
      if (httpRangeSupported && !abort)
      {
        if (Sage.DBG) System.out.println("HTTP read failed; trying to reconnect...");
        restartHttpDownload(downloadedBytes);
        return urlinStream.read(buffer);
      }
      else
        throw e;
    }
  }

  // NOTE: We should monitor the download and resume it if it hangs for some reason....that will definitely occur
  // on large file transfers at some point or another
  private void httpTaskRun()
  {
    downloadedBytes = 0;
    if (xferBuf == null)
      xferBuf = new byte[Sage.getInt("file_transfer_buffer_size", 65536)];
    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();
    if (abort)
    {
      // In case someone is viewing while we're downloading
      Sage.processInactiveFile(myDestFile.toString());
      return;
    }
    boolean isFlashStream = false;
    boolean foundID3Tag = false;
    int id3HeaderSize = 0;
    boolean isMP3Stream = false;
    boolean isSWFStream = false;
    boolean isMpeg2PSStream = false;
    boolean isMpeg2TSStream = false;
    bytesUntilNextSizeTimestamp = 4;
    numParsedSizeTimestampBytes = 0;
    byte[] decryptBuf = null;
    try
    {
      download_loop:
        do
        {
          if (abort)
          {
            // In case someone is viewing while we're downloading
            Sage.processInactiveFile(myDestFile.toString());
            return;
          }
          if (circSize > 0)
          {
            if (lastNotifyReadOffset < Math.max(lastDownloadOffset, downloadedBytes - circSize))
            {
              if (Sage.DBG) System.out.println("DOWNLOADER restarting download to move the stream BACKWARDS notifyReadOffset=" + lastNotifyReadOffset +
                  " lastSeekTimestamp=" + lastSeekNotifyTimestamp);
              // Try to figure out where we should really seek too if we can
              long reseekTarget = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, MP4_RESEEK_PREROLL_TIME);
              /*						if (isMP4Stream && lastSeekNotifyTimestamp > 0)
						{
							long[] dataRange = getMinMaxByteRangeForTimestamp(lastSeekNotifyTimestamp);
							if (dataRange[0] < reseekTarget)
							{
								if (Sage.DBG) System.out.println("Adjusting the backwards request to get all streams in it....was=" +
									reseekTarget + " now=" + dataRange[0]);
								reseekTarget = dataRange[0];
							}
						}*/
              if (reseekTarget < Math.max(lastDownloadOffset, downloadedBytes - circSize))
              {
                bytesUntilNextAtom += downloadedBytes - reseekTarget; // if we're doing the initial MP4 parsing this is needed
                restartHttpDownload(reseekTarget);
                lastAutoReseekTime = Sage.time();
              }
              else if (Sage.DBG) System.out.println("Skipping re-seek since it's target is within our download buffer " + reseekTarget);
            }
            // Check to see if this read request is for data that's after what's in the buffer
            else if (isMP4Stream && lastNotifyReadOffset > downloadedBytes + 256*1024)
              /*					else if (isMP4Stream && (lastNotifyReadOffset > downloadedBytes + circSize ||
						(lastSeekNotifyTimestamp != 0 && lastNotifyReadOffset > downloadedBytes + 128*1024)))*/
              //						((Sage.time() - lastAutoReseekTime) < 5000 ? (circSize*8/10) : 512*1024))
            {
              // Only skip MP4 streams ahead if it's more than the circular buffer (which is definitely a skip)
              // or if the timestamp seek hint has been set as well...otherwise this is probably just another read
              if (Sage.DBG) System.out.println("DOWNLOADER restarting download to move the stream FORWARDS notifyOffset=" + lastNotifyReadOffset +
                  " lastSeekTimestamp=" + lastSeekNotifyTimestamp);
              long reseekTarget = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, MP4_RESEEK_PREROLL_TIME);
              /*						if (lastSeekNotifyTimestamp > 0)
						{
							long[] dataRange = getMinMaxByteRangeForTimestamp(lastSeekNotifyTimestamp);
							if (dataRange[0] < reseekTarget)
							{
								if (Sage.DBG) System.out.println("Adjusting the forwards request to get all streams in it....was=" +
									reseekTarget + " now=" + dataRange[0]);
								reseekTarget = dataRange[0];
							}
						}*/
              if (reseekTarget > downloadedBytes)
              {
                bytesUntilNextAtom -= reseekTarget - downloadedBytes; // if we're doing the initial MP4 parsing this is needed
                restartHttpDownload(reseekTarget);
                lastAutoReseekTime = Sage.time();
              }
              else if (Sage.DBG) System.out.println("Skipping re-seek since it's target is within our download buffer " + reseekTarget);
            }
          }
          if (decryptBuf != null)
          {
            byte[] tempBuf = xferBuf;
            // Swap the buffers back
            xferBuf = decryptBuf;
            decryptBuf = tempBuf;
          }
          int numRead = safeHttpRead(xferBuf);
          if (numRead <= 0)
          {
            if (circSize > 0 && isMP4Stream && httpRangeSupported && indexAtEndOfMP4 && !abort)
            {
              if (Sage.DBG) System.out.println("Waiting at the end of the MP4 stream since we're probably just reading the header...");
              fileOut.flush();
            }
            while (circSize > 0 && isMP4Stream && httpRangeSupported && indexAtEndOfMP4 && !abort)
            {
              if (lastNotifyReadOffset < Math.max(lastDownloadOffset, downloadedBytes - circSize))
              {
                continue download_loop;
              }
              synchronized (notifyRead)
              {
                try
                {
                  notifyRead.wait(30);
                }
                catch (InterruptedException e2){}
              }
            }
            if (Sage.DBG) System.out.println("Read " + numRead + " bytes from the URL stream; download has completed");
            if (cryptoInfo != null)
            {
              numRead = decryptBuffer(xferBuf, 0, decryptBuf, true);
              if (numRead > 0)
              {
                if (circSize > 0)
                {
                  waitForCircWrite(numRead);
                  synchronized (notifyRead)
                  {
                    if (fileOut.position() + numRead < circSize)
                      fileOut.write(decryptBuf, 0, numRead);
                    else
                    {
                      int firstWrite = (int)(circSize - fileOut.position());
                      fileOut.write(decryptBuf, 0, firstWrite);
                      fileOut.seek(0);
                      fileOut.write(decryptBuf, firstWrite, numRead - firstWrite);
                    }
                  }
                }
                else
                  fileOut.write(decryptBuf, 0, numRead);
              }
            }
            break;
          }
          if (cryptoInfo != null)
          {
            if (decryptBuf == null)
              decryptBuf = new byte[xferBuf.length*3/2]; // make it 50% bigger to handle the final buffer which may be larger
            numRead = decryptBuffer(xferBuf, numRead, decryptBuf, false);
            byte[] tempBuf = xferBuf;
            // Swap the buffers now, then do it again before we download more data
            xferBuf = decryptBuf;
            decryptBuf = tempBuf;
          }
          if (circSize > 0)
          {
            waitForCircWrite(numRead);
            synchronized (notifyRead)
            {
              if (fileOut.position() + numRead < circSize)
                fileOut.write(xferBuf, 0, numRead);
              else
              {
                int firstWrite = (int)(circSize - fileOut.position());
                fileOut.write(xferBuf, 0, firstWrite);
                fileOut.seek(0);
                fileOut.write(xferBuf, firstWrite, numRead - firstWrite);
              }
            }
          }
          else
            fileOut.write(xferBuf, 0, numRead);
          if (downloadedBytes == 0 && numRead >= 10 && !isFlashStream && !isMP3Stream && !isMP4Stream && !isSWFStream && !isMpeg2PSStream && !isMpeg2TSStream)
          {
            // Check if it's a flash stream
            if (xferBuf[0] == 'F' && xferBuf[1] == 'L' && xferBuf[2] == 'V' && (((xferBuf[4] & 0x1) == 1) || ((xferBuf[4] & 0x4) == 4)))
            {
              if (Sage.DBG) System.out.println("Detected FLV stream downloading...");
              isFlashStream = true;
              processFlashPacket(xferBuf, 9, numRead - 9);
            }
            else if (xferBuf[0] == 'I' && xferBuf[1] == 'D' && xferBuf[2] == '3')
            {
              if (Sage.DBG) System.out.println("Detected ID3 tag in downloading...");
              foundID3Tag = true;
              id3HeaderSize = ((xferBuf[6]&0x7F)<<21) | ((xferBuf[7]&0x7F)<<14) |
                  ((xferBuf[8]&0x7F)<<7) | (xferBuf[9]&0x7F);
              id3HeaderSize += 10; // for the 10 initial bytes
              isMP3Stream = true; // assume ID3 is MP3 for now
            }
            else if (xferBuf[4] == 'f' && xferBuf[5] == 't' && xferBuf[6] == 'y' && xferBuf[7] == 'p')
            {
              // NOTE: This check could be much more solid....but this should do I'm pretty sure. :)
              if (Sage.DBG) System.out.println("Detected MP4 ISO stream downloading...");
              isMP4Stream = true;
              initMP4Parsing();
              processMP4Packet(xferBuf, 0, numRead);
            }
            else if (xferBuf[4] == 'm' && xferBuf[5] == 'd' && xferBuf[6] == 'a' && xferBuf[7] == 't')
            {
              // Handle quicktime files like MP4s for now....
              if (Sage.DBG) System.out.println("Detected Quicktime stream downloading...");
              isMP4Stream = true;
              initMP4Parsing();
              processMP4Packet(xferBuf, 0, numRead);
            }
            else if ((xferBuf[0] == 'C' || xferBuf[0] == 'F') && xferBuf[1] == 'W' && xferBuf[2] == 'S')
            {
              if (Sage.DBG) System.out.println("Detected SWF stream downloading...");
              isSWFStream = true;
            }
            else if (xferBuf[0] == 0 && xferBuf[1] == 0 && xferBuf[2] == 1 && (xferBuf[3] & 0xFF) == 0xBA)
            {
              if (Sage.DBG) System.out.println("Detected MPEG2-PS stream downloading...");
              isMpeg2PSStream = true;
            }
            else if (xferBuf[0] == 0x47 && (numRead < 189 || xferBuf[188] == 0x47) && (numRead < 377 || xferBuf[376] == 0x47))
            {
              if (Sage.DBG) System.out.println("Detected MPEG2-TS stream downloading...");
              isMpeg2TSStream = true;
            }
            else if (processMP3Packet(xferBuf, 0, numRead))
            {
              if (Sage.DBG) System.out.println("Detected MPEG audio stream downloading...");
              isMP3Stream = true;
            }
          }
          else if (isFlashStream)
            processFlashPacket(xferBuf, 0, numRead);
          else if (isMP4Stream)
            processMP4Packet(xferBuf, 0, numRead);
          else if (isMP3Stream && mp3Bitrate == 0)
          {
            processMP3Packet(xferBuf, 0, numRead);
          }
          else if (isMpeg2PSStream && !captureMode)
            processMpeg2PSPacket(xferBuf, 0, numRead);
          else if (isMpeg2TSStream && !captureMode)
            processMpeg2TSPacket(xferBuf, 0, numRead);
          if (downloadedBytes == 0)
          {
            // So we don't have a zero length file on disk which'll fail verification
            fileOut.flush();
          }
          downloadedBytes += numRead;
          if (CIRC_DOWNLOAD_DEBUG) System.out.println("DownloadedBytes=" + downloadedBytes);
          if (isMP3Stream && downloadedBytes > id3HeaderSize && mp3Bitrate > 0)
          {
            // Estimate the MP3 time that we've downloaded (milliseconds)
            lastDownloadTimestamp = (downloadedBytes - id3HeaderSize)*8000 / mp3Bitrate;
          }
          if (remoteSize > 0)
            statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);
          else
            statusMessage = (downloadedBytes/1024) + " KB";
        }while (true);
    }
    catch (Exception e)
    {
      if (Sage.DBG) System.out.println("ERROR during file download of:" + e);
      Sage.printStackTrace(e);
      statusMessage = "Error:" + e.toString();
      return;
    }
    finally
    {
      try{
        if (urlinStream != null)
          urlinStream.close();
      }catch (Exception e3){}
      try{
        if (fileOut != null)
          fileOut.close();
      }catch (Exception e4){}
      cleanupMP4Parsing();
    }

    succeeded();
    // In case someone is viewing while we're downloading
    if (!captureMode)
      Sage.processInactiveFile(myDestFile.toString());
  }


  // We have the type+size+timestamp(1+3+3)
  private int bytesUntilNextSizeTimestamp = 4; // 4 byte offset into the first packet
  private int numParsedSizeTimestampBytes = 0;
  private byte[] sizeTimestampData = new byte[7];
  private void processFlashPacket(byte[] buf, int offset, int length)
  {
    while (length > 0)
    {
      if (bytesUntilNextSizeTimestamp > 0)
      {
        if (bytesUntilNextSizeTimestamp < length)
        {
          offset += bytesUntilNextSizeTimestamp;
          length -= bytesUntilNextSizeTimestamp;
          bytesUntilNextSizeTimestamp = 0;
          continue;
        }
        else
        {
          bytesUntilNextSizeTimestamp -= length;
          return;
        }
      }
      else
      {
        int neededBytes = 7 - numParsedSizeTimestampBytes;
        int availBytes = Math.min(neededBytes, length);
        System.arraycopy(buf, offset, sizeTimestampData, numParsedSizeTimestampBytes, availBytes);
        numParsedSizeTimestampBytes += availBytes;
        offset += availBytes;
        length -= availBytes;

        if (numParsedSizeTimestampBytes == 7)
        {
          // We have a complete type+size + timestamp
          int streamType = sizeTimestampData[0];
          int tagBodyLength = ((sizeTimestampData[1] & 0xFF) << 16) + ((sizeTimestampData[2] & 0xFF) << 8) + (sizeTimestampData[3] & 0xFF);
          int tagTimestamp = ((sizeTimestampData[4] & 0xFF) << 16) + ((sizeTimestampData[5] & 0xFF) << 8) + (sizeTimestampData[6] & 0xFF);
          if (streamType == 9) // video
          {
            lastDownloadTimestamp = tagTimestamp;
          }
          numParsedSizeTimestampBytes = 0;
          bytesUntilNextSizeTimestamp = tagBodyLength + 8;
        }
      }
    }
  }

  private void processMpeg2PSPacket(byte[] buf, int offset, int length)
  {
    // We just search for the PTS's in the 0xe0 video stream and use those for the latest timestamp
    for (int i = offset; i < offset + length - 27; i++)
    {
      if (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && (buf[i + 3] & 0xFF) == 0xBA)
      {
        // Pack header is here
        // Skip over the data in the pack header and land on the pack_stuffing_length byte (it uses the lower 3 bits)
        i += 13;
        // Check for any stuffing bytes in the pack header and skip them
        i += (1 + (buf[i] & 0x7));
        if (i + 13 >= offset + length) break;
        // Check for a start code next, it can be a system header or a pes packet
        while (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && (buf[i + 3] & 0xFF) != 0xBA)
        {
          i += 3;
          if ((buf[i] & 0xFF) == 0xBB)
          {
            // system header, skip it
            i += 3 + (((buf[i + 1] & 0xFF) << 8) | (buf[i + 2] & 0xFF));
            // Check for the start code next
            if (i + 15 >= offset + length) break;
            if (buf[i] != 0 || buf[i + 1] != 0 || buf[i + 2] != 1)
              continue;
            else
              i += 3;
          }
          if (i + 15 >= offset + length) break;
          int stream_id = buf[i] & 0xFF;
          int packet_length = ((buf[i + 1] & 0xFF) << 8) | (buf[i + 2] & 0xFF);
          //System.out.println("PES stream id=0x" + Integer.toString(stream_id, 16) + " length=" + packet_length);
          if (stream_id == 0xBD || (stream_id >= 0xC0 && stream_id <= 0xEF))
          {
            //System.out.println("Audio/video substream found");
            // Valid audio or video stream, check for the pts flag
            if ((buf[i + 4] & 0x80) == 0x80)
            {
              // PTS found, extract it
              long pts = (((long)(buf[i + 6] & 0x0E)) << 29) |
                  ((buf[i + 7] & 0xFF) << 22) |
                  ((buf[i + 8] & 0xFE) << 14) |
                  ((buf[i + 9] & 0xFF) << 7) |
                  ((buf[i + 10] & 0xFE) >> 1);
              lastDownloadTimestamp = pts / 90; // convert to milliseconds
            }
          }
        }
      }
    }
  }

  private void processMpeg2TSPacket(byte[] buf, int offset, int length)
  {
    for (int i = offset; i < offset + length - 188; i++)
    {
      // Align on a TS sync code
      if (buf[i] == 0x47)
      {
        // Find a payload start indicator, that's the only way there's PTS at the head
        if ((buf[i + 1] & 0x40) != 0)
        {
          int adaptation_field_control = (buf[i + 3] & 0x30) >> 4;
    if (adaptation_field_control == 2)
      i += (188 - 4);
    else if (adaptation_field_control == 3)
      i += Math.min(188 - 6, 1 + (buf[i + 4] & 0xFF));
    i += 4;
    if (adaptation_field_control == 1 || adaptation_field_control == 3)
    {
      // Check for a start code next, it can be a system header or a pes packet
      if (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1)
      {
        i += 3;
        if (i + 15 >= offset + length) break;
        int stream_id = buf[i] & 0xFF;
        //System.out.println("PES stream id=0x" + Integer.toString(stream_id, 16));
        if ((stream_id == 0xBD || (stream_id >= 0xC0 && stream_id <= 0xEF) || (stream_id == 0xFD)))
        {
          //System.out.println("Audio/video substream found");
          // Valid audio or video stream, check for the pts flag
          if ((buf[i + 4] & 0x80) == 0x80)
          {
            // PTS found, extract it
            long pts = (((long)(buf[i + 6] & 0x0E)) << 29) |
                ((buf[i + 7] & 0xFF) << 22) |
                ((buf[i + 8] & 0xFE) << 14) |
                ((buf[i + 9] & 0xFF) << 7) |
                ((buf[i + 10] & 0xFE) >> 1);
            lastDownloadTimestamp = pts / 90; // convert to milliseconds
          }
        }
      }
    }
        }
      }
    }
  }

  private long bytesUntilNextAtom = 0;
  private byte[] remAtomData = new byte[28];
  private int remAtomSize = 0;
  private int[] sttsDataA; // even are the counts, odd are the durations
  private int[] sttsDataV; // even are the counts, odd are the durations
  private int sttsIndex;
  private int[] stscDataA; // even are the first chunk #, odd are the samples per chunk
  private int[] stscDataV; // even are the first chunk #, odd are the samples per chunk
  private int stscIndex;
  private long[] chunkOffsetsA; // one for each chunk
  private long[] chunkOffsetsV; // one for each chunk
  private int stcoIndex;
  private boolean areOffsets64Bits;
  private int chunkQuality;
  private int timeScale;
  private long[] mp4TimeIndexA = null; // chunkOffsets has the positions, this is the same length and has the times
  private long[] mp4TimeIndexV = null; // chunkOffsets has the positions, this is the same length and has the times
  private boolean movieDataBegan;
  private long lastSeekNotifyTimestamp;
  private long lastPlayerRequestTimestamp;
  private static final boolean MP4_PARSER_DEBUG = false;
  private void initMP4Parsing()
  {
    bytesUntilNextAtom = 0;
    remAtomSize = 0;
    sttsIndex = stscIndex = stcoIndex = -1;
    chunkQuality = 0;
    movieDataBegan = false;
  }
  private void cleanupMP4Parsing()
  {
    chunkOffsetsA = chunkOffsetsV = null;
    sttsDataA = sttsDataV = stscDataA = stscDataV = null;
    mp4TimeIndexA = mp4TimeIndexV = null;
  }
  private long findTimestampForBytePos(long pos, long[] bytePos, long[] timestamps)
  {
    int idx = java.util.Arrays.binarySearch(bytePos, pos);
    if (idx < 0)
      idx = (-(idx + 1)) - 1; // find the insertion point then backup one
    if (idx < 0)
      idx = 0; // don't go before zero
    return timestamps[idx];
  }
  private long findBytePosForTimestamp(long timestamp, long[] bytePos, long[] timestamps)
  {
    int idx = java.util.Arrays.binarySearch(timestamps, timestamp);
    if (idx < 0)
      idx = (-(idx + 1)) - 1; // find the insertion point then backup one
    if (idx < 0)
      idx = 0; // don't go before zero
    return bytePos[idx];
  }
  private long[] getMinMaxByteRangeForTimestamp(long timestamp)
  {
    if (!isMP4Stream)
      return null;
    long min = Long.MAX_VALUE;
    long max = 0;
    if (chunkOffsetsA != null && mp4TimeIndexA != null)
    {
      long apos = findBytePosForTimestamp(timestamp, chunkOffsetsA, mp4TimeIndexA);
      min = Math.min(apos, min);
      max = Math.max(apos, max);
    }
    if (chunkOffsetsV != null && mp4TimeIndexV != null)
    {
      long vpos = findBytePosForTimestamp(timestamp, chunkOffsetsV, mp4TimeIndexV);
      min = Math.min(vpos, min);
      max = Math.max(vpos, max);
    }
    return new long[] { min, max };
  }
  private long getSafeMP4SeekPositionForReadPosition(long readPos, long timeOffset)
  {
    if (isMP4Stream && chunkOffsetsV != null && mp4TimeIndexV != null && mp4TimeIndexA != null &&
        chunkOffsetsA != null)
    {
      // Check to see if we think we know exactly what time they're looking for:
      int testidx = java.util.Arrays.binarySearch(chunkOffsetsA, readPos);
      long atime, vtime;
      if (testidx >= 0)
      {
        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING-A seek time to be:" + mp4TimeIndexA[testidx]);
        atime = vtime = mp4TimeIndexA[testidx];
      }
      else if ((testidx = java.util.Arrays.binarySearch(chunkOffsetsV, readPos)) >= 0)
      {
        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING-V seek time to be:" + mp4TimeIndexV[testidx]);
        atime = vtime = mp4TimeIndexV[testidx];
      }
      else
      {
        // Figure out what are the two potential timestamps for this read position.
        // Then take the minimum of those two values.
        // Then look up the positions for each stream for that timestamp.
        // Then use the minimum of those two positions for where we think the client is reading from currently.
        atime = findTimestampForBytePos(readPos, chunkOffsetsA, mp4TimeIndexA);
        vtime = findTimestampForBytePos(readPos, chunkOffsetsV, mp4TimeIndexV);
        long maxtime, mintime;
        if (atime > vtime)
        {
          maxtime = atime;
          mintime = vtime;
        }
        else
        {
          maxtime = vtime;
          mintime = atime;
        }
        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING A-V seek times to be A=" + atime + " V=" + vtime);
      }
      long testTime = Math.min(atime, vtime);
      testTime = Math.max(0, testTime - timeOffset); // for keyframe alignment
      atime = findBytePosForTimestamp(testTime, chunkOffsetsA, mp4TimeIndexA);
      vtime = findBytePosForTimestamp(testTime, chunkOffsetsV, mp4TimeIndexV);
      return Math.min(readPos, Math.min(atime, vtime));
    }
    else
      return readPos;
  }

  private void processMP4Packet(byte[] buf, int offset, int length)
  {
    if (movieDataBegan)
    {
      long newMinTimestamp = Long.MAX_VALUE;
      if (!fullDownloadRequired)
      {
        if (chunkOffsetsA != null && mp4TimeIndexA != null)
        {
          newMinTimestamp = Math.min(findTimestampForBytePos(downloadedBytes, chunkOffsetsA, mp4TimeIndexA), newMinTimestamp);
        }
        if (chunkOffsetsV != null && mp4TimeIndexV != null)
        {
          newMinTimestamp = Math.min(findTimestampForBytePos(downloadedBytes, chunkOffsetsV, mp4TimeIndexV), newMinTimestamp);
        }
        if (newMinTimestamp != Long.MAX_VALUE)
          lastDownloadTimestamp = newMinTimestamp;
      }
      if (!MP4_PARSER_DEBUG && (!indexAtEndOfMP4 || (mp4TimeIndexA != null && mp4TimeIndexV != null)))
        return;
    }
    if (bytesUntilNextAtom > 0)
    {
      if (remAtomSize > 0)
      {
        int used = Math.min((int)bytesUntilNextAtom, remAtomSize);
        if (used != remAtomSize)
        {
          System.arraycopy(remAtomData, used, remAtomData, 0, remAtomSize - used);
        }
        remAtomSize -= used;
        bytesUntilNextAtom -= used;
      }
      if (bytesUntilNextAtom > 0)
      {
        long used = Math.min(bytesUntilNextAtom, length);
        length -= used;
        offset += used;
        bytesUntilNextAtom -= used;
        if (length == 0)
          return;
      }
    }

    while ((length + remAtomSize >= remAtomData.length && bytesUntilNextAtom >= 0) || (downloadedBytes + offset + length == remoteSize && length > 0))
    {
      System.arraycopy(buf, offset, remAtomData, remAtomSize, Math.min(remAtomData.length - remAtomSize, length));
      long currSize;
      if (stcoIndex >= 0)
      {
        long currVal;
        bytesUntilNextAtom = 0;
        long[] chunkOffsets = (chunkQuality == 2) ? chunkOffsetsV : chunkOffsetsA;
        if (areOffsets64Bits)
        {
          for (int x = 0; x + 7 < remAtomData.length; x+=8)
          {
            currVal = ((remAtomData[x] & 0xFF) << 56) | ((remAtomData[x+1] & 0xFF) << 48) | ((remAtomData[x+2] & 0xFF) << 40) | ((remAtomData[x+3] & 0xFF) << 32) |
                ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);
            bytesUntilNextAtom += 8;
            chunkOffsets[stcoIndex++] = currVal;
            if (stcoIndex == chunkOffsets.length)
            {
              stcoIndex = -1;
              if (MP4_PARSER_DEBUG) System.out.println("Done reading chunk offset atom");
              break;
            }
          }
        }
        else
        {
          for (int x = 0; x + 3 < remAtomData.length; x+=4)
          {
            currVal = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);
            bytesUntilNextAtom += 4;
            chunkOffsets[stcoIndex++] = currVal;
            if (stcoIndex == chunkOffsets.length)
            {
              stcoIndex = -1;
              if (MP4_PARSER_DEBUG) System.out.println("Done reading chunk offset atom");
              break;
            }
          }
        }
        if (stcoIndex == -1)
        {
          // We're done parsing the sample information so now we can build the time-byte index
          if (Sage.DBG) System.out.println("Building MP4 byte-time index map to track downloading");
          long[] mp4TimeIndex = new long[chunkOffsets.length];
          if (chunkQuality == 2)
            mp4TimeIndexV = mp4TimeIndex;
          else
            mp4TimeIndexA = mp4TimeIndex;
          int chunkNum = 0;
          long sampleNum = 0;
          sttsIndex = 0;
          stscIndex = 0;
          long time = 0;
          long sampleDuration;
          int samplesLeftInChunk = 0;
          int[] stscData = (chunkQuality == 2) ? stscDataV : stscDataA;
          int[] sttsData = (chunkQuality == 2) ? sttsDataV : sttsDataA;
          while (true)
          {
            if (samplesLeftInChunk == 0)
            {
              mp4TimeIndex[chunkNum] = time;
              chunkNum++;
              if (chunkNum == chunkOffsets.length)
                break;
              if (stscIndex + 2 < stscData.length && chunkNum >= stscData[stscIndex + 2])
                stscIndex += 2;
              samplesLeftInChunk = stscData[stscIndex + 1];
            }
            if (sttsData[sttsIndex] == 0)
              sttsIndex+=2;
            sampleDuration = sttsData[sttsIndex + 1] * 1000 / timeScale;

            int samplesThisTime = Math.min(samplesLeftInChunk, sttsData[sttsIndex]);
            sttsData[sttsIndex] -= samplesThisTime;
            time += sampleDuration * samplesThisTime;
            samplesLeftInChunk -= samplesThisTime;
          }
          if (MP4_PARSER_DEBUG)
          {
            System.out.println("Done building MP4 time index map byte-time");
            for (int i = 0; i < chunkOffsets.length; i++)
            {
              System.out.println("Offset=" + chunkOffsets[i] + " \ttime=" + mp4TimeIndex[i]);;
            }
          }
          sttsIndex = -1;
          stscIndex = -1;
        }
      }
      else if (stscIndex >= 0)
      {
        bytesUntilNextAtom = 0;
        int[] stscData = (chunkQuality == 2) ? stscDataV : stscDataA;
        for (int x = 0; x + 7 < remAtomData.length; x+= 12)
        {
          int firstChunk = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);
          int sampPerChunk = ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);
          bytesUntilNextAtom += 12;
          stscData[stscIndex++] = firstChunk;
          stscData[stscIndex++] = sampPerChunk;
          if (stscIndex == stscData.length)
          {
            stscIndex = -1;
            if (MP4_PARSER_DEBUG)
            {
              System.out.println("Done reading sample-to-chunk atom");
              for (int j = 0; j*2 < stscData.length; j++)
              {
                System.out.println("stsc chunk#" + stscData[2*j] + " \tsamplesPerChunk=" + stscData[2*j +1]);;
              }
            }
            break;
          }
        }
      }
      else if (sttsIndex >= 0)
      {
        bytesUntilNextAtom = 0;
        int[] sttsData = (chunkQuality == 2) ? sttsDataV : sttsDataA;
        for (int x = 0; x + 7 < remAtomData.length; x+= 8)
        {
          int sampleCount = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);
          int sampleDuration = ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);
          bytesUntilNextAtom += 8;
          sttsData[sttsIndex++] = sampleCount;
          sttsData[sttsIndex++] = sampleDuration;
          if (sttsIndex == sttsData.length)
          {
            sttsIndex = -1;
            if (MP4_PARSER_DEBUG)
            {
              System.out.println("Done reading time-to-sample atom");
              for (int j = 0; j*2 < sttsData.length; j++)
              {
                System.out.println("stts sampleCount=" + sttsData[2*j] + " \tsampleDuration=" + sttsData[2*j +1]);;
              }
            }
            break;
          }
        }
      }
      else
      {
        currSize = ((remAtomData[0] & 0xFF) << 24) | ((remAtomData[1] & 0xFF) << 16) | ((remAtomData[2] & 0xFF) << 8) | (remAtomData[3] & 0xFF);
        if (currSize == 0)
          bytesUntilNextAtom = Long.MAX_VALUE; // goes to end of the file
        else if (currSize == 1)
        {
          // extended 64-bit size
          currSize = (((long)(remAtomData[8] & 0xFF)) << 56) | (((long)(remAtomData[9] & 0xFF)) << 48) | (((long)(remAtomData[10] & 0xFF)) << 40) |
              (((long)(remAtomData[11] & 0xFF)) << 32) | (((long)(remAtomData[12] & 0xFF)) << 24) | (((long)(remAtomData[13] & 0xFF)) << 16) |
              (((long)(remAtomData[14] & 0xFF)) << 8) | ((long)(remAtomData[15] & 0xFF));
          bytesUntilNextAtom = currSize;
        }
        else
          bytesUntilNextAtom = currSize;
        String type = ((char) remAtomData[4]) + "" + ((char) remAtomData[5]) + "" + ((char) remAtomData[6]) + "" + ((char) remAtomData[7]);
        if (MP4_PARSER_DEBUG) System.out.println("MP4 size="  + currSize + " type=" + type);
        if ("mdhd".equals(type))
        {
          timeScale = ((remAtomData[20] & 0xFF) << 24) | ((remAtomData[21] & 0xFF) << 16) | ((remAtomData[22] & 0xFF) << 8) | (remAtomData[23] & 0xFF);
          int duration = ((remAtomData[24] & 0xFF) << 24) | ((remAtomData[25] & 0xFF) << 16) | ((remAtomData[26] & 0xFF) << 8) | (remAtomData[27] & 0xFF);
          if (MP4_PARSER_DEBUG) System.out.println("timeScale=" + timeScale + " duration=" + duration + " REAL DURATION(seconds)=" + (duration/timeScale));
        }
        else if ("hdlr".equals(type))
        {
          // Check if it's audio or video
          String compType = ((char) remAtomData[12]) + "" + ((char) remAtomData[13]) + "" + ((char) remAtomData[14]) + "" + ((char) remAtomData[15]);
          String compSubtype = ((char) remAtomData[16]) + "" + ((char) remAtomData[17]) + "" + ((char) remAtomData[18]) + "" + ((char) remAtomData[19]);
          if (MP4_PARSER_DEBUG) System.out.println("MP4 Media compType=" + compType + " subtype=" + compSubtype);
          if (chunkQuality == -1)
          {
            if ("vide".equals(compSubtype))
              chunkQuality = 2;
            else if ("soun".equals(compSubtype))
              chunkQuality = 1;
          }
        }
        else if ("stsc".equals(type) && chunkQuality > 0)
        {
          int[] stscData = new int[2 * (((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |
              ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF))];
          if (chunkQuality == 2)
            stscDataV = stscData;
          else
            stscDataA = stscData;
          stscIndex = 0;
          bytesUntilNextAtom = 16;
          if (MP4_PARSER_DEBUG) System.out.println("Found sample-to-chunk atom");
        }
        else if ("stts".equals(type) && chunkQuality > 0)
        {
          int[] sttsData = new int[2 * (((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |
              ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF))];
          if (chunkQuality == 2)
            sttsDataV = sttsData;
          else
            sttsDataA = sttsData;
          sttsIndex = 0;
          bytesUntilNextAtom = 16;
          if (MP4_PARSER_DEBUG) System.out.println("Found time-to-sample atom");
        }
        else if ("stco".equals(type) && chunkQuality > 0)
        {
          // Parse this table so then we know what chunk # we are on for a given position in the stream (also handle 'co64')
          long[] chunkOffsets = new long[((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |
                                         ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF)];
          if (chunkQuality == 2)
            chunkOffsetsV = chunkOffsets;
          else
            chunkOffsetsA = chunkOffsets;
          stcoIndex = 0;
          bytesUntilNextAtom = 16;
          areOffsets64Bits = false;
          if (MP4_PARSER_DEBUG) System.out.println("Found chunk offset atom");
        }
        else if ("co64".equals(type) && chunkQuality > 0)
        {
          // Parse this table so then we know what chunk # we are on for a given position in the stream (also handle 'co64')
          long[] chunkOffsets = new long[((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |
                                         ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF)];
          if (chunkQuality == 2)
            chunkOffsetsV = chunkOffsets;
          else
            chunkOffsetsA = chunkOffsets;
          stcoIndex = 0;
          bytesUntilNextAtom = 16;
          areOffsets64Bits = true;
          if (MP4_PARSER_DEBUG) System.out.println("Found chunk offset (64-bit) atom");
        }
        else if ("mdat".equals(type))
        {
          if (chunkOffsetsV == null || mp4TimeIndexV == null)
          {
            if (Sage.DBG) System.out.println("MP4 file being downloaded is NOT STREAMBLE; the mdat section came before the sample tables");
            if (circSize > 0)
            {
              lastDownloadTimestamp = 5000; // to make it think we've got somewhere in the download
              indexAtEndOfMP4 = true;
              fullDownloadRequired = false;
            }
            else
              indexAtEndOfMP4 = fullDownloadRequired = true;
          }
          movieDataBegan = true;
        }
        if ("trak".equals(type))
          chunkQuality = -1; // signifies time to look for a new media type
        if ("moov".equals(type) || "trak".equals(type) || "mdia".equals(type) || "minf".equals(type) || "stbl".equals(type)) // it contains nested atoms
          bytesUntilNextAtom = 8;
      }
      if (bytesUntilNextAtom > 0)
      {
        if (remAtomSize > 0)
        {
          int used = Math.min((int)bytesUntilNextAtom, remAtomSize);
          if (used != remAtomSize)
          {
            System.arraycopy(remAtomData, used, remAtomData, 0, remAtomSize - used);
          }
          remAtomSize -= used;
          bytesUntilNextAtom -= used;
        }
        if (bytesUntilNextAtom > 0)
        {
          long used = Math.min(bytesUntilNextAtom, length);
          length -= used;
          offset += used;
          bytesUntilNextAtom -= used;
          if (length == 0)
            return;
        }
      }
    }
    if (length > 0)
    {
      System.arraycopy(buf, offset, remAtomData, remAtomSize, length);
      remAtomSize = length;
    }
  }

  private boolean processMP3Packet(byte[] buf, int offset, int length)
  {
    int index = 0;
    // scan through file to get syncword
    int headerstring = ((buf[index + offset] << 16) & 0x00FF0000)
        | ((buf[index+1+offset] << 8) & 0x0000FF00)
        | ((buf[index+2+offset] << 0) & 0x000000FF);
    boolean isSync = false;
    index += 2;
    do {
      headerstring <<= 8;
      index++;
      if (index+offset >= length) {
        // We didn't find the MP3 syncword so it probably isn't it
        return false;
      }

      headerstring |= (buf[index+offset] & 0x000000FF);
      isSync = ((headerstring & 0xFFE00000) == 0xFFE00000);
      // MPEG 2.5
      // filter out invalid sample rate
      if (isSync)
        isSync = (((headerstring >>> 10) & 3) != 3);
      // filter out invalid layer
      if (isSync)
        isSync = (((headerstring >>> 17) & 3) != 0);
      // filter out invalid version
      if (isSync)
        isSync = (((headerstring >>> 19) & 3) != 1);
    } while (!isSync);
    // Got a header... lets have a look at it...
    int h_version = ((headerstring >>> 19) & 1);
    if (((headerstring >>> 20) & 1) == 0) // SZD: MPEG2.5 detection
      if (h_version == MP3Utils.MPEG2_LSF)
        h_version = MP3Utils.MPEG25_LSF;
      else {
        if (Sage.DBG) System.out.println("invalid MPEG header (version)");
        return false;
      }
    int h_sample_frequency;
    if ((h_sample_frequency = ((headerstring >>> 10) & 3)) == 3) {
      if (Sage.DBG) System.out.println("invalid MPEG header (sample freq)");
      return false;
    }
    int h_layer = 4 - (headerstring >>> 17) & 3;
    int h_bitrate_index = (headerstring >>> 12) & 0xF;
    mp3Bitrate = MP3Utils.bitrates[h_version][h_layer - 1][h_bitrate_index];
    if (Sage.DBG) System.out.println("Detected mp3 bitrate of:" + mp3Bitrate);
    return true;
  }

  // NOTE: Not all MP4 content can be played back streaming. They may not have certain atom information
  // at the front of the file that is necessary to decode before playback. We judge this based off
  // the sample table. If we haven't hit it yet then we don't know if it's streamable. But we can tell if
  // we see a very large atom which is going to take the majority of the file that we know it will not be
  // streamable.

  private void stvTaskRun()
  {
    downloadedBytes = 0;
    if (xferBuf == null)
      xferBuf = new byte[Sage.getInt("file_transfer_buffer_size", 65536)];
    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();
    if (abort)
    {
      // In case someone is viewing while we're downloading
      Sage.processInactiveFile(myDestFile.toString());
      return;
    }
    long totalFileSize = remoteSize;
    try
    {
      long currOffset = 0;
      while (totalFileSize > 0)
      {
        if (abort)
        {
          // In case someone is viewing while we're downloading
          Sage.processInactiveFile(myDestFile.toString());
          return;
        }
        int currRead = (int)Math.min(xferBuf.length, totalFileSize);
        outStream.write(("READ " + currOffset + " " + currRead + "\r\n").getBytes(Sage.BYTE_CHARSET));
        outStream.flush();
        inStream.readFully(xferBuf, 0, currRead);
        if (circSize > 0)
        {
          waitForCircWrite(currRead);
          if (fileOut.position() + currRead < circSize)
            fileOut.write(xferBuf, 0, currRead);
          else
          {
            int firstWrite = (int)(circSize - fileOut.position());
            fileOut.write(xferBuf, 0, firstWrite);
            fileOut.seek(0);
            fileOut.write(xferBuf, firstWrite, currRead - firstWrite);
          }
        }
        else
          fileOut.write(xferBuf, 0, currRead);
        downloadedBytes += currRead;
        statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);
        totalFileSize -= currRead;
        currOffset += currRead;
      }
      outStream.write("QUIT\r\n".getBytes(Sage.BYTE_CHARSET));
      outStream.flush();
    }
    catch (Exception e)
    {
      if (Sage.DBG) System.out.println("ERROR during file download of:" + e);
      Sage.printStackTrace(e);
      statusMessage = "Error:" + e.toString();
      return;
    }
    finally
    {
      try{
        if (sock != null)
          sock.close();
      }catch (Exception e1){}
      try{
        if (outStream != null)
          outStream.close();
      }catch (Exception e2){}
      try{
        if (inStream != null)
          inStream.close();
      }catch (Exception e3){}
      try{
        if (fileOut != null)
          fileOut.close();
      }catch (Exception e4){}
    }

    succeeded();
    // In case someone is viewing while we're downloading..not needed for synchronous transfers
    if (!synchronous)
      Sage.processInactiveFile(myDestFile.toString());
  }

  private void remoteUITaskRun()
  {
    downloadedBytes = 0;
    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();
    MiniClientSageRenderer.RemoteFSXfer fsXferOp = (MiniClientSageRenderer.RemoteFSXfer) fsXferOpWeak.get();
    while (!fsXferOp.isDone())
    {
      if (abort)
      {
        fsXferOp.abortNow();
        return;
      }
      try{Thread.sleep(250);}catch(Exception e){}
      downloadedBytes = fsXferOp.getBytesXferd();
      statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);
    }

    if (fsXferOp.error != 0)
    {
      statusMessage = "Error:" + fsXferOp.error;
      return;
    }
    succeeded();
  }

  public long getNumDownloadedBytes()
  {
    return downloadedBytes;
  }

  public long getLastDownloadTimestamp()
  {
    return lastDownloadTimestamp;
  }

  public long getMinSeekTimestamp()
  {
    if (circSize > 0)
    {
      if (isMP4Stream && httpRangeSupported & !isComplete())
        return 0; // We can seek anywhere in this case
      else
      {
        // Only let them seek backwards into what we still have in the circular buffer. But this is
        // tricky because we'll need to track timestamps for this. For now just guess based on avg. bitrate
        // which we can easily get. And put the seek point 10% of the way into the buffer.
        if (downloadedBytes < circSize)
          return 0;
        else
        {
          long minSeekPoint = Math.max(lastDownloadOffset, downloadedBytes - circSize) + circSize/10;
          if (minSeekPoint >= downloadedBytes)
            return lastDownloadTimestamp;
          else
          {
            return lastDownloadTimestamp * minSeekPoint / downloadedBytes;
          }
        }
      }
    }
    else
      return 0; // the whole thing's being downloaded
  }

  public long getMaxSeekTimestamp()
  {
    if (circSize > 0 && isMP4Stream && httpRangeSupported)
      return Long.MAX_VALUE/10; // avoid overflows when this is used in math outside of here
    else
      return lastDownloadTimestamp;
  }

  // Returns true if things interested in this downloaders timing progress need
  // to make calls to find out more. (i.e. this is always true for circular files; but its false once a normal download completes)
  public boolean needsTracking()
  {
    return circSize > 0 || !isComplete();
  }

  public boolean isProgressivePlay()
  {
    return !fullDownloadRequired;
  }

  public long getCircularDownloadSize()
  {
    return circSize;
  }

  // This call is used during progressive download playback w/ circular file storage.
  // It's called so the downloader knows where the reading is currently at so it doesn't get too
  // far ahead and wrap around and write over what hasn't been played yet
  public void notifyReadRequest(long offset, long length)
  {
    lastNotifyReadOffset = offset;
    lastNotifyReadLength = length;
    if (CIRC_DOWNLOAD_DEBUG) System.out.println("notifyReadRequest=" + offset + " downloadedRange=" + Math.max(lastDownloadOffset, downloadedBytes - circSize) + " - " +
        downloadedBytes);
    if (circSize > 0 && !isComplete())
    {
      // Check to see if this read request is for data that's before what's in the buffer
      if (offset < Math.max(lastDownloadOffset, downloadedBytes - circSize))
      {
        //System.out.println("WARNING : Attempting to read from offset before the buffer: offset=" + offset);
        waitingForRead = true;
        while (alive && !abort && offset < Math.max(lastDownloadOffset, downloadedBytes - circSize))
        {
          synchronized (notifyRead)
          {
            try
            {
              notifyRead.wait(30);
            }
            catch (InterruptedException e){ }
          }
        }
        //System.out.println("DONE waiting for backwards read request to be ready!!");
        waitingForRead = false;
      }
      // Check to see if this read request is for data that's after what's in the buffer
      long maxRead = (remoteSize > 0) ? Math.min(remoteSize, offset + length) : (offset + length);
      if (maxRead > downloadedBytes/* && isMP4Stream*/)
      {
        //System.out.println("WARNING: Trying to read data beyond what's been downloaded diff=" + ((maxRead) - downloadedBytes));
        waitingForRead = true;
        while (alive && !abort && maxRead > downloadedBytes)
        {
          synchronized (notifyRead)
          {
            try
            {
              notifyRead.wait(30);
            }
            catch (InterruptedException e){ }
          }
        }
        //System.out.println("DONE waiting for forwards read request to be ready!!");
        waitingForRead = false;
      }
      synchronized (notifyRead)
      {
        notifyRead.notify();
        // Check if we need to flush this to disk yet in case the read from the circular file would be on data that's still
        // in the write buffer for the RAF
        if (offset + length > downloadedBytes - 65536)
        {
          try
          {
            if (fileOut != null)
              fileOut.flush();
          }
          catch (Exception e){}
        }
      }
    }
  }

  // Pauses and waits until it is OK to write data of the specified size to the circular file.
  protected void waitForCircWrite(int writeSize)
  {
    if (circSize > 0 && (downloadedBytes + writeSize) >= circSize)
    {
      // We want the player to be in the middle of the circular buffer. So if they're at least 40% of the
      // way into the buffer for their last read, we do this write.
      long markerPoint = Math.max(0, downloadedBytes - circSize) + circSize * 4 / 10;
      long minClientReadPoint = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, 0);
      if (CIRC_DOWNLOAD_DEBUG && markerPoint > minClientReadPoint)
        System.out.println("WAITING for circ buffer write to be OK");
      while (alive && markerPoint > minClientReadPoint && !abort && !waitingForRead)
      {
        synchronized (notifyRead)
        {
          try
          {
            notifyRead.wait(50);
          }
          catch (InterruptedException e){ }
        }
      }
    }
  }

  public boolean isClientWaitingForRead()
  {
    return waitingForRead;
  }

  public void notifyForPlayerSeek(long seekTimestamp)
  {
    if (CIRC_DOWNLOAD_DEBUG) System.out.println("Downloader notified of seek to: " + seekTimestamp);
    lastSeekNotifyTimestamp = seekTimestamp;
  }


  public long getRemoteSize() { return remoteSize; }

  //	private static boolean addedBouncy = false;
  private int decryptBuffer(byte[] xferBuf, int numRead, byte[] decryptBuf, boolean finalStep) throws Exception
  {
    // Here's where we can go to native code instead for the operation for performance reasons
    if (decryptCipher == null)
    {
      decryptKey = new javax.crypto.spec.SecretKeySpec(cryptoInfo.key, cryptoInfo.algorithm.substring(0, cryptoInfo.algorithm.indexOf('/')));
      /*			if (!addedBouncy)
			{
				java.security.Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
				addedBouncy = true;
			}*/
      decryptCipher = javax.crypto.Cipher.getInstance(cryptoInfo.algorithm);
      if (cryptoInfo.iv != null)
      {
        javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(cryptoInfo.iv);
        decryptCipher.init(javax.crypto.Cipher.DECRYPT_MODE, decryptKey, ivSpec);
      }
      else
        decryptCipher.init(javax.crypto.Cipher.DECRYPT_MODE, decryptKey);
    }
    if (finalStep)
    {
      return decryptCipher.doFinal(xferBuf, 0, numRead, decryptBuf);
    }
    else
      return decryptCipher.update(xferBuf, 0, numRead, decryptBuf);
  }

  public static class DecryptInfo
  {
    public DecryptInfo(String algorithm, byte[] key, byte[] iv)
    {
      this.algorithm = algorithm;
      this.key = key;
      this.iv = iv;
    }
    public String algorithm;
    public byte[] key;
    public byte[] iv;
  }

  protected String myServerName;
  protected java.io.File mySrcFile;
  protected java.io.File myDestFile;
  protected java.net.Socket sock = null;
  protected java.io.DataOutputStream outStream = null;
  protected java.io.DataInputStream inStream = null;
  protected sage.io.SageFileSource fileOut = null;
  protected long remoteSize;
  protected boolean isMP4Stream;

  protected java.net.URL myURL;
  protected java.net.URLConnection myURLConn;
  protected java.io.InputStream urlinStream = null;

  protected long lastDownloadTimestamp;

  protected boolean remoteUIXfer;

  protected boolean gotMSAccess;
  protected boolean gotSMBAccess;

  protected boolean httpRangeSupported;

  protected long downloadedBytes;
  protected int mp3Bitrate;
  protected boolean fullDownloadRequired;
  protected boolean indexAtEndOfMP4;
  protected long circSize;
  protected long lastNotifyReadOffset;
  protected long lastNotifyReadLength;
  protected long lastDownloadOffset; // if we restart a download at a new offset, save it here
  protected Object notifyRead = new Object();
  protected boolean waitingForRead = false;
  protected long lastAutoReseekTime;


  // NOTE: Must use weak references here so we don't prevent GC of the UI stuff from
  // being a value in the WeakHashMap (who's value then has a strong reference to us, and the WeakHashMap values are strong refs as well)
  protected java.lang.ref.WeakReference uiMgrWeak;
  protected java.lang.ref.WeakReference fsXferOpWeak;

  protected boolean synchronous;
  protected byte[] xferBuf;
  protected boolean captureMode;

  protected DecryptInfo cryptoInfo;
  protected javax.crypto.Cipher decryptCipher;
  protected javax.crypto.spec.SecretKeySpec decryptKey;
}

<MSG> Merge pull request #1 from google/master

Update #1
<DFF> @@ -184,6 +184,8 @@ public class FileDownloader extends SystemTask
           myURLConn = myURL.openConnection();
           if (httpRangeSupported)
             myURLConn.setRequestProperty("Range", "bytes=0-");
+          if (Sage.getBoolean("use_sagetv_user_agent", false))
+            myURLConn.setRequestProperty("User-Agent", "SageTV/" + sage.Version.MAJOR_VERSION + "." + sage.Version.MINOR_VERSION + "." + sage.Version.MICRO_VERSION);
           if (requestProps != null)
           {
             java.util.Iterator walker = requestProps.entrySet().iterator();

{'commit': 'diff --git a/AUTHORS b/AUTHORS\nindex 2a48d343..bde5fa18 100644\n--- a/AUTHORS\n+++ b/AUTHORS\n@@ -7,3 +7,4 @@\n # The email address is not required for organizations.\n \n Google Inc.\n+Christopher Piper <fuzzy@weirdness.com>\ndiff --git a/CONTRIBUTORS b/CONTRIBUTORS\nindex aa80be3d..d5f7c081 100644\n--- a/CONTRIBUTORS\n+++ b/CONTRIBUTORS\n@@ -17,3 +17,4 @@ Niel Markwick <nielm@google.com>\n David DeHaven <daved@bacomatic.org>\n Brian Russell <i601@me.com>\n John McDole <codefu@google.com>\n+Christopher Piper <fuzzy@weirdness.com>\ndiff --git a/README b/README\nindex d7bacbb1..e901defe 100644\n--- a/README\n+++ b/README\n@@ -42,3 +42,39 @@ have it packaged into tarballs and debian installers.\n \n The Windows components need to be built with Visual Studio 2005, mingw/msys and javac.\n There is no script for doing this included.\n+\n+Release Notes for V9\n+\n+1. Java compliance level is now 1.5\n+2. Read/write locking support in the database (previously, it only had generic rw locks...now their are explicit locks for read and write on each table)\n+3. If on a menu w/out full screen video, but video is playing, switch to the full screen video menu when the screen saver timeout expires\n+4. Lucene support for search (search API calls are now executed on the server and utilize the Lucene index for performance)\n+5. Loads of various bug fixes and performance enhancements :)\n+6. New Airing API calls: GetParentalLimitsExceeded, GetPlayableAiring\n+7. New Channel API calls: GetChannelLogoURL\n+8. New Configuration API calls: SetCurrentlyAiringProgramsStartLive, GetCurrentlyAiringProgramsStartLive\n+9. New Database API calls(NTE methods support numeric text input, such as using 228 would match ‘cat’ or ‘bat’, more details in the code): SearchForPeopleNTE, SearchForTitlesNTE, SearchSelectedFieldsNTE, GetMediaMask, HasMediaMask, SearchForChannel\n+10. Various filtering, sorting and grouping methods in the Database API are now optimized and don’t go back through the API interpreter for filter/sort/group method execution\n+11. Following Database API calls now support extra args to pass to filter/sort/group methods: FilterByBoolMethod, FilterByMethod, GroupByMethod\n+12. New Favorite API calls: GetFavoritePresentationName, GetFavoritePresentationPrefix, GetFavoritesForAiring\n+13. New Global API calls: SageCommand (with payload), IsTransitioningToMenus, IsTransitioningFromMenus, GetUIComponentPositionXf, GetUIComponentPositionYf, GetUIComponentWidthf, GetUIComponentHeightf, IsClientDisconnected, GetClientServerConnectionProgress, GetEPGUpdateState, SwitchEmbeddedModeTo/AreScreenshotsSupported/SaveScreenshotToFile (these 3 are only useful on embedded)\n+14. New MediaPlayer API calls: GetRecentChannels, ClearRecentChannels\n+15. New SeriesInfo API calls: GetSeriesImage(bool), GetSeriesImageURL, GetSeriesImageCount, GetSeriesImageAtIndex, GetSeriesImageURLAtIndex, HasSeriesActorImage, GetSeriesActorImage, GetSeriesActorImageURL\n+16. New Show API calls: IsMovie, HasMovieImage, GetMovieImage, GetMovieImageURL, GetMovieImageCount, GetMovieImageAtIndex, GetMovieImageURLAtIndex, HasPersonImage, GetPersonImage, GetPersonImageURL, GetPersonDateOfBirth, GetPersonDateOfDeath, GetPersonBirthplace, GetPersonID, GetPersonForID, GetMovieStarRating\n+17. New Utility API calls: GetMetaImageAspectRatio, GetMetaImageBytes, GetLocalFileAsString, ConvertNteChars, StringIndexOfNTE, StringStartsWithNTE, DumpServerThreadStates\n+18. Person is a new database object (rather than just being Stringer objects as they were previously)\n+19. Series/person/character image information now exists in the database (follows TMS naming conventions)\n+20. Thread monitoring and regular logging of CPU usage of various threads\n+21. Significant performance improvements to overall database system\n+22. Licensing/key checking code removed\n+23. “Spoiler Protection” when watching live TV, it will not default to a channel which could possibly ‘spoil’ something else you care about. It also tries to avoid selecting anything that needs parental control by default.\n+24. Codebase from EMBEDDED (HD300) merged with main SageTV codebase (NOTE: This will no longer run on the HD300 because it’s not Java 1.5 source rather than Java 1.4)\n+25. Added new hooks: RecordRequestLiveMultiConflict, WatchRequestLiveMultiConflict (these allow processing all conflicts for watch/record requests at once rather than one at a time; if these hooks are not in the STV then the non-Multi hooks that have always existed will be called instead)\n+26. TVTV support removed\n+27. Added property “hdmi_always_select_best” which defaults to false, but if set true will auto select the best HDMI resolution for extender clients\n+28. Added property “studio/alert_on_syntax_error” which defaults to false, but if true will popup an alert in the Studio if a syntax error is entered (i.e. an expression the parser cannot properly compile)\n+29. Support for rendering of CEA-708 closed captions (i.e. Digital TV closed captions)\n+30. Added downloader for Weather Underground data (API license key required to use)\n+31. CVS integration for SageTV Studio (although not that useful since this is hosted on GIT now; but it’s done in an abstract way so GIT support could be added)\n+32. Added RemotePusherClient support. This allows for a separate process that handles the streaming (push & pull mode) for SageTV rather than running this through the Java classes.\n+33. On Windows the global environment variable SAGETVUSERKEY should be set to be your license key if you’re using the EXE built from the open source codebase. This will then allow you to communicate with the SageTV EPG server.\ndiff --git a/java/sage/FileDownloader.java b/java/sage/FileDownloader.java\nindex 28033fa2..89530979 100755\n--- a/java/sage/FileDownloader.java\n+++ b/java/sage/FileDownloader.java\n@@ -184,6 +184,8 @@ public class FileDownloader extends SystemTask\n           myURLConn = myURL.openConnection();\n           if (httpRangeSupported)\n             myURLConn.setRequestProperty("Range", "bytes=0-");\n+          if (Sage.getBoolean("use_sagetv_user_agent", false))\n+            myURLConn.setRequestProperty("User-Agent", "SageTV/" + sage.Version.MAJOR_VERSION + "." + sage.Version.MINOR_VERSION + "." + sage.Version.MICRO_VERSION);\n           if (requestProps != null)\n           {\n             java.util.Iterator walker = requestProps.entrySet().iterator();\n', 'commit_subject': 'Merge pull request #1 from google/master', 'repo_name': 'google/sagetv', 'committer': {'name': 'TuscanRaider', 'email': '9261240d843f67280da82568a7f0a6129c1c3f35@gmail.com', 'time_sec': '1439339757', 'tz_offset': '-420', 'date': {'seconds': '1439339757'}}, 'commit_tree': '1dd203c536b5bd972b122fdf6d319b06791adf7e', 'commit_message': 'Merge pull request #1 from google/master\n\nUpdate #1', 'language_name': 'C++', 'license': 'apache-2.0', 'hunks': '@@ -184,6 +184,8 @@ public class FileDownloader extends SystemTask\n           myURLConn = myURL.openConnection();\n           if (httpRangeSupported)\n             myURLConn.setRequestProperty("Range", "bytes=0-");\n+          if (Sage.getBoolean("use_sagetv_user_agent", false))\n+            myURLConn.setRequestProperty("User-Agent", "SageTV/" + sage.Version.MAJOR_VERSION + "." + sage.Version.MINOR_VERSION + "." + sage.Version.MICRO_VERSION);\n           if (requestProps != null)\n           {\n             java.util.Iterator walker = requestProps.entrySet().iterator();\n', 'addition_count': 2, 'deletion_count': 0, 'src_file': 'a/java/sage/FileDownloader.java', 'tgt_file': 'b/java/sage/FileDownloader.java', 'file_extension': '.java', 'before_file': '/*\n * Copyright 2015 The SageTV Authors. All Rights Reserved.\n *\n * Licensed under the Apache License, Version 2.0 (the "License");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *  http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an "AS IS" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage sage;\n\nimport sage.io.BufferedSageFile;\nimport sage.io.LocalSageFile;\nimport java.io.IOException;\nimport java.net.URI;\n\npublic class FileDownloader extends SystemTask\n{\n  private static final int MP4_RESEEK_PREROLL_TIME = 1000;\n  private static final boolean CIRC_DOWNLOAD_DEBUG = false;\n  private static java.util.Map uiXferMap = new java.util.WeakHashMap();\n  private static java.util.Map fileMap = new java.util.HashMap();\n  // This stores the status results from file download operations....in a way this is a memory leak since it\'ll grow without bound;\n  // but that growth should be very minimal and not cause any overall memory issue (similar to MetaImage caching all URL images forever)\n  private static java.util.Map resultMap = new java.util.HashMap();\n  public static FileDownloader getFileDownloader(UIManager uiMgr)\n  {\n    synchronized (uiXferMap)\n    {\n      if (uiXferMap.containsKey(uiMgr))\n        return (FileDownloader) uiXferMap.get(uiMgr);\n      FileDownloader rv = new FileDownloader(uiMgr);\n      uiXferMap.put(uiMgr, rv);\n      return rv;\n    }\n  }\n\n  public FileDownloader(UIManager inUIMgr)\n  {\n    if (inUIMgr != null)\n      uiMgrWeak = new java.lang.ref.WeakReference(inUIMgr);\n  }\n\n  public static FileDownloader getFileDownloader(java.io.File testFile)\n  {\n    synchronized (fileMap)\n    {\n      FileDownloader fd = (FileDownloader) fileMap.get(testFile);\n      if (fd != null && !fd.isComplete())\n        return fd;\n    }\n    return null;\n  }\n\n  public static Object getBGDownloadResult(java.io.File testFile)\n  {\n    return resultMap.get(testFile);\n  }\n\n  public static boolean isDownloading(java.io.File testFile)\n  {\n    return isDownloading(null, testFile);\n  }\n  public static boolean isDownloading(UIManager uiMgr, java.io.File testFile)\n  {\n    if (uiMgr == null)\n    {\n      return getFileDownloader(testFile) != null;\n    }\n    synchronized (uiXferMap)\n    {\n      FileDownloader fd = getFileDownloader(uiMgr);\n      return (fd.myDestFile != null && fd.myDestFile.equals(testFile) && !fd.isComplete());\n    }\n  }\n\n  public void cancel()\n  {\n    abort = true;\n    if (taskThread != null)\n    {\n      if (Sage.DBG) System.out.println("Waiting for download thread to terminate...");\n      try\n      {\n        // Forcibly close the HTTP connection if it\'s there so we don\'t worry about timeout issues\n        if (urlinStream != null)\n          urlinStream.close();\n      }\n      catch (Exception e){}\n      try\n      {\n        taskThread.join(30000);\n      }\n      catch (InterruptedException e)\n      {}\n      if (Sage.DBG) System.out.println("Download thread has terminated.");\n    }\n  }\n\n  public synchronized Object downloadCircularFile(String serverName, String srcFile, java.io.File destFile)\n  {\n    return downloadFile(serverName, srcFile, destFile, true);\n  }\n  // serverName can be null which means use the preferredServer, srcFile must be a file, it\'s downloaded to destFile which must also be a file\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile)\n  {\n    return downloadFile(serverName, srcFile, destFile, false);\n  }\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular)\n  {\n    return downloadFile(serverName, srcFile, destFile, circular, null);\n  }\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps)\n  {\n    return downloadFile(serverName, srcFile, destFile, circular, requestProps, false);\n  }\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous)\n  {\n    return downloadFile(serverName, srcFile, destFile, circular, requestProps, inSynchronous, false);\n  }\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous,\n      boolean captureMode)\n  {\n    return downloadFile(serverName, srcFile, destFile, circular, requestProps, inSynchronous, captureMode, null);\n  }\n  public synchronized Object downloadFile(String serverName, String srcFile, java.io.File destFile, boolean circular, java.util.Properties requestProps, boolean inSynchronous,\n      boolean captureMode, DecryptInfo cryptoSpec)\n  {\n    this.captureMode = captureMode;\n    this.cryptoInfo = cryptoSpec;\n    synchronous = inSynchronous;\n    UIManager uiMgr = (uiMgrWeak != null) ? ((UIManager) uiMgrWeak.get()) : null;\n    success = false;\n    if (circular && !Sage.client)\n      circSize = Sage.getLong("downloader/circular_file_size", 8*1024*1024);\n    else\n      circSize = 0;\n    indexAtEndOfMP4 = fullDownloadRequired = httpRangeSupported= false;\n    gotSMBAccess = gotMSAccess = false;\n    downloadedBytes = lastDownloadOffset = 0;\n    lastPlayerRequestTimestamp = lastSeekNotifyTimestamp = 0;\n    lastNotifyReadOffset = 0;\n    lastDownloadTimestamp = 0;\n    isMP4Stream = false;\n    statusMessage = "";\n    if (Sage.DBG) System.out.println("Download requested for files server=" + serverName + " src=" + srcFile + " dest=" + destFile);\n    if (Sage.client && serverName == null)\n      serverName = Sage.preferredServer;\n    remoteUIXfer = uiMgr != null && uiMgr.getUIClientType() == UIClient.REMOTE_UI && uiMgr.hasRemoteFSSupport();\n    if (serverName != null && (serverName.startsWith("http:") || serverName.startsWith("https:") || serverName.startsWith("ftp:") || serverName.startsWith("file:")))\n      remoteUIXfer = false;\n    if (serverName == null && !remoteUIXfer)\n      return Boolean.FALSE;\n\n    myServerName = serverName;\n    if (myServerName != null && myServerName.startsWith("smb://"))\n    {\n      srcFile = FSManager.getInstance().requestLocalSMBAccess(myServerName);\n      if (srcFile == null)\n        return Boolean.FALSE;\n      gotSMBAccess = true;\n    }\n    if (myServerName!=null && myServerName.startsWith("file:"))\n      mySrcFile = new java.io.File(URI.create(myServerName));\n    else\n      mySrcFile = (srcFile == null) ? null : new java.io.File(srcFile);\n\n    myDestFile = destFile;\n    if (!remoteUIXfer)\n    {\n      java.io.File destParent = myDestFile.getParentFile();\n      if (destParent != null)\n        IOUtils.safemkdirs(destParent);\n    }\n\n    if (myServerName != null && (myServerName.startsWith("http:") || myServerName.startsWith("https:") || myServerName.startsWith("ftp:")))\n    {\n          myURLConn = myURL.openConnection();\n          if (httpRangeSupported)\n            myURLConn.setRequestProperty("Range", "bytes=0-");\n          if (requestProps != null)\n          {\n            java.util.Iterator walker = requestProps.entrySet().iterator();\n        {\n          myURLConn = myURL.openConnection();\n          if (httpRangeSupported)\n            myURLConn.setRequestProperty("Range", "bytes=0-");\n          if (Sage.getBoolean("use_sagetv_user_agent", false))\n            myURLConn.setRequestProperty("User-Agent", "SageTV/" + sage.Version.MAJOR_VERSION + "." + sage.Version.MINOR_VERSION + "." + sage.Version.MICRO_VERSION);\n          if (!"".equals(Sage.get("custom_user_agent", "")))\n            myURLConn.setRequestProperty("User-Agent", Sage.get("custom_user_agent", ""));\n          if (requestProps != null)\n          {\n            java.util.Iterator walker = requestProps.entrySet().iterator();\n            while (walker.hasNext())\n            {\n              java.util.Map.Entry currEnt = (java.util.Map.Entry) walker.next();\n              if (currEnt.getKey() != null && currEnt.getValue() != null)\n                myURLConn.setRequestProperty(currEnt.getKey().toString(), currEnt.getValue().toString());\n            }\n          }\n          try\n          {\n            myURLConn.setConnectTimeout(30000);\n            myURLConn.setReadTimeout(30000);\n          }catch (Throwable tr){} // in case its a bad JRE version w/ out these calls\n          try\n          {\n            urlinStream = myURLConn.getInputStream();\n          }\n          catch (Exception e1)\n          {\n            // Check to see if HTTP Range requests are supported from this server\n            if (myURLConn instanceof java.net.HttpURLConnection && httpRangeSupported)\n            {\n              java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;\n              // 416 is the proper response for denying range requests; but some servers just send\n              // a generic 400 response (like last.fm)\n              if (httpConn.getResponseCode() / 100 == 4)\n              {\n                if (Sage.DBG) System.out.println("HTTP Range requests are NOT supported by this server. errcode=" + httpConn.getResponseCode());\n                httpRangeSupported = false;\n                continue;\n              }\n            }\n            throw e1;\n          }\n          remoteSize = myURLConn.getContentLength();\n          if (Sage.DBG) System.out.println("Download remoteSize=" + remoteSize);\n          if (myURLConn instanceof java.net.HttpURLConnection)\n          {\n            java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;\n            if (httpConn.getResponseCode() / 100 == 3)\n            {\n              if (Sage.DBG) System.out.println("Internally processing HTTP redirect...");\n              urlinStream.close();\n              myURL = new java.net.URL(httpConn.getHeaderField("Location"));\n              continue;\n            }\n          }\n          break;\n        }\n        fileOut = new BufferedSageFile(new LocalSageFile(myDestFile, false));\n        if (captureMode)\n          fileOut.seek(fileOut.length());\n        else\n          fileOut.setLength(0);\n      }\n      catch (Exception e)\n      {\n        System.out.println("ERROR with file download of:" + e);\n        try{\n          if (urlinStream != null)\n            urlinStream.close();\n        }catch (Exception e3){}\n        try{\n          if (fileOut != null)\n            fileOut.close();\n        }catch (Exception e4){}\n        cleanup();\n        return Boolean.FALSE;\n      }\n    }\n    else if (remoteUIXfer)\n    {\n      if (captureMode)\n      {\n        // capture mode not supported for remote UI xfers\n        return Boolean.FALSE;\n      }\n      if (!mySrcFile.isFile())\n      {\n        cleanup();\n        return Boolean.FALSE;\n      }\n\n      MiniClientSageRenderer mcsr = (MiniClientSageRenderer) uiMgr.getRootPanel().getRenderEngine();\n      remoteSize = mySrcFile.length();\n      MiniClientSageRenderer.RemoteFSXfer fsXferOp = mcsr.fsDownloadFile(mySrcFile, destFile.toString());\n      if (fsXferOp.error != 0)\n      {\n        cleanup();\n        return Boolean.FALSE;\n      }\n      fsXferOpWeak = new java.lang.ref.WeakReference(fsXferOp);\n    }\n    else if (myServerName!=null && mySrcFile!=null && mySrcFile.isFile() && myServerName.startsWith("file:"))\n    {\n      if (Sage.DBG) System.out.println("FileDownloader: Local File Copy: From: " + mySrcFile + " to " + destFile);\n      // file url passed as the url to download\n      if (!mySrcFile.exists())\n      {\n        cleanup();\n        return Boolean.FALSE;\n      }\n    }\n    else\n    {\n      try\n      {\n        sock = new java.net.Socket();\n        sock.connect(new java.net.InetSocketAddress(Sage.preferredServer, 7818), 5000);\n        sock.setSoTimeout(30000);\n        //sock.setTcpNoDelay(true);\n        outStream = new java.io.DataOutputStream(new java.io.BufferedOutputStream(sock.getOutputStream()));\n        inStream = new java.io.DataInputStream(new java.io.BufferedInputStream(sock.getInputStream()));\n        outStream.write("OPENW ".getBytes(Sage.BYTE_CHARSET));\n        outStream.write(mySrcFile.toString().getBytes("UTF-16BE"));\n        outStream.write("\\r\\n".getBytes(Sage.BYTE_CHARSET));\n        outStream.flush();\n        String str = Sage.readLineBytes(inStream);\n        if ("NON_MEDIA".equals(str))\n        {\n          // We may need to get special access to read this file; so try that first\n          if (NetworkClient.getSN().requestMediaServerAccess(mySrcFile, true))\n          {\n            gotMSAccess = true;\n            outStream.write("OPENW ".getBytes(Sage.BYTE_CHARSET));\n            outStream.write(mySrcFile.toString().getBytes("UTF-16BE"));\n            outStream.write("\\r\\n".getBytes(Sage.BYTE_CHARSET));\n            outStream.flush();\n            str = Sage.readLineBytes(inStream);\n          }\n        }\n        if (!"OK".equals(str))\n          throw new java.io.IOException("Error opening remote file of:" + str);\n        // get the size\n        outStream.write("SIZE\\r\\n".getBytes(Sage.BYTE_CHARSET));\n        outStream.flush();\n        str = Sage.readLineBytes(inStream);\n        remoteSize = Long.parseLong(str.substring(0, str.indexOf(\' \')));\n        fileOut = new BufferedSageFile(new LocalSageFile(myDestFile, false));\n        if (captureMode)\n          fileOut.seek(fileOut.length());\n        else\n          fileOut.setLength(0);\n      }\n      catch (Exception e)\n      {\n        System.out.println("ERROR with file download of:" + e);\n        try{\n          if (sock != null)\n            sock.close();\n        }catch (Exception e1){}\n        try{\n          if (outStream != null)\n            outStream.close();\n        }catch (Exception e2){}\n        try{\n          if (inStream != null)\n            inStream.close();\n        }catch (Exception e3){}\n        try{\n          if (fileOut != null)\n            fileOut.close();\n        }catch (Exception e4){}\n        cleanup();\n        return Boolean.FALSE;\n      }\n    }\n    // Don\'t allow others to look at our files if this was done in appendMode because it won\'t give proper information\n    if (!captureMode)\n    {\n      synchronized (fileMap)\n      {\n        fileMap.put(myDestFile, this);\n      }\n    }\n    startTaskThread("FileDownload", synchronous);\n\n    return (synchronous && !success) ? Boolean.FALSE : Boolean.TRUE;\n  }\n\n  public void taskRun()\n  {\n    try\n    {\n      if (myServerName != null && (myServerName.startsWith("http:") || myServerName.startsWith("https:") || myServerName.startsWith("ftp:")))\n      {\n        httpTaskRun();\n      }\n      else if (remoteUIXfer)\n      {\n        remoteUITaskRun();\n      }\n      else if (myServerName!=null && myServerName.startsWith("file:"))\n      {\n        fileTaskRun();\n      }\n      else\n      {\n        stvTaskRun();\n      }\n      if (Sage.DBG) System.out.println("Download completed for files server=" + myServerName + " src=" + mySrcFile + " dest=" + myDestFile + " abort=" + abort);\n    }\n    finally\n    {\n      cleanup();\n      if (uiMgrWeak == null)\n      {\n        resultMap.put(myDestFile, wasSuccessful() ? (Object)Boolean.TRUE : (Object)("Error: " + getStatusMessage()));\n      }\n      synchronized (fileMap)\n      {\n        fileMap.remove(myDestFile);\n      }\n    }\n  }\n\n  private void fileTaskRun() {\n    try {\n      downloadedBytes=myDestFile.length();\n      statusMessage="100%";\n\n      if (mySrcFile.equals(myDestFile)) {\n        succeeded();\n        return;\n      }\n\n      IOUtils.copyFile(mySrcFile, myDestFile);\n      succeeded();\n    }\n    catch (IOException e)\n    {\n      if (Sage.DBG) System.out.println("ERROR during file download/copy of:" + e);\n      Sage.printStackTrace(e);\n      statusMessage = "Error:" + e.toString();\n    }\n  }\n\n  private void cleanup()\n  {\n    if (gotMSAccess)\n    {\n      NetworkClient.getSN().requestMediaServerAccess(mySrcFile, false);\n      gotMSAccess = false;\n    }\n    if (gotSMBAccess)\n    {\n      FSManager.getInstance().releaseLocalSMBAccess(myServerName);\n      gotSMBAccess = false;\n    }\n\n  }\n\n  // NOTE: Not all servers support Range download requests!!!!\n  private void restartHttpDownload(long startOffset) throws java.io.IOException\n  {\n    if (startOffset > 0 && !httpRangeSupported)\n      throw new java.io.IOException("Cannot restart HTTP download because we already know this server doesn\'t support Range! offset=" + startOffset);\n    // Close current connection first\n    try{\n      if (urlinStream != null)\n        urlinStream.close();\n    }catch (Exception e3){}\n    myURLConn = myURL.openConnection();\n    if (httpRangeSupported)\n      myURLConn.setRequestProperty("Range", "bytes=" + startOffset + "-");\n    try\n    {\n      myURLConn.setConnectTimeout(30000);\n      myURLConn.setReadTimeout(30000);\n    }catch (Throwable tr){} // in case its a bad JRE version w/ out these calls\n    urlinStream = myURLConn.getInputStream();\n    if (myURLConn instanceof java.net.HttpURLConnection)\n    {\n      java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) myURLConn;\n      if (httpConn.getResponseCode() / 100 == 3)\n      {\n        if (Sage.DBG) System.out.println("Internally processing HTTP redirect to: " + httpConn.getHeaderField("Location") +\n            " responseCode=" + httpConn.getResponseCode());\n        urlinStream.close();\n        myURL = new java.net.URL(httpConn.getHeaderField("Location"));\n        if (abort) return;\n        restartHttpDownload(startOffset);\n        return;\n      }\n    }\n    lastDownloadOffset = downloadedBytes = startOffset;\n    //\t\tlastSeekNotifyTimestamp = 0; // reset this indicator\n    if (circSize > 0)\n    {\n      synchronized (notifyRead)\n      {\n        // Reposition this file\n        if (fileOut.length() < (startOffset % circSize))\n          fileOut.setLength(startOffset % circSize);\n        fileOut.seek(startOffset % circSize);\n        notifyRead.notify();\n      }\n    }\n    if (Sage.DBG) System.out.println("HTTP Download has been restarted at offset=" + startOffset);\n  }\n\n  // This\'ll reconnect to the server if it needs to\n  private int safeHttpRead(byte[] buffer) throws java.io.IOException\n  {\n    try\n    {\n      int rv = urlinStream.read(buffer);\n      // For encrypted files, due to partial decryption we can\'t trust the download size check here\n      if (rv <= 0 && remoteSize > 0 && downloadedBytes < remoteSize && httpRangeSupported && cryptoInfo == null)\n      {\n        if (Sage.DBG) System.out.println("Read " + rv + " bytes from the URL stream; but we\'re not done with the file yet...restart the download...");\n        restartHttpDownload(downloadedBytes);\n        return urlinStream.read(buffer);\n      }\n      return rv;\n    }\n    catch (java.io.IOException e)\n    {\n      if (httpRangeSupported && !abort)\n      {\n        if (Sage.DBG) System.out.println("HTTP read failed; trying to reconnect...");\n        restartHttpDownload(downloadedBytes);\n        return urlinStream.read(buffer);\n      }\n      else\n        throw e;\n    }\n  }\n\n  // NOTE: We should monitor the download and resume it if it hangs for some reason....that will definitely occur\n  // on large file transfers at some point or another\n  private void httpTaskRun()\n  {\n    downloadedBytes = 0;\n    if (xferBuf == null)\n      xferBuf = new byte[Sage.getInt("file_transfer_buffer_size", 65536)];\n    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();\n    if (abort)\n    {\n      // In case someone is viewing while we\'re downloading\n      Sage.processInactiveFile(myDestFile.toString());\n      return;\n    }\n    boolean isFlashStream = false;\n    boolean foundID3Tag = false;\n    int id3HeaderSize = 0;\n    boolean isMP3Stream = false;\n    boolean isSWFStream = false;\n    boolean isMpeg2PSStream = false;\n    boolean isMpeg2TSStream = false;\n    bytesUntilNextSizeTimestamp = 4;\n    numParsedSizeTimestampBytes = 0;\n    byte[] decryptBuf = null;\n    try\n    {\n      download_loop:\n        do\n        {\n          if (abort)\n          {\n            // In case someone is viewing while we\'re downloading\n            Sage.processInactiveFile(myDestFile.toString());\n            return;\n          }\n          if (circSize > 0)\n          {\n            if (lastNotifyReadOffset < Math.max(lastDownloadOffset, downloadedBytes - circSize))\n            {\n              if (Sage.DBG) System.out.println("DOWNLOADER restarting download to move the stream BACKWARDS notifyReadOffset=" + lastNotifyReadOffset +\n                  " lastSeekTimestamp=" + lastSeekNotifyTimestamp);\n              // Try to figure out where we should really seek too if we can\n              long reseekTarget = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, MP4_RESEEK_PREROLL_TIME);\n              /*\t\t\t\t\t\tif (isMP4Stream && lastSeekNotifyTimestamp > 0)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlong[] dataRange = getMinMaxByteRangeForTimestamp(lastSeekNotifyTimestamp);\n\t\t\t\t\t\t\tif (dataRange[0] < reseekTarget)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tif (Sage.DBG) System.out.println("Adjusting the backwards request to get all streams in it....was=" +\n\t\t\t\t\t\t\t\t\treseekTarget + " now=" + dataRange[0]);\n\t\t\t\t\t\t\t\treseekTarget = dataRange[0];\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}*/\n              if (reseekTarget < Math.max(lastDownloadOffset, downloadedBytes - circSize))\n              {\n                bytesUntilNextAtom += downloadedBytes - reseekTarget; // if we\'re doing the initial MP4 parsing this is needed\n                restartHttpDownload(reseekTarget);\n                lastAutoReseekTime = Sage.time();\n              }\n              else if (Sage.DBG) System.out.println("Skipping re-seek since it\'s target is within our download buffer " + reseekTarget);\n            }\n            // Check to see if this read request is for data that\'s after what\'s in the buffer\n            else if (isMP4Stream && lastNotifyReadOffset > downloadedBytes + 256*1024)\n              /*\t\t\t\t\telse if (isMP4Stream && (lastNotifyReadOffset > downloadedBytes + circSize ||\n\t\t\t\t\t\t(lastSeekNotifyTimestamp != 0 && lastNotifyReadOffset > downloadedBytes + 128*1024)))*/\n              //\t\t\t\t\t\t((Sage.time() - lastAutoReseekTime) < 5000 ? (circSize*8/10) : 512*1024))\n            {\n              // Only skip MP4 streams ahead if it\'s more than the circular buffer (which is definitely a skip)\n              // or if the timestamp seek hint has been set as well...otherwise this is probably just another read\n              if (Sage.DBG) System.out.println("DOWNLOADER restarting download to move the stream FORWARDS notifyOffset=" + lastNotifyReadOffset +\n                  " lastSeekTimestamp=" + lastSeekNotifyTimestamp);\n              long reseekTarget = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, MP4_RESEEK_PREROLL_TIME);\n              /*\t\t\t\t\t\tif (lastSeekNotifyTimestamp > 0)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlong[] dataRange = getMinMaxByteRangeForTimestamp(lastSeekNotifyTimestamp);\n\t\t\t\t\t\t\tif (dataRange[0] < reseekTarget)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tif (Sage.DBG) System.out.println("Adjusting the forwards request to get all streams in it....was=" +\n\t\t\t\t\t\t\t\t\treseekTarget + " now=" + dataRange[0]);\n\t\t\t\t\t\t\t\treseekTarget = dataRange[0];\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}*/\n              if (reseekTarget > downloadedBytes)\n              {\n                bytesUntilNextAtom -= reseekTarget - downloadedBytes; // if we\'re doing the initial MP4 parsing this is needed\n                restartHttpDownload(reseekTarget);\n                lastAutoReseekTime = Sage.time();\n              }\n              else if (Sage.DBG) System.out.println("Skipping re-seek since it\'s target is within our download buffer " + reseekTarget);\n            }\n          }\n          if (decryptBuf != null)\n          {\n            byte[] tempBuf = xferBuf;\n            // Swap the buffers back\n            xferBuf = decryptBuf;\n            decryptBuf = tempBuf;\n          }\n          int numRead = safeHttpRead(xferBuf);\n          if (numRead <= 0)\n          {\n            if (circSize > 0 && isMP4Stream && httpRangeSupported && indexAtEndOfMP4 && !abort)\n            {\n              if (Sage.DBG) System.out.println("Waiting at the end of the MP4 stream since we\'re probably just reading the header...");\n              fileOut.flush();\n            }\n            while (circSize > 0 && isMP4Stream && httpRangeSupported && indexAtEndOfMP4 && !abort)\n            {\n              if (lastNotifyReadOffset < Math.max(lastDownloadOffset, downloadedBytes - circSize))\n              {\n                continue download_loop;\n              }\n              synchronized (notifyRead)\n              {\n                try\n                {\n                  notifyRead.wait(30);\n                }\n                catch (InterruptedException e2){}\n              }\n            }\n            if (Sage.DBG) System.out.println("Read " + numRead + " bytes from the URL stream; download has completed");\n            if (cryptoInfo != null)\n            {\n              numRead = decryptBuffer(xferBuf, 0, decryptBuf, true);\n              if (numRead > 0)\n              {\n                if (circSize > 0)\n                {\n                  waitForCircWrite(numRead);\n                  synchronized (notifyRead)\n                  {\n                    if (fileOut.position() + numRead < circSize)\n                      fileOut.write(decryptBuf, 0, numRead);\n                    else\n                    {\n                      int firstWrite = (int)(circSize - fileOut.position());\n                      fileOut.write(decryptBuf, 0, firstWrite);\n                      fileOut.seek(0);\n                      fileOut.write(decryptBuf, firstWrite, numRead - firstWrite);\n                    }\n                  }\n                }\n                else\n                  fileOut.write(decryptBuf, 0, numRead);\n              }\n            }\n            break;\n          }\n          if (cryptoInfo != null)\n          {\n            if (decryptBuf == null)\n              decryptBuf = new byte[xferBuf.length*3/2]; // make it 50% bigger to handle the final buffer which may be larger\n            numRead = decryptBuffer(xferBuf, numRead, decryptBuf, false);\n            byte[] tempBuf = xferBuf;\n            // Swap the buffers now, then do it again before we download more data\n            xferBuf = decryptBuf;\n            decryptBuf = tempBuf;\n          }\n          if (circSize > 0)\n          {\n            waitForCircWrite(numRead);\n            synchronized (notifyRead)\n            {\n              if (fileOut.position() + numRead < circSize)\n                fileOut.write(xferBuf, 0, numRead);\n              else\n              {\n                int firstWrite = (int)(circSize - fileOut.position());\n                fileOut.write(xferBuf, 0, firstWrite);\n                fileOut.seek(0);\n                fileOut.write(xferBuf, firstWrite, numRead - firstWrite);\n              }\n            }\n          }\n          else\n            fileOut.write(xferBuf, 0, numRead);\n          if (downloadedBytes == 0 && numRead >= 10 && !isFlashStream && !isMP3Stream && !isMP4Stream && !isSWFStream && !isMpeg2PSStream && !isMpeg2TSStream)\n          {\n            // Check if it\'s a flash stream\n            if (xferBuf[0] == \'F\' && xferBuf[1] == \'L\' && xferBuf[2] == \'V\' && (((xferBuf[4] & 0x1) == 1) || ((xferBuf[4] & 0x4) == 4)))\n            {\n              if (Sage.DBG) System.out.println("Detected FLV stream downloading...");\n              isFlashStream = true;\n              processFlashPacket(xferBuf, 9, numRead - 9);\n            }\n            else if (xferBuf[0] == \'I\' && xferBuf[1] == \'D\' && xferBuf[2] == \'3\')\n            {\n              if (Sage.DBG) System.out.println("Detected ID3 tag in downloading...");\n              foundID3Tag = true;\n              id3HeaderSize = ((xferBuf[6]&0x7F)<<21) | ((xferBuf[7]&0x7F)<<14) |\n                  ((xferBuf[8]&0x7F)<<7) | (xferBuf[9]&0x7F);\n              id3HeaderSize += 10; // for the 10 initial bytes\n              isMP3Stream = true; // assume ID3 is MP3 for now\n            }\n            else if (xferBuf[4] == \'f\' && xferBuf[5] == \'t\' && xferBuf[6] == \'y\' && xferBuf[7] == \'p\')\n            {\n              // NOTE: This check could be much more solid....but this should do I\'m pretty sure. :)\n              if (Sage.DBG) System.out.println("Detected MP4 ISO stream downloading...");\n              isMP4Stream = true;\n              initMP4Parsing();\n              processMP4Packet(xferBuf, 0, numRead);\n            }\n            else if (xferBuf[4] == \'m\' && xferBuf[5] == \'d\' && xferBuf[6] == \'a\' && xferBuf[7] == \'t\')\n            {\n              // Handle quicktime files like MP4s for now....\n              if (Sage.DBG) System.out.println("Detected Quicktime stream downloading...");\n              isMP4Stream = true;\n              initMP4Parsing();\n              processMP4Packet(xferBuf, 0, numRead);\n            }\n            else if ((xferBuf[0] == \'C\' || xferBuf[0] == \'F\') && xferBuf[1] == \'W\' && xferBuf[2] == \'S\')\n            {\n              if (Sage.DBG) System.out.println("Detected SWF stream downloading...");\n              isSWFStream = true;\n            }\n            else if (xferBuf[0] == 0 && xferBuf[1] == 0 && xferBuf[2] == 1 && (xferBuf[3] & 0xFF) == 0xBA)\n            {\n              if (Sage.DBG) System.out.println("Detected MPEG2-PS stream downloading...");\n              isMpeg2PSStream = true;\n            }\n            else if (xferBuf[0] == 0x47 && (numRead < 189 || xferBuf[188] == 0x47) && (numRead < 377 || xferBuf[376] == 0x47))\n            {\n              if (Sage.DBG) System.out.println("Detected MPEG2-TS stream downloading...");\n              isMpeg2TSStream = true;\n            }\n            else if (processMP3Packet(xferBuf, 0, numRead))\n            {\n              if (Sage.DBG) System.out.println("Detected MPEG audio stream downloading...");\n              isMP3Stream = true;\n            }\n          }\n          else if (isFlashStream)\n            processFlashPacket(xferBuf, 0, numRead);\n          else if (isMP4Stream)\n            processMP4Packet(xferBuf, 0, numRead);\n          else if (isMP3Stream && mp3Bitrate == 0)\n          {\n            processMP3Packet(xferBuf, 0, numRead);\n          }\n          else if (isMpeg2PSStream && !captureMode)\n            processMpeg2PSPacket(xferBuf, 0, numRead);\n          else if (isMpeg2TSStream && !captureMode)\n            processMpeg2TSPacket(xferBuf, 0, numRead);\n          if (downloadedBytes == 0)\n          {\n            // So we don\'t have a zero length file on disk which\'ll fail verification\n            fileOut.flush();\n          }\n          downloadedBytes += numRead;\n          if (CIRC_DOWNLOAD_DEBUG) System.out.println("DownloadedBytes=" + downloadedBytes);\n          if (isMP3Stream && downloadedBytes > id3HeaderSize && mp3Bitrate > 0)\n          {\n            // Estimate the MP3 time that we\'ve downloaded (milliseconds)\n            lastDownloadTimestamp = (downloadedBytes - id3HeaderSize)*8000 / mp3Bitrate;\n          }\n          if (remoteSize > 0)\n            statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);\n          else\n            statusMessage = (downloadedBytes/1024) + " KB";\n        }while (true);\n    }\n    catch (Exception e)\n    {\n      if (Sage.DBG) System.out.println("ERROR during file download of:" + e);\n      Sage.printStackTrace(e);\n      statusMessage = "Error:" + e.toString();\n      return;\n    }\n    finally\n    {\n      try{\n        if (urlinStream != null)\n          urlinStream.close();\n      }catch (Exception e3){}\n      try{\n        if (fileOut != null)\n          fileOut.close();\n      }catch (Exception e4){}\n      cleanupMP4Parsing();\n    }\n\n    succeeded();\n    // In case someone is viewing while we\'re downloading\n    if (!captureMode)\n      Sage.processInactiveFile(myDestFile.toString());\n  }\n\n\n  // We have the type+size+timestamp(1+3+3)\n  private int bytesUntilNextSizeTimestamp = 4; // 4 byte offset into the first packet\n  private int numParsedSizeTimestampBytes = 0;\n  private byte[] sizeTimestampData = new byte[7];\n  private void processFlashPacket(byte[] buf, int offset, int length)\n  {\n    while (length > 0)\n    {\n      if (bytesUntilNextSizeTimestamp > 0)\n      {\n        if (bytesUntilNextSizeTimestamp < length)\n        {\n          offset += bytesUntilNextSizeTimestamp;\n          length -= bytesUntilNextSizeTimestamp;\n          bytesUntilNextSizeTimestamp = 0;\n          continue;\n        }\n        else\n        {\n          bytesUntilNextSizeTimestamp -= length;\n          return;\n        }\n      }\n      else\n      {\n        int neededBytes = 7 - numParsedSizeTimestampBytes;\n        int availBytes = Math.min(neededBytes, length);\n        System.arraycopy(buf, offset, sizeTimestampData, numParsedSizeTimestampBytes, availBytes);\n        numParsedSizeTimestampBytes += availBytes;\n        offset += availBytes;\n        length -= availBytes;\n\n        if (numParsedSizeTimestampBytes == 7)\n        {\n          // We have a complete type+size + timestamp\n          int streamType = sizeTimestampData[0];\n          int tagBodyLength = ((sizeTimestampData[1] & 0xFF) << 16) + ((sizeTimestampData[2] & 0xFF) << 8) + (sizeTimestampData[3] & 0xFF);\n          int tagTimestamp = ((sizeTimestampData[4] & 0xFF) << 16) + ((sizeTimestampData[5] & 0xFF) << 8) + (sizeTimestampData[6] & 0xFF);\n          if (streamType == 9) // video\n          {\n            lastDownloadTimestamp = tagTimestamp;\n          }\n          numParsedSizeTimestampBytes = 0;\n          bytesUntilNextSizeTimestamp = tagBodyLength + 8;\n        }\n      }\n    }\n  }\n\n  private void processMpeg2PSPacket(byte[] buf, int offset, int length)\n  {\n    // We just search for the PTS\'s in the 0xe0 video stream and use those for the latest timestamp\n    for (int i = offset; i < offset + length - 27; i++)\n    {\n      if (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && (buf[i + 3] & 0xFF) == 0xBA)\n      {\n        // Pack header is here\n        // Skip over the data in the pack header and land on the pack_stuffing_length byte (it uses the lower 3 bits)\n        i += 13;\n        // Check for any stuffing bytes in the pack header and skip them\n        i += (1 + (buf[i] & 0x7));\n        if (i + 13 >= offset + length) break;\n        // Check for a start code next, it can be a system header or a pes packet\n        while (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && (buf[i + 3] & 0xFF) != 0xBA)\n        {\n          i += 3;\n          if ((buf[i] & 0xFF) == 0xBB)\n          {\n            // system header, skip it\n            i += 3 + (((buf[i + 1] & 0xFF) << 8) | (buf[i + 2] & 0xFF));\n            // Check for the start code next\n            if (i + 15 >= offset + length) break;\n            if (buf[i] != 0 || buf[i + 1] != 0 || buf[i + 2] != 1)\n              continue;\n            else\n              i += 3;\n          }\n          if (i + 15 >= offset + length) break;\n          int stream_id = buf[i] & 0xFF;\n          int packet_length = ((buf[i + 1] & 0xFF) << 8) | (buf[i + 2] & 0xFF);\n          //System.out.println("PES stream id=0x" + Integer.toString(stream_id, 16) + " length=" + packet_length);\n          if (stream_id == 0xBD || (stream_id >= 0xC0 && stream_id <= 0xEF))\n          {\n            //System.out.println("Audio/video substream found");\n            // Valid audio or video stream, check for the pts flag\n            if ((buf[i + 4] & 0x80) == 0x80)\n            {\n              // PTS found, extract it\n              long pts = (((long)(buf[i + 6] & 0x0E)) << 29) |\n                  ((buf[i + 7] & 0xFF) << 22) |\n                  ((buf[i + 8] & 0xFE) << 14) |\n                  ((buf[i + 9] & 0xFF) << 7) |\n                  ((buf[i + 10] & 0xFE) >> 1);\n              lastDownloadTimestamp = pts / 90; // convert to milliseconds\n            }\n          }\n        }\n      }\n    }\n  }\n\n  private void processMpeg2TSPacket(byte[] buf, int offset, int length)\n  {\n    for (int i = offset; i < offset + length - 188; i++)\n    {\n      // Align on a TS sync code\n      if (buf[i] == 0x47)\n      {\n        // Find a payload start indicator, that\'s the only way there\'s PTS at the head\n        if ((buf[i + 1] & 0x40) != 0)\n        {\n          int adaptation_field_control = (buf[i + 3] & 0x30) >> 4;\n    if (adaptation_field_control == 2)\n      i += (188 - 4);\n    else if (adaptation_field_control == 3)\n      i += Math.min(188 - 6, 1 + (buf[i + 4] & 0xFF));\n    i += 4;\n    if (adaptation_field_control == 1 || adaptation_field_control == 3)\n    {\n      // Check for a start code next, it can be a system header or a pes packet\n      if (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1)\n      {\n        i += 3;\n        if (i + 15 >= offset + length) break;\n        int stream_id = buf[i] & 0xFF;\n        //System.out.println("PES stream id=0x" + Integer.toString(stream_id, 16));\n        if ((stream_id == 0xBD || (stream_id >= 0xC0 && stream_id <= 0xEF) || (stream_id == 0xFD)))\n        {\n          //System.out.println("Audio/video substream found");\n          // Valid audio or video stream, check for the pts flag\n          if ((buf[i + 4] & 0x80) == 0x80)\n          {\n            // PTS found, extract it\n            long pts = (((long)(buf[i + 6] & 0x0E)) << 29) |\n                ((buf[i + 7] & 0xFF) << 22) |\n                ((buf[i + 8] & 0xFE) << 14) |\n                ((buf[i + 9] & 0xFF) << 7) |\n                ((buf[i + 10] & 0xFE) >> 1);\n            lastDownloadTimestamp = pts / 90; // convert to milliseconds\n          }\n        }\n      }\n    }\n        }\n      }\n    }\n  }\n\n  private long bytesUntilNextAtom = 0;\n  private byte[] remAtomData = new byte[28];\n  private int remAtomSize = 0;\n  private int[] sttsDataA; // even are the counts, odd are the durations\n  private int[] sttsDataV; // even are the counts, odd are the durations\n  private int sttsIndex;\n  private int[] stscDataA; // even are the first chunk #, odd are the samples per chunk\n  private int[] stscDataV; // even are the first chunk #, odd are the samples per chunk\n  private int stscIndex;\n  private long[] chunkOffsetsA; // one for each chunk\n  private long[] chunkOffsetsV; // one for each chunk\n  private int stcoIndex;\n  private boolean areOffsets64Bits;\n  private int chunkQuality;\n  private int timeScale;\n  private long[] mp4TimeIndexA = null; // chunkOffsets has the positions, this is the same length and has the times\n  private long[] mp4TimeIndexV = null; // chunkOffsets has the positions, this is the same length and has the times\n  private boolean movieDataBegan;\n  private long lastSeekNotifyTimestamp;\n  private long lastPlayerRequestTimestamp;\n  private static final boolean MP4_PARSER_DEBUG = false;\n  private void initMP4Parsing()\n  {\n    bytesUntilNextAtom = 0;\n    remAtomSize = 0;\n    sttsIndex = stscIndex = stcoIndex = -1;\n    chunkQuality = 0;\n    movieDataBegan = false;\n  }\n  private void cleanupMP4Parsing()\n  {\n    chunkOffsetsA = chunkOffsetsV = null;\n    sttsDataA = sttsDataV = stscDataA = stscDataV = null;\n    mp4TimeIndexA = mp4TimeIndexV = null;\n  }\n  private long findTimestampForBytePos(long pos, long[] bytePos, long[] timestamps)\n  {\n    int idx = java.util.Arrays.binarySearch(bytePos, pos);\n    if (idx < 0)\n      idx = (-(idx + 1)) - 1; // find the insertion point then backup one\n    if (idx < 0)\n      idx = 0; // don\'t go before zero\n    return timestamps[idx];\n  }\n  private long findBytePosForTimestamp(long timestamp, long[] bytePos, long[] timestamps)\n  {\n    int idx = java.util.Arrays.binarySearch(timestamps, timestamp);\n    if (idx < 0)\n      idx = (-(idx + 1)) - 1; // find the insertion point then backup one\n    if (idx < 0)\n      idx = 0; // don\'t go before zero\n    return bytePos[idx];\n  }\n  private long[] getMinMaxByteRangeForTimestamp(long timestamp)\n  {\n    if (!isMP4Stream)\n      return null;\n    long min = Long.MAX_VALUE;\n    long max = 0;\n    if (chunkOffsetsA != null && mp4TimeIndexA != null)\n    {\n      long apos = findBytePosForTimestamp(timestamp, chunkOffsetsA, mp4TimeIndexA);\n      min = Math.min(apos, min);\n      max = Math.max(apos, max);\n    }\n    if (chunkOffsetsV != null && mp4TimeIndexV != null)\n    {\n      long vpos = findBytePosForTimestamp(timestamp, chunkOffsetsV, mp4TimeIndexV);\n      min = Math.min(vpos, min);\n      max = Math.max(vpos, max);\n    }\n    return new long[] { min, max };\n  }\n  private long getSafeMP4SeekPositionForReadPosition(long readPos, long timeOffset)\n  {\n    if (isMP4Stream && chunkOffsetsV != null && mp4TimeIndexV != null && mp4TimeIndexA != null &&\n        chunkOffsetsA != null)\n    {\n      // Check to see if we think we know exactly what time they\'re looking for:\n      int testidx = java.util.Arrays.binarySearch(chunkOffsetsA, readPos);\n      long atime, vtime;\n      if (testidx >= 0)\n      {\n        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING-A seek time to be:" + mp4TimeIndexA[testidx]);\n        atime = vtime = mp4TimeIndexA[testidx];\n      }\n      else if ((testidx = java.util.Arrays.binarySearch(chunkOffsetsV, readPos)) >= 0)\n      {\n        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING-V seek time to be:" + mp4TimeIndexV[testidx]);\n        atime = vtime = mp4TimeIndexV[testidx];\n      }\n      else\n      {\n        // Figure out what are the two potential timestamps for this read position.\n        // Then take the minimum of those two values.\n        // Then look up the positions for each stream for that timestamp.\n        // Then use the minimum of those two positions for where we think the client is reading from currently.\n        atime = findTimestampForBytePos(readPos, chunkOffsetsA, mp4TimeIndexA);\n        vtime = findTimestampForBytePos(readPos, chunkOffsetsV, mp4TimeIndexV);\n        long maxtime, mintime;\n        if (atime > vtime)\n        {\n          maxtime = atime;\n          mintime = vtime;\n        }\n        else\n        {\n          maxtime = vtime;\n          mintime = atime;\n        }\n        if (CIRC_DOWNLOAD_DEBUG) System.out.println("ESTIMATING A-V seek times to be A=" + atime + " V=" + vtime);\n      }\n      long testTime = Math.min(atime, vtime);\n      testTime = Math.max(0, testTime - timeOffset); // for keyframe alignment\n      atime = findBytePosForTimestamp(testTime, chunkOffsetsA, mp4TimeIndexA);\n      vtime = findBytePosForTimestamp(testTime, chunkOffsetsV, mp4TimeIndexV);\n      return Math.min(readPos, Math.min(atime, vtime));\n    }\n    else\n      return readPos;\n  }\n\n  private void processMP4Packet(byte[] buf, int offset, int length)\n  {\n    if (movieDataBegan)\n    {\n      long newMinTimestamp = Long.MAX_VALUE;\n      if (!fullDownloadRequired)\n      {\n        if (chunkOffsetsA != null && mp4TimeIndexA != null)\n        {\n          newMinTimestamp = Math.min(findTimestampForBytePos(downloadedBytes, chunkOffsetsA, mp4TimeIndexA), newMinTimestamp);\n        }\n        if (chunkOffsetsV != null && mp4TimeIndexV != null)\n        {\n          newMinTimestamp = Math.min(findTimestampForBytePos(downloadedBytes, chunkOffsetsV, mp4TimeIndexV), newMinTimestamp);\n        }\n        if (newMinTimestamp != Long.MAX_VALUE)\n          lastDownloadTimestamp = newMinTimestamp;\n      }\n      if (!MP4_PARSER_DEBUG && (!indexAtEndOfMP4 || (mp4TimeIndexA != null && mp4TimeIndexV != null)))\n        return;\n    }\n    if (bytesUntilNextAtom > 0)\n    {\n      if (remAtomSize > 0)\n      {\n        int used = Math.min((int)bytesUntilNextAtom, remAtomSize);\n        if (used != remAtomSize)\n        {\n          System.arraycopy(remAtomData, used, remAtomData, 0, remAtomSize - used);\n        }\n        remAtomSize -= used;\n        bytesUntilNextAtom -= used;\n      }\n      if (bytesUntilNextAtom > 0)\n      {\n        long used = Math.min(bytesUntilNextAtom, length);\n        length -= used;\n        offset += used;\n        bytesUntilNextAtom -= used;\n        if (length == 0)\n          return;\n      }\n    }\n\n    while ((length + remAtomSize >= remAtomData.length && bytesUntilNextAtom >= 0) || (downloadedBytes + offset + length == remoteSize && length > 0))\n    {\n      System.arraycopy(buf, offset, remAtomData, remAtomSize, Math.min(remAtomData.length - remAtomSize, length));\n      long currSize;\n      if (stcoIndex >= 0)\n      {\n        long currVal;\n        bytesUntilNextAtom = 0;\n        long[] chunkOffsets = (chunkQuality == 2) ? chunkOffsetsV : chunkOffsetsA;\n        if (areOffsets64Bits)\n        {\n          for (int x = 0; x + 7 < remAtomData.length; x+=8)\n          {\n            currVal = ((remAtomData[x] & 0xFF) << 56) | ((remAtomData[x+1] & 0xFF) << 48) | ((remAtomData[x+2] & 0xFF) << 40) | ((remAtomData[x+3] & 0xFF) << 32) |\n                ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);\n            bytesUntilNextAtom += 8;\n            chunkOffsets[stcoIndex++] = currVal;\n            if (stcoIndex == chunkOffsets.length)\n            {\n              stcoIndex = -1;\n              if (MP4_PARSER_DEBUG) System.out.println("Done reading chunk offset atom");\n              break;\n            }\n          }\n        }\n        else\n        {\n          for (int x = 0; x + 3 < remAtomData.length; x+=4)\n          {\n            currVal = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);\n            bytesUntilNextAtom += 4;\n            chunkOffsets[stcoIndex++] = currVal;\n            if (stcoIndex == chunkOffsets.length)\n            {\n              stcoIndex = -1;\n              if (MP4_PARSER_DEBUG) System.out.println("Done reading chunk offset atom");\n              break;\n            }\n          }\n        }\n        if (stcoIndex == -1)\n        {\n          // We\'re done parsing the sample information so now we can build the time-byte index\n          if (Sage.DBG) System.out.println("Building MP4 byte-time index map to track downloading");\n          long[] mp4TimeIndex = new long[chunkOffsets.length];\n          if (chunkQuality == 2)\n            mp4TimeIndexV = mp4TimeIndex;\n          else\n            mp4TimeIndexA = mp4TimeIndex;\n          int chunkNum = 0;\n          long sampleNum = 0;\n          sttsIndex = 0;\n          stscIndex = 0;\n          long time = 0;\n          long sampleDuration;\n          int samplesLeftInChunk = 0;\n          int[] stscData = (chunkQuality == 2) ? stscDataV : stscDataA;\n          int[] sttsData = (chunkQuality == 2) ? sttsDataV : sttsDataA;\n          while (true)\n          {\n            if (samplesLeftInChunk == 0)\n            {\n              mp4TimeIndex[chunkNum] = time;\n              chunkNum++;\n              if (chunkNum == chunkOffsets.length)\n                break;\n              if (stscIndex + 2 < stscData.length && chunkNum >= stscData[stscIndex + 2])\n                stscIndex += 2;\n              samplesLeftInChunk = stscData[stscIndex + 1];\n            }\n            if (sttsData[sttsIndex] == 0)\n              sttsIndex+=2;\n            sampleDuration = sttsData[sttsIndex + 1] * 1000 / timeScale;\n\n            int samplesThisTime = Math.min(samplesLeftInChunk, sttsData[sttsIndex]);\n            sttsData[sttsIndex] -= samplesThisTime;\n            time += sampleDuration * samplesThisTime;\n            samplesLeftInChunk -= samplesThisTime;\n          }\n          if (MP4_PARSER_DEBUG)\n          {\n            System.out.println("Done building MP4 time index map byte-time");\n            for (int i = 0; i < chunkOffsets.length; i++)\n            {\n              System.out.println("Offset=" + chunkOffsets[i] + " \\ttime=" + mp4TimeIndex[i]);;\n            }\n          }\n          sttsIndex = -1;\n          stscIndex = -1;\n        }\n      }\n      else if (stscIndex >= 0)\n      {\n        bytesUntilNextAtom = 0;\n        int[] stscData = (chunkQuality == 2) ? stscDataV : stscDataA;\n        for (int x = 0; x + 7 < remAtomData.length; x+= 12)\n        {\n          int firstChunk = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);\n          int sampPerChunk = ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);\n          bytesUntilNextAtom += 12;\n          stscData[stscIndex++] = firstChunk;\n          stscData[stscIndex++] = sampPerChunk;\n          if (stscIndex == stscData.length)\n          {\n            stscIndex = -1;\n            if (MP4_PARSER_DEBUG)\n            {\n              System.out.println("Done reading sample-to-chunk atom");\n              for (int j = 0; j*2 < stscData.length; j++)\n              {\n                System.out.println("stsc chunk#" + stscData[2*j] + " \\tsamplesPerChunk=" + stscData[2*j +1]);;\n              }\n            }\n            break;\n          }\n        }\n      }\n      else if (sttsIndex >= 0)\n      {\n        bytesUntilNextAtom = 0;\n        int[] sttsData = (chunkQuality == 2) ? sttsDataV : sttsDataA;\n        for (int x = 0; x + 7 < remAtomData.length; x+= 8)\n        {\n          int sampleCount = ((remAtomData[x] & 0xFF) << 24) | ((remAtomData[x+1] & 0xFF) << 16) | ((remAtomData[x+2] & 0xFF) << 8) | (remAtomData[x+3] & 0xFF);\n          int sampleDuration = ((remAtomData[x+4] & 0xFF) << 24) | ((remAtomData[x+5] & 0xFF) << 16) | ((remAtomData[x+6] & 0xFF) << 8) | (remAtomData[x+7] & 0xFF);\n          bytesUntilNextAtom += 8;\n          sttsData[sttsIndex++] = sampleCount;\n          sttsData[sttsIndex++] = sampleDuration;\n          if (sttsIndex == sttsData.length)\n          {\n            sttsIndex = -1;\n            if (MP4_PARSER_DEBUG)\n            {\n              System.out.println("Done reading time-to-sample atom");\n              for (int j = 0; j*2 < sttsData.length; j++)\n              {\n                System.out.println("stts sampleCount=" + sttsData[2*j] + " \\tsampleDuration=" + sttsData[2*j +1]);;\n              }\n            }\n            break;\n          }\n        }\n      }\n      else\n      {\n        currSize = ((remAtomData[0] & 0xFF) << 24) | ((remAtomData[1] & 0xFF) << 16) | ((remAtomData[2] & 0xFF) << 8) | (remAtomData[3] & 0xFF);\n        if (currSize == 0)\n          bytesUntilNextAtom = Long.MAX_VALUE; // goes to end of the file\n        else if (currSize == 1)\n        {\n          // extended 64-bit size\n          currSize = (((long)(remAtomData[8] & 0xFF)) << 56) | (((long)(remAtomData[9] & 0xFF)) << 48) | (((long)(remAtomData[10] & 0xFF)) << 40) |\n              (((long)(remAtomData[11] & 0xFF)) << 32) | (((long)(remAtomData[12] & 0xFF)) << 24) | (((long)(remAtomData[13] & 0xFF)) << 16) |\n              (((long)(remAtomData[14] & 0xFF)) << 8) | ((long)(remAtomData[15] & 0xFF));\n          bytesUntilNextAtom = currSize;\n        }\n        else\n          bytesUntilNextAtom = currSize;\n        String type = ((char) remAtomData[4]) + "" + ((char) remAtomData[5]) + "" + ((char) remAtomData[6]) + "" + ((char) remAtomData[7]);\n        if (MP4_PARSER_DEBUG) System.out.println("MP4 size="  + currSize + " type=" + type);\n        if ("mdhd".equals(type))\n        {\n          timeScale = ((remAtomData[20] & 0xFF) << 24) | ((remAtomData[21] & 0xFF) << 16) | ((remAtomData[22] & 0xFF) << 8) | (remAtomData[23] & 0xFF);\n          int duration = ((remAtomData[24] & 0xFF) << 24) | ((remAtomData[25] & 0xFF) << 16) | ((remAtomData[26] & 0xFF) << 8) | (remAtomData[27] & 0xFF);\n          if (MP4_PARSER_DEBUG) System.out.println("timeScale=" + timeScale + " duration=" + duration + " REAL DURATION(seconds)=" + (duration/timeScale));\n        }\n        else if ("hdlr".equals(type))\n        {\n          // Check if it\'s audio or video\n          String compType = ((char) remAtomData[12]) + "" + ((char) remAtomData[13]) + "" + ((char) remAtomData[14]) + "" + ((char) remAtomData[15]);\n          String compSubtype = ((char) remAtomData[16]) + "" + ((char) remAtomData[17]) + "" + ((char) remAtomData[18]) + "" + ((char) remAtomData[19]);\n          if (MP4_PARSER_DEBUG) System.out.println("MP4 Media compType=" + compType + " subtype=" + compSubtype);\n          if (chunkQuality == -1)\n          {\n            if ("vide".equals(compSubtype))\n              chunkQuality = 2;\n            else if ("soun".equals(compSubtype))\n              chunkQuality = 1;\n          }\n        }\n        else if ("stsc".equals(type) && chunkQuality > 0)\n        {\n          int[] stscData = new int[2 * (((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |\n              ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF))];\n          if (chunkQuality == 2)\n            stscDataV = stscData;\n          else\n            stscDataA = stscData;\n          stscIndex = 0;\n          bytesUntilNextAtom = 16;\n          if (MP4_PARSER_DEBUG) System.out.println("Found sample-to-chunk atom");\n        }\n        else if ("stts".equals(type) && chunkQuality > 0)\n        {\n          int[] sttsData = new int[2 * (((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |\n              ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF))];\n          if (chunkQuality == 2)\n            sttsDataV = sttsData;\n          else\n            sttsDataA = sttsData;\n          sttsIndex = 0;\n          bytesUntilNextAtom = 16;\n          if (MP4_PARSER_DEBUG) System.out.println("Found time-to-sample atom");\n        }\n        else if ("stco".equals(type) && chunkQuality > 0)\n        {\n          // Parse this table so then we know what chunk # we are on for a given position in the stream (also handle \'co64\')\n          long[] chunkOffsets = new long[((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |\n                                         ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF)];\n          if (chunkQuality == 2)\n            chunkOffsetsV = chunkOffsets;\n          else\n            chunkOffsetsA = chunkOffsets;\n          stcoIndex = 0;\n          bytesUntilNextAtom = 16;\n          areOffsets64Bits = false;\n          if (MP4_PARSER_DEBUG) System.out.println("Found chunk offset atom");\n        }\n        else if ("co64".equals(type) && chunkQuality > 0)\n        {\n          // Parse this table so then we know what chunk # we are on for a given position in the stream (also handle \'co64\')\n          long[] chunkOffsets = new long[((remAtomData[12] & 0xFF) << 24) | ((remAtomData[13] & 0xFF) << 16) |\n                                         ((remAtomData[14] & 0xFF) << 8) | (remAtomData[15] & 0xFF)];\n          if (chunkQuality == 2)\n            chunkOffsetsV = chunkOffsets;\n          else\n            chunkOffsetsA = chunkOffsets;\n          stcoIndex = 0;\n          bytesUntilNextAtom = 16;\n          areOffsets64Bits = true;\n          if (MP4_PARSER_DEBUG) System.out.println("Found chunk offset (64-bit) atom");\n        }\n        else if ("mdat".equals(type))\n        {\n          if (chunkOffsetsV == null || mp4TimeIndexV == null)\n          {\n            if (Sage.DBG) System.out.println("MP4 file being downloaded is NOT STREAMBLE; the mdat section came before the sample tables");\n            if (circSize > 0)\n            {\n              lastDownloadTimestamp = 5000; // to make it think we\'ve got somewhere in the download\n              indexAtEndOfMP4 = true;\n              fullDownloadRequired = false;\n            }\n            else\n              indexAtEndOfMP4 = fullDownloadRequired = true;\n          }\n          movieDataBegan = true;\n        }\n        if ("trak".equals(type))\n          chunkQuality = -1; // signifies time to look for a new media type\n        if ("moov".equals(type) || "trak".equals(type) || "mdia".equals(type) || "minf".equals(type) || "stbl".equals(type)) // it contains nested atoms\n          bytesUntilNextAtom = 8;\n      }\n      if (bytesUntilNextAtom > 0)\n      {\n        if (remAtomSize > 0)\n        {\n          int used = Math.min((int)bytesUntilNextAtom, remAtomSize);\n          if (used != remAtomSize)\n          {\n            System.arraycopy(remAtomData, used, remAtomData, 0, remAtomSize - used);\n          }\n          remAtomSize -= used;\n          bytesUntilNextAtom -= used;\n        }\n        if (bytesUntilNextAtom > 0)\n        {\n          long used = Math.min(bytesUntilNextAtom, length);\n          length -= used;\n          offset += used;\n          bytesUntilNextAtom -= used;\n          if (length == 0)\n            return;\n        }\n      }\n    }\n    if (length > 0)\n    {\n      System.arraycopy(buf, offset, remAtomData, remAtomSize, length);\n      remAtomSize = length;\n    }\n  }\n\n  private boolean processMP3Packet(byte[] buf, int offset, int length)\n  {\n    int index = 0;\n    // scan through file to get syncword\n    int headerstring = ((buf[index + offset] << 16) & 0x00FF0000)\n        | ((buf[index+1+offset] << 8) & 0x0000FF00)\n        | ((buf[index+2+offset] << 0) & 0x000000FF);\n    boolean isSync = false;\n    index += 2;\n    do {\n      headerstring <<= 8;\n      index++;\n      if (index+offset >= length) {\n        // We didn\'t find the MP3 syncword so it probably isn\'t it\n        return false;\n      }\n\n      headerstring |= (buf[index+offset] & 0x000000FF);\n      isSync = ((headerstring & 0xFFE00000) == 0xFFE00000);\n      // MPEG 2.5\n      // filter out invalid sample rate\n      if (isSync)\n        isSync = (((headerstring >>> 10) & 3) != 3);\n      // filter out invalid layer\n      if (isSync)\n        isSync = (((headerstring >>> 17) & 3) != 0);\n      // filter out invalid version\n      if (isSync)\n        isSync = (((headerstring >>> 19) & 3) != 1);\n    } while (!isSync);\n    // Got a header... lets have a look at it...\n    int h_version = ((headerstring >>> 19) & 1);\n    if (((headerstring >>> 20) & 1) == 0) // SZD: MPEG2.5 detection\n      if (h_version == MP3Utils.MPEG2_LSF)\n        h_version = MP3Utils.MPEG25_LSF;\n      else {\n        if (Sage.DBG) System.out.println("invalid MPEG header (version)");\n        return false;\n      }\n    int h_sample_frequency;\n    if ((h_sample_frequency = ((headerstring >>> 10) & 3)) == 3) {\n      if (Sage.DBG) System.out.println("invalid MPEG header (sample freq)");\n      return false;\n    }\n    int h_layer = 4 - (headerstring >>> 17) & 3;\n    int h_bitrate_index = (headerstring >>> 12) & 0xF;\n    mp3Bitrate = MP3Utils.bitrates[h_version][h_layer - 1][h_bitrate_index];\n    if (Sage.DBG) System.out.println("Detected mp3 bitrate of:" + mp3Bitrate);\n    return true;\n  }\n\n  // NOTE: Not all MP4 content can be played back streaming. They may not have certain atom information\n  // at the front of the file that is necessary to decode before playback. We judge this based off\n  // the sample table. If we haven\'t hit it yet then we don\'t know if it\'s streamable. But we can tell if\n  // we see a very large atom which is going to take the majority of the file that we know it will not be\n  // streamable.\n\n  private void stvTaskRun()\n  {\n    downloadedBytes = 0;\n    if (xferBuf == null)\n      xferBuf = new byte[Sage.getInt("file_transfer_buffer_size", 65536)];\n    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();\n    if (abort)\n    {\n      // In case someone is viewing while we\'re downloading\n      Sage.processInactiveFile(myDestFile.toString());\n      return;\n    }\n    long totalFileSize = remoteSize;\n    try\n    {\n      long currOffset = 0;\n      while (totalFileSize > 0)\n      {\n        if (abort)\n        {\n          // In case someone is viewing while we\'re downloading\n          Sage.processInactiveFile(myDestFile.toString());\n          return;\n        }\n        int currRead = (int)Math.min(xferBuf.length, totalFileSize);\n        outStream.write(("READ " + currOffset + " " + currRead + "\\r\\n").getBytes(Sage.BYTE_CHARSET));\n        outStream.flush();\n        inStream.readFully(xferBuf, 0, currRead);\n        if (circSize > 0)\n        {\n          waitForCircWrite(currRead);\n          if (fileOut.position() + currRead < circSize)\n            fileOut.write(xferBuf, 0, currRead);\n          else\n          {\n            int firstWrite = (int)(circSize - fileOut.position());\n            fileOut.write(xferBuf, 0, firstWrite);\n            fileOut.seek(0);\n            fileOut.write(xferBuf, firstWrite, currRead - firstWrite);\n          }\n        }\n        else\n          fileOut.write(xferBuf, 0, currRead);\n        downloadedBytes += currRead;\n        statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);\n        totalFileSize -= currRead;\n        currOffset += currRead;\n      }\n      outStream.write("QUIT\\r\\n".getBytes(Sage.BYTE_CHARSET));\n      outStream.flush();\n    }\n    catch (Exception e)\n    {\n      if (Sage.DBG) System.out.println("ERROR during file download of:" + e);\n      Sage.printStackTrace(e);\n      statusMessage = "Error:" + e.toString();\n      return;\n    }\n    finally\n    {\n      try{\n        if (sock != null)\n          sock.close();\n      }catch (Exception e1){}\n      try{\n        if (outStream != null)\n          outStream.close();\n      }catch (Exception e2){}\n      try{\n        if (inStream != null)\n          inStream.close();\n      }catch (Exception e3){}\n      try{\n        if (fileOut != null)\n          fileOut.close();\n      }catch (Exception e4){}\n    }\n\n    succeeded();\n    // In case someone is viewing while we\'re downloading..not needed for synchronous transfers\n    if (!synchronous)\n      Sage.processInactiveFile(myDestFile.toString());\n  }\n\n  private void remoteUITaskRun()\n  {\n    downloadedBytes = 0;\n    java.text.NumberFormat prctForm = java.text.NumberFormat.getPercentInstance();\n    MiniClientSageRenderer.RemoteFSXfer fsXferOp = (MiniClientSageRenderer.RemoteFSXfer) fsXferOpWeak.get();\n    while (!fsXferOp.isDone())\n    {\n      if (abort)\n      {\n        fsXferOp.abortNow();\n        return;\n      }\n      try{Thread.sleep(250);}catch(Exception e){}\n      downloadedBytes = fsXferOp.getBytesXferd();\n      statusMessage = prctForm.format(((double)downloadedBytes)/remoteSize);\n    }\n\n    if (fsXferOp.error != 0)\n    {\n      statusMessage = "Error:" + fsXferOp.error;\n      return;\n    }\n    succeeded();\n  }\n\n  public long getNumDownloadedBytes()\n  {\n    return downloadedBytes;\n  }\n\n  public long getLastDownloadTimestamp()\n  {\n    return lastDownloadTimestamp;\n  }\n\n  public long getMinSeekTimestamp()\n  {\n    if (circSize > 0)\n    {\n      if (isMP4Stream && httpRangeSupported & !isComplete())\n        return 0; // We can seek anywhere in this case\n      else\n      {\n        // Only let them seek backwards into what we still have in the circular buffer. But this is\n        // tricky because we\'ll need to track timestamps for this. For now just guess based on avg. bitrate\n        // which we can easily get. And put the seek point 10% of the way into the buffer.\n        if (downloadedBytes < circSize)\n          return 0;\n        else\n        {\n          long minSeekPoint = Math.max(lastDownloadOffset, downloadedBytes - circSize) + circSize/10;\n          if (minSeekPoint >= downloadedBytes)\n            return lastDownloadTimestamp;\n          else\n          {\n            return lastDownloadTimestamp * minSeekPoint / downloadedBytes;\n          }\n        }\n      }\n    }\n    else\n      return 0; // the whole thing\'s being downloaded\n  }\n\n  public long getMaxSeekTimestamp()\n  {\n    if (circSize > 0 && isMP4Stream && httpRangeSupported)\n      return Long.MAX_VALUE/10; // avoid overflows when this is used in math outside of here\n    else\n      return lastDownloadTimestamp;\n  }\n\n  // Returns true if things interested in this downloaders timing progress need\n  // to make calls to find out more. (i.e. this is always true for circular files; but its false once a normal download completes)\n  public boolean needsTracking()\n  {\n    return circSize > 0 || !isComplete();\n  }\n\n  public boolean isProgressivePlay()\n  {\n    return !fullDownloadRequired;\n  }\n\n  public long getCircularDownloadSize()\n  {\n    return circSize;\n  }\n\n  // This call is used during progressive download playback w/ circular file storage.\n  // It\'s called so the downloader knows where the reading is currently at so it doesn\'t get too\n  // far ahead and wrap around and write over what hasn\'t been played yet\n  public void notifyReadRequest(long offset, long length)\n  {\n    lastNotifyReadOffset = offset;\n    lastNotifyReadLength = length;\n    if (CIRC_DOWNLOAD_DEBUG) System.out.println("notifyReadRequest=" + offset + " downloadedRange=" + Math.max(lastDownloadOffset, downloadedBytes - circSize) + " - " +\n        downloadedBytes);\n    if (circSize > 0 && !isComplete())\n    {\n      // Check to see if this read request is for data that\'s before what\'s in the buffer\n      if (offset < Math.max(lastDownloadOffset, downloadedBytes - circSize))\n      {\n        //System.out.println("WARNING : Attempting to read from offset before the buffer: offset=" + offset);\n        waitingForRead = true;\n        while (alive && !abort && offset < Math.max(lastDownloadOffset, downloadedBytes - circSize))\n        {\n          synchronized (notifyRead)\n          {\n            try\n            {\n              notifyRead.wait(30);\n            }\n            catch (InterruptedException e){ }\n          }\n        }\n        //System.out.println("DONE waiting for backwards read request to be ready!!");\n        waitingForRead = false;\n      }\n      // Check to see if this read request is for data that\'s after what\'s in the buffer\n      long maxRead = (remoteSize > 0) ? Math.min(remoteSize, offset + length) : (offset + length);\n      if (maxRead > downloadedBytes/* && isMP4Stream*/)\n      {\n        //System.out.println("WARNING: Trying to read data beyond what\'s been downloaded diff=" + ((maxRead) - downloadedBytes));\n        waitingForRead = true;\n        while (alive && !abort && maxRead > downloadedBytes)\n        {\n          synchronized (notifyRead)\n          {\n            try\n            {\n              notifyRead.wait(30);\n            }\n            catch (InterruptedException e){ }\n          }\n        }\n        //System.out.println("DONE waiting for forwards read request to be ready!!");\n        waitingForRead = false;\n      }\n      synchronized (notifyRead)\n      {\n        notifyRead.notify();\n        // Check if we need to flush this to disk yet in case the read from the circular file would be on data that\'s still\n        // in the write buffer for the RAF\n        if (offset + length > downloadedBytes - 65536)\n        {\n          try\n          {\n            if (fileOut != null)\n              fileOut.flush();\n          }\n          catch (Exception e){}\n        }\n      }\n    }\n  }\n\n  // Pauses and waits until it is OK to write data of the specified size to the circular file.\n  protected void waitForCircWrite(int writeSize)\n  {\n    if (circSize > 0 && (downloadedBytes + writeSize) >= circSize)\n    {\n      // We want the player to be in the middle of the circular buffer. So if they\'re at least 40% of the\n      // way into the buffer for their last read, we do this write.\n      long markerPoint = Math.max(0, downloadedBytes - circSize) + circSize * 4 / 10;\n      long minClientReadPoint = getSafeMP4SeekPositionForReadPosition(lastNotifyReadOffset, 0);\n      if (CIRC_DOWNLOAD_DEBUG && markerPoint > minClientReadPoint)\n        System.out.println("WAITING for circ buffer write to be OK");\n      while (alive && markerPoint > minClientReadPoint && !abort && !waitingForRead)\n      {\n        synchronized (notifyRead)\n        {\n          try\n          {\n            notifyRead.wait(50);\n          }\n          catch (InterruptedException e){ }\n        }\n      }\n    }\n  }\n\n  public boolean isClientWaitingForRead()\n  {\n    return waitingForRead;\n  }\n\n  public void notifyForPlayerSeek(long seekTimestamp)\n  {\n    if (CIRC_DOWNLOAD_DEBUG) System.out.println("Downloader notified of seek to: " + seekTimestamp);\n    lastSeekNotifyTimestamp = seekTimestamp;\n  }\n\n\n  public long getRemoteSize() { return remoteSize; }\n\n  //\tprivate static boolean addedBouncy = false;\n  private int decryptBuffer(byte[] xferBuf, int numRead, byte[] decryptBuf, boolean finalStep) throws Exception\n  {\n    // Here\'s where we can go to native code instead for the operation for performance reasons\n    if (decryptCipher == null)\n    {\n      decryptKey = new javax.crypto.spec.SecretKeySpec(cryptoInfo.key, cryptoInfo.algorithm.substring(0, cryptoInfo.algorithm.indexOf(\'/\')));\n      /*\t\t\tif (!addedBouncy)\n\t\t\t{\n\t\t\t\tjava.security.Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());\n\t\t\t\taddedBouncy = true;\n\t\t\t}*/\n      decryptCipher = javax.crypto.Cipher.getInstance(cryptoInfo.algorithm);\n      if (cryptoInfo.iv != null)\n      {\n        javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(cryptoInfo.iv);\n        decryptCipher.init(javax.crypto.Cipher.DECRYPT_MODE, decryptKey, ivSpec);\n      }\n      else\n        decryptCipher.init(javax.crypto.Cipher.DECRYPT_MODE, decryptKey);\n    }\n    if (finalStep)\n    {\n      return decryptCipher.doFinal(xferBuf, 0, numRead, decryptBuf);\n    }\n    else\n      return decryptCipher.update(xferBuf, 0, numRead, decryptBuf);\n  }\n\n  public static class DecryptInfo\n  {\n    public DecryptInfo(String algorithm, byte[] key, byte[] iv)\n    {\n      this.algorithm = algorithm;\n      this.key = key;\n      this.iv = iv;\n    }\n    public String algorithm;\n    public byte[] key;\n    public byte[] iv;\n  }\n\n  protected String myServerName;\n  protected java.io.File mySrcFile;\n  protected java.io.File myDestFile;\n  protected java.net.Socket sock = null;\n  protected java.io.DataOutputStream outStream = null;\n  protected java.io.DataInputStream inStream = null;\n  protected sage.io.SageFileSource fileOut = null;\n  protected long remoteSize;\n  protected boolean isMP4Stream;\n\n  protected java.net.URL myURL;\n  protected java.net.URLConnection myURLConn;\n  protected java.io.InputStream urlinStream = null;\n\n  protected long lastDownloadTimestamp;\n\n  protected boolean remoteUIXfer;\n\n  protected boolean gotMSAccess;\n  protected boolean gotSMBAccess;\n\n  protected boolean httpRangeSupported;\n\n  protected long downloadedBytes;\n  protected int mp3Bitrate;\n  protected boolean fullDownloadRequired;\n  protected boolean indexAtEndOfMP4;\n  protected long circSize;\n  protected long lastNotifyReadOffset;\n  protected long lastNotifyReadLength;\n  protected long lastDownloadOffset; // if we restart a download at a new offset, save it here\n  protected Object notifyRead = new Object();\n  protected boolean waitingForRead = false;\n  protected long lastAutoReseekTime;\n\n\n  // NOTE: Must use weak references here so we don\'t prevent GC of the UI stuff from\n  // being a value in the WeakHashMap (who\'s value then has a strong reference to us, and the WeakHashMap values are strong refs as well)\n  protected java.lang.ref.WeakReference uiMgrWeak;\n  protected java.lang.ref.WeakReference fsXferOpWeak;\n\n  protected boolean synchronous;\n  protected byte[] xferBuf;\n  protected boolean captureMode;\n\n  protected DecryptInfo cryptoInfo;\n  protected javax.crypto.Cipher decryptCipher;\n  protected javax.crypto.spec.SecretKeySpec decryptKey;\n}\n', 'message': 'Merge pull request #1 from google/master\n\nUpdate #1', 'author_name': 'TuscanRaider', 'lang': 'java'}