diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index baaba090..7731c946 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -43,6 +43,7 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/library_scan_progress_stream" private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L + private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180 private val LARGE_JSON_RESULT_FILE_KEY = "__json_file" private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -329,9 +330,47 @@ class MainActivity: FlutterFragmentActivity() { .replace(Regex("_+"), "_") .trim('_', ' ') + sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES) + sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ') return if (sanitized.isBlank()) "Unknown" else sanitized } + private fun truncateSafDisplayName(name: String, maxBytes: Int): String { + if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name + + val dotIndex = name.lastIndexOf('.') + val ext = if ( + dotIndex > 0 && + dotIndex < name.length - 1 && + name.length - dotIndex <= 10 + ) { + name.substring(dotIndex) + } else { + "" + } + val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name + val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1) + return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext + } + + private fun truncateUtf8Bytes(value: String, maxBytes: Int): String { + if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value + + val builder = StringBuilder() + var usedBytes = 0 + var index = 0 + while (index < value.length) { + val codePoint = value.codePointAt(index) + val char = String(Character.toChars(codePoint)) + val charBytes = char.toByteArray(Charsets.UTF_8).size + if (usedBytes + charBytes > maxBytes) break + builder.append(char) + usedBytes += charBytes + index += Character.charCount(codePoint) + } + return builder.toString() + } + private fun sanitizeRelativeDir(relativeDir: String): String { if (relativeDir.isBlank()) return "" return relativeDir diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index 742b5841..13cb9397 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -14,6 +14,7 @@ import java.util.Locale */ object SafDownloadHandler { private val safDirLock = Any() + private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180 fun handle(context: Context, requestJson: String, downloader: (String) -> String): String { val req = JSONObject(requestJson) @@ -321,9 +322,47 @@ object SafDownloadHandler { .replace(Regex("_+"), "_") .trim('_', ' ') + sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES) + sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ') return if (sanitized.isBlank()) "Unknown" else sanitized } + private fun truncateSafDisplayName(name: String, maxBytes: Int): String { + if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name + + val dotIndex = name.lastIndexOf('.') + val ext = if ( + dotIndex > 0 && + dotIndex < name.length - 1 && + name.length - dotIndex <= 10 + ) { + name.substring(dotIndex) + } else { + "" + } + val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name + val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1) + return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext + } + + private fun truncateUtf8Bytes(value: String, maxBytes: Int): String { + if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value + + val builder = StringBuilder() + var usedBytes = 0 + var index = 0 + while (index < value.length) { + val codePoint = value.codePointAt(index) + val char = String(Character.toChars(codePoint)) + val charBytes = char.toByteArray(Charsets.UTF_8).size + if (usedBytes + charBytes > maxBytes) break + builder.append(char) + usedBytes += charBytes + index += Character.charCount(codePoint) + } + return builder.toString() + } + private fun sanitizeRelativeDir(relativeDir: String): String { if (relativeDir.isBlank()) return "" return relativeDir diff --git a/go_backend/filename.go b/go_backend/filename.go index a94b328a..14650374 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -48,7 +48,7 @@ func sanitizeFilename(filename string) string { } if len(sanitized) > 200 { - sanitized = sanitized[:200] + sanitized = truncateUTF8Bytes(sanitized, 200) sanitized = strings.TrimSpace(strings.Trim(sanitized, ". ")) sanitized = strings.Trim(sanitized, "_ ") } @@ -60,6 +60,25 @@ func sanitizeFilename(filename string) string { return sanitized } +func truncateUTF8Bytes(value string, maxBytes int) string { + if maxBytes <= 0 || len(value) <= maxBytes { + return value + } + + used := 0 + for i, r := range value { + runeLen := utf8.RuneLen(r) + if runeLen < 0 { + runeLen = len(string(r)) + } + if used+runeLen > maxBytes { + return value[:i] + } + used += runeLen + } + return value +} + func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { if template == "" { template = "{artist} - {title}" diff --git a/go_backend/filename_test.go b/go_backend/filename_test.go index b3647cc1..a418c5dd 100644 --- a/go_backend/filename_test.go +++ b/go_backend/filename_test.go @@ -1,6 +1,10 @@ package gobackend -import "testing" +import ( + "strings" + "testing" + "unicode/utf8" +) func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) { metadata := map[string]interface{}{ @@ -98,3 +102,13 @@ func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) { t.Fatalf("expected %q, got %q", "Unknown", got) } } + +func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) { + got := sanitizeFilename(strings.Repeat("あ", 80)) + if !utf8.ValidString(got) { + t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got) + } + if len(got) > 200 { + t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got)) + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b63d8cc5..1a85e7d7 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -36,6 +36,8 @@ double _log10(num x) => log(x) / ln10; final _yearRegex = RegExp(r'^(\d{4})'); const _defaultOutputFolderName = 'SpotiFLAC'; const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName'; +const _maxSafFilenameUtf8Bytes = 180; +const _maxSafDirSegmentUtf8Bytes = 120; class DownloadHistoryItem { final String id; @@ -2311,6 +2313,55 @@ class DownloadQueueNotifier extends Notifier { return sanitized; } + String _truncateUtf8Bytes(String value, int maxBytes) { + if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) { + return value; + } + + final buffer = StringBuffer(); + var usedBytes = 0; + for (final rune in value.runes) { + final char = String.fromCharCode(rune); + final charBytes = utf8.encode(char).length; + if (usedBytes + charBytes > maxBytes) break; + buffer.write(char); + usedBytes += charBytes; + } + return buffer.toString(); + } + + String _trimSafeName(String value) { + var trimmed = value.trim(); + trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, ''); + trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, ''); + return trimmed.isEmpty ? 'Unknown' : trimmed; + } + + String _sanitizeSafRelativeDir(String relativeDir) { + if (relativeDir.trim().isEmpty) return ''; + final parts = relativeDir + .split('/') + .map(_sanitizeFolderName) + .map((part) { + final truncated = _truncateUtf8Bytes( + part, + _maxSafDirSegmentUtf8Bytes, + ); + return _trimSafeName(truncated); + }) + .where((part) => part.isNotEmpty && part != '.' && part != '..') + .toList(growable: false); + return parts.join('/'); + } + + Future _buildSafFileName(String baseName, String outputExt) async { + final sanitized = await PlatformBridge.sanitizeFilename(baseName); + final extBytes = utf8.encode(outputExt).length; + final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes); + final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes); + return '${_trimSafeName(truncated)}$outputExt'; + } + static final _featuredArtistPattern = RegExp( r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+', caseSensitive: false, @@ -4771,7 +4822,7 @@ class DownloadQueueNotifier extends Notifier { if (quality == 'DEFAULT') quality = state.audioQuality; final isSafMode = _isSafMode(settings); - final outputDir = isSafMode + final rawOutputDir = isSafMode ? await _buildRelativeOutputDir( item.track, settings.folderOrganization, @@ -4796,6 +4847,9 @@ class DownloadQueueNotifier extends Notifier { settings.filterContributingArtistsInAlbumArtist, playlistName: item.playlistName, ); + final outputDir = isSafMode + ? _sanitizeSafRelativeDir(rawOutputDir) + : rawOutputDir; if (!isSafMode) { await _ensureDirExists(outputDir, label: 'Output folder'); } @@ -4822,8 +4876,7 @@ class DownloadQueueNotifier extends Notifier { 'year': _extractYear(item.track.releaseDate) ?? '', 'date': item.track.releaseDate ?? '', }); - final sanitized = await PlatformBridge.sanitizeFilename(baseName); - safFileName = '$sanitized$safOutputExt'; + safFileName = await _buildSafFileName(baseName, safOutputExt); } var trackForPayload = item.track; @@ -6240,7 +6293,9 @@ class DownloadQueueNotifier extends Notifier { settings.filterContributingArtistsInAlbumArtist, playlistName: item.playlistName, ); - var effectiveOutputDir = initialOutputDir; + var effectiveOutputDir = isSafMode + ? _sanitizeSafRelativeDir(initialOutputDir) + : initialOutputDir; var effectiveSafMode = isSafMode; String? safFileName; @@ -6259,9 +6314,8 @@ class DownloadQueueNotifier extends Notifier { 'year': _extractYear(trackToDownload.releaseDate) ?? '', 'date': trackToDownload.releaseDate ?? '', }); - final sanitized = await PlatformBridge.sanitizeFilename(baseName); - safBaseName = sanitized; - safFileName = '$sanitized$safOutputExt'; + safFileName = await _buildSafFileName(baseName, safOutputExt); + safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); } String? finalSafFileName = safFileName;