v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search

This commit is contained in:
zarzet
2026-01-10 04:32:47 +07:00
parent 8726a0858a
commit bea5dd1d4a
16 changed files with 621 additions and 312 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '2.1.7';
static const String buildNumber = '45';
static const String version = '2.2.0';
static const String buildNumber = '46';
static const String fullVersion = '$version+$buildNumber';
+1 -1
View File
@@ -25,7 +25,7 @@ class AppSettings {
final String metadataSource; // spotify, deezer - source for search and metadata
const AppSettings({
this.defaultService = 'qobuz',
this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
+72 -33
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -35,6 +36,9 @@ class DownloadHistoryItem {
final int? duration;
final String? releaseDate;
final String? quality;
// Audio quality info (from file after download)
final int? bitDepth;
final int? sampleRate;
const DownloadHistoryItem({
required this.id,
@@ -53,6 +57,8 @@ class DownloadHistoryItem {
this.duration,
this.releaseDate,
this.quality,
this.bitDepth,
this.sampleRate,
});
Map<String, dynamic> toJson() => {
@@ -72,6 +78,8 @@ class DownloadHistoryItem {
'duration': duration,
'releaseDate': releaseDate,
'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
@@ -91,6 +99,8 @@ class DownloadHistoryItem {
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
);
}
@@ -763,7 +773,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
try {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
// Download cover using HTTP
@@ -821,6 +831,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
_log.d('Metadata map content: $metadata');
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
// Since we are in the Flutter context, we can call the bridge to get lyrics
@@ -1094,40 +1106,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// If track number is missing/0 (common from Search results), fetch full metadata
// This ensures the downloaded file has correct tags (Track, Disc, Year)
Track trackToDownload = item.track;
if (trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0) {
// Enrich metadata if ISRC or track number is missing (common from Search results)
// ISRC is critical for accurate track matching on streaming services
final needsEnrichment = trackToDownload.id.startsWith('deezer:') &&
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0);
if (needsEnrichment) {
try {
if (trackToDownload.id.startsWith('deezer:')) {
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
final rawId = trackToDownload.id.split(':')[1];
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
if (fullData.containsKey('track')) {
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
// Merge with existing (keep override quality/service if any, but update metadata)
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
_log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}');
final rawId = trackToDownload.id.split(':')[1];
_log.d('Fetching full metadata for Deezer ID: $rawId');
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
_log.d('Got response keys: ${fullData.keys.toList()}');
if (fullData.containsKey('track')) {
// Parse Go backend response (snake_case) to Track
final trackData = fullData['track'];
_log.d('Track data type: ${trackData.runtimeType}');
if (trackData is Map<String, dynamic>) {
final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}');
trackToDownload = Track(
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
name: fullTrack.name,
artistName: fullTrack.artistName,
albumName: fullTrack.albumName,
albumArtist: fullTrack.albumArtist,
coverUrl: fullTrack.coverUrl,
duration: fullTrack.duration,
isrc: fullTrack.isrc ?? trackToDownload.isrc,
trackNumber: fullTrack.trackNumber,
discNumber: fullTrack.discNumber,
releaseDate: fullTrack.releaseDate,
deezerId: fullTrack.deezerId,
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name,
artistName: (data['artists'] as String?) ?? trackToDownload.artistName,
albumName: (data['album_name'] as String?) ?? trackToDownload.albumName,
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
// duration_ms from Go is in milliseconds, Track.duration is in seconds
duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000,
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability,
);
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
// Update item in state with enriched track
// This is important so the UI (and history) reflects the enriched data
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
}
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}');
} else {
_log.w('Unexpected track data type: ${trackData.runtimeType}');
}
} else {
_log.w('Response does not contain track key');
}
} catch (e) {
} catch (e, stack) {
_log.w('Failed to enrich metadata: $e');
_log.w('Stack trace: $stack');
}
}
@@ -1257,7 +1284,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?;
final backendAlbum = result['album'] as String?;
// Create updated track object
_log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear');
// Create updated track object with safety check for 0/null
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber;
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber;
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber');
finalTrack = Track(
id: trackToDownload.id,
name: trackToDownload.name,
@@ -1267,8 +1301,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: trackToDownload.isrc,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber,
trackNumber: newTrackNumber,
discNumber: newDiscNumber,
releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
@@ -1337,6 +1371,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
@@ -1350,13 +1387,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
// Additional metadata
isrc: item.track.isrc,
isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc,
spotifyId: item.track.id,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
duration: item.track.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
quality: actualQuality,
bitDepth: backendBitDepth,
sampleRate: backendSampleRate,
),
);
+64 -59
View File
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final file = File(widget.item.filePath);
final exists = await file.exists();
int? size;
if (exists) {
try {
size = await file.length();
} catch (_) {}
}
if (mounted) {
setState(() {
_fileExists = exists;
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// Use data directly from history item (cached from download)
DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
String get albumName => item.albumName;
String? get albumArtist => item.albumArtist;
int? get trackNumber => item.trackNumber;
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
int? get bitDepth => item.bitDepth;
int? get sampleRate => item.sampleRate;
@override
Widget build(BuildContext context) {
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track name
// Track name (from file metadata)
Text(
item.trackName,
trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 4),
// Artist name
// Artist name (from file metadata)
Text(
item.artistName,
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
// Album name
// Album name (from file metadata)
Row(
children: [
Icon(
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
item.albumName,
albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -401,28 +414,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
}
final items = <_MetadataItem>[
_MetadataItem('Track name', item.trackName),
_MetadataItem('Artist', item.artistName),
if (item.albumArtist != null && item.albumArtist != item.artistName)
_MetadataItem('Album artist', item.albumArtist!),
_MetadataItem('Album', item.albumName),
if (item.trackNumber != null)
_MetadataItem('Track number', item.trackNumber.toString()),
if (item.discNumber != null && item.discNumber! > 1)
_MetadataItem('Disc number', item.discNumber.toString()),
_MetadataItem('Track name', trackName),
_MetadataItem('Artist', artistName),
if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', albumName),
if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', trackNumber.toString()),
if (discNumber != null && discNumber! > 1)
_MetadataItem('Disc number', discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.quality != null && item.quality!.contains('bit'))
_MetadataItem('Audio quality', item.quality!),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty)
_MetadataItem('ISRC', item.isrc!),
if (audioQualityStr != null)
_MetadataItem('Audio quality', audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', releaseDate!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
_MetadataItem('Spotify ID', item.spotifyId!),
if (item.quality != null && item.quality!.isNotEmpty)
_MetadataItem('Quality', _formatQuality(item.quality!)),
_MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
];
@@ -476,32 +494,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
String _formatQuality(String quality) {
switch (quality) {
case 'LOSSLESS':
return 'Lossless (16-bit)';
case 'HI_RES':
return 'Hi-Res (24-bit)';
case 'HI_RES_LOSSLESS':
return 'Hi-Res Lossless (24-bit)';
default:
return quality;
}
}
String _formatQualityShort(String quality) {
switch (quality) {
case 'LOSSLESS':
return '16-bit';
case 'HI_RES':
return '24-bit';
case 'HI_RES_LOSSLESS':
return 'Hi-Res';
default:
return quality;
}
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last;
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
@@ -570,7 +562,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
if (item.quality != null)
if (bitDepth != null && sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -578,7 +570,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatQualityShort(item.quality!),
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
@@ -891,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
@@ -908,10 +900,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove from history?'),
title: const Text('Remove from device?'),
content: const Text(
'This will remove the track from your download history. '
'The downloaded file will not be deleted.',
'This will permanently delete the downloaded file and remove it from your history.',
),
actions: [
TextButton(
@@ -919,12 +910,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
onPressed: () async {
// Delete the file first
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
debugPrint('Failed to delete file: $e');
}
// Remove from history
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history
if (context.mounted) {
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history
}
},
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
),
],
),
+14 -1
View File
@@ -111,7 +111,7 @@ class PlatformBridge {
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String preferredService = 'qobuz',
String preferredService = 'tidal',
String? itemId,
int durationMs = 0,
}) async {
@@ -248,6 +248,16 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections');
}
/// Read metadata directly from a FLAC file
/// Returns all embedded metadata (title, artist, album, track number, etc.)
/// This reads from the actual file, not from cached/database data
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({
String trackName = '',
@@ -335,6 +345,9 @@ class PlatformBridge {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
}
return jsonDecode(result as String) as Map<String, dynamic>;
}