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.
This commit is contained in:
zarzet
2026-06-26 18:35:32 +07:00
parent 9c054b9e3a
commit 6d932386b0
10 changed files with 151 additions and 35 deletions
+1
View File
@@ -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"`
+2
View File
@@ -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,
+15 -1
View File
@@ -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 ""
+17
View File
@@ -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",
+4
View File
@@ -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,
);
}
+2
View File
@@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -41,6 +42,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
'playlistPosition': instance.playlistPosition,
};
const _$DownloadStatusEnumMap = {
+86 -31
View File
@@ -3746,6 +3746,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -3759,6 +3760,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition: playlistPosition,
);
state = state.copyWith(items: [...state.items, item]);
@@ -3776,12 +3778,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
List<int?>? 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<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition:
explicitPosition ?? (shouldAssignPlaylistPositions ? index + 1 : null),
);
}).toList();
@@ -3802,6 +3816,45 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
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<String, dynamic> _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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
? (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<DownloadQueueState> {
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
totalTracks: trackToDownload.totalTracks ?? 0,
totalDiscs: trackToDownload.totalDiscs ?? 0,
+17 -3
View File
@@ -564,7 +564,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
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<PlaylistScreen> {
);
}
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<PlaylistScreen> {
service,
qualityOverride: quality,
playlistName: _playlistName,
playlistPosition: playlistPosition,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -612,7 +621,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
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))),
);
@@ -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}',
@@ -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,