mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Improve handling of downloads for chapters with same metadata and optionally for OSes that don't support Unicode in filename (#2305)
Co-authored-by: jkim <jhskim@hotmail.com> Co-authored-by: fatotak <111342761+fatotak@users.noreply.github.com> Co-authored-by: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -11,13 +11,17 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co | ||||
| - `Other` - for technical stuff. | ||||
|  | ||||
| ## [Unreleased] | ||||
| ### Added | ||||
| - Advanced setting to limit download filenames to ASCII characters. This is provided only as a workaround for OSes that do not properly handle standard Unicode filenames. This setting is generally not recommended and should only be used as a last resort ([@raxod502](https://github.com/radian-software)) ([#2305](https://github.com/mihonapp/mihon/pull/2305)) | ||||
|  | ||||
| ### Changed | ||||
| - Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476)) | ||||
|  | ||||
| ### Improved | ||||
| - Spoofing of `X-Requested-With` header to support newer WebView versions ([@Guzmazow](https://github.com/Guzmazow)) ([#2491](https://github.com/mihonapp/mihon/pull/2491)) | ||||
| - Download support for chapters with the same metadata. Now a hash based on chapter's url is appended to download filename to tell them apart, letting you download both. Existing downloaded chapters will continue to work normally ([@raxod502](https://github.com/radian-software)) ([#2305](https://github.com/mihonapp/mihon/pull/2305)) | ||||
|  | ||||
| ### Fixes | ||||
| ### Fixed | ||||
| - Fix height of description not being calculated correctly if images are present ([@Secozzi](https://github.com/Secozzi)) ([#2382](https://github.com/mihonapp/mihon/pull/2382)) | ||||
| - Fix migration progress not updating after manual search ([@Secozzi](https://github.com/Secozzi)) ([#2484](https://github.com/mihonapp/mihon/pull/2484)) | ||||
| - Fix category migration flag being ignored due to incorrect check against chapter flag ([@Secozzi](https://github.com/Secozzi)) ([#2484](https://github.com/mihonapp/mihon/pull/2484)) | ||||
| @@ -30,7 +34,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co | ||||
| ### Removed | ||||
| - Predictive back support ([@AntsyLich](https://github.com/AntsyLich)) ([#2362](https://github.com/mihonapp/mihon/pull/2362)) | ||||
|  | ||||
| ### Fixes | ||||
| ### Fixed | ||||
| - Fix scrollbar sometimes not showing during scroll or not reaching the bottom with few items ([@anirudhsnayak](https://github.com/anirudhsnayak)) ([#2304](https://github.com/mihonapp/mihon/pull/2304)) | ||||
| - Fix local source EPUB files not loading ([@AntsyLich](https://github.com/AntsyLich)) ([#2369](https://github.com/mihonapp/mihon/pull/2369)) | ||||
| - Fix title text color in light mode on mass migration list ([@AntsyLich](https://github.com/AntsyLich)) ([#2370](https://github.com/mihonapp/mihon/pull/2370)) | ||||
| @@ -76,7 +80,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co | ||||
| - Make local source default chapter sorting match file explorer behavior ([@AntsyLich](https://github.com/AntsyLich)) ([#2224](https://github.com/mihonapp/mihon/pull/224)) | ||||
| - Include Manga `initialized` status in backup ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2285](https://github.com/mihonapp/mihon/pull/2285)) | ||||
|  | ||||
| ### Fixes | ||||
| ### Fixed | ||||
| - Fix Bangumi search results including novels ([@MajorTanya](https://github.com/MajorTanya)) ([#1885](https://github.com/mihonapp/mihon/pull/1885)) | ||||
| - Fix next chapter button occasionally jumping to the last page of the current chapter ([@perokhe](https://github.com/perokhe)) ([#1920](https://github.com/mihonapp/mihon/pull/1920)) | ||||
|   - Fix page number not appearing when opening chapter ([@perokhe](https://github.com/perokhe)) ([#1936](https://github.com/mihonapp/mihon/pull/1936)) | ||||
|   | ||||
| @@ -114,6 +114,7 @@ class SyncChaptersWithSource( | ||||
|                         downloadManager.isChapterDownloaded( | ||||
|                             dbChapter.name, | ||||
|                             dbChapter.scanlator, | ||||
|                             dbChapter.url, | ||||
|                             manga.title, | ||||
|                             manga.source, | ||||
|                         ) | ||||
| @@ -121,12 +122,14 @@ class SyncChaptersWithSource( | ||||
|                     if (shouldRenameChapter) { | ||||
|                         downloadManager.renameChapter(source, manga, dbChapter, chapter) | ||||
|                     } | ||||
|  | ||||
|                     var toChangeChapter = dbChapter.copy( | ||||
|                         name = chapter.name, | ||||
|                         chapterNumber = chapter.chapterNumber, | ||||
|                         scanlator = chapter.scanlator, | ||||
|                         sourceOrder = chapter.sourceOrder, | ||||
|                     ) | ||||
|  | ||||
|                     if (chapter.dateUpload != 0L) { | ||||
|                         toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload) | ||||
|                     } | ||||
|   | ||||
| @@ -26,6 +26,7 @@ fun List<Chapter>.applyFilters(manga: Manga, downloadManager: DownloadManager): | ||||
|                 val downloaded = downloadManager.isChapterDownloaded( | ||||
|                     chapter.name, | ||||
|                     chapter.scanlator, | ||||
|                     chapter.url, | ||||
|                     manga.title, | ||||
|                     manga.source, | ||||
|                 ) | ||||
|   | ||||
| @@ -323,6 +323,11 @@ object SettingsAdvancedScreen : SearchableSettings { | ||||
|                     title = stringResource(MR.strings.pref_update_library_manga_titles), | ||||
|                     subtitle = stringResource(MR.strings.pref_update_library_manga_titles_summary), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     preference = libraryPreferences.disallowNonAsciiFilenames(), | ||||
|                     title = stringResource(MR.strings.pref_disallow_non_ascii_filenames), | ||||
|                     subtitle = stringResource(MR.strings.pref_disallow_non_ascii_filenames_details), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -128,6 +128,7 @@ class DownloadCache( | ||||
|      * | ||||
|      * @param chapterName the name of the chapter to query. | ||||
|      * @param chapterScanlator scanlator of the chapter to query | ||||
|      * @param chapterUrl the url of the chapter to query | ||||
|      * @param mangaTitle the title of the manga to query. | ||||
|      * @param sourceId the id of the source of the chapter. | ||||
|      * @param skipCache whether to skip the directory cache and check in the filesystem. | ||||
| @@ -135,13 +136,14 @@ class DownloadCache( | ||||
|     fun isChapterDownloaded( | ||||
|         chapterName: String, | ||||
|         chapterScanlator: String?, | ||||
|         chapterUrl: String, | ||||
|         mangaTitle: String, | ||||
|         sourceId: Long, | ||||
|         skipCache: Boolean, | ||||
|     ): Boolean { | ||||
|         if (skipCache) { | ||||
|             val source = sourceManager.getOrStub(sourceId) | ||||
|             return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null | ||||
|             return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null | ||||
|         } | ||||
|  | ||||
|         renewCache() | ||||
| @@ -153,6 +155,7 @@ class DownloadCache( | ||||
|                 return provider.getValidChapterDirNames( | ||||
|                     chapterName, | ||||
|                     chapterScanlator, | ||||
|                     chapterUrl, | ||||
|                 ).any { it in mangaDir.chapterDirs } | ||||
|             } | ||||
|         } | ||||
| @@ -233,7 +236,7 @@ class DownloadCache( | ||||
|         rootDownloadsDirMutex.withLock { | ||||
|             val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return | ||||
|             val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return | ||||
|             provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach { | ||||
|             provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { | ||||
|                 if (it in mangaDir.chapterDirs) { | ||||
|                     mangaDir.chapterDirs -= it | ||||
|                 } | ||||
| @@ -254,7 +257,7 @@ class DownloadCache( | ||||
|             val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return | ||||
|             val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return | ||||
|             chapters.forEach { chapter -> | ||||
|                 provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach { | ||||
|                 provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { | ||||
|                     if (it in mangaDir.chapterDirs) { | ||||
|                         mangaDir.chapterDirs -= it | ||||
|                     } | ||||
|   | ||||
| @@ -159,7 +159,7 @@ class DownloadManager( | ||||
|      * @return the list of pages from the chapter. | ||||
|      */ | ||||
|     fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> { | ||||
|         val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, manga.title, source) | ||||
|         val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source) | ||||
|         val files = chapterDir?.listFiles().orEmpty() | ||||
|             .filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } } | ||||
|  | ||||
| @@ -185,11 +185,12 @@ class DownloadManager( | ||||
|     fun isChapterDownloaded( | ||||
|         chapterName: String, | ||||
|         chapterScanlator: String?, | ||||
|         chapterUrl: String, | ||||
|         mangaTitle: String, | ||||
|         sourceId: Long, | ||||
|         skipCache: Boolean = false, | ||||
|     ): Boolean { | ||||
|         return cache.isChapterDownloaded(chapterName, chapterScanlator, mangaTitle, sourceId, skipCache) | ||||
|         return cache.isChapterDownloaded(chapterName, chapterScanlator, chapterUrl, mangaTitle, sourceId, skipCache) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -368,7 +369,7 @@ class DownloadManager( | ||||
|      * @param newChapter the target chapter with the new name. | ||||
|      */ | ||||
|     suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) { | ||||
|         val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator) | ||||
|         val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url) | ||||
|         val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e -> | ||||
|             logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" } | ||||
|             return | ||||
| @@ -379,7 +380,7 @@ class DownloadManager( | ||||
|             .mapNotNull { mangaDir.findFile(it) } | ||||
|             .firstOrNull() ?: return | ||||
|  | ||||
|         var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator) | ||||
|         var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) | ||||
|         if (oldDownload.isFile && oldDownload.extension == "cbz") { | ||||
|             newName += ".cbz" | ||||
|         } | ||||
|   | ||||
| @@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.data.download | ||||
| import android.content.Context | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.util.lang.Hash.md5 | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.common.i18n.stringResource | ||||
| import tachiyomi.core.common.storage.displayablePath | ||||
| import tachiyomi.core.common.util.system.logcat | ||||
| import tachiyomi.domain.chapter.model.Chapter | ||||
| import tachiyomi.domain.library.service.LibraryPreferences | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.storage.service.StorageManager | ||||
| import tachiyomi.i18n.MR | ||||
| @@ -25,6 +27,7 @@ import java.io.IOException | ||||
| class DownloadProvider( | ||||
|     private val context: Context, | ||||
|     private val storageManager: StorageManager = Injekt.get(), | ||||
|     private val libraryPreferences: LibraryPreferences = Injekt.get(), | ||||
| ) { | ||||
|  | ||||
|     private val downloadsDir: UniFile? | ||||
| @@ -96,9 +99,15 @@ class DownloadProvider( | ||||
|      * @param mangaTitle the title of the manga to query. | ||||
|      * @param source the source of the chapter. | ||||
|      */ | ||||
|     fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? { | ||||
|     fun findChapterDir( | ||||
|         chapterName: String, | ||||
|         chapterScanlator: String?, | ||||
|         chapterUrl: String, | ||||
|         mangaTitle: String, | ||||
|         source: Source, | ||||
|     ): UniFile? { | ||||
|         val mangaDir = findMangaDir(mangaTitle, source) | ||||
|         return getValidChapterDirNames(chapterName, chapterScanlator).asSequence() | ||||
|         return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence() | ||||
|             .mapNotNull { mangaDir?.findFile(it) } | ||||
|             .firstOrNull() | ||||
|     } | ||||
| @@ -113,7 +122,7 @@ class DownloadProvider( | ||||
|     fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> { | ||||
|         val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() | ||||
|         return mangaDir to chapters.mapNotNull { chapter -> | ||||
|             getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() | ||||
|             getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence() | ||||
|                 .mapNotNull { mangaDir.findFile(it) } | ||||
|                 .firstOrNull() | ||||
|         } | ||||
| @@ -125,7 +134,10 @@ class DownloadProvider( | ||||
|      * @param source the source to query. | ||||
|      */ | ||||
|     fun getSourceDirName(source: Source): String { | ||||
|         return DiskUtil.buildValidFilename(source.toString()) | ||||
|         return DiskUtil.buildValidFilename( | ||||
|             source.toString(), | ||||
|             disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -134,23 +146,75 @@ class DownloadProvider( | ||||
|      * @param mangaTitle the title of the manga to query. | ||||
|      */ | ||||
|     fun getMangaDirName(mangaTitle: String): String { | ||||
|         return DiskUtil.buildValidFilename(mangaTitle) | ||||
|         return DiskUtil.buildValidFilename( | ||||
|             mangaTitle, | ||||
|             disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the chapter directory name for a chapter. | ||||
|      * | ||||
|      * @param chapterName the name of the chapter to query. | ||||
|      * @param chapterScanlator scanlator of the chapter to query | ||||
|      * @param chapterScanlator scanlator of the chapter to query. | ||||
|      * @param chapterUrl url of the chapter to query. | ||||
|      */ | ||||
|     fun getChapterDirName(chapterName: String, chapterScanlator: String?): String { | ||||
|         val newChapterName = sanitizeChapterName(chapterName) | ||||
|         return DiskUtil.buildValidFilename( | ||||
|     fun getChapterDirName( | ||||
|         chapterName: String, | ||||
|         chapterScanlator: String?, | ||||
|         chapterUrl: String, | ||||
|         disallowNonAsciiFilenames: Boolean = libraryPreferences.disallowNonAsciiFilenames().get(), | ||||
|     ): String { | ||||
|         var dirName = sanitizeChapterName(chapterName) | ||||
|         if (!chapterScanlator.isNullOrBlank()) { | ||||
|             dirName = chapterScanlator + "_" + dirName | ||||
|         } | ||||
|         // Subtract 7 bytes for hash and underscore, 4 bytes for .cbz | ||||
|         dirName = DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAsciiFilenames) | ||||
|         dirName += "_" + md5(chapterUrl).take(6) | ||||
|         return dirName | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns list of names that might have been previously used as | ||||
|      * the directory name for a chapter. | ||||
|      * Add to this list if naming pattern ever changes. | ||||
|      * | ||||
|      * @param chapterName the name of the chapter to query. | ||||
|      * @param chapterScanlator scanlator of the chapter to query. | ||||
|      * @param chapterUrl url of the chapter to query. | ||||
|      */ | ||||
|     private fun getLegacyChapterDirNames( | ||||
|         chapterName: String, | ||||
|         chapterScanlator: String?, | ||||
|         chapterUrl: String, | ||||
|     ): List<String> { | ||||
|         val sanitizedChapterName = sanitizeChapterName(chapterName) | ||||
|         val chapterNameV1 = DiskUtil.buildValidFilename( | ||||
|             when { | ||||
|                 !chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$newChapterName" | ||||
|                 else -> newChapterName | ||||
|                 !chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$sanitizedChapterName" | ||||
|                 else -> sanitizedChapterName | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         // Get the filename that would be generated if the user were | ||||
|         // using the other value for the disallow non-ASCII | ||||
|         // filenames setting. This ensures that chapters downloaded | ||||
|         // before the user changed the setting can still be found. | ||||
|         val otherChapterDirName = | ||||
|             getChapterDirName( | ||||
|                 chapterName, | ||||
|                 chapterScanlator, | ||||
|                 chapterUrl, | ||||
|                 !libraryPreferences.disallowNonAsciiFilenames().get(), | ||||
|             ) | ||||
|  | ||||
|         return buildList(2) { | ||||
|             // Chapter name without hash (unable to handle duplicate | ||||
|             // chapter names) | ||||
|             add(chapterNameV1) | ||||
|             add(otherChapterDirName) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -165,24 +229,30 @@ class DownloadProvider( | ||||
|     } | ||||
|  | ||||
|     fun isChapterDirNameChanged(oldChapter: Chapter, newChapter: Chapter): Boolean { | ||||
|         return oldChapter.name != newChapter.name || | ||||
|             oldChapter.scanlator?.takeIf { it.isNotBlank() } != newChapter.scanlator?.takeIf { it.isNotBlank() } | ||||
|         return getChapterDirName(oldChapter.name, oldChapter.scanlator, oldChapter.url) != | ||||
|             getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns valid downloaded chapter directory names. | ||||
|      * | ||||
|      * @param chapterName the name of the chapter to query. | ||||
|      * @param chapterScanlator scanlator of the chapter to query | ||||
|      * @param chapter the domain chapter object. | ||||
|      */ | ||||
|     fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?): List<String> { | ||||
|         val chapterDirName = getChapterDirName(chapterName, chapterScanlator) | ||||
|         return buildList(2) { | ||||
|     fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?, chapterUrl: String): List<String> { | ||||
|         val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl) | ||||
|         val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl) | ||||
|  | ||||
|         return buildList { | ||||
|             // Folder of images | ||||
|             add(chapterDirName) | ||||
|  | ||||
|             // Archived chapters | ||||
|             add("$chapterDirName.cbz") | ||||
|  | ||||
|             // any legacy names | ||||
|             legacyChapterDirNames.forEach { | ||||
|                 add(it) | ||||
|                 add("$it.cbz") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -274,7 +274,7 @@ class Downloader( | ||||
|         val wasEmpty = queueState.value.isEmpty() | ||||
|         val chaptersToQueue = chapters.asSequence() | ||||
|             // Filter out those already downloaded. | ||||
|             .filter { provider.findChapterDir(it.name, it.scanlator, manga.title, source) == null } | ||||
|             .filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null } | ||||
|             // Add chapters to queue from the start. | ||||
|             .sortedByDescending { it.sourceOrder } | ||||
|             // Filter out those already enqueued. | ||||
| @@ -336,7 +336,11 @@ class Downloader( | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val chapterDirname = provider.getChapterDirName(download.chapter.name, download.chapter.scanlator) | ||||
|         val chapterDirname = provider.getChapterDirName( | ||||
|             download.chapter.name, | ||||
|             download.chapter.scanlator, | ||||
|             download.chapter.url, | ||||
|         ) | ||||
|         val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!! | ||||
|  | ||||
|         try { | ||||
|   | ||||
| @@ -484,6 +484,7 @@ class LibraryScreenModel( | ||||
|                             downloadManager.isChapterDownloaded( | ||||
|                                 chapter.name, | ||||
|                                 chapter.scanlator, | ||||
|                                 chapter.url, | ||||
|                                 manga.title, | ||||
|                                 manga.source, | ||||
|                             ) | ||||
|   | ||||
| @@ -527,7 +527,13 @@ class MangaScreenModel( | ||||
|             val downloaded = if (isLocal) { | ||||
|                 true | ||||
|             } else { | ||||
|                 downloadManager.isChapterDownloaded(chapter.name, chapter.scanlator, manga.title, manga.source) | ||||
|                 downloadManager.isChapterDownloaded( | ||||
|                     chapter.name, | ||||
|                     chapter.scanlator, | ||||
|                     chapter.url, | ||||
|                     manga.title, | ||||
|                     manga.source, | ||||
|                 ) | ||||
|             } | ||||
|             val downloadState = when { | ||||
|                 activeDownload != null -> activeDownload.status | ||||
|   | ||||
| @@ -38,7 +38,6 @@ import eu.kanade.tachiyomi.util.chapter.filterDownloaded | ||||
| import eu.kanade.tachiyomi.util.chapter.removeDuplicates | ||||
| import eu.kanade.tachiyomi.util.editCover | ||||
| import eu.kanade.tachiyomi.util.lang.byteSize | ||||
| import eu.kanade.tachiyomi.util.lang.takeBytes | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.cacheImageDir | ||||
| import kotlinx.coroutines.CancellationException | ||||
| @@ -175,6 +174,7 @@ class ReaderViewModel @JvmOverloads constructor( | ||||
|                                         !downloadManager.isChapterDownloaded( | ||||
|                                             it.name, | ||||
|                                             it.scanlator, | ||||
|                                             it.url, | ||||
|                                             manga.title, | ||||
|                                             manga.source, | ||||
|                                         ) | ||||
| @@ -184,6 +184,7 @@ class ReaderViewModel @JvmOverloads constructor( | ||||
|                                         downloadManager.isChapterDownloaded( | ||||
|                                             it.name, | ||||
|                                             it.scanlator, | ||||
|                                             it.url, | ||||
|                                             manga.title, | ||||
|                                             manga.source, | ||||
|                                         ) | ||||
| @@ -397,6 +398,7 @@ class ReaderViewModel @JvmOverloads constructor( | ||||
|             val isDownloaded = downloadManager.isChapterDownloaded( | ||||
|                 dbChapter.name, | ||||
|                 dbChapter.scanlator, | ||||
|                 dbChapter.url, | ||||
|                 manga.title, | ||||
|                 manga.source, | ||||
|                 skipCache = true, | ||||
| @@ -473,6 +475,7 @@ class ReaderViewModel @JvmOverloads constructor( | ||||
|             val isNextChapterDownloaded = downloadManager.isChapterDownloaded( | ||||
|                 nextChapter.name, | ||||
|                 nextChapter.scanlator, | ||||
|                 nextChapter.url, | ||||
|                 manga.title, | ||||
|                 manga.source, | ||||
|             ) | ||||
| @@ -757,7 +760,8 @@ class ReaderViewModel @JvmOverloads constructor( | ||||
|         val chapter = page.chapter.chapter | ||||
|         val filenameSuffix = " - ${page.number}" | ||||
|         return DiskUtil.buildValidFilename( | ||||
|             "${manga.title} - ${chapter.name}".takeBytes(DiskUtil.MAX_FILE_NAME_BYTES - filenameSuffix.byteSize()), | ||||
|             "${manga.title} - ${chapter.name}", | ||||
|             DiskUtil.MAX_FILE_NAME_BYTES - filenameSuffix.byteSize(), | ||||
|         ) + filenameSuffix | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -80,6 +80,7 @@ class ChapterLoader( | ||||
|         val isDownloaded = downloadManager.isChapterDownloaded( | ||||
|             dbChapter.name, | ||||
|             dbChapter.scanlator, | ||||
|             dbChapter.url, | ||||
|             manga.title, | ||||
|             manga.source, | ||||
|             skipCache = true, | ||||
|   | ||||
| @@ -33,7 +33,13 @@ internal class DownloadPageLoader( | ||||
|  | ||||
|     override suspend fun getPages(): List<ReaderPage> { | ||||
|         val dbChapter = chapter.chapter | ||||
|         val chapterPath = downloadProvider.findChapterDir(dbChapter.name, dbChapter.scanlator, manga.title, source) | ||||
|         val chapterPath = downloadProvider.findChapterDir( | ||||
|             dbChapter.name, | ||||
|             dbChapter.scanlator, | ||||
|             dbChapter.url, | ||||
|             manga.title, | ||||
|             source, | ||||
|         ) | ||||
|         return if (chapterPath?.isFile == true) { | ||||
|             getPagesFromArchive(chapterPath) | ||||
|         } else { | ||||
|   | ||||
| @@ -37,6 +37,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At | ||||
|                         downloadManager.isChapterDownloaded( | ||||
|                             chapterName = goingToChapter.name, | ||||
|                             chapterScanlator = goingToChapter.scanlator, | ||||
|                             chapterUrl = goingToChapter.url, | ||||
|                             mangaTitle = manga.title, | ||||
|                             sourceId = manga.source, | ||||
|                             skipCache = true, | ||||
|   | ||||
| @@ -107,6 +107,7 @@ class UpdatesScreenModel( | ||||
|                 val downloaded = downloadManager.isChapterDownloaded( | ||||
|                     update.chapterName, | ||||
|                     update.scanlator, | ||||
|                     update.chapterUrl, | ||||
|                     update.mangaTitle, | ||||
|                     update.sourceId, | ||||
|                 ) | ||||
|   | ||||
| @@ -15,5 +15,5 @@ fun List<Chapter>.filterDownloaded(manga: Manga): List<Chapter> { | ||||
|  | ||||
|     val downloadCache: DownloadCache = Injekt.get() | ||||
|  | ||||
|     return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source, false) } | ||||
|     return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, it.url, manga.title, manga.source, false) } | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,9 @@ import androidx.core.content.ContextCompat | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.util.lang.Hash | ||||
| import java.io.File | ||||
| import java.nio.ByteBuffer | ||||
| import java.nio.CharBuffer | ||||
| import java.nio.charset.CodingErrorAction | ||||
|  | ||||
| object DiskUtil { | ||||
|  | ||||
| @@ -102,26 +105,84 @@ object DiskUtil { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mutate the given filename to make it valid for a FAT filesystem, | ||||
|      * replacing any invalid characters with "_". This method doesn't allow hidden files (starting | ||||
|      * with a dot), but you can manually add it later. | ||||
|      * Transform a filename fragment to make it safe to use on almost | ||||
|      * all commonly used filesystems. You can pass an entire filename, | ||||
|      * or just part of one, in case you want a specific part of a long | ||||
|      * filename to be truncated, rather than the end of it. | ||||
|      * | ||||
|      * Characters that are potentially unsafe for some filesystems are | ||||
|      * replaced with underscores. This includes the standard ones from | ||||
|      * https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file | ||||
|      * but does allow any other valid Unicode code point. | ||||
|      * | ||||
|      * Excessively long filenames are truncated, by default to 240 | ||||
|      * bytes. Note that the truncation is based on bytes rather than | ||||
|      * characters (code points), because this is what is relevant to | ||||
|      * filesystem restrictions in most cases. | ||||
|      * | ||||
|      * Leading periods are stripped, to avoid the creation of hidden | ||||
|      * files by default. If a hidden file is desired, a period can be | ||||
|      * prepended to the return value from this function. | ||||
|      * | ||||
|      * If the optional argument disallowNonAscii is set to true, | ||||
|      * then ANYTHING outside the ASCII range is replaced not with underscores, | ||||
|      * but with its hexadecimal encoding. This is to make it so that distinct | ||||
|      * non-English titles of things remain distinct, since not all | ||||
|      * places where this function is used also take care of | ||||
|      * disambiguation. | ||||
|      * | ||||
|      * We could instead replace only non-ASCII characters known to | ||||
|      * be problematic, but so far nobody with a non-Unicode-compliant | ||||
|      * device has been able to provide either directions to reproduce | ||||
|      * their issue nor any documentation or tests that would allow us | ||||
|      * to determine which characters are problems and which are not. | ||||
|      */ | ||||
|     fun buildValidFilename(origName: String): String { | ||||
|     fun buildValidFilename( | ||||
|         origName: String, | ||||
|         maxBytes: Int = MAX_FILE_NAME_BYTES, | ||||
|         disallowNonAscii: Boolean = false, | ||||
|     ): String { | ||||
|         val name = origName.trim('.', ' ') | ||||
|         if (name.isEmpty()) { | ||||
|             return "(invalid)" | ||||
|         } | ||||
|         val sb = StringBuilder(name.length) | ||||
|         name.forEach { c -> | ||||
|             if (isValidFatFilenameChar(c)) { | ||||
|             if (disallowNonAscii && c >= 0x80.toChar()) { | ||||
|                 sb.append( | ||||
|                     c.toString().toByteArray(Charsets.UTF_8).toHexString( | ||||
|                         HexFormat { | ||||
|                             upperCase = false | ||||
|                         }, | ||||
|                     ), | ||||
|                 ) | ||||
|             } else if (isValidFatFilenameChar(c)) { | ||||
|                 sb.append(c) | ||||
|             } else { | ||||
|                 sb.append('_') | ||||
|             } | ||||
|         } | ||||
|         // Even though vfat allows 255 UCS-2 chars, we might eventually write to | ||||
|         // ext4 through a FUSE layer, so use that limit minus 15 reserved characters. | ||||
|         return sb.toString().take(240) | ||||
|         return truncateToLength(sb.toString(), maxBytes) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Truncate a string to a maximum length, while maintaining valid Unicode encoding. | ||||
|      */ | ||||
|     fun truncateToLength(s: String, maxBytes: Int): String { | ||||
|         val charset = Charsets.UTF_8 | ||||
|         val decoder = charset.newDecoder() | ||||
|         val sba = s.toByteArray(charset) | ||||
|         if (sba.size <= maxBytes) { | ||||
|             return s | ||||
|         } | ||||
|         // Ensure truncation by having byte buffer = maxBytes | ||||
|         val bb = ByteBuffer.wrap(sba, 0, maxBytes) | ||||
|         val cb = CharBuffer.allocate(maxBytes) | ||||
|         // Ignore an incomplete character | ||||
|         decoder.onMalformedInput(CodingErrorAction.IGNORE) | ||||
|         decoder.decode(bb, cb, true) | ||||
|         decoder.flush(cb) | ||||
|         return String(cb.array(), 0, cb.position()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -139,6 +200,8 @@ object DiskUtil { | ||||
|  | ||||
|     const val NOMEDIA_FILE = ".nomedia" | ||||
|  | ||||
|     // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) | ||||
|     const val MAX_FILE_NAME_BYTES = 250 | ||||
|     // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8). | ||||
|     // To allow for writing to ext4 through a FUSE layer in the future, also subtract 15 | ||||
|     // reserved characters. | ||||
|     const val MAX_FILE_NAME_BYTES = 240 | ||||
| } | ||||
|   | ||||
| @@ -52,6 +52,7 @@ class UpdatesRepositoryImpl( | ||||
|         chapterId: Long, | ||||
|         chapterName: String, | ||||
|         scanlator: String?, | ||||
|         chapterUrl: String, | ||||
|         read: Boolean, | ||||
|         bookmark: Boolean, | ||||
|         lastPageRead: Long, | ||||
| @@ -67,6 +68,7 @@ class UpdatesRepositoryImpl( | ||||
|         chapterId = chapterId, | ||||
|         chapterName = chapterName, | ||||
|         scanlator = scanlator, | ||||
|         chapterUrl = chapterUrl, | ||||
|         read = read, | ||||
|         bookmark = bookmark, | ||||
|         lastPageRead = lastPageRead, | ||||
|   | ||||
							
								
								
									
										24
									
								
								data/src/main/sqldelight/tachiyomi/migrations/7.sqm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								data/src/main/sqldelight/tachiyomi/migrations/7.sqm
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| -- Add chapter urls to updates view | ||||
| DROP VIEW IF EXISTS updatesView; | ||||
| CREATE VIEW updatesView AS | ||||
| SELECT | ||||
|     mangas._id AS mangaId, | ||||
|     mangas.title AS mangaTitle, | ||||
|     chapters._id AS chapterId, | ||||
|     chapters.name AS chapterName, | ||||
|     chapters.scanlator, | ||||
|     chapters.url AS chapterUrl, | ||||
|     chapters.read, | ||||
|     chapters.bookmark, | ||||
|     chapters.last_page_read, | ||||
|     mangas.source, | ||||
|     mangas.favorite, | ||||
|     mangas.thumbnail_url AS thumbnailUrl, | ||||
|     mangas.cover_last_modified AS coverLastModified, | ||||
|     chapters.date_upload AS dateUpload, | ||||
|     chapters.date_fetch AS datefetch | ||||
| FROM mangas JOIN chapters | ||||
| ON mangas._id = chapters.manga_id | ||||
| WHERE favorite = 1 | ||||
| AND date_fetch > date_added | ||||
| ORDER BY date_fetch DESC; | ||||
| @@ -5,6 +5,7 @@ SELECT | ||||
|     chapters._id AS chapterId, | ||||
|     chapters.name AS chapterName, | ||||
|     chapters.scanlator, | ||||
|     chapters.url AS chapterUrl, | ||||
|     chapters.read, | ||||
|     chapters.bookmark, | ||||
|     chapters.last_page_read, | ||||
| @@ -31,4 +32,4 @@ SELECT * | ||||
| FROM updatesView | ||||
| WHERE read = :read | ||||
| AND dateUpload > :after | ||||
| LIMIT :limit; | ||||
| LIMIT :limit; | ||||
|   | ||||
| @@ -192,6 +192,8 @@ class LibraryPreferences( | ||||
|  | ||||
|     fun updateMangaTitles() = preferenceStore.getBoolean("pref_update_library_manga_titles", false) | ||||
|  | ||||
|     fun disallowNonAsciiFilenames() = preferenceStore.getBoolean("disallow_non_ascii_filenames", false) | ||||
|  | ||||
|     // endregion | ||||
|  | ||||
|     enum class ChapterSwipeAction { | ||||
|   | ||||
| @@ -8,6 +8,7 @@ data class UpdatesWithRelations( | ||||
|     val chapterId: Long, | ||||
|     val chapterName: String, | ||||
|     val scanlator: String?, | ||||
|     val chapterUrl: String, | ||||
|     val read: Boolean, | ||||
|     val bookmark: Boolean, | ||||
|     val lastPageRead: Long, | ||||
|   | ||||
| @@ -320,6 +320,8 @@ | ||||
|     <string name="pref_mark_duplicate_read_chapter_read_new">After fetching new chapter</string> | ||||
|  | ||||
|     <string name="pref_hide_missing_chapter_indicators">Hide missing chapter indicators</string> | ||||
|     <string name="pref_disallow_non_ascii_filenames">Disallow non-ASCII filenames</string> | ||||
|     <string name="pref_disallow_non_ascii_filenames_details">Ensures compatibility with certain storage media that don't support Unicode. When this is enabled, you'll need to manually rename source and manga folders by replacing non-ASCII characters with their lowercase UTF-8 hexadecimal representations. Chapter files don't need to be renamed.</string> | ||||
|  | ||||
|       <!-- Extension section --> | ||||
|     <string name="multi_lang">Multi</string> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user