mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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
This commit is contained in:
+45
-190
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+23
-37
@@ -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 {
|
||||
|
||||
@@ -1298,7 +1298,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
.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<ExtensionState> {
|
||||
.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,
|
||||
|
||||
@@ -584,15 +584,30 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> toggleWishlist(Track track) async {
|
||||
Future<bool> _toggleTrackEntry(
|
||||
Track track, {
|
||||
required bool Function(String key) contains,
|
||||
required List<CollectionTrackEntry> Function(LibraryCollectionsState state)
|
||||
select,
|
||||
required LibraryCollectionsState Function(List<CollectionTrackEntry> list)
|
||||
withList,
|
||||
required Future<void> Function(String key) dbDelete,
|
||||
required Future<void> 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<LibraryCollectionsState> {
|
||||
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<bool> 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<bool> 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<bool> 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<bool> toggleFavoriteArtist({
|
||||
required String artistId,
|
||||
@@ -654,11 +659,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
? 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<LibraryCollectionsState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> removeFavoriteArtist(String artistKey) async {
|
||||
Future<void> _removeEntry<T>(
|
||||
String key, {
|
||||
required bool Function(String key) contains,
|
||||
required List<T> Function(LibraryCollectionsState state) select,
|
||||
required String Function(T entry) keyOf,
|
||||
required LibraryCollectionsState Function(List<T> list) withList,
|
||||
required Future<void> 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<void> removeFromWishlist(String trackKey) async {
|
||||
await _ensureLoaded();
|
||||
if (!state.containsWishlistKey(trackKey)) return;
|
||||
Future<void> 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<void> 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<void> 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<void> 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<String> createPlaylist(String name) async {
|
||||
await _ensureLoaded();
|
||||
|
||||
+176
-322
@@ -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 = <String>[
|
||||
'-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<bool> _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 = <String>[
|
||||
'-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<bool> _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<String> 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<String?> 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<String, String>? 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<String?> _convertToAlac({
|
||||
required String inputPath,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
int? targetBitDepth,
|
||||
int? targetSampleRate,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||
final arguments = <String>['-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<String?> _convertToFlac({
|
||||
static Future<String?> _convertToLossless({
|
||||
required String inputPath,
|
||||
required Map<String, String> 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 = <String>['-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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
@@ -72,26 +71,13 @@ class HistoryDatabase {
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('history.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _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<void> _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<void> _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<void> _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<void> _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<void> _createPathKeyTable(DatabaseExecutor db) =>
|
||||
sqlite.createPathKeyTable(db, 'history_path_keys');
|
||||
|
||||
Future<void> _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\-]+/',
|
||||
|
||||
@@ -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<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDb();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _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<void> _createDb(Database db, int version) async {
|
||||
@@ -412,67 +398,70 @@ class LibraryCollectionsDatabase {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> upsertWishlistEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
Future<void> _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<void> deleteWishlistEntry(String trackKey) async {
|
||||
Future<void> _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<void> upsertWishlistEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
required String addedAt,
|
||||
}) => _upsertEntry(
|
||||
_tableWishlist,
|
||||
'track',
|
||||
key: trackKey,
|
||||
json: trackJson,
|
||||
addedAt: addedAt,
|
||||
);
|
||||
|
||||
Future<void> deleteWishlistEntry(String trackKey) =>
|
||||
_deleteEntry(_tableWishlist, 'track', trackKey);
|
||||
|
||||
Future<void> 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<void> deleteLovedEntry(String trackKey) async {
|
||||
final db = await database;
|
||||
await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]);
|
||||
}
|
||||
Future<void> deleteLovedEntry(String trackKey) =>
|
||||
_deleteEntry(_tableLoved, 'track', trackKey);
|
||||
|
||||
Future<void> 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<void> deleteFavoriteArtistEntry(String artistKey) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_tableFavoriteArtists,
|
||||
where: 'artist_key = ?',
|
||||
whereArgs: [artistKey],
|
||||
);
|
||||
}
|
||||
Future<void> deleteFavoriteArtistEntry(String artistKey) =>
|
||||
_deleteEntry(_tableFavoriteArtists, 'artist', artistKey);
|
||||
|
||||
Future<void> 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<void> replaceAllFromBackup(Map<String, dynamic> collectionsJson) async {
|
||||
Future<void> replaceAllFromBackup(
|
||||
Map<String, dynamic> collectionsJson,
|
||||
) async {
|
||||
final nowIso = DateTime.now().toIso8601String();
|
||||
|
||||
List<Map<String, dynamic>> listOf(String key) {
|
||||
|
||||
@@ -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<Database> 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<Database> _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<void> _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<void> _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<void> _createPathKeyTable(DatabaseExecutor db) =>
|
||||
sqlite.createPathKeyTable(db, 'library_path_keys');
|
||||
|
||||
Future<void> _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<void> _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<void> _createNormalizedIndexes(DatabaseExecutor db) async {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)',
|
||||
|
||||
@@ -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<void> 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<void> 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<void> 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<void> 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<void> 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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+118
-211
@@ -138,62 +138,66 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> checkAvailability(
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
) async {
|
||||
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
|
||||
if (cacheKey.isEmpty) {
|
||||
) {
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _invokeMap(
|
||||
String method, [
|
||||
dynamic args,
|
||||
]) async {
|
||||
final result = await _channel.invokeMethod(method, args);
|
||||
return _decodeRequiredMapResult(result, method);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _cachedInvoke(
|
||||
String cacheKey,
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
Map<String, Future<Map<String, dynamic>>> inFlight,
|
||||
Duration ttl,
|
||||
String persistentCacheKey,
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _invokeCachedMap(
|
||||
String key,
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _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<Map<String, dynamic>> downloadByStrategy({
|
||||
@@ -585,12 +587,8 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> 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<String> buildFilename(
|
||||
@@ -642,22 +640,20 @@ class PlatformBridge {
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> safStat(String uri) async {
|
||||
final result = await _channel.invokeMethod('safStat', {'uri': uri});
|
||||
return _decodeRequiredMapResult(result, 'safStat');
|
||||
static Future<Map<String, dynamic>> safStat(String uri) {
|
||||
return _invokeMap('safStat', {'uri': uri});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> 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<String?> 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<String> 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<Map<String, dynamic>> 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<void> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> getLyricsFetchOptions() async {
|
||||
final result = await _channel.invokeMethod('getLyricsFetchOptions');
|
||||
return _decodeRequiredMapResult(result, 'getLyricsFetchOptions');
|
||||
static Future<Map<String, dynamic>> getLyricsFetchOptions() {
|
||||
return _invokeMap('getLyricsFetchOptions');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> reEnrichFile(
|
||||
Map<String, dynamic> 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<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'readFileMetadata');
|
||||
static Future<Map<String, dynamic>> readFileMetadata(String filePath) {
|
||||
return _invokeMap('readFileMetadata', {'file_path': filePath});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> editFileMetadata(
|
||||
String filePath,
|
||||
Map<String, String> 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<Map<String, dynamic>> writeM4AFreeformTags(
|
||||
String filePath,
|
||||
Map<String, String> 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<Map<String, dynamic>> 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<String, String> 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<bool> 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<bool> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, String>?> getDeezerExtendedMetadata(
|
||||
@@ -1221,40 +1172,18 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> 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<List<Map<String, dynamic>>> getGoLogs() async {
|
||||
@@ -1295,12 +1224,9 @@ class PlatformBridge {
|
||||
|
||||
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> unloadExtension(String extensionId) async {
|
||||
@@ -1333,20 +1256,14 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
||||
@@ -1406,20 +1323,14 @@ class PlatformBridge {
|
||||
|
||||
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> setExtensionSettings(
|
||||
@@ -1928,10 +1839,9 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
static Future<Map<String, int>> getSafFileModTimes(List<String> 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<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? 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<Map<String, dynamic>> 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<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||
@@ -2265,12 +2173,11 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Database> openAppDatabase(
|
||||
String fileName, {
|
||||
required int version,
|
||||
required Future<void> Function(Database db, int version) onCreate,
|
||||
required Future<void> 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<void> 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<void> 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<void> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user