fix: preserve flat singles output for extension releases

This commit is contained in:
zarzet 2026-04-06 04:27:37 +07:00
parent 67833424cc
commit eff709480d
5 changed files with 155 additions and 33 deletions

View file

@ -2000,6 +2000,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: dir); state = state.copyWith(outputDir: dir);
} }
bool _shouldTreatAsSingleRelease(Track track) {
if (track.isSingle) {
return true;
}
final normalizedAlbumType = normalizeOptionalString(
track.albumType,
)?.toLowerCase();
if (normalizedAlbumType != null && normalizedAlbumType.isNotEmpty) {
return false;
}
final totalTracks = track.totalTracks;
if (totalTracks == 1) {
return true;
}
final normalizedAlbumName = normalizeOptionalString(
track.albumName,
)?.toLowerCase();
if (normalizedAlbumName == 'single' || normalizedAlbumName == 'singles') {
return totalTracks == null || totalTracks <= 2;
}
return false;
}
Future<String> _buildOutputDir( Future<String> _buildOutputDir(
Track track, Track track,
String folderOrganization, { String folderOrganization, {
@ -2036,7 +2063,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = _shouldTreatAsSingleRelease(track);
final artistName = _sanitizeFolderName(folderArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
@ -2215,7 +2242,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = _shouldTreatAsSingleRelease(track);
final artistName = _sanitizeFolderName(folderArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
@ -4137,7 +4164,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safBaseName; String? safBaseName;
String safOutputExt = _determineOutputExt(quality, item.service); String safOutputExt = _determineOutputExt(quality, item.service);
if (isSafMode) { if (isSafMode) {
final effectiveFormat = trackToDownload.isSingle final effectiveFormat = _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat ? state.singleFilenameFormat
: state.filenameFormat; : state.filenameFormat;
final baseName = await PlatformBridge.buildFilename(effectiveFormat, { final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
@ -4552,7 +4579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? (trackToDownload.coverUrl ?? '') ? (trackToDownload.coverUrl ?? '')
: '', : '',
outputDir: outputDir, outputDir: outputDir,
filenameFormat: trackToDownload.isSingle filenameFormat: _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat ? state.singleFilenameFormat
: state.filenameFormat, : state.filenameFormat,
quality: quality, quality: quality,

View file

@ -908,7 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType: normalizeOptionalString(data['album_type']?.toString()),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
); );
@ -945,7 +945,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
source: effectiveSource, source: effectiveSource,
albumType: data['album_type']?.toString(), albumType: normalizeOptionalString(data['album_type']?.toString()),
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
itemType: itemType, itemType: itemType,
); );

View file

@ -75,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error; String? _error;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId; String? _artistId;
String? _albumType;
int? _albumTotalTracks;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@override @override
@ -112,6 +114,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_tracks = _AlbumCache.get(widget.albumId); _tracks = _AlbumCache.get(widget.albumId);
} }
_artistId = widget.artistId; _artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
@ -179,13 +183,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
deezerAlbumId, deezerAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@ -193,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@ -204,13 +219,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
qobuzAlbumId, qobuzAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@ -218,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@ -229,13 +255,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
tidalAlbumId, tidalAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@ -243,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@ -255,13 +292,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
final trackList = result['tracks'] as List<dynamic>; final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = result['album'] as Map<String, dynamic>?; final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@ -269,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@ -284,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
return Track( return Track(
id: data['spotify_id'] as String? ?? '', id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
@ -301,8 +353,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType:
totalTracks: data['total_tracks'] as int?, normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
); );
} }

View file

@ -412,7 +412,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType, albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId, source: data['provider_id']?.toString() ?? widget.extensionId,
@ -1057,9 +1059,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
); );
if (result != null && result['tracks'] != null) { if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>; final tracksList = result['tracks'] as List<dynamic>;
return tracksList final parsedTracks = tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album)) .map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList(); .toList();
return parsedTracks;
} }
} else if (album.id.startsWith('deezer:')) { } else if (album.id.startsWith('deezer:')) {
final deezerId = album.id.replaceFirst('deezer:', ''); final deezerId = album.id.replaceFirst('deezer:', '');
@ -1934,6 +1937,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albumId: album.id, albumId: album.id,
albumName: album.name, albumName: album.name,
coverUrl: album.coverUrl, coverUrl: album.coverUrl,
initialAlbumType: album.albumType,
initialTotalTracks: album.totalTracks,
), ),
), ),
); );

View file

@ -3031,6 +3031,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: albumItem.id, albumId: albumItem.id,
albumName: albumItem.name, albumName: albumItem.name,
coverUrl: albumItem.coverUrl, coverUrl: albumItem.coverUrl,
initialAlbumType: albumItem.albumType,
initialTotalTracks: albumItem.totalTracks,
), ),
), ),
); );
@ -4315,6 +4317,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String albumId; final String albumId;
final String albumName; final String albumName;
final String? coverUrl; final String? coverUrl;
final String? initialAlbumType;
final int? initialTotalTracks;
const ExtensionAlbumScreen({ const ExtensionAlbumScreen({
super.key, super.key,
@ -4322,6 +4326,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
required this.albumId, required this.albumId,
required this.albumName, required this.albumName,
this.coverUrl, this.coverUrl,
this.initialAlbumType,
this.initialTotalTracks,
}); });
@override @override
@ -4335,10 +4341,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _error; String? _error;
String? _artistId; String? _artistId;
String? _artistName; String? _artistName;
String? _albumType;
int? _albumTotalTracks;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_albumType = normalizeOptionalString(widget.initialAlbumType);
_albumTotalTracks = widget.initialTotalTracks;
_fetchTracks(); _fetchTracks();
} }
@ -4372,17 +4382,28 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return; return;
} }
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?; final artistName = result['artists'] as String?;
final albumType =
normalizeOptionalString(result['album_type']?.toString()) ??
_albumType;
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_artistName = artistName; _artistName = artistName;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@ -4394,7 +4415,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
int durationMs = 0; int durationMs = 0;
final durationValue = data['duration_ms']; final durationValue = data['duration_ms'];
if (durationValue is int) { if (durationValue is int) {
@ -4422,7 +4447,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
source: widget.extensionId, source: widget.extensionId,
); );