mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
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:
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user