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:
Radon Rosborough
2025-10-07 16:07:09 -07:00
committed by GitHub
parent 1a31c7c7ee
commit 58b25d697f
23 changed files with 253 additions and 47 deletions

View File

@@ -11,13 +11,17 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [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 ### Changed
- Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476)) - Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476))
### Improved ### 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)) - 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 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 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)) - 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 ### Removed
- Predictive back support ([@AntsyLich](https://github.com/AntsyLich)) ([#2362](https://github.com/mihonapp/mihon/pull/2362)) - 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 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 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)) - 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)) - 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)) - 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 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 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)) - Fix page number not appearing when opening chapter ([@perokhe](https://github.com/perokhe)) ([#1936](https://github.com/mihonapp/mihon/pull/1936))

View File

@@ -114,6 +114,7 @@ class SyncChaptersWithSource(
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloaded(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -121,12 +122,14 @@ class SyncChaptersWithSource(
if (shouldRenameChapter) { if (shouldRenameChapter) {
downloadManager.renameChapter(source, manga, dbChapter, chapter) downloadManager.renameChapter(source, manga, dbChapter, chapter)
} }
var toChangeChapter = dbChapter.copy( var toChangeChapter = dbChapter.copy(
name = chapter.name, name = chapter.name,
chapterNumber = chapter.chapterNumber, chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator, scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder, sourceOrder = chapter.sourceOrder,
) )
if (chapter.dateUpload != 0L) { if (chapter.dateUpload != 0L) {
toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload) toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload)
} }

View File

@@ -26,6 +26,7 @@ fun List<Chapter>.applyFilters(manga: Manga, downloadManager: DownloadManager):
val downloaded = downloadManager.isChapterDownloaded( val downloaded = downloadManager.isChapterDownloaded(
chapter.name, chapter.name,
chapter.scanlator, chapter.scanlator,
chapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )

View File

@@ -323,6 +323,11 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(MR.strings.pref_update_library_manga_titles), title = stringResource(MR.strings.pref_update_library_manga_titles),
subtitle = stringResource(MR.strings.pref_update_library_manga_titles_summary), 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),
),
), ),
) )
} }

View File

