feat: expose audio codec in download result and skip lossy-to-lossless conversion

Go backend:
- Add AudioCodec field to DownloadResult and DownloadResponse
- Extension download results can now include audio_codec/audioCodec
- ffmpegGetInfo and probeAudioQuality now return codec field
- Add trackItemBytes option to file.download() for custom progress handling

Flutter:
- Check audio_codec before container conversion
- Skip FLAC conversion if source codec is lossy (AAC, MP3, Opus, etc.)
- Prevents fake upscale from lossy to lossless containers
This commit is contained in:
zarzet
2026-05-15 04:37:25 +07:00
parent 8b7cecc1c5
commit fb4cd75cb2
6 changed files with 45 additions and 6 deletions
+9 -1
View File
@@ -313,6 +313,7 @@ type DownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
@@ -342,6 +343,7 @@ type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
@@ -863,6 +865,7 @@ func buildDownloadSuccessResponse(
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
@@ -920,7 +923,12 @@ func enrichResultQualityFromFile(result *DownloadResult) {
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
+3
View File
@@ -236,6 +236,7 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult
FilePath: strings.TrimSpace(result.FilePath),
BitDepth: result.BitDepth,
SampleRate: result.SampleRate,
AudioCodec: strings.TrimSpace(result.AudioCodec),
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
@@ -420,6 +421,7 @@ type ExtDownloadResult struct {
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
@@ -873,6 +875,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
+1
View File
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
"codec": quality.Codec,
})
}
+14 -4
View File
@@ -136,6 +136,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
@@ -151,6 +152,15 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
@@ -194,7 +204,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
req, err := http.NewRequest("GET", urlStr, nil)
@@ -244,7 +254,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != ""
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
@@ -321,7 +331,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
@@ -391,7 +401,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != ""
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
+1
View File
@@ -415,6 +415,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
"duration": quality.Duration,
"codec": quality.Codec,
})
})
+17 -1
View File
@@ -6246,6 +6246,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (context.quality == 'HIGH' || context.outputExt != '.flac') {
return filePath;
}
final resultAudioFormat = _normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString(),
);
if (_isLossyAudioFormat(resultAudioFormat)) {
_log.d(
'Native-worker output is $resultAudioFormat; preserving native container.',
);
return filePath;
}
final requiresContainerConversion =
result['requires_container_conversion'] == true ||
result['requiresContainerConversion'] == true;
@@ -7411,10 +7421,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
result,
filePath: filePath,
);
final resultAudioFormat = _normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString(),
);
final resultIsLossyAudio = _isLossyAudioFormat(resultAudioFormat);
final requiresContainerConversion =
result['requires_container_conversion'] == true ||
result['requiresContainerConversion'] == true ||
_shouldRequestContainerConversion(actualService, safOutputExt);
(!resultIsLossyAudio &&
_shouldRequestContainerConversion(actualService, safOutputExt));
final preferredOutputExt = _extensionPreferredOutputExt(actualService);
final shouldPreserveNativeM4a =
!requiresContainerConversion &&