diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef659c5d..0bba98f6a 100644 --- a/CHANGELOG.md +++ b/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)) diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index 2a34e5994..20ecb2493 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/domain/chapter/model/ChapterFilter.kt b/app/src/main/java/eu/kanade/domain/chapter/model/ChapterFilter.kt index ad476418f..d95a23de8 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/model/ChapterFilter.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/model/ChapterFilter.kt @@ -26,6 +26,7 @@ fun List.applyFilters(manga: Manga, downloadManager: DownloadManager): val downloaded = downloadManager.isChapterDownloaded( chapter.name, chapter.scanlator, + chapter.url, manga.title, manga.source, ) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 14323f491..ef19d7398 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -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), + ), ), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 1e7d98326..920be6172 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 2e79061b5..2d7377488 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -159,7 +159,7 @@ class DownloadManager( * @return the list of pages from the chapter. */ fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List { - 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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index 0e290bf46..94e34ffeb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -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, manga: Manga, source: Source): Pair> { 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 { + 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 { - val chapterDirName = getChapterDirName(chapterName, chapterScanlator) - return buildList(2) { + fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?, chapterUrl: String): List { + 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") + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 820fb51ae..d9609d490 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index c5ac3131e..289d3be96 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -484,6 +484,7 @@ class LibraryScreenModel( downloadManager.isChapterDownloaded( chapter.name, chapter.scanlator, + chapter.url, manga.title, manga.source, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index fa7da81b4..ad180ea03 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index d6f33062a..31e99742e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 903938a47..761c212a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -80,6 +80,7 @@ class ChapterLoader( val isDownloaded = downloadManager.isChapterDownloaded( dbChapter.name, dbChapter.scanlator, + dbChapter.url, manga.title, manga.source, skipCache = true, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 59d7f21e5..026faac3e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -33,7 +33,13 @@ internal class DownloadPageLoader( override suspend fun getPages(): List { 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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt index 8918cdf58..e935157d4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt index c5385d1f0..d77786243 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt @@ -107,6 +107,7 @@ class UpdatesScreenModel( val downloaded = downloadManager.isChapterDownloaded( update.chapterName, update.scanlator, + update.chapterUrl, update.mangaTitle, update.sourceId, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterFilterDownloaded.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterFilterDownloaded.kt index 0f489f2f5..f33313c68 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterFilterDownloaded.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterFilterDownloaded.kt @@ -15,5 +15,5 @@ fun List.filterDownloaded(manga: Manga): List { 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) } } diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 6d5d2ffb6..b755b925b 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -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 } diff --git a/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt index 0af6bcd50..c7bbe7c8b 100644 --- a/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt @@ -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, diff --git a/data/src/main/sqldelight/tachiyomi/migrations/7.sqm b/data/src/main/sqldelight/tachiyomi/migrations/7.sqm new file mode 100644 index 000000000..766a6b67a --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/7.sqm @@ -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; diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index 5fdd3ea75..f18c55401 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -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; \ No newline at end of file +LIMIT :limit; diff --git a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt index 18b2aa260..a2e979fac 100644 --- a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt @@ -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 { diff --git a/domain/src/main/java/tachiyomi/domain/updates/model/UpdatesWithRelations.kt b/domain/src/main/java/tachiyomi/domain/updates/model/UpdatesWithRelations.kt index bc2bd2be4..4bdb441bf 100644 --- a/domain/src/main/java/tachiyomi/domain/updates/model/UpdatesWithRelations.kt +++ b/domain/src/main/java/tachiyomi/domain/updates/model/UpdatesWithRelations.kt @@ -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, diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 074cac6f0..000c517f6 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -320,6 +320,8 @@ After fetching new chapter Hide missing chapter indicators + Disallow non-ASCII filenames + 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. Multi