mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 21:02:28 +02:00
feat(filename): add provider traceability tags
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user