diff --git a/app/src/main/java/eu/kanade/mangafeed/data/helpers/DownloadManager.java b/app/src/main/java/eu/kanade/mangafeed/data/helpers/DownloadManager.java index 3aa2ee4f5..63dc14949 100644 --- a/app/src/main/java/eu/kanade/mangafeed/data/helpers/DownloadManager.java +++ b/app/src/main/java/eu/kanade/mangafeed/data/helpers/DownloadManager.java @@ -2,11 +2,21 @@ package eu.kanade.mangafeed.data.helpers; import android.content.Context; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; + import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.List; import eu.kanade.mangafeed.data.models.Chapter; +import eu.kanade.mangafeed.data.models.Download; import eu.kanade.mangafeed.data.models.Manga; import eu.kanade.mangafeed.data.models.Page; import eu.kanade.mangafeed.events.DownloadChapterEvent; @@ -20,77 +30,107 @@ import rx.subjects.PublishSubject; public class DownloadManager { private PublishSubject<DownloadChapterEvent> downloadsSubject; - private Subscription downloadsSubscription; + private Subscription downloadSubscription; private Context context; private SourceManager sourceManager; private PreferencesHelper preferences; + private Gson gson; + + private List<Download> queue; public DownloadManager(Context context, SourceManager sourceManager, PreferencesHelper preferences) { this.context = context; this.sourceManager = sourceManager; this.preferences = preferences; + this.gson = new Gson(); + + queue = new ArrayList<>(); initializeDownloadSubscription(); } + public PublishSubject<DownloadChapterEvent> getDownloadsSubject() { + return downloadsSubject; + } + private void initializeDownloadSubscription() { - if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed()) { - downloadsSubscription.unsubscribe(); + if (downloadSubscription != null && !downloadSubscription.isUnsubscribed()) { + downloadSubscription.unsubscribe(); } downloadsSubject = PublishSubject.create(); - downloadsSubscription = downloadsSubject + // Listen for download events, add them to queue and download + downloadSubscription = downloadsSubject .subscribeOn(Schedulers.io()) - .concatMap(event -> downloadChapter(event.getManga(), event.getChapter())) + .filter(event -> !isChapterDownloaded(event)) + .flatMap(this::createDownload) + .window(preferences.getDownloadThreads()) + .concatMap(concurrentDownloads -> concurrentDownloads + .concatMap(this::downloadChapter)) .onBackpressureBuffer() .subscribe(); } - public Observable<Page> downloadChapter(Manga manga, Chapter chapter) { - final Source source = sourceManager.get(manga.source); - final File chapterDirectory = getAbsoluteChapterDirectory(source, manga, chapter); + // Check if a chapter is already downloaded + private boolean isChapterDownloaded(DownloadChapterEvent event) { + final Source source = sourceManager.get(event.getManga().source); - return source - .pullPageListFromNetwork(chapter.url) - // Ensure we don't download a chapter already downloaded - .filter(pages -> !isChapterDownloaded(chapterDirectory, pages)) + // If the chapter is already queued, don't add it again + for (Download download : queue) { + if (download.chapter.id == event.getChapter().id) + return true; + } + + // If the directory doesn't exist, the chapter isn't downloaded + File dir = getAbsoluteChapterDirectory(source, event.getManga(), event.getChapter()); + if (!dir.exists()) + return false; + + // If the page list doesn't exist, the chapter isn't download (or maybe it's, + // but we consider it's not) + List<Page> savedPages = getSavedPageList(source, event.getManga(), event.getChapter()); + if (savedPages == null) + return false; + + // If the number of files matches the number of pages, the chapter is downloaded. + // We have the index file, so we check one file less + return (dir.listFiles().length - 1) == savedPages.size(); + } + + // Create a download object and add it to the downloads queue + private Observable<Download> createDownload(DownloadChapterEvent event) { + Download download = new Download( + sourceManager.get(event.getManga().source), + event.getManga(), + event.getChapter()); + + download.directory = getAbsoluteChapterDirectory( + download.source, download.manga, download.chapter); + + queue.add(download); + return Observable.just(download); + } + + // Download the entire chapter + private Observable<Page> downloadChapter(Download download) { + return download.source + .pullPageListFromNetwork(download.chapter.url) + .subscribeOn(Schedulers.io()) + // Add resulting pages to download object + .doOnNext(pages -> download.pages = pages) // Get all the URLs to the source images, fetch pages if necessary .flatMap(pageList -> Observable.merge( Observable.from(pageList).filter(page -> page.getImageUrl() != null), - source.getRemainingImageUrlsFromPageList(pageList))) - // Start downloading images - .flatMap(page -> getDownloadedImage(page, source, chapterDirectory)); - } - - public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) { - return new File(preferences.getDownloadsDirectory(), - getChapterDirectory(source, manga, chapter)); - } - - public String getChapterDirectory(Source source, Manga manga, Chapter chapter) { - return source.getName() + - File.separator + - manga.title.replaceAll("[^a-zA-Z0-9.-]", "_") + - File.separator + - chapter.name.replaceAll("[^a-zA-Z0-9.-]", "_"); - } - - private String getImageFilename(Page page) { - return page.getImageUrl().substring( - page.getImageUrl().lastIndexOf("/") + 1, - page.getImageUrl().length()); - } - - private boolean isChapterDownloaded(File chapterDir, List<Page> pages) { - return chapterDir.exists() && chapterDir.listFiles().length == pages.size(); - } - - private boolean isImageDownloaded(File imagePath) { - return imagePath.exists() && !imagePath.isDirectory(); + download.source.getRemainingImageUrlsFromPageList(pageList))) + // Start downloading images, consider we can have downloaded images already + .concatMap(page -> getDownloadedImage(page, download.source, download.directory)) + // Remove from the queue + .doOnCompleted(() -> removeFromQueue(download)); } + // Get downloaded image if exists, otherwise download it with the method below public Observable<Page> getDownloadedImage(final Page page, Source source, File chapterDir) { Observable<Page> obs = Observable.just(page); if (page.getImageUrl() == null) @@ -114,6 +154,7 @@ public class DownloadManager { }); } + // Download the image private Observable<Page> downloadImage(final Page page, Source source, File chapterDir, String imageFilename) { return source.getImageProgressResponse(page) .flatMap(resp -> { @@ -127,8 +168,62 @@ public class DownloadManager { }); } - public PublishSubject<DownloadChapterEvent> getDownloadsSubject() { - return downloadsSubject; + // Get the filename for an image given the page + private String getImageFilename(Page page) { + return page.getImageUrl().substring( + page.getImageUrl().lastIndexOf("/") + 1, + page.getImageUrl().length()); + } + + private boolean isImageDownloaded(File imagePath) { + return imagePath.exists() && !imagePath.isDirectory(); + } + + private void removeFromQueue(final Download download) { + savePageList(download.source, download.manga, download.chapter, download.pages); + queue.remove(download); + } + + // Return the page list from the chapter's directory if it exists, null otherwise + public List<Page> getSavedPageList(Source source, Manga manga, Chapter chapter) { + File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter); + File pagesFile = new File(chapterDir, "index.json"); + + try { + JsonReader reader = new JsonReader(new FileReader(pagesFile.getAbsolutePath())); + + Type collectionType = new TypeToken<List<Page>>() {}.getType(); + return gson.fromJson(reader, collectionType); + } catch (FileNotFoundException e) { + return null; + } + } + + // Save the page list to the chapter's directory + public void savePageList(Source source, Manga manga, Chapter chapter, List<Page> pages) { + File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter); + File pagesFile = new File(chapterDir, "index.json"); + + FileOutputStream out; + try { + out = new FileOutputStream(pagesFile); + out.write(gson.toJson(pages).getBytes()); + out.flush(); + out.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + // Get the absolute path to the chapter directory + public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) { + String chapterRelativePath = source.getName() + + File.separator + + manga.title.replaceAll("[^a-zA-Z0-9.-]", "_") + + File.separator + + chapter.name.replaceAll("[^a-zA-Z0-9.-]", "_"); + + return new File(preferences.getDownloadsDirectory(), chapterRelativePath); } } diff --git a/app/src/main/java/eu/kanade/mangafeed/data/helpers/PreferencesHelper.java b/app/src/main/java/eu/kanade/mangafeed/data/helpers/PreferencesHelper.java index 51b400eec..c2b3f7ca0 100644 --- a/app/src/main/java/eu/kanade/mangafeed/data/helpers/PreferencesHelper.java +++ b/app/src/main/java/eu/kanade/mangafeed/data/helpers/PreferencesHelper.java @@ -59,4 +59,8 @@ public class PreferencesHelper { DiskUtils.getStorageDirectories(context)[0]); } + public int getDownloadThreads() { + return Integer.parseInt(mPref.getString(getKey(R.string.pref_download_threads_key), "1")); + } + } diff --git a/app/src/main/java/eu/kanade/mangafeed/data/models/Download.java b/app/src/main/java/eu/kanade/mangafeed/data/models/Download.java new file mode 100644 index 000000000..f55d184dd --- /dev/null +++ b/app/src/main/java/eu/kanade/mangafeed/data/models/Download.java @@ -0,0 +1,20 @@ +package eu.kanade.mangafeed.data.models; + +import java.io.File; +import java.util.List; + +import eu.kanade.mangafeed.sources.base.Source; + +public class Download { + public Source source; + public Manga manga; + public Chapter chapter; + public List<Page> pages; + public File directory; + + public Download(Source source, Manga manga, Chapter chapter) { + this.source = source; + this.manga = manga; + this.chapter = chapter; + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java b/app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java index fa41f545f..90c59577e 100644 --- a/app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java +++ b/app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java @@ -15,6 +15,7 @@ import eu.kanade.mangafeed.data.helpers.SourceManager; import eu.kanade.mangafeed.data.models.Chapter; import eu.kanade.mangafeed.data.models.Manga; import eu.kanade.mangafeed.events.ChapterCountEvent; +import eu.kanade.mangafeed.events.DownloadChapterEvent; import eu.kanade.mangafeed.events.SourceMangaChapterEvent; import eu.kanade.mangafeed.sources.base.Source; import eu.kanade.mangafeed.ui.fragment.MangaChaptersFragment; @@ -38,7 +39,8 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment> private static final int DB_CHAPTERS = 1; private static final int ONLINE_CHAPTERS = 2; - private Subscription menuOperationSubscription; + private Subscription markReadSubscription; + private Subscription downloadSubscription; @Override protected void onCreate(Bundle savedState) { @@ -90,10 +92,6 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment> } } - public Manga getManga() { - return manga; - } - public void refreshChapters() { if (getView() != null) getView().setSwipeRefreshing(); @@ -120,10 +118,10 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment> } public void markChaptersRead(Observable<Chapter> selectedChapters, boolean read) { - if (menuOperationSubscription != null) - remove(menuOperationSubscription); + if (markReadSubscription != null) + remove(markReadSubscription); - add(menuOperationSubscription = selectedChapters + add(markReadSubscription = selectedChapters .subscribeOn(Schedulers.io()) .map(chapter -> { chapter.read = read; @@ -137,6 +135,18 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment> })); } + public void downloadChapters(Observable<Chapter> selectedChapters) { + if (downloadSubscription != null) + remove(downloadSubscription); + + add(downloadSubscription = selectedChapters + .subscribeOn(Schedulers.io()) + .subscribe(chapter -> { + EventBus.getDefault().post( + new DownloadChapterEvent(manga, chapter)); + })); + } + public void checkIsChapterDownloaded(Chapter chapter) { File dir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter); diff --git a/app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java b/app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java index 0fb6d30e4..cdaae91e6 100644 --- a/app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java +++ b/app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java @@ -18,11 +18,9 @@ import java.util.List; import butterknife.Bind; import butterknife.ButterKnife; -import de.greenrobot.event.EventBus; import eu.kanade.mangafeed.R; import eu.kanade.mangafeed.data.models.Chapter; import eu.kanade.mangafeed.data.services.DownloadService; -import eu.kanade.mangafeed.events.DownloadChapterEvent; import eu.kanade.mangafeed.presenter.MangaChaptersPresenter; import eu.kanade.mangafeed.ui.activity.MangaDetailActivity; import eu.kanade.mangafeed.ui.activity.ReaderActivity; @@ -31,8 +29,6 @@ import eu.kanade.mangafeed.ui.adapter.ChaptersAdapter; import eu.kanade.mangafeed.ui.fragment.base.BaseRxFragment; import nucleus.factory.RequiresPresenter; import rx.Observable; -import rx.Subscription; -import rx.schedulers.Schedulers; @RequiresPresenter(MangaChaptersPresenter.class) public class MangaChaptersFragment extends BaseRxFragment<MangaChaptersPresenter> implements @@ -44,7 +40,6 @@ public class MangaChaptersFragment extends BaseRxFragment<MangaChaptersPresenter private ChaptersAdapter adapter; private ActionMode actionMode; - private Subscription downloadSubscription; public static Fragment newInstance() { return new MangaChaptersFragment(); @@ -146,7 +141,7 @@ public class MangaChaptersFragment extends BaseRxFragment<MangaChaptersPresenter getPresenter().markChaptersRead(getSelectedChapters(), false); return true; case R.id.action_download: - onDownloadChapters(); + getPresenter().downloadChapters(getSelectedChapters()); return true; } return false; @@ -207,19 +202,4 @@ public class MangaChaptersFragment extends BaseRxFragment<MangaChaptersPresenter actionMode.setTitle(getString(R.string.selected_chapters_title, count)); } - private void onDownloadChapters() { - if (downloadSubscription != null && !downloadSubscription.isUnsubscribed()) { - downloadSubscription.unsubscribe(); - downloadSubscription = null; - } - - downloadSubscription = getSelectedChapters() - .subscribeOn(Schedulers.io()) - .subscribe(chapter -> { - EventBus.getDefault().post( - new DownloadChapterEvent(getPresenter().getManga(), chapter)); - downloadSubscription.unsubscribe(); - }); - } - } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a89cd1024..81aa3dc97 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -14,4 +14,10 @@ <item>4</item> </string-array> + <string-array name="download_threads"> + <item>1</item> + <item>2</item> + <item>3</item> + </string-array> + </resources> \ No newline at end of file diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 3e7b28ee4..04ea8b2c2 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -6,4 +6,5 @@ <string name="pref_fullscreen_key">pref_fullscreen_key</string> <string name="pref_default_viewer_key">pref_default_viewer_key</string> <string name="pref_download_directory_key">pref_download_directory_key</string> + <string name="pref_download_threads_key">pref_download_threads_key</string> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 265678495..a084b9b6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,5 +92,6 @@ <string name="notification_completed">Update completed</string> <string name="notification_no_new_chapters">No new chapters found</string> <string name="notification_new_chapters">Found new chapters for:</string> + <string name="pref_download_threads">Download threads</string> </resources> diff --git a/app/src/main/res/xml/pref_downloads.xml b/app/src/main/res/xml/pref_downloads.xml index 70056be23..7c79e587d 100644 --- a/app/src/main/res/xml/pref_downloads.xml +++ b/app/src/main/res/xml/pref_downloads.xml @@ -2,4 +2,11 @@ <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" android:orderingFromXml="true"> + <ListPreference android:title="@string/pref_download_threads" + android:key="@string/pref_download_threads_key" + android:entries="@array/download_threads" + android:entryValues="@array/download_threads" + android:defaultValue="1" + android:summary="%s"/> + </PreferenceScreen> \ No newline at end of file