From 4e0cae9c20edf33b456296d7965251f40cb2e57a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 12 Jul 2026 14:10:23 +0700 Subject: [PATCH] refactor: extract shared helpers for repeated low-level patterns Dart: - notification_service: single _details() builder replaces 13 copies of the NotificationDetails block - platform_bridge: _invokeMap() for 34 invoke+decode call sites, _cachedInvoke() unifies the three TTL/in-flight cache scaffolds - ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(), single _writeReplayGainTags() and _convertToLossless() for the ALAC/FLAC twins - sqlite_helpers.dart: shared openAppDatabase/path-key/migration helpers for the three database classes - library_collections: parametrized wishlist/loved/favorite CRUD - extension_provider: one predicate-based replacedBuiltIn* lookup Go: - extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP shared by httpGet/httpPost/httpRequest/shortcuts/fetch - exports_metadata: applyAudioMetadataToResult + successMethodJSON, APE edit path reuses audioMetadataFromEditFields - lyrics: lrclibGet() for both LRCLib fetchers - extension_store: drop hand-rolled strings helpers --- go_backend/exports_metadata.go | 235 ++------- go_backend/extension_runtime_http.go | 424 +++++---------- go_backend/extension_runtime_polyfills.go | 49 +- .../extension_runtime_supplement_test.go | 3 - go_backend/extension_signed_session.go | 9 +- go_backend/extension_store.go | 39 +- go_backend/lyrics.go | 60 +-- lib/providers/extension_provider.dart | 40 +- .../library_collections_provider.dart | 146 ++--- lib/services/ffmpeg_service.dart | 498 +++++++----------- lib/services/history_database.dart | 92 +--- .../library_collections_database.dart | 111 ++-- lib/services/library_database.dart | 107 ++-- lib/services/notification_service.dart | 386 +++----------- lib/services/platform_bridge.dart | 329 +++++------- lib/services/sqlite_helpers.dart | 102 ++++ 16 files changed, 851 insertions(+), 1779 deletions(-) create mode 100644 lib/services/sqlite_helpers.dart diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 64d0cd14..cc3f533d 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -9,6 +9,36 @@ import ( "time" ) +func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak +} + +func successMethodJSON(method string) (string, error) { + return marshalJSONString(map[string]any{"success": true, "method": method}) +} + func ReadFileMetadata(filePath string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") @@ -121,29 +151,7 @@ func ReadFileMetadata(filePath string) (string, error) { result["format"] = "m4a" meta, err := ReadM4ATags(filePath) if err == nil && meta != nil { - result["title"] = meta.Title - result["artist"] = meta.Artist - result["album"] = meta.Album - result["album_artist"] = meta.AlbumArtist - result["date"] = meta.Date - if meta.Date == "" { - result["date"] = meta.Year - } - result["track_number"] = meta.TrackNumber - result["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + applyAudioMetadataToResult(result, meta) } quality, qualityErr := GetM4AQuality(filePath) if qualityErr == nil { @@ -163,29 +171,7 @@ func ReadFileMetadata(filePath string) (string, error) { result["audio_codec"] = "mp3" meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { - result["title"] = meta.Title - result["artist"] = meta.Artist - result["album"] = meta.Album - result["album_artist"] = meta.AlbumArtist - result["date"] = meta.Date - if meta.Date == "" { - result["date"] = meta.Year - } - result["track_number"] = meta.TrackNumber - result["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + applyAudioMetadataToResult(result, meta) } quality, qualityErr := GetMP3Quality(filePath) if qualityErr == nil { @@ -201,29 +187,7 @@ func ReadFileMetadata(filePath string) (string, error) { result["audio_codec"] = "opus" meta, err := ReadOggVorbisComments(filePath) if err == nil && meta != nil { - result["title"] = meta.Title - result["artist"] = meta.Artist - result["album"] = meta.Album - result["album_artist"] = meta.AlbumArtist - result["date"] = meta.Date - if meta.Date == "" { - result["date"] = meta.Year - } - result["track_number"] = meta.TrackNumber - result["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + applyAudioMetadataToResult(result, meta) } quality, qualityErr := GetOggQuality(filePath) if qualityErr == nil { @@ -240,29 +204,7 @@ func ReadFileMetadata(filePath string) (string, error) { if apeErr == nil && apeTag != nil { meta := APETagToAudioMetadata(apeTag) if meta != nil { - result["title"] = meta.Title - result["artist"] = meta.Artist - result["album"] = meta.Album - result["album_artist"] = meta.AlbumArtist - result["date"] = meta.Date - if meta.Date == "" { - result["date"] = meta.Year - } - result["track_number"] = meta.TrackNumber - result["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + applyAudioMetadataToResult(result, meta) } } } else if isWav || isAiff { @@ -281,29 +223,7 @@ func ReadFileMetadata(filePath string) (string, error) { quality, qualityErr = GetWAVQuality(filePath) } if meta != nil { - result["title"] = meta.Title - result["artist"] = meta.Artist - result["album"] = meta.Album - result["album_artist"] = meta.AlbumArtist - result["date"] = meta.Date - if meta.Date == "" { - result["date"] = meta.Year - } - result["track_number"] = meta.TrackNumber - result["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + applyAudioMetadataToResult(result, meta) } if qualityErr == nil && quality != nil { result["bit_depth"] = quality.BitDepth @@ -376,9 +296,7 @@ func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) { return "", fmt.Errorf("failed to write M4A freeform tags: %w", err) } - resp := map[string]any{"success": true, "method": "native_m4a_freeform"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_m4a_freeform") } // EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO @@ -424,12 +342,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return "", fmt.Errorf("failed to write M4A metadata: %w", err) } - resp := map[string]any{ - "success": true, - "method": "native_m4a_replaygain", - } - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_m4a_replaygain") } if isFlac { @@ -446,12 +359,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return "", fmt.Errorf("failed to write FLAC metadata: %w", err) } - resp := map[string]any{ - "success": true, - "method": "native", - } - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native") } // WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively. @@ -459,59 +367,17 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { if err := WriteWAVTags(filePath, fields); err != nil { return "", fmt.Errorf("failed to write WAV metadata: %w", err) } - resp := map[string]any{"success": true, "method": "native_wav"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_wav") } if isAiffFile { if err := WriteAIFFTags(filePath, fields); err != nil { return "", fmt.Errorf("failed to write AIFF metadata: %w", err) } - resp := map[string]any{"success": true, "method": "native_aiff"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_aiff") } if isApeFile { - trackNum := 0 - totalTracks := 0 - discNum := 0 - totalDiscs := 0 - if v, ok := fields["track_number"]; ok && v != "" { - fmt.Sscanf(v, "%d", &trackNum) - } - if v, ok := fields["track_total"]; ok && v != "" { - fmt.Sscanf(v, "%d", &totalTracks) - } - if v, ok := fields["disc_number"]; ok && v != "" { - fmt.Sscanf(v, "%d", &discNum) - } - if v, ok := fields["disc_total"]; ok && v != "" { - fmt.Sscanf(v, "%d", &totalDiscs) - } - - meta := &AudioMetadata{ - Title: fields["title"], - Artist: fields["artist"], - Album: fields["album"], - AlbumArtist: fields["album_artist"], - Date: fields["date"], - TrackNumber: trackNum, - TotalTracks: totalTracks, - DiscNumber: discNum, - TotalDiscs: totalDiscs, - ISRC: fields["isrc"], - Lyrics: fields["lyrics"], - Genre: fields["genre"], - Label: fields["label"], - Copyright: fields["copyright"], - Composer: fields["composer"], - Comment: fields["comment"], - ReplayGainTrackGain: fields["replaygain_track_gain"], - ReplayGainTrackPeak: fields["replaygain_track_peak"], - ReplayGainAlbumGain: fields["replaygain_album_gain"], - ReplayGainAlbumPeak: fields["replaygain_album_peak"], - } + meta := audioMetadataFromEditFields(fields) newItems := AudioMetadataToAPEItems(meta) @@ -560,12 +426,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return "", fmt.Errorf("failed to write APE tags: %w", err) } - resp := map[string]any{ - "success": true, - "method": "native_ape", - } - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_ape") } // MP3, Ogg/Opus, and M4A have native editors that preserve foreign @@ -578,27 +439,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { if err := EditMP3Fields(filePath, fields); err != nil { GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err) } else { - resp := map[string]any{"success": true, "method": "native_mp3"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_mp3") } } if isOggFile { if err := EditOggFields(filePath, fields); err != nil { GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err) } else { - resp := map[string]any{"success": true, "method": "native_ogg"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_ogg") } } if isM4AFile || isMP4ContainerFile(filePath) { if err := EditM4AFields(filePath, fields); err != nil { GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err) } else { - resp := map[string]any{"success": true, "method": "native_m4a"} - s, _ := marshalJSONString(resp) - return s, nil + return successMethodJSON("native_m4a") } } diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index 05210f9b..c4d1118a 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -74,126 +74,86 @@ func (r *extensionRuntime) validateDomain(urlStr string) error { return nil } -func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]any{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - +// parseGojaHeaders converts an exported goja value (expected map[string]any) +// into string headers. Non-map values yield an empty map. +func parseGojaHeaders(v any) map[string]string { headers := make(map[string]string) - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - headersObj := call.Arguments[1].Export() - if h, ok := headersObj.(map[string]any); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } + if h, ok := v.(map[string]any); ok { + for k, val := range h { + headers[k] = fmt.Sprintf("%v", val) } } - - req, err := http.NewRequest("GET", urlStr, nil) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - req = r.bindDownloadCancelContext(req) - - for k, v := range headers { - req.Header.Set(k, v) - } - - setDefaultExtensionUA(req) - - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - body, err := readExtensionHTTPResponseBody(resp) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - - respHeaders := make(map[string]any) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - - return r.vm.ToValue(map[string]any{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "url": resp.Request.URL.String(), - "body": string(body), - "headers": respHeaders, - }) + return headers } -func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value { +// coerceExportedBody stringifies a request body already exported from goja: +// strings pass through, maps/arrays are JSON-encoded, anything else is %v. +func coerceExportedBody(v any) (string, error) { + switch b := v.(type) { + case string: + return b, nil + case map[string]any, []any: + jsonBytes, err := json.Marshal(b) + if err != nil { + return "", fmt.Errorf("failed to stringify body: %v", err) + } + return string(jsonBytes), nil + default: + return fmt.Sprintf("%v", b), nil + } +} + +// coerceGojaBody is coerceExportedBody for a raw goja argument; undefined/null +// yield "", and the fallback uses goja's own String() conversion. +func coerceGojaBody(v goja.Value) (string, error) { + if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { + return "", nil + } + switch b := v.Export().(type) { + case string: + return b, nil + case map[string]any, []any: + return coerceExportedBody(b) + default: + return v.String(), nil + } +} + +func flattenHTTPHeaders(h http.Header) map[string]any { + flat := make(map[string]any, len(h)) + for k, v := range h { + if len(v) == 1 { + flat[k] = v[0] + } else { + flat[k] = v + } + } + return flat +} + +// checkExtensionURL extracts and allowlist-validates the URL argument. +// On failure it returns a non-nil error value to hand back to JS. +func (r *extensionRuntime) checkExtensionURL(call goja.FunctionCall) (string, goja.Value) { if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]any{ + return "", r.vm.ToValue(map[string]any{ "error": "URL is required", }) } - urlStr := call.Arguments[0].String() - if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]any{ + return "", r.vm.ToValue(map[string]any{ "error": err.Error(), }) } + return urlStr, nil +} - var bodyStr string - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyArg := call.Arguments[1].Export() - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]any, []any: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - bodyStr = call.Arguments[1].String() - } - } - - headers := make(map[string]string) - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - headersObj := call.Arguments[2].Export() - if h, ok := headersObj.(map[string]any); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - - req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) +// doExtensionHTTP builds and executes the request, returning the extension +// response map (or {"error": ...}). defaultJSON sets Content-Type +// application/json when the caller did not provide one. +func (r *extensionRuntime) doExtensionHTTP(method, urlStr string, body io.Reader, defaultJSON bool, headers map[string]string) goja.Value { + req, err := http.NewRequest(method, urlStr, body) if err != nil { return r.vm.ToValue(map[string]any{ "error": err.Error(), @@ -204,9 +164,8 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value { for k, v := range headers { req.Header.Set(k, v) } - setDefaultExtensionUA(req) - if req.Header.Get("Content-Type") == "" { + if defaultJSON && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } @@ -218,138 +177,78 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - body, err := readExtensionHTTPResponseBody(resp) + respBody, err := readExtensionHTTPResponseBody(resp) if err != nil { return r.vm.ToValue(map[string]any{ "error": err.Error(), }) } - respHeaders := make(map[string]any) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - return r.vm.ToValue(map[string]any{ "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "url": resp.Request.URL.String(), - "body": string(body), - "headers": respHeaders, + "body": string(respBody), + "headers": flattenHTTPHeaders(resp.Header), }) } -func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]any{ - "error": "URL is required", - }) +func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value { + urlStr, errVal := r.checkExtensionURL(call) + if errVal != nil { + return errVal } + headers := parseGojaHeaders(call.Argument(1).Export()) + return r.doExtensionHTTP("GET", urlStr, nil, false, headers) +} - urlStr := call.Arguments[0].String() - - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) +func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value { + urlStr, errVal := r.checkExtensionURL(call) + if errVal != nil { + return errVal + } + bodyStr, err := coerceGojaBody(call.Argument(1)) + if err != nil { return r.vm.ToValue(map[string]any{ "error": err.Error(), }) } + headers := parseGojaHeaders(call.Argument(2).Export()) + // POST always sends a (possibly empty) body and defaults Content-Type. + return r.doExtensionHTTP("POST", urlStr, strings.NewReader(bodyStr), true, headers) +} + +func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { + urlStr, errVal := r.checkExtensionURL(call) + if errVal != nil { + return errVal + } method := "GET" var bodyStr string - headers := make(map[string]string) + var headers map[string]string - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - optionsObj := call.Arguments[1].Export() - if opts, ok := optionsObj.(map[string]any); ok { - if m, ok := opts["method"].(string); ok { - method = strings.ToUpper(m) - } - - if bodyArg, ok := opts["body"]; ok && bodyArg != nil { - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]any, []any: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - bodyStr = fmt.Sprintf("%v", v) - } - } - - if h, ok := opts["headers"].(map[string]any); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } + if opts, ok := call.Argument(1).Export().(map[string]any); ok { + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + var err error + if bodyStr, err = coerceExportedBody(bodyArg); err != nil { + return r.vm.ToValue(map[string]any{ + "error": err.Error(), + }) } } + headers = parseGojaHeaders(opts["headers"]) } var reqBody io.Reader if bodyStr != "" { reqBody = strings.NewReader(bodyStr) } - - req, err := http.NewRequest(method, urlStr, reqBody) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - req = r.bindDownloadCancelContext(req) - - for k, v := range headers { - req.Header.Set(k, v) - } - - setDefaultExtensionUA(req) - if bodyStr != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - body, err := readExtensionHTTPResponseBody(resp) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - - respHeaders := make(map[string]any) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - - return r.vm.ToValue(map[string]any{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "url": resp.Request.URL.String(), - "body": string(body), - "headers": respHeaders, - }) + return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers) } func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value { @@ -365,115 +264,30 @@ func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { } func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]any{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) + urlStr, errVal := r.checkExtensionURL(call) + if errVal != nil { + return errVal } + // DELETE takes (url, headers); other methods take (url, body, headers). var bodyStr string - headers := make(map[string]string) - - if method == "DELETE" { - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - headersObj := call.Arguments[1].Export() - if h, ok := headersObj.(map[string]any); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - } else { - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyArg := call.Arguments[1].Export() - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]any, []any: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - bodyStr = call.Arguments[1].String() - } - } - - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - headersObj := call.Arguments[2].Export() - if h, ok := headersObj.(map[string]any); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } + headerArg := 1 + if method != "DELETE" { + var err error + if bodyStr, err = coerceGojaBody(call.Argument(1)); err != nil { + return r.vm.ToValue(map[string]any{ + "error": err.Error(), + }) } + headerArg = 2 } + headers := parseGojaHeaders(call.Argument(headerArg).Export()) var reqBody io.Reader if bodyStr != "" { reqBody = strings.NewReader(bodyStr) } - - req, err := http.NewRequest(method, urlStr, reqBody) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - req = r.bindDownloadCancelContext(req) - - for k, v := range headers { - req.Header.Set(k, v) - } - setDefaultExtensionUA(req) - if bodyStr != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - body, err := readExtensionHTTPResponseBody(resp) - if err != nil { - return r.vm.ToValue(map[string]any{ - "error": err.Error(), - }) - } - - respHeaders := make(map[string]any) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - - return r.vm.ToValue(map[string]any{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "url": resp.Request.URL.String(), - "body": string(body), - "headers": respHeaders, - }) + return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers) } func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index 7025b2f2..82c80ce6 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -25,39 +25,19 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { method := "GET" var bodyStr string - headers := make(map[string]string) + var headers map[string]string - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - optionsObj := call.Arguments[1].Export() - if opts, ok := optionsObj.(map[string]any); ok { - if m, ok := opts["method"].(string); ok { - method = strings.ToUpper(m) - } - - if bodyArg, ok := opts["body"]; ok && bodyArg != nil { - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]any, []any: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err)) - } - bodyStr = string(jsonBytes) - default: - bodyStr = fmt.Sprintf("%v", v) - } - } - - if h, ok := opts["headers"]; ok && h != nil { - switch hv := h.(type) { - case map[string]any: - for k, v := range hv { - headers[k] = fmt.Sprintf("%v", v) - } - } + if opts, ok := call.Argument(1).Export().(map[string]any); ok { + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + var err error + if bodyStr, err = coerceExportedBody(bodyArg); err != nil { + return r.createFetchError(err.Error()) } } + headers = parseGojaHeaders(opts["headers"]) } var reqBody io.Reader @@ -92,14 +72,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { return r.createFetchError(err.Error()) } - respHeaders := make(map[string]any) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } + respHeaders := flattenHTTPHeaders(resp.Header) responseObj := r.vm.NewObject() responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 41994b91..e6f89b91 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -481,9 +481,6 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) { if cats := store.getCategories(); len(cats) != 5 { t.Fatalf("categories = %#v", cats) } - if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") { - t.Fatal("string helper mismatch") - } if err := requireHTTPSURL("http://example.com", "registry"); err == nil { t.Fatal("expected HTTPS validation error") } diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 978ecc2e..763c90b3 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -335,14 +335,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value body = []byte(call.Arguments[2].String()) } } - extraHeaders := map[string]string{} - if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) { - if h, ok := call.Arguments[3].Export().(map[string]any); ok { - for k, v := range h { - extraHeaders[k] = fmt.Sprintf("%v", v) - } - } - } + extraHeaders := parseGojaHeaders(call.Argument(3).Export()) record, err := r.ensureSignedSession(config) if err != nil { diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 58736bf8..c4b1cfb4 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -511,7 +511,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor } result := make([]storeExtensionResponse, 0, len(extensions)) - queryLower := toLower(query) + queryLower := strings.ToLower(query) for _, ext := range extensions { if category != "" && ext.Category != category { @@ -519,12 +519,12 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor } if query != "" { - if !containsIgnoreCase(ext.Name, queryLower) && - !containsIgnoreCase(ext.DisplayName, queryLower) && - !containsIgnoreCase(ext.Description, queryLower) { + if !strings.Contains(strings.ToLower(ext.Name), queryLower) && + !strings.Contains(strings.ToLower(ext.DisplayName), queryLower) && + !strings.Contains(strings.ToLower(ext.Description), queryLower) { found := false for _, tag := range ext.Tags { - if containsIgnoreCase(tag, queryLower) { + if strings.Contains(strings.ToLower(tag), queryLower) { found = true break } @@ -555,32 +555,3 @@ func (s *extensionStore) clearCache() { LogInfo("ExtensionStore", "Cache cleared") } - -func containsIgnoreCase(s, substr string) bool { - return containsStr(toLower(s), substr) -} - -func toLower(s string) string { - result := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - result[i] = c - } - return string(result) -} - -func containsStr(s, substr string) bool { - return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0) -} - -func findSubstring(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 406f0c85..3c9ccaba 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -429,68 +429,54 @@ func NewLyricsClient() *LyricsClient { } } -func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsResponse, error) { - baseURL := "https://lrclib.net/api/get" - params := url.Values{} - params.Set("artist_name", artist) - params.Set("track_name", track) - - fullURL := baseURL + "?" + params.Encode() - - req, err := http.NewRequest("GET", fullURL, nil) +// lrclibGet performs a GET against lrclib.net and decodes the JSON body into +// dst. 404 is reported as a typed lyrics-not-found error. +func (c *LyricsClient) lrclibGet(path string, params url.Values, dst any) error { + req, err := http.NewRequest("GET", "https://lrclib.net"+path+"?"+params.Encode(), nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch lyrics: %w", err) + return fmt.Errorf("failed to fetch lyrics: %w", err) } defer resp.Body.Close() if resp.StatusCode == 404 { - return nil, lyricsNotFoundErrorf("lyrics not found") + return lyricsNotFoundErrorf("lyrics not found") + } + if resp.StatusCode != 200 { + return lyricsHTTPStatusError(resp.StatusCode, "unexpected status code: %d", resp.StatusCode) } - if resp.StatusCode != 200 { - return nil, lyricsHTTPStatusError(resp.StatusCode, "unexpected status code: %d", resp.StatusCode) + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("failed to decode response: %w", err) } + return nil +} + +func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsResponse, error) { + params := url.Values{} + params.Set("artist_name", artist) + params.Set("track_name", track) var lrcResp LRCLibResponse - if err := json.NewDecoder(resp.Body).Decode(&lrcResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.lrclibGet("/api/get", params, &lrcResp); err != nil { + return nil, err } return c.parseLRCLibResponse(&lrcResp), nil } func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) { - baseURL := "https://lrclib.net/api/search" params := url.Values{} params.Set("q", query) - fullURL := baseURL + "?" + params.Encode() - - req, err := http.NewRequest("GET", fullURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to search lyrics: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, lyricsHTTPStatusError(resp.StatusCode, "unexpected status code: %d", resp.StatusCode) - } - var results []LRCLibResponse - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.lrclibGet("/api/search", params, &results); err != nil { + return nil, err } if len(results) == 0 { diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 0df1b83c..087f45b1 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1298,7 +1298,10 @@ class ExtensionNotifier extends Notifier { .firstOrNull; } - String? replacedBuiltInDownloadProviderFor(String providerId) { + String? _replacedBuiltInProviderFor( + String providerId, + bool Function(Extension ext) hasCapability, + ) { final normalized = providerId.trim().toLowerCase(); if (normalized.isEmpty) return null; @@ -1306,42 +1309,21 @@ class ExtensionNotifier extends Notifier { .where( (ext) => ext.enabled && - ext.hasDownloadProvider && + hasCapability(ext) && ext.replacesBuiltInProviders.contains(normalized), ) .map((ext) => ext.id) .firstOrNull; } - String? replacedBuiltInSearchProviderFor(String providerId) { - final normalized = providerId.trim().toLowerCase(); - if (normalized.isEmpty) return null; + String? replacedBuiltInDownloadProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasDownloadProvider); - return state.extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.replacesBuiltInProviders.contains(normalized), - ) - .map((ext) => ext.id) - .firstOrNull; - } + String? replacedBuiltInSearchProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasCustomSearch); - String? replacedBuiltInMetadataProviderFor(String providerId) { - final normalized = providerId.trim().toLowerCase(); - if (normalized.isEmpty) return null; - - return state.extensions - .where( - (ext) => - ext.enabled && - ext.hasMetadataProvider && - ext.replacesBuiltInProviders.contains(normalized), - ) - .map((ext) => ext.id) - .firstOrNull; - } + String? replacedBuiltInMetadataProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasMetadataProvider); bool downloadProviderReplacesLegacyProvider( String providerId, diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 7a33bfc7..aff18c42 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -584,15 +584,30 @@ class LibraryCollectionsNotifier extends Notifier { return true; } - Future toggleWishlist(Track track) async { + Future _toggleTrackEntry( + Track track, { + required bool Function(String key) contains, + required List Function(LibraryCollectionsState state) + select, + required LibraryCollectionsState Function(List list) + withList, + required Future Function(String key) dbDelete, + required Future Function({ + required String trackKey, + required String trackJson, + required String addedAt, + }) + dbUpsert, + }) async { await _ensureLoaded(); final key = trackCollectionKey(track); - if (state.containsWishlistKey(key)) { - await _db.deleteWishlistEntry(key); - final updated = state.wishlist - .where((entry) => entry.key != key) - .toList(growable: false); - state = state.copyWith(wishlist: updated); + if (contains(key)) { + await dbDelete(key); + state = withList( + select( + state, + ).where((entry) => entry.key != key).toList(growable: false), + ); return false; } @@ -601,42 +616,32 @@ class LibraryCollectionsNotifier extends Notifier { track: track, addedAt: DateTime.now(), ); - await _db.upsertWishlistEntry( + await dbUpsert( trackKey: key, trackJson: jsonEncode(track.toJson()), addedAt: entry.addedAt.toIso8601String(), ); - final updated = [entry, ...state.wishlist]; - state = state.copyWith(wishlist: updated); + state = withList([entry, ...select(state)]); return true; } - Future toggleLoved(Track track) async { - await _ensureLoaded(); - final key = trackCollectionKey(track); - if (state.containsLovedKey(key)) { - await _db.deleteLovedEntry(key); - final updated = state.loved - .where((entry) => entry.key != key) - .toList(growable: false); - state = state.copyWith(loved: updated); - return false; - } + Future toggleWishlist(Track track) => _toggleTrackEntry( + track, + contains: (key) => state.containsWishlistKey(key), + select: (state) => state.wishlist, + withList: (list) => state.copyWith(wishlist: list), + dbDelete: _db.deleteWishlistEntry, + dbUpsert: _db.upsertWishlistEntry, + ); - final entry = CollectionTrackEntry( - key: key, - track: track, - addedAt: DateTime.now(), - ); - await _db.upsertLovedEntry( - trackKey: key, - trackJson: jsonEncode(track.toJson()), - addedAt: entry.addedAt.toIso8601String(), - ); - final updated = [entry, ...state.loved]; - state = state.copyWith(loved: updated); - return true; - } + Future toggleLoved(Track track) => _toggleTrackEntry( + track, + contains: (key) => state.containsLovedKey(key), + select: (state) => state.loved, + withList: (list) => state.copyWith(loved: list), + dbDelete: _db.deleteLovedEntry, + dbUpsert: _db.upsertLovedEntry, + ); Future toggleFavoriteArtist({ required String artistId, @@ -654,11 +659,7 @@ class LibraryCollectionsNotifier extends Notifier { ? trimmedProviderId : (source.isNotEmpty && source != 'builtin' ? source : null); if (state.containsFavoriteArtistKey(key)) { - await _db.deleteFavoriteArtistEntry(key); - final updated = state.favoriteArtists - .where((entry) => entry.key != key) - .toList(growable: false); - state = state.copyWith(favoriteArtists: updated); + await removeFavoriteArtist(key); return false; } @@ -680,38 +681,51 @@ class LibraryCollectionsNotifier extends Notifier { return true; } - Future removeFavoriteArtist(String artistKey) async { + Future _removeEntry( + String key, { + required bool Function(String key) contains, + required List Function(LibraryCollectionsState state) select, + required String Function(T entry) keyOf, + required LibraryCollectionsState Function(List list) withList, + required Future Function(String key) dbDelete, + }) async { await _ensureLoaded(); - if (!state.containsFavoriteArtistKey(artistKey)) return; + if (!contains(key)) return; - await _db.deleteFavoriteArtistEntry(artistKey); - final updated = state.favoriteArtists - .where((entry) => entry.key != artistKey) - .toList(growable: false); - state = state.copyWith(favoriteArtists: updated); + await dbDelete(key); + state = withList( + select( + state, + ).where((entry) => keyOf(entry) != key).toList(growable: false), + ); } - Future removeFromWishlist(String trackKey) async { - await _ensureLoaded(); - if (!state.containsWishlistKey(trackKey)) return; + Future removeFavoriteArtist(String artistKey) => _removeEntry( + artistKey, + contains: (key) => state.containsFavoriteArtistKey(key), + select: (state) => state.favoriteArtists, + keyOf: (entry) => entry.key, + withList: (list) => state.copyWith(favoriteArtists: list), + dbDelete: _db.deleteFavoriteArtistEntry, + ); - await _db.deleteWishlistEntry(trackKey); - final updated = state.wishlist - .where((entry) => entry.key != trackKey) - .toList(growable: false); - state = state.copyWith(wishlist: updated); - } + Future removeFromWishlist(String trackKey) => _removeEntry( + trackKey, + contains: (key) => state.containsWishlistKey(key), + select: (state) => state.wishlist, + keyOf: (entry) => entry.key, + withList: (list) => state.copyWith(wishlist: list), + dbDelete: _db.deleteWishlistEntry, + ); - Future removeFromLoved(String trackKey) async { - await _ensureLoaded(); - if (!state.containsLovedKey(trackKey)) return; - - await _db.deleteLovedEntry(trackKey); - final updated = state.loved - .where((entry) => entry.key != trackKey) - .toList(growable: false); - state = state.copyWith(loved: updated); - } + Future removeFromLoved(String trackKey) => _removeEntry( + trackKey, + contains: (key) => state.containsLovedKey(key), + select: (state) => state.loved, + keyOf: (entry) => entry.key, + withList: (list) => state.copyWith(loved: list), + dbDelete: _db.deleteLovedEntry, + ); Future createPlaylist(String name) async { await _ensureLoaded(); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index dab629c6..83029f4a 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1445,75 +1445,14 @@ class FFmpegService { String albumPeak, { bool returnTempPath = false, void Function(String tempPath)? onTempReady, - }) async { - final ext = filePath.contains('.') - ? '.${filePath.split('.').last}' - : '.tmp'; - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, ext); - final arguments = [ - '-v', - 'error', - '-hide_banner', - '-i', - filePath, - '-map', - '0', - '-c', - 'copy', - '-map_metadata', - '0', - '-metadata', - 'REPLAYGAIN_ALBUM_GAIN=$albumGain', - '-metadata', - 'REPLAYGAIN_ALBUM_PEAK=$albumPeak', - ]; - - if (ext.toLowerCase() == '.opus') { - final r128 = replayGainDbToR128(albumGain); - if (r128 != null) { - arguments - ..add('-metadata') - ..add('R128_ALBUM_GAIN=$r128'); - } - } - - arguments - ..add(tempOutput) - ..add('-y'); - - _log.d('Writing album ReplayGain tags via FFmpeg'); - final result = await _executeWithArguments(arguments); - - if (result.success) { - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - if (returnTempPath) { - onTempReady?.call(tempOutput); - return true; - } - final originalFile = File(filePath); - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(filePath); - await tempFile.delete(); - _log.d('Album ReplayGain tags written successfully'); - return true; - } - } catch (e) { - _log.w('Failed to replace file with album ReplayGain: $e'); - } - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (_) {} - - return false; - } + }) => _writeReplayGainTags( + filePath, + 'Album', + albumGain, + albumPeak, + returnTempPath: returnTempPath, + onTempReady: onTempReady, + ); /// Write track ReplayGain tags to a file via FFmpeg, replacing it in place. /// @@ -1525,12 +1464,24 @@ class FFmpegService { String filePath, String trackGain, String trackPeak, - ) async { + ) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak); + + /// Shared implementation for album/track ReplayGain tagging. + /// [scope] is 'Album' or 'Track'; it selects the REPLAYGAIN_*/R128_* tags. + static Future _writeReplayGainTags( + String filePath, + String scope, + String gain, + String peak, { + bool returnTempPath = false, + void Function(String tempPath)? onTempReady, + }) async { final ext = filePath.contains('.') ? '.${filePath.split('.').last}' : '.tmp'; final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, ext); + final tag = scope.toUpperCase(); final arguments = [ '-v', 'error', @@ -1544,17 +1495,17 @@ class FFmpegService { '-map_metadata', '0', '-metadata', - 'REPLAYGAIN_TRACK_GAIN=$trackGain', + 'REPLAYGAIN_${tag}_GAIN=$gain', '-metadata', - 'REPLAYGAIN_TRACK_PEAK=$trackPeak', + 'REPLAYGAIN_${tag}_PEAK=$peak', ]; if (ext.toLowerCase() == '.opus') { - final r128 = replayGainDbToR128(trackGain); + final r128 = replayGainDbToR128(gain); if (r128 != null) { arguments ..add('-metadata') - ..add('R128_TRACK_GAIN=$r128'); + ..add('R128_${tag}_GAIN=$r128'); } } @@ -1562,24 +1513,30 @@ class FFmpegService { ..add(tempOutput) ..add('-y'); - _log.d('Writing track ReplayGain tags via FFmpeg'); + _log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg'); final result = await _executeWithArguments(arguments); if (result.success) { - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - final originalFile = File(filePath); - if (await originalFile.exists()) { - await originalFile.delete(); + if (returnTempPath) { + try { + if (await File(tempOutput).exists()) { + onTempReady?.call(tempOutput); + return true; } - await tempFile.copy(filePath); - await tempFile.delete(); - _log.d('Track ReplayGain tags written successfully'); - return true; + } catch (e) { + _log.w( + 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', + ); } - } catch (e) { - _log.w('Failed to replace file with track ReplayGain: $e'); + } else if (await _promoteTempOutput( + tempOutput, + filePath, + onError: (e) => _log.w( + 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', + ), + )) { + _log.d('$scope ReplayGain tags written successfully'); + return true; } } @@ -1591,6 +1548,58 @@ class FFmpegService { return false; } + /// Replace [targetPath] with the successful FFmpeg temp output at + /// [tempOutput]. Returns `true` when the target was replaced. The temp + /// file is always cleaned up, including when the replacement fails. + static Future _promoteTempOutput( + String tempOutput, + String targetPath, { + void Function()? onMissing, + required void Function(Object e) onError, + }) async { + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + final originalFile = File(targetPath); + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(targetPath); + await tempFile.delete(); + return true; + } + onMissing?.call(); + return false; + } catch (e) { + onError(e); + return false; + } finally { + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) await tempFile.delete(); + } catch (_) {} + } + } + + /// Map input #1 (the cover image) as attached picture art. + static void _appendCoverInputArgs( + List arguments, { + String map = '1:v', + String disposition = '-disposition:v:0', + }) { + arguments + ..add('-map') + ..add(map) + ..add('-c:v') + ..add('copy') + ..add(disposition) + ..add('attached_pic') + ..add('-metadata:s:v') + ..add('title=Album cover') + ..add('-metadata:s:v') + ..add('comment=Cover (front)'); + } + static Future embedMetadata({ required String flacPath, String? coverPath, @@ -1612,17 +1621,11 @@ class FFmpegService { ..add('0:a'); if (coverPath != null) { - 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)'); + _appendCoverInputArgs( + arguments, + map: '1:0', + disposition: '-disposition:v', + ); } arguments @@ -1645,26 +1648,14 @@ class FFmpegService { final result = await _executeWithArguments(arguments); if (result.success) { - try { - final tempFile = File(tempOutput); - final originalFile = File(flacPath); - - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(flacPath); - await tempFile.delete(); - - return flacPath; - } else { - _log.e('Temp output file not found: $tempOutput'); - return null; - } - } catch (e) { - _log.e('Failed to replace file after metadata embed: $e'); - return null; - } + final promoted = await _promoteTempOutput( + tempOutput, + flacPath, + onMissing: () => _log.e('Temp output file not found: $tempOutput'), + onError: (e) => + _log.e('Failed to replace file after metadata embed: $e'), + ); + return promoted ? flacPath : null; } try { @@ -1824,27 +1815,16 @@ class FFmpegService { String mp3Path, String tempOutput, ) async { - try { - final tempFile = File(tempOutput); - final originalFile = File(mp3Path); - - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(mp3Path); - await tempFile.delete(); - - _log.d('MP3 metadata embedded successfully'); - return mp3Path; - } else { - _log.e('Temp MP3 output file not found: $tempOutput'); - return null; - } - } catch (e) { - _log.e('Failed to replace MP3 file after metadata embed: $e'); - return null; - } + final promoted = await _promoteTempOutput( + tempOutput, + mp3Path, + onMissing: () => _log.e('Temp MP3 output file not found: $tempOutput'), + onError: (e) => + _log.e('Failed to replace MP3 file after metadata embed: $e'), + ); + if (!promoted) return null; + _log.d('MP3 metadata embedded successfully'); + return mp3Path; } static String? _extractLyricsForId3(Map? metadata) { @@ -2105,27 +2085,17 @@ class FFmpegService { final result = await _executeWithArguments(arguments); 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; - } + final promoted = await _promoteTempOutput( + tempOutput, + opusPath, + onMissing: () => + _log.e('Temp Opus output file not found: $tempOutput'), + onError: (e) => + _log.e('Failed to replace Opus file after metadata embed: $e'), + ); + if (!promoted) return null; + _log.d('Opus metadata embedded successfully'); + return opusPath; } try { @@ -2186,17 +2156,7 @@ class FFmpegService { if (hasCover) { // Mark the image as an attached picture so the container writes a proper // covr atom instead of a generic MJPEG video track. - 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)'); + _appendCoverInputArgs(arguments); } if (metadata != null) { @@ -2236,34 +2196,24 @@ class FFmpegService { } if (result.success) { - try { - final tempFile = File(tempOutput); - final originalFile = File(m4aPath); + final promoted = await _promoteTempOutput( + tempOutput, + m4aPath, + onMissing: () => _log.e('Temp M4A output file not found: $tempOutput'), + onError: (e) => + _log.e('Failed to replace M4A file after metadata embed: $e'), + ); + if (!promoted) return null; - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(m4aPath); - await tempFile.delete(); - - // FFmpeg's MP4 muxer ignores ISRC and label, so write them natively - // as iTunes freeform atoms. Only fields the caller supplied are - // touched (an empty value clears the tag). - if (metadata != null) { - await _writeM4AFreeformTags(m4aPath, metadata); - } - - _log.d('M4A metadata embedded successfully'); - return m4aPath; - } else { - _log.e('Temp M4A output file not found: $tempOutput'); - return null; - } - } catch (e) { - _log.e('Failed to replace M4A file after metadata embed: $e'); - return null; + // FFmpeg's MP4 muxer ignores ISRC and label, so write them natively + // as iTunes freeform atoms. Only fields the caller supplied are + // touched (an empty value clears the tag). + if (metadata != null) { + await _writeM4AFreeformTags(m4aPath, metadata); } + + _log.d('M4A metadata embedded successfully'); + return m4aPath; } try { @@ -2424,21 +2374,11 @@ class FFmpegService { ) : const _ResolvedLosslessConversionQuality(); - if (format == 'alac') { - return _convertToAlac( - inputPath: inputPath, - metadata: metadata, - coverPath: coverPath, - targetBitDepth: resolvedLosslessQuality.targetBitDepth, - targetSampleRate: resolvedLosslessQuality.targetSampleRate, - processing: losslessProcessing, - deleteOriginal: deleteOriginal, - ); - } - if (format == 'flac') { - return _convertToFlac( + if (format == 'alac' || format == 'flac') { + return _convertToLossless( inputPath: inputPath, metadata: metadata, + codec: format, coverPath: coverPath, artistTagMode: artistTagMode, targetBitDepth: resolvedLosslessQuality.targetBitDepth, @@ -2547,97 +2487,12 @@ class FFmpegService { return outputPath; } + /// Convert to ALAC (.m4a) or FLAC per [codec]. /// Metadata and cover art are embedded in a single FFmpeg pass. - static Future _convertToAlac({ - required String inputPath, - required Map metadata, - String? coverPath, - int? targetBitDepth, - int? targetSampleRate, - LosslessConversionProcessing processing = - const LosslessConversionProcessing(), - bool deleteOriginal = true, - }) async { - final outputPath = _buildOutputPath(inputPath, '.m4a'); - final arguments = ['-v', 'error', '-hide_banner', '-i', inputPath]; - - final hasCover = - coverPath != null && - coverPath.trim().isNotEmpty && - await File(coverPath).exists(); - if (hasCover) { - arguments - ..add('-i') - ..add(coverPath); - } - - arguments - ..add('-map') - ..add('0:a'); - if (hasCover) { - 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)'); - } - arguments - ..add('-c:a') - ..add('alac'); - _appendLosslessCodecQualityArguments( - arguments, - codec: 'alac', - targetBitDepth: targetBitDepth, - targetSampleRate: targetSampleRate, - processing: processing, - ); - arguments - ..add('-map_metadata') - ..add('-1'); - - _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); - - arguments - ..add(outputPath) - ..add('-y'); - - _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC' - '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' - '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' - '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' - '${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}', - ); - final result = await _executeWithArguments(arguments); - - if (!result.success) { - _log.e('ALAC conversion failed: ${result.output}'); - return null; - } - - if (deleteOriginal) { - try { - await File(inputPath).delete(); - _log.i( - 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', - ); - } catch (e) { - _log.w('Failed to delete original: $e'); - } - } - - return outputPath; - } - - static Future _convertToFlac({ + static Future _convertToLossless({ required String inputPath, required Map metadata, + required String codec, // 'alac' or 'flac' String? coverPath, String artistTagMode = artistTagModeJoined, int? targetBitDepth, @@ -2646,7 +2501,8 @@ class FFmpegService { const LosslessConversionProcessing(), bool deleteOriginal = true, }) async { - final outputPath = _buildOutputPath(inputPath, '.flac'); + final isAlac = codec == 'alac'; + final outputPath = _buildOutputPath(inputPath, isAlac ? '.m4a' : '.flac'); final arguments = ['-v', 'error', '-hide_banner', '-i', inputPath]; final hasCover = @@ -2663,46 +2519,44 @@ class FFmpegService { ..add('-map') ..add('0:a'); if (hasCover) { - 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)'); + _appendCoverInputArgs(arguments); } arguments ..add('-c:a') - ..add('flac') - ..add('-compression_level') - ..add('8'); + ..add(codec); + if (!isAlac) { + arguments + ..add('-compression_level') + ..add('8'); + } _appendLosslessCodecQualityArguments( arguments, - codec: 'flac', + codec: codec, targetBitDepth: targetBitDepth, targetSampleRate: targetSampleRate, processing: processing, ); arguments ..add('-map_metadata') - ..add('0'); + ..add(isAlac ? '-1' : '0'); - _appendVorbisMetadataToArguments( - arguments, - metadata, - artistTagMode: artistTagMode, - ); + if (isAlac) { + _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); + } else { + _appendVorbisMetadataToArguments( + arguments, + metadata, + artistTagMode: artistTagMode, + ); + } arguments ..add(outputPath) ..add('-y'); + final label = isAlac ? 'ALAC' : 'FLAC'; _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC' + 'Converting ${inputPath.split(Platform.pathSeparator).last} to $label' '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' @@ -2711,7 +2565,7 @@ class FFmpegService { final result = await _executeWithArguments(arguments); if (!result.success) { - _log.e('FLAC conversion failed: ${result.output}'); + _log.e('$label conversion failed: ${result.output}'); return null; } diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index cf84b1e8..f5c49199 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -1,11 +1,10 @@ import 'dart:convert'; import 'dart:io'; import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; import 'package:spotiflac_android/utils/logger.dart'; -import 'package:spotiflac_android/utils/path_match_keys.dart'; final _log = AppLogger('HistoryDatabase'); final Future _prefs = SharedPreferences.getInstance(); @@ -72,26 +71,13 @@ class HistoryDatabase { Future get database async { if (_database != null) return _database!; - _database = await _initDB('history.db'); - return _database!; - } - - Future _initDB(String fileName) async { - final dbPath = await getApplicationDocumentsDirectory(); - final path = join(dbPath.path, fileName); - - _log.i('Initializing database at: $path'); - - return await openDatabase( - path, + _database = await sqlite.openAppDatabase( + 'history.db', version: 9, - onConfigure: (db) async { - await db.rawQuery('PRAGMA journal_mode = WAL'); - await db.execute('PRAGMA synchronous = NORMAL'); - }, onCreate: _createDB, onUpgrade: _upgradeDB, ); + return _database!; } Future _createDB(Database db, int version) async { @@ -196,24 +182,23 @@ class HistoryDatabase { } if (oldVersion < 7) { await _createPathKeyTable(db); - await _backfillPathKeys(db); + await sqlite.backfillPathKeys(db, 'history', 'history_path_keys'); } if (oldVersion < 8) { - await _addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT'); - await _addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT'); - await _addColumnIfMissing(db, 'history', 'match_key', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'history', 'match_key', 'TEXT'); await _backfillNormalizedColumns(db); await _createNormalizedIndexes(db); } if (oldVersion < 9) { - await _addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER'); - await _addColumnIfMissing(db, 'history', 'format', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER'); + await sqlite.addColumnIfMissing(db, 'history', 'format', 'TEXT'); } } - static String normalizeLookupText(String? value) { - return (value ?? '').trim().toLowerCase(); - } + static String normalizeLookupText(String? value) => + sqlite.normalizeLookupText(value); static String normalizeIsrc(String? value) { return (value ?? '').trim().toUpperCase().replaceAll(RegExp(r'[-\s]'), ''); @@ -243,21 +228,6 @@ class HistoryDatabase { return candidates.toList(growable: false); } - Future _addColumnIfMissing( - Database db, - String table, - String column, - String type, - ) async { - final columns = await db.rawQuery('PRAGMA table_info($table)'); - final exists = columns.any( - (row) => (row['name']?.toString().toLowerCase() ?? '') == column, - ); - if (!exists) { - await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); - } - } - Future _createNormalizedIndexes(DatabaseExecutor db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)', @@ -305,41 +275,11 @@ class HistoryDatabase { }; } - Future _createPathKeyTable(DatabaseExecutor db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS history_path_keys ( - item_id TEXT NOT NULL, - path_key TEXT NOT NULL, - PRIMARY KEY (item_id, path_key) - ) - '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)', - ); - } + Future _createPathKeyTable(DatabaseExecutor db) => + sqlite.createPathKeyTable(db, 'history_path_keys'); - Future _backfillPathKeys(Database db) async { - final rows = await db.query('history', columns: ['id', 'file_path']); - final batch = db.batch(); - for (final row in rows) { - _putPathKeysInBatch( - batch, - row['id'] as String, - row['file_path'] as String?, - ); - } - await batch.commit(noResult: true); - } - - void _putPathKeysInBatch(Batch batch, String id, String? filePath) { - batch.delete('history_path_keys', where: 'item_id = ?', whereArgs: [id]); - for (final key in buildPathMatchKeys(filePath)) { - batch.insert('history_path_keys', { - 'item_id': id, - 'path_key': key, - }, conflictAlgorithm: ConflictAlgorithm.ignore); - } - } + void _putPathKeysInBatch(Batch batch, String id, String? filePath) => + sqlite.putPathKeysInBatch(batch, 'history_path_keys', id, filePath); static final _iosContainerPattern = RegExp( r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/', diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index c7ab8e11..189f2f57 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -1,9 +1,8 @@ import 'dart:convert'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('LibraryCollectionsDb'); @@ -69,27 +68,14 @@ class LibraryCollectionsDatabase { Future get database async { if (_database != null) return _database!; - _database = await _initDb(); - return _database!; - } - - Future _initDb() async { - final dbPath = await getApplicationDocumentsDirectory(); - final path = join(dbPath.path, _dbFileName); - - _log.i('Initializing collections database at: $path'); - - return openDatabase( - path, + _database = await sqlite.openAppDatabase( + _dbFileName, version: _dbVersion, - onConfigure: (db) async { - await db.execute('PRAGMA foreign_keys = ON'); - await db.rawQuery('PRAGMA journal_mode = WAL'); - await db.execute('PRAGMA synchronous = NORMAL'); - }, + foreignKeys: true, onCreate: _createDb, onUpgrade: _upgradeDb, ); + return _database!; } Future _createDb(Database db, int version) async { @@ -412,67 +398,70 @@ class LibraryCollectionsDatabase { .toList(growable: false); } - Future upsertWishlistEntry({ - required String trackKey, - required String trackJson, + Future _upsertEntry( + String table, + String prefix, { + required String key, + required String json, required String addedAt, }) async { final db = await database; - await db.insert(_tableWishlist, { - 'track_key': trackKey, - 'track_json': trackJson, + await db.insert(table, { + '${prefix}_key': key, + '${prefix}_json': json, 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } - Future deleteWishlistEntry(String trackKey) async { + Future _deleteEntry(String table, String prefix, String key) async { final db = await database; - await db.delete( - _tableWishlist, - where: 'track_key = ?', - whereArgs: [trackKey], - ); + await db.delete(table, where: '${prefix}_key = ?', whereArgs: [key]); } + Future upsertWishlistEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) => _upsertEntry( + _tableWishlist, + 'track', + key: trackKey, + json: trackJson, + addedAt: addedAt, + ); + + Future deleteWishlistEntry(String trackKey) => + _deleteEntry(_tableWishlist, 'track', trackKey); + Future upsertLovedEntry({ required String trackKey, required String trackJson, required String addedAt, - }) async { - final db = await database; - await db.insert(_tableLoved, { - 'track_key': trackKey, - 'track_json': trackJson, - 'added_at': addedAt, - }, conflictAlgorithm: ConflictAlgorithm.replace); - } + }) => _upsertEntry( + _tableLoved, + 'track', + key: trackKey, + json: trackJson, + addedAt: addedAt, + ); - Future deleteLovedEntry(String trackKey) async { - final db = await database; - await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); - } + Future deleteLovedEntry(String trackKey) => + _deleteEntry(_tableLoved, 'track', trackKey); Future upsertFavoriteArtistEntry({ required String artistKey, required String artistJson, required String addedAt, - }) async { - final db = await database; - await db.insert(_tableFavoriteArtists, { - 'artist_key': artistKey, - 'artist_json': artistJson, - 'added_at': addedAt, - }, conflictAlgorithm: ConflictAlgorithm.replace); - } + }) => _upsertEntry( + _tableFavoriteArtists, + 'artist', + key: artistKey, + json: artistJson, + addedAt: addedAt, + ); - Future deleteFavoriteArtistEntry(String artistKey) async { - final db = await database; - await db.delete( - _tableFavoriteArtists, - where: 'artist_key = ?', - whereArgs: [artistKey], - ); - } + Future deleteFavoriteArtistEntry(String artistKey) => + _deleteEntry(_tableFavoriteArtists, 'artist', artistKey); Future upsertPlaylist({ required String id, @@ -602,7 +591,9 @@ class LibraryCollectionsDatabase { /// `LibraryCollectionsState.toJson()` (wishlist/loved/playlists/favoriteArtists). /// Track entries carry a nested `track` map (stored as `track_json`); favorite /// artist entries are stored whole as `artist_json`. - Future replaceAllFromBackup(Map collectionsJson) async { + Future replaceAllFromBackup( + Map collectionsJson, + ) async { final nowIso = DateTime.now().toIso8601String(); List> listOf(String key) { diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index a4136463..c30d6584 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/history_database.dart'; -import 'package:spotiflac_android/utils/path_match_keys.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; final _log = AppLogger('LibraryDatabase'); @@ -264,7 +264,12 @@ class LibraryDatabase { Future get database async { if (_database != null) return _database!; - _database = await _initDB('local_library.db'); + _database = await sqlite.openAppDatabase( + 'local_library.db', + version: 8, + onCreate: _createDB, + onUpgrade: _upgradeDB, + ); return _database!; } @@ -285,24 +290,6 @@ class LibraryDatabase { _historyAttached = true; } - Future _initDB(String fileName) async { - final dbPath = await getApplicationDocumentsDirectory(); - final path = join(dbPath.path, fileName); - - _log.i('Initializing library database at: $path'); - - return await openDatabase( - path, - version: 8, - onConfigure: (db) async { - await db.rawQuery('PRAGMA journal_mode = WAL'); - await db.execute('PRAGMA synchronous = NORMAL'); - }, - onCreate: _createDB, - onUpgrade: _upgradeDB, - ); - } - Future _createDB(Database db, int version) async { _log.i('Creating library database schema v$version'); @@ -389,62 +376,41 @@ class LibraryDatabase { } if (oldVersion < 7) { - await _addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT'); - await _addColumnIfMissing(db, 'library', 'artist_name_norm', 'TEXT'); - await _addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT'); - await _addColumnIfMissing(db, 'library', 'album_artist_norm', 'TEXT'); - await _addColumnIfMissing(db, 'library', 'match_key', 'TEXT'); - await _addColumnIfMissing(db, 'library', 'album_key', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT'); + await sqlite.addColumnIfMissing( + db, + 'library', + 'artist_name_norm', + 'TEXT', + ); + await sqlite.addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT'); + await sqlite.addColumnIfMissing( + db, + 'library', + 'album_artist_norm', + 'TEXT', + ); + await sqlite.addColumnIfMissing(db, 'library', 'match_key', 'TEXT'); + await sqlite.addColumnIfMissing(db, 'library', 'album_key', 'TEXT'); await _backfillNormalizedColumns(db); await _createNormalizedIndexes(db); _log.i('Added normalized local library lookup columns'); } if (oldVersion < 8) { await _createPathKeyTable(db); - await _backfillPathKeys(db); + await sqlite.backfillPathKeys(db, 'library', 'library_path_keys'); _log.i('Added local library path-key lookup table'); } } - Future _createPathKeyTable(DatabaseExecutor db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS library_path_keys ( - item_id TEXT NOT NULL, - path_key TEXT NOT NULL, - PRIMARY KEY (item_id, path_key) - ) - '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_path_keys_key ON library_path_keys(path_key)', - ); - } + Future _createPathKeyTable(DatabaseExecutor db) => + sqlite.createPathKeyTable(db, 'library_path_keys'); - Future _backfillPathKeys(Database db) async { - final rows = await db.query('library', columns: ['id', 'file_path']); - final batch = db.batch(); - for (final row in rows) { - _putPathKeysInBatch( - batch, - row['id'] as String, - row['file_path'] as String?, - ); - } - await batch.commit(noResult: true); - } + void _putPathKeysInBatch(Batch batch, String id, String? filePath) => + sqlite.putPathKeysInBatch(batch, 'library_path_keys', id, filePath); - void _putPathKeysInBatch(Batch batch, String id, String? filePath) { - batch.delete('library_path_keys', where: 'item_id = ?', whereArgs: [id]); - for (final key in buildPathMatchKeys(filePath)) { - batch.insert('library_path_keys', { - 'item_id': id, - 'path_key': key, - }, conflictAlgorithm: ConflictAlgorithm.ignore); - } - } - - static String normalizeLookupText(String? value) { - return (value ?? '').trim().toLowerCase(); - } + static String normalizeLookupText(String? value) => + sqlite.normalizeLookupText(value); static String matchKeyFor(String trackName, String artistName) { return '${normalizeLookupText(trackName)}|${normalizeLookupText(artistName)}'; @@ -458,19 +424,6 @@ class LibraryDatabase { return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}'; } - Future _addColumnIfMissing( - Database db, - String table, - String column, - String type, - ) async { - final info = await db.rawQuery('PRAGMA table_info($table)'); - final exists = info.any((row) => row['name'] == column); - if (!exists) { - await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); - } - } - Future _createNormalizedIndexes(DatabaseExecutor db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)', diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ead2bbc5..8ce023ca 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -138,6 +138,43 @@ class NotificationService { } } + /// Builds [NotificationDetails]. A non-null [progress] switches to the + /// low-importance ongoing progress style; otherwise a dismissible alert. + NotificationDetails _details({ + bool library = false, + int? progress, + bool playSound = false, + bool presentBadge = false, + bool presentSound = false, + }) { + final inProgress = progress != null; + return NotificationDetails( + android: AndroidNotificationDetails( + library ? libraryChannelId : channelId, + library ? libraryChannelName : channelName, + channelDescription: library + ? libraryChannelDescription + : channelDescription, + importance: inProgress ? Importance.low : Importance.defaultImportance, + priority: inProgress ? Priority.low : Priority.defaultPriority, + showProgress: inProgress, + maxProgress: inProgress ? 100 : 0, + progress: progress ?? 0, + ongoing: inProgress, + autoCancel: !inProgress, + playSound: playSound, + enableVibration: !inProgress, + onlyAlertOnce: inProgress, + icon: '@mipmap/ic_launcher', + ), + iOS: DarwinNotificationDetails( + presentAlert: !inProgress, + presentBadge: presentBadge, + presentSound: presentSound, + ), + ); + } + Future showDownloadProgress({ required String trackName, required String artistName, @@ -148,40 +185,12 @@ class NotificationService { final percentage = total > 0 ? (progress * 100 ~/ total) : 0; - final androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.low, - priority: Priority.low, - showProgress: true, - maxProgress: 100, - progress: percentage, - ongoing: true, - autoCancel: false, - playSound: false, - enableVibration: false, - onlyAlertOnce: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: false, - presentBadge: false, - presentSound: false, - ); - - final details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: _l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName', body: '$artistName • $percentage%', - details: details, + details: _details(progress: percentage), ); } @@ -191,41 +200,12 @@ class NotificationService { }) async { if (!_isInitialized) await initialize(); - final androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.low, - priority: Priority.low, - showProgress: true, - maxProgress: 100, - progress: 100, - indeterminate: false, - ongoing: true, - autoCancel: false, - playSound: false, - enableVibration: false, - onlyAlertOnce: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: false, - presentBadge: false, - presentSound: false, - ); - - final details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName', body: '$artistName • ${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}', - details: details, + details: _details(progress: 100), ); } @@ -252,33 +232,11 @@ class NotificationService { : (_l10n?.notifDownloadComplete ?? 'Download Complete'); } - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: false, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: title, body: '$trackName - $artistName', - details: details, + details: _details(presentBadge: true), ); } @@ -304,33 +262,15 @@ class NotificationService { : (_l10n?.notifTracksDownloadedSuccess(completedCount) ?? '$completedCount tracks downloaded successfully'); - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: title, body: body, - details: details, + details: _details( + playSound: true, + presentBadge: true, + presentSound: true, + ), ); } @@ -344,33 +284,15 @@ class NotificationService { _l10n?.notifVerificationRequiredBody ?? 'Open the app to complete verification and resume downloads'; - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: title, body: body, - details: details, + details: _details( + playSound: true, + presentBadge: true, + presentSound: true, + ), ); } @@ -384,33 +306,11 @@ class NotificationService { _l10n?.notifDownloadsCanceledBody(canceledCount) ?? '$canceledCount downloads canceled by user'; - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: false, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: downloadProgressId, title: title, body: body, - details: details, + details: _details(presentBadge: true), ); } @@ -441,39 +341,11 @@ class NotificationService { ? '$progressBody\n$currentFile' : progressBody; - final androidDetails = AndroidNotificationDetails( - libraryChannelId, - libraryChannelName, - channelDescription: libraryChannelDescription, - importance: Importance.low, - priority: Priority.low, - showProgress: true, - maxProgress: 100, - progress: percentage, - ongoing: true, - autoCancel: false, - playSound: false, - enableVibration: false, - onlyAlertOnce: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: false, - presentBadge: false, - presentSound: false, - ); - - final details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: libraryScanId, title: _l10n?.notifScanningLibrary ?? 'Scanning local library', body: body, - details: details, + details: _details(library: true, progress: percentage), ); } @@ -498,100 +370,34 @@ class NotificationService { } final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})'; - const androidDetails = AndroidNotificationDetails( - libraryChannelId, - libraryChannelName, - channelDescription: libraryChannelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: false, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: false, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: libraryScanId, title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete', body: '${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix', - details: details, + details: _details(library: true), ); } Future showLibraryScanFailed(String message) async { if (!_isInitialized) await initialize(); - const androidDetails = AndroidNotificationDetails( - libraryChannelId, - libraryChannelName, - channelDescription: libraryChannelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: false, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: false, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: libraryScanId, title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed', body: message, - details: details, + details: _details(library: true), ); } Future showLibraryScanCancelled() async { if (!_isInitialized) await initialize(); - const androidDetails = AndroidNotificationDetails( - libraryChannelId, - libraryChannelName, - channelDescription: libraryChannelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: false, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: false, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: libraryScanId, title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled', body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.', - details: details, + details: _details(library: true), ); } @@ -610,34 +416,6 @@ class NotificationService { final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); final totalMB = (total / 1024 / 1024).toStringAsFixed(1); - final androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.low, - priority: Priority.low, - showProgress: true, - maxProgress: 100, - progress: percentage, - ongoing: true, - autoCancel: false, - playSound: false, - enableVibration: false, - onlyAlertOnce: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: false, - presentBadge: false, - presentSound: false, - ); - - final details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: updateDownloadId, title: @@ -646,76 +424,38 @@ class NotificationService { body: _l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ?? '$receivedMB / $totalMB MB • $percentage%', - details: details, + details: _details(progress: percentage), ); } Future showUpdateDownloadComplete({required String version}) async { if (!_isInitialized) await initialize(); - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - playSound: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: updateDownloadId, title: _l10n?.notifUpdateReady ?? 'Update Ready', body: _l10n?.notifUpdateReadyBody(version) ?? '${AppInfo.appName} v$version downloaded. Tap to install.', - details: details, + details: _details( + playSound: true, + presentBadge: true, + presentSound: true, + ), ); } Future showUpdateDownloadFailed() async { if (!_isInitialized) await initialize(); - const androidDetails = AndroidNotificationDetails( - channelId, - channelName, - channelDescription: channelDescription, - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - autoCancel: true, - icon: '@mipmap/ic_launcher', - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: false, - presentSound: false, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - await _showSafely( id: updateDownloadId, title: _l10n?.notifUpdateFailed ?? 'Update Failed', body: _l10n?.notifUpdateFailedBody ?? 'Could not download update. Try again later.', - details: details, + // Android playSound defaults to true here (was omitted originally). + details: _details(playSound: true), ); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 5744129f..135e7879 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -138,62 +138,66 @@ class PlatformBridge { static Future> checkAvailability( String spotifyId, String isrc, - ) async { - final cacheKey = _availabilityCacheKey(spotifyId, isrc); - if (cacheKey.isEmpty) { + ) { + Future> load() { _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); - final result = await _channel.invokeMethod('checkAvailability', { + return _invokeMap('checkAvailability', { 'spotify_id': spotifyId, 'isrc': isrc, }); - return _decodeRequiredMapResult(result, 'checkAvailability'); } - await _ensurePersistentLookupCachesLoaded(); - final cached = _getCachedMap(_availabilityCache, cacheKey); - if (cached != null) return cached; - final inFlight = _availabilityInFlight[cacheKey]; - if (inFlight != null) return _copyStringMap(await inFlight); - - final generation = _lookupCacheGeneration; - final future = _invokeCachedMap( + final cacheKey = _availabilityCacheKey(spotifyId, isrc); + if (cacheKey.isEmpty) return load(); + return _cachedInvoke( cacheKey, _availabilityCache, - () async { - _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); - final result = await _channel.invokeMethod('checkAvailability', { - 'spotify_id': spotifyId, - 'isrc': isrc, - }); - return _decodeRequiredMapResult(result, 'checkAvailability'); - }, + _availabilityInFlight, _availabilityCacheTtl, - generation, _availabilityPersistentCacheKey, + load, ); - _availabilityInFlight[cacheKey] = future; + } + + static Future> _invokeMap( + String method, [ + dynamic args, + ]) async { + final result = await _channel.invokeMethod(method, args); + return _decodeRequiredMapResult(result, method); + } + + static Future> _cachedInvoke( + String cacheKey, + Map cache, + Map>> inFlight, + Duration ttl, + String persistentCacheKey, + Future> Function() loader, + ) async { + await _ensurePersistentLookupCachesLoaded(); + final cached = _getCachedMap(cache, cacheKey); + if (cached != null) return cached; + + final pending = inFlight[cacheKey]; + if (pending != null) return _copyStringMap(await pending); + + final generation = _lookupCacheGeneration; + final future = () async { + final value = await loader(); + if (generation == _lookupCacheGeneration) { + _putCachedMap(cache, cacheKey, value, ttl, persistentCacheKey); + } + return _copyStringMap(value); + }(); + inFlight[cacheKey] = future; try { return _copyStringMap(await future); } finally { - _availabilityInFlight.remove(cacheKey); + inFlight.remove(cacheKey); } } - static Future> _invokeCachedMap( - String key, - Map cache, - Future> Function() loader, - Duration ttl, - int generation, - String persistentCacheKey, - ) async { - final value = await loader(); - if (generation == _lookupCacheGeneration) { - _putCachedMap(cache, key, value, ttl, persistentCacheKey); - } - return _copyStringMap(value); - } - static String _availabilityCacheKey(String spotifyId, String isrc) { final normalizedIsrc = isrc.trim().toUpperCase(); if (normalizedIsrc.isNotEmpty) { @@ -465,10 +469,8 @@ class PlatformBridge { static Future> _invokeDownloadMethod( String method, DownloadRequestPayload payload, - ) async { - final request = jsonEncode(payload.toJson()); - final result = await _channel.invokeMethod(method, request); - return _decodeRequiredMapResult(result, method); + ) { + return _invokeMap(method, jsonEncode(payload.toJson())); } static Future> downloadByStrategy({ @@ -585,12 +587,8 @@ class PlatformBridge { static Future> checkDuplicate( String outputDir, String isrc, - ) async { - final result = await _channel.invokeMethod('checkDuplicate', { - 'output_dir': outputDir, - 'isrc': isrc, - }); - return _decodeRequiredMapResult(result, 'checkDuplicate'); + ) { + return _invokeMap('checkDuplicate', {'output_dir': outputDir, 'isrc': isrc}); } static Future buildFilename( @@ -642,22 +640,20 @@ class PlatformBridge { return result as bool; } - static Future> safStat(String uri) async { - final result = await _channel.invokeMethod('safStat', {'uri': uri}); - return _decodeRequiredMapResult(result, 'safStat'); + static Future> safStat(String uri) { + return _invokeMap('safStat', {'uri': uri}); } static Future> resolveSafFile({ required String treeUri, required String fileName, String relativeDir = '', - }) async { - final result = await _channel.invokeMethod('resolveSafFile', { + }) { + return _invokeMap('resolveSafFile', { 'tree_uri': treeUri, 'relative_dir': relativeDir, 'file_name': fileName, }); - return _decodeRequiredMapResult(result, 'resolveSafFile'); } static Future copyContentUriToTemp(String uri) async { @@ -724,14 +720,13 @@ class PlatformBridge { String trackName, String artistName, { int durationMs = 0, - }) async { - final result = await _channel.invokeMethod('fetchLyrics', { + }) { + return _invokeMap('fetchLyrics', { 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, 'duration_ms': durationMs, }); - return _decodeRequiredMapResult(result, 'fetchLyrics'); } static Future getLyricsLRC( @@ -757,26 +752,24 @@ class PlatformBridge { String artistName, { String? filePath, int durationMs = 0, - }) async { - final result = await _channel.invokeMethod('getLyricsLRCWithSource', { + }) { + return _invokeMap('getLyricsLRCWithSource', { 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, 'file_path': filePath ?? '', 'duration_ms': durationMs, }); - return _decodeRequiredMapResult(result, 'getLyricsLRCWithSource'); } static Future> embedLyricsToFile( String filePath, String lyrics, - ) async { - final result = await _channel.invokeMethod('embedLyricsToFile', { + ) { + return _invokeMap('embedLyricsToFile', { 'file_path': filePath, 'lyrics': lyrics, }); - return _decodeRequiredMapResult(result, 'embedLyricsToFile'); } static Future cleanupConnections() async { @@ -787,24 +780,22 @@ class PlatformBridge { String coverUrl, String outputPath, { bool maxQuality = true, - }) async { - final result = await _channel.invokeMethod('downloadCoverToFile', { + }) { + return _invokeMap('downloadCoverToFile', { 'cover_url': coverUrl, 'output_path': outputPath, 'max_quality': maxQuality, }); - return _decodeRequiredMapResult(result, 'downloadCoverToFile'); } static Future> extractCoverToFile( String audioPath, String outputPath, - ) async { - final result = await _channel.invokeMethod('extractCoverToFile', { + ) { + return _invokeMap('extractCoverToFile', { 'audio_path': audioPath, 'output_path': outputPath, }); - return _decodeRequiredMapResult(result, 'extractCoverToFile'); } static Future> fetchAndSaveLyrics({ @@ -814,8 +805,8 @@ class PlatformBridge { required int durationMs, required String outputPath, String audioFilePath = '', - }) async { - final result = await _channel.invokeMethod('fetchAndSaveLyrics', { + }) { + return _invokeMap('fetchAndSaveLyrics', { 'track_name': trackName, 'artist_name': artistName, 'spotify_id': spotifyId, @@ -823,7 +814,6 @@ class PlatformBridge { 'output_path': outputPath, 'audio_file_path': audioFilePath, }); - return _decodeRequiredMapResult(result, 'fetchAndSaveLyrics'); } /// Providers not in the list are disabled. @@ -855,38 +845,28 @@ class PlatformBridge { }); } - static Future> getLyricsFetchOptions() async { - final result = await _channel.invokeMethod('getLyricsFetchOptions'); - return _decodeRequiredMapResult(result, 'getLyricsFetchOptions'); + static Future> getLyricsFetchOptions() { + return _invokeMap('getLyricsFetchOptions'); } static Future> reEnrichFile( Map request, - ) async { - final requestJSON = jsonEncode(request); - final result = await _channel.invokeMethod('reEnrichFile', { - 'request_json': requestJSON, - }); - return _decodeRequiredMapResult(result, 'reEnrichFile'); + ) { + return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)}); } - static Future> readFileMetadata(String filePath) async { - final result = await _channel.invokeMethod('readFileMetadata', { - 'file_path': filePath, - }); - return _decodeRequiredMapResult(result, 'readFileMetadata'); + static Future> readFileMetadata(String filePath) { + return _invokeMap('readFileMetadata', {'file_path': filePath}); } static Future> editFileMetadata( String filePath, Map metadata, - ) async { - final metadataJSON = jsonEncode(metadata); - final result = await _channel.invokeMethod('editFileMetadata', { + ) { + return _invokeMap('editFileMetadata', { 'file_path': filePath, - 'metadata_json': metadataJSON, + 'metadata_json': jsonEncode(metadata), }); - return _decodeRequiredMapResult(result, 'editFileMetadata'); } /// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms. @@ -896,13 +876,11 @@ class PlatformBridge { static Future> writeM4AFreeformTags( String filePath, Map fields, - ) async { - final metadataJSON = jsonEncode(fields); - final result = await _channel.invokeMethod('writeM4AFreeformTags', { + ) { + return _invokeMap('writeM4AFreeformTags', { 'file_path': filePath, - 'metadata_json': metadataJSON, + 'metadata_json': jsonEncode(fields), }); - return _decodeRequiredMapResult(result, 'writeM4AFreeformTags'); } /// Normalizes a decrypted AC-4 file to a standards-compliant ISO MP4 and @@ -912,12 +890,11 @@ class PlatformBridge { static Future> ensureAC4Config( String filePath, String sourcePath, - ) async { - final result = await _channel.invokeMethod('ensureAC4Config', { + ) { + return _invokeMap('ensureAC4Config', { 'file_path': filePath, 'source_path': sourcePath, }); - return _decodeRequiredMapResult(result, 'ensureAC4Config'); } /// Writes iTunes-style metadata (and cover art) into an AC-4 MP4. Returns a @@ -928,14 +905,12 @@ class PlatformBridge { String filePath, Map metadata, String coverPath, - ) async { - final metadataJSON = jsonEncode(metadata); - final result = await _channel.invokeMethod('writeAC4Metadata', { + ) { + return _invokeMap('writeAC4Metadata', { 'file_path': filePath, - 'metadata_json': metadataJSON, + 'metadata_json': jsonEncode(metadata), 'cover_path': coverPath, }); - return _decodeRequiredMapResult(result, 'writeAC4Metadata'); } /// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries @@ -944,30 +919,27 @@ class PlatformBridge { String filePath, String artist, String albumArtist, - ) async { - final result = await _channel.invokeMethod('rewriteSplitArtistTags', { + ) { + return _invokeMap('rewriteSplitArtistTags', { 'file_path': filePath, 'artist': artist, 'album_artist': albumArtist, }); - return _decodeRequiredMapResult(result, 'rewriteSplitArtistTags'); } static Future writeTempToSaf(String tempPath, String safUri) async { - final result = await _channel.invokeMethod('writeTempToSaf', { + final map = await _invokeMap('writeTempToSaf', { 'temp_path': tempPath, 'saf_uri': safUri, }); - final map = _decodeRequiredMapResult(result, 'writeTempToSaf'); return map['success'] == true; } static Future writeSafSidecarLrc(String safUri, String lyrics) async { - final result = await _channel.invokeMethod('writeSafSidecarLrc', { + final map = await _invokeMap('writeSafSidecarLrc', { 'saf_uri': safUri, 'lyrics': lyrics, }); - final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc'); return map['success'] == true; } @@ -1130,35 +1102,24 @@ class PlatformBridge { static Future> getDeezerRelatedArtists( String artistId, { int limit = 12, - }) async { - final result = await _channel.invokeMethod('getDeezerRelatedArtists', { + }) { + return _invokeMap('getDeezerRelatedArtists', { 'artist_id': artistId, 'limit': limit, }); - return _decodeRequiredMapResult(result, 'getDeezerRelatedArtists'); } static Future> getProviderMetadata( String providerId, String resourceType, String resourceId, - ) async { - final cacheKey = _providerMetadataCacheKey( - providerId, - resourceType, - resourceId, - ); - await _ensurePersistentLookupCachesLoaded(); - final cached = _getCachedMap(_metadataCache, cacheKey); - if (cached != null) return cached; - - final inFlight = _metadataInFlight[cacheKey]; - if (inFlight != null) return _copyStringMap(await inFlight); - - final generation = _lookupCacheGeneration; - final future = _invokeCachedMap( - cacheKey, + ) { + return _cachedInvoke( + _providerMetadataCacheKey(providerId, resourceType, resourceId), _metadataCache, + _metadataInFlight, + _metadataCacheTtl, + _metadataPersistentCacheKey, () async { final result = await _channel.invokeMethod('getProviderMetadata', { 'provider_id': providerId, @@ -1172,27 +1133,17 @@ class PlatformBridge { } return _decodeRequiredMapResult(result, 'getProviderMetadata'); }, - _metadataCacheTtl, - generation, - _metadataPersistentCacheKey, ); - _metadataInFlight[cacheKey] = future; - try { - return _copyStringMap(await future); - } finally { - _metadataInFlight.remove(cacheKey); - } } static Future> searchDeezerByISRC( String isrc, { String? itemId, - }) async { - final result = await _channel.invokeMethod('searchDeezerByISRC', { + }) { + return _invokeMap('searchDeezerByISRC', { 'isrc': isrc, 'item_id': itemId ?? '', }); - return _decodeRequiredMapResult(result, 'searchDeezerByISRC'); } static Future?> getDeezerExtendedMetadata( @@ -1221,40 +1172,18 @@ class PlatformBridge { static Future> convertSpotifyToDeezer( String resourceType, String spotifyId, - ) async { - final cacheKey = _providerMetadataCacheKey( - 'spotify-to-deezer', - resourceType, - spotifyId, - ); - await _ensurePersistentLookupCachesLoaded(); - final cached = _getCachedMap(_metadataCache, cacheKey); - if (cached != null) return cached; - - final inFlight = _metadataInFlight[cacheKey]; - if (inFlight != null) return _copyStringMap(await inFlight); - - final generation = _lookupCacheGeneration; - final future = _invokeCachedMap( - cacheKey, + ) { + return _cachedInvoke( + _providerMetadataCacheKey('spotify-to-deezer', resourceType, spotifyId), _metadataCache, - () async { - final result = await _channel.invokeMethod('convertSpotifyToDeezer', { - 'resource_type': resourceType, - 'spotify_id': spotifyId, - }); - return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer'); - }, + _metadataInFlight, _metadataCacheTtl, - generation, _metadataPersistentCacheKey, + () => _invokeMap('convertSpotifyToDeezer', { + 'resource_type': resourceType, + 'spotify_id': spotifyId, + }), ); - _metadataInFlight[cacheKey] = future; - try { - return _copyStringMap(await future); - } finally { - _metadataInFlight.remove(cacheKey); - } } static Future>> getGoLogs() async { @@ -1295,12 +1224,9 @@ class PlatformBridge { static Future> loadExtensionsFromDir( String dirPath, - ) async { + ) { _log.d('loadExtensionsFromDir: $dirPath'); - final result = await _channel.invokeMethod('loadExtensionsFromDir', { - 'dir_path': dirPath, - }); - return _decodeRequiredMapResult(result, 'loadExtensionsFromDir'); + return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath}); } static Future> loadExtensionFromPath( @@ -1308,10 +1234,7 @@ class PlatformBridge { ) async { _log.d('loadExtensionFromPath: $filePath'); await _clearLookupCaches(); - final result = await _channel.invokeMethod('loadExtensionFromPath', { - 'file_path': filePath, - }); - return _decodeRequiredMapResult(result, 'loadExtensionFromPath'); + return _invokeMap('loadExtensionFromPath', {'file_path': filePath}); } static Future unloadExtension(String extensionId) async { @@ -1333,20 +1256,14 @@ class PlatformBridge { static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); await _clearLookupCaches(); - final result = await _channel.invokeMethod('upgradeExtension', { - 'file_path': filePath, - }); - return _decodeRequiredMapResult(result, 'upgradeExtension'); + return _invokeMap('upgradeExtension', {'file_path': filePath}); } static Future> checkExtensionUpgrade( String filePath, - ) async { + ) { _log.d('checkExtensionUpgrade: $filePath'); - final result = await _channel.invokeMethod('checkExtensionUpgrade', { - 'file_path': filePath, - }); - return _decodeRequiredMapResult(result, 'checkExtensionUpgrade'); + return _invokeMap('checkExtensionUpgrade', {'file_path': filePath}); } static Future>> getInstalledExtensions() async { @@ -1406,20 +1323,14 @@ class PlatformBridge { static Future> getExtensionSettings( String extensionId, - ) async { - final result = await _channel.invokeMethod('getExtensionSettings', { - 'extension_id': extensionId, - }); - return _decodeRequiredMapResult(result, 'getExtensionSettings'); + ) { + return _invokeMap('getExtensionSettings', {'extension_id': extensionId}); } static Future> checkExtensionHealth( String extensionId, - ) async { - final result = await _channel.invokeMethod('checkExtensionHealth', { - 'extension_id': extensionId, - }); - return _decodeRequiredMapResult(result, 'checkExtensionHealth'); + ) { + return _invokeMap('checkExtensionHealth', {'extension_id': extensionId}); } static Future setExtensionSettings( @@ -1928,10 +1839,9 @@ class PlatformBridge { } static Future> getSafFileModTimes(List uris) async { - final result = await _channel.invokeMethod('getSafFileModTimes', { + final map = await _invokeMap('getSafFileModTimes', { 'uris': jsonEncode(uris), }); - final map = _decodeRequiredMapResult(result, 'getSafFileModTimes'); return map.map((key, value) => MapEntry(key, (value as num).toInt())); } @@ -2165,12 +2075,11 @@ class PlatformBridge { static Future> runPostProcessing( String filePath, { Map? metadata, - }) async { - final result = await _channel.invokeMethod('runPostProcessing', { + }) { + return _invokeMap('runPostProcessing', { 'file_path': filePath, 'metadata': metadata != null ? jsonEncode(metadata) : '', }); - return _decodeRequiredMapResult(result, 'runPostProcessing'); } static Future> runPostProcessingV2( @@ -2183,11 +2092,10 @@ class PlatformBridge { } else { input['path'] = filePath; } - final result = await _channel.invokeMethod('runPostProcessingV2', { + return _invokeMap('runPostProcessingV2', { 'input': jsonEncode(input), 'metadata': metadata != null ? jsonEncode(metadata) : '', }); - return _decodeRequiredMapResult(result, 'runPostProcessingV2'); } static Future>> getPostProcessingProviders() async { @@ -2265,12 +2173,11 @@ class PlatformBridge { static Future> parseCueSheet( String cuePath, { String audioDir = '', - }) async { + }) { _log.i('parseCueSheet: $cuePath (audioDir: $audioDir)'); - final result = await _channel.invokeMethod('parseCueSheet', { + return _invokeMap('parseCueSheet', { 'cue_path': cuePath, 'audio_dir': audioDir, }); - return _decodeRequiredMapResult(result, 'parseCueSheet'); } } diff --git a/lib/services/sqlite_helpers.dart b/lib/services/sqlite_helpers.dart new file mode 100644 index 00000000..829b1abf --- /dev/null +++ b/lib/services/sqlite_helpers.dart @@ -0,0 +1,102 @@ +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; +import 'package:sqflite/sqflite.dart'; + +final _log = AppLogger('AppSqlite'); + +/// Opens a database file in the app documents directory with the shared +/// WAL + synchronous=NORMAL configuration. +Future openAppDatabase( + String fileName, { + required int version, + required Future Function(Database db, int version) onCreate, + required Future Function(Database db, int oldVersion, int newVersion) + onUpgrade, + bool foreignKeys = false, +}) async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, fileName); + + _log.i('Initializing database at: $path'); + + return openDatabase( + path, + version: version, + onConfigure: (db) async { + if (foreignKeys) { + await db.execute('PRAGMA foreign_keys = ON'); + } + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, + onCreate: onCreate, + onUpgrade: onUpgrade, + ); +} + +String normalizeLookupText(String? value) { + return (value ?? '').trim().toLowerCase(); +} + +Future addColumnIfMissing( + Database db, + String table, + String column, + String type, +) async { + final columns = await db.rawQuery('PRAGMA table_info($table)'); + final exists = columns.any( + (row) => (row['name']?.toString().toLowerCase() ?? '') == column, + ); + if (!exists) { + await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); + } +} + +Future createPathKeyTable(DatabaseExecutor db, String table) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS $table ( + item_id TEXT NOT NULL, + path_key TEXT NOT NULL, + PRIMARY KEY (item_id, path_key) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_${table}_key ON $table(path_key)', + ); +} + +Future backfillPathKeys( + Database db, + String sourceTable, + String keyTable, +) async { + final rows = await db.query(sourceTable, columns: ['id', 'file_path']); + final batch = db.batch(); + for (final row in rows) { + putPathKeysInBatch( + batch, + keyTable, + row['id'] as String, + row['file_path'] as String?, + ); + } + await batch.commit(noResult: true); +} + +void putPathKeysInBatch( + Batch batch, + String table, + String id, + String? filePath, +) { + batch.delete(table, where: 'item_id = ?', whereArgs: [id]); + for (final key in buildPathMatchKeys(filePath)) { + batch.insert(table, { + 'item_id': id, + 'path_key': key, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } +}