mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-04 01:00:44 +02:00
perf(extensions): streamline prepared download work
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/cipher"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPatternedTransformBufferSize = int64(1 << 20)
|
||||
maxPatternedTransformBufferSize = int64(16 << 20)
|
||||
maxPatternedTransformSegmentSize = int64(16 << 20)
|
||||
)
|
||||
|
||||
// fileTransformPatternedBlocks streams one file into another while applying an
|
||||
// independent block-cipher transform to selected fixed-size segments. It keeps
|
||||
// provider-specific layout knowledge in the extension: the host only receives
|
||||
// a generic period/offset declaration and cipher parameters.
|
||||
//
|
||||
// JS signature:
|
||||
//
|
||||
// file.transformPatternedBlocks(inputPath, outputPath, {
|
||||
// operation: "decrypt", algorithm: "blowfish", mode: "cbc",
|
||||
// key: "...", keyEncoding: "hex", iv: "...", ivEncoding: "hex",
|
||||
// segmentSize: 2048, transformEvery: 3, transformOffset: 0,
|
||||
// bufferSize: 1048576, transformPartial: false
|
||||
// }, function(processedBytes, totalBytes) {})
|
||||
func (r *extensionRuntime) fileTransformPatternedBlocks(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 3 {
|
||||
return r.jsError("input path, output path, and options are required")
|
||||
}
|
||||
|
||||
inputPath, err := r.validatePath(call.Arguments[0].String())
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
}
|
||||
outputPath, err := r.validatePath(call.Arguments[1].String())
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
}
|
||||
options := parseRuntimeOptionsArgument(call, 2)
|
||||
parsedCipher, err := parseRuntimeBlockCipherOptions(options)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
}
|
||||
if parsedCipher.Padding != "none" {
|
||||
return r.jsError("patterned file transforms only support padding: none")
|
||||
}
|
||||
if parsedCipher.Mode != "cbc" && parsedCipher.Mode != "ctr" {
|
||||
return r.jsError("unsupported block cipher mode: %s", parsedCipher.Mode)
|
||||
}
|
||||
operation := strings.ToLower(runtimeOptionString(options, "operation", "decrypt"))
|
||||
if operation != "decrypt" && operation != "encrypt" {
|
||||
return r.jsError("operation must be decrypt or encrypt")
|
||||
}
|
||||
|
||||
segmentSize := runtimeOptionInt64(options, "segmentSize", 0)
|
||||
transformEvery := runtimeOptionInt64(options, "transformEvery", 1)
|
||||
transformOffset := runtimeOptionInt64(options, "transformOffset", 0)
|
||||
bufferSize := runtimeOptionInt64(options, "bufferSize", defaultPatternedTransformBufferSize)
|
||||
transformPartial := runtimeOptionBool(options, "transformPartial", false)
|
||||
if segmentSize <= 0 || segmentSize > maxPatternedTransformSegmentSize {
|
||||
return r.jsError("segmentSize must be between 1 and %d bytes", maxPatternedTransformSegmentSize)
|
||||
}
|
||||
if transformEvery <= 0 {
|
||||
return r.jsError("transformEvery must be greater than zero")
|
||||
}
|
||||
if transformOffset < 0 || transformOffset >= transformEvery {
|
||||
return r.jsError("transformOffset must be between 0 and transformEvery - 1")
|
||||
}
|
||||
if bufferSize < segmentSize {
|
||||
bufferSize = segmentSize
|
||||
}
|
||||
if bufferSize > maxPatternedTransformBufferSize {
|
||||
bufferSize = maxPatternedTransformBufferSize
|
||||
}
|
||||
bufferSize -= bufferSize % segmentSize
|
||||
if bufferSize == 0 {
|
||||
bufferSize = segmentSize
|
||||
}
|
||||
|
||||
block, err := newRuntimeBlockCipher(parsedCipher)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
}
|
||||
if len(parsedCipher.IV) != block.BlockSize() {
|
||||
return r.jsError("iv must be %d bytes for %s", block.BlockSize(), parsedCipher.Algorithm)
|
||||
}
|
||||
if parsedCipher.Mode == "cbc" && segmentSize%int64(block.BlockSize()) != 0 {
|
||||
return r.jsError("segmentSize must be a multiple of %d bytes for CBC", block.BlockSize())
|
||||
}
|
||||
|
||||
var onProgress goja.Callable
|
||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||
callback, ok := goja.AssertFunction(call.Arguments[3])
|
||||
if !ok {
|
||||
return r.jsError("progress callback must be a function")
|
||||
}
|
||||
onProgress = callback
|
||||
}
|
||||
|
||||
unlock := lockDownloadOutputPath(outputPath)
|
||||
defer unlock()
|
||||
|
||||
input, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return r.jsError("failed to open input file: %v", err)
|
||||
}
|
||||
info, err := input.Stat()
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return r.jsError("failed to stat input file: %v", err)
|
||||
}
|
||||
totalSize := info.Size()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
input.Close()
|
||||
return r.jsError("failed to create output directory: %v", err)
|
||||
}
|
||||
stagedPath := outputPath + ".transform.partial"
|
||||
if filepath.Clean(stagedPath) == filepath.Clean(inputPath) {
|
||||
input.Close()
|
||||
return r.jsError("input path conflicts with transform staging path")
|
||||
}
|
||||
_ = os.Remove(stagedPath)
|
||||
output, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return r.jsError("failed to create staged output: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
input.Close()
|
||||
output.Close()
|
||||
_ = os.Remove(stagedPath)
|
||||
}
|
||||
ctx := r.activeOperationContext(context.Background())
|
||||
buffer := make([]byte, int(bufferSize))
|
||||
processed := int64(0)
|
||||
segmentIndex := int64(0)
|
||||
segmentsTransformed := int64(0)
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
cleanup()
|
||||
return r.jsError("patterned file transform cancelled: %v", err)
|
||||
}
|
||||
readCount, readErr := io.ReadFull(input, buffer)
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
cleanup()
|
||||
return r.jsError("failed to read input file: %v", readErr)
|
||||
}
|
||||
if readCount == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
chunk := buffer[:readCount]
|
||||
for offset := 0; offset < readCount; offset += int(segmentSize) {
|
||||
end := min(offset+int(segmentSize), readCount)
|
||||
segment := chunk[offset:end]
|
||||
selected := segmentIndex%transformEvery == transformOffset
|
||||
fullSegment := len(segment) == int(segmentSize)
|
||||
if selected && (fullSegment || transformPartial) {
|
||||
if parsedCipher.Mode == "cbc" && len(segment)%block.BlockSize() != 0 {
|
||||
cleanup()
|
||||
return r.jsError("selected segment %d is not a multiple of %d bytes", segmentIndex, block.BlockSize())
|
||||
}
|
||||
transformPatternedSegment(block, parsedCipher, operation, segment)
|
||||
segmentsTransformed++
|
||||
}
|
||||
segmentIndex++
|
||||
}
|
||||
|
||||
written, writeErr := output.Write(chunk)
|
||||
if writeErr != nil || written != len(chunk) {
|
||||
if writeErr == nil {
|
||||
writeErr = io.ErrShortWrite
|
||||
}
|
||||
cleanup()
|
||||
return r.jsError("failed to write transformed file: %v", writeErr)
|
||||
}
|
||||
processed += int64(written)
|
||||
if onProgress != nil {
|
||||
if _, callbackErr := onProgress(
|
||||
goja.Undefined(),
|
||||
r.vm.ToValue(processed),
|
||||
r.vm.ToValue(totalSize),
|
||||
); callbackErr != nil {
|
||||
cleanup()
|
||||
return r.jsError("progress callback failed: %v", callbackErr)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := input.Close(); err != nil {
|
||||
output.Close()
|
||||
_ = os.Remove(stagedPath)
|
||||
return r.jsError("failed to close input file: %v", err)
|
||||
}
|
||||
if err := output.Sync(); err != nil {
|
||||
output.Close()
|
||||
_ = os.Remove(stagedPath)
|
||||
return r.jsError("failed to sync transformed file: %v", err)
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return r.jsError("failed to close transformed file: %v", err)
|
||||
}
|
||||
if err := os.Rename(stagedPath, outputPath); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return r.jsError("failed to publish transformed file: %v", err)
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
"path": outputPath,
|
||||
"bytes_processed": processed,
|
||||
"segments_processed": segmentIndex,
|
||||
"segments_transformed": segmentsTransformed,
|
||||
})
|
||||
}
|
||||
|
||||
func transformPatternedSegment(
|
||||
block cipher.Block,
|
||||
options *runtimeBlockCipherOptions,
|
||||
operation string,
|
||||
segment []byte,
|
||||
) {
|
||||
if options.Mode == "ctr" {
|
||||
cipher.NewCTR(block, options.IV).XORKeyStream(segment, segment)
|
||||
return
|
||||
}
|
||||
if operation == "encrypt" {
|
||||
cipher.NewCBCEncrypter(block, options.IV).CryptBlocks(segment, segment)
|
||||
return
|
||||
}
|
||||
cipher.NewCBCDecrypter(block, options.IV).CryptBlocks(segment, segment)
|
||||
}
|
||||
Reference in New Issue
Block a user