content: /*
   This file is part of Subsonic.
  
   Subsonic is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.
  
   Subsonic is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.
  
   You should have received a copy of the GNU General Public License
   along with Subsonic.  If not, see <http://www.gnu.org/licenses/>.
  
   Copyright 2009 (C) Sindre Mehus
   */
  package net.nullsum.audinaut.service;
  
  import android.content.Context;
  import android.net.wifi.WifiManager;
  import android.os.PowerManager;
  import android.util.Log;
  
  import net.nullsum.audinaut.domain.MusicDirectory;
  import net.nullsum.audinaut.util.BufferFile;
  import net.nullsum.audinaut.util.CacheCleaner;
  import net.nullsum.audinaut.util.Constants;
  import net.nullsum.audinaut.util.FileUtil;
  import net.nullsum.audinaut.util.SilentBackgroundTask;
  import net.nullsum.audinaut.util.Util;
  
  import java.io.File;
  import java.io.FileNotFoundException;
  import java.io.FileOutputStream;
  import java.io.IOException;
  import java.io.InputStream;
  import java.io.OutputStream;
  
  import okhttp3.Response;
  
  /**
   * @author Sindre Mehus
   * @version $Id$
   */
  public class DownloadFile implements BufferFile {
      private static final String TAG = DownloadFile.class.getSimpleName();
      private static final int MAX_FAILURES = 5;
      private final Context context;
      private final MusicDirectory.Entry song;
      private final File partialFile;
      private final File completeFile;
      private final File saveFile;
  
      private final MediaStoreService mediaStoreService;
      private final boolean save;
      private final Long contentLength = null;
      private DownloadTask downloadTask;
      private boolean failedDownload = false;
      private int failed = 0;
      private int bitRate;
      private boolean isPlaying = false;
      private boolean saveWhenDone = false;
      private boolean completeWhenDone = false;
      private boolean rateLimit = false;
  
      public DownloadFile(Context context, MusicDirectory.Entry song, boolean save) {
          this.context = context;
          this.song = song;
          this.save = save;
          saveFile = FileUtil.getSongFile(context, song);
          bitRate = getActualBitrate();
          partialFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) +
                  ".partial." + FileUtil.getExtension(saveFile.getName()));
          completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) +
                  ".complete." + FileUtil.getExtension(saveFile.getName()));
          mediaStoreService = new MediaStoreService(context);
      }
  
      public MusicDirectory.Entry getSong() {
          return song;
      }
  
      public boolean isSong() {
          return song.isSong();
      }
  
      /**
       * Returns the effective bit rate.
       */
      public int getBitRate() {
          if (!partialFile.exists()) {
              bitRate = getActualBitrate();
          }
          if (bitRate > 0) {
              return bitRate;
          }
          return song.getBitRate() == null ? 160 : song.getBitRate();
      }
  
      private int getActualBitrate() {
          int br = Util.getMaxBitrate(context);
          if (br == 0 && song.getTranscodedSuffix() != null && "mp3".equals(song.getTranscodedSuffix().toLowerCase())) {
              if (song.getBitRate() != null) {
                  br = Math.min(320, song.getBitRate());
              } else {
                  br = 320;
              }
          } else if (song.getSuffix() != null && (song.getTranscodedSuffix() == null || song.getSuffix().equals(song.getTranscodedSuffix()))) {
              // If just downsampling, don't try to upsample (ie: 128 kpbs -> 192 kpbs)
              if (song.getBitRate() != null && (br == 0 || br > song.getBitRate())) {
                  br = song.getBitRate();
              }
          }
  
          return br;
      }
  
      public Long getContentLength() {
          return contentLength;
      }
  
      @Override
      public long getEstimatedSize() {
          if (contentLength != null) {
              return contentLength;
          }
  
          File file = getCompleteFile();
          if (file.exists()) {
              return file.length();
          } else if (song.getDuration() == null) {
              return 0;
          } else {
              int br = (getBitRate() * 1000) / 8;
              int duration = song.getDuration();
              return br * duration;
          }
      }
  
      public synchronized void download() {
          rateLimit = false;
          preDownload();
          downloadTask.execute();
      }
  
      private void preDownload() {
          FileUtil.createDirectoryForParent(saveFile);
          failedDownload = false;
          if (!partialFile.exists()) {
              bitRate = getActualBitrate();
          }
          downloadTask = new DownloadTask(context);
      }
  
      public synchronized void cancelDownload() {
          if (downloadTask != null) {
              downloadTask.cancel();
          }
      }
  
      @Override
      public File getFile() {
          if (saveFile.exists()) {
              return saveFile;
          } else if (completeFile.exists()) {
              return completeFile;
          } else {
              return partialFile;
          }
      }
  
      public File getCompleteFile() {
          if (saveFile.exists()) {
              return saveFile;
          }
  
          if (completeFile.exists()) {
              return completeFile;
          }
  
          return saveFile;
      }
  
      public File getPartialFile() {
          return partialFile;
      }
  
      public boolean isSaved() {
          return saveFile.exists();
      }
  
      public synchronized boolean isCompleteFileAvailable() {
          return saveFile.exists() || completeFile.exists();
      }
  
      @Override
      public synchronized boolean isWorkDone() {
          return saveFile.exists() || (completeFile.exists() && !save) || saveWhenDone || completeWhenDone;
      }
  
      @Override
      public void onStart() {
          setPlaying(true);
      }
  
      @Override
      public void onStop() {
          setPlaying(false);
      }
  
      @Override
      public synchronized void onResume() {
          if (!isWorkDone() && !isFailedMax() && !isDownloading() && !isDownloadCancelled()) {
              download();
          }
      }
  
      public synchronized boolean isDownloading() {
          return downloadTask != null && downloadTask.isRunning();
      }
  
      public synchronized boolean isDownloadCancelled() {
          return downloadTask != null && downloadTask.isCancelled();
      }
  
      public boolean shouldSave() {
          return save;
      }
  
      public boolean isFailed() {
          return failedDownload;
      }
  
      public boolean isFailedMax() {
          return failed > MAX_FAILURES;
      }
  
      public void delete() {
          cancelDownload();
  
          // Remove from mediaStore BEFORE deleting file since it calls getCompleteFile
          deleteFromStore();
  
          // Delete all possible versions of the file
          File parent = partialFile.getParentFile();
          Util.delete(partialFile);
          Util.delete(completeFile);
          Util.delete(saveFile);
          FileUtil.deleteEmptyDir(parent);
      }
  
      public void unpin() {
          if (saveFile.exists()) {
              // Delete old store entry before renaming to pinned file
              saveFile.renameTo(completeFile);
              renameInStore(saveFile, completeFile);
          }
      }
  
      public boolean cleanup() {
          boolean ok = true;
          if (completeFile.exists() || saveFile.exists()) {
              ok = Util.delete(partialFile);
          }
          if (saveFile.exists()) {
              ok &= Util.delete(completeFile);
          }
          return ok;
      }
  
      // In support of LRU caching.
      public void updateModificationDate() {
          updateModificationDate(saveFile);
          updateModificationDate(partialFile);
          updateModificationDate(completeFile);
      }
  
      private void updateModificationDate(File file) {
          if (file.exists()) {
              boolean ok = file.setLastModified(System.currentTimeMillis());
              if (!ok) {
                  Log.w(TAG, "Failed to set last-modified date on " + file);
              }
          }
      }
  
      public void renamePartial() {
          Util.renameFile(partialFile, completeFile);
          saveToStore();
      }
  
      public void setPlaying(boolean isPlaying) {
          if (saveWhenDone && !isPlaying) {
              Util.renameFile(completeFile, saveFile);
              renameInStore(completeFile, saveFile);
              saveWhenDone = false;
          } else if (completeWhenDone && !isPlaying) {
              if (save) {
                  Util.renameFile(partialFile, saveFile);
                  saveToStore();
              } else {
                  Util.renameFile(partialFile, completeFile);
                  saveToStore();
              }
              completeWhenDone = false;
          }
  
          this.isPlaying = isPlaying;
      }
  
      private void deleteFromStore() {
          try {
              mediaStoreService.deleteFromMediaStore(this);
          } catch (Exception e) {
              Log.w(TAG, "Failed to remove from store", e);
          }
      }
  
      private void saveToStore() {
          if (!Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_HIDE_MEDIA, false)) {
              try {
                  mediaStoreService.saveInMediaStore(this);
              } catch (Exception e) {
                  Log.w(TAG, "Failed to save in media store", e);
              }
          }
      }
  
      private void renameInStore(File start, File end) {
          try {
              mediaStoreService.renameInMediaStore(start, end);
          } catch (Exception e) {
              Log.w(TAG, "Failed to rename in store", e);
          }
      }
  
      @Override
      public String toString() {
          return "DownloadFile (" + song + ")";
      }
  
      // Don't do this.  Causes infinite loop if two instances of same song
      /*@Override
      public boolean equals(Object o) {
          if (this == o) {
              return true;
          }
          if (o == null || getClass() != o.getClass()) {
              return false;
          }
  
          DownloadFile downloadFile = (DownloadFile) o;
          return Util.equals(this.getSong(), downloadFile.getSong());
      }*/
  
      private class DownloadTask extends SilentBackgroundTask<Void> {
          private MusicService musicService;
  
          public DownloadTask(Context context) {
              super(context);
          }
  
          @Override
          public Void doInBackground() throws InterruptedException {
              InputStream in = null;
              FileOutputStream out = null;
              PowerManager.WakeLock wakeLock = null;
              WifiManager.WifiLock wifiLock = null;
              try {
  
                  if (Util.isScreenLitOnDownload(context)) {
                      PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
                      wakeLock = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, toString());
                      wakeLock.acquire();
                  }
  
                  wifiLock = Util.createWifiLock(context, toString());
                  wifiLock.acquire();
  
                  if (saveFile.exists()) {
                      Log.i(TAG, saveFile + " already exists. Skipping.");
                      checkDownloads();
                      return null;
                  }
                  if (completeFile.exists()) {
                      if (save) {
                          if (isPlaying) {
                              saveWhenDone = true;
                          } else {
                              Util.renameFile(completeFile, saveFile);
                              renameInStore(completeFile, saveFile);
                          }
                      } else {
                          Log.i(TAG, completeFile + " already exists. Skipping.");
                      }
                      checkDownloads();
                      return null;
                  }
  
                  if (musicService == null) {
                      musicService = MusicServiceFactory.getMusicService(context);
                  }
  
                  // Some devices seem to throw error on partial file which doesn't exist
                  boolean compare;
                  try {
                      compare = (bitRate == 0) || (song.getDuration() == 0) || (partialFile.length() == 0) || (bitRate * song.getDuration() * 1000 / 8) > partialFile.length();
                  } catch (Exception e) {
                      compare = true;
                  }
                  if (compare) {
                      // Attempt partial HTTP GET, appending to the file if it exists.
                      Response response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this);
                      if (response.header("Content-Length") != null) {
                          Log.i(TAG, "Content Length: " + contentLength);
                      }
  
                      boolean partial = response.code() == 206;
                      if (partial) {
                          Log.i(TAG, "Executed partial HTTP GET, skipping " + partialFile.length() + " bytes");
                      }
  
                      in = response.body().byteStream();
  
                      out = new FileOutputStream(partialFile, partial);
                      long n = copy(in, out);
                      Log.i(TAG, "Downloaded " + n + " bytes to " + partialFile);
                      out.flush();
                      out.close();
  
                      if (isCancelled()) {
                          throw new Exception("Download of '" + song + "' was cancelled");
                      } else if (partialFile.length() == 0) {
                          throw new Exception("Download of '" + song + "' failed.  File is 0 bytes long.");
                      }
  
                      downloadAndSaveCoverArt(musicService);
                  }
  
                  if (isPlaying) {
                      completeWhenDone = true;
                  } else {
                      if (save) {
                          Util.renameFile(partialFile, saveFile);
                      } else {
                          Util.renameFile(partialFile, completeFile);
                      }
                      DownloadFile.this.saveToStore();
                  }
  
              } catch (InterruptedException x) {
                  throw x;
              } catch (FileNotFoundException x) {
                  Util.delete(completeFile);
                  Util.delete(saveFile);
                  if (!isCancelled()) {
                      failed = MAX_FAILURES + 1;
                      failedDownload = true;
                      Log.w(TAG, "Failed to download '" + song + "'.", x);
                  }
              } catch (IOException x) {
                  Util.delete(completeFile);
                  Util.delete(saveFile);
                  if (!isCancelled()) {
                      failedDownload = true;
                      Log.w(TAG, "Failed to download '" + song + "'.", x);
                  }
              } catch (Exception x) {
                  Util.delete(completeFile);
                  Util.delete(saveFile);
                  if (!isCancelled()) {
                      failed++;
                      failedDownload = true;
                      Log.w(TAG, "Failed to download '" + song + "'.", x);
                  }
              } finally {
                  Util.close(in);
                  Util.close(out);
                  if (wakeLock != null) {
                      wakeLock.release();
                      Log.i(TAG, "Released wake lock " + wakeLock);
                  }
                  if (wifiLock != null) {
                      wifiLock.release();
                  }
              }
  
              // Only run these if not interrupted, ie: cancelled
              DownloadService downloadService = DownloadService.getInstance();
              if (downloadService != null && !isCancelled()) {
                  new CacheCleaner(context, downloadService).cleanSpace();
                  checkDownloads();
              }
  
              return null;
          }
  
          private void checkDownloads() {
              DownloadService downloadService = DownloadService.getInstance();
              if (downloadService != null) {
                  downloadService.checkDownloads();
              }
          }
  
          @Override
          public String toString() {
              return "DownloadTask (" + song + ")";
          }
  
          private void downloadAndSaveCoverArt(MusicService musicService) {
              try {
                  if (song.getCoverArt() != null) {
                      // Check if album art already exists, don't want to needlessly load into memory
                      File albumArtFile = FileUtil.getAlbumArtFile(context, song);
                      if (!albumArtFile.exists()) {
                          musicService.getCoverArt(context, song, 0, null, null);
                      }
                  }
              } catch (Exception x) {
                  Log.e(TAG, "Failed to get cover art.", x);
              }
          }
  
          private long copy(final InputStream in, OutputStream out) throws IOException, InterruptedException {
  
              // Start a thread that will close the input stream if the task is
              // cancelled, thus causing the copy() method to return.
              new Thread("DownloadFile_copy") {
                  @Override
                  public void run() {
                      while (true) {
                          Util.sleepQuietly(3000L);
                          if (isCancelled()) {
                              Util.close(in);
                              return;
                          }
                          if (!isRunning()) {
                              return;
                          }
                      }
                  }
              }.start();
  
              byte[] buffer = new byte[1024 * 16];
              long count = 0;
              int n;
              long lastLog = System.currentTimeMillis();
              long lastCount = 0;
  
              boolean activeLimit = rateLimit;
              while (!isCancelled() && (n = in.read(buffer)) != -1) {
                  out.write(buffer, 0, n);
                  count += n;
                  lastCount += n;
  
                  long now = System.currentTimeMillis();
                  if (now - lastLog > 3000L) {  // Only every so often.
                      Log.i(TAG, "Downloaded " + Util.formatBytes(count) + " of " + song);
                      lastLog = now;
                      lastCount = 0;
  
                      // Re-establish every few seconds whether screen is on or not
                      if (rateLimit) {
                          PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
                          activeLimit = pm.isScreenOn();
                      }
                  }
  
                  // If screen is on and rateLimit is true, stop downloading from exhausting bandwidth
                  if (activeLimit) {
                      Thread.sleep(10L);
                  }
              }
              return count;
          }
      }
  }
  