From 2e115308b70a373175094b2f32e7e842e54f5318 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 17 Aug 2026 20:51:27 +0700 Subject: [PATCH] feat(filename): add provider traceability tags --- .../zarz/spotiflac/NativeFinalizerFFmpeg.kt | 1 + .../com/zarz/spotiflac/SafDownloadHandler.kt | 20 +++++++-- go_backend/exports_download.go | 6 +++ go_backend/exports_test.go | 32 ++++++++++++++ go_backend/extension_fallback.go | 2 + go_backend/extension_fallback_output.go | 20 +++++++++ go_backend/filename.go | 32 ++++++++++++++ go_backend/filename_test.go | 44 +++++++++++++++++++ lib/providers/download_queue_provider.dart | 2 + .../download_queue_provider_paths.dart | 28 ++++++++++++ lib/screens/settings/files_settings_page.dart | 5 ++- lib/services/download_request_payload.dart | 8 ++++ test/models_and_utils_test.dart | 6 +++ 13 files changed, 201 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt index 76b89207..90a6e023 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt @@ -243,6 +243,7 @@ internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extensio internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String { val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } } val rawName = input.result.optString("quality_variant_file_name", "") + .ifBlank { input.result.optString("resolved_file_name", "") } .ifBlank { input.request.optString("saf_file_name", "") } .ifBlank { state.fileName } .ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" } 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 52b6c04b..c322a54e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -126,15 +126,18 @@ object SafDownloadHandler { val response = downloader(req.toString()) val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { + val resolvedFileName = respObj.optString("resolved_file_name", "") + .trim() + .let { if (it.isNotEmpty()) forceFilenameExt(it, outputExt) else fileName } val reportedPath = respObj.optString("file_path", "").trim() if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) { respObj.put("file_path", workingFile.absolutePath) } else if (reportedPath != workingFile.absolutePath) { workingFile.delete() } - respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName }) + respObj.put("file_name", resolvedFileName) respObj.put("saf_deferred_publish", true) - respObj.put("saf_final_file_name", fileName) + respObj.put("saf_final_file_name", resolvedFileName) respObj.put("saf_relative_dir", relativeDir) respObj.put("saf_tree_uri", treeUriStr) respObj.put("saf_output_ext", outputExt) @@ -169,7 +172,12 @@ object SafDownloadHandler { val response = downloader(req.toString()) val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { - var finalFileName = fileName + val resolvedFileName = respObj.optString("resolved_file_name", "").trim() + var finalFileName = if (resolvedFileName.isNotEmpty()) { + forceFilenameExt(resolvedFileName, outputExt) + } else { + fileName + } val goFilePath = respObj.optString("file_path", "") if (goFilePath.isNotEmpty() && !goFilePath.startsWith("content://") && @@ -185,7 +193,11 @@ object SafDownloadHandler { respObj.put("actual_extension", actualExt) } if (actualExt.isNotBlank() && actualExt != outputExt) { - val actualFileName = buildSafFileName(req, actualExt) + val actualFileName = if (resolvedFileName.isNotEmpty()) { + forceFilenameExt(resolvedFileName, actualExt) + } else { + buildSafFileName(req, actualExt) + } val actualStagedFileName = if (useStagedOutput) { buildStagedSafFileName(actualFileName) } else { diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 1b7d7477..02d26beb 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -11,6 +11,8 @@ type DownloadRequest struct { ContractVersion int `json:"contract_version,omitempty"` ISRC string `json:"isrc"` Service string `json:"service"` + DownloadProvider string `json:"download_provider,omitempty"` + ProviderTrackID string `json:"provider_track_id,omitempty"` SpotifyID string `json:"spotify_id"` TrackName string `json:"track_name"` ArtistName string `json:"artist_name"` @@ -59,6 +61,8 @@ type DownloadResponse struct { Success bool `json:"success"` Message string `json:"message"` FilePath string `json:"file_path,omitempty"` + ResolvedFileName string `json:"resolved_file_name,omitempty"` + ProviderTrackID string `json:"provider_track_id,omitempty"` Error string `json:"error,omitempty"` ErrorType string `json:"error_type,omitempty"` RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` @@ -181,6 +185,8 @@ func buildDownloadSuccessResponse( Success: true, Message: message, FilePath: filePath, + ResolvedFileName: resolvedDownloadFilename(req, result, filePath), + ProviderTrackID: req.ProviderTrackID, AlreadyExists: alreadyExists, ActualBitDepth: result.BitDepth, ActualSampleRate: result.SampleRate, diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 860eba4e..715dcaf5 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -134,6 +134,38 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) { } } +func TestBuildDownloadSuccessResponseReturnsResolvedProviderFilename(t *testing.T) { + req := DownloadRequest{ + TrackName: "Track", + ArtistName: "Artist", + DownloadProvider: "soundcloud", + ProviderTrackID: "998877", + FilenameFormat: "{artist} - {title} [{isrc}] [{provider}-{provider_id}]", + OutputExt: ".flac", + } + result := DownloadResult{ + ISRC: "USABC1234567", + ActualExtension: ".m4a", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "soundcloud", + "ok", + "/proc/self/fd/10", + false, + ) + + want := "Artist - Track [USABC1234567] [soundcloud-998877].m4a" + if resp.ResolvedFileName != want { + t.Fatalf("resolved filename = %q, want %q", resp.ResolvedFileName, want) + } + if resp.ProviderTrackID != req.ProviderTrackID { + t.Fatalf("provider track ID = %q, want %q", resp.ProviderTrackID, req.ProviderTrackID) + } +} + func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) { req := DownloadRequest{ TrackName: "Track", diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index a903cb42..831275aa 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -25,6 +25,8 @@ func attemptExtensionDownload( lastErrType *string, lastRetryAfterSeconds *int, ) (resp *DownloadResponse, cancelledOuter bool) { + req.DownloadProvider = strings.TrimSpace(providerLabel) + req.ProviderTrackID = strings.TrimSpace(trackID) outputPath := buildOutputPathForExtension(req, ext) if shouldReuseExistingOutput(req, outputPath) { result := DownloadResult{FilePath: outputPath} diff --git a/go_backend/extension_fallback_output.go b/go_backend/extension_fallback_output.go index 9e52c93b..1bfafcca 100644 --- a/go_backend/extension_fallback_output.go +++ b/go_backend/extension_fallback_output.go @@ -26,6 +26,8 @@ func buildDownloadFilename(req DownloadRequest) string { "date": req.ReleaseDate, "release_date": req.ReleaseDate, "isrc": req.ISRC, + "provider": req.DownloadProvider, + "provider_id": req.ProviderTrackID, "composer": req.Composer, "quality": req.Quality, "quality_variant": req.QualityVariant, @@ -47,6 +49,24 @@ func buildDownloadFilename(req DownloadRequest) string { return filename + ext } +func resolvedDownloadFilename(req DownloadRequest, result DownloadResult, filePath string) string { + resolved := req + if isrc := strings.TrimSpace(result.ISRC); isrc != "" { + resolved.ISRC = isrc + } + extension := strings.TrimSpace(result.ActualExtension) + if extension == "" { + extension = filepath.Ext(strings.TrimSpace(result.FilePath)) + } + if extension == "" { + extension = filepath.Ext(strings.TrimSpace(filePath)) + } + if extension != "" { + resolved.OutputExt = extension + } + return buildDownloadFilename(resolved) +} + func buildOutputPath(req DownloadRequest) string { if strings.TrimSpace(req.OutputPath) != "" { return strings.TrimSpace(req.OutputPath) diff --git a/go_backend/filename.go b/go_backend/filename.go index 5d152c7f..9183f3ad 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -16,6 +16,9 @@ var ( formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`) dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`) yearPattern = regexp.MustCompile(`\d{4}`) + emptyFilenameGroupExpr = regexp.MustCompile(`\[\s*\]|\(\s*\)`) + danglingGroupSeparatorExpr = regexp.MustCompile(`\s*[-_|]\s*([\]\)])`) + repeatedFilenameSeparatorExpr = regexp.MustCompile(`\s*[-–—_|]\s*(?:[-–—_|]\s*)+`) ) const maxSanitizedFilenameBytes = 200 @@ -135,15 +138,44 @@ func buildFilenameFromTemplate(template string, metadata map[string]any) string "{disc_raw}": formatRawNumber(getInt(metadata, "disc")), "{quality}": getString(metadata, "quality"), "{quality_variant}": getString(metadata, "quality_variant"), + "{isrc}": getString(metadata, "isrc"), + "{provider}": getString(metadata, "provider"), + "{platform}": getString(metadata, "provider"), + "{provider_id}": getString(metadata, "provider_id"), + "{id}": getString(metadata, "provider_id"), } + hasEmptyOptionalPlaceholder := false for placeholder, value := range placeholders { + if value == "" && isOptionalFilenamePlaceholder(placeholder) && strings.Contains(result, placeholder) { + hasEmptyOptionalPlaceholder = true + } result = strings.ReplaceAll(result, placeholder, value) } + if hasEmptyOptionalPlaceholder { + result = cleanupEmptyFilenameDecorations(result) + } return result } +func isOptionalFilenamePlaceholder(placeholder string) bool { + switch placeholder { + case "{isrc}", "{provider}", "{platform}", "{provider_id}", "{id}": + return true + default: + return false + } +} + +func cleanupEmptyFilenameDecorations(value string) string { + cleaned := emptyFilenameGroupExpr.ReplaceAllString(value, "") + cleaned = danglingGroupSeparatorExpr.ReplaceAllString(cleaned, "$1") + cleaned = repeatedFilenameSeparatorExpr.ReplaceAllString(cleaned, " - ") + cleaned = strings.Join(strings.Fields(cleaned), " ") + return strings.Trim(cleaned, " -–—_|") +} + func replaceFormattedNumberPlaceholders(template string, metadata map[string]any) string { return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string { parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match) diff --git a/go_backend/filename_test.go b/go_backend/filename_test.go index d4013f83..26863bca 100644 --- a/go_backend/filename_test.go +++ b/go_backend/filename_test.go @@ -118,6 +118,50 @@ func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) { } } +func TestBuildDownloadFilename_ProvidesTraceabilityPlaceholders(t *testing.T) { + filename := buildDownloadFilename(DownloadRequest{ + TrackName: "Song Name", + ArtistName: "Artist Name", + ISRC: "USABC1234567", + DownloadProvider: "tidal-web", + ProviderTrackID: "123456789", + FilenameFormat: "{artist} - {title} [{isrc}] [{provider}-{provider_id}]", + OutputExt: ".flac", + }) + + expected := "Artist Name - Song Name [USABC1234567] [tidal-web-123456789].flac" + if filename != expected { + t.Fatalf("expected %q, got %q", expected, filename) + } +} + +func TestBuildFilenameFromTemplate_TraceabilityAliases(t *testing.T) { + metadata := map[string]any{ + "provider": "soundcloud", + "provider_id": "998877", + } + + formatted := buildFilenameFromTemplate("{platform}-{id}", metadata) + if formatted != "soundcloud-998877" { + t.Fatalf("unexpected alias filename: %q", formatted) + } +} + +func TestBuildFilenameFromTemplate_CleansEmptyTraceabilityDecorations(t *testing.T) { + metadata := map[string]any{ + "title": "Song Name", + "provider": "tidal-web", + } + + formatted := buildFilenameFromTemplate( + "{title} [{isrc}] [{provider}-{provider_id}]", + metadata, + ) + if formatted != "Song Name [tidal-web]" { + t.Fatalf("unexpected empty placeholder cleanup: %q", formatted) + } +} + func TestBuildDownloadFilename_PreservesVariantTokenWhenTruncated(t *testing.T) { filename := buildDownloadFilename(DownloadRequest{ TrackName: strings.Repeat("Very Long Song ", 30), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 765744c0..fab9a56f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -690,6 +690,8 @@ class DownloadQueueNotifier extends Notifier { return DownloadRequestPayload( isrc: track.isrc ?? '', service: item.service, + downloadProvider: item.service, + providerTrackId: _knownProviderTrackId(track, item.service), spotifyId: payloadSpotifyId, trackName: track.name, artistName: track.artistName, diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart index e0c4ffb8..76744556 100644 --- a/lib/providers/download_queue_provider_paths.dart +++ b/lib/providers/download_queue_provider_paths.dart @@ -340,6 +340,8 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { filenameFormat, _filenameMetadataForTrack( track, + provider: item.service, + providerTrackId: _knownProviderTrackId(track, item.service), quality: quality, qualityVariant: qualityVariant, playlistPosition: _validPlaylistPosition(item), @@ -717,6 +719,8 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { Map _filenameMetadataForTrack( Track track, { + required String provider, + required String providerTrackId, required String quality, String qualityVariant = '', int playlistPosition = 0, @@ -729,10 +733,34 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { 'disc': track.discNumber ?? 0, 'year': _extractYear(track.releaseDate) ?? '', 'date': track.releaseDate ?? '', + 'isrc': track.isrc ?? '', + 'provider': provider, + 'provider_id': providerTrackId, 'playlist_position': playlistPosition, 'playlistPosition': playlistPosition, 'quality': quality, 'quality_variant': qualityVariant, }; } + + String _knownProviderTrackId(Track track, String provider) { + final normalizedProvider = provider.trim().toLowerCase(); + final normalizedSource = track.source?.trim().toLowerCase() ?? ''; + final rawId = track.id.trim(); + if (rawId.isEmpty) return ''; + + if (normalizedSource == normalizedProvider && normalizedSource.isNotEmpty) { + final separator = rawId.indexOf(':'); + return separator >= 0 && separator + 1 < rawId.length + ? rawId.substring(separator + 1) + : rawId; + } + + final separator = rawId.indexOf(':'); + if (separator > 0 && + rawId.substring(0, separator).toLowerCase() == normalizedProvider) { + return rawId.substring(separator + 1); + } + return ''; + } } diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index aacee087..f52138e3 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -938,6 +938,9 @@ class _FilenameFormatEditorSheetState '{playlist_position}', ]; static const _advancedTags = [ + '{isrc}', + '{provider}', + '{provider_id}', '{track_raw}', '{track:02}', '{track:1}', @@ -957,7 +960,7 @@ class _FilenameFormatEditorSheetState super.initState(); _controller = TextEditingController(text: widget.initialText); _showAdvancedTags = RegExp( - r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}', + r'\{(?:isrc|provider|platform|provider_id|id|track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}', caseSensitive: false, ).hasMatch(widget.initialText); } diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 3d484d35..a58188bc 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -4,6 +4,8 @@ class DownloadRequestPayload { final int contractVersion; final String isrc; final String service; + final String downloadProvider; + final String providerTrackId; final String spotifyId; final String trackName; final String artistName; @@ -60,6 +62,8 @@ class DownloadRequestPayload { this.contractVersion = nativeWorkerContractVersion, this.isrc = '', this.service = '', + this.downloadProvider = '', + this.providerTrackId = '', this.spotifyId = '', required this.trackName, required this.artistName, @@ -118,6 +122,8 @@ class DownloadRequestPayload { 'contract_version': contractVersion, 'isrc': isrc, 'service': service, + 'download_provider': downloadProvider, + 'provider_track_id': providerTrackId, 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, @@ -180,6 +186,8 @@ class DownloadRequestPayload { contractVersion: contractVersion, isrc: isrc, service: service, + downloadProvider: downloadProvider, + providerTrackId: providerTrackId, spotifyId: spotifyId, trackName: trackName, artistName: artistName, diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 8e16a36e..29bfb1e6 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -821,6 +821,8 @@ void main() { const payload = DownloadRequestPayload( isrc: 'ISRC123', service: 'tidal', + downloadProvider: 'tidal-web', + providerTrackId: '123456789', spotifyId: 'spotify:track:1', trackName: 'Song', artistName: 'Artist', @@ -875,6 +877,8 @@ void main() { 'contract_version': DownloadRequestPayload.nativeWorkerContractVersion, 'isrc': 'ISRC123', 'service': 'tidal', + 'download_provider': 'tidal-web', + 'provider_track_id': '123456789', 'spotify_id': 'spotify:track:1', 'track_name': 'Song', 'artist_name': 'Artist', @@ -946,6 +950,8 @@ void main() { expect(updated.useFallback, isTrue); expect(updated.trackName, payload.trackName); expect(updated.filenameFormat, payload.filenameFormat); + expect(updated.downloadProvider, payload.downloadProvider); + expect(updated.providerTrackId, payload.providerTrackId); expect(updated.allowQualityVariant, payload.allowQualityVariant); expect(updated.qualityVariant, payload.qualityVariant); expect(updated.autoConvertDownloads, payload.autoConvertDownloads);