DownloadManager.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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.util.List;
  13. import eu.kanade.mangafeed.data.database.models.Chapter;
  14. import eu.kanade.mangafeed.data.database.models.Manga;
  15. import eu.kanade.mangafeed.data.download.model.Download;
  16. import eu.kanade.mangafeed.data.download.model.DownloadQueue;
  17. import eu.kanade.mangafeed.data.preference.PreferencesHelper;
  18. import eu.kanade.mangafeed.data.source.SourceManager;
  19. import eu.kanade.mangafeed.data.source.base.Source;
  20. import eu.kanade.mangafeed.data.source.model.Page;
  21. import eu.kanade.mangafeed.event.DownloadChaptersEvent;
  22. import eu.kanade.mangafeed.util.DiskUtils;
  23. import eu.kanade.mangafeed.util.DynamicConcurrentMergeOperator;
  24. import eu.kanade.mangafeed.util.UrlUtil;
  25. import rx.Observable;
  26. import rx.Subscription;
  27. import rx.android.schedulers.AndroidSchedulers;
  28. import rx.schedulers.Schedulers;
  29. import rx.subjects.BehaviorSubject;
  30. import rx.subjects.PublishSubject;
  31. import timber.log.Timber;
  32. public class DownloadManager {
  33. private Context context;
  34. private SourceManager sourceManager;
  35. private PreferencesHelper preferences;
  36. private Gson gson;
  37. private PublishSubject<Download> downloadsQueueSubject;
  38. private BehaviorSubject<Integer> threadsNumber;
  39. private Subscription downloadsSubscription;
  40. private Subscription threadsNumberSubscription;
  41. private DownloadQueue queue;
  42. private volatile boolean isQueuePaused;
  43. private volatile boolean isRunning;
  44. public static final String PAGE_LIST_FILE = "index.json";
  45. public DownloadManager(Context context, SourceManager sourceManager, PreferencesHelper preferences) {
  46. this.context = context;
  47. this.sourceManager = sourceManager;
  48. this.preferences = preferences;
  49. gson = new Gson();
  50. queue = new DownloadQueue();
  51. downloadsQueueSubject = PublishSubject.create();
  52. threadsNumber = BehaviorSubject.create();
  53. }
  54. public void initializeSubscriptions() {
  55. if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed())
  56. downloadsSubscription.unsubscribe();
  57. if (threadsNumberSubscription != null && !threadsNumberSubscription.isUnsubscribed())
  58. threadsNumberSubscription.unsubscribe();
  59. threadsNumberSubscription = preferences.getDownloadTheadsObservable()
  60. .filter(n -> !isQueuePaused)
  61. .doOnNext(n -> isQueuePaused = (n == 0))
  62. .subscribe(threadsNumber::onNext);
  63. downloadsSubscription = downloadsQueueSubject
  64. .lift(new DynamicConcurrentMergeOperator<>(this::downloadChapter, threadsNumber))
  65. .onBackpressureBuffer()
  66. .observeOn(AndroidSchedulers.mainThread())
  67. .map(download -> areAllDownloadsFinished())
  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<Download> 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. .doOnNext(pages -> download.setStatus(Download.DOWNLOADING))
  151. .doOnNext(pages -> download.downloadedImages = 0)
  152. // Get all the URLs to the source images, fetch pages if necessary
  153. .flatMap(download.source::getAllImageUrlsFromPageList)
  154. // Start downloading images, consider we can have downloaded images already
  155. .concatMap(page -> getOrDownloadImage(page, download))
  156. // Do after download completes
  157. .doOnCompleted(() -> onDownloadCompleted(download))
  158. .toList()
  159. .flatMap(pages -> Observable.just(download))
  160. // If the page list threw, it will resume here
  161. .onErrorResumeNext(error -> {
  162. download.setStatus(Download.ERROR);
  163. return Observable.just(download);
  164. });
  165. }
  166. // Get the image from the filesystem if it exists or download from network
  167. private Observable<Page> getOrDownloadImage(final Page page, Download download) {
  168. // If the image URL is empty, do nothing
  169. if (page.getImageUrl() == null)
  170. return Observable.just(page);
  171. String filename = getImageFilename(page);
  172. File imagePath = new File(download.directory, filename);
  173. // If the image is already downloaded, do nothing. Otherwise download from network
  174. Observable<Page> pageObservable = isImageDownloaded(imagePath) ?
  175. Observable.just(page) :
  176. downloadImage(page, download.source, download.directory, filename);
  177. return pageObservable
  178. // When the image is ready, set image path, progress (just in case) and status
  179. .doOnNext(p -> {
  180. page.setImagePath(imagePath.getAbsolutePath());
  181. page.setProgress(100);
  182. page.setStatus(Page.READY);
  183. download.downloadedImages++;
  184. })
  185. // If the download fails, mark this page as error
  186. .doOnError(e -> page.setStatus(Page.ERROR))
  187. // Allow to download the remaining images
  188. .onErrorResumeNext(e -> Observable.just(page));
  189. }
  190. // Save image on disk
  191. private Observable<Page> downloadImage(Page page, Source source, File directory, String filename) {
  192. page.setStatus(Page.DOWNLOAD_IMAGE);
  193. return source.getImageProgressResponse(page)
  194. .flatMap(resp -> {
  195. try {
  196. DiskUtils.saveBufferedSourceToDirectory(resp.body().source(), directory, filename);
  197. } catch (Exception e) {
  198. return Observable.error(e);
  199. }
  200. return Observable.just(page);
  201. });
  202. }
  203. // Public method to get the image from the filesystem. It does NOT provide any way to download the iamge
  204. public Observable<Page> getDownloadedImage(final Page page, File chapterDir) {
  205. if (page.getImageUrl() == null) {
  206. page.setStatus(Page.ERROR);
  207. return Observable.just(page);
  208. }
  209. File imagePath = new File(chapterDir, getImageFilename(page));
  210. // When the image is ready, set image path, progress (just in case) and status
  211. if (isImageDownloaded(imagePath)) {
  212. page.setImagePath(imagePath.getAbsolutePath());
  213. page.setProgress(100);
  214. page.setStatus(Page.READY);
  215. } else {
  216. page.setStatus(Page.ERROR);
  217. }
  218. return Observable.just(page);
  219. }
  220. // Get the filename for an image given the page
  221. private String getImageFilename(Page page) {
  222. String url = UrlUtil.getPath(page.getImageUrl());
  223. return url.substring(
  224. url.lastIndexOf("/") + 1,
  225. url.length());
  226. }
  227. private boolean isImageDownloaded(File imagePath) {
  228. return imagePath.exists();
  229. }
  230. // Called when a download finishes. This doesn't mean the download was successful, so we check it
  231. private void onDownloadCompleted(final Download download) {
  232. checkDownloadIsSuccessful(download);
  233. savePageList(download);
  234. }
  235. private void checkDownloadIsSuccessful(final Download download) {
  236. int actualProgress = 0;
  237. int status = Download.DOWNLOADED;
  238. // If any page has an error, the download result will be error
  239. for (Page page : download.pages) {
  240. actualProgress += page.getProgress();
  241. if (page.getStatus() != Page.READY) status = Download.ERROR;
  242. }
  243. // Ensure that the chapter folder has all the images
  244. if (!isChapterDownloaded(download.directory, download.pages)) {
  245. status = Download.ERROR;
  246. }
  247. download.totalProgress = actualProgress;
  248. download.setStatus(status);
  249. }
  250. // Return the page list from the chapter's directory if it exists, null otherwise
  251. public List<Page> getSavedPageList(Source source, Manga manga, Chapter chapter) {
  252. List<Page> pages = null;
  253. File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
  254. File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
  255. JsonReader reader = null;
  256. try {
  257. if (pagesFile.exists()) {
  258. reader = new JsonReader(new FileReader(pagesFile.getAbsolutePath()));
  259. Type collectionType = new TypeToken<List<Page>>() {}.getType();
  260. pages = gson.fromJson(reader, collectionType);
  261. }
  262. } catch (FileNotFoundException e) {
  263. Timber.e(e.fillInStackTrace(), e.getMessage());
  264. } finally {
  265. if (reader != null) try { reader.close(); } catch (IOException e) { /* Do nothing */ }
  266. }
  267. return pages;
  268. }
  269. // Shortcut for the method above
  270. private List<Page> getSavedPageList(Download download) {
  271. return getSavedPageList(download.source, download.manga, download.chapter);
  272. }
  273. // Save the page list to the chapter's directory
  274. public void savePageList(Source source, Manga manga, Chapter chapter, List<Page> pages) {
  275. File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
  276. File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
  277. FileOutputStream out = null;
  278. try {
  279. out = new FileOutputStream(pagesFile);
  280. out.write(gson.toJson(pages).getBytes());
  281. out.flush();
  282. } catch (IOException e) {
  283. Timber.e(e.fillInStackTrace(), e.getMessage());
  284. } finally {
  285. if (out != null) try { out.close(); } catch (IOException e) { /* Do nothing */ }
  286. }
  287. }
  288. // Shortcut for the method above
  289. private void savePageList(Download download) {
  290. savePageList(download.source, download.manga, download.chapter, download.pages);
  291. }
  292. // Get the absolute path to the chapter directory
  293. public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) {
  294. String chapterRelativePath = source.getName() +
  295. File.separator +
  296. manga.title.replaceAll("[^a-zA-Z0-9.-]", "_") +
  297. File.separator +
  298. chapter.name.replaceAll("[^a-zA-Z0-9.-]", "_");
  299. return new File(preferences.getDownloadsDirectory(), chapterRelativePath);
  300. }
  301. // Shortcut for the method above
  302. private File getAbsoluteChapterDirectory(Download download) {
  303. return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter);
  304. }
  305. public void deleteChapter(Source source, Manga manga, Chapter chapter) {
  306. File path = getAbsoluteChapterDirectory(source, manga, chapter);
  307. DiskUtils.deleteFiles(path);
  308. }
  309. public DownloadQueue getQueue() {
  310. return queue;
  311. }
  312. public boolean areAllDownloadsFinished() {
  313. for (Download download : queue.get()) {
  314. if (download.getStatus() <= Download.DOWNLOADING)
  315. return false;
  316. }
  317. return true;
  318. }
  319. public void resumeDownloads() {
  320. isQueuePaused = false;
  321. threadsNumber.onNext(preferences.getDownloadThreads());
  322. }
  323. public void pauseDownloads() {
  324. threadsNumber.onNext(0);
  325. }
  326. public boolean startDownloads() {
  327. boolean hasPendingDownloads = false;
  328. if (downloadsSubscription == null || threadsNumberSubscription == null)
  329. initializeSubscriptions();
  330. for (Download download : queue.get()) {
  331. if (download.getStatus() != Download.DOWNLOADED) {
  332. download.setStatus(Download.QUEUE);
  333. if (!hasPendingDownloads) hasPendingDownloads = true;
  334. downloadsQueueSubject.onNext(download);
  335. }
  336. }
  337. return hasPendingDownloads;
  338. }
  339. public void stopDownloads() {
  340. destroySubscriptions();
  341. for (Download download : queue.get()) {
  342. if (download.getStatus() == Download.DOWNLOADING) {
  343. download.setStatus(Download.ERROR);
  344. }
  345. }
  346. }
  347. public boolean isRunning() {
  348. return isRunning;
  349. }
  350. }