DownloadManager.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. package eu.kanade.mangafeed.data.download;
  2. import android.content.Context;
  3. import com.google.gson.Gson;
  4. import com.google.gson.reflect.TypeToken;
  5. import com.google.gson.stream.JsonReader;
  6. import java.io.File;
  7. import java.io.FileNotFoundException;
  8. import java.io.FileOutputStream;
  9. import java.io.FileReader;
  10. import java.io.IOException;
  11. import java.lang.reflect.Type;
  12. import java.net.MalformedURLException;
  13. import java.net.URL;
  14. import java.util.List;
  15. import eu.kanade.mangafeed.data.database.models.Chapter;
  16. import eu.kanade.mangafeed.data.database.models.Manga;
  17. import eu.kanade.mangafeed.data.download.model.Download;
  18. import eu.kanade.mangafeed.data.download.model.DownloadQueue;
  19. import eu.kanade.mangafeed.data.preference.PreferencesHelper;
  20. import eu.kanade.mangafeed.data.source.SourceManager;
  21. import eu.kanade.mangafeed.data.source.base.Source;
  22. import eu.kanade.mangafeed.data.source.model.Page;
  23. import eu.kanade.mangafeed.event.DownloadChaptersEvent;
  24. import eu.kanade.mangafeed.util.DiskUtils;
  25. import eu.kanade.mangafeed.util.DynamicConcurrentMergeOperator;
  26. import rx.Observable;
  27. import rx.Subscription;
  28. import rx.android.schedulers.AndroidSchedulers;
  29. import rx.schedulers.Schedulers;
  30. import rx.subjects.BehaviorSubject;
  31. import rx.subjects.PublishSubject;
  32. import timber.log.Timber;
  33. public class DownloadManager {
  34. private Context context;
  35. private SourceManager sourceManager;
  36. private PreferencesHelper preferences;
  37. private Gson gson;
  38. private PublishSubject<Download> downloadsQueueSubject;
  39. private BehaviorSubject<Integer> threadsNumber;
  40. private Subscription downloadsSubscription;
  41. private Subscription threadsNumberSubscription;
  42. private DownloadQueue queue;
  43. private volatile boolean isQueuePaused;
  44. private volatile boolean isRunning;
  45. public static final String PAGE_LIST_FILE = "index.json";
  46. public DownloadManager(Context context, SourceManager sourceManager, PreferencesHelper preferences) {
  47. this.context = context;
  48. this.sourceManager = sourceManager;
  49. this.preferences = preferences;
  50. gson = new Gson();
  51. queue = new DownloadQueue();
  52. downloadsQueueSubject = PublishSubject.create();
  53. threadsNumber = BehaviorSubject.create();
  54. }
  55. public void initializeSubscriptions() {
  56. if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed())
  57. downloadsSubscription.unsubscribe();
  58. if (threadsNumberSubscription != null && !threadsNumberSubscription.isUnsubscribed())
  59. threadsNumberSubscription.unsubscribe();
  60. threadsNumberSubscription = preferences.getDownloadTheadsObservable()
  61. .filter(n -> !isQueuePaused)
  62. .doOnNext(n -> isQueuePaused = (n == 0))
  63. .subscribe(threadsNumber::onNext);
  64. downloadsSubscription = downloadsQueueSubject
  65. .lift(new DynamicConcurrentMergeOperator<>(this::downloadChapter, threadsNumber))
  66. .onBackpressureBuffer()
  67. .observeOn(AndroidSchedulers.mainThread())
  68. .subscribe(finished -> {
  69. if (finished) {
  70. DownloadService.stop(context);
  71. }
  72. }, e -> Timber.e(e.fillInStackTrace(), e.getMessage()));
  73. isRunning = true;
  74. }
  75. public void destroySubscriptions() {
  76. isRunning = false;
  77. if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed()) {
  78. downloadsSubscription.unsubscribe();
  79. downloadsSubscription = null;
  80. }
  81. if (threadsNumberSubscription != null && !threadsNumberSubscription.isUnsubscribed()) {
  82. threadsNumberSubscription.unsubscribe();
  83. threadsNumberSubscription = null;
  84. }
  85. }
  86. // Create a download object for every chapter in the event and add them to the downloads queue
  87. public void onDownloadChaptersEvent(DownloadChaptersEvent event) {
  88. final Manga manga = event.getManga();
  89. final Source source = sourceManager.get(manga.source);
  90. for (Chapter chapter : event.getChapters()) {
  91. Download download = new Download(source, manga, chapter);
  92. if (!prepareDownload(download)) {
  93. queue.add(download);
  94. if (isRunning) downloadsQueueSubject.onNext(download);
  95. }
  96. }
  97. }
  98. // Public method to check if a chapter is downloaded
  99. public boolean isChapterDownloaded(Source source, Manga manga, Chapter chapter) {
  100. File directory = getAbsoluteChapterDirectory(source, manga, chapter);
  101. if (!directory.exists())
  102. return false;
  103. List<Page> pages = getSavedPageList(source, manga, chapter);
  104. return isChapterDownloaded(directory, pages);
  105. }
  106. // Prepare the download. Returns true if the chapter is already downloaded
  107. private boolean prepareDownload(Download download) {
  108. // If the chapter is already queued, don't add it again
  109. for (Download queuedDownload : queue.get()) {
  110. if (download.chapter.id.equals(queuedDownload.chapter.id))
  111. return true;
  112. }
  113. // Add the directory to the download object for future access
  114. download.directory = getAbsoluteChapterDirectory(download);
  115. // If the directory doesn't exist, the chapter isn't downloaded.
  116. if (!download.directory.exists()) {
  117. return false;
  118. }
  119. // If the page list doesn't exist, the chapter isn't downloaded
  120. List<Page> savedPages = getSavedPageList(download);
  121. if (savedPages == null)
  122. return false;
  123. // Add the page list to the download object for future access
  124. download.pages = savedPages;
  125. // If the number of files matches the number of pages, the chapter is downloaded.
  126. // We have the index file, so we check one file more
  127. return isChapterDownloaded(download.directory, download.pages);
  128. }
  129. // Check that all the images are downloaded
  130. private boolean isChapterDownloaded(File directory, List<Page> pages) {
  131. return pages != null && pages.size() + 1 == directory.listFiles().length;
  132. }
  133. // Download the entire chapter
  134. private Observable<Boolean> downloadChapter(Download download) {
  135. try {
  136. DiskUtils.createDirectory(download.directory);
  137. } catch (IOException e) {
  138. return Observable.error(e);
  139. }
  140. Observable<List<Page>> pageListObservable = download.pages == null ?
  141. // Pull page list from network and add them to download object
  142. download.source
  143. .pullPageListFromNetwork(download.chapter.url)
  144. .doOnNext(pages -> download.pages = pages)
  145. .doOnNext(pages -> savePageList(download)) :
  146. // Or if the page list already exists, start from the file
  147. Observable.just(download.pages);
  148. return pageListObservable
  149. .subscribeOn(Schedulers.io())
  150. .doOnError(error -> download.setStatus(Download.ERROR))
  151. .doOnNext(pages -> download.setStatus(Download.DOWNLOADING))
  152. .doOnNext(pages -> download.downloadedImages = 0)
  153. // Get all the URLs to the source images, fetch pages if necessary
  154. .flatMap(download.source::getAllImageUrlsFromPageList)
  155. // Start downloading images, consider we can have downloaded images already
  156. .concatMap(page -> getOrDownloadImage(page, download))
  157. // Do after download completes
  158. .doOnCompleted(() -> onDownloadCompleted(download))
  159. .toList()
  160. .flatMap(pages -> Observable.just(download))
  161. // If the page list threw, it will resume here
  162. .onErrorResumeNext(error -> Observable.just(download))
  163. .map(d -> areAllDownloadsFinished());
  164. }
  165. // Get the image from the filesystem if it exists or download from network
  166. private Observable<Page> getOrDownloadImage(final Page page, Download download) {
  167. // If the image URL is empty, do nothing
  168. if (page.getImageUrl() == null)
  169. return Observable.just(page);
  170. String filename = getImageFilename(page);
  171. File imagePath = new File(download.directory, filename);
  172. // If the image is already downloaded, do nothing. Otherwise download from network
  173. Observable<Page> pageObservable = isImageDownloaded(imagePath) ?
  174. Observable.just(page) :
  175. downloadImage(page, download.source, download.directory, filename);
  176. return pageObservable
  177. // When the image is ready, set image path, progress (just in case) and status
  178. .doOnNext(p -> {
  179. p.setImagePath(imagePath.getAbsolutePath());
  180. p.setProgress(100);
  181. p.setStatus(Page.READY);
  182. download.downloadedImages++;
  183. })
  184. // If the download fails, mark this page as error
  185. .doOnError(e -> page.setStatus(Page.ERROR))
  186. // Allow to download the remaining images
  187. .onErrorResumeNext(e -> Observable.just(page));
  188. }
  189. private Observable<Page> downloadImage(Page page, Source source, File directory, String filename) {
  190. page.setStatus(Page.DOWNLOAD_IMAGE);
  191. return source.getImageProgressResponse(page)
  192. .flatMap(resp -> {
  193. try {
  194. DiskUtils.saveBufferedSourceToDirectory(resp.body().source(), directory, filename);
  195. } catch (Exception e) {
  196. return Observable.error(e);
  197. }
  198. return Observable.just(page);
  199. });
  200. }
  201. // Public method to get the image from the filesystem. It does NOT provide any way to download the iamge
  202. public Observable<Page> getDownloadedImage(final Page page, File chapterDir) {
  203. if (page.getImageUrl() == null) {
  204. page.setStatus(Page.ERROR);
  205. return Observable.just(page);
  206. }
  207. File imagePath = new File(chapterDir, getImageFilename(page));
  208. // When the image is ready, set image path, progress (just in case) and status
  209. if (isImageDownloaded(imagePath)) {
  210. page.setImagePath(imagePath.getAbsolutePath());
  211. page.setProgress(100);
  212. page.setStatus(Page.READY);
  213. } else {
  214. page.setStatus(Page.ERROR);
  215. }
  216. return Observable.just(page);
  217. }
  218. // Get the filename for an image given the page
  219. private String getImageFilename(Page page) {
  220. String url;
  221. try {
  222. url = new URL(page.getImageUrl()).getPath();
  223. } catch (MalformedURLException e) {
  224. url = page.getImageUrl();
  225. }
  226. return url.substring(
  227. url.lastIndexOf("/") + 1,
  228. url.length());
  229. }
  230. private boolean isImageDownloaded(File imagePath) {
  231. return imagePath.exists();
  232. }
  233. // Called when a download finishes. This doesn't mean the download was successful, so we check it
  234. private void onDownloadCompleted(final Download download) {
  235. checkDownloadIsSuccessful(download);
  236. savePageList(download);
  237. }
  238. private void checkDownloadIsSuccessful(final Download download) {
  239. int actualProgress = 0;
  240. int status = Download.DOWNLOADED;
  241. // If any page has an error, the download result will be error
  242. for (Page page : download.pages) {
  243. actualProgress += page.getProgress();
  244. if (page.getStatus() == Page.ERROR) status = Download.ERROR;
  245. }
  246. download.totalProgress = actualProgress;
  247. download.setStatus(status);
  248. }
  249. // Return the page list from the chapter's directory if it exists, null otherwise
  250. public List<Page> getSavedPageList(Source source, Manga manga, Chapter chapter) {
  251. List<Page> pages = null;
  252. File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
  253. File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
  254. JsonReader reader = null;
  255. try {
  256. if (pagesFile.exists()) {
  257. reader = new JsonReader(new FileReader(pagesFile.getAbsolutePath()));
  258. Type collectionType = new TypeToken<List<Page>>() {}.getType();
  259. pages = gson.fromJson(reader, collectionType);
  260. }
  261. } catch (FileNotFoundException e) {
  262. Timber.e(e.fillInStackTrace(), e.getMessage());
  263. } finally {
  264. if (reader != null) try { reader.close(); } catch (IOException e) { /* Do nothing */ }
  265. }
  266. return pages;
  267. }
  268. // Shortcut for the method above
  269. private List<Page> getSavedPageList(Download download) {
  270. return getSavedPageList(download.source, download.manga, download.chapter);
  271. }
  272. // Save the page list to the chapter's directory
  273. public void savePageList(Source source, Manga manga, Chapter chapter, List<Page> pages) {
  274. File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
  275. File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
  276. FileOutputStream out = null;
  277. try {
  278. out = new FileOutputStream(pagesFile);
  279. out.write(gson.toJson(pages).getBytes());
  280. out.flush();
  281. } catch (IOException e) {
  282. Timber.e(e.fillInStackTrace(), e.getMessage());
  283. } finally {
  284. if (out != null) try { out.close(); } catch (IOException e) { /* Do nothing */ }
  285. }
  286. }
  287. // Shortcut for the method above
  288. private void savePageList(Download download) {
  289. savePageList(download.source, download.manga, download.chapter, download.pages);
  290. }
  291. // Get the absolute path to the chapter directory
  292. public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) {
  293. String chapterRelativePath = source.getName() +
  294. File.separator +
  295. manga.title.replaceAll("[^a-zA-Z0-9.-]", "_") +
  296. File.separator +
  297. chapter.name.replaceAll("[^a-zA-Z0-9.-]", "_");
  298. return new File(preferences.getDownloadsDirectory(), chapterRelativePath);
  299. }
  300. // Shortcut for the method above
  301. private File getAbsoluteChapterDirectory(Download download) {
  302. return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter);
  303. }
  304. public void deleteChapter(Source source, Manga manga, Chapter chapter) {
  305. File path = getAbsoluteChapterDirectory(source, manga, chapter);
  306. DiskUtils.deleteFiles(path);
  307. }
  308. public DownloadQueue getQueue() {
  309. return queue;
  310. }
  311. public boolean areAllDownloadsFinished() {
  312. for (Download download : queue.get()) {
  313. if (download.getStatus() <= Download.DOWNLOADING)
  314. return false;
  315. }
  316. return true;
  317. }
  318. public void resumeDownloads() {
  319. isQueuePaused = false;
  320. threadsNumber.onNext(preferences.getDownloadThreads());
  321. }
  322. public void pauseDownloads() {
  323. threadsNumber.onNext(0);
  324. }
  325. public boolean startDownloads() {
  326. boolean hasPendingDownloads = false;
  327. if (downloadsSubscription == null || threadsNumberSubscription == null)
  328. initializeSubscriptions();
  329. for (Download download : queue.get()) {
  330. if (download.getStatus() != Download.DOWNLOADED) {
  331. download.setStatus(Download.QUEUE);
  332. if (!hasPendingDownloads) hasPendingDownloads = true;
  333. downloadsQueueSubject.onNext(download);
  334. }
  335. }
  336. return hasPendingDownloads;
  337. }
  338. public void stopDownloads() {
  339. destroySubscriptions();
  340. for (Download download : queue.get()) {
  341. if (download.getStatus() == Download.DOWNLOADING) {
  342. download.setStatus(Download.ERROR);
  343. }
  344. }
  345. }
  346. public boolean isRunning() {
  347. return isRunning;
  348. }
  349. }