feat(filename): add provider traceability tags

This commit is contained in:
zarzet
2026-08-17 20:51:27 +07:00
parent efc03db308
commit 2e115308b7
13 changed files with 201 additions and 5 deletions
@@ -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"))}" }
@@ -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 {
+6
View File
@@ -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,
+32
View File
@@ -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",
+2
View File
@@ -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}
+20
View File
@@ -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)
+32
View File
@@ -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)
+44
View File
@@ -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),
@@ -690,6 +690,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadRequestPayload(
isrc: track.isrc ?? '',
service: item.service,
downloadProvider: item.service,
providerTrackId: _knownProviderTrackId(track, item.service),
spotifyId: payloadSpotifyId,
trackName: track.name,
artistName: track.artistName,
@@ -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<String, dynamic> _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 '';
}
}
@@ -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);
}
@@ -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,
+6
View File
@@ -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);