refactor(models): add Track.fromBackendMap and copyWith, dedupe track parsing

The provider and the redownload service each hand-rolled the same
backend-map parsing and duration extraction. The factory ports the richer
provider variant (cover normalization, native-vs-spotify preferredId), so
the redownload service gains those for free. checkAvailability's 30-line
manual reconstruction becomes copyWith, which also stops silently
dropping previewUrl, totalDiscs, and deezerId.
This commit is contained in:
zarzet
2026-07-11 16:36:09 +07:00
parent b12502c8f6
commit fca3039208
3 changed files with 108 additions and 150 deletions
+100
View File
@@ -1,4 +1,6 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
part 'track.g.dart';
@@ -79,6 +81,104 @@ class Track {
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Builds a [Track] from a backend/extension search payload map.
factory Track.fromBackendMap(Map<String, dynamic> data, {String? source}) {
final durationMs = extractDurationMs(data);
final itemType = data['item_type']?.toString();
final effectiveSource =
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
? (nativeId.isNotEmpty ? nativeId : spotifyId)
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
return Track(
id: preferredId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: effectiveSource,
albumType: normalizeOptionalString(data['album_type']?.toString()),
composer: data['composer']?.toString(),
itemType: itemType,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
Track copyWith({
String? id,
String? name,
String? artistName,
String? albumName,
String? albumArtist,
String? artistId,
String? albumId,
String? coverUrl,
String? isrc,
String? previewUrl,
int? duration,
int? trackNumber,
int? discNumber,
int? totalDiscs,
String? releaseDate,
String? deezerId,
ServiceAvailability? availability,
String? source,
String? albumType,
int? totalTracks,
String? composer,
String? itemType,
String? audioQuality,
String? audioModes,
bool? explicit,
}) {
return Track(
id: id ?? this.id,
name: name ?? this.name,
artistName: artistName ?? this.artistName,
albumName: albumName ?? this.albumName,
albumArtist: albumArtist ?? this.albumArtist,
artistId: artistId ?? this.artistId,
albumId: albumId ?? this.albumId,
coverUrl: coverUrl ?? this.coverUrl,
isrc: isrc ?? this.isrc,
previewUrl: previewUrl ?? this.previewUrl,
duration: duration ?? this.duration,
trackNumber: trackNumber ?? this.trackNumber,
discNumber: discNumber ?? this.discNumber,
totalDiscs: totalDiscs ?? this.totalDiscs,
releaseDate: releaseDate ?? this.releaseDate,
deezerId: deezerId ?? this.deezerId,
availability: availability ?? this.availability,
source: source ?? this.source,
albumType: albumType ?? this.albumType,
totalTracks: totalTracks ?? this.totalTracks,
composer: composer ?? this.composer,
itemType: itemType ?? this.itemType,
audioQuality: audioQuality ?? this.audioQuality,
audioModes: audioModes ?? this.audioModes,
explicit: explicit ?? this.explicit,
);
}
bool get isFromExtension => source != null && source!.isNotEmpty;
bool get isDolbyAtmos =>
+6 -94
View File
@@ -273,7 +273,7 @@ class TrackNotifier extends Notifier<TrackState> {
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
final track = Track.fromBackendMap(trackData, source: extensionId);
if (track.name.isEmpty) {
state = TrackState(
@@ -295,7 +295,7 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map(
(t) => _parseSearchTrack(
(t) => Track.fromBackendMap(
t as Map<String, dynamic>,
source: extensionId,
),
@@ -328,7 +328,7 @@ class TrackNotifier extends Notifier<TrackState> {
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
(t) => Track.fromBackendMap(
t as Map<String, dynamic>,
source: extensionId,
),
@@ -489,7 +489,7 @@ class TrackNotifier extends Notifier<TrackState> {
for (int i = 0; i < trackSearchResults.length; i++) {
final t = trackSearchResults[i];
try {
tracks.add(_parseSearchTrack(t));
tracks.add(Track.fromBackendMap(t));
} catch (e) {
_log.e('Failed to parse track[$i]: $e', e);
}
@@ -604,7 +604,7 @@ class TrackNotifier extends Notifier<TrackState> {
for (int i = 0; i < results.length; i++) {
final t = results[i];
try {
tracks.add(_parseSearchTrack(t, source: extensionId));
tracks.add(Track.fromBackendMap(t, source: extensionId));
} catch (e) {
_log.e('Failed to parse custom search track[$i]: $e', e);
}
@@ -729,28 +729,7 @@ class TrackNotifier extends Notifier<TrackState> {
track.id,
track.isrc!,
);
final updatedTrack = Track(
id: track.id,
name: track.name,
artistName: track.artistName,
albumName: track.albumName,
albumArtist: track.albumArtist,
artistId: track.artistId,
albumId: track.albumId,
coverUrl: track.coverUrl,
isrc: track.isrc,
duration: track.duration,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
totalTracks: track.totalTracks,
source: track.source,
composer: track.composer,
itemType: track.itemType,
audioQuality: track.audioQuality,
audioModes: track.audioModes,
explicit: track.explicit,
final updatedTrack = track.copyWith(
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -810,73 +789,6 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
final effectiveSource =
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
? (nativeId.isNotEmpty ? nativeId : spotifyId)
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
return Track(
id: preferredId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: effectiveSource,
albumType: normalizeOptionalString(data['album_type']?.toString()),
composer: data['composer']?.toString(),
itemType: itemType,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
@@ -57,7 +57,7 @@ class LocalTrackRedownloadService {
rawResults
.map(
(raw) => (
track: _parseSearchTrack(raw),
track: Track.fromBackendMap(raw),
score: _scoreMatch(item, raw),
),
)
@@ -133,62 +133,8 @@ class LocalTrackRedownloadService {
return '${item.trackName} $artist'.trim();
}
static Track _parseSearchTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
previewUrl: data['preview_url']?.toString(),
);
}
static int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
final track = _parseSearchTrack(raw);
final track = Track.fromBackendMap(raw);
var score = 0;
final localIsrc = _normalizedIsrc(item.isrc);