fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise

- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
This commit is contained in:
zarzet
2026-04-03 00:56:09 +07:00
parent f2f45fa31d
commit 355f2eba2a
19 changed files with 336 additions and 14 deletions
+30
View File
@@ -1037,6 +1037,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
req.ReleaseDate = enrichedTrack.ReleaseDate
}
if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 {
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
req.TrackNumber = enrichedTrack.TrackNumber
}
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
req.DiscNumber = enrichedTrack.DiscNumber
}
}
}
}
@@ -1427,6 +1435,28 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
return resp, nil
}
+15 -2
View File
@@ -2162,13 +2162,26 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
}
// Use track_number / disc_number from the actual Tidal API data when the
// request doesn't carry them (e.g. downloads from search results / popular).
resolvedTrackNumber := req.TrackNumber
resolvedDiscNumber := req.DiscNumber
if actualTrack != nil {
if resolvedTrackNumber == 0 && actualTrack.TrackNumber > 0 {
resolvedTrackNumber = actualTrack.TrackNumber
}
if resolvedDiscNumber == 0 && actualTrack.VolumeNumber > 0 {
resolvedDiscNumber = actualTrack.VolumeNumber
}
}
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
ISRC: strings.TrimSpace(req.ISRC),
Duration: expectedDurationSec,
TrackNumber: req.TrackNumber,
VolumeNumber: req.DiscNumber,
TrackNumber: resolvedTrackNumber,
VolumeNumber: resolvedDiscNumber,
}
track.Artist.Name = strings.TrimSpace(req.ArtistName)
track.Album.Title = strings.TrimSpace(req.AlbumName)
+12
View File
@@ -2248,6 +2248,18 @@ abstract class AppLocalizations {
/// **'Lyrics not available for this track'**
String get trackLyricsNotAvailable;
/// Message when no embedded lyrics in audio file
///
/// In en, this message translates to:
/// **'No lyrics found in this file'**
String get trackLyricsNotInFile;
/// Action - fetch lyrics from online providers
///
/// In en, this message translates to:
/// **'Fetch from Online'**
String get trackFetchOnlineLyrics;
/// Message when lyrics request times out
///
/// In en, this message translates to:
+6
View File
@@ -1219,6 +1219,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackLyricsNotAvailable =>
'Lyrics sind für diesen Titel nicht verfügbar';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout =>
'Anfrage Timeout. Versuche es später erneut.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1202,6 +1202,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1207,6 +1207,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.';
+6
View File
@@ -1194,6 +1194,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。';
+6
View File
@@ -1180,6 +1180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1220,6 +1220,12 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackLyricsNotAvailable =>
'Текст песни недоступен для этого трека';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout =>
'Время ожидания запроса истекло. Повторите попытку позже.';
+6
View File
@@ -1206,6 +1206,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+6
View File
@@ -1200,6 +1200,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override
String get trackLyricsTimeout => 'Request timed out. Try again later.';
+8
View File
@@ -1563,6 +1563,14 @@
"@trackLyricsNotAvailable": {
"description": "Message when lyrics not found"
},
"trackLyricsNotInFile": "No lyrics found in this file",
"@trackLyricsNotInFile": {
"description": "Message when no embedded lyrics in audio file"
},
"trackFetchOnlineLyrics": "Fetch from Online",
"@trackFetchOnlineLyrics": {
"description": "Action - fetch lyrics from online providers"
},
"trackLyricsTimeout": "Request timed out. Try again later.",
"@trackLyricsTimeout": {
"description": "Message when lyrics request times out"
+172 -2
View File
@@ -69,6 +69,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _lyricsEmbedded = false;
bool _isEmbedding = false;
bool _isInstrumental = false;
bool _embeddedLyricsChecked = false;
bool _isConverting = false;
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
@@ -241,7 +242,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (mounted && exists && _lyrics == null && !_lyricsLoading) {
_fetchLyrics();
_checkEmbeddedLyrics();
}
if (mounted &&
exists &&
@@ -1664,7 +1665,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
TextButton(
onPressed: _fetchLyrics,
onPressed: _fetchOnlineLyrics,
child: Text(context.l10n.dialogRetry),
),
],
@@ -1732,6 +1733,46 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
],
],
)
else if (_embeddedLyricsChecked && _fileExists)
Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.lyrics_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.trackLyricsNotInFile,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 12),
Center(
child: FilledButton.tonalIcon(
onPressed: _fetchOnlineLyrics,
icon: const Icon(Icons.cloud_download_outlined),
label: Text(context.l10n.trackFetchOnlineLyrics),
),
),
],
)
else
Center(
child: FilledButton.tonalIcon(
@@ -1746,6 +1787,134 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
/// Check for lyrics embedded in the audio file only (no network requests).
/// Called automatically when the screen opens.
Future<void> _checkEmbeddedLyrics() async {
if (_lyricsLoading || !_fileExists) return;
setState(() {
_lyricsLoading = true;
_lyricsError = null;
_isInstrumental = false;
_lyricsSource = null;
});
try {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
'',
trackName,
artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(
const Duration(seconds: 5),
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
);
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (mounted) {
if (embeddedLyrics.isNotEmpty) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: 'Embedded';
_lyricsEmbedded = true;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
} else {
setState(() {
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
}
}
}
/// Fetch lyrics from online providers. Only called by user action.
Future<void> _fetchOnlineLyrics() async {
if (_lyricsLoading) return;
setState(() {
_lyricsLoading = true;
_lyricsError = null;
_isInstrumental = false;
_lyricsSource = null;
});
try {
final durationMs = (duration ?? 0) * 1000;
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null,
durationMs: durationMs,
).timeout(const Duration(seconds: 20));
final lrcText = result['lyrics']?.toString() ?? '';
final source = result['source']?.toString() ?? '';
final instrumental =
(result['instrumental'] as bool? ?? false) ||
lrcText == '[instrumental:true]';
if (mounted) {
if (instrumental) {
setState(() {
_isInstrumental = true;
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsLoading = false;
});
} else if (lrcText.isEmpty) {
setState(() {
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
});
} else {
final cleanLyrics = _cleanLrcForDisplay(lrcText);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = lrcText;
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false;
_lyricsLoading = false;
});
}
}
} on TimeoutException {
if (mounted) {
setState(() {
_lyricsError = context.l10n.trackLyricsTimeout;
_lyricsLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_lyricsError = context.l10n.trackLyricsLoadFailed;
_lyricsLoading = false;
});
}
}
}
/// Full lyrics fetch: check embedded first, then online.
/// Used by the "Load Lyrics" button when file doesn't exist (non-local items).
Future<void> _fetchLyrics() async {
if (_lyricsLoading) return;
@@ -1786,6 +1955,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
: 'Embedded';
_lyricsEmbedded = true;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
}
return;
+21 -10
View File
@@ -190,10 +190,10 @@ class FFmpegService {
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
final result = await _execute(command);
@@ -327,7 +327,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.mp3');
final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
@@ -779,7 +779,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.opus');
final command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
final result = await _execute(command);
@@ -852,10 +852,10 @@ class FFmpegService {
String command;
if (codec == 'alac') {
command =
'-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final result = await _execute(command);
@@ -895,8 +895,10 @@ class FFmpegService {
// Run FFmpeg with ebur128 filter + astats for true peak.
// -nostats suppresses the interactive progress line.
// ebur128=peak=true prints integrated loudness + true peak.
// framelog=quiet suppresses per-frame measurements (very verbose),
// keeping only the final summary which we parse.
final command =
'-nostats -i "$filePath" -filter_complex ebur128=peak=true -f null -';
'-hide_banner -nostats -i "$filePath" -filter_complex ebur128=peak=true:framelog=quiet -f null -';
_log.d(
'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}',
@@ -998,7 +1000,7 @@ class FFmpegService {
// -map_metadata 0 preserves all existing metadata from the input.
// -metadata flags add/overwrite only the specified keys.
final command =
'-i "$filePath" -map 0 -c copy -map_metadata 0 '
'-v error -hide_banner -i "$filePath" -map 0 -c copy -map_metadata 0 '
'-metadata REPLAYGAIN_ALBUM_GAIN="$sanitizedGain" '
'-metadata REPLAYGAIN_ALBUM_PEAK="$sanitizedPeak" '
'"$tempOutput" -y';
@@ -1048,6 +1050,7 @@ class FFmpegService {
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$flacPath" ');
if (coverPath != null) {
@@ -1127,6 +1130,7 @@ class FFmpegService {
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
@@ -1213,6 +1217,9 @@ class FFmpegService {
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final mapMetaValue = preserveMetadata ? '0' : '-1';
final arguments = <String>[
'-v',
'error',
'-hide_banner',
'-i',
opusPath,
'-map',
@@ -1305,6 +1312,7 @@ class FFmpegService {
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$m4aPath" ');
final hasCover = coverPath != null && await File(coverPath).exists();
@@ -1528,10 +1536,10 @@ class FFmpegService {
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
}
_log.i(
@@ -1605,6 +1613,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final hasCover =
@@ -1667,6 +1676,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.flac');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final hasCover =
@@ -2080,6 +2090,7 @@ class FFmpegService {
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$audioPath" ');
final startTime = _formatSecondsForFFmpeg(track.startSec);