@@ -128,6 +128,7 @@ class DownloadCache(
* *
* @param chapterName the name of the chapter to query. * @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 the url of the chapter to query
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param sourceId the id of the source of the chapter. * @param sourceId the id of the source of the chapter.
* @param skipCache whether to skip the directory cache and check in the filesystem. * @param skipCache whether to skip the directory cache and check in the filesystem.
@@ -135,13 +136,14 @@ class DownloadCache(
fun isChapterDownloaded( fun isChapterDownloaded(
chapterName: String, chapterName: String,
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String, mangaTitle: String,
sourceId: Long, sourceId: Long,
skipCache: Boolean, skipCache: Boolean,
): Boolean { ): Boolean {
if (skipCache) { if (skipCache) {
val source = sourceManager.getOrStub(sourceId) val source = sourceManager.getOrStub(sourceId)
return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null
} }
renewCache() renewCache()
@@ -153,6 +155,7 @@ class DownloadCache(
return provider.getValidChapterDirNames( return provider.getValidChapterDirNames(
chapterName, chapterName,
chapterScanlator, chapterScanlator,
chapterUrl,
).any { it in mangaDir.chapterDirs } ).any { it in mangaDir.chapterDirs }
} }
} }
@@ -233,7 +236,7 @@ class DownloadCache(
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: 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) { if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it mangaDir.chapterDirs -= it
} }
@@ -254,7 +257,7 @@ class DownloadCache(
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
chapters.forEach { chapter -> chapters.forEach { chapter ->
provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach { provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
if (it in mangaDir.chapterDirs) { if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it mangaDir.chapterDirs -= it
} }

View File

@@ -159,7 +159,7 @@ class DownloadManager(
* @return the list of pages from the chapter. * @return the list of pages from the chapter.
*/ */
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> { 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() val files = chapterDir?.listFiles().orEmpty()
.filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } } .filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } }
@@ -185,11 +185,12 @@ class DownloadManager(
fun isChapterDownloaded( fun isChapterDownloaded(
chapterName: String, chapterName: String,
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String, mangaTitle: String,
sourceId: Long, sourceId: Long,
skipCache: Boolean = false, skipCache: Boolean = false,
): Boolean { ): 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. * @param newChapter the target chapter with the new name.
*/ */
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) { 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 -> val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" } logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" }
return return
@@ -379,7 +380,7 @@ class DownloadManager(
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() ?: return .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") { if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz" newName += ".cbz"
} }

View File

@@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.Hash.md5
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath import tachiyomi.core.common.storage.displayablePath
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -25,6 +27,7 @@ import java.io.IOException
class DownloadProvider( class DownloadProvider(
private val context: Context, private val context: Context,
private val storageManager: StorageManager = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
) { ) {
private val downloadsDir: UniFile? private val downloadsDir: UniFile?
@@ -96,9 +99,15 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param source the source of the chapter. * @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) val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence() return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
.mapNotNull { mangaDir?.findFile(it) } .mapNotNull { mangaDir?.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -113,7 +122,7 @@ class DownloadProvider(
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> { fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter -> return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -125,7 +134,10 @@ class DownloadProvider(
* @param source the source to query. * @param source the source to query.
*/ */
fun getSourceDirName(source: Source): String { 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. * @param mangaTitle the title of the manga to query.
*/ */
fun getMangaDirName(mangaTitle: String): String { 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. * Returns the chapter directory name for a chapter.
* *
* @param chapterName the name of the chapter to query. * @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 { fun getChapterDirName(
val newChapterName = sanitizeChapterName(chapterName) chapterName: String,
return DiskUtil.buildValidFilename( 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 { when {
!chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$newChapterName" !chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$sanitizedChapterName"
else -> newChapterName 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 { fun isChapterDirNameChanged(oldChapter: Chapter, newChapter: Chapter): Boolean {
return oldChapter.name != newChapter.name || return getChapterDirName(oldChapter.name, oldChapter.scanlator, oldChapter.url) !=
oldChapter.scanlator?.takeIf { it.isNotBlank() } != newChapter.scanlator?.takeIf { it.isNotBlank() } getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
} }
/** /**
* Returns valid downloaded chapter directory names. * Returns valid downloaded chapter directory names.
* *
* @param chapterName the name of the chapter to query. * @param chapter the domain chapter object.
* @param chapterScanlator scanlator of the chapter to query
*/ */
fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?): List<String> { fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?, chapterUrl: String): List<String> {
val chapterDirName = getChapterDirName(chapterName, chapterScanlator) val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl)
return buildList(2) { val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl)
return buildList {
// Folder of images // Folder of images
add(chapterDirName) add(chapterDirName)
// Archived chapters // Archived chapters
add("$chapterDirName.cbz") add("$chapterDirName.cbz")
// any legacy names
legacyChapterDirNames.forEach {
add(it)
add("$it.cbz")
}
} }
} }
} }

View File

@@ -274,7 +274,7 @@ class Downloader(
val wasEmpty = queueState.value.isEmpty() val wasEmpty = queueState.value.isEmpty()
val chaptersToQueue = chapters.asSequence() val chaptersToQueue = chapters.asSequence()
// Filter out those already downloaded. // 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. // Add chapters to queue from the start.
.sortedByDescending { it.sourceOrder } .sortedByDescending { it.sourceOrder }
// Filter out those already enqueued. // Filter out those already enqueued.
@@ -336,7 +336,11 @@ class Downloader(
return 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)!! val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try { try {

View File

@@ -484,6 +484,7 @@ class LibraryScreenModel(
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloaded(
chapter.name, chapter.name,
chapter.scanlator, chapter.scanlator,
chapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )

View File

@@ -527,7 +527,13 @@ class MangaScreenModel(
val downloaded = if (isLocal) { val downloaded = if (isLocal) {
true true
} else { } 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 { val downloadState = when {
activeDownload != null -> activeDownload.status activeDownload != null -> activeDownload.status

View File

@@ -38,7 +38,6 @@ import eu.kanade.tachiyomi.util.chapter.filterDownloaded
import eu.kanade.tachiyomi.util.chapter.removeDuplicates import eu.kanade.tachiyomi.util.chapter.removeDuplicates
import eu.kanade.tachiyomi.util.editCover import eu.kanade.tachiyomi.util.editCover
import eu.kanade.tachiyomi.util.lang.byteSize 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.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -175,6 +174,7 @@ class ReaderViewModel @JvmOverloads constructor(
!downloadManager.isChapterDownloaded( !downloadManager.isChapterDownloaded(
it.name, it.name,
it.scanlator, it.scanlator,
it.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -184,6 +184,7 @@ class ReaderViewModel @JvmOverloads constructor(
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloaded(
it.name, it.name,
it.scanlator, it.scanlator,
it.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -397,6 +398,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isDownloaded = downloadManager.isChapterDownloaded( val isDownloaded = downloadManager.isChapterDownloaded(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url,
manga.title, manga.title,
manga.source, manga.source,
skipCache = true, skipCache = true,
@@ -473,6 +475,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isNextChapterDownloaded = downloadManager.isChapterDownloaded( val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
nextChapter.name, nextChapter.name,
nextChapter.scanlator, nextChapter.scanlator,
nextChapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -757,7 +760,8 @@ class ReaderViewModel @JvmOverloads constructor(
val chapter = page.chapter.chapter val chapter = page.chapter.chapter
val filenameSuffix = " - ${page.number}" val filenameSuffix = " - ${page.number}"
return DiskUtil.buildValidFilename( 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 ) + filenameSuffix
} }

View File

@@ -80,6 +80,7 @@ class ChapterLoader(
val isDownloaded = downloadManager.isChapterDownloaded( val isDownloaded = downloadManager.isChapterDownloaded(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url,
manga.title, manga.title,
manga.source, manga.source,
skipCache = true, skipCache = true,

View File

@@ -33,7 +33,13 @@ internal class DownloadPageLoader(
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter 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) { return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath) getPagesFromArchive(chapterPath)
} else { } else {

View File

@@ -37,6 +37,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloaded(
chapterName = goingToChapter.name, chapterName = goingToChapter.name,
chapterScanlator = goingToChapter.scanlator, chapterScanlator = goingToChapter.scanlator,
chapterUrl = goingToChapter.url,
mangaTitle = manga.title, mangaTitle = manga.title,
sourceId = manga.source, sourceId = manga.source,
skipCache = true, skipCache = true,

View File

@@ -107,6 +107,7 @@ class UpdatesScreenModel(
val downloaded = downloadManager.isChapterDownloaded( val downloaded = downloadManager.isChapterDownloaded(
update.chapterName, update.chapterName,
update.scanlator, update.scanlator,
update.chapterUrl,
update.mangaTitle, update.mangaTitle,
update.sourceId, update.sourceId,
) )

View File

@@ -15,5 +15,5 @@ fun List<Chapter>.filterDownloaded(manga: Manga): List<Chapter> {
val downloadCache: DownloadCache = Injekt.get() 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) }
} }

View File

@@ -9,6 +9,9 @@ import androidx.core.content.ContextCompat
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File import java.io.File
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.CodingErrorAction
object DiskUtil { object DiskUtil {
@@ -102,26 +105,84 @@ object DiskUtil {
} }
/** /**
* Mutate the given filename to make it valid for a FAT filesystem, * Transform a filename fragment to make it safe to use on almost
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting * all commonly used filesystems. You can pass an entire filename,
* with a dot), but you can manually add it later. * 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('.', ' ') val name = origName.trim('.', ' ')
if (name.isEmpty()) { if (name.isEmpty()) {
return "(invalid)" return "(invalid)"
} }
val sb = StringBuilder(name.length) val sb = StringBuilder(name.length)
name.forEach { c -> 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) sb.append(c)
} else { } else {
sb.append('_') sb.append('_')
} }
} }
// Even though vfat allows 255 UCS-2 chars, we might eventually write to return truncateToLength(sb.toString(), maxBytes)
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters. }
return sb.toString().take(240)
/**
* 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" const val NOMEDIA_FILE = ".nomedia"
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8).
const val MAX_FILE_NAME_BYTES = 250 // 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
} }

View File

@@ -52,6 +52,7 @@ class UpdatesRepositoryImpl(
chapterId: Long, chapterId: Long,
chapterName: String, chapterName: String,
scanlator: String?, scanlator: String?,
chapterUrl: String,
read: Boolean, read: Boolean,
bookmark: Boolean, bookmark: Boolean,
lastPageRead: Long, lastPageRead: Long,
@@ -67,6 +68,7 @@ class UpdatesRepositoryImpl(
chapterId = chapterId, chapterId = chapterId,
chapterName = chapterName, chapterName = chapterName,
scanlator = scanlator, scanlator = scanlator,
chapterUrl = chapterUrl,
read = read, read = read,
bookmark = bookmark, bookmark = bookmark,
lastPageRead = lastPageRead, lastPageRead = lastPageRead,

View 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;

View File

@@ -5,6 +5,7 @@ SELECT
chapters._id AS chapterId, chapters._id AS chapterId,
chapters.name AS chapterName, chapters.name AS chapterName,
chapters.scanlator, chapters.scanlator,
chapters.url AS chapterUrl,
chapters.read, chapters.read,
chapters.bookmark, chapters.bookmark,
chapters.last_page_read, chapters.last_page_read,
@@ -31,4 +32,4 @@ SELECT *
FROM updatesView FROM updatesView
WHERE read = :read WHERE read = :read
AND dateUpload > :after AND dateUpload > :after
LIMIT :limit; LIMIT :limit;

View File

@@ -192,6 +192,8 @@ class LibraryPreferences(
fun updateMangaTitles() = preferenceStore.getBoolean("pref_update_library_manga_titles", false) fun updateMangaTitles() = preferenceStore.getBoolean("pref_update_library_manga_titles", false)
fun disallowNonAsciiFilenames() = preferenceStore.getBoolean("disallow_non_ascii_filenames", false)
// endregion // endregion
enum class ChapterSwipeAction { enum class ChapterSwipeAction {

View File

@@ -8,6 +8,7 @@ data class UpdatesWithRelations(
val chapterId: Long, val chapterId: Long,
val chapterName: String, val chapterName: String,
val scanlator: String?, val scanlator: String?,
val chapterUrl: String,
val read: Boolean, val read: Boolean,
val bookmark: Boolean, val bookmark: Boolean,
val lastPageRead: Long, val lastPageRead: Long,

View File

@@ -320,6 +320,8 @@
<string name="pref_mark_duplicate_read_chapter_read_new">After fetching new chapter</string> <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_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 --> <!-- Extension section -->
<string name="multi_lang">Multi</string> <string name="multi_lang">Multi</string>