fix: align metadata sanitization and lyrics editing

This commit is contained in:
zarzet
2026-04-09 16:53:08 +07:00
parent 8615cde898
commit c104a5d8a3
7 changed files with 427 additions and 198 deletions
@@ -308,7 +308,24 @@ class MainActivity: FlutterFragmentActivity() {
}
private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
}
private fun sanitizeRelativeDir(relativeDir: String): String {
+1
View File
@@ -1361,6 +1361,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
DiscNumber: discNum,
TotalDiscs: totalDiscs,
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
+29 -4
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
@@ -17,19 +19,42 @@ var (
)
func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_")
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
if len(sanitized) > 200 {
sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
if sanitized == "" {
sanitized = "untitled"
return "Unknown"
}
return sanitized
+15
View File
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
+27 -5
View File
@@ -26,7 +26,10 @@ final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trailingDotsRegex = RegExp(r'\.+$');
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
final _multiWhitespaceRegex = RegExp(r'\s+');
final _multiUnderscoreRegex = RegExp(r'_+');
/// log10 helper using dart:math's natural log.
double _log10(num x) => log(x) / ln10;
@@ -2165,10 +2168,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String _sanitizeFolderName(String name) {
return name
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '')
.trim();
final buffer = StringBuffer();
for (final rune in name.runes) {
if (rune < 0x20 || rune == 0x7f) {
continue;
}
final char = String.fromCharCode(rune);
if (_invalidFolderChars.hasMatch(char)) {
buffer.write(' ');
continue;
}
buffer.write(char);
}
var sanitized = buffer.toString().trim();
sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, '');
sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' ');
sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_');
sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, '');
if (sanitized.isEmpty) {
return 'Unknown';
}
return sanitized;
}
static final _featuredArtistPattern = RegExp(
+123 -40
View File
@@ -4270,8 +4270,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'copyright': val('copyright', copyright),
'composer': val('composer', composer),
'comment': fileMetadata?['comment']?.toString() ?? '',
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
};
final initialDurationSeconds =
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>(
@@ -4287,6 +4291,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
initialValues: initialValues,
filePath: cleanFilePath,
sourceTrackId: _spotifyId,
durationMs: initialDurationSeconds > 0
? initialDurationSeconds * 1000
: 0,
artistTagMode: ref.read(settingsProvider).artistTagMode,
),
);
@@ -4297,7 +4304,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? '';
setState(() {
_editedMetadata = refreshed;
_lyricsError = null;
_isInstrumental = false;
_embeddedLyricsChecked = true;
if (refreshedLyrics.isNotEmpty) {
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
_rawLyrics = refreshedLyrics;
_lyricsSource = 'Embedded';
_lyricsEmbedded = true;
} else {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = null;
_lyricsEmbedded = false;
}
});
} catch (_) {
setState(() {});
}
@@ -4514,6 +4538,7 @@ class _EditMetadataSheet extends StatefulWidget {
final Map<String, String> initialValues;
final String filePath;
final String? sourceTrackId;
final int durationMs;
final String artistTagMode;
const _EditMetadataSheet({
@@ -4521,6 +4546,7 @@ class _EditMetadataSheet extends StatefulWidget {
required this.initialValues,
required this.filePath,
this.sourceTrackId,
required this.durationMs,
required this.artistTagMode,
});
@@ -4560,6 +4586,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'total_discs': 'total_discs',
'genre': 'genre',
'isrc': 'isrc',
'lyrics': 'lyrics',
'label': 'label',
'copyright': 'copyright',
'composer': 'composer',
@@ -4577,6 +4604,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
late final TextEditingController _discTotalCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _lyricsCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
@@ -4772,6 +4800,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return l10n.editMetadataFieldGenre;
case 'isrc':
return l10n.editMetadataFieldIsrc;
case 'lyrics':
return l10n.trackLyrics;
case 'label':
return l10n.editMetadataFieldLabel;
case 'copyright':
@@ -4809,6 +4839,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return _genreCtrl;
case 'isrc':
return _isrcCtrl;
case 'lyrics':
return _lyricsCtrl;
case 'label':
return _labelCtrl;
case 'copyright':
@@ -5107,19 +5139,23 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final artist = _artistCtrl.text.trim();
final album = _albumCtrl.text.trim();
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
Map<String, dynamic>? best;
String? deezerId;
try {
final resolved = await _resolveAutoFillTrackFromIdentifiers(
currentIsrc,
);
if (resolved != null) {
best = resolved.track;
deezerId = resolved.deezerId;
if (needsTrackLookup) {
try {
final resolved = await _resolveAutoFillTrackFromIdentifiers(
currentIsrc,
);
if (resolved != null) {
best = resolved.track;
deezerId = resolved.deezerId;
}
} catch (e) {
_log.w('Identifier-first autofill lookup failed: $e');
}
} catch (e) {
_log.w('Identifier-first autofill lookup failed: $e');
}
final queryParts = <String>[];
@@ -5127,7 +5163,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (artist.isNotEmpty) queryParts.add(artist);
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
if (best == null && queryParts.isEmpty) {
if (needsTrackLookup && best == null && queryParts.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
@@ -5140,7 +5176,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final normalizedArtist = _normalizeMetadataText(artist);
final normalizedAlbum = _normalizeMetadataText(album);
if (best == null) {
if (needsTrackLookup && best == null) {
final query = queryParts.join(' ');
final results = await PlatformBridge.searchTracksWithMetadataProviders(
query,
@@ -5175,39 +5211,47 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
final selectedBest = best;
if (selectedBest == null) {
if (needsTrackLookup && selectedBest == null) {
throw StateError('No metadata match resolved for auto-fill');
}
final enriched = <String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
.toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'date': (selectedBest['release_date'] ?? '').toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(),
};
_mergeOnlineTrackData(enriched, selectedBest);
final enriched = <String, String>{};
if (selectedBest != null) {
enriched.addAll(<String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
.toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'date': (selectedBest['release_date'] ?? '').toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(),
});
_mergeOnlineTrackData(enriched, selectedBest);
}
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
final needsIsrc =
_autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty;
_autoFillFields.contains('isrc') && enrichedIsrc.isEmpty;
final needsExtended =
_autoFillFields.contains('genre') ||
_autoFillFields.contains('label') ||
_autoFillFields.contains('copyright') ||
_autoFillFields.contains('composer');
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
final rawSpotifyId = selectedBest == null
? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId)
: _extractRawSpotifyTrackId(selectedBest);
deezerId ??= _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enriched['isrc']!.trim().toUpperCase();
deezerId ??= selectedBest == null
? null
: _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enrichedIsrc.toUpperCase();
final deezerLookupIsrc = _looksLikeIsrc(currentIsrc)
? currentIsrc
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
@@ -5243,7 +5287,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Fetch ISRC from Deezer track metadata if still missing
if (needsIsrc && enriched['isrc']!.isEmpty && deezerId != null) {
if (needsIsrc &&
(enriched['isrc'] ?? '').trim().isEmpty &&
deezerId != null) {
try {
final deezerMeta = await PlatformBridge.getDeezerMetadata(
'track',
@@ -5275,6 +5321,37 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
if (shouldFetchLyrics) {
final lyricsTitle =
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
.toString())
.trim();
final lyricsArtist =
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
.toString())
.trim();
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
try {
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
rawSpotifyId ?? '',
lyricsTitle,
lyricsArtist,
durationMs: widget.durationMs,
);
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
final instrumental =
(lyricsResult['instrumental'] as bool? ?? false) ||
lyricsText == '[instrumental:true]';
if (!instrumental && lyricsText.isNotEmpty) {
enriched['lyrics'] = lyricsText;
}
} catch (e) {
_log.w('Lyrics autofill failed: $e');
}
}
}
if (!mounted) return;
var filledCount = 0;
@@ -5293,7 +5370,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
if (_autoFillFields.contains('cover')) {
if (_autoFillFields.contains('cover') && selectedBest != null) {
final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
.toString();
@@ -5369,6 +5446,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_lyricsCtrl = TextEditingController(text: v['lyrics'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
@@ -5391,6 +5469,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_lyricsCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
@@ -5413,6 +5492,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
@@ -5477,6 +5557,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
: '',
'GENRE': metadata['genre'] ?? '',
'ISRC': metadata['isrc'] ?? '',
'LYRICS': metadata['lyrics'] ?? '',
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
'ORGANIZATION': metadata['label'] ?? '',
'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '',
@@ -5486,11 +5568,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
if (existingLyrics != null && existingLyrics.isNotEmpty) {
vorbisMap['LYRICS'] = existingLyrics;
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
}
// Preserve ReplayGain tags if present — these are computed once
// during download and should survive manual metadata edits.
final rgFields = <String, String>{
@@ -5717,6 +5794,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
_field(
context.l10n.trackLyrics,
_lyricsCtrl,
maxLines: 8,
keyboard: TextInputType.multiline,
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
+214 -148
View File
@@ -1139,20 +1139,28 @@ class FFmpegService {
: '.tmp';
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final sanitizedGain = albumGain.replaceAll('"', '\\"');
final sanitizedPeak = albumPeak.replaceAll('"', '\\"');
// -map_metadata 0 preserves all existing metadata from the input.
// -metadata flags add/overwrite only the specified keys.
final command =
'-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';
final arguments = <String>[
'-v',
'error',
'-hide_banner',
'-i',
filePath,
'-map',
'0',
'-c',
'copy',
'-map_metadata',
'0',
'-metadata',
'REPLAYGAIN_ALBUM_GAIN=$albumGain',
'-metadata',
'REPLAYGAIN_ALBUM_PEAK=$albumPeak',
tempOutput,
'-y',
];
_log.d('Writing album ReplayGain tags via FFmpeg');
final result = await _execute(command);
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
@@ -1194,41 +1202,50 @@ class FFmpegService {
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$flacPath" ');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', flacPath];
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
arguments
..add('-i')
..add(coverPath);
}
cmdBuffer.write('-map 0:a ');
arguments
..add('-map')
..add('0:a');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
cmdBuffer.write('-disposition:v attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
arguments
..add('-map')
..add('1:0')
..add('-c:v')
..add('copy')
..add('-disposition:v')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
cmdBuffer.write('-c:a copy ');
arguments
..add('-c:a')
..add('copy');
if (metadata != null) {
_appendVorbisMetadataToCommandBuffer(
cmdBuffer,
_appendVorbisMetadataToArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
);
}
cmdBuffer.write('"$tempOutput" -y');
arguments
..add(tempOutput)
..add('-y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
final result = await _execute(command);
_log.d('Executing FFmpeg FLAC embed command');
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
@@ -1274,46 +1291,50 @@ class FFmpegService {
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$mp3Path" ');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', mp3Path];
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
arguments
..add('-i')
..add(coverPath);
}
cmdBuffer.write('-map 0:a ');
cmdBuffer.write(
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
);
arguments
..add('-map')
..add('0:a')
..add('-map_metadata')
..add(preserveMetadata ? '0' : '-1');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
arguments
..add('-map')
..add('1:0')
..add('-c:v:0')
..add('copy')
..add('-id3v2_version')
..add('3')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
cmdBuffer.write('-c:a copy ');
arguments
..add('-c:a')
..add('copy');
if (metadata != null) {
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
_appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata));
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
arguments
..add('-id3v2_version')
..add('3')
..add(tempOutput)
..add('-y');
final command = cmdBuffer.toString();
_log.d(
'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
_log.d('Executing FFmpeg MP3 embed command');
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
@@ -1456,10 +1477,7 @@ class FFmpegService {
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$m4aPath" ');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
final normalizedCoverPath = coverPath?.trim();
final hasCover =
@@ -1467,48 +1485,61 @@ class FFmpegService {
normalizedCoverPath.isNotEmpty &&
await File(normalizedCoverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$normalizedCoverPath" ');
arguments
..add('-i')
..add(normalizedCoverPath);
}
final preserveExistingStreams = preserveMetadata && !hasCover;
if (preserveExistingStreams) {
// When no replacement cover is provided, preserve all input streams so
// the existing attached artwork is not dropped during the metadata rewrite.
cmdBuffer.write('-map 0 -c copy ');
arguments
..add('-map')
..add('0')
..add('-c')
..add('copy');
} else {
cmdBuffer.write('-map 0:a -c:a copy ');
arguments
..add('-map')
..add('0:a')
..add('-c:a')
..add('copy');
}
cmdBuffer.write(
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
);
arguments
..add('-map_metadata')
..add(preserveMetadata ? '0' : '-1');
// For M4A cover replacements, mark the image as an attached picture so the
// mp4 muxer writes a proper covr atom instead of a generic MJPEG video track.
// Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a)
// does not register a codec tag for mjpeg on FFmpeg 8.0+.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
cmdBuffer.write('-f mp4 ');
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)')
..add('-f')
..add('mp4');
}
if (metadata != null) {
final m4aMetadata = _convertToM4aTags(metadata);
for (final entry in m4aMetadata.entries) {
final sanitizedValue = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
}
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
}
cmdBuffer.write('"$tempOutput" -y');
arguments
..add(tempOutput)
..add('-y');
final command = cmdBuffer.toString();
_log.d(
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
_log.d('Executing FFmpeg M4A embed command');
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
@@ -1767,40 +1798,50 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
arguments
..add('-i')
..add(coverPath);
}
cmdBuffer.write('-map 0:a ');
arguments
..add('-map')
..add('0:a');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
arguments
..add('-c:a')
..add('alac')
..add('-map_metadata')
..add('-1');
final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
cmdBuffer.write('"$outputPath" -y');
arguments
..add(outputPath)
..add('-y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
);
final result = await _execute(cmdBuffer.toString());
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}');
@@ -1830,40 +1871,56 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
arguments
..add('-i')
..add(coverPath);
}
cmdBuffer.write('-map 0:a ');
arguments
..add('-map')
..add('0:a');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 ');
arguments
..add('-c:a')
..add('flac')
..add('-compression_level')
..add('8')
..add('-map_metadata')
..add('0');
_appendVorbisMetadataToCommandBuffer(
cmdBuffer,
_appendVorbisMetadataToArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
);
cmdBuffer.write('"$outputPath" -y');
arguments
..add(outputPath)
..add('-y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
);
final result = await _execute(cmdBuffer.toString());
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}');
@@ -1969,20 +2026,6 @@ class FFmpegService {
return vorbis;
}
static void _appendVorbisMetadataToCommandBuffer(
StringBuffer cmdBuffer,
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
for (final entry in _buildVorbisMetadataEntries(
metadata,
artistTagMode: artistTagMode,
)) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
}
static void _appendVorbisMetadataToArguments(
List<String> arguments,
Map<String, String> metadata, {
@@ -1998,6 +2041,17 @@ class FFmpegService {
}
}
static void _appendMappedMetadataToArguments(
List<String> arguments,
Map<String, String> metadata,
) {
for (final entry in metadata.entries) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
@@ -2255,23 +2309,36 @@ class FFmpegService {
final trackNumStr = track.number.toString().padLeft(2, '0');
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$audioPath" ');
final arguments = <String>[
'-v',
'error',
'-hide_banner',
'-i',
audioPath,
];
final startTime = _formatSecondsForFFmpeg(track.startSec);
cmdBuffer.write('-ss $startTime ');
arguments
..add('-ss')
..add(startTime);
if (track.endSec > 0) {
final endTime = _formatSecondsForFFmpeg(track.endSec);
cmdBuffer.write('-to $endTime ');
arguments
..add('-to')
..add(endTime);
}
if (outputExt == 'flac') {
cmdBuffer.write('-c:a flac -compression_level 8 ');
arguments
..add('-c:a')
..add('flac')
..add('-compression_level')
..add('8');
} else {
cmdBuffer.write('-c:a copy ');
arguments
..add('-c:a')
..add('copy');
}
final artist = track.artist.isNotEmpty
@@ -2280,11 +2347,11 @@ class FFmpegService {
final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? '';
final date = albumMetadata['date'] ?? '';
final cueMetadata = <String, String>{};
void addMeta(String key, String value) {
if (value.isNotEmpty) {
final sanitized = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitized" ');
cueMetadata[key] = value;
}
}
@@ -2298,14 +2365,13 @@ class FFmpegService {
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer);
cmdBuffer.write('"$outputPath" -y');
_appendMappedMetadataToArguments(arguments, cueMetadata);
arguments
..add(outputPath)
..add('-y');
final command = cmdBuffer.toString();
_log.d(
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
_log.d('CUE split track ${track.number}');
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('CUE split failed for track ${track.number}: ${result.output}');
continue;