From 6d932386b0bce0e1f8401991f3d898cec90e1a1c Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 26 Jun 2026 18:35:32 +0700 Subject: [PATCH] feat(download): add {playlist_position} filename placeholder Add a playlist position token usable in filename templates, plumbed from the playlist screen through the download queue to the Go backend. Auto-prefix playlist downloads with the position when the template lacks the token. Includes Go filename test. --- go_backend/exports.go | 1 + go_backend/extension_providers.go | 2 + go_backend/filename.go | 16 ++- go_backend/filename_test.go | 17 +++ lib/models/download_item.dart | 4 + lib/models/download_item.g.dart | 2 + lib/providers/download_queue_provider.dart | 117 +++++++++++++----- lib/screens/playlist_screen.dart | 20 ++- lib/screens/settings/files_settings_page.dart | 3 + lib/services/download_request_payload.dart | 4 + 10 files changed, 151 insertions(+), 35 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index da418acd..8cc74d17 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -283,6 +283,7 @@ type DownloadRequest struct { PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"` TidalHighFormat string `json:"tidal_high_format,omitempty"` TrackNumber int `json:"track_number"` + PlaylistPosition int `json:"playlist_position,omitempty"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` TotalDiscs int `json:"total_discs,omitempty"` diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 100b443e..7110abf8 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -2709,6 +2709,7 @@ func buildOutputPath(req DownloadRequest) string { "track": req.TrackNumber, "track_number": req.TrackNumber, "total_tracks": req.TotalTracks, + "playlist_position": req.PlaylistPosition, "disc": req.DiscNumber, "disc_number": req.DiscNumber, "total_discs": req.TotalDiscs, @@ -2768,6 +2769,7 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri "track": req.TrackNumber, "track_number": req.TrackNumber, "total_tracks": req.TotalTracks, + "playlist_position": req.PlaylistPosition, "disc": req.DiscNumber, "disc_number": req.DiscNumber, "total_discs": req.TotalDiscs, diff --git a/go_backend/filename.go b/go_backend/filename.go index 14650374..7b5168b0 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -13,7 +13,7 @@ import ( var ( invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) multiUnderscore = regexp.MustCompile(`_+`) - formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`) + formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`) dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`) yearPattern = regexp.MustCompile(`\d{4}`) ) @@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{}) "{album}": getString(metadata, "album"), "{track}": formatTrackNumber(getInt(metadata, "track")), "{track_raw}": formatRawNumber(getInt(metadata, "track")), + "{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)), + "{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)), + "{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)), + "{position}": formatTrackNumber(getPlaylistPosition(metadata)), + "{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)), "{year}": yearValue, "{date}": dateValue, "{disc}": formatDiscNumber(getInt(metadata, "disc")), @@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int } number := getInt(metadata, parts[1]) + if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" { + number = getPlaylistPosition(metadata) + } width, err := strconv.Atoi(parts[2]) if err != nil { return "" @@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int { candidateKeys = append(candidateKeys, "track_number") case "disc": candidateKeys = append(candidateKeys, "disc_number") + case "playlist_position", "playlistPosition", "playlist position", "position": + candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position") } for _, candidate := range candidateKeys { @@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int { return 0 } +func getPlaylistPosition(metadata map[string]interface{}) int { + return getInt(metadata, "playlist_position") +} + func formatTrackNumber(n int) string { if n <= 0 { return "" diff --git a/go_backend/filename_test.go b/go_backend/filename_test.go index a418c5dd..7e4149f8 100644 --- a/go_backend/filename_test.go +++ b/go_backend/filename_test.go @@ -55,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) { } } +func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) { + metadata := map[string]interface{}{ + "playlist_position": 4, + "artist": "Artist Name", + "title": "Song Name", + } + + formatted := buildFilenameFromTemplate( + "{playlist_position:02} - {artist} - {title}", + metadata, + ) + expected := "04 - Artist Name - Song Name" + if formatted != expected { + t.Fatalf("expected %q, got %q", expected, formatted) + } +} + func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) { metadata := map[string]interface{}{ "artist": "Artist Name", diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 8c9534f9..3d128647 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -37,6 +37,7 @@ class DownloadItem { final DateTime createdAt; final String? qualityOverride; // Override quality for this specific download final String? playlistName; // Playlist context for folder organization + final int? playlistPosition; // 1-based position in the source playlist const DownloadItem({ required this.id, @@ -53,6 +54,7 @@ class DownloadItem { required this.createdAt, this.qualityOverride, this.playlistName, + this.playlistPosition, }); DownloadItem copyWith({ @@ -70,6 +72,7 @@ class DownloadItem { DateTime? createdAt, String? qualityOverride, String? playlistName, + int? playlistPosition, }) { return DownloadItem( id: id ?? this.id, @@ -86,6 +89,7 @@ class DownloadItem { createdAt: createdAt ?? this.createdAt, qualityOverride: qualityOverride ?? this.qualityOverride, playlistName: playlistName ?? this.playlistName, + playlistPosition: playlistPosition ?? this.playlistPosition, ); } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 0cf5e622..c73ca99f 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( createdAt: DateTime.parse(json['createdAt'] as String), qualityOverride: json['qualityOverride'] as String?, playlistName: json['playlistName'] as String?, + playlistPosition: (json['playlistPosition'] as num?)?.toInt(), ); Map _$DownloadItemToJson(DownloadItem instance) => @@ -41,6 +42,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'createdAt': instance.createdAt.toIso8601String(), 'qualityOverride': instance.qualityOverride, 'playlistName': instance.playlistName, + 'playlistPosition': instance.playlistPosition, }; const _$DownloadStatusEnumMap = { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index be5f90bb..7a684e2e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3746,6 +3746,7 @@ class DownloadQueueNotifier extends Notifier { String service, { String? qualityOverride, String? playlistName, + int? playlistPosition, }) { final settings = ref.read(settingsProvider); updateSettings(settings); @@ -3759,6 +3760,7 @@ class DownloadQueueNotifier extends Notifier { createdAt: DateTime.now(), qualityOverride: qualityOverride, playlistName: playlistName, + playlistPosition: playlistPosition, ); state = state.copyWith(items: [...state.items, item]); @@ -3776,12 +3778,22 @@ class DownloadQueueNotifier extends Notifier { String service, { String? qualityOverride, String? playlistName, + List? playlistPositions, }) { final settings = ref.read(settingsProvider); updateSettings(settings); final takenIds = state.items.map((item) => item.id).toSet(); - final newItems = tracks.map((track) { + final shouldAssignPlaylistPositions = + playlistName != null && playlistName.trim().isNotEmpty; + final newItems = tracks.asMap().entries.map((entry) { + final track = entry.value; + final index = entry.key; + final explicitPosition = playlistPositions != null && + index < playlistPositions.length && + (playlistPositions[index] ?? 0) > 0 + ? playlistPositions[index] + : null; final id = _newQueueItemId(track, takenIds: takenIds); takenIds.add(id); return DownloadItem( @@ -3791,6 +3803,8 @@ class DownloadQueueNotifier extends Notifier { createdAt: DateTime.now(), qualityOverride: qualityOverride, playlistName: playlistName, + playlistPosition: + explicitPosition ?? (shouldAssignPlaylistPositions ? index + 1 : null), ); }).toList(); @@ -3802,6 +3816,45 @@ class DownloadQueueNotifier extends Notifier { } } + int _validPlaylistPosition(DownloadItem item) { + final position = item.playlistPosition; + if (position == null || position <= 0) return 0; + return position; + } + + String _filenameFormatForItem(DownloadItem item, String baseFormat) { + if (_validPlaylistPosition(item) == 0 || + item.playlistName == null || + item.playlistName!.trim().isEmpty) { + return baseFormat; + } + + final lower = baseFormat.toLowerCase(); + if (lower.contains('{playlist_position') || + lower.contains('{playlist position') || + lower.contains('{playlistposition')) { + return baseFormat; + } + return '{playlist_position:02} - $baseFormat'; + } + + Map _filenameMetadataForTrack( + Track track, { + int playlistPosition = 0, + }) { + return { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'track': track.trackNumber ?? 0, + 'disc': track.discNumber ?? 0, + 'year': _extractYear(track.releaseDate) ?? '', + 'date': track.releaseDate ?? '', + 'playlist_position': playlistPosition, + 'playlistPosition': playlistPosition, + }; + } + void updateItemStatus( String id, DownloadStatus status, { @@ -5703,19 +5756,21 @@ class DownloadQueueNotifier extends Notifier { String? safFileName; final safOutputExt = isSafMode ? outputExt : ''; + final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track) + ? state.singleFilenameFormat + : state.filenameFormat; + final effectiveFilenameFormat = _filenameFormatForItem( + item, + baseFilenameFormat, + ); if (isSafMode) { - final effectiveFormat = _shouldTreatAsSingleRelease(item.track) - ? state.singleFilenameFormat - : state.filenameFormat; - final baseName = await PlatformBridge.buildFilename(effectiveFormat, { - 'title': item.track.name, - 'artist': item.track.artistName, - 'album': item.track.albumName, - 'track': item.track.trackNumber ?? 0, - 'disc': item.track.discNumber ?? 0, - 'year': _extractYear(item.track.releaseDate) ?? '', - 'date': item.track.releaseDate ?? '', - }); + final baseName = await PlatformBridge.buildFilename( + effectiveFilenameFormat, + _filenameMetadataForTrack( + item.track, + playlistPosition: _validPlaylistPosition(item), + ), + ); safFileName = await _buildSafFileName(baseName, safOutputExt); } @@ -5803,9 +5858,7 @@ class DownloadQueueNotifier extends Notifier { albumArtist: resolvedAlbumArtist ?? '', coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '', outputDir: outputDir, - filenameFormat: _shouldTreatAsSingleRelease(trackForPayload) - ? state.singleFilenameFormat - : state.filenameFormat, + filenameFormat: effectiveFilenameFormat, quality: quality, embedMetadata: settings.embedMetadata, artistTagMode: settings.artistTagMode, @@ -5822,6 +5875,7 @@ class DownloadQueueNotifier extends Notifier { postProcessingEnabled: postProcessingEnabled, tidalHighFormat: settings.tidalHighFormat, trackNumber: normalizedTrackNumber, + playlistPosition: _validPlaylistPosition(item), discNumber: normalizedDiscNumber, totalTracks: trackForPayload.totalTracks ?? 0, totalDiscs: trackForPayload.totalDiscs ?? 0, @@ -7328,19 +7382,21 @@ class DownloadQueueNotifier extends Notifier { String? safFileName; String? safBaseName; String safOutputExt = _determineOutputExt(quality, item.service); + final baseFilenameFormat = _shouldTreatAsSingleRelease(trackToDownload) + ? state.singleFilenameFormat + : state.filenameFormat; + final effectiveFilenameFormat = _filenameFormatForItem( + item, + baseFilenameFormat, + ); if (isSafMode) { - final effectiveFormat = _shouldTreatAsSingleRelease(trackToDownload) - ? state.singleFilenameFormat - : state.filenameFormat; - final baseName = await PlatformBridge.buildFilename(effectiveFormat, { - 'title': trackToDownload.name, - 'artist': trackToDownload.artistName, - 'album': trackToDownload.albumName, - 'track': trackToDownload.trackNumber ?? 0, - 'disc': trackToDownload.discNumber ?? 0, - 'year': _extractYear(trackToDownload.releaseDate) ?? '', - 'date': trackToDownload.releaseDate ?? '', - }); + final baseName = await PlatformBridge.buildFilename( + effectiveFilenameFormat, + _filenameMetadataForTrack( + trackToDownload, + playlistPosition: _validPlaylistPosition(item), + ), + ); safFileName = await _buildSafFileName(baseName, safOutputExt); safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); } @@ -7529,9 +7585,7 @@ class DownloadQueueNotifier extends Notifier { ? (trackToDownload.coverUrl ?? '') : '', outputDir: outputDir, - filenameFormat: _shouldTreatAsSingleRelease(trackToDownload) - ? state.singleFilenameFormat - : state.filenameFormat, + filenameFormat: effectiveFilenameFormat, quality: quality, embedMetadata: metadataEmbeddingEnabled, artistTagMode: settings.artistTagMode, @@ -7549,6 +7603,7 @@ class DownloadQueueNotifier extends Notifier { postProcessingEnabled: postProcessingEnabled, tidalHighFormat: settings.tidalHighFormat, trackNumber: normalizedTrackNumber, + playlistPosition: _validPlaylistPosition(item), discNumber: normalizedDiscNumber, totalTracks: trackToDownload.totalTracks ?? 0, totalDiscs: trackToDownload.totalDiscs ?? 0, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 069b3a20..532fb3e2 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -564,7 +564,11 @@ class _PlaylistScreenState extends ConsumerState { child: _PlaylistTrackItem( track: track, isInHistory: isInHistory, - onDownload: () => _downloadTrack(context, track), + onDownload: () => _downloadTrack( + context, + track, + playlistPosition: index + 1, + ), ), ), ); @@ -572,7 +576,11 @@ class _PlaylistScreenState extends ConsumerState { ); } - void _downloadTrack(BuildContext context, Track track) { + void _downloadTrack( + BuildContext context, + Track track, { + int? playlistPosition, + }) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { @@ -590,6 +598,7 @@ class _PlaylistScreenState extends ConsumerState { service, qualityOverride: quality, playlistName: _playlistName, + playlistPosition: playlistPosition, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -612,7 +621,12 @@ class _PlaylistScreenState extends ConsumerState { } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, service, playlistName: _playlistName); + .addToQueue( + track, + service, + playlistName: _playlistName, + playlistPosition: playlistPosition, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 4cf17568..1c0d5dc7 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -964,11 +964,14 @@ class _FilenameFormatEditorSheetState '{year}', '{date}', '{disc}', + '{playlist_position}', ]; static const _advancedTags = [ '{track_raw}', '{track:02}', '{track:1}', + '{playlist_position_raw}', + '{playlist_position:02}', '{date:%Y}', '{date:%Y-%m-%d}', '{disc_raw}', diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index d803169f..5f4eb38a 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -21,6 +21,7 @@ class DownloadRequestPayload { final bool postProcessingEnabled; final String tidalHighFormat; final int trackNumber; + final int playlistPosition; final int discNumber; final int totalTracks; final int totalDiscs; @@ -70,6 +71,7 @@ class DownloadRequestPayload { this.postProcessingEnabled = false, this.tidalHighFormat = 'mp3_320', this.trackNumber = 0, + this.playlistPosition = 0, this.discNumber = 0, this.totalTracks = 1, this.totalDiscs = 0, @@ -121,6 +123,7 @@ class DownloadRequestPayload { 'post_processing_enabled': postProcessingEnabled, 'tidal_high_format': tidalHighFormat, 'track_number': trackNumber, + 'playlist_position': playlistPosition, 'disc_number': discNumber, 'total_tracks': totalTracks, 'total_discs': totalDiscs, @@ -176,6 +179,7 @@ class DownloadRequestPayload { postProcessingEnabled: postProcessingEnabled, tidalHighFormat: tidalHighFormat, trackNumber: trackNumber, + playlistPosition: playlistPosition, discNumber: discNumber, totalTracks: totalTracks, totalDiscs: totalDiscs,