diff --git a/CHANGELOG.md b/CHANGELOG.md index cdef96b8..4aa35b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ ### Added -- **Lossy Format Support**: Download in MP3 or Opus format with configurable quality - - New "Enable Lossy Option" toggle in Settings > Download > Audio Quality - - Choose between MP3 (320kbps) or Opus (128kbps) format - - Downloads FLAC first, then converts using FFmpeg +- **IDHS Fallback**: Added I Don't Have Spotify (IDHS) as fallback link resolver when SongLink fails + - Automatically tries IDHS when SongLink returns errors or rate limits + - Supports both Spotify→other platforms and Deezer→other platforms lookups + - Rate limited to 8 requests/minute to respect API limits +- **Lossy Bitrate Options**: More quality choices for lossy format downloads + - MP3: 320kbps (Best), 256kbps, 192kbps, 128kbps + - Opus: 128kbps (Best), 96kbps, 64kbps + - Replaces the simple MP3/Opus toggle with a unified quality picker - **Search Filters**: Filter search results by type (Tracks, Artists, Albums, Playlists) - Works with both default Deezer search and extension search providers - Filter chips appear below search bar when results are shown @@ -18,19 +22,12 @@ ### Changed -- **FFmpeg Plugin Migration**: Replaced custom FFmpeg AAR with `ffmpeg_kit_flutter_new_audio` plugin - - Unified FFmpeg implementation for both Android and iOS - - Removed custom FFmpeg MethodChannel from MainActivity - - Simplified build process (no more custom AAR in android/app/libs/) - **Amazon Download API**: Switched to AfkarXYZ API for improved reliability - **Qobuz Download API**: Added Jumo API as fallback with quality fallback support - **Search Results**: Reduced artist limit from 5 to 2 for cleaner results ### Fixed -- **MP3/Lossy Download 403 Error**: Fixed 403 Forbidden when selecting lossy quality - - Now downloads FLAC first, then converts to selected lossy format - - Tidal/Qobuz APIs don't support direct MP3 quality parameter - **Opus Cover Art**: Fixed cover art not being embedded in Opus files - **Deezer Pagination**: Fixed albums/playlists with >25 tracks only showing first 25 diff --git a/go_backend/idhs.go b/go_backend/idhs.go new file mode 100644 index 00000000..72c8cff3 --- /dev/null +++ b/go_backend/idhs.go @@ -0,0 +1,189 @@ +package gobackend + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// IDHSClient is a client for I Don't Have Spotify API +// Used as fallback when SongLink fails or is rate limited +type IDHSClient struct { + client *http.Client +} + +var ( + globalIDHSClient *IDHSClient + idhsClientOnce sync.Once + idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit) +) + +// IDHSSearchRequest represents the request body for IDHS API +type IDHSSearchRequest struct { + Link string `json:"link"` + Adapters []string `json:"adapters,omitempty"` +} + +// IDHSSearchResponse represents the response from IDHS API +type IDHSSearchResponse struct { + ID string `json:"id"` + Type string `json:"type"` // song, album, artist, podcast, show + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image,omitempty"` + Audio string `json:"audio,omitempty"` + Source string `json:"source"` + UniversalLink string `json:"universalLink"` + Links []IDHSLink `json:"links"` +} + +// IDHSLink represents a link to a streaming platform +type IDHSLink struct { + Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal + URL string `json:"url"` + IsVerified bool `json:"isVerified,omitempty"` + NotAvailable bool `json:"notAvailable,omitempty"` +} + +// NewIDHSClient creates a new IDHS client +func NewIDHSClient() *IDHSClient { + idhsClientOnce.Do(func() { + globalIDHSClient = &IDHSClient{ + client: NewHTTPClientWithTimeout(15 * time.Second), + } + }) + return globalIDHSClient +} + +// Search converts a music link to links on other platforms +func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) { + idhsRateLimiter.WaitForSlot() + + reqBody := IDHSSearchRequest{ + Link: link, + Adapters: adapters, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 400 { + return nil, fmt.Errorf("invalid link or missing parameters") + } + if resp.StatusCode == 429 { + return nil, fmt.Errorf("IDHS rate limit exceeded") + } + if resp.StatusCode == 500 { + return nil, fmt.Errorf("IDHS processing failed") + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode) + } + + body, err := ReadResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result IDHSSearchResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetAvailabilityFromSpotify checks track availability using IDHS as fallback +func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) + + // Request only the platforms we need + adapters := []string{"tidal", "deezer"} + + result, err := c.Search(spotifyURL, adapters) + if err != nil { + return nil, err + } + + availability := &TrackAvailability{ + SpotifyID: spotifyTrackID, + } + + for _, link := range result.Links { + if link.NotAvailable { + continue + } + + switch strings.ToLower(link.Type) { + case "tidal": + availability.Tidal = true + availability.TidalURL = link.URL + case "deezer": + availability.Deezer = true + availability.DeezerURL = link.URL + availability.DeezerID = extractDeezerIDFromURL(link.URL) + } + } + + LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v", + spotifyTrackID, availability.Tidal, availability.Deezer) + + return availability, nil +} + +// GetAvailabilityFromDeezer checks track availability using IDHS +func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { + deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) + + // Request only the platforms we need + adapters := []string{"spotify", "tidal"} + + result, err := c.Search(deezerURL, adapters) + if err != nil { + return nil, err + } + + availability := &TrackAvailability{ + Deezer: true, + DeezerID: deezerTrackID, + } + + for _, link := range result.Links { + if link.NotAvailable { + continue + } + + switch strings.ToLower(link.Type) { + case "spotify": + availability.SpotifyID = extractSpotifyIDFromURL(link.URL) + case "tidal": + availability.Tidal = true + availability.TidalURL = link.URL + } + } + + LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v", + deezerTrackID, availability.SpotifyID, availability.Tidal) + + return availability, nil +} diff --git a/go_backend/songlink.go b/go_backend/songlink.go index f898af9a..0fc526f9 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -46,7 +46,30 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri if spotifyTrackID == "" { return nil, fmt.Errorf("spotify track ID is empty") } - + + // Try SongLink first + availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID) + if err != nil { + // Fallback to IDHS if SongLink fails + LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err) + idhsClient := NewIDHSClient() + availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID) + if err != nil { + return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err) + } + LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID) + } + + // Check Qobuz availability separately via ISRC + if isrc != "" { + availability.Qobuz = checkQobuzAvailability(isrc) + } + + return availability, nil +} + +// checkTrackAvailabilitySongLink is the original SongLink implementation +func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") @@ -115,10 +138,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - if isrc != "" { - availability.Qobuz = checkQobuzAvailability(isrc) - } - return availability, nil } @@ -191,11 +210,11 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, if err != nil { return "", err } - + if !availability.Deezer || availability.DeezerID == "" { return "", fmt.Errorf("track not found on Deezer") } - + return availability.DeezerID, nil } @@ -268,11 +287,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str if err != nil { return "", err } - + if !availability.Deezer || availability.DeezerID == "" { return "", fmt.Errorf("album not found on Deezer") } - + return availability.DeezerID, nil } @@ -281,7 +300,25 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra if deezerTrackID == "" { return nil, fmt.Errorf("deezer track ID is empty") } - + + // Try SongLink first + availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID) + if err != nil { + // Fallback to IDHS if SongLink fails + LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err) + idhsClient := NewIDHSClient() + availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID) + if err != nil { + return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err) + } + LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID) + } + + return availability, nil +} + +// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer +func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) @@ -369,7 +406,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit if entityID == "" { return nil, fmt.Errorf("%s ID is empty", platform) } - + // Use global rate limiter songLinkRateLimiter.WaitForSlot() @@ -464,11 +501,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e if err != nil { return "", err } - + if availability.SpotifyID == "" { return "", fmt.Errorf("track not found on Spotify") } - + return availability.SpotifyID, nil } @@ -478,11 +515,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er if err != nil { return "", err } - + if !availability.Tidal || availability.TidalURL == "" { return "", fmt.Errorf("track not found on Tidal") } - + return availability.TidalURL, nil } @@ -491,10 +528,10 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e if err != nil { return "", err } - + if !availability.Amazon || availability.AmazonURL == "" { return "", fmt.Errorf("track not found on Amazon Music") } - + return availability.AmazonURL, nil } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b3e436ca..f90e276d 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -33,6 +33,7 @@ class AppSettings { final String locale; final bool enableLossyOption; final String lossyFormat; + final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64' final String lyricsMode; const AppSettings({ @@ -65,6 +66,7 @@ class AppSettings { this.locale = 'system', this.enableLossyOption = false, this.lossyFormat = 'mp3', + this.lossyBitrate = 'mp3_320', this.lyricsMode = 'embed', }); @@ -99,6 +101,7 @@ class AppSettings { String? locale, bool? enableLossyOption, String? lossyFormat, + String? lossyBitrate, String? lyricsMode, }) { return AppSettings( @@ -131,6 +134,7 @@ class AppSettings { locale: locale ?? this.locale, enableLossyOption: enableLossyOption ?? this.enableLossyOption, lossyFormat: lossyFormat ?? this.lossyFormat, + lossyBitrate: lossyBitrate ?? this.lossyBitrate, lyricsMode: lyricsMode ?? this.lyricsMode, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 34af547e..ba3b7f05 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -38,6 +38,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', enableLossyOption: json['enableLossyOption'] as bool? ?? false, lossyFormat: json['lossyFormat'] as String? ?? 'mp3', + lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320', lyricsMode: json['lyricsMode'] as String? ?? 'embed', ); @@ -72,5 +73,6 @@ Map _$AppSettingsToJson(AppSettings instance) => 'locale': instance.locale, 'enableLossyOption': instance.enableLossyOption, 'lossyFormat': instance.lossyFormat, + 'lossyBitrate': instance.lossyBitrate, 'lyricsMode': instance.lyricsMode, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 518e7aad..ebaaf56c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2111,7 +2111,8 @@ class DownloadQueueNotifier extends Notifier { _log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file'); } else { final lossyFormat = settings.lossyFormat; - _log.i('Lossy quality selected, converting FLAC to $lossyFormat...'); + final lossyBitrate = settings.lossyBitrate; + _log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...'); updateItemStatus( item.id, DownloadStatus.downloading, @@ -2122,13 +2123,18 @@ class DownloadQueueNotifier extends Notifier { final convertedPath = await FFmpegService.convertFlacToLossy( filePath, format: lossyFormat, + bitrate: lossyBitrate, deleteOriginal: true, ); if (convertedPath != null) { filePath = convertedPath; - actualQuality = lossyFormat == 'opus' ? 'Opus 128kbps' : 'MP3 320kbps'; - _log.i('Successfully converted to $lossyFormat: $convertedPath'); + // Extract bitrate for display (e.g., 'mp3_320' -> '320kbps') + final bitrateDisplay = lossyBitrate.contains('_') + ? '${lossyBitrate.split('_').last}kbps' + : (lossyFormat == 'opus' ? '128kbps' : '320kbps'); + actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay'; + _log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath'); // Embed metadata and cover for both MP3 and Opus _log.i('Embedding metadata to $lossyFormat...'); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9b91272a..eb5d118a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -244,6 +244,13 @@ class SettingsNotifier extends Notifier { state = state.copyWith(lossyFormat: format); _saveSettings(); } + + void setLossyBitrate(String bitrate) { + // Extract format from bitrate (e.g., 'mp3_320' -> 'mp3') + final format = bitrate.split('_').first; + state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 63c186c2..9f45194e 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -114,10 +114,8 @@ class DownloadSettingsPage extends ConsumerWidget { SettingsItem( icon: Icons.tune, title: context.l10n.lossyFormat, - subtitle: settings.lossyFormat == 'opus' - ? 'Opus 128kbps' - : 'MP3 320kbps', - onTap: () => _showLossyFormatPicker(context, ref, settings.lossyFormat), + subtitle: _getLossyBitrateLabel(settings.lossyBitrate), + onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate), ), if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ _QualityOption( @@ -733,7 +731,28 @@ class DownloadSettingsPage extends ConsumerWidget { ); } - void _showLossyFormatPicker( + String _getLossyBitrateLabel(String bitrate) { + switch (bitrate) { + case 'mp3_320': + return 'MP3 320kbps (Best)'; + case 'mp3_256': + return 'MP3 256kbps'; + case 'mp3_192': + return 'MP3 192kbps'; + case 'mp3_128': + return 'MP3 128kbps'; + case 'opus_128': + return 'Opus 128kbps (Best)'; + case 'opus_96': + return 'Opus 96kbps'; + case 'opus_64': + return 'Opus 64kbps'; + default: + return 'MP3 320kbps'; + } + } + + void _showLossyBitratePicker( BuildContext context, WidgetRef ref, String current, @@ -742,54 +761,130 @@ class DownloadSettingsPage extends ConsumerWidget { showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.lossyFormat, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.lossyFormatDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.lossyFormat, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: const Text('MP3'), - subtitle: Text(context.l10n.lossyFormatMp3Subtitle), - trailing: current == 'mp3' ? const Icon(Icons.check) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyFormat('mp3'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('Opus'), - subtitle: Text(context.l10n.lossyFormatOpusSubtitle), - trailing: current == 'opus' ? const Icon(Icons.check) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyFormat('opus'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.lossyFormatDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + // MP3 Section + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + 'MP3', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('320kbps'), + subtitle: const Text('Best quality, larger files'), + trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('256kbps'), + subtitle: const Text('High quality'), + trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('192kbps'), + subtitle: const Text('Good quality'), + trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('128kbps'), + subtitle: const Text('Smaller files'), + trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128'); + Navigator.pop(context); + }, + ), + const Divider(indent: 24, endIndent: 24), + // Opus Section + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + 'Opus', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: const Text('128kbps'), + subtitle: const Text('Best quality, efficient codec'), + trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('opus_128'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: const Text('96kbps'), + subtitle: const Text('Good quality'), + trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('opus_96'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: const Text('64kbps'), + subtitle: const Text('Smallest files'), + trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLossyBitrate('opus_64'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), ), ), ); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 478eff21..aafded3f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -99,17 +99,30 @@ class FFmpegService { /// Convert FLAC to lossy format based on format parameter /// format: 'mp3' or 'opus' + /// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value static Future convertFlacToLossy( String inputPath, { required String format, + String? bitrate, bool deleteOriginal = true, }) async { + // Extract bitrate value from format like 'mp3_320' -> '320k' + String bitrateValue = '320k'; // default for mp3 + if (bitrate != null && bitrate.contains('_')) { + final parts = bitrate.split('_'); + if (parts.length == 2) { + bitrateValue = '${parts[1]}k'; + } + } + switch (format.toLowerCase()) { case 'opus': - return convertFlacToOpus(inputPath, deleteOriginal: deleteOriginal); + final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k'; + return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal); case 'mp3': default: - return convertFlacToMp3(inputPath, deleteOriginal: deleteOriginal); + final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k'; + return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal); } }