diff --git a/go_backend/deezer.go b/go_backend/deezer.go index dce19752..42231e26 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -146,6 +146,7 @@ type deezerAlbumFull struct { CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` + RecordType string `json:"record_type"` // album, single, ep, compile Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` Tracks struct { @@ -326,6 +327,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) + // Normalize record_type (Deezer uses "compile" instead of "compilation") + albumType := album.RecordType + if albumType == "compile" { + albumType = "compilation" + } + for _, track := range album.Tracks.Data { trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] @@ -345,6 +352,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp ExternalURL: track.Link, ISRC: isrc, AlbumID: fmt.Sprintf("deezer:%d", album.ID), + AlbumType: albumType, }) } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index ad8f56aa..37846633 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -158,6 +158,7 @@ type TrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } // AlbumTrackMetadata holds per-track info for album/playlist @@ -177,6 +178,7 @@ type AlbumTrackMetadata struct { ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` + AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } // AlbumInfoMetadata holds album information @@ -301,6 +303,7 @@ type albumSimplified struct { Images []image `json:"images"` ExternalURL externalURL `json:"external_urls"` Artists []artist `json:"artists"` + AlbumType string `json:"album_type"` // album, single, compilation } type trackFull struct { @@ -381,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumType: track.Album.AlbumType, }) } @@ -448,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumType: track.Album.AlbumType, }) } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 598b42dc..0e11a65b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -27,6 +27,7 @@ class AppSettings { final bool enableLogging; // Enable detailed logging for debugging final bool useExtensionProviders; // Use extension providers for downloads when available final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID + final bool separateSingles; // Separate singles/EPs into their own folder const AppSettings({ this.defaultService = 'tidal', @@ -52,6 +53,7 @@ class AppSettings { this.enableLogging = false, // Default: disabled for performance this.useExtensionProviders = true, // Default: use extensions when available this.searchProvider, // Default: null (use Deezer/Spotify) + this.separateSingles = false, // Default: disabled }); AppSettings copyWith({ @@ -78,6 +80,7 @@ class AppSettings { bool? enableLogging, bool? useExtensionProviders, String? searchProvider, + bool? separateSingles, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -103,6 +106,7 @@ class AppSettings { enableLogging: enableLogging ?? this.enableLogging, useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, searchProvider: searchProvider ?? this.searchProvider, + separateSingles: separateSingles ?? this.separateSingles, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2dae4431..899ad5c3 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -31,6 +31,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, + separateSingles: json['separateSingles'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -58,4 +59,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, + 'separateSingles': instance.separateSingles, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index 080d045a..ac579b50 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -19,6 +19,7 @@ class Track { final String? deezerId; final ServiceAvailability? availability; final String? source; // Extension ID that provided this track (null for built-in sources) + final String? albumType; // album, single, ep, compilation (from metadata API) const Track({ required this.id, @@ -35,8 +36,12 @@ class Track { this.deezerId, this.availability, this.source, + this.albumType, }); + /// Check if this track is a single (based on album_type metadata) + bool get isSingle => albumType == 'single' || albumType == 'ep'; + factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 5a866a4e..0836a5b2 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -25,6 +25,7 @@ Track _$TrackFromJson(Map json) => Track( json['availability'] as Map, ), source: json['source'] as String?, + albumType: json['albumType'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -42,6 +43,7 @@ Map _$TrackToJson(Track instance) => { 'deezerId': instance.deezerId, 'availability': instance.availability, 'source': instance.source, + 'albumType': instance.albumType, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 65fbb7ef..6bf6d6ce 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -668,35 +668,55 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting - Future _buildOutputDir(Track track, String folderOrganization) async { + /// Build output directory based on folder organization setting and separateSingles + Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async { String baseDir = state.outputDir; - if (folderOrganization == 'none') { - return baseDir; + // If separateSingles is enabled, use Albums/Singles structure + if (separateSingles) { + final isSingle = track.isSingle; + + if (isSingle) { + // Singles go to Singles folder (flat structure) + final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; + final dir = Directory(singlesPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + _log.d('Created Singles folder: $singlesPath'); + } + return singlesPath; + } else { + // Albums go to Albums/Artist/Album structure + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final albumName = _sanitizeFolderName(track.albumName); + final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + final dir = Directory(albumPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + _log.d('Created Album folder: $albumPath'); + } + return albumPath; + } } - // Sanitize folder names (remove invalid characters) - String sanitize(String name) { - return name - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots - .trim(); + // Original folder organization logic (when separateSingles is disabled) + if (folderOrganization == 'none') { + return baseDir; } String subPath = ''; switch (folderOrganization) { case 'artist': - final artistName = sanitize(track.albumArtist ?? track.artistName); + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); subPath = artistName; break; case 'album': - final albumName = sanitize(track.albumName); + final albumName = _sanitizeFolderName(track.albumName); subPath = albumName; break; case 'artist_album': - final artistName = sanitize(track.albumArtist ?? track.artistName); - final albumName = sanitize(track.albumName); + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final albumName = _sanitizeFolderName(track.albumName); subPath = '$artistName${Platform.pathSeparator}$albumName'; break; } @@ -714,6 +734,14 @@ class DownloadQueueNotifier extends Notifier { return baseDir; } + /// Sanitize folder names (remove invalid characters) + String _sanitizeFolderName(String name) { + return name + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots + .trim(); + } + void updateSettings(AppSettings settings) { state = state.copyWith( outputDir: settings.downloadDirectory.isNotEmpty @@ -1417,6 +1445,7 @@ class DownloadQueueNotifier extends Notifier { final outputDir = await _buildOutputDir( trackToDownload, settings.folderOrganization, + separateSingles: settings.separateSingles, ); // Use quality override if set, otherwise use default from settings diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 42b5bde3..5251b1e6 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -211,6 +211,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(useExtensionProviders: enabled); _saveSettings(); } + + void setSeparateSingles(bool enabled) { + state = state.copyWith(separateSingles: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 9ed80280..fe6ee1f0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -466,6 +466,7 @@ class TrackNotifier extends Notifier { discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(), + albumType: data['album_type']?.toString(), ); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b3c5e97a..c712621d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -185,19 +185,31 @@ class DownloadSettingsPage extends ConsumerWidget { : settings.downloadDirectory, onTap: () => _pickDirectory(context, ref), ), - SettingsItem( - icon: Icons.create_new_folder_outlined, - title: 'Folder Organization', - subtitle: _getFolderOrganizationLabel( - settings.folderOrganization, - ), - onTap: () => _showFolderOrganizationPicker( - context, - ref, - settings.folderOrganization, - ), - showDivider: false, + SettingsSwitchItem( + icon: Icons.library_music_outlined, + title: 'Separate Singles Folder', + subtitle: settings.separateSingles + ? 'Albums/ and Singles/ folders' + : 'All files in same structure', + value: settings.separateSingles, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setSeparateSingles(value), ), + if (!settings.separateSingles) + SettingsItem( + icon: Icons.create_new_folder_outlined, + title: 'Folder Organization', + subtitle: _getFolderOrganizationLabel( + settings.folderOrganization, + ), + onTap: () => _showFolderOrganizationPicker( + context, + ref, + settings.folderOrganization, + ), + showDivider: false, + ), ], ), ),