Manga cover updates (#3101)

* cover caching overhaul

* add ui for removing custom cover

* skip some loading work

* minor cleanup

* allow refresh library metadata to refresh local manga

* rename metadata_date to cover_last_modified

* rearrange removeMangaFromLibrary

* change custom cover directory
add setting for updating cover when refreshing library

* remove toggle and explicit action for updating covers
This commit is contained in:
MCAxiaz 2020-05-10 08:15:25 -07:00 committed by GitHub
parent 436253dd63
commit dc54299e24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 441 additions and 208 deletions

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.cache package eu.kanade.tachiyomi.data.cache
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -17,51 +18,89 @@ import java.io.InputStream
*/ */
class CoverCache(private val context: Context) { class CoverCache(private val context: Context) {
companion object {
private const val COVERS_DIR = "covers"
private const val CUSTOM_COVERS_DIR = "covers/custom"
}
/** /**
* Cache directory used for cache management. * Cache directory used for cache management.
*/ */
private val cacheDir = context.getExternalFilesDir("covers") private val cacheDir = getCacheDir(COVERS_DIR)
?: File(context.filesDir, "covers").also { it.mkdirs() }
private val customCoverCacheDir = getCacheDir(CUSTOM_COVERS_DIR)
/** /**
* Returns the cover from cache. * Returns the cover from cache.
* *
* @param thumbnailUrl the thumbnail url. * @param manga the manga.
* @return cover image. * @return cover image.
*/ */
fun getCoverFile(thumbnailUrl: String): File { fun getCoverFile(manga: Manga): File? {
return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl)) return manga.thumbnail_url?.let {
File(cacheDir, DiskUtil.hashKeyForDisk(it))
}
} }
/** /**
* Copy the given stream to this cache. * Returns the custom cover from cache.
* *
* @param thumbnailUrl url of the thumbnail. * @param manga the manga.
* @return cover image.
*/
fun getCustomCoverFile(manga: Manga): File {
return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(manga.id.toString()))
}
/**
* Saves the given stream as the manga's custom cover to cache.
*
* @param manga the manga.
* @param inputStream the stream to copy. * @param inputStream the stream to copy.
* @throws IOException if there's any error. * @throws IOException if there's any error.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun copyToCache(thumbnailUrl: String, inputStream: InputStream) { fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
// Get destination file. getCustomCoverFile(manga).outputStream().use {
val destFile = getCoverFile(thumbnailUrl) inputStream.copyTo(it)
}
destFile.outputStream().use { inputStream.copyTo(it) }
} }
/** /**
* Delete the cover file from the cache. * Delete the cover files of the manga from the cache.
* *
* @param thumbnailUrl the thumbnail url. * @param manga the manga.
* @return status of deletion. * @param deleteCustomCover whether the custom cover should be deleted.
* @return number of files that were deleted.
*/ */
fun deleteFromCache(thumbnailUrl: String?): Boolean { fun deleteFromCache(manga: Manga, deleteCustomCover: Boolean = false): Int {
// Check if url is empty. var deleted = 0
if (thumbnailUrl.isNullOrEmpty()) {
return false getCoverFile(manga)?.let {
if (it.exists() && it.delete()) ++deleted
} }
// Remove file. if (deleteCustomCover) {
val file = getCoverFile(thumbnailUrl) if (deleteCustomCover(manga)) ++deleted
return file.exists() && file.delete() }
return deleted
}
/**
* Delete custom cover of the manga from the cache
*
* @param manga the manga.
* @return whether the cover was deleted.
*/
fun deleteCustomCover(manga: Manga): Boolean {
return getCustomCoverFile(manga).let {
it.exists() && it.delete()
}
}
private fun getCacheDir(dir: String): File {
return context.getExternalFilesDir(dir)
?: File(context.filesDir, dir).also { it.mkdirs() }
} }
} }

View File

@ -20,7 +20,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
/** /**
* Version of the database. * Version of the database.
*/ */
const val DATABASE_VERSION = 9 const val DATABASE_VERSION = 10
} }
override fun onCreate(db: SupportSQLiteDatabase) = with(db) { override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
@ -75,6 +75,9 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
db.execSQL(TrackTable.addStartDate) db.execSQL(TrackTable.addStartDate)
db.execSQL(TrackTable.addFinishDate) db.execSQL(TrackTable.addFinishDate)
} }
if (oldVersion < 10) {
db.execSQL(MangaTable.addCoverLastModified)
}
} }
override fun onConfigure(db: SupportSQLiteDatabase) { override fun onConfigure(db: SupportSQLiteDatabase) {

View File

@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ARTIST import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ARTIST
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_AUTHOR import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_AUTHOR
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_CHAPTER_FLAGS import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_CHAPTER_FLAGS
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_COVER_LAST_MODIFIED
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_DESCRIPTION import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_DESCRIPTION
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_FAVORITE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_FAVORITE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_GENRE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_GENRE
@ -62,6 +63,7 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
put(COL_INITIALIZED, obj.initialized) put(COL_INITIALIZED, obj.initialized)
put(COL_VIEWER, obj.viewer) put(COL_VIEWER, obj.viewer)
put(COL_CHAPTER_FLAGS, obj.chapter_flags) put(COL_CHAPTER_FLAGS, obj.chapter_flags)
put(COL_COVER_LAST_MODIFIED, obj.cover_last_modified)
} }
} }
@ -82,6 +84,7 @@ interface BaseMangaGetResolver {
initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)) == 1 initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)) == 1
viewer = cursor.getInt(cursor.getColumnIndex(COL_VIEWER)) viewer = cursor.getInt(cursor.getColumnIndex(COL_VIEWER))
chapter_flags = cursor.getInt(cursor.getColumnIndex(COL_CHAPTER_FLAGS)) chapter_flags = cursor.getInt(cursor.getColumnIndex(COL_CHAPTER_FLAGS))
cover_last_modified = cursor.getLong(cursor.getColumnIndex(COL_COVER_LAST_MODIFIED))
} }
} }

View File

@ -16,6 +16,8 @@ interface Manga : SManga {
var chapter_flags: Int var chapter_flags: Int
var cover_last_modified: Long
fun setChapterOrder(order: Int) { fun setChapterOrder(order: Int) {
setFlags(order, SORT_MASK) setFlags(order, SORT_MASK)
} }

View File

@ -32,6 +32,8 @@ open class MangaImpl : Manga {
override var chapter_flags: Int = 0 override var chapter_flags: Int = 0
override var cover_last_modified: Long = 0
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || javaClass != other.javaClass) return false if (other == null || javaClass != other.javaClass) return false

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaCoverLastModifiedPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
@ -102,6 +103,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaTitlePutResolver()) .withPutResolver(MangaTitlePutResolver())
.prepare() .prepare()
fun updateMangaCoverLastModified(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaCoverLastModifiedPutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()

View File

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class MangaCoverLastModifiedPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_COVER_LAST_MODIFIED, manga.cover_last_modified)
}
}

View File

@ -38,6 +38,8 @@ object MangaTable {
const val COL_CATEGORY = "category" const val COL_CATEGORY = "category"
const val COL_COVER_LAST_MODIFIED = "cover_last_modified"
val createTableQuery: String val createTableQuery: String
get() = get() =
"""CREATE TABLE $TABLE( """CREATE TABLE $TABLE(
@ -55,7 +57,8 @@ object MangaTable {
$COL_LAST_UPDATE LONG, $COL_LAST_UPDATE LONG,
$COL_INITIALIZED BOOLEAN NOT NULL, $COL_INITIALIZED BOOLEAN NOT NULL,
$COL_VIEWER INTEGER NOT NULL, $COL_VIEWER INTEGER NOT NULL,
$COL_CHAPTER_FLAGS INTEGER NOT NULL $COL_CHAPTER_FLAGS INTEGER NOT NULL,
$COL_COVER_LAST_MODIFIED LONG NOT NULL
)""" )"""
val createUrlIndexQuery: String val createUrlIndexQuery: String
@ -64,4 +67,7 @@ object MangaTable {
val createLibraryIndexQuery: String val createLibraryIndexQuery: String
get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " + get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
"WHERE $COL_FAVORITE = 1" "WHERE $COL_FAVORITE = 1"
val addCoverLastModified: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_COVER_LAST_MODIFIED LONG NOT NULL DEFAULT 0"
} }

View File

@ -12,7 +12,7 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import timber.log.Timber import timber.log.Timber
open class FileFetcher(private val file: File) : DataFetcher<InputStream> { open class FileFetcher(private val filePath: String = "") : DataFetcher<InputStream> {
private var data: InputStream? = null private var data: InputStream? = null
@ -20,7 +20,11 @@ open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
loadFromFile(callback) loadFromFile(callback)
} }
protected fun loadFromFile(callback: DataFetcher.DataCallback<in InputStream>) { private fun loadFromFile(callback: DataFetcher.DataCallback<in InputStream>) {
loadFromFile(File(filePath), callback)
}
protected fun loadFromFile(file: File, callback: DataFetcher.DataCallback<in InputStream>) {
try { try {
data = FileInputStream(file) data = FileInputStream(file)
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.data.DataFetcher
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File
import java.io.InputStream
import java.lang.Exception
open class LibraryMangaCustomCoverFetcher(
private val manga: Manga,
private val coverCache: CoverCache
) : FileFetcher() {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
getCustomCoverFile()?.let {
loadFromFile(it, callback)
} ?: callback.onLoadFailed(Exception("Custom cover file not found"))
}
protected fun getCustomCoverFile(): File? {
return coverCache.getCustomCoverFile(manga).takeIf { it.exists() }
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.data.DataFetcher
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -19,31 +20,41 @@ import java.io.InputStream
class LibraryMangaUrlFetcher( class LibraryMangaUrlFetcher(
private val networkFetcher: DataFetcher<InputStream>, private val networkFetcher: DataFetcher<InputStream>,
private val manga: Manga, private val manga: Manga,
private val file: File private val coverCache: CoverCache
) : ) : LibraryMangaCustomCoverFetcher(manga, coverCache) {
FileFetcher(file) {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
if (!file.exists()) { getCustomCoverFile()?.let {
loadFromFile(it, callback)
return
}
val cover = coverCache.getCoverFile(manga)
if (cover == null) {
callback.onLoadFailed(Exception("Null thumbnail url"))
return
}
if (!cover.exists()) {
networkFetcher.loadData( networkFetcher.loadData(
priority, priority,
object : DataFetcher.DataCallback<InputStream> { object : DataFetcher.DataCallback<InputStream> {
override fun onDataReady(data: InputStream?) { override fun onDataReady(data: InputStream?) {
if (data != null) { if (data != null) {
val tmpFile = File(file.path + ".tmp") val tmpFile = File(cover.path + ".tmp")
try { try {
// Retrieve destination stream, create parent folders if needed. // Retrieve destination stream, create parent folders if needed.
val output = try { val output = try {
tmpFile.outputStream() tmpFile.outputStream()
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
tmpFile.parentFile.mkdirs() tmpFile.parentFile!!.mkdirs()
tmpFile.outputStream() tmpFile.outputStream()
} }
// Copy the file and rename to the original. // Copy the file and rename to the original.
data.use { output.use { data.copyTo(output) } } data.use { output.use { data.copyTo(output) } }
tmpFile.renameTo(file) tmpFile.renameTo(cover)
loadFromFile(callback) loadFromFile(cover, callback)
} catch (e: Exception) { } catch (e: Exception) {
tmpFile.delete() tmpFile.delete()
callback.onLoadFailed(e) callback.onLoadFailed(e)
@ -59,7 +70,7 @@ class LibraryMangaUrlFetcher(
} }
) )
} else { } else {
loadFromFile(callback) loadFromFile(cover, callback)
} }
} }

View File

@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.load.Key
import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File
import java.security.MessageDigest
class MangaSignature(manga: Manga, file: File) : Key {
private val key = manga.thumbnail_url + file.lastModified()
override fun equals(other: Any?): Boolean {
return if (other is MangaSignature) {
key == other.key
} else {
false
}
}
override fun hashCode(): Int {
return key.hashCode()
}
override fun updateDiskCacheKey(md: MessageDigest) {
md.update(key.toByteArray(Key.CHARSET))
}
}

View File

@ -1,7 +1,15 @@
package eu.kanade.tachiyomi.data.glide package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.load.Key
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import java.security.MessageDigest
data class MangaThumbnail(val manga: Manga, val url: String?) data class MangaThumbnail(val manga: Manga, val coverLastModified: Long) : Key {
val key = manga.url + coverLastModified
fun Manga.toMangaThumbnail() = MangaThumbnail(this, this.thumbnail_url) override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(key.toByteArray(Key.CHARSET))
}
}
fun Manga.toMangaThumbnail() = MangaThumbnail(this, cover_last_modified)

View File

@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import java.io.File import eu.kanade.tachiyomi.util.isLocal
import java.io.InputStream import java.io.InputStream
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -48,12 +48,6 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
*/ */
private val defaultClient = Injekt.get<NetworkHelper>().client private val defaultClient = Injekt.get<NetworkHelper>().client
/**
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
* and the file where it should be stored in case the manga is a favorite.
*/
private val lruCache = LruCache<GlideUrl, File>(100)
/** /**
* Map where request headers are stored for a source. * Map where request headers are stored for a source.
*/ */
@ -78,7 +72,7 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
/** /**
* Returns a fetcher for the given manga or null if the url is empty. * Returns a fetcher for the given manga or null if the url is empty.
* *
* @param manga the model. * @param mangaThumbnail the model.
* @param width the width of the view where the resource will be loaded. * @param width the width of the view where the resource will be loaded.
* @param height the height of the view where the resource will be loaded. * @param height the height of the view where the resource will be loaded.
*/ */
@ -88,13 +82,16 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
height: Int, height: Int,
options: Options options: Options
): ModelLoader.LoadData<InputStream>? { ): ModelLoader.LoadData<InputStream>? {
// Check thumbnail is not null or empty
val url = mangaThumbnail.url
if (url == null || url.isEmpty()) {
return null
}
val manga = mangaThumbnail.manga val manga = mangaThumbnail.manga
val url = manga.thumbnail_url
if (url.isNullOrEmpty()) {
return if (!manga.favorite || manga.isLocal()) {
null
} else {
ModelLoader.LoadData(mangaThumbnail, LibraryMangaCustomCoverFetcher(manga, coverCache))
}
}
if (url.startsWith("http", true)) { if (url.startsWith("http", true)) {
val source = sourceManager.get(manga.source) as? HttpSource val source = sourceManager.get(manga.source) as? HttpSource
@ -107,19 +104,13 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
return ModelLoader.LoadData(glideUrl, networkFetcher) return ModelLoader.LoadData(glideUrl, networkFetcher)
} }
// Obtain the file for this url from the LRU cache, or retrieve and add it to the cache. val libraryFetcher = LibraryMangaUrlFetcher(networkFetcher, manga, coverCache)
val file = lruCache.getOrPut(glideUrl) { coverCache.getCoverFile(url) }
val libraryFetcher = LibraryMangaUrlFetcher(networkFetcher, manga, file)
// Return an instance of the fetcher providing the needed elements. // Return an instance of the fetcher providing the needed elements.
return ModelLoader.LoadData(MangaSignature(manga, file), libraryFetcher) return ModelLoader.LoadData(mangaThumbnail, libraryFetcher)
} else { } else {
// Get the file from the url, removing the scheme if present.
val file = File(url.substringAfter("file://"))
// Return an instance of the fetcher providing the needed elements. // Return an instance of the fetcher providing the needed elements.
return ModelLoader.LoadData(MangaSignature(manga, file), FileFetcher(file)) return ModelLoader.LoadData(mangaThumbnail, FileFetcher(url.removePrefix("file://")))
} }
} }

View File

@ -15,6 +15,7 @@ import androidx.core.app.NotificationCompat.GROUP_ALERT_SUMMARY
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -31,10 +32,10 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.chop import eu.kanade.tachiyomi.util.lang.chop
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning import eu.kanade.tachiyomi.util.system.isServiceRunning
import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notification
@ -64,7 +65,8 @@ class LibraryUpdateService(
val sourceManager: SourceManager = Injekt.get(), val sourceManager: SourceManager = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get(), val preferences: PreferencesHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(),
val trackManager: TrackManager = Injekt.get() val trackManager: TrackManager = Injekt.get(),
val coverCache: CoverCache = Injekt.get()
) : Service() { ) : Service() {
/** /**
@ -110,6 +112,7 @@ class LibraryUpdateService(
*/ */
enum class Target { enum class Target {
CHAPTERS, // Manga chapters CHAPTERS, // Manga chapters
COVERS, // Manga covers
TRACKING // Tracking metadata TRACKING // Tracking metadata
} }
@ -233,6 +236,7 @@ class LibraryUpdateService(
// Update either chapter list or manga details. // Update either chapter list or manga details.
when (target) { when (target) {
Target.CHAPTERS -> updateChapterList(mangaList) Target.CHAPTERS -> updateChapterList(mangaList)
Target.COVERS -> updateCovers(mangaList)
Target.TRACKING -> updateTrackings(mangaList) Target.TRACKING -> updateTrackings(mangaList)
} }
} }
@ -387,11 +391,14 @@ class LibraryUpdateService(
* @return a pair of the inserted and removed chapters. * @return a pair of the inserted and removed chapters.
*/ */
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> { fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty() val source = sourceManager.get(manga.source) ?: return Observable.empty()
// Update manga details metadata in the background // Update manga details metadata in the background
source.fetchMangaDetails(manga) source.fetchMangaDetails(manga)
.map { networkManga -> .map { networkManga ->
if (manga.thumbnail_url != networkManga.thumbnail_url) {
manga.prepUpdateCover(coverCache)
}
manga.copyFrom(networkManga) manga.copyFrom(networkManga)
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
manga manga
@ -404,6 +411,21 @@ class LibraryUpdateService(
.map { syncChaptersWithSource(db, it, manga, source) } .map { syncChaptersWithSource(db, it, manga, source) }
} }
private fun updateCovers(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
var count = 0
return Observable.from(mangaToUpdate)
.doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) }
.map { manga ->
manga.prepUpdateCover(coverCache)
db.insertManga(manga).executeAsBlocking()
manga
}
.doOnCompleted {
cancelProgressNotification()
}
}
/** /**
* Method that updates the metadata of the connected tracking services. It's called in a * Method that updates the metadata of the connected tracking services. It's called in a
* background thread, so it's safe to do heavy operations or network calls here. * background thread, so it's safe to do heavy operations or network calls here.

View File

@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TextItem
import eu.kanade.tachiyomi.ui.browse.source.filter.TextSectionItem import eu.kanade.tachiyomi.ui.browse.source.filter.TextSectionItem
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
import eu.kanade.tachiyomi.util.removeCovers
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
@ -279,7 +280,7 @@ open class BrowseSourcePresenter(
fun changeMangaFavorite(manga: Manga) { fun changeMangaFavorite(manga: Manga) {
manga.favorite = !manga.favorite manga.favorite = !manga.favorite
if (!manga.favorite) { if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url) manga.removeCovers(coverCache)
} }
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
} }

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
private lateinit var manga: Manga
constructor(target: T, manga: Manga) : this() {
targetController = target
this.manga = manga
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.title(R.string.action_edit_cover)
.positiveButton(R.string.action_edit) {
(targetController as? Listener)?.openMangaCoverPicker(manga)
}
.negativeButton(android.R.string.cancel)
.neutralButton(R.string.action_delete) {
(targetController as? Listener)?.deleteMangaCover(manga)
}
}
interface Listener {
fun deleteMangaCover(manga: Manga)
fun openMangaCoverPicker(manga: Manga)
}
}

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.library
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -22,6 +21,7 @@ import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
@ -37,7 +37,6 @@ import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.util.view.visible
import java.io.IOException
import kotlinx.android.synthetic.main.main_activity.tabs import kotlinx.android.synthetic.main.main_activity.tabs
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -51,11 +50,13 @@ import uy.kohesive.injekt.api.get
class LibraryController( class LibraryController(
bundle: Bundle? = null, bundle: Bundle? = null,
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get(),
private val coverCache: CoverCache = Injekt.get()
) : NucleusController<LibraryControllerBinding, LibraryPresenter>(bundle), ) : NucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
RootController, RootController,
TabbedController, TabbedController,
ActionMode.Callback, ActionMode.Callback,
ChangeMangaCoverDialog.Listener,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DeleteLibraryMangasDialog.Listener { DeleteLibraryMangasDialog.Listener {
@ -424,10 +425,7 @@ class LibraryController(
private fun onActionItemClicked(item: MenuItem): Boolean { private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_edit_cover -> { R.id.action_edit_cover -> handleChangeCover()
changeSelectedCover()
destroyActionModeIfNeeded()
}
R.id.action_move_to_category -> showChangeMangaCategoriesDialog() R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
R.id.action_delete -> showDeleteMangaDialog() R.id.action_delete -> showDeleteMangaDialog()
R.id.action_select_all -> selectAllCategoryManga() R.id.action_select_all -> selectAllCategoryManga()
@ -486,6 +484,23 @@ class LibraryController(
} }
} }
private fun handleChangeCover() {
val manga = selectedMangas.firstOrNull() ?: return
if (coverCache.getCustomCoverFile(manga).exists()) {
showEditCoverDialog(manga)
} else {
openMangaCoverPicker(manga)
}
}
/**
* Edit custom cover for selected manga.
*/
private fun showEditCoverDialog(manga: Manga) {
ChangeMangaCoverDialog(this, manga).showDialog(router)
}
/** /**
* Move the selected manga to a list of categories. * Move the selected manga to a list of categories.
*/ */
@ -509,21 +524,7 @@ class LibraryController(
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router) DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router)
} }
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) { override fun openMangaCoverPicker(manga: Manga) {
presenter.moveMangasToCategories(categories, mangas)
destroyActionModeIfNeeded()
}
override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
presenter.removeMangaFromLibrary(mangas, deleteChapters)
destroyActionModeIfNeeded()
}
/**
* Changes the cover for the selected manga.
*/
private fun changeSelectedCover() {
val manga = selectedMangas.firstOrNull() ?: return
selectedCoverManga = manga selectedCoverManga = manga
if (manga.favorite) { if (manga.favorite) {
@ -539,6 +540,23 @@ class LibraryController(
} else { } else {
activity?.toast(R.string.notification_first_add_to_library) activity?.toast(R.string.notification_first_add_to_library)
} }
destroyActionModeIfNeeded()
}
override fun deleteMangaCover(manga: Manga) {
presenter.deleteCustomCover(manga)
destroyActionModeIfNeeded()
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
presenter.moveMangasToCategories(categories, mangas)
destroyActionModeIfNeeded()
}
override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
presenter.removeMangaFromLibrary(mangas, deleteChapters)
destroyActionModeIfNeeded()
} }
private fun selectAllCategoryManga() { private fun selectAllCategoryManga() {
@ -555,28 +573,25 @@ class LibraryController(
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_IMAGE_OPEN) { if (requestCode == REQUEST_IMAGE_OPEN) {
if (data == null || resultCode != Activity.RESULT_OK) return val dataUri = data?.data
if (dataUri == null || resultCode != Activity.RESULT_OK) return
val activity = activity ?: return val activity = activity ?: return
val manga = selectedCoverManga ?: return val manga = selectedCoverManga ?: return
try {
// Get the file's input stream from the incoming Intent
activity.contentResolver.openInputStream(data.data ?: Uri.EMPTY).use {
// Update cover to selected file, show error if something went wrong
if (it != null && presenter.editCoverWithStream(it, manga)) {
// TODO refresh cover
} else {
activity.toast(R.string.notification_cover_update_failed)
}
}
} catch (error: IOException) {
activity.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
selectedCoverManga = null selectedCoverManga = null
presenter.editCover(manga, activity, dataUri)
} }
} }
fun onSetCoverSuccess() {
activity?.toast(R.string.cover_updated)
}
fun onSetCoverError(error: Throwable) {
activity?.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
private companion object { private companion object {
/** /**
* Key to change the cover of a manga in [onActivityResult]. * Key to change the cover of a manga in [onActivityResult].

View File

@ -5,7 +5,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.view.visibleIf import eu.kanade.tachiyomi.util.view.visibleIf
import kotlinx.android.synthetic.main.source_grid_item.download_text import kotlinx.android.synthetic.main.source_grid_item.download_text
import kotlinx.android.synthetic.main.source_grid_item.local_text import kotlinx.android.synthetic.main.source_grid_item.local_text
@ -48,7 +48,7 @@ class LibraryGridHolder(
text = item.downloadCount.toString() text = item.downloadCount.toString()
} }
// set local visibility if its local manga // set local visibility if its local manga
local_text.visibleIf { item.manga.source == LocalSource.ID } local_text.visibleIf { item.manga.isLocal() }
// Update the cover. // Update the cover.
GlideApp.with(view.context).clear(thumbnail) GlideApp.with(view.context).clear(thumbnail)

View File

@ -5,7 +5,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.view.visibleIf import eu.kanade.tachiyomi.util.view.visibleIf
import kotlinx.android.synthetic.main.source_list_item.download_text import kotlinx.android.synthetic.main.source_list_item.download_text
import kotlinx.android.synthetic.main.source_list_item.local_text import kotlinx.android.synthetic.main.source_list_item.local_text
@ -49,7 +49,7 @@ class LibraryListHolder(
text = "${item.downloadCount}" text = "${item.downloadCount}"
} }
// show local text badge if local manga // show local text badge if local manga
local_text.visibleIf { item.manga.source == LocalSource.ID } local_text.visibleIf { item.manga.isLocal() }
// Create thumbnail onclick to simulate long click // Create thumbnail onclick to simulate long click
thumbnail.setOnClickListener { thumbnail.setOnClickListener {

View File

@ -1,5 +1,7 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
@ -14,11 +16,12 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.combineLatest import eu.kanade.tachiyomi.util.lang.combineLatest
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import java.io.IOException import eu.kanade.tachiyomi.util.removeCovers
import java.io.InputStream import eu.kanade.tachiyomi.util.updateCoverLastModified
import java.util.ArrayList import java.util.ArrayList
import java.util.Collections import java.util.Collections
import java.util.Comparator import java.util.Comparator
@ -128,7 +131,7 @@ class LibraryPresenter(
// Filter when there are no downloads. // Filter when there are no downloads.
if (filterDownloaded) { if (filterDownloaded) {
// Local manga are always downloaded // Local manga are always downloaded
if (item.manga.source == LocalSource.ID) { if (item.manga.isLocal()) {
return@f true return@f true
} }
// Don't bother with directory checking if download count has been set. // Don't bother with directory checking if download count has been set.
@ -318,16 +321,17 @@ class LibraryPresenter(
* @param deleteChapters whether to also delete downloaded chapters. * @param deleteChapters whether to also delete downloaded chapters.
*/ */
fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) { fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
// Create a set of the list
val mangaToDelete = mangas.distinctBy { it.id }
mangaToDelete.forEach { it.favorite = false }
launchIO { launchIO {
val mangaToDelete = mangas.distinctBy { it.id }
mangaToDelete.forEach {
it.favorite = false
it.removeCovers(coverCache)
}
db.insertMangas(mangaToDelete).executeAsBlocking() db.insertMangas(mangaToDelete).executeAsBlocking()
mangaToDelete.forEach { manga ->
coverCache.deleteFromCache(manga.thumbnail_url)
if (deleteChapters) { if (deleteChapters) {
mangaToDelete.forEach { manga ->
val source = sourceManager.get(manga.source) as? HttpSource val source = sourceManager.get(manga.source) as? HttpSource
if (source != null) { if (source != null) {
downloadManager.deleteManga(manga, source) downloadManager.deleteManga(manga, source)
@ -358,21 +362,42 @@ class LibraryPresenter(
/** /**
* Update cover with local file. * Update cover with local file.
* *
* @param inputStream the new cover.
* @param manga the manga edited. * @param manga the manga edited.
* @return true if the cover is updated, false otherwise * @param context Context.
* @param data uri of the cover resource.
*/ */
@Throws(IOException::class) fun editCover(manga: Manga, context: Context, data: Uri) {
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { Observable
if (manga.source == LocalSource.ID) { .fromCallable {
LocalSource.updateCover(context, manga, inputStream) context.contentResolver.openInputStream(data)?.use {
return true if (manga.isLocal()) {
LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db)
} else if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db)
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
} }
if (manga.thumbnail_url != null && manga.favorite) { fun deleteCustomCover(manga: Manga) {
coverCache.copyToCache(manga.thumbnail_url!!, inputStream) Observable
return true .fromCallable {
coverCache.deleteCustomCover(manga)
manga.updateCoverLastModified(db)
} }
return false .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
} }
} }

View File

@ -9,10 +9,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import java.util.Date import java.util.Date
import rx.Observable import rx.Observable
@ -189,7 +189,7 @@ class ChaptersPresenter(
observable = observable.filter { it.read } observable = observable.filter { it.read }
} }
if (onlyDownloaded()) { if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } observable = observable.filter { it.isDownloaded || it.manga.isLocal() }
} }
if (onlyBookmarked()) { if (onlyBookmarked()) {
observable = observable.filter { it.bookmark } observable = observable.filter { it.bookmark }

View File

@ -67,8 +67,6 @@ class MangaInfoController(private val fromSource: Boolean = false) :
private var initialLoad: Boolean = true private var initialLoad: Boolean = true
private var thumbnailUrl: String? = null
override fun createPresenter(): MangaInfoPresenter { override fun createPresenter(): MangaInfoPresenter {
val ctrl = parentController as MangaController val ctrl = parentController as MangaController
return MangaInfoPresenter( return MangaInfoPresenter(
@ -113,7 +111,7 @@ class MangaInfoController(private val fromSource: Boolean = false) :
// Set SwipeRefresh to refresh manga data. // Set SwipeRefresh to refresh manga data.
binding.swipeRefresh.refreshes() binding.swipeRefresh.refreshes()
.onEach { fetchMangaFromSource() } .onEach { fetchMangaFromSource(manualFetch = true) }
.launchIn(scope) .launchIn(scope)
binding.mangaFullTitle.longClicks() binding.mangaFullTitle.longClicks()
@ -241,8 +239,6 @@ class MangaInfoController(private val fromSource: Boolean = false) :
setFavoriteButtonState(manga.favorite) setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already. // Set cover if it wasn't already.
if (binding.mangaCover.drawable == null || manga.thumbnail_url != thumbnailUrl) {
thumbnailUrl = manga.thumbnail_url
val mangaThumbnail = manga.toMangaThumbnail() val mangaThumbnail = manga.toMangaThumbnail()
GlideApp.with(view.context) GlideApp.with(view.context)
@ -251,13 +247,12 @@ class MangaInfoController(private val fromSource: Boolean = false) :
.centerCrop() .centerCrop()
.into(binding.mangaCover) .into(binding.mangaCover)
if (binding.backdrop != null) { binding.backdrop?.let {
GlideApp.with(view.context) GlideApp.with(view.context)
.load(mangaThumbnail) .load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop() .centerCrop()
.into(binding.backdrop!!) .into(it)
}
} }
// Manga info section // Manga info section
@ -422,10 +417,10 @@ class MangaInfoController(private val fromSource: Boolean = false) :
/** /**
* Start fetching manga information from source. * Start fetching manga information from source.
*/ */
private fun fetchMangaFromSource() { private fun fetchMangaFromSource(manualFetch: Boolean = false) {
setRefreshing(true) setRefreshing(true)
// Call presenter and start fetching manga information // Call presenter and start fetching manga information
presenter.fetchMangaFromSource() presenter.fetchMangaFromSource(manualFetch)
} }
/** /**

View File

@ -12,6 +12,8 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import java.util.Date import java.util.Date
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
@ -36,11 +38,6 @@ class MangaInfoPresenter(
private val coverCache: CoverCache = Injekt.get() private val coverCache: CoverCache = Injekt.get()
) : BasePresenter<MangaInfoController>() { ) : BasePresenter<MangaInfoController>() {
/**
* Subscription to send the manga to the view.
*/
private var viewMangaSubscription: Subscription? = null
/** /**
* Subscription to update the manga from the source. * Subscription to update the manga from the source.
*/ */
@ -48,7 +45,9 @@ class MangaInfoPresenter(
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
sendMangaToView()
getMangaObservable()
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
// Update chapter count // Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
@ -64,22 +63,21 @@ class MangaInfoPresenter(
.subscribeLatestCache(MangaInfoController::setLastUpdateDate) .subscribeLatestCache(MangaInfoController::setLastUpdateDate)
} }
/** private fun getMangaObservable(): Observable<Manga> {
* Sends the active manga to the view. return db.getManga(manga.url, manga.source).asRxObservable()
*/ .observeOn(AndroidSchedulers.mainThread())
fun sendMangaToView() {
viewMangaSubscription?.let { remove(it) }
viewMangaSubscription = Observable.just(manga)
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
} }
/** /**
* Fetch manga information from source. * Fetch manga information from source.
*/ */
fun fetchMangaFromSource() { fun fetchMangaFromSource(manualFetch: Boolean = false) {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga -> .map { networkManga ->
if (manualFetch || manga.thumbnail_url != networkManga.thumbnail_url) {
manga.prepUpdateCover(coverCache)
}
manga.copyFrom(networkManga) manga.copyFrom(networkManga)
manga.initialized = true manga.initialized = true
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
@ -87,7 +85,6 @@ class MangaInfoPresenter(
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { sendMangaToView() }
.subscribeFirst( .subscribeFirst(
{ view, _ -> { view, _ ->
view.onFetchMangaDone() view.onFetchMangaDone()
@ -104,10 +101,9 @@ class MangaInfoPresenter(
fun toggleFavorite(): Boolean { fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite manga.favorite = !manga.favorite
if (!manga.favorite) { if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url) manga.removeCovers(coverCache)
} }
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
sendMangaToView()
return manga.favorite return manga.favorite
} }

View File

@ -21,11 +21,13 @@ import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.byteSize import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.takeBytes import eu.kanade.tachiyomi.util.lang.takeBytes
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.updateCoverLastModified
import java.io.File import java.io.File
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -565,15 +567,16 @@ class ReaderPresenter(
Observable Observable
.fromCallable { .fromCallable {
if (manga.source == LocalSource.ID) { if (manga.isLocal()) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream()) LocalSource.updateCover(context, manga, stream())
manga.updateCoverLastModified(db)
R.string.cover_updated R.string.cover_updated
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) { if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream()) coverCache.setCustomCoverToCache(manga, stream())
manga.updateCoverLastModified(db)
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
SetAsCoverResult.AddToLibraryFirst SetAsCoverResult.AddToLibraryFirst

View File

@ -64,12 +64,10 @@ class HistoryHolder(
// Set cover // Set cover
GlideApp.with(itemView.context).clear(cover) GlideApp.with(itemView.context).clear(cover)
if (!manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context) GlideApp.with(itemView.context)
.load(manga.toMangaThumbnail()) .load(manga.toMangaThumbnail())
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop() .centerCrop()
.into(cover) .into(cover)
} }
}
} }

View File

@ -56,13 +56,11 @@ class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter)
// Set cover // Set cover
GlideApp.with(itemView.context).clear(manga_cover) GlideApp.with(itemView.context).clear(manga_cover)
if (!item.manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context) GlideApp.with(itemView.context)
.load(item.manga.toMangaThumbnail()) .load(item.manga.toMangaThumbnail())
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.circleCrop() .circleCrop()
.into(manga_cover) .into(manga_cover)
}
// Check if chapter is read and set correct color // Check if chapter is read and set correct color
if (item.chapter.read) { if (item.chapter.read) {

View File

@ -76,6 +76,11 @@ class SettingsAdvancedController : SettingsController() {
ctrl.showDialog(router) ctrl.showDialog(router)
} }
} }
preference {
titleRes = R.string.pref_refresh_library_covers
onClick { LibraryUpdateService.start(context, target = Target.COVERS) }
}
preference { preference {
titleRes = R.string.pref_refresh_library_tracking titleRes = R.string.pref_refresh_library_tracking
summaryRes = R.string.pref_refresh_library_tracking_summary summaryRes = R.string.pref_refresh_library_tracking_summary

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.util
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.LocalSource
import java.util.Date
fun Manga.isLocal() = source == LocalSource.ID
/**
* Call before updating [Manga.thumbnail_url] to ensure old cover can be cleared from cache
*/
fun Manga.prepUpdateCover(coverCache: CoverCache) {
cover_last_modified = Date().time
if (!isLocal()) {
coverCache.deleteFromCache(this, false)
}
}
fun Manga.removeCovers(coverCache: CoverCache) {
if (isLocal()) return
cover_last_modified = Date().time
coverCache.deleteFromCache(this, true)
}
fun Manga.updateCoverLastModified(db: DatabaseHelper) {
cover_last_modified = Date().time
db.updateMangaCoverLastModified(this).executeAsBlocking()
}

View File

@ -354,6 +354,7 @@
<string name="pref_clear_database_summary">Delete manga and chapters that are not in your library</string> <string name="pref_clear_database_summary">Delete manga and chapters that are not in your library</string>
<string name="clear_database_confirmation">Are you sure? Read chapters and progress of non-library manga will be lost</string> <string name="clear_database_confirmation">Are you sure? Read chapters and progress of non-library manga will be lost</string>
<string name="clear_database_completed">Entries deleted</string> <string name="clear_database_completed">Entries deleted</string>
<string name="pref_refresh_library_covers">Refresh library manga covers</string>
<string name="pref_refresh_library_tracking">Refresh tracking</string> <string name="pref_refresh_library_tracking">Refresh tracking</string>
<string name="pref_refresh_library_tracking_summary">Updates status, score and last chapter read from the tracking services</string> <string name="pref_refresh_library_tracking_summary">Updates status, score and last chapter read from the tracking services</string>
<string name="pref_disable_battery_optimization">Disable battery optimization</string> <string name="pref_disable_battery_optimization">Disable battery optimization</string>