feat: add separate filename format for singles and EPs (#271)

Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
This commit is contained in:
zarzet
2026-03-31 18:54:29 +07:00
parent c936bd7dd0
commit a1aa1319ce
21 changed files with 182 additions and 26 deletions
+12
View File
@@ -256,6 +256,18 @@ abstract class AppLocalizations {
/// **'Filename Format'**
String get downloadFilenameFormat;
/// Setting for output filename pattern for singles/EPs
///
/// In en, this message translates to:
/// **'Single Filename Format'**
String get downloadSingleFilenameFormat;
/// Subtitle description for single filename format setting
///
/// In en, this message translates to:
/// **'Filename pattern for singles and EPs. Uses the same tags as the album format.'**
String get downloadSingleFilenameFormatDescription;
/// Title of the folder organization picker bottom sheet
///
/// In en, this message translates to:
+7
View File
@@ -76,6 +76,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Dateinamenformat';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Ordnerstruktur';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Nom du fichier';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Organisation du dossier';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+7
View File
@@ -76,6 +76,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Format Nama File';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Organisasi Folder';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadFilenameFormat => 'ファイル名の形式';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'フォルダ構成';
+7
View File
@@ -74,6 +74,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadFilenameFormat => '파일 이름 형식';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => '폴더 분류 형식';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+7
View File
@@ -76,6 +76,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Формат имени файла';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Организация папок';
+7
View File
@@ -76,6 +76,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Dosya adı formatı';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Dosya Organizasyonu';
+7
View File
@@ -75,6 +75,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override
String get downloadFolderOrganization => 'Folder Organization';
+8
View File
@@ -89,6 +89,14 @@
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadSingleFilenameFormat": "Single Filename Format",
"@downloadSingleFilenameFormat": {
"description": "Setting for output filename pattern for singles/EPs"
},
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
"@downloadSingleFilenameFormatDescription": {
"description": "Subtitle description for single filename format setting"
},
"downloadFolderOrganization": "Folder Organization",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
+4
View File
@@ -35,6 +35,7 @@ class AppSettings {
final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles;
final String singleFilenameFormat;
final String albumFolderStructure;
final bool showExtensionStore;
final String locale;
@@ -108,6 +109,7 @@ class AppSettings {
this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
@@ -170,6 +172,7 @@ class AppSettings {
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
String? singleFilenameFormat,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
@@ -232,6 +235,7 @@ class AppSettings {
? null
: (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles,
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
+4 -1
View File
@@ -15,7 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true,
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
@@ -37,6 +37,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
json['singleFilenameFormat'] as String? ?? '{title} - {artist}',
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
@@ -104,6 +106,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
+6 -8
View File
@@ -49,26 +49,24 @@ class Track {
bool get isSingle {
switch (albumType?.toLowerCase()) {
case 'single':
return true;
case 'ep':
final count = totalTracks;
return count == null || count <= 1;
return true;
default:
return false;
}
}
bool get isAlbumItem => itemType == 'album';
bool get isPlaylistItem => itemType == 'playlist';
bool get isArtistItem => itemType == 'artist';
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
bool get isFromExtension => source != null && source!.isNotEmpty;
}
+20 -11
View File
@@ -1104,6 +1104,7 @@ class DownloadQueueState {
final bool isPaused;
final String outputDir;
final String filenameFormat;
final String singleFilenameFormat;
final String audioQuality;
final bool autoFallback;
final int concurrentDownloads;
@@ -1115,6 +1116,7 @@ class DownloadQueueState {
this.isPaused = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.singleFilenameFormat = '{title} - {artist}',
this.audioQuality = 'LOSSLESS',
this.autoFallback = true,
this.concurrentDownloads = 1,
@@ -1127,6 +1129,7 @@ class DownloadQueueState {
bool? isPaused,
String? outputDir,
String? filenameFormat,
String? singleFilenameFormat,
String? audioQuality,
bool? autoFallback,
int? concurrentDownloads,
@@ -1140,6 +1143,7 @@ class DownloadQueueState {
isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
@@ -2256,6 +2260,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? settings.downloadDirectory
: state.outputDir,
filenameFormat: settings.filenameFormat,
singleFilenameFormat: settings.singleFilenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: concurrentDownloads,
@@ -3842,16 +3847,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safBaseName;
String safOutputExt = _determineOutputExt(quality, item.service);
if (isSafMode) {
final baseName =
await PlatformBridge.buildFilename(state.filenameFormat, {
'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 effectiveFormat = trackToDownload.isSingle
? 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 sanitized = await PlatformBridge.sanitizeFilename(baseName);
safBaseName = sanitized;
safFileName = '$sanitized$safOutputExt';
@@ -4214,7 +4221,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? (trackToDownload.coverUrl ?? '')
: '',
outputDir: outputDir,
filenameFormat: state.filenameFormat,
filenameFormat: trackToDownload.isSingle
? state.singleFilenameFormat
: state.filenameFormat,
quality: quality,
embedMetadata: metadataEmbeddingEnabled,
artistTagMode: settings.artistTagMode,
+5
View File
@@ -196,6 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSingleFilenameFormat(String format) {
state = state.copyWith(singleFilenameFormat: format);
_saveSettings();
}
void setDownloadDirectory(String directory) {
state = state.copyWith(downloadDirectory: directory);
_saveSettings();
@@ -592,6 +592,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.filenameFormat,
),
),
SettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.downloadSingleFilenameFormat,
subtitle: settings.singleFilenameFormat,
onTap: () => _showFormatEditor(
context,
ref,
settings.singleFilenameFormat,
onSave: ref
.read(settingsProvider.notifier)
.setSingleFilenameFormat,
title: context.l10n.downloadSingleFilenameFormat,
description:
context.l10n.downloadSingleFilenameFormatDescription,
),
),
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.downloadDirectory,
@@ -952,7 +968,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
void _showFormatEditor(
BuildContext context,
WidgetRef ref,
String current, {
void Function(String)? onSave,
String? title,
String? description,
}) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
@@ -1035,14 +1058,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
Text(
context.l10n.filenameFormat,
title ?? context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
context.l10n.downloadFilenameDescription,
description ?? context.l10n.downloadFilenameDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1149,9 +1172,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
final save =
onSave ??
ref
.read(settingsProvider.notifier)
.setFilenameFormat;
save(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(