diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ad9d5c6d..518e7aad 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1376,6 +1376,143 @@ class DownloadQueueNotifier extends Notifier { } } + Future _embedMetadataToOpus( + String opusPath, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { + final settings = ref.read(settingsProvider); + + String? coverPath; + var coverUrl = track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + try { + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality for Opus: $coverUrl'); + } + + final tempDir = await getTemporaryDirectory(); + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + coverPath = '${tempDir.path}/cover_opus_$uniqueId.jpg'; + + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(coverUrl)); + final response = await request.close(); + if (response.statusCode == 200) { + final file = File(coverPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + _log.d('Cover downloaded for Opus: $coverPath'); + } else { + _log.w('Failed to download cover for Opus: HTTP ${response.statusCode}'); + coverPath = null; + } + httpClient.close(); + } catch (e) { + _log.e('Failed to download cover for Opus: $e'); + coverPath = null; + } + } + + try { + final metadata = { + 'TITLE': track.name, + 'ARTIST': track.artistName, + 'ALBUM': track.albumName, + }; + + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? + track.artistName; + metadata['ALBUMARTIST'] = albumArtist; + + if (track.trackNumber != null) { + metadata['TRACKNUMBER'] = track.trackNumber.toString(); + } + + if (track.discNumber != null) { + metadata['DISCNUMBER'] = track.discNumber.toString(); + } + + if (track.releaseDate != null) { + metadata['DATE'] = track.releaseDate!; + } + + if (track.isrc != null) { + metadata['ISRC'] = track.isrc!; + } + + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE to Opus: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label) to Opus: $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT to Opus: $copyright'); + } + + _log.d('Opus Metadata map content: $metadata'); + + if (settings.embedLyrics) { + try { + final durationMs = track.duration * 1000; + + final lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (lrcContent.isNotEmpty) { + metadata['LYRICS'] = lrcContent; + _log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for Opus embedding: $e'); + } + } + + _log.d('Embedding tags to Opus: $metadata'); + + final result = await FFmpegService.embedMetadataToOpus( + opusPath: opusPath, + coverPath: coverPath != null && await File(coverPath).exists() + ? coverPath + : null, + metadata: metadata, + ); + + if (result != null) { + _log.d('Metadata, lyrics, and cover embedded to Opus via FFmpeg'); + } else { + _log.w('FFmpeg Opus metadata/cover embed failed'); + } + + if (coverPath != null) { + try { + final coverFile = File(coverPath); + if (await coverFile.exists()) { + await coverFile.delete(); + } + } catch (e) { + _log.w('Failed to cleanup Opus cover file: $e'); + } + } + } catch (e) { + _log.e('Failed to embed metadata to Opus: $e'); + } + } + Future _processQueue() async { if (state.isProcessing) return; @@ -1993,25 +2130,33 @@ class DownloadQueueNotifier extends Notifier { actualQuality = lossyFormat == 'opus' ? 'Opus 128kbps' : 'MP3 320kbps'; _log.i('Successfully converted to $lossyFormat: $convertedPath'); - // Only embed metadata for MP3 (Opus uses Vorbis comments which are preserved) + // Embed metadata and cover for both MP3 and Opus + _log.i('Embedding metadata to $lossyFormat...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + + final lossyBackendGenre = result['genre'] as String?; + final lossyBackendLabel = result['label'] as String?; + final lossyBackendCopyright = result['copyright'] as String?; + if (lossyFormat == 'mp3') { - _log.i('Embedding metadata to MP3...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, - ); - - final mp3BackendGenre = result['genre'] as String?; - final mp3BackendLabel = result['label'] as String?; - final mp3BackendCopyright = result['copyright'] as String?; - await _embedMetadataToMp3( convertedPath, trackToDownload, - genre: mp3BackendGenre ?? genre, - label: mp3BackendLabel ?? label, - copyright: mp3BackendCopyright, + genre: lossyBackendGenre ?? genre, + label: lossyBackendLabel ?? label, + copyright: lossyBackendCopyright, + ); + } else if (lossyFormat == 'opus') { + await _embedMetadataToOpus( + convertedPath, + trackToDownload, + genre: lossyBackendGenre ?? genre, + label: lossyBackendLabel ?? label, + copyright: lossyBackendCopyright, ); } } else { diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 22d4e272..478eff21 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -323,6 +323,86 @@ class FFmpegService { return null; } + static Future embedMetadataToOpus({ + required String opusPath, + String? coverPath, + Map? metadata, + }) async { + final tempDir = await getTemporaryDirectory(); + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus'; + + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$opusPath" '); + + if (coverPath != null) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 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)" '); + } + + cmdBuffer.write('-c:a copy '); + + if (metadata != null) { + metadata.forEach((key, value) { + final sanitizedValue = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitizedValue" '); + }); + } + + cmdBuffer.write('"$tempOutput" -y'); + + final command = cmdBuffer.toString(); + _log.d('Executing FFmpeg Opus embed command: $command'); + + final result = await _execute(command); + + if (result.success) { + try { + final tempFile = File(tempOutput); + final originalFile = File(opusPath); + + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(opusPath); + await tempFile.delete(); + + _log.d('Opus metadata embedded successfully'); + return opusPath; + } else { + _log.e('Temp Opus output file not found: $tempOutput'); + return null; + } + + } catch (e) { + _log.e('Failed to replace Opus file after metadata embed: $e'); + return null; + } + } + + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + await tempFile.delete(); + } + } catch (e) { + _log.w('Failed to cleanup temp Opus file: $e'); + } + + _log.e('Opus Metadata/Cover embed failed: ${result.output}'); + return null; + } + static Map _convertToId3Tags(Map vorbisMetadata) { final id3Map = {